├── assets ├── card.jpg ├── device.cs.png ├── device.en.png ├── sensor.cs.png └── sensor.en.png ├── hacs.json ├── .github └── workflows │ └── hassfest.yaml ├── custom_components └── pid_departures │ ├── manifest.json │ ├── errors.py │ ├── system_health.py │ ├── entity.py │ ├── const.py │ ├── __init__.py │ ├── dep_board_api.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── calendar.py │ ├── sensor.py │ ├── translations │ ├── cs.json │ ├── en.json │ ├── sk.json │ └── de.json │ └── hub.py ├── card.yaml └── readme.md /assets/card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvejsada/PID_integration/HEAD/assets/card.jpg -------------------------------------------------------------------------------- /assets/device.cs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvejsada/PID_integration/HEAD/assets/device.cs.png -------------------------------------------------------------------------------- /assets/device.en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvejsada/PID_integration/HEAD/assets/device.en.png -------------------------------------------------------------------------------- /assets/sensor.cs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvejsada/PID_integration/HEAD/assets/sensor.cs.png -------------------------------------------------------------------------------- /assets/sensor.en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvejsada/PID_integration/HEAD/assets/sensor.en.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Prague Departure Boards", 3 | "filename": "pid_departures.zip", 4 | "hide_default_branch": true, 5 | "homeassistant": "2023.8.0", 6 | "render_readme": true, 7 | "zip_release": true 8 | } -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /custom_components/pid_departures/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "pid_departures", 3 | "name": "PID Departure Boards", 4 | "codeowners": ["@dvejsada"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/dvejsada/PID_integration/blob/master/readme.md", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/dvejsada/PID_integration/issues", 10 | "requirements": ["aiohttp", "attrs"], 11 | "version": "2.1.0" 12 | } 13 | -------------------------------------------------------------------------------- /card.yaml: -------------------------------------------------------------------------------- 1 | type: custom:flex-table-card 2 | title: [STOP_NAME] 3 | entities: 4 | include: [sensor.STOPNAME_PLATFORM_next_route_name_*] 5 | columns: 6 | - data: icon 7 | name: [] 8 | - data: departure_time_sched 9 | name: TIME 10 | modify: x.match(/[0-9]{2}:[0-9]{2}/); 11 | - data: state 12 | name: LINE 13 | - data: trip_headsign 14 | name: DESTINATION 15 | - data: is_delay_avail 16 | name: DELAY 17 | modify: |- 18 | if (x){ 19 | Math.floor(this.entity.attributes.delay_sec/60) + " min." 20 | } else { 21 | "-" 22 | }; -------------------------------------------------------------------------------- /custom_components/pid_departures/errors.py: -------------------------------------------------------------------------------- 1 | from homeassistant.exceptions import HomeAssistantError 2 | 3 | 4 | class CannotConnect(HomeAssistantError): 5 | """Error to indicate we cannot connect for unknown reason.""" 6 | 7 | 8 | class NoDeparturesSelected(HomeAssistantError): 9 | """Error to indicate wrong stop was provided.""" 10 | 11 | 12 | class StopNotFound(HomeAssistantError): 13 | """Error to indicate wrong stop was provided.""" 14 | 15 | 16 | class StopNotInList(HomeAssistantError): 17 | """Error to indicate stop not on the list was provided.""" 18 | 19 | 20 | class WrongApiKey(HomeAssistantError): 21 | """Error to indicate wrong API key was provided.""" 22 | -------------------------------------------------------------------------------- /custom_components/pid_departures/system_health.py: -------------------------------------------------------------------------------- 1 | """Provide info to system health.""" 2 | from homeassistant.components import system_health 3 | from homeassistant.core import HomeAssistant, callback 4 | 5 | @callback 6 | def async_register( 7 | hass: HomeAssistant, register: system_health.SystemHealthRegistration 8 | ) -> None: 9 | """Register system health callbacks.""" 10 | register.async_register_info(system_health_info) 11 | 12 | 13 | async def system_health_info(hass): 14 | """Get info for the info page.""" 15 | return { 16 | "api_endpoint_reachable": system_health.async_check_can_reach_url( 17 | hass, "https://api.golemio.cz/" 18 | ) 19 | } -------------------------------------------------------------------------------- /custom_components/pid_departures/entity.py: -------------------------------------------------------------------------------- 1 | from homeassistant.helpers.entity import Entity 2 | 3 | from .hub import DepartureBoard 4 | 5 | 6 | class BaseEntity(Entity): 7 | """Base class for entities in the PID Departures integration.""" 8 | 9 | # NOTE: Do not set _attr_entity_name, it breaks localization! 10 | _attr_has_entity_name = True 11 | 12 | def __init__(self, departure_board: DepartureBoard) -> None: 13 | super().__init__() 14 | 15 | if hasattr(self, "entity_description") and not self.translation_key: 16 | self._attr_translation_key = self.entity_description.key 17 | assert self.translation_key is not None, "translation_key is not set" 18 | 19 | self._departure_board = departure_board 20 | self._attr_device_info = departure_board.device_info 21 | self._attr_unique_id = f"{departure_board.board_id}_{self.translation_key}" 22 | -------------------------------------------------------------------------------- /custom_components/pid_departures/const.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defining constants for the project. 3 | """ 4 | from aiohttp import ClientTimeout 5 | from enum import StrEnum, auto 6 | from typing import Final 7 | 8 | 9 | class RouteType(StrEnum): 10 | UNKNOWN = auto() 11 | TRAM = auto() 12 | METRO = auto() 13 | TRAIN = auto() 14 | BUS = auto() 15 | FERRY = auto() 16 | FUNICULAR = auto() 17 | TROLLEYBUS = auto() 18 | 19 | 20 | API_URL = "https://api.golemio.cz/v2/pid/departureboards" 21 | HTTP_TIMEOUT: Final = ClientTimeout(total=10) 22 | 23 | ICON_STOP = "mdi:bus-stop-uncovered" 24 | ICON_WHEEL = "mdi:wheelchair" 25 | ICON_LAT = "mdi:latitude" 26 | ICON_LON = "mdi:longitude" 27 | ICON_PLATFORM = "mdi:bus-stop-covered" 28 | ICON_ZONE = "mdi:map-clock" 29 | ICON_INFO_ON = "mdi:alert-outline" 30 | ICON_INFO_OFF = "mdi:check-circle-outline" 31 | ICON_UPDATE = "mdi:update" 32 | DOMAIN = "pid_departures" 33 | CONF_CAL_EVENTS_NUM = "cal_events_number" 34 | CONF_DEP_NUM = "departures_number" 35 | CONF_STOP_SEL = "stop_selector" 36 | CONF_WALKING_OFFSET = "walking_offset" 37 | 38 | ROUTE_TYPE_ICON: Final = { 39 | RouteType.TRAM: "mdi:tram", 40 | RouteType.METRO: "mdi:train-variant", 41 | RouteType.TRAIN: "mdi:train", 42 | RouteType.BUS: "mdi:bus", 43 | RouteType.FERRY: "mdi:ferry", 44 | RouteType.FUNICULAR: "mdi:gondola", 45 | RouteType.TROLLEYBUS: "mdi:bus-electric", 46 | } 47 | 48 | CAL_EVENT_MIN_DURATION_SEC = 15 49 | -------------------------------------------------------------------------------- /custom_components/pid_departures/__init__.py: -------------------------------------------------------------------------------- 1 | """Prague Departure Board integration.""" 2 | from __future__ import annotations 3 | 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.const import CONF_API_KEY, CONF_ID 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.exceptions import ConfigEntryNotReady 8 | 9 | from .const import DOMAIN, CONF_DEP_NUM, CONF_WALKING_OFFSET 10 | from .errors import CannotConnect, StopNotFound, WrongApiKey 11 | from .hub import DepartureBoard 12 | 13 | PLATFORMS: list[str] = ["sensor", "binary_sensor", "calendar"] 14 | 15 | 16 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 17 | """Set up Departure Board from a config entry flow.""" 18 | walking_offset = entry.data.get(CONF_WALKING_OFFSET, 0) 19 | hub = DepartureBoard( 20 | hass, 21 | entry.data[CONF_API_KEY], 22 | entry.data[CONF_ID], 23 | entry.data[CONF_DEP_NUM], 24 | walking_offset 25 | ) # type: ignore[Any] 26 | try: 27 | await hub.async_update() 28 | except CannotConnect: 29 | # try again later again 30 | raise ConfigEntryNotReady from None 31 | except StopNotFound: 32 | return False 33 | except WrongApiKey: 34 | return False 35 | 36 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub # type: ignore[Any] 37 | 38 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 39 | return True 40 | 41 | 42 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 43 | """Unload a config entry.""" 44 | # This is called when an entry/configured device is to be removed. The class 45 | # needs to unload itself, and remove callbacks. See the classes for further 46 | # details 47 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 48 | if unload_ok: 49 | hass.data[DOMAIN].pop(entry.entry_id) # type: ignore[Any] 50 | 51 | return unload_ok 52 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PID Departure Board integration 2 | 3 | This custom component provides a departures board information for the selected stops of the Prague Integrated Transport [PID](http://www.pid.cz/). 4 | 5 | Multiple departure boards can be configured. 6 | 7 | | Device page | Sensor attributes | 8 | |:------------------------------------------------|:----------------------------------------------------:| 9 | | ![device page](assets/device.en.png "Device page") | ![sensor attributes](assets/sensor.en.png "Sensor attributes") | 10 | 11 | ## Installation 12 | 13 | ### Using [HACS](https://hacs.xyz/) 14 | 15 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=dvejsada&repository=PID_integration&category=Integration) 16 | 17 | ### Manual 18 | 19 | To install this integration manually you have to download pid_departures folder into your config/custom_components folder. 20 | 21 | ## Configuration 22 | 23 | ### Using UI 24 | 25 | From the Home Assistant front page go to **Configuration** and then select **Integrations** from the list. 26 | 27 | Use the "plus" button in the bottom right to add a new integration called **PID Departure boards**. 28 | 29 | Fill in: 30 | 31 | - API key (if you don't have the API key, you can obtain it here: https://api.golemio.cz/api-keys/auth/sign-up), 32 | - number of departures to be displayed, 33 | - choose a stop from the list, and 34 | - number of calendar events for departures to be created. 35 | 36 | It is only required to fill in API key once - for additional departure boards it should be prefilled in the config dialogue. 37 | 38 | The success dialog will appear or an error will be displayed in the popup. 39 | 40 | ## Dashboard 41 | 42 | The repo includes example card based on [Flex-table-card](https://github.com/custom-cards/flex-table-card) for display on dashboard. 43 | 44 | Just modify the headline and the departure entity name - number in the name shall be replaced by * to include all departures. 45 | 46 | ![card](assets/card.jpg "Card") -------------------------------------------------------------------------------- /custom_components/pid_departures/dep_board_api.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | import logging 3 | from typing import Any 4 | from urllib.parse import urlencode 5 | 6 | import aiohttp 7 | 8 | from .const import API_URL, HTTP_TIMEOUT 9 | from .errors import CannotConnect, StopNotFound, WrongApiKey 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class PIDDepartureBoardAPI: 15 | # According to docs https://api.golemio.cz/pid/docs/openapi. 16 | TIME_BEFORE_RANGE = (timedelta(minutes=-4320), timedelta(minutes=30)) 17 | TIME_AFTER_RANGE = (timedelta(minutes=-4320), timedelta(minutes=4320)) 18 | DEFAULT_TIME_BEFORE = timedelta(0) 19 | DEFAULT_TIME_AFTER = timedelta(minutes=4320) 20 | 21 | @staticmethod 22 | async def async_fetch_data( 23 | api_key: str, 24 | stop_id: str, 25 | limit: int = 1, 26 | time_before: timedelta = DEFAULT_TIME_BEFORE, 27 | time_after: timedelta = DEFAULT_TIME_AFTER, 28 | ) -> dict[str, Any]: 29 | """Get new data from API.""" 30 | headers = {"Content-Type": "application/json; charset=utf-8", "x-access-token": api_key} 31 | parameters = { 32 | "aswIds": stop_id, 33 | "limit": limit, 34 | "minutesBefore": int(time_before.total_seconds() / 60), 35 | "minutesAfter": int(time_after.total_seconds() / 60), 36 | } 37 | 38 | _LOGGER.debug(f"GET {API_URL}?{urlencode(parameters)}") 39 | async with ( 40 | aiohttp.ClientSession(raise_for_status=False, timeout=HTTP_TIMEOUT) as http, 41 | http.get(API_URL, params=parameters, headers=headers) as resp 42 | ): 43 | if _LOGGER.isEnabledFor(logging.DEBUG): 44 | body = await resp.text() 45 | _LOGGER.debug(f"Received response for GET {API_URL}: HTTP {resp.status}\n" + 46 | ellipsis(body, 1024)) 47 | if resp.status == 200: 48 | data: dict[str, Any] = await resp.json() 49 | return data 50 | elif resp.status == 401: 51 | raise WrongApiKey 52 | elif resp.status == 404: 53 | raise StopNotFound 54 | else: 55 | _LOGGER.error(f"GET {resp.url} returned HTTP {resp.status}") 56 | raise CannotConnect 57 | 58 | 59 | def ellipsis(text: str, maxlen: int) -> str: 60 | if len(text) > maxlen: 61 | return text[:(maxlen - 3)] + "..." 62 | else: 63 | return text 64 | -------------------------------------------------------------------------------- /custom_components/pid_departures/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for binary sensor.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Mapping 5 | from typing import Any 6 | 7 | from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import EntityCategory 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | 13 | from .const import ICON_INFO_ON, DOMAIN, ICON_INFO_OFF, ICON_WHEEL 14 | from .entity import BaseEntity 15 | from .hub import DepartureBoard 16 | 17 | async def async_setup_entry( 18 | hass: HomeAssistant, 19 | config_entry: ConfigEntry, 20 | async_add_entities: AddEntitiesCallback 21 | ) -> None: 22 | """Add sensors for passed config_entry in HA.""" 23 | departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any] 24 | async_add_entities([WheelchairSensor(departure_board), InfotextBinarySensor(departure_board)]) 25 | 26 | 27 | class InfotextBinarySensor(BaseEntity, BinarySensorEntity): 28 | """Sensor for info text.""" 29 | 30 | _attr_translation_key = "infotext" 31 | _attr_device_class = BinarySensorDeviceClass.PROBLEM 32 | _attr_entity_category = EntityCategory.DIAGNOSTIC 33 | _attr_should_poll = False 34 | 35 | @property 36 | def is_on(self) -> bool | None: 37 | return self._departure_board.info_text[0] 38 | 39 | @property 40 | def extra_state_attributes(self) -> Mapping[str, Any]: 41 | return self._departure_board.info_text[1] 42 | 43 | @property 44 | def icon(self) -> str: 45 | if self._attr_state: 46 | return ICON_INFO_ON 47 | else: 48 | return ICON_INFO_OFF 49 | 50 | async def async_added_to_hass(self) -> None: 51 | """Run when this Entity has been added to HA.""" 52 | # Sensors should also register callbacks to HA when their state changes 53 | self._departure_board.register_callback(self.async_write_ha_state) 54 | 55 | async def async_will_remove_from_hass(self) -> None: 56 | """Entity being removed from hass.""" 57 | # The opposite of async_added_to_hass. Remove any registered call backs here. 58 | self._departure_board.remove_callback(self.async_write_ha_state) 59 | 60 | 61 | class WheelchairSensor(BaseEntity, BinarySensorEntity): 62 | """Sensor for wheelchair accessibility of the station.""" 63 | 64 | _attr_translation_key = "wheelchair_accessible" 65 | _attr_icon = ICON_WHEEL 66 | _attr_entity_category = EntityCategory.DIAGNOSTIC 67 | _attr_should_poll = False 68 | 69 | @property 70 | def is_on(self) -> bool | None: 71 | if self._departure_board.wheelchair_accessible == 1: 72 | return True 73 | elif self._departure_board.wheelchair_accessible == 2: 74 | return False 75 | else: 76 | return None 77 | -------------------------------------------------------------------------------- /custom_components/pid_departures/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, cast 3 | from datetime import timedelta 4 | 5 | from homeassistant.const import CONF_API_KEY, CONF_ID 6 | from homeassistant import config_entries 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.selector import selector 9 | import voluptuous as vol 10 | 11 | from .const import CONF_CAL_EVENTS_NUM, CONF_DEP_NUM, CONF_STOP_SEL, CONF_WALKING_OFFSET, DOMAIN 12 | from .dep_board_api import PIDDepartureBoardAPI 13 | from .errors import CannotConnect, NoDeparturesSelected, StopNotFound, StopNotInList, WrongApiKey 14 | from .hub import DepartureBoard 15 | from .stop_list import STOP_LIST, ASW_IDS 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[dict[str, str], dict[str, Any]]: 21 | """Validate the user input allows us to connect. 22 | Data has the keys from DATA_SCHEMA with values provided by the user. 23 | """ 24 | try: 25 | data[CONF_ID] = ASW_IDS[STOP_LIST.index(data[CONF_STOP_SEL])] # type: ignore[Any] 26 | except Exception: 27 | raise StopNotInList 28 | 29 | # Get walking offset in minutes (user input) and convert to API format 30 | user_offset_minutes = data.get(CONF_WALKING_OFFSET, 0) 31 | # Convert user-friendly format to API format: 32 | # User: positive = future, negative = past 33 | # API: positive = past, negative = future 34 | # So we need to invert the sign 35 | api_offset_minutes = -user_offset_minutes 36 | walking_offset_timedelta = timedelta(minutes=api_offset_minutes) 37 | 38 | reply = await PIDDepartureBoardAPI.async_fetch_data( 39 | data[CONF_API_KEY], 40 | data[CONF_ID], 41 | data[CONF_DEP_NUM], 42 | time_before=walking_offset_timedelta 43 | ) # type: ignore[Any] 44 | 45 | title: str = reply["stops"][0]["stop_name"] + " " + (reply["stops"][0]["platform_code"] or "") 46 | if data[CONF_DEP_NUM] == 0: 47 | raise NoDeparturesSelected() 48 | else: 49 | return {"title": title}, data 50 | 51 | 52 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 53 | 54 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 55 | VERSION = 0.1 56 | 57 | async def async_step_user(self, user_input: dict[str, Any] | None = None) -> config_entries.FlowResult: 58 | # Check for any previous instance of the integration 59 | api_key: str | None = None 60 | if (boards := self.hass.data.get(DOMAIN, {})): # type: ignore[Any] 61 | board: DepartureBoard = next(iter(boards.values())) # type: ignore[Any] 62 | # If previous instance exists, use the API key as suggestion to new config 63 | api_key = board.api_key 64 | 65 | data_schema: dict[Any, Any] = { 66 | vol.Required(CONF_API_KEY, default=api_key): str, 67 | vol.Required(CONF_DEP_NUM, default=1): int, 68 | CONF_STOP_SEL: selector({ 69 | "select": { 70 | "options": STOP_LIST, 71 | "mode": "dropdown", 72 | "sort": True, 73 | "custom_value": True 74 | } 75 | }), 76 | vol.Optional(CONF_CAL_EVENTS_NUM, default=20): vol.All( 77 | vol.Coerce(int), 78 | vol.Range(0, 1000), 79 | ), 80 | vol.Optional(CONF_WALKING_OFFSET, default=0): vol.All( 81 | vol.Coerce(int), 82 | vol.Range(-30, 4320), 83 | ), 84 | } 85 | 86 | # Set dict for errors 87 | errors: dict[str, str] = {} 88 | 89 | # Steps to take if user input is received 90 | if user_input is not None: 91 | try: 92 | info, data = await validate_input(self.hass, user_input) 93 | return self.async_create_entry(title=info["title"], data=data) 94 | 95 | except CannotConnect: 96 | _LOGGER.exception("Cannot connect to API, check your internet connection.") 97 | errors["base"] = "cannot_connect" 98 | 99 | except WrongApiKey: 100 | _LOGGER.exception("Wrong or no API key provided, cannot authorize connection to API.") 101 | errors[CONF_API_KEY] = "wrong_api_key" 102 | 103 | except StopNotFound: 104 | _LOGGER.exception("Stop was not found by the API.") 105 | errors[CONF_STOP_SEL] = "stop_not_found" 106 | 107 | except StopNotInList: 108 | errors[CONF_STOP_SEL] = "stop_not_in_list" 109 | 110 | except NoDeparturesSelected: 111 | errors[CONF_DEP_NUM] = "no_departures_selected" 112 | 113 | except Exception: # pylint: disable=broad-except 114 | _LOGGER.exception("Unknown exception") 115 | errors["base"] = "Unknown exception" 116 | 117 | # If there is no user input or there were errors, show the form again, including any errors that were found with the input. 118 | return self.async_show_form( 119 | step_id="user", data_schema=vol.Schema(data_schema), errors=errors 120 | ) 121 | -------------------------------------------------------------------------------- /custom_components/pid_departures/calendar.py: -------------------------------------------------------------------------------- 1 | """Platform for calendar integration.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Mapping 5 | from datetime import datetime, timedelta 6 | import logging 7 | from typing import Any, cast 8 | from typing_extensions import override 9 | 10 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, STATE_ON 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.util import dt 16 | 17 | from .const import CAL_EVENT_MIN_DURATION_SEC, CONF_CAL_EVENTS_NUM, DOMAIN, ICON_STOP, ROUTE_TYPE_ICON, RouteType 18 | from .dep_board_api import PIDDepartureBoardAPI 19 | from .entity import BaseEntity 20 | from .hub import DepartureBoard, DepartureData 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | async def async_setup_entry( 26 | hass: HomeAssistant, 27 | config_entry: ConfigEntry, 28 | async_add_entities: AddEntitiesCallback, 29 | ) -> None: 30 | departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any] 31 | events_count: int = config_entry.data[CONF_CAL_EVENTS_NUM] # type: ignore[Any] 32 | async_add_entities([ 33 | DeparturesCalendarEntity(departure_board, events_count=events_count), 34 | ]) 35 | 36 | 37 | class DeparturesCalendarEntity(BaseEntity, CalendarEntity): 38 | 39 | _attr_should_poll = False 40 | _attr_translation_key = "departures" 41 | 42 | def __init__(self, departure_board: DepartureBoard, events_count: int) -> None: 43 | super().__init__(departure_board) 44 | self._events_count = events_count 45 | self._event: CalendarEvent | None = None 46 | 47 | @override 48 | async def async_added_to_hass(self): 49 | """Run when this Entity has been added to HA.""" 50 | # Sensors should also register callbacks to HA when their state changes 51 | self._departure_board.register_callback(self.async_write_ha_state) 52 | 53 | @override 54 | async def async_will_remove_from_hass(self): 55 | """Entity being removed from hass.""" 56 | # The opposite of async_added_to_hass. Remove any registered call backs here. 57 | self._departure_board.remove_callback(self.async_write_ha_state) 58 | 59 | @property 60 | @override 61 | def event(self) -> CalendarEvent | None: 62 | """Return the current or next upcoming event.""" 63 | return self._create_event(self._departure_board.departures[0]) 64 | 65 | @property 66 | @override 67 | def icon(self) -> str: 68 | """Return entity icon based on the type of route.""" 69 | if self.state == STATE_ON: 70 | route_type = self._departure_board.departures[0].route_type 71 | return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) 72 | else: 73 | return ICON_STOP 74 | 75 | @property 76 | @override 77 | def extra_state_attributes(self) -> Mapping[str, Any]: 78 | # NOTE: When CONF_LATITUDE and CONF_LONGITUDE is included, HASS shows 79 | # the entity on the map. 80 | return { 81 | **self._departure_board.departures[0].as_dict(), 82 | CONF_LATITUDE: self._departure_board.latitude, 83 | CONF_LONGITUDE: self._departure_board.longitude, 84 | } 85 | 86 | @override 87 | async def async_get_events( 88 | self, hass: HomeAssistant, start_date: datetime, end_date: datetime 89 | ) -> list[CalendarEvent]: 90 | if self._events_count == 0: 91 | return [] 92 | time_before = dt.now() - start_date 93 | time_after = end_date - dt.now() 94 | 95 | if (not timedelta_in_range(time_before, *PIDDepartureBoardAPI.TIME_BEFORE_RANGE) and 96 | not timedelta_in_range(time_after, *PIDDepartureBoardAPI.TIME_AFTER_RANGE)): 97 | _LOGGER.debug(f"async_get_events: start_date={start_date} end_date={end_date} is out of range") 98 | return [] 99 | 100 | data = await PIDDepartureBoardAPI.async_fetch_data( 101 | self._departure_board.api_key, 102 | self._departure_board.board_id, 103 | limit=self._events_count, 104 | time_before=timedelta_clamp(time_before, *PIDDepartureBoardAPI.TIME_BEFORE_RANGE), 105 | time_after=timedelta_clamp(time_after, *PIDDepartureBoardAPI.TIME_AFTER_RANGE)) 106 | 107 | events = ( 108 | self._create_event(DepartureData.from_api(dep)) 109 | for dep in cast(list[dict[str, Any]], data["departures"]) 110 | ) 111 | return [event for event in events if event] 112 | 113 | def _create_event(self, departure: DepartureData) -> CalendarEvent | None: 114 | start = departure.arrival_time_est 115 | end = departure.departure_time_est 116 | 117 | if not start and not end: 118 | _LOGGER.error('Invalid data, both "arrival_timestamp" and "departure_timestamp" is null') 119 | return None 120 | elif start: 121 | # departure_timestamp is null on last stops. 122 | if not end or (end - start).seconds < CAL_EVENT_MIN_DURATION_SEC: 123 | end = start + timedelta(seconds=CAL_EVENT_MIN_DURATION_SEC) 124 | elif end: 125 | # arrival_timestamp is null on first stops. 126 | start = end - timedelta(seconds=CAL_EVENT_MIN_DURATION_SEC) 127 | 128 | route_type = self._translate(f"state_attributes.route_type.state.{departure.route_type}") 129 | short_name = departure.route_name or "?" 130 | 131 | return CalendarEvent( 132 | start=start, 133 | end=end, 134 | summary=f"{route_type} {short_name}", 135 | location=self._departure_board.name, 136 | description=f"Trip to {departure.trip_headsign}", 137 | ) 138 | 139 | def _translate(self, key_path: str) -> str: 140 | """Translate the given key path.""" 141 | key = (f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + 142 | f".{self.translation_key}.{key_path}") 143 | 144 | if hasattr(self.platform, "platform_data"): 145 | return self.platform.platform_data.platform_translations.get(key, key_path) 146 | else: 147 | return self.platform.platform_translations.get(key, key_path) 148 | 149 | 150 | def timedelta_clamp(delta: timedelta, min: timedelta, max: timedelta) -> timedelta: 151 | if delta < min: 152 | return min 153 | elif delta > max: 154 | return max 155 | else: 156 | return delta 157 | 158 | 159 | def timedelta_in_range(delta: timedelta, min: timedelta, max: timedelta) -> bool: 160 | return min <= delta <= max 161 | -------------------------------------------------------------------------------- /custom_components/pid_departures/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Mapping 5 | from datetime import timedelta, datetime 6 | from typing import Any 7 | from zoneinfo import ZoneInfo 8 | 9 | from homeassistant.helpers.entity import Entity 10 | from homeassistant.components.sensor import SensorEntity, SensorDeviceClass 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, EntityCategory 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | 16 | from .const import DOMAIN, ICON_STOP, ICON_LAT, ICON_LON, ICON_ZONE, ICON_PLATFORM, ICON_UPDATE, ROUTE_TYPE_ICON, RouteType 17 | from .entity import BaseEntity 18 | from .hub import DepartureBoard 19 | 20 | SCAN_INTERVAL = timedelta(seconds=60) 21 | 22 | 23 | async def async_setup_entry( 24 | hass: HomeAssistant, 25 | config_entry: ConfigEntry, 26 | async_add_entities: AddEntitiesCallback 27 | ) -> None: 28 | """Add sensors for passed config_entry in HA.""" 29 | departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any] 30 | new_entities: list[Entity] = [] 31 | 32 | # Set entities for departures 33 | for i in range(departure_board.conn_num): 34 | new_entities.append(RouteNameSensor(departure_board, i)) 35 | new_entities.append(DepartureTimeSensor(departure_board, i)) 36 | 37 | # Set diagnostic entities 38 | new_entities.append(StopSensor(departure_board)) 39 | new_entities.append(LatSensor(departure_board)) 40 | new_entities.append(LonSensor(departure_board)) 41 | new_entities.append(ZoneSensor(departure_board)) 42 | if departure_board.platform != "": 43 | new_entities.append(PlatformSensor(departure_board)) 44 | new_entities.append(UpdateSensor(departure_board)) 45 | 46 | # Add all entities to HA 47 | async_add_entities(new_entities) 48 | 49 | 50 | class RouteNameSensor(BaseEntity, SensorEntity): 51 | """Sensor for departure route name.""" 52 | 53 | _attr_translation_key = "route_name" 54 | _attr_should_poll = False 55 | 56 | def __init__(self, departure_board: DepartureBoard, departure_num: int) -> None: 57 | super().__init__(departure_board) 58 | self._departure = departure_num 59 | self._attr_unique_id = f"{departure_board.board_id}_{self.translation_key}_{departure_num + 1}" 60 | self._attr_translation_placeholders = {"num": str(departure_num + 1)} 61 | 62 | @property 63 | def native_value(self) -> str: 64 | """ Returns name of the route as state.""" 65 | return self._departure_board.departures[self._departure].route_name or "?" 66 | 67 | @property 68 | def extra_state_attributes(self) -> Mapping[str, Any]: 69 | """ Returns dictionary of additional state attributes""" 70 | # NOTE: When CONF_LATITUDE and CONF_LONGITUDE is included, HASS shows 71 | # the entity on the map. 72 | return { 73 | **self._departure_board.departures[self._departure].as_dict(), 74 | CONF_LATITUDE: self._departure_board.latitude, 75 | CONF_LONGITUDE: self._departure_board.longitude, 76 | } 77 | 78 | @property 79 | def icon(self) -> str: 80 | """Returns entity icon based on the type of route""" 81 | route_type = self._departure_board.departures[self._departure].route_type 82 | return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) 83 | 84 | async def async_added_to_hass(self) -> None: 85 | """Run when this Entity has been added to HA.""" 86 | # Sensors should also register callbacks to HA when their state changes 87 | self._departure_board.register_callback(self.async_write_ha_state) 88 | 89 | async def async_will_remove_from_hass(self) -> None: 90 | """Entity being removed from hass.""" 91 | # The opposite of async_added_to_hass. Remove any registered call backs here. 92 | self._departure_board.remove_callback(self.async_write_ha_state) 93 | 94 | 95 | class DepartureTimeSensor(BaseEntity, SensorEntity): 96 | """Sensor for the next departure time (estimated).""" 97 | 98 | _attr_translation_key = "departure_time" 99 | _attr_should_poll = False 100 | _attr_device_class = SensorDeviceClass.TIMESTAMP 101 | 102 | def __init__(self, departure_board: DepartureBoard, departure_num: int) -> None: 103 | super().__init__(departure_board) 104 | self._departure_num = departure_num 105 | self._attr_unique_id = f"{departure_board.board_id}_{self.translation_key}_{departure_num + 1}" 106 | self._attr_translation_placeholders = {"num": str(departure_num + 1)} 107 | 108 | @property 109 | def native_value(self) -> datetime | None: 110 | return self._departure_board.departures[self._departure_num].departure_time_est 111 | 112 | @property 113 | def icon(self) -> str: 114 | """Returns entity icon based on the type of route""" 115 | route_type = self._departure_board.departures[self._departure_num].route_type 116 | return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) 117 | 118 | async def async_added_to_hass(self): 119 | """Run when this Entity has been added to HA.""" 120 | # Sensors should also register callbacks to HA when their state changes 121 | self._departure_board.register_callback(self.async_write_ha_state) 122 | 123 | async def async_will_remove_from_hass(self): 124 | """Entity being removed from hass.""" 125 | # The opposite of async_added_to_hass. Remove any registered call backs here. 126 | self._departure_board.remove_callback(self.async_write_ha_state) 127 | 128 | 129 | class StopSensor(BaseEntity, SensorEntity): 130 | """Sensor for stop name.""" 131 | 132 | _attr_translation_key = "stop_name" 133 | _attr_icon = ICON_STOP 134 | _attr_entity_category = EntityCategory.DIAGNOSTIC 135 | _attr_should_poll = False 136 | 137 | @property 138 | def native_value(self) -> str: 139 | return self._departure_board.stop_name 140 | 141 | 142 | class LatSensor(BaseEntity, SensorEntity): 143 | """Sensor for latitude of the stop.""" 144 | 145 | _attr_translation_key = "latitude" 146 | _attr_icon = ICON_LAT 147 | _attr_entity_category = EntityCategory.DIAGNOSTIC 148 | _attr_should_poll = False 149 | 150 | @property 151 | def native_value(self) -> float: 152 | return self._departure_board.latitude 153 | 154 | 155 | class LonSensor(BaseEntity, SensorEntity): 156 | """Sensor for longitude of the stop.""" 157 | 158 | _attr_translation_key = "longitude" 159 | _attr_icon = ICON_LON 160 | _attr_entity_category = EntityCategory.DIAGNOSTIC 161 | _attr_should_poll = False 162 | 163 | @property 164 | def native_value(self) -> float: 165 | return self._departure_board.longitude 166 | 167 | 168 | class ZoneSensor(BaseEntity, SensorEntity): 169 | """Sensor for zone.""" 170 | 171 | _attr_translation_key = "zone" 172 | _attr_icon = ICON_ZONE 173 | _attr_entity_category = EntityCategory.DIAGNOSTIC 174 | _attr_should_poll = False 175 | 176 | @property 177 | def native_value(self) -> str: 178 | return self._departure_board.zone 179 | 180 | 181 | class PlatformSensor(BaseEntity, SensorEntity): 182 | """Sensor for platform.""" 183 | 184 | _attr_translation_key = "platform" 185 | _attr_icon = ICON_PLATFORM 186 | _attr_entity_category = EntityCategory.DIAGNOSTIC 187 | _attr_should_poll = False 188 | 189 | @property 190 | def native_value(self) -> str: 191 | return self._departure_board.platform 192 | 193 | 194 | class UpdateSensor(BaseEntity, SensorEntity): 195 | """Sensor for API update.""" 196 | 197 | _attr_translation_key = "updated" 198 | _attr_entity_category = EntityCategory.DIAGNOSTIC 199 | _attr_icon = ICON_UPDATE 200 | _attr_device_class = SensorDeviceClass.TIMESTAMP 201 | 202 | def __init__(self, departure_board: DepartureBoard) -> None: 203 | super().__init__(departure_board) 204 | self._attr_native_value = datetime.now(tz=ZoneInfo("Europe/Prague")) 205 | 206 | async def async_update(self) -> None: 207 | """ Calls regular update of data from API. """ 208 | await self._departure_board.async_update() 209 | self._attr_native_value = datetime.now(tz=ZoneInfo("Europe/Prague")) 210 | -------------------------------------------------------------------------------- /custom_components/pid_departures/translations/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Přidej odjezdovou tabuli", 6 | "description": "Vyplň konfigurační data. Více info v dokumentaci", 7 | "data": { 8 | "stop_selector": "Vyber zastávku", 9 | "api_key": "Vlož API klíč", 10 | "departures_number": "Vyber počet odjezdů k zobrazení", 11 | "cal_events_count": "Počet kalendářních událostí odjezdů", 12 | "walking_offset": "Časový posun pro chůzi (minuty)" 13 | }, 14 | "data_description": { 15 | "stop_selector": "Začni psát k hledání", 16 | "api_key": "API klíč pro Golemio API", 17 | "walking_offset": "Posun pro kompenzaci vzdálenosti chůze k zastávce (kladné = zobrazí budoucí odjezdy, záporné = zobrazí minulé odjezdy)" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "cannot_connect": "Neznámá chyba připojení k API", 23 | "stop_not_in_list": "Zastávka nenalezena v seznamu - vyber zastávku ze seznamu.", 24 | "stop_not_found": "Zastávka s daným aswIds nenalezena.", 25 | "no_departures_selected": "Počet odjezdů nemůže být 0.", 26 | "wrong_api_key": "Připojení nebylo autorizováno, poskytnut chybný API klíč." 27 | } 28 | }, 29 | "entity": { 30 | "binary_sensor": { 31 | "infotext": { 32 | "name": "Infotext" 33 | }, 34 | "wheelchair_accessible": { 35 | "name": "Bezbariérový spoj", 36 | "state": { 37 | "on": "Ano", 38 | "off": "Ne" 39 | } 40 | } 41 | }, 42 | "calendar": { 43 | "departures": { 44 | "name": "Vozidlo na zastávce", 45 | "state": { 46 | "on": "Ano", 47 | "off": "Ne" 48 | }, 49 | "state_attributes": { 50 | "arrival_time_est": { 51 | "name": "Čas příjezdu (vč. zpoždění)" 52 | }, 53 | "arrival_time_sched": { 54 | "name": "Čas příjezdu (plánovaný)" 55 | }, 56 | "departure_time_est": { 57 | "name": "Čas odjezdu (vč. zpoždění)" 58 | }, 59 | "departure_time_sched": { 60 | "name": "Čas odjezdu (plánovaný)" 61 | }, 62 | "departure_in_min": { 63 | "name": "Čas odjezdu (v minutách)" 64 | }, 65 | "is_delay_avail": { 66 | "name": "Zpoždění dostupné" 67 | }, 68 | "delay_min": { 69 | "name": "Zpoždění (minuty)" 70 | }, 71 | "delay_sec": { 72 | "name": "Zpoždění (sekundy)" 73 | }, 74 | "route_name": { 75 | "name": "Číslo linky" 76 | }, 77 | "route_type": { 78 | "name": "Typ linky", 79 | "state": { 80 | "tram": "Tram", 81 | "metro": "Metro", 82 | "train": "Vlak", 83 | "bus": "Bus", 84 | "ferry": "Trajekt", 85 | "funicular": "Lanovka", 86 | "trolleybus": "Trolejbus" 87 | } 88 | }, 89 | "train_number": { 90 | "name": "Číslo vlaku" 91 | }, 92 | "trip_id": { 93 | "name": "GTFS ID trasy" 94 | }, 95 | "trip_direction": { 96 | "name": "Směr další jízdy" 97 | }, 98 | "trip_headsign": { 99 | "name": "Směr spoje (cílová zastávka)" 100 | }, 101 | "is_air_conditioned": { 102 | "name": "Vozidlo má klimatizaci" 103 | }, 104 | "is_at_stop": { 105 | "name": "Vozidlo je fyzicky v zastávce" 106 | }, 107 | "is_canceled": { 108 | "name": "Spoj je zrušený" 109 | }, 110 | "is_night": { 111 | "name": "Noční linka" 112 | }, 113 | "is_regional": { 114 | "name": "Příměstská nebo regionální linka" 115 | }, 116 | "is_substitute": { 117 | "name": "Náhradní linka" 118 | }, 119 | "is_wheelchair_accessible": { 120 | "name": "Vozidlo je bezbariérové" 121 | }, 122 | "last_stop_id": { 123 | "name": "GTFS ID poslední reportované zastávky" 124 | }, 125 | "last_stop_name": { 126 | "name": "Název poslední reportované zastávky" 127 | }, 128 | "stop_id": { 129 | "name": "GTFS ID zastávky" 130 | }, 131 | "stop_platform": { 132 | "name": "Kód stanoviště zastávky" 133 | }, 134 | "latitude": { 135 | "name": "Poloha zastávky (z.š.)" 136 | }, 137 | "longitude": { 138 | "name": "Poloha zastávky (z.d.)" 139 | } 140 | } 141 | } 142 | }, 143 | "sensor": { 144 | "departure_time": { 145 | "name": "Čas příštího odjezdu ({num})" 146 | }, 147 | "latitude": { 148 | "name": "Poloha zastávky (z.š.)" 149 | }, 150 | "longitude": { 151 | "name": "Poloha zastávky (z.d.)" 152 | }, 153 | "platform": { 154 | "name": "Kód stanoviště" 155 | }, 156 | "route_name": { 157 | "name": "Příští linka ({num})", 158 | "state_attributes": { 159 | "arrival_time_est": { 160 | "name": "Čas příjezdu (vč. zpoždění)" 161 | }, 162 | "arrival_time_sched": { 163 | "name": "Čas příjezdu (plánovaný)" 164 | }, 165 | "departure_time_est": { 166 | "name": "Čas odjezdu (vč. zpoždění)" 167 | }, 168 | "departure_time_sched": { 169 | "name": "Čas odjezdu (plánovaný)" 170 | }, 171 | "is_delay_avail": { 172 | "name": "Zpoždění dostupné" 173 | }, 174 | "delay_sec": { 175 | "name": "Zpoždění (sekundy)" 176 | }, 177 | "route_name": { 178 | "name": "Číslo linky" 179 | }, 180 | "route_type": { 181 | "name": "Typ linky", 182 | "state": { 183 | "tram": "Tram", 184 | "metro": "Metro", 185 | "train": "Vlak", 186 | "bus": "Bus", 187 | "ferry": "Trajekt", 188 | "funicular": "Lanovka", 189 | "trolleybus": "Trolejbus" 190 | } 191 | }, 192 | "train_number": { 193 | "name": "Číslo vlaku" 194 | }, 195 | "trip_id": { 196 | "name": "GTFS ID trasy" 197 | }, 198 | "trip_direction": { 199 | "name": "Směr další jízdy" 200 | }, 201 | "trip_headsign": { 202 | "name": "Směr spoje (cílová zastávka)" 203 | }, 204 | "is_air_conditioned": { 205 | "name": "Vozidlo má klimatizaci" 206 | }, 207 | "is_at_stop": { 208 | "name": "Vozidlo je fyzicky v zastávce" 209 | }, 210 | "is_canceled": { 211 | "name": "Spoj je zrušený" 212 | }, 213 | "is_night": { 214 | "name": "Noční linka" 215 | }, 216 | "is_regional": { 217 | "name": "Příměstská nebo regionální linka" 218 | }, 219 | "is_substitute": { 220 | "name": "Náhradní linka" 221 | }, 222 | "is_wheelchair_accessible": { 223 | "name": "Vozidlo je bezbariérové" 224 | }, 225 | "last_stop_id": { 226 | "name": "GTFS ID poslední reportované zastávky" 227 | }, 228 | "last_stop_name": { 229 | "name": "Název poslední reportované zastávky" 230 | }, 231 | "stop_id": { 232 | "name": "GTFS ID zastávky" 233 | }, 234 | "stop_platform": { 235 | "name": "Kód stanoviště zastávky" 236 | }, 237 | "latitude": { 238 | "name": "Poloha zastávky (z.š.)" 239 | }, 240 | "longitude": { 241 | "name": "Poloha zastávky (z.d.)" 242 | } 243 | } 244 | }, 245 | "stop_name": { 246 | "name": "Název zastávky" 247 | }, 248 | "updated": { 249 | "name": "Aktualizováno" 250 | }, 251 | "zone": { 252 | "name": "Zóna" 253 | } 254 | } 255 | }, 256 | "system_health": { 257 | "info": { 258 | "api_endpoint_reachable": "Stav API služby" 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /custom_components/pid_departures/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Add departure board", 6 | "description": "Provide configuration data. See documentation for further info.", 7 | "data": { 8 | "stop_selector": "Select the stop", 9 | "api_key": "Enter API key", 10 | "departures_number": "Number of departures to display", 11 | "cal_events_number": "Number of calendar events for departures to be created", 12 | "walking_offset": "Walking time offset (minutes)" 13 | }, 14 | "data_description": { 15 | "stop_selector": "Hint - type to search", 16 | "api_key": "API key for Golemio API", 17 | "walking_offset": "Offset to compensate for walking distance to stop (positive = show future departures, negative = show past departures)" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "cannot_connect": "Unknown API connection error", 23 | "stop_not_in_list": "Stop was not found in list - choose only stop in provided list.", 24 | "stop_not_found": "Stop with provided awsIDs was not found.", 25 | "no_departures_selected": "Number of departures cannot be 0.", 26 | "wrong_api_key": "Connection was not authorized. Wrong or no API key provided." 27 | } 28 | }, 29 | "entity": { 30 | "binary_sensor": { 31 | "infotext": { 32 | "name": "Infotext" 33 | }, 34 | "wheelchair_accessible": { 35 | "name": "Wheelchair accessible", 36 | "state": { 37 | "on": "Yes", 38 | "off": "No" 39 | } 40 | } 41 | }, 42 | "calendar": { 43 | "departures": { 44 | "name": "Vehicle at stop", 45 | "state": { 46 | "on": "Yes", 47 | "off": "No" 48 | }, 49 | "state_attributes": { 50 | "arrival_time_est": { 51 | "name": "Arrival time (incl. delay)" 52 | }, 53 | "arrival_time_sched": { 54 | "name": "Arrival time (scheduled)" 55 | }, 56 | "departure_time_est": { 57 | "name": "Departure time (incl. delay)" 58 | }, 59 | "departure_time_sched": { 60 | "name": "Departure time (scheduled)" 61 | }, 62 | "departure_in_min": { 63 | "name": "Departure time (in minutes)" 64 | }, 65 | "is_delay_avail": { 66 | "name": "Delay is available" 67 | }, 68 | "delay_min": { 69 | "name": "Delay (minutes)" 70 | }, 71 | "delay_sec": { 72 | "name": "Delay (seconds)" 73 | }, 74 | "route_name": { 75 | "name": "Route name" 76 | }, 77 | "route_type": { 78 | "name": "Route type", 79 | "state": { 80 | "tram": "Tram", 81 | "metro": "Metro", 82 | "train": "Train", 83 | "bus": "Bus", 84 | "ferry": "Ferry", 85 | "funicular": "Funicular", 86 | "trolleybus": "Trolleybus" 87 | } 88 | }, 89 | "train_number": { 90 | "name": "Train number" 91 | }, 92 | "trip_id": { 93 | "name": "GTFS trip ID" 94 | }, 95 | "trip_direction": { 96 | "name": "Next direction" 97 | }, 98 | "trip_headsign": { 99 | "name": "Trip headsign" 100 | }, 101 | "is_air_conditioned": { 102 | "name": "Vehicle has air conditioning" 103 | }, 104 | "is_at_stop": { 105 | "name": "Vehicle is physically at stop" 106 | }, 107 | "is_canceled": { 108 | "name": "Trip is cancelled" 109 | }, 110 | "is_night": { 111 | "name": "Night route" 112 | }, 113 | "is_regional": { 114 | "name": "Suburban or regional route" 115 | }, 116 | "is_substitute": { 117 | "name": "Substitute service" 118 | }, 119 | "is_wheelchair_accessible": { 120 | "name": "Vehicle is wheelchair accessible" 121 | }, 122 | "last_stop_id": { 123 | "name": "Last reported stop (GTFS ID)" 124 | }, 125 | "last_stop_name": { 126 | "name": "Last reported stop (name)" 127 | }, 128 | "stop_id": { 129 | "name": "Stop GTFS ID" 130 | }, 131 | "stop_platform": { 132 | "name": "Stop platform code" 133 | }, 134 | "latitude": { 135 | "name": "Stop location (latitude)" 136 | }, 137 | "longitude": { 138 | "name": "Stop location (longitude)" 139 | } 140 | } 141 | } 142 | }, 143 | "sensor": { 144 | "departure_time": { 145 | "name": "Next departure time ({num})" 146 | }, 147 | "latitude": { 148 | "name": "Stop location (latitude)" 149 | }, 150 | "longitude": { 151 | "name": "Stop location (longitude)" 152 | }, 153 | "platform": { 154 | "name": "Platform" 155 | }, 156 | "route_name": { 157 | "name": "Next route name ({num})", 158 | "state_attributes": { 159 | "arrival_time_est": { 160 | "name": "Arrival time (incl. delay)" 161 | }, 162 | "arrival_time_sched": { 163 | "name": "Arrival time (scheduled)" 164 | }, 165 | "departure_time_est": { 166 | "name": "Departure time (incl. delay)" 167 | }, 168 | "departure_time_sched": { 169 | "name": "Departure time (scheduled)" 170 | }, 171 | "is_delay_avail": { 172 | "name": "Delay is available" 173 | }, 174 | "delay_sec": { 175 | "name": "Delay (seconds)" 176 | }, 177 | "route_name": { 178 | "name": "Route name" 179 | }, 180 | "route_type": { 181 | "name": "Route type", 182 | "state": { 183 | "tram": "Tram", 184 | "metro": "Metro", 185 | "train": "Train", 186 | "bus": "Bus", 187 | "ferry": "Ferry", 188 | "funicular": "Funicular", 189 | "trolleybus": "Trolleybus" 190 | } 191 | }, 192 | "train_number": { 193 | "name": "Train number" 194 | }, 195 | "trip_id": { 196 | "name": "GTFS trip ID" 197 | }, 198 | "trip_direction": { 199 | "name": "Next direction" 200 | }, 201 | "trip_headsign": { 202 | "name": "Trip headsign" 203 | }, 204 | "is_air_conditioned": { 205 | "name": "Vehicle has air conditioning" 206 | }, 207 | "is_at_stop": { 208 | "name": "Vehicle is physically at stop" 209 | }, 210 | "is_canceled": { 211 | "name": "Trip is cancelled" 212 | }, 213 | "is_night": { 214 | "name": "Night route" 215 | }, 216 | "is_regional": { 217 | "name": "Suburban or regional route" 218 | }, 219 | "is_substitute": { 220 | "name": "Substitute service" 221 | }, 222 | "is_wheelchair_accessible": { 223 | "name": "Vehicle is wheelchair accessible" 224 | }, 225 | "last_stop_id": { 226 | "name": "Last reported stop (GTFS ID)" 227 | }, 228 | "last_stop_name": { 229 | "name": "Last reported stop (name)" 230 | }, 231 | "stop_id": { 232 | "name": "Stop GTFS ID" 233 | }, 234 | "stop_platform": { 235 | "name": "Stop platform code" 236 | }, 237 | "latitude": { 238 | "name": "Stop location (latitude)" 239 | }, 240 | "longitude": { 241 | "name": "Stop location (longitude)" 242 | } 243 | } 244 | }, 245 | "stop_name": { 246 | "name": "Stop name" 247 | }, 248 | "updated": { 249 | "name": "Updated" 250 | }, 251 | "zone": { 252 | "name": "Zone" 253 | } 254 | } 255 | }, 256 | "system_health": { 257 | "info": { 258 | "api_endpoint_reachable": "API service status" 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /custom_components/pid_departures/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Pridať odchodovú tabuľu", 6 | "description": "Zadajte konfiguračné údaje. Ďalšie informácie nájdete v dokumentácii.", 7 | "data": { 8 | "stop_selector": "Vyberte zastávku", 9 | "api_key": "Zadajte API kľúč", 10 | "departures_number": "Počet odchodov na zobrazenie", 11 | "cal_events_number": "Počet kalendárnych udalostí pre odchody, ktoré sa majú vytvoriť", 12 | "walking_offset": "Časový posun pre chôdzu (minúty)" 13 | }, 14 | "data_description": { 15 | "stop_selector": "Tip - píšte pre vyhľadávanie", 16 | "api_key": "API kľúč pre Golemio API", 17 | "walking_offset": "Posun na kompenzáciu vzdialenosti chôdze k zastávke (kladné = zobrazí budúce odchody, záporné = zobrazí minulé odchody)" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "cannot_connect": "Neznáma chyba API pripojenia", 23 | "stop_not_in_list": "Zastávka nebola nájdená v zozname - vyberte iba zastávku zo zobrazeného zoznamu.", 24 | "stop_not_found": "Zastávka s poskytnutými awsID nebola nájdená.", 25 | "no_departures_selected": "Počet odchodov nemôže byť 0.", 26 | "wrong_api_key": "Pripojenie nebolo autorizované. Bol zadaný nesprávny alebo žiadny API kľúč." 27 | } 28 | }, 29 | "entity": { 30 | "binary_sensor": { 31 | "infotext": { 32 | "name": "Informačný text" 33 | }, 34 | "wheelchair_accessible": { 35 | "name": "Prístupné pre invalidný vozík", 36 | "state": { 37 | "on": "Áno", 38 | "off": "Nie" 39 | } 40 | } 41 | }, 42 | "calendar": { 43 | "departures": { 44 | "name": "Vozidlo na zastávke", 45 | "state": { 46 | "on": "Áno", 47 | "off": "Nie" 48 | }, 49 | "state_attributes": { 50 | "arrival_time_est": { 51 | "name": "Čas príchodu (vrátane meškania)" 52 | }, 53 | "arrival_time_sched": { 54 | "name": "Čas príchodu (plánovaný)" 55 | }, 56 | "departure_time_est": { 57 | "name": "Čas odchodu (vrátane meškania)" 58 | }, 59 | "departure_time_sched": { 60 | "name": "Čas odchodu (plánovaný)" 61 | }, 62 | "departure_in_min": { 63 | "name": "Čas odchodu (v minútach)" 64 | }, 65 | "is_delay_avail": { 66 | "name": "Meškanie je dostupné" 67 | }, 68 | "delay_min": { 69 | "name": "Meškanie (minúty)" 70 | }, 71 | "delay_sec": { 72 | "name": "Meškanie (sekundy)" 73 | }, 74 | "route_name": { 75 | "name": "Názov trasy" 76 | }, 77 | "route_type": { 78 | "name": "Typ trasy", 79 | "state": { 80 | "tram": "Električka", 81 | "metro": "Metro", 82 | "train": "Vlak", 83 | "bus": "Autobus", 84 | "ferry": "Trajekt", 85 | "funicular": "Lanová dráha", 86 | "trolleybus": "Trolejbus" 87 | } 88 | }, 89 | "train_number": { 90 | "name": "Číslo vlaku" 91 | }, 92 | "trip_id": { 93 | "name": "GTFS ID cesty" 94 | }, 95 | "trip_direction": { 96 | "name": "Ďalší smer" 97 | }, 98 | "trip_headsign": { 99 | "name": "Cieľ cesty" 100 | }, 101 | "is_air_conditioned": { 102 | "name": "Vozidlo má klimatizáciu" 103 | }, 104 | "is_at_stop": { 105 | "name": "Vozidlo je fyzicky na zastávke" 106 | }, 107 | "is_canceled": { 108 | "name": "Cesta je zrušená" 109 | }, 110 | "is_night": { 111 | "name": "Nočná trasa" 112 | }, 113 | "is_regional": { 114 | "name": "Prímestská alebo regionálna trasa" 115 | }, 116 | "is_substitute": { 117 | "name": "Náhradná doprava" 118 | }, 119 | "is_wheelchair_accessible": { 120 | "name": "Vozidlo je prístupné pre invalidný vozík" 121 | }, 122 | "last_stop_id": { 123 | "name": "Posledná hlásená zastávka (GTFS ID)" 124 | }, 125 | "last_stop_name": { 126 | "name": "Posledná hlásená zastávka (názov)" 127 | }, 128 | "stop_id": { 129 | "name": "GTFS ID zastávky" 130 | }, 131 | "stop_platform": { 132 | "name": "Kód nástupiška zastávky" 133 | }, 134 | "latitude": { 135 | "name": "Poloha zastávky (zemepisná šírka)" 136 | }, 137 | "longitude": { 138 | "name": "Poloha zastávky (zemepisná dĺžka)" 139 | } 140 | } 141 | } 142 | }, 143 | "sensor": { 144 | "departure_time": { 145 | "name": "Čas ďalšieho odchodu ({num})" 146 | }, 147 | "latitude": { 148 | "name": "Poloha zastávky (zemepisná šírka)" 149 | }, 150 | "longitude": { 151 | "name": "Poloha zastávky (zemepisná dĺžka)" 152 | }, 153 | "platform": { 154 | "name": "Nástupisko" 155 | }, 156 | "route_name": { 157 | "name": "Názov ďalšej trasy ({num})", 158 | "state_attributes": { 159 | "arrival_time_est": { 160 | "name": "Čas príchodu (vrátane meškania)" 161 | }, 162 | "arrival_time_sched": { 163 | "name": "Čas príchodu (plánovaný)" 164 | }, 165 | "departure_time_est": { 166 | "name": "Čas odchodu (vrátane meškania)" 167 | }, 168 | "departure_time_sched": { 169 | "name": "Čas odchodu (plánovaný)" 170 | }, 171 | "is_delay_avail": { 172 | "name": "Meškanie je dostupné" 173 | }, 174 | "delay_sec": { 175 | "name": "Meškanie (sekundy)" 176 | }, 177 | "route_name": { 178 | "name": "Názov trasy" 179 | }, 180 | "route_type": { 181 | "name": "Typ trasy", 182 | "state": { 183 | "tram": "Električka", 184 | "metro": "Metro", 185 | "train": "Vlak", 186 | "bus": "Autobus", 187 | "ferry": "Trajekt", 188 | "funicular": "Lanová dráha", 189 | "trolleybus": "Trolejbus" 190 | } 191 | }, 192 | "train_number": { 193 | "name": "Číslo vlaku" 194 | }, 195 | "trip_id": { 196 | "name": "GTFS ID cesty" 197 | }, 198 | "trip_direction": { 199 | "name": "Ďalší smer" 200 | }, 201 | "trip_headsign": { 202 | "name": "Cieľ cesty" 203 | }, 204 | "is_air_conditioned": { 205 | "name": "Vozidlo má klimatizáciu" 206 | }, 207 | "is_at_stop": { 208 | "name": "Vozidlo je fyzicky na zastávke" 209 | }, 210 | "is_canceled": { 211 | "name": "Cesta je zrušená" 212 | }, 213 | "is_night": { 214 | "name": "Nočná trasa" 215 | }, 216 | "is_regional": { 217 | "name": "Prímestská alebo regionálna trasa" 218 | }, 219 | "is_substitute": { 220 | "name": "Náhradná doprava" 221 | }, 222 | "is_wheelchair_accessible": { 223 | "name": "Vozidlo je prístupné pre invalidný vozík" 224 | }, 225 | "last_stop_id": { 226 | "name": "Posledná hlásená zastávka (GTFS ID)" 227 | }, 228 | "last_stop_name": { 229 | "name": "Posledná hlásená zastávka (názov)" 230 | }, 231 | "stop_id": { 232 | "name": "GTFS ID zastávky" 233 | }, 234 | "stop_platform": { 235 | "name": "Kód nástupiška zastávky" 236 | }, 237 | "latitude": { 238 | "name": "Poloha zastávky (zemepisná šírka)" 239 | }, 240 | "longitude": { 241 | "name": "Poloha zastávky (zemepisná dĺžka)" 242 | } 243 | } 244 | }, 245 | "stop_name": { 246 | "name": "Názov zastávky" 247 | }, 248 | "updated": { 249 | "name": "Aktualizované" 250 | }, 251 | "zone": { 252 | "name": "Zóna" 253 | } 254 | } 255 | }, 256 | "system_health": { 257 | "info": { 258 | "api_endpoint_reachable": "Stav API služby" 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /custom_components/pid_departures/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Abfahrtstafel hinzufügen", 6 | "description": "Geben Sie Konfigurationsdaten ein. Weitere Informationen finden Sie in der Dokumentation.", 7 | "data": { 8 | "stop_selector": "Haltestelle auswählen", 9 | "api_key": "API-Schlüssel eingeben", 10 | "departures_number": "Anzahl der anzuzeigenden Abfahrten", 11 | "cal_events_number": "Anzahl der Kalendertermine für zu erstellende Abfahrten", 12 | "walking_offset": "Gehzeit-Versatz (Minuten)" 13 | }, 14 | "data_description": { 15 | "stop_selector": "Tipp - tippen Sie zum Suchen", 16 | "api_key": "API-Schlüssel für Golemio API", 17 | "walking_offset": "Versatz zur Kompensation der Gehstrecke zur Haltestelle (positiv = zukünftige Abfahrten anzeigen, negativ = vergangene Abfahrten anzeigen)" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "cannot_connect": "Unbekannter API-Verbindungsfehler", 23 | "stop_not_in_list": "Haltestelle wurde nicht in der Liste gefunden - wählen Sie nur eine Haltestelle aus der bereitgestellten Liste.", 24 | "stop_not_found": "Haltestelle mit den angegebenen awsIDs wurde nicht gefunden.", 25 | "no_departures_selected": "Anzahl der Abfahrten darf nicht 0 sein.", 26 | "wrong_api_key": "Verbindung wurde nicht autorisiert. Falscher oder kein API-Schlüssel angegeben." 27 | } 28 | }, 29 | "entity": { 30 | "binary_sensor": { 31 | "infotext": { 32 | "name": "Infotext" 33 | }, 34 | "wheelchair_accessible": { 35 | "name": "Rollstuhlgerecht", 36 | "state": { 37 | "on": "Ja", 38 | "off": "Nein" 39 | } 40 | } 41 | }, 42 | "calendar": { 43 | "departures": { 44 | "name": "Fahrzeug an Haltestelle", 45 | "state": { 46 | "on": "Ja", 47 | "off": "Nein" 48 | }, 49 | "state_attributes": { 50 | "arrival_time_est": { 51 | "name": "Ankunftszeit (inkl. Verspätung)" 52 | }, 53 | "arrival_time_sched": { 54 | "name": "Ankunftszeit (geplant)" 55 | }, 56 | "departure_time_est": { 57 | "name": "Abfahrtszeit (inkl. Verspätung)" 58 | }, 59 | "departure_time_sched": { 60 | "name": "Abfahrtszeit (geplant)" 61 | }, 62 | "departure_in_min": { 63 | "name": "Abfahrtszeit (in Minuten)" 64 | }, 65 | "is_delay_avail": { 66 | "name": "Verspätung verfügbar" 67 | }, 68 | "delay_min": { 69 | "name": "Verspätung (Minuten)" 70 | }, 71 | "delay_sec": { 72 | "name": "Verspätung (Sekunden)" 73 | }, 74 | "route_name": { 75 | "name": "Linienname" 76 | }, 77 | "route_type": { 78 | "name": "Linientyp", 79 | "state": { 80 | "tram": "Straßenbahn", 81 | "metro": "U-Bahn", 82 | "train": "Zug", 83 | "bus": "Bus", 84 | "ferry": "Fähre", 85 | "funicular": "Seilbahn", 86 | "trolleybus": "Oberleitungsbus" 87 | } 88 | }, 89 | "train_number": { 90 | "name": "Zugnummer" 91 | }, 92 | "trip_id": { 93 | "name": "GTFS Fahrt-ID" 94 | }, 95 | "trip_direction": { 96 | "name": "Nächste Richtung" 97 | }, 98 | "trip_headsign": { 99 | "name": "Fahrziel" 100 | }, 101 | "is_air_conditioned": { 102 | "name": "Fahrzeug hat Klimaanlage" 103 | }, 104 | "is_at_stop": { 105 | "name": "Fahrzeug ist physisch an der Haltestelle" 106 | }, 107 | "is_canceled": { 108 | "name": "Fahrt ist ausgefallen" 109 | }, 110 | "is_night": { 111 | "name": "Nachtlinie" 112 | }, 113 | "is_regional": { 114 | "name": "Vorort- oder Regionallinie" 115 | }, 116 | "is_substitute": { 117 | "name": "Ersatzverkehr" 118 | }, 119 | "is_wheelchair_accessible": { 120 | "name": "Fahrzeug ist rollstuhlgerecht" 121 | }, 122 | "last_stop_id": { 123 | "name": "Zuletzt gemeldete Haltestelle (GTFS ID)" 124 | }, 125 | "last_stop_name": { 126 | "name": "Zuletzt gemeldete Haltestelle (Name)" 127 | }, 128 | "stop_id": { 129 | "name": "Haltestellen GTFS ID" 130 | }, 131 | "stop_platform": { 132 | "name": "Haltestellenplattform-Code" 133 | }, 134 | "latitude": { 135 | "name": "Haltestellenposition (Breitengrad)" 136 | }, 137 | "longitude": { 138 | "name": "Haltestellenposition (Längengrad)" 139 | } 140 | } 141 | } 142 | }, 143 | "sensor": { 144 | "departure_time": { 145 | "name": "Nächste Abfahrtszeit ({num})" 146 | }, 147 | "latitude": { 148 | "name": "Haltestellenposition (Breitengrad)" 149 | }, 150 | "longitude": { 151 | "name": "Haltestellenposition (Längengrad)" 152 | }, 153 | "platform": { 154 | "name": "Plattform" 155 | }, 156 | "route_name": { 157 | "name": "Nächster Linienname ({num})", 158 | "state_attributes": { 159 | "arrival_time_est": { 160 | "name": "Ankunftszeit (inkl. Verspätung)" 161 | }, 162 | "arrival_time_sched": { 163 | "name": "Ankunftszeit (geplant)" 164 | }, 165 | "departure_time_est": { 166 | "name": "Abfahrtszeit (inkl. Verspätung)" 167 | }, 168 | "departure_time_sched": { 169 | "name": "Abfahrtszeit (geplant)" 170 | }, 171 | "is_delay_avail": { 172 | "name": "Verspätung verfügbar" 173 | }, 174 | "delay_sec": { 175 | "name": "Verspätung (Sekunden)" 176 | }, 177 | "route_name": { 178 | "name": "Linienname" 179 | }, 180 | "route_type": { 181 | "name": "Linientyp", 182 | "state": { 183 | "tram": "Straßenbahn", 184 | "metro": "U-Bahn", 185 | "train": "Zug", 186 | "bus": "Bus", 187 | "ferry": "Fähre", 188 | "funicular": "Seilbahn", 189 | "trolleybus": "Oberleitungsbus" 190 | } 191 | }, 192 | "train_number": { 193 | "name": "Zugnummer" 194 | }, 195 | "trip_id": { 196 | "name": "GTFS Fahrt-ID" 197 | }, 198 | "trip_direction": { 199 | "name": "Nächste Richtung" 200 | }, 201 | "trip_headsign": { 202 | "name": "Fahrziel" 203 | }, 204 | "is_air_conditioned": { 205 | "name": "Fahrzeug hat Klimaanlage" 206 | }, 207 | "is_at_stop": { 208 | "name": "Fahrzeug ist physisch an der Haltestelle" 209 | }, 210 | "is_canceled": { 211 | "name": "Fahrt ist ausgefallen" 212 | }, 213 | "is_night": { 214 | "name": "Nachtlinie" 215 | }, 216 | "is_regional": { 217 | "name": "Vorort- oder Regionallinie" 218 | }, 219 | "is_substitute": { 220 | "name": "Ersatzverkehr" 221 | }, 222 | "is_wheelchair_accessible": { 223 | "name": "Fahrzeug ist rollstuhlgerecht" 224 | }, 225 | "last_stop_id": { 226 | "name": "Zuletzt gemeldete Haltestelle (GTFS ID)" 227 | }, 228 | "last_stop_name": { 229 | "name": "Zuletzt gemeldete Haltestelle (Name)" 230 | }, 231 | "stop_id": { 232 | "name": "Haltestellen GTFS ID" 233 | }, 234 | "stop_platform": { 235 | "name": "Haltestellenplattform-Code" 236 | }, 237 | "latitude": { 238 | "name": "Haltestellenposition (Breitengrad)" 239 | }, 240 | "longitude": { 241 | "name": "Haltestellenposition (Längengrad)" 242 | } 243 | } 244 | }, 245 | "stop_name": { 246 | "name": "Haltestellenname" 247 | }, 248 | "updated": { 249 | "name": "Aktualisiert" 250 | }, 251 | "zone": { 252 | "name": "Zone" 253 | } 254 | } 255 | }, 256 | "system_health": { 257 | "info": { 258 | "api_endpoint_reachable": "API-Dienststatus" 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /custom_components/pid_departures/hub.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from attrs import asdict, converters, define, field, fields 4 | from collections.abc import Callable 5 | from datetime import datetime, timedelta 6 | from functools import reduce 7 | from typing import Any, cast 8 | 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity import DeviceInfo 11 | 12 | from .const import DOMAIN, RouteType 13 | from .dep_board_api import PIDDepartureBoardAPI 14 | 15 | 16 | # Based on PID Departure Board schema in https://api.golemio.cz/pid/docs/openapi/. 17 | ROUTE_TYPES_NUM = { 18 | 0: RouteType.TRAM, 19 | 1: RouteType.METRO, 20 | 2: RouteType.TRAIN, 21 | 3: RouteType.BUS, 22 | 4: RouteType.FERRY, 23 | 7: RouteType.FUNICULAR, 24 | 11: RouteType.TROLLEYBUS 25 | } 26 | 27 | def parse_route_type(num: int | None) -> RouteType: 28 | if num is None: 29 | return RouteType.UNKNOWN 30 | return ROUTE_TYPES_NUM.get(num) or RouteType.UNKNOWN 31 | 32 | parse_datetime = converters.optional(datetime.fromisoformat) 33 | 34 | 35 | # Based on PID Departure Board schema in https://api.golemio.cz/pid/docs/openapi/. 36 | @define(kw_only=True) 37 | class DepartureData: 38 | arrival_time_est: datetime | None = field(metadata={"src": "arrival_timestamp.predicted"}, converter=parse_datetime) 39 | arrival_time_sched: datetime | None = field(metadata={"src": "arrival_timestamp.scheduled"}, converter=parse_datetime) 40 | departure_time_est: datetime | None = field(metadata={"src": "departure_timestamp.predicted"}, converter=parse_datetime) 41 | departure_time_sched: datetime | None = field(metadata={"src": "departure_timestamp.scheduled"}, converter=parse_datetime) 42 | departure_in_min: str | None = field(metadata={"src": "departure_timestamp.minutes"}) 43 | is_delay_avail: bool = field(metadata={"src": "delay.is_available"}) 44 | delay_min: int | None = field(metadata={"src": "delay.minutes"}) 45 | delay_sec: int | None = field(metadata={"src": "delay.seconds"}) 46 | route_name: str | None = field(metadata={"src": "route.short_name"}) 47 | route_type: RouteType = field(metadata={"src": "route.type"}, converter=parse_route_type) 48 | train_number: str | None = field(metadata={"src": "trip.short_name"}) 49 | trip_id: str = field(metadata={"src": "trip.id"}) 50 | trip_direction: str | None = field(metadata={"src": "trip.direction"}) 51 | trip_headsign: str = field(metadata={"src": "trip.headsign"}) 52 | is_air_conditioned: bool = field(metadata={"src": "trip.is_air_conditioned"}) 53 | is_at_stop: bool = field(metadata={"src": "trip.is_at_stop"}) 54 | is_canceled: bool = field(metadata={"src": "trip.is_canceled"}) 55 | is_night: bool = field(metadata={"src": "route.is_night"}) 56 | is_regional: bool = field(metadata={"src": "route.is_regional"}) 57 | is_substitute: bool = field(metadata={"src": "route.is_substitute_transport"}) 58 | is_wheelchair_accessible: bool = field(metadata={"src": "trip.is_wheelchair_accessible"}) 59 | last_stop_id: str | None = field(metadata={"src": "last_stop.id"}) 60 | last_stop_name: str | None = field(metadata={"src": "last_stop.name"}) 61 | stop_id: str = field(metadata={"src": "stop.id"}) 62 | stop_platform: str | None = field(metadata={"src": "stop.platform_code"}) 63 | 64 | @staticmethod 65 | def from_api(data: dict[str, Any]) -> DepartureData: 66 | """Create a DepartureData from the PID Departure Board API response.""" 67 | attrs: dict[str, Any] = {} 68 | for f in fields(DepartureData): # type: ignore[Any] 69 | keypath: str = f.metadata["src"] # type: ignore[Any] 70 | attrs[f.name] = dig(data, keypath.split(".")) # type: ignore[Any] 71 | return DepartureData(**attrs) # type: ignore[Any] 72 | 73 | def as_dict(self) -> dict[str, Any]: 74 | """Return data as a dict.""" 75 | return asdict(self) 76 | 77 | 78 | class DepartureBoard: 79 | """Setting Departure board as device.""" 80 | 81 | def __init__(self, hass: HomeAssistant, api_key: str, stop_id: str, conn_num: int, walking_offset: int = 0) -> None: 82 | """Initialize departure board.""" 83 | super().__init__() 84 | self._hass = hass 85 | self._api_key: str = api_key 86 | self._stop_id: str = stop_id 87 | self.conn_num: int = int(conn_num) 88 | self.walking_offset: int = walking_offset # User input in minutes (positive = future) 89 | self.response: dict[str, Any] = {} 90 | self._departures: list[DepartureData] = [] 91 | self._callbacks: set[Callable[[], None]] = set() 92 | 93 | @property 94 | def board_id(self) -> str: 95 | """ID for departure board.""" 96 | return self._stop_id 97 | 98 | @property 99 | def device_info(self) -> DeviceInfo: 100 | """ Provides a device info. """ 101 | return {"identifiers": {(DOMAIN, self.board_id)}, "name": self.name, "manufacturer": "Prague Integrated Transport"} 102 | 103 | @property 104 | def name(self) -> str: 105 | """Provides name for departure board.""" 106 | return self.stop_name + " " + self.platform 107 | 108 | @property 109 | def stop_name(self) -> str: 110 | """ Provides name of the stop.""" 111 | return self.response["stops"][0]["stop_name"] # type: ignore[Any] 112 | 113 | @property 114 | def platform(self) -> str: 115 | """ Provides platform of the stop.""" 116 | if self.response["stops"][0]["platform_code"] is not None: 117 | value: str = self.response["stops"][0]["platform_code"] 118 | else: 119 | value = "" 120 | return value 121 | 122 | @property 123 | def departures(self) -> list[DepartureData]: 124 | """Return a list of fetched departures from this stop sorted from earliest to latest.""" 125 | return self._departures 126 | 127 | @property 128 | def latitude(self) -> float: 129 | """ Returns latitude of the stop.""" 130 | return self.response["stops"][0]["stop_lat"] # type: ignore[Any] 131 | 132 | @property 133 | def longitude(self) -> float: 134 | """Returns longitude of the stop.""" 135 | return self.response["stops"][0]["stop_lon"] # type: ignore[Any] 136 | 137 | @property 138 | def api_key(self) -> str: 139 | """ Returns API key.""" 140 | return self._api_key 141 | 142 | async def async_update(self) -> None: 143 | """ Updates the data from API.""" 144 | # Convert user-friendly walking offset to API format 145 | # User: positive = future, negative = past (intuitive) 146 | # API: positive = past, negative = future (counter-intuitive) 147 | # So we invert the sign and convert minutes to timedelta 148 | api_offset_minutes = -self.walking_offset 149 | walking_offset_timedelta = timedelta(minutes=api_offset_minutes) 150 | 151 | data = await PIDDepartureBoardAPI.async_fetch_data( 152 | self.api_key, 153 | self._stop_id, 154 | self.conn_num, 155 | time_before=walking_offset_timedelta 156 | ) 157 | self.response = data 158 | self._departures = [DepartureData.from_api(dep) 159 | for dep in cast(list[dict[str, Any]], data["departures"])] 160 | await self.publish_updates() 161 | 162 | def register_callback(self, callback: Callable[[], None]) -> None: 163 | """Register callback, called when there are new data.""" 164 | self._callbacks.add(callback) 165 | 166 | def remove_callback(self, callback: Callable[[], None]) -> None: 167 | """Remove previously registered callback.""" 168 | self._callbacks.discard(callback) 169 | 170 | async def publish_updates(self) -> None: 171 | """Schedule call to all registered callbacks.""" 172 | for callback in self._callbacks: 173 | callback() 174 | 175 | @property 176 | def wheelchair_accessible(self) -> int: 177 | """Returns wheelchair accessibility of the stop.""" 178 | return int(self.response["stops"][0]["wheelchair_boarding"]) # type: ignore[Any] 179 | 180 | @property 181 | def zone(self) -> str: 182 | """Zone of the stop""" 183 | return self.response["stops"][0]["zone_id"] # type: ignore[Any] 184 | 185 | @property 186 | def info_text(self) -> tuple[bool, dict[str, Any]]: 187 | """ State and content of info text""" 188 | if len(self.response["infotexts"]) != 0: # type: ignore[Any] 189 | state = True 190 | text: dict[str, Any] = self.response["infotexts"][0] 191 | else: 192 | state = False 193 | text = {} 194 | 195 | return state, text 196 | 197 | 198 | def dig(d: dict[str, Any], keypath: list[str]) -> Any: # type: ignore[Any] 199 | return reduce(dict.__getitem__, keypath, d) # type: ignore[reportUnknownArgumentType] 200 | --------------------------------------------------------------------------------