From 43fa7e25d12da79baaef97dc35da957c281c4f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:24:37 +0000 Subject: [PATCH 1/4] Modernize integration to current HA standards Refactor the integration to follow current Home Assistant architecture and config-flow standards. - Add DataUpdateCoordinator (coordinator.py) and drop the custom hub/callback pattern; all entities are now CoordinatorEntity, so a single centralized poll drives every entity and availability is handled automatically (previously the whole board stopped updating if the "updated" sensor was disabled). - Store the coordinator on entry.runtime_data with a typed PIDConfigEntry instead of hass.data[DOMAIN]; removes the untyped dict access across every platform. - Use Home Assistant's shared aiohttp session instead of opening a new ClientSession on every request; wrap client errors as CannotConnect. - manifest: drop aiohttp/attrs from requirements (both ship with core), add integration_type "device", bump version to 3.0.0. - Config flow: VERSION = 1 (was the invalid float 0.1), remove the deprecated/incorrect CONNECTION_CLASS, use ConfigFlowResult, set a unique_id per stop and abort on duplicates, and map the unknown-error case to a real translation key. - Add an options flow (departures, calendar events, walking offset) reading options with a fallback to data so existing entries keep working without migration, and reload on change. - Add a reauth flow: the coordinator raises ConfigEntryAuthFailed on an invalid key so users can enter a new one instead of re-adding. - Smaller fixes: dt.now() instead of ZoneInfo, fix the infotext icon to use is_on, type system_health_info, add strings.json, and extend all translations (en/cs/de/sk) with options/reauth/unknown keys (also fixes a cs.json cal_events key typo). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ww8phBs758zj4tCrato36c --- custom_components/pid_departures/__init__.py | 51 +--- .../pid_departures/binary_sensor.py | 37 +-- custom_components/pid_departures/calendar.py | 53 ++-- .../pid_departures/config_flow.py | 144 +++++++-- custom_components/pid_departures/const.py | 2 + .../pid_departures/coordinator.py | 196 ++++++++++++ .../pid_departures/dep_board_api.py | 42 +-- custom_components/pid_departures/entity.py | 15 +- custom_components/pid_departures/hub.py | 131 +------- .../pid_departures/manifest.json | 5 +- custom_components/pid_departures/sensor.py | 110 +++---- custom_components/pid_departures/strings.json | 289 ++++++++++++++++++ .../pid_departures/system_health.py | 7 +- .../pid_departures/translations/cs.json | 32 +- .../pid_departures/translations/de.json | 30 +- .../pid_departures/translations/en.json | 30 +- .../pid_departures/translations/sk.json | 30 +- readme.md | 6 + 18 files changed, 845 insertions(+), 365 deletions(-) create mode 100644 custom_components/pid_departures/coordinator.py create mode 100644 custom_components/pid_departures/strings.json diff --git a/custom_components/pid_departures/__init__.py b/custom_components/pid_departures/__init__.py index a5ae663..d95d399 100644 --- a/custom_components/pid_departures/__init__.py +++ b/custom_components/pid_departures/__init__.py @@ -1,51 +1,30 @@ """Prague Departure Board integration.""" from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, CONF_DEP_NUM, CONF_WALKING_OFFSET -from .errors import CannotConnect, StopNotFound, WrongApiKey -from .hub import DepartureBoard +from .coordinator import PIDConfigEntry, PIDDepartureUpdateCoordinator PLATFORMS: list[str] = ["sensor", "binary_sensor", "calendar"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Departure Board from a config entry flow.""" - walking_offset = entry.data.get(CONF_WALKING_OFFSET, 0) - hub = DepartureBoard( - hass, - entry.data[CONF_API_KEY], - entry.data[CONF_ID], - entry.data[CONF_DEP_NUM], - walking_offset - ) # type: ignore[Any] - try: - await hub.async_update() - except CannotConnect: - # try again later again - raise ConfigEntryNotReady from None - except StopNotFound: - return False - except WrongApiKey: - return False - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub # type: ignore[Any] +async def async_setup_entry(hass: HomeAssistant, entry: PIDConfigEntry) -> bool: + """Set up Departure Board from a config entry.""" + coordinator = PIDDepartureUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PIDConfigEntry) -> bool: """Unload a config entry.""" - # This is called when an entry/configured device is to be removed. The class - # needs to unload itself, and remove callbacks. See the classes for further - # details - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) # type: ignore[Any] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _async_update_listener(hass: HomeAssistant, entry: PIDConfigEntry) -> None: + """Reload the entry when its options are updated.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/pid_departures/binary_sensor.py b/custom_components/pid_departures/binary_sensor.py index b5059b0..16ba54e 100644 --- a/custom_components/pid_departures/binary_sensor.py +++ b/custom_components/pid_departures/binary_sensor.py @@ -5,23 +5,23 @@ from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ICON_INFO_ON, DOMAIN, ICON_INFO_OFF, ICON_WHEEL +from .const import ICON_INFO_ON, ICON_INFO_OFF, ICON_WHEEL +from .coordinator import PIDConfigEntry from .entity import BaseEntity -from .hub import DepartureBoard + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PIDConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add sensors for passed config_entry in HA.""" - departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any] - async_add_entities([WheelchairSensor(departure_board), InfotextBinarySensor(departure_board)]) + coordinator = config_entry.runtime_data + async_add_entities([WheelchairSensor(coordinator), InfotextBinarySensor(coordinator)]) class InfotextBinarySensor(BaseEntity, BinarySensorEntity): @@ -30,32 +30,20 @@ class InfotextBinarySensor(BaseEntity, BinarySensorEntity): _attr_translation_key = "infotext" _attr_device_class = BinarySensorDeviceClass.PROBLEM _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_should_poll = False @property def is_on(self) -> bool | None: - return self._departure_board.info_text[0] + return self.coordinator.info_text[0] @property def extra_state_attributes(self) -> Mapping[str, Any]: - return self._departure_board.info_text[1] + return self.coordinator.info_text[1] @property def icon(self) -> str: - if self._attr_state: + if self.is_on: return ICON_INFO_ON - else: - return ICON_INFO_OFF - - async def async_added_to_hass(self) -> None: - """Run when this Entity has been added to HA.""" - # Sensors should also register callbacks to HA when their state changes - self._departure_board.register_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Entity being removed from hass.""" - # The opposite of async_added_to_hass. Remove any registered call backs here. - self._departure_board.remove_callback(self.async_write_ha_state) + return ICON_INFO_OFF class WheelchairSensor(BaseEntity, BinarySensorEntity): @@ -64,13 +52,12 @@ class WheelchairSensor(BaseEntity, BinarySensorEntity): _attr_translation_key = "wheelchair_accessible" _attr_icon = ICON_WHEEL _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_should_poll = False @property def is_on(self) -> bool | None: - if self._departure_board.wheelchair_accessible == 1: + if self.coordinator.wheelchair_accessible == 1: return True - elif self._departure_board.wheelchair_accessible == 2: + elif self.coordinator.wheelchair_accessible == 2: return False else: return None diff --git a/custom_components/pid_departures/calendar.py b/custom_components/pid_departures/calendar.py index d900de7..2ea4bcb 100644 --- a/custom_components/pid_departures/calendar.py +++ b/custom_components/pid_departures/calendar.py @@ -4,70 +4,56 @@ from collections.abc import Mapping from datetime import datetime, timedelta import logging -from typing import Any, cast +from typing import Any from typing_extensions import override from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, STATE_ON from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt -from .const import CAL_EVENT_MIN_DURATION_SEC, CONF_CAL_EVENTS_NUM, DOMAIN, ICON_STOP, ROUTE_TYPE_ICON, RouteType +from .const import CAL_EVENT_MIN_DURATION_SEC, ICON_STOP, ROUTE_TYPE_ICON, RouteType +from .coordinator import PIDConfigEntry, PIDDepartureUpdateCoordinator from .dep_board_api import PIDDepartureBoardAPI from .entity import BaseEntity -from .hub import DepartureBoard, DepartureData +from .hub import DepartureData _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PIDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any] - events_count: int = config_entry.data[CONF_CAL_EVENTS_NUM] # type: ignore[Any] + coordinator = config_entry.runtime_data async_add_entities([ - DeparturesCalendarEntity(departure_board, events_count=events_count), + DeparturesCalendarEntity(coordinator, events_count=coordinator.cal_events_count), ]) class DeparturesCalendarEntity(BaseEntity, CalendarEntity): - _attr_should_poll = False _attr_translation_key = "departures" - def __init__(self, departure_board: DepartureBoard, events_count: int) -> None: - super().__init__(departure_board) + def __init__(self, coordinator: PIDDepartureUpdateCoordinator, events_count: int) -> None: + super().__init__(coordinator) self._events_count = events_count self._event: CalendarEvent | None = None - @override - async def async_added_to_hass(self): - """Run when this Entity has been added to HA.""" - # Sensors should also register callbacks to HA when their state changes - self._departure_board.register_callback(self.async_write_ha_state) - - @override - async def async_will_remove_from_hass(self): - """Entity being removed from hass.""" - # The opposite of async_added_to_hass. Remove any registered call backs here. - self._departure_board.remove_callback(self.async_write_ha_state) - @property @override def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" - return self._create_event(self._departure_board.departures[0]) + return self._create_event(self.coordinator.departures[0]) @property @override def icon(self) -> str: """Return entity icon based on the type of route.""" if self.state == STATE_ON: - route_type = self._departure_board.departures[0].route_type + route_type = self.coordinator.departures[0].route_type return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) else: return ICON_STOP @@ -78,9 +64,9 @@ def extra_state_attributes(self) -> Mapping[str, Any]: # NOTE: When CONF_LATITUDE and CONF_LONGITUDE is included, HASS shows # the entity on the map. return { - **self._departure_board.departures[0].as_dict(), - CONF_LATITUDE: self._departure_board.latitude, - CONF_LONGITUDE: self._departure_board.longitude, + **self.coordinator.departures[0].as_dict(), + CONF_LATITUDE: self.coordinator.latitude, + CONF_LONGITUDE: self.coordinator.longitude, } @override @@ -97,17 +83,12 @@ async def async_get_events( _LOGGER.debug(f"async_get_events: start_date={start_date} end_date={end_date} is out of range") return [] - data = await PIDDepartureBoardAPI.async_fetch_data( - self._departure_board.api_key, - self._departure_board.board_id, + departures = await self.coordinator.async_get_departures( limit=self._events_count, time_before=timedelta_clamp(time_before, *PIDDepartureBoardAPI.TIME_BEFORE_RANGE), time_after=timedelta_clamp(time_after, *PIDDepartureBoardAPI.TIME_AFTER_RANGE)) - events = ( - self._create_event(DepartureData.from_api(dep)) - for dep in cast(list[dict[str, Any]], data["departures"]) - ) + events = (self._create_event(dep) for dep in departures) return [event for event in events if event] def _create_event(self, departure: DepartureData) -> CalendarEvent | None: @@ -132,7 +113,7 @@ def _create_event(self, departure: DepartureData) -> CalendarEvent | None: start=start, end=end, summary=f"{route_type} {short_name}", - location=self._departure_board.name, + location=self.coordinator.name, description=f"Trip to {departure.trip_headsign}", ) diff --git a/custom_components/pid_departures/config_flow.py b/custom_components/pid_departures/config_flow.py index b5d9996..5dbdc24 100644 --- a/custom_components/pid_departures/config_flow.py +++ b/custom_components/pid_departures/config_flow.py @@ -1,17 +1,19 @@ import logging -from typing import Any, cast +from typing import Any, Mapping from datetime import timedelta from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant import config_entries -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import selector import voluptuous as vol from .const import CONF_CAL_EVENTS_NUM, CONF_DEP_NUM, CONF_STOP_SEL, CONF_WALKING_OFFSET, DOMAIN +from .coordinator import PIDConfigEntry from .dep_board_api import PIDDepartureBoardAPI from .errors import CannotConnect, NoDeparturesSelected, StopNotFound, StopNotInList, WrongApiKey -from .hub import DepartureBoard from .stop_list import STOP_LIST, ASW_IDS _LOGGER = logging.getLogger(__name__) @@ -22,45 +24,49 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[dic Data has the keys from DATA_SCHEMA with values provided by the user. """ try: - data[CONF_ID] = ASW_IDS[STOP_LIST.index(data[CONF_STOP_SEL])] # type: ignore[Any] + data[CONF_ID] = ASW_IDS[STOP_LIST.index(data[CONF_STOP_SEL])] except Exception: raise StopNotInList - # Get walking offset in minutes (user input) and convert to API format - user_offset_minutes = data.get(CONF_WALKING_OFFSET, 0) - # Convert user-friendly format to API format: + if data[CONF_DEP_NUM] == 0: + raise NoDeparturesSelected() + + # Convert user-friendly walking offset to API format: # User: positive = future, negative = past # API: positive = past, negative = future - # So we need to invert the sign - api_offset_minutes = -user_offset_minutes - walking_offset_timedelta = timedelta(minutes=api_offset_minutes) + # So we need to invert the sign. + walking_offset_timedelta = timedelta(minutes=-data.get(CONF_WALKING_OFFSET, 0)) reply = await PIDDepartureBoardAPI.async_fetch_data( + async_get_clientsession(hass), data[CONF_API_KEY], data[CONF_ID], data[CONF_DEP_NUM], - time_before=walking_offset_timedelta - ) # type: ignore[Any] + time_before=walking_offset_timedelta, + ) title: str = reply["stops"][0]["stop_name"] + " " + (reply["stops"][0]["platform_code"] or "") - if data[CONF_DEP_NUM] == 0: - raise NoDeparturesSelected() - else: - return {"title": title}, data + return {"title": title}, data + + +async def validate_api_key(hass: HomeAssistant, api_key: str, stop_id: str) -> None: + """Validate an API key against a known stop (used by the reauth flow).""" + await PIDDepartureBoardAPI.async_fetch_data( + async_get_clientsession(hass), api_key, stop_id, 1 + ) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - VERSION = 0.1 + VERSION = 1 - async def async_step_user(self, user_input: dict[str, Any] | None = None) -> config_entries.FlowResult: + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: # Check for any previous instance of the integration api_key: str | None = None - if (boards := self.hass.data.get(DOMAIN, {})): # type: ignore[Any] - board: DepartureBoard = next(iter(boards.values())) # type: ignore[Any] - # If previous instance exists, use the API key as suggestion to new config - api_key = board.api_key + entries = self.hass.config_entries.async_entries(DOMAIN) + if entries: + # If a previous instance exists, use its API key as suggestion for the new config. + api_key = entries[0].data.get(CONF_API_KEY) data_schema: dict[Any, Any] = { vol.Required(CONF_API_KEY, default=api_key): str, @@ -90,31 +96,107 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> con if user_input is not None: try: info, data = await validate_input(self.hass, user_input) - return self.async_create_entry(title=info["title"], data=data) - except CannotConnect: _LOGGER.exception("Cannot connect to API, check your internet connection.") errors["base"] = "cannot_connect" - except WrongApiKey: _LOGGER.exception("Wrong or no API key provided, cannot authorize connection to API.") errors[CONF_API_KEY] = "wrong_api_key" - except StopNotFound: _LOGGER.exception("Stop was not found by the API.") errors[CONF_STOP_SEL] = "stop_not_found" - except StopNotInList: errors[CONF_STOP_SEL] = "stop_not_in_list" - except NoDeparturesSelected: errors[CONF_DEP_NUM] = "no_departures_selected" - except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown exception") - errors["base"] = "Unknown exception" + errors["base"] = "unknown" + else: + await self.async_set_unique_id(data[CONF_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=data) # If there is no user input or there were errors, show the form again, including any errors that were found with the input. return self.async_show_form( step_id="user", data_schema=vol.Schema(data_schema), errors=errors ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult: + """Handle a re-authentication (expired/changed API key).""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication by validating a new API key.""" + reauth_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert reauth_entry is not None + errors: dict[str, str] = {} + + if user_input is not None: + try: + await validate_api_key( + self.hass, user_input[CONF_API_KEY], reauth_entry.data[CONF_ID] + ) + except WrongApiKey: + errors[CONF_API_KEY] = "wrong_api_key" + except StopNotFound: + errors["base"] = "stop_not_found" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data={**reauth_entry.data, CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: PIDConfigEntry, + ) -> "OptionsFlowHandler": + """Get the options flow for this handler.""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle changes to the tunable options of a departure board.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + entry = self.config_entry + + def current(key: str, default: Any) -> Any: + return entry.options.get(key, entry.data.get(key, default)) + + data_schema = vol.Schema({ + vol.Required(CONF_DEP_NUM, default=current(CONF_DEP_NUM, 1)): vol.All( + vol.Coerce(int), + vol.Range(min=1), + ), + vol.Optional(CONF_CAL_EVENTS_NUM, default=current(CONF_CAL_EVENTS_NUM, 20)): vol.All( + vol.Coerce(int), + vol.Range(0, 1000), + ), + vol.Optional(CONF_WALKING_OFFSET, default=current(CONF_WALKING_OFFSET, 0)): vol.All( + vol.Coerce(int), + vol.Range(-30, 4320), + ), + }) + + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/custom_components/pid_departures/const.py b/custom_components/pid_departures/const.py index e83b855..a24d59c 100644 --- a/custom_components/pid_departures/const.py +++ b/custom_components/pid_departures/const.py @@ -2,6 +2,7 @@ Defining constants for the project. """ from aiohttp import ClientTimeout +from datetime import timedelta from enum import StrEnum, auto from typing import Final @@ -19,6 +20,7 @@ class RouteType(StrEnum): API_URL = "https://api.golemio.cz/v2/pid/departureboards" HTTP_TIMEOUT: Final = ClientTimeout(total=10) +SCAN_INTERVAL: Final = timedelta(seconds=60) ICON_STOP = "mdi:bus-stop-uncovered" ICON_WHEEL = "mdi:wheelchair" diff --git a/custom_components/pid_departures/coordinator.py b/custom_components/pid_departures/coordinator.py new file mode 100644 index 0000000..0e67706 --- /dev/null +++ b/custom_components/pid_departures/coordinator.py @@ -0,0 +1,196 @@ +"""Data update coordinator for the PID Departure Board integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any, cast + +from attrs import define +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_CAL_EVENTS_NUM, CONF_DEP_NUM, CONF_WALKING_OFFSET, DOMAIN, SCAN_INTERVAL +from .dep_board_api import PIDDepartureBoardAPI +from .errors import CannotConnect, StopNotFound, WrongApiKey +from .hub import DepartureData + +_LOGGER = logging.getLogger(__name__) + +PIDConfigEntry = ConfigEntry["PIDDepartureUpdateCoordinator"] + + +@define +class BoardData: + """Parsed state of a single departure board.""" + + departures: list[DepartureData] + stop_name: str + platform: str + latitude: float + longitude: float + zone: str + wheelchair_boarding: int + infotexts: list[dict[str, Any]] + + @staticmethod + def from_response(response: dict[str, Any]) -> "BoardData": + """Create a BoardData from the PID Departure Board API response.""" + stop = response["stops"][0] + return BoardData( + departures=[ + DepartureData.from_api(dep) + for dep in cast(list[dict[str, Any]], response["departures"]) + ], + stop_name=stop["stop_name"], + platform=stop["platform_code"] or "", + latitude=stop["stop_lat"], + longitude=stop["stop_lon"], + zone=stop["zone_id"], + wheelchair_boarding=int(stop["wheelchair_boarding"]), + infotexts=response.get("infotexts", []), + ) + + +class PIDDepartureUpdateCoordinator(DataUpdateCoordinator[BoardData]): + """Coordinator polling the Golemio departure board API for a single stop.""" + + config_entry: PIDConfigEntry + + def __init__(self, hass: HomeAssistant, entry: PIDConfigEntry) -> None: + """Initialize the coordinator from a config entry.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._session = async_get_clientsession(hass) + self.api_key: str = entry.data[CONF_API_KEY] + self.stop_id: str = entry.data[CONF_ID] + # Tunables live in options with a fallback to data for legacy entries. + self.conn_num: int = int(self._option(CONF_DEP_NUM, 1)) + self.walking_offset: int = int(self._option(CONF_WALKING_OFFSET, 0)) + self.cal_events_count: int = int(self._option(CONF_CAL_EVENTS_NUM, 0)) + self.last_updated: datetime | None = None + + def _option(self, key: str, default: Any) -> Any: + """Read a tunable from options, falling back to data, then default.""" + entry = self.config_entry + return entry.options.get(key, entry.data.get(key, default)) + + @property + def _walking_offset_timedelta(self) -> timedelta: + """Convert the user-friendly walking offset to the API's time_before. + + User: positive = future, negative = past (intuitive). + API: positive = past, negative = future (counter-intuitive). + So the sign is inverted. + """ + return timedelta(minutes=-self.walking_offset) + + async def _async_update_data(self) -> BoardData: + """Fetch the latest departure board data from the API.""" + try: + data = await PIDDepartureBoardAPI.async_fetch_data( + self._session, + self.api_key, + self.stop_id, + self.conn_num, + time_before=self._walking_offset_timedelta, + ) + except WrongApiKey as err: + raise ConfigEntryAuthFailed("Invalid API key") from err + except StopNotFound as err: + raise UpdateFailed(f"Stop {self.stop_id} was not found by the API") from err + except CannotConnect as err: + raise UpdateFailed("Error communicating with the API") from err + + self.last_updated = dt_util.now() + return BoardData.from_response(data) + + async def async_get_departures( + self, limit: int, time_before: timedelta, time_after: timedelta + ) -> list[DepartureData]: + """Fetch a custom range of departures (used by the calendar platform).""" + data = await PIDDepartureBoardAPI.async_fetch_data( + self._session, + self.api_key, + self.stop_id, + limit, + time_before=time_before, + time_after=time_after, + ) + return [ + DepartureData.from_api(dep) + for dep in cast(list[dict[str, Any]], data["departures"]) + ] + + # Convenience accessors used by the entities. ------------------------- + + @property + def board_id(self) -> str: + """Stable ID for the departure board (the stop ASW ID).""" + return self.stop_id + + @property + def name(self) -> str: + """Display name for the departure board.""" + return (self.data.stop_name + " " + self.data.platform).strip() + + @property + def device_info(self) -> DeviceInfo: + """Provide device info for the departure board.""" + return DeviceInfo( + identifiers={(DOMAIN, self.board_id)}, + name=self.name, + manufacturer="Prague Integrated Transport", + ) + + @property + def departures(self) -> list[DepartureData]: + """Return the fetched departures, earliest first.""" + return self.data.departures + + @property + def stop_name(self) -> str: + """Name of the stop.""" + return self.data.stop_name + + @property + def platform(self) -> str: + """Platform code of the stop.""" + return self.data.platform + + @property + def latitude(self) -> float: + """Latitude of the stop.""" + return self.data.latitude + + @property + def longitude(self) -> float: + """Longitude of the stop.""" + return self.data.longitude + + @property + def zone(self) -> str: + """Fare zone of the stop.""" + return self.data.zone + + @property + def wheelchair_accessible(self) -> int: + """Wheelchair accessibility of the stop.""" + return self.data.wheelchair_boarding + + @property + def info_text(self) -> tuple[bool, dict[str, Any]]: + """State and content of the first info text.""" + if self.data.infotexts: + return True, self.data.infotexts[0] + return False, {} diff --git a/custom_components/pid_departures/dep_board_api.py b/custom_components/pid_departures/dep_board_api.py index 34171b9..d6230ab 100644 --- a/custom_components/pid_departures/dep_board_api.py +++ b/custom_components/pid_departures/dep_board_api.py @@ -20,13 +20,14 @@ class PIDDepartureBoardAPI: @staticmethod async def async_fetch_data( + session: aiohttp.ClientSession, api_key: str, stop_id: str, limit: int = 1, time_before: timedelta = DEFAULT_TIME_BEFORE, time_after: timedelta = DEFAULT_TIME_AFTER, ) -> dict[str, Any]: - """Get new data from API.""" + """Get new data from API using Home Assistant's shared client session.""" headers = {"Content-Type": "application/json; charset=utf-8", "x-access-token": api_key} parameters = { "aswIds": stop_id, @@ -36,24 +37,27 @@ async def async_fetch_data( } _LOGGER.debug(f"GET {API_URL}?{urlencode(parameters)}") - async with ( - aiohttp.ClientSession(raise_for_status=False, timeout=HTTP_TIMEOUT) as http, - http.get(API_URL, params=parameters, headers=headers) as resp - ): - if _LOGGER.isEnabledFor(logging.DEBUG): - body = await resp.text() - _LOGGER.debug(f"Received response for GET {API_URL}: HTTP {resp.status}\n" + - ellipsis(body, 1024)) - if resp.status == 200: - data: dict[str, Any] = await resp.json() - return data - elif resp.status == 401: - raise WrongApiKey - elif resp.status == 404: - raise StopNotFound - else: - _LOGGER.error(f"GET {resp.url} returned HTTP {resp.status}") - raise CannotConnect + try: + async with session.get( + API_URL, params=parameters, headers=headers, timeout=HTTP_TIMEOUT + ) as resp: + if _LOGGER.isEnabledFor(logging.DEBUG): + body = await resp.text() + _LOGGER.debug(f"Received response for GET {API_URL}: HTTP {resp.status}\n" + + ellipsis(body, 1024)) + if resp.status == 200: + data: dict[str, Any] = await resp.json() + return data + elif resp.status == 401: + raise WrongApiKey + elif resp.status == 404: + raise StopNotFound + else: + _LOGGER.error(f"GET {resp.url} returned HTTP {resp.status}") + raise CannotConnect + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.error(f"Error fetching data from {API_URL}: {err}") + raise CannotConnect from err def ellipsis(text: str, maxlen: int) -> str: diff --git a/custom_components/pid_departures/entity.py b/custom_components/pid_departures/entity.py index 8664cc9..67f04a1 100644 --- a/custom_components/pid_departures/entity.py +++ b/custom_components/pid_departures/entity.py @@ -1,21 +1,20 @@ -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .hub import DepartureBoard +from .coordinator import PIDDepartureUpdateCoordinator -class BaseEntity(Entity): +class BaseEntity(CoordinatorEntity[PIDDepartureUpdateCoordinator]): """Base class for entities in the PID Departures integration.""" # NOTE: Do not set _attr_entity_name, it breaks localization! _attr_has_entity_name = True - def __init__(self, departure_board: DepartureBoard) -> None: - super().__init__() + def __init__(self, coordinator: PIDDepartureUpdateCoordinator) -> None: + super().__init__(coordinator) if hasattr(self, "entity_description") and not self.translation_key: self._attr_translation_key = self.entity_description.key assert self.translation_key is not None, "translation_key is not set" - self._departure_board = departure_board - self._attr_device_info = departure_board.device_info - self._attr_unique_id = f"{departure_board.board_id}_{self.translation_key}" + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.board_id}_{self.translation_key}" diff --git a/custom_components/pid_departures/hub.py b/custom_components/pid_departures/hub.py index 7e696c6..2046467 100644 --- a/custom_components/pid_departures/hub.py +++ b/custom_components/pid_departures/hub.py @@ -1,16 +1,11 @@ from __future__ import annotations from attrs import asdict, converters, define, field, fields -from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import datetime from functools import reduce -from typing import Any, cast +from typing import Any -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo - -from .const import DOMAIN, RouteType -from .dep_board_api import PIDDepartureBoardAPI +from .const import RouteType # Based on PID Departure Board schema in https://api.golemio.cz/pid/docs/openapi/. @@ -75,125 +70,5 @@ def as_dict(self) -> dict[str, Any]: return asdict(self) -class DepartureBoard: - """Setting Departure board as device.""" - - def __init__(self, hass: HomeAssistant, api_key: str, stop_id: str, conn_num: int, walking_offset: int = 0) -> None: - """Initialize departure board.""" - super().__init__() - self._hass = hass - self._api_key: str = api_key - self._stop_id: str = stop_id - self.conn_num: int = int(conn_num) - self.walking_offset: int = walking_offset # User input in minutes (positive = future) - self.response: dict[str, Any] = {} - self._departures: list[DepartureData] = [] - self._callbacks: set[Callable[[], None]] = set() - - @property - def board_id(self) -> str: - """ID for departure board.""" - return self._stop_id - - @property - def device_info(self) -> DeviceInfo: - """ Provides a device info. """ - return {"identifiers": {(DOMAIN, self.board_id)}, "name": self.name, "manufacturer": "Prague Integrated Transport"} - - @property - def name(self) -> str: - """Provides name for departure board.""" - return self.stop_name + " " + self.platform - - @property - def stop_name(self) -> str: - """ Provides name of the stop.""" - return self.response["stops"][0]["stop_name"] # type: ignore[Any] - - @property - def platform(self) -> str: - """ Provides platform of the stop.""" - if self.response["stops"][0]["platform_code"] is not None: - value: str = self.response["stops"][0]["platform_code"] - else: - value = "" - return value - - @property - def departures(self) -> list[DepartureData]: - """Return a list of fetched departures from this stop sorted from earliest to latest.""" - return self._departures - - @property - def latitude(self) -> float: - """ Returns latitude of the stop.""" - return self.response["stops"][0]["stop_lat"] # type: ignore[Any] - - @property - def longitude(self) -> float: - """Returns longitude of the stop.""" - return self.response["stops"][0]["stop_lon"] # type: ignore[Any] - - @property - def api_key(self) -> str: - """ Returns API key.""" - return self._api_key - - async def async_update(self) -> None: - """ Updates the data from API.""" - # Convert user-friendly walking offset to API format - # User: positive = future, negative = past (intuitive) - # API: positive = past, negative = future (counter-intuitive) - # So we invert the sign and convert minutes to timedelta - api_offset_minutes = -self.walking_offset - walking_offset_timedelta = timedelta(minutes=api_offset_minutes) - - data = await PIDDepartureBoardAPI.async_fetch_data( - self.api_key, - self._stop_id, - self.conn_num, - time_before=walking_offset_timedelta - ) - self.response = data - self._departures = [DepartureData.from_api(dep) - for dep in cast(list[dict[str, Any]], data["departures"])] - await self.publish_updates() - - def register_callback(self, callback: Callable[[], None]) -> None: - """Register callback, called when there are new data.""" - self._callbacks.add(callback) - - def remove_callback(self, callback: Callable[[], None]) -> None: - """Remove previously registered callback.""" - self._callbacks.discard(callback) - - async def publish_updates(self) -> None: - """Schedule call to all registered callbacks.""" - for callback in self._callbacks: - callback() - - @property - def wheelchair_accessible(self) -> int: - """Returns wheelchair accessibility of the stop.""" - return int(self.response["stops"][0]["wheelchair_boarding"]) # type: ignore[Any] - - @property - def zone(self) -> str: - """Zone of the stop""" - return self.response["stops"][0]["zone_id"] # type: ignore[Any] - - @property - def info_text(self) -> tuple[bool, dict[str, Any]]: - """ State and content of info text""" - if len(self.response["infotexts"]) != 0: # type: ignore[Any] - state = True - text: dict[str, Any] = self.response["infotexts"][0] - else: - state = False - text = {} - - return state, text - - def dig(d: dict[str, Any], keypath: list[str]) -> Any: # type: ignore[Any] return reduce(dict.__getitem__, keypath, d) # type: ignore[reportUnknownArgumentType] diff --git a/custom_components/pid_departures/manifest.json b/custom_components/pid_departures/manifest.json index 96b33c9..4bcd10f 100644 --- a/custom_components/pid_departures/manifest.json +++ b/custom_components/pid_departures/manifest.json @@ -5,8 +5,9 @@ "config_flow": true, "dependencies": [], "documentation": "https://github.com/dvejsada/PID_integration/blob/master/readme.md", + "integration_type": "device", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/dvejsada/PID_integration/issues", - "requirements": ["aiohttp", "attrs"], - "version": "2.1.0" + "requirements": [], + "version": "3.0.0" } diff --git a/custom_components/pid_departures/sensor.py b/custom_components/pid_departures/sensor.py index bc9712b..c39fead 100644 --- a/custom_components/pid_departures/sensor.py +++ b/custom_components/pid_departures/sensor.py @@ -2,46 +2,42 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import timedelta, datetime +from datetime import datetime from typing import Any -from zoneinfo import ZoneInfo from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import SensorEntity, SensorDeviceClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ICON_STOP, ICON_LAT, ICON_LON, ICON_ZONE, ICON_PLATFORM, ICON_UPDATE, ROUTE_TYPE_ICON, RouteType +from .const import ICON_STOP, ICON_LAT, ICON_LON, ICON_ZONE, ICON_PLATFORM, ICON_UPDATE, ROUTE_TYPE_ICON, RouteType +from .coordinator import PIDConfigEntry, PIDDepartureUpdateCoordinator from .entity import BaseEntity -from .hub import DepartureBoard - -SCAN_INTERVAL = timedelta(seconds=60) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PIDConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add sensors for passed config_entry in HA.""" - departure_board: DepartureBoard = hass.data[DOMAIN][config_entry.entry_id] # type: ignore[Any] + coordinator = config_entry.runtime_data new_entities: list[Entity] = [] # Set entities for departures - for i in range(departure_board.conn_num): - new_entities.append(RouteNameSensor(departure_board, i)) - new_entities.append(DepartureTimeSensor(departure_board, i)) + for i in range(coordinator.conn_num): + new_entities.append(RouteNameSensor(coordinator, i)) + new_entities.append(DepartureTimeSensor(coordinator, i)) # Set diagnostic entities - new_entities.append(StopSensor(departure_board)) - new_entities.append(LatSensor(departure_board)) - new_entities.append(LonSensor(departure_board)) - new_entities.append(ZoneSensor(departure_board)) - if departure_board.platform != "": - new_entities.append(PlatformSensor(departure_board)) - new_entities.append(UpdateSensor(departure_board)) + new_entities.append(StopSensor(coordinator)) + new_entities.append(LatSensor(coordinator)) + new_entities.append(LonSensor(coordinator)) + new_entities.append(ZoneSensor(coordinator)) + if coordinator.platform != "": + new_entities.append(PlatformSensor(coordinator)) + new_entities.append(UpdateSensor(coordinator)) # Add all entities to HA async_add_entities(new_entities) @@ -51,18 +47,17 @@ class RouteNameSensor(BaseEntity, SensorEntity): """Sensor for departure route name.""" _attr_translation_key = "route_name" - _attr_should_poll = False - def __init__(self, departure_board: DepartureBoard, departure_num: int) -> None: - super().__init__(departure_board) + def __init__(self, coordinator: PIDDepartureUpdateCoordinator, departure_num: int) -> None: + super().__init__(coordinator) self._departure = departure_num - self._attr_unique_id = f"{departure_board.board_id}_{self.translation_key}_{departure_num + 1}" + self._attr_unique_id = f"{coordinator.board_id}_{self.translation_key}_{departure_num + 1}" self._attr_translation_placeholders = {"num": str(departure_num + 1)} @property def native_value(self) -> str: """ Returns name of the route as state.""" - return self._departure_board.departures[self._departure].route_name or "?" + return self.coordinator.departures[self._departure].route_name or "?" @property def extra_state_attributes(self) -> Mapping[str, Any]: @@ -70,61 +65,40 @@ def extra_state_attributes(self) -> Mapping[str, Any]: # NOTE: When CONF_LATITUDE and CONF_LONGITUDE is included, HASS shows # the entity on the map. return { - **self._departure_board.departures[self._departure].as_dict(), - CONF_LATITUDE: self._departure_board.latitude, - CONF_LONGITUDE: self._departure_board.longitude, + **self.coordinator.departures[self._departure].as_dict(), + CONF_LATITUDE: self.coordinator.latitude, + CONF_LONGITUDE: self.coordinator.longitude, } @property def icon(self) -> str: """Returns entity icon based on the type of route""" - route_type = self._departure_board.departures[self._departure].route_type + route_type = self.coordinator.departures[self._departure].route_type return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) - async def async_added_to_hass(self) -> None: - """Run when this Entity has been added to HA.""" - # Sensors should also register callbacks to HA when their state changes - self._departure_board.register_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Entity being removed from hass.""" - # The opposite of async_added_to_hass. Remove any registered call backs here. - self._departure_board.remove_callback(self.async_write_ha_state) - class DepartureTimeSensor(BaseEntity, SensorEntity): """Sensor for the next departure time (estimated).""" _attr_translation_key = "departure_time" - _attr_should_poll = False _attr_device_class = SensorDeviceClass.TIMESTAMP - def __init__(self, departure_board: DepartureBoard, departure_num: int) -> None: - super().__init__(departure_board) + def __init__(self, coordinator: PIDDepartureUpdateCoordinator, departure_num: int) -> None: + super().__init__(coordinator) self._departure_num = departure_num - self._attr_unique_id = f"{departure_board.board_id}_{self.translation_key}_{departure_num + 1}" + self._attr_unique_id = f"{coordinator.board_id}_{self.translation_key}_{departure_num + 1}" self._attr_translation_placeholders = {"num": str(departure_num + 1)} @property def native_value(self) -> datetime | None: - return self._departure_board.departures[self._departure_num].departure_time_est + return self.coordinator.departures[self._departure_num].departure_time_est @property def icon(self) -> str: """Returns entity icon based on the type of route""" - route_type = self._departure_board.departures[self._departure_num].route_type + route_type = self.coordinator.departures[self._departure_num].route_type return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) - async def async_added_to_hass(self): - """Run when this Entity has been added to HA.""" - # Sensors should also register callbacks to HA when their state changes - self._departure_board.register_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self): - """Entity being removed from hass.""" - # The opposite of async_added_to_hass. Remove any registered call backs here. - self._departure_board.remove_callback(self.async_write_ha_state) - class StopSensor(BaseEntity, SensorEntity): """Sensor for stop name.""" @@ -132,11 +106,10 @@ class StopSensor(BaseEntity, SensorEntity): _attr_translation_key = "stop_name" _attr_icon = ICON_STOP _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_should_poll = False @property def native_value(self) -> str: - return self._departure_board.stop_name + return self.coordinator.stop_name class LatSensor(BaseEntity, SensorEntity): @@ -145,11 +118,10 @@ class LatSensor(BaseEntity, SensorEntity): _attr_translation_key = "latitude" _attr_icon = ICON_LAT _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_should_poll = False @property def native_value(self) -> float: - return self._departure_board.latitude + return self.coordinator.latitude class LonSensor(BaseEntity, SensorEntity): @@ -158,11 +130,10 @@ class LonSensor(BaseEntity, SensorEntity): _attr_translation_key = "longitude" _attr_icon = ICON_LON _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_should_poll = False @property def native_value(self) -> float: - return self._departure_board.longitude + return self.coordinator.longitude class ZoneSensor(BaseEntity, SensorEntity): @@ -171,11 +142,10 @@ class ZoneSensor(BaseEntity, SensorEntity): _attr_translation_key = "zone" _attr_icon = ICON_ZONE _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_should_poll = False @property def native_value(self) -> str: - return self._departure_board.zone + return self.coordinator.zone class PlatformSensor(BaseEntity, SensorEntity): @@ -184,26 +154,20 @@ class PlatformSensor(BaseEntity, SensorEntity): _attr_translation_key = "platform" _attr_icon = ICON_PLATFORM _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_should_poll = False @property def native_value(self) -> str: - return self._departure_board.platform + return self.coordinator.platform class UpdateSensor(BaseEntity, SensorEntity): - """Sensor for API update.""" + """Sensor reporting the time of the last successful API update.""" _attr_translation_key = "updated" _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_icon = ICON_UPDATE _attr_device_class = SensorDeviceClass.TIMESTAMP - def __init__(self, departure_board: DepartureBoard) -> None: - super().__init__(departure_board) - self._attr_native_value = datetime.now(tz=ZoneInfo("Europe/Prague")) - - async def async_update(self) -> None: - """ Calls regular update of data from API. """ - await self._departure_board.async_update() - self._attr_native_value = datetime.now(tz=ZoneInfo("Europe/Prague")) + @property + def native_value(self) -> datetime | None: + return self.coordinator.last_updated diff --git a/custom_components/pid_departures/strings.json b/custom_components/pid_departures/strings.json new file mode 100644 index 0000000..e37c386 --- /dev/null +++ b/custom_components/pid_departures/strings.json @@ -0,0 +1,289 @@ +{ + "config": { + "step": { + "user": { + "title": "Add departure board", + "description": "Provide configuration data. See documentation for further info.", + "data": { + "stop_selector": "Select the stop", + "api_key": "Enter API key", + "departures_number": "Number of departures to display", + "cal_events_number": "Number of calendar events for departures to be created", + "walking_offset": "Walking time offset (minutes)" + }, + "data_description": { + "stop_selector": "Hint - type to search", + "api_key": "API key for Golemio API", + "walking_offset": "Offset to compensate for walking distance to stop (positive = show future departures, negative = show past departures)" + } + }, + "reauth_confirm": { + "title": "Update API key", + "description": "The API key for the Golemio API is no longer valid. Enter a new one.", + "data": { + "api_key": "Enter API key" + } + } + }, + "error": { + "cannot_connect": "Unknown API connection error", + "stop_not_in_list": "Stop was not found in list - choose only stop in provided list.", + "stop_not_found": "Stop with provided awsIDs was not found.", + "no_departures_selected": "Number of departures cannot be 0.", + "wrong_api_key": "Connection was not authorized. Wrong or no API key provided.", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This departure board is already configured.", + "reauth_successful": "Re-authentication was successful." + } + }, + "options": { + "step": { + "init": { + "title": "Departure board options", + "description": "Change display options for this departure board.", + "data": { + "departures_number": "Number of departures to display", + "cal_events_number": "Number of calendar events for departures to be created", + "walking_offset": "Walking time offset (minutes)" + }, + "data_description": { + "walking_offset": "Offset to compensate for walking distance to stop (positive = show future departures, negative = show past departures)" + } + } + } + }, + "entity": { + "binary_sensor": { + "infotext": { + "name": "Infotext" + }, + "wheelchair_accessible": { + "name": "Wheelchair accessible", + "state": { + "on": "Yes", + "off": "No" + } + } + }, + "calendar": { + "departures": { + "name": "Vehicle at stop", + "state": { + "on": "Yes", + "off": "No" + }, + "state_attributes": { + "arrival_time_est": { + "name": "Arrival time (incl. delay)" + }, + "arrival_time_sched": { + "name": "Arrival time (scheduled)" + }, + "departure_time_est": { + "name": "Departure time (incl. delay)" + }, + "departure_time_sched": { + "name": "Departure time (scheduled)" + }, + "departure_in_min": { + "name": "Departure time (in minutes)" + }, + "is_delay_avail": { + "name": "Delay is available" + }, + "delay_min": { + "name": "Delay (minutes)" + }, + "delay_sec": { + "name": "Delay (seconds)" + }, + "route_name": { + "name": "Route name" + }, + "route_type": { + "name": "Route type", + "state": { + "tram": "Tram", + "metro": "Metro", + "train": "Train", + "bus": "Bus", + "ferry": "Ferry", + "funicular": "Funicular", + "trolleybus": "Trolleybus" + } + }, + "train_number": { + "name": "Train number" + }, + "trip_id": { + "name": "GTFS trip ID" + }, + "trip_direction": { + "name": "Next direction" + }, + "trip_headsign": { + "name": "Trip headsign" + }, + "is_air_conditioned": { + "name": "Vehicle has air conditioning" + }, + "is_at_stop": { + "name": "Vehicle is physically at stop" + }, + "is_canceled": { + "name": "Trip is cancelled" + }, + "is_night": { + "name": "Night route" + }, + "is_regional": { + "name": "Suburban or regional route" + }, + "is_substitute": { + "name": "Substitute service" + }, + "is_wheelchair_accessible": { + "name": "Vehicle is wheelchair accessible" + }, + "last_stop_id": { + "name": "Last reported stop (GTFS ID)" + }, + "last_stop_name": { + "name": "Last reported stop (name)" + }, + "stop_id": { + "name": "Stop GTFS ID" + }, + "stop_platform": { + "name": "Stop platform code" + }, + "latitude": { + "name": "Stop location (latitude)" + }, + "longitude": { + "name": "Stop location (longitude)" + } + } + } + }, + "sensor": { + "departure_time": { + "name": "Next departure time ({num})" + }, + "latitude": { + "name": "Stop location (latitude)" + }, + "longitude": { + "name": "Stop location (longitude)" + }, + "platform": { + "name": "Platform" + }, + "route_name": { + "name": "Next route name ({num})", + "state_attributes": { + "arrival_time_est": { + "name": "Arrival time (incl. delay)" + }, + "arrival_time_sched": { + "name": "Arrival time (scheduled)" + }, + "departure_time_est": { + "name": "Departure time (incl. delay)" + }, + "departure_time_sched": { + "name": "Departure time (scheduled)" + }, + "is_delay_avail": { + "name": "Delay is available" + }, + "delay_sec": { + "name": "Delay (seconds)" + }, + "route_name": { + "name": "Route name" + }, + "route_type": { + "name": "Route type", + "state": { + "tram": "Tram", + "metro": "Metro", + "train": "Train", + "bus": "Bus", + "ferry": "Ferry", + "funicular": "Funicular", + "trolleybus": "Trolleybus" + } + }, + "train_number": { + "name": "Train number" + }, + "trip_id": { + "name": "GTFS trip ID" + }, + "trip_direction": { + "name": "Next direction" + }, + "trip_headsign": { + "name": "Trip headsign" + }, + "is_air_conditioned": { + "name": "Vehicle has air conditioning" + }, + "is_at_stop": { + "name": "Vehicle is physically at stop" + }, + "is_canceled": { + "name": "Trip is cancelled" + }, + "is_night": { + "name": "Night route" + }, + "is_regional": { + "name": "Suburban or regional route" + }, + "is_substitute": { + "name": "Substitute service" + }, + "is_wheelchair_accessible": { + "name": "Vehicle is wheelchair accessible" + }, + "last_stop_id": { + "name": "Last reported stop (GTFS ID)" + }, + "last_stop_name": { + "name": "Last reported stop (name)" + }, + "stop_id": { + "name": "Stop GTFS ID" + }, + "stop_platform": { + "name": "Stop platform code" + }, + "latitude": { + "name": "Stop location (latitude)" + }, + "longitude": { + "name": "Stop location (longitude)" + } + } + }, + "stop_name": { + "name": "Stop name" + }, + "updated": { + "name": "Updated" + }, + "zone": { + "name": "Zone" + } + } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "API service status" + } + } +} diff --git a/custom_components/pid_departures/system_health.py b/custom_components/pid_departures/system_health.py index 1977ed3..b42a106 100644 --- a/custom_components/pid_departures/system_health.py +++ b/custom_components/pid_departures/system_health.py @@ -1,7 +1,10 @@ """Provide info to system health.""" +from typing import Any + from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback + @callback def async_register( hass: HomeAssistant, register: system_health.SystemHealthRegistration @@ -10,10 +13,10 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" return { "api_endpoint_reachable": system_health.async_check_can_reach_url( hass, "https://api.golemio.cz/" ) - } \ No newline at end of file + } diff --git a/custom_components/pid_departures/translations/cs.json b/custom_components/pid_departures/translations/cs.json index d1e5dcf..a2c1113 100644 --- a/custom_components/pid_departures/translations/cs.json +++ b/custom_components/pid_departures/translations/cs.json @@ -8,7 +8,7 @@ "stop_selector": "Vyber zastávku", "api_key": "Vlož API klíč", "departures_number": "Vyber počet odjezdů k zobrazení", - "cal_events_count": "Počet kalendářních událostí odjezdů", + "cal_events_number": "Počet kalendářních událostí odjezdů", "walking_offset": "Časový posun pro chůzi (minuty)" }, "data_description": { @@ -16,6 +16,13 @@ "api_key": "API klíč pro Golemio API", "walking_offset": "Posun pro kompenzaci vzdálenosti chůze k zastávce (kladné = zobrazí budoucí odjezdy, záporné = zobrazí minulé odjezdy)" } + }, + "reauth_confirm": { + "title": "Aktualizace API klíče", + "description": "API klíč pro Golemio API již není platný. Zadej nový.", + "data": { + "api_key": "Vlož API klíč" + } } }, "error": { @@ -23,7 +30,28 @@ "stop_not_in_list": "Zastávka nenalezena v seznamu - vyber zastávku ze seznamu.", "stop_not_found": "Zastávka s daným aswIds nenalezena.", "no_departures_selected": "Počet odjezdů nemůže být 0.", - "wrong_api_key": "Připojení nebylo autorizováno, poskytnut chybný API klíč." + "wrong_api_key": "Připojení nebylo autorizováno, poskytnut chybný API klíč.", + "unknown": "Neočekávaná chyba" + }, + "abort": { + "already_configured": "Tato odjezdová tabule je již nakonfigurována.", + "reauth_successful": "Opětovné ověření proběhlo úspěšně." + } + }, + "options": { + "step": { + "init": { + "title": "Nastavení odjezdové tabule", + "description": "Změň nastavení zobrazení této odjezdové tabule.", + "data": { + "departures_number": "Vyber počet odjezdů k zobrazení", + "cal_events_number": "Počet kalendářních událostí odjezdů", + "walking_offset": "Časový posun pro chůzi (minuty)" + }, + "data_description": { + "walking_offset": "Posun pro kompenzaci vzdálenosti chůze k zastávce (kladné = zobrazí budoucí odjezdy, záporné = zobrazí minulé odjezdy)" + } + } } }, "entity": { diff --git a/custom_components/pid_departures/translations/de.json b/custom_components/pid_departures/translations/de.json index 460e39f..cbaa3b2 100644 --- a/custom_components/pid_departures/translations/de.json +++ b/custom_components/pid_departures/translations/de.json @@ -16,6 +16,13 @@ "api_key": "API-Schlüssel für Golemio API", "walking_offset": "Versatz zur Kompensation der Gehstrecke zur Haltestelle (positiv = zukünftige Abfahrten anzeigen, negativ = vergangene Abfahrten anzeigen)" } + }, + "reauth_confirm": { + "title": "API-Schlüssel aktualisieren", + "description": "Der API-Schlüssel für die Golemio API ist nicht mehr gültig. Geben Sie einen neuen ein.", + "data": { + "api_key": "API-Schlüssel eingeben" + } } }, "error": { @@ -23,7 +30,28 @@ "stop_not_in_list": "Haltestelle wurde nicht in der Liste gefunden - wählen Sie nur eine Haltestelle aus der bereitgestellten Liste.", "stop_not_found": "Haltestelle mit den angegebenen awsIDs wurde nicht gefunden.", "no_departures_selected": "Anzahl der Abfahrten darf nicht 0 sein.", - "wrong_api_key": "Verbindung wurde nicht autorisiert. Falscher oder kein API-Schlüssel angegeben." + "wrong_api_key": "Verbindung wurde nicht autorisiert. Falscher oder kein API-Schlüssel angegeben.", + "unknown": "Unerwarteter Fehler" + }, + "abort": { + "already_configured": "Diese Abfahrtstafel ist bereits konfiguriert.", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich." + } + }, + "options": { + "step": { + "init": { + "title": "Optionen der Abfahrtstafel", + "description": "Ändern Sie die Anzeigeoptionen für diese Abfahrtstafel.", + "data": { + "departures_number": "Anzahl der anzuzeigenden Abfahrten", + "cal_events_number": "Anzahl der Kalendertermine für zu erstellende Abfahrten", + "walking_offset": "Gehzeit-Versatz (Minuten)" + }, + "data_description": { + "walking_offset": "Versatz zur Kompensation der Gehstrecke zur Haltestelle (positiv = zukünftige Abfahrten anzeigen, negativ = vergangene Abfahrten anzeigen)" + } + } } }, "entity": { diff --git a/custom_components/pid_departures/translations/en.json b/custom_components/pid_departures/translations/en.json index 9d7ebc4..e37c386 100644 --- a/custom_components/pid_departures/translations/en.json +++ b/custom_components/pid_departures/translations/en.json @@ -16,6 +16,13 @@ "api_key": "API key for Golemio API", "walking_offset": "Offset to compensate for walking distance to stop (positive = show future departures, negative = show past departures)" } + }, + "reauth_confirm": { + "title": "Update API key", + "description": "The API key for the Golemio API is no longer valid. Enter a new one.", + "data": { + "api_key": "Enter API key" + } } }, "error": { @@ -23,7 +30,28 @@ "stop_not_in_list": "Stop was not found in list - choose only stop in provided list.", "stop_not_found": "Stop with provided awsIDs was not found.", "no_departures_selected": "Number of departures cannot be 0.", - "wrong_api_key": "Connection was not authorized. Wrong or no API key provided." + "wrong_api_key": "Connection was not authorized. Wrong or no API key provided.", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This departure board is already configured.", + "reauth_successful": "Re-authentication was successful." + } + }, + "options": { + "step": { + "init": { + "title": "Departure board options", + "description": "Change display options for this departure board.", + "data": { + "departures_number": "Number of departures to display", + "cal_events_number": "Number of calendar events for departures to be created", + "walking_offset": "Walking time offset (minutes)" + }, + "data_description": { + "walking_offset": "Offset to compensate for walking distance to stop (positive = show future departures, negative = show past departures)" + } + } } }, "entity": { diff --git a/custom_components/pid_departures/translations/sk.json b/custom_components/pid_departures/translations/sk.json index ec73099..1c75b7a 100644 --- a/custom_components/pid_departures/translations/sk.json +++ b/custom_components/pid_departures/translations/sk.json @@ -16,6 +16,13 @@ "api_key": "API kľúč pre Golemio API", "walking_offset": "Posun na kompenzáciu vzdialenosti chôdze k zastávke (kladné = zobrazí budúce odchody, záporné = zobrazí minulé odchody)" } + }, + "reauth_confirm": { + "title": "Aktualizácia API kľúča", + "description": "API kľúč pre Golemio API už nie je platný. Zadajte nový.", + "data": { + "api_key": "Zadajte API kľúč" + } } }, "error": { @@ -23,7 +30,28 @@ "stop_not_in_list": "Zastávka nebola nájdená v zozname - vyberte iba zastávku zo zobrazeného zoznamu.", "stop_not_found": "Zastávka s poskytnutými awsID nebola nájdená.", "no_departures_selected": "Počet odchodov nemôže byť 0.", - "wrong_api_key": "Pripojenie nebolo autorizované. Bol zadaný nesprávny alebo žiadny API kľúč." + "wrong_api_key": "Pripojenie nebolo autorizované. Bol zadaný nesprávny alebo žiadny API kľúč.", + "unknown": "Neočakávaná chyba" + }, + "abort": { + "already_configured": "Táto odchodová tabuľa je už nakonfigurovaná.", + "reauth_successful": "Opätovné overenie bolo úspešné." + } + }, + "options": { + "step": { + "init": { + "title": "Možnosti odchodovej tabule", + "description": "Zmeňte možnosti zobrazenia tejto odchodovej tabule.", + "data": { + "departures_number": "Počet odchodov na zobrazenie", + "cal_events_number": "Počet kalendárnych udalostí pre odchody, ktoré sa majú vytvoriť", + "walking_offset": "Časový posun pre chôdzu (minúty)" + }, + "data_description": { + "walking_offset": "Posun na kompenzáciu vzdialenosti chôdze k zastávke (kladné = zobrazí budúce odchody, záporné = zobrazí minulé odchody)" + } + } } }, "entity": { diff --git a/readme.md b/readme.md index 5064efa..95c7133 100644 --- a/readme.md +++ b/readme.md @@ -37,6 +37,12 @@ It is only required to fill in API key once - for additional departure boards it The success dialog will appear or an error will be displayed in the popup. +### Changing options + +The number of departures, number of calendar events, and the walking-time offset can be changed at any time via the **Configure** button on the integration entry — there is no need to delete and re-add the board. + +If your Golemio API key stops working (e.g. it expired), Home Assistant will prompt you to re-authenticate; just enter a new key and the board reloads automatically. + ## Dashboard The repo includes example card based on [Flex-table-card](https://github.com/custom-cards/flex-table-card) for display on dashboard. From 7791fb802a01a1d29c5c89f72742d700984c8b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:33:54 +0000 Subject: [PATCH 2/4] Fix setup crash and harden departure entities - Rename the coordinator's `name` property to `board_name`. DataUpdateCoordinator.__init__ assigns self.name, so the read-only property raised AttributeError and no departure board could be set up. - Departure sensors and the calendar are unavailable instead of raising IndexError on every poll when the API returns fewer departures than configured (e.g. at night). - Remove departure entities left over after the number of departures is lowered in the options. - Give entries created before unique IDs existed the stop ID as unique ID, so adding the same stop again is detected; existing duplicates are left as they are. - Raise the minimum Home Assistant version in hacs.json to 2024.11 (runtime_data, coordinator config_entry, options flow config_entry). - Readme: minimum version, link to the PID Departures Card repository. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BtKZ4PW3A6jeHULgkzuGUc --- custom_components/pid_departures/__init__.py | 41 ++++++++++++ custom_components/pid_departures/calendar.py | 14 ++-- .../pid_departures/coordinator.py | 10 ++- custom_components/pid_departures/sensor.py | 64 +++++++++++-------- hacs.json | 4 +- readme.md | 16 ++++- 6 files changed, 109 insertions(+), 40 deletions(-) diff --git a/custom_components/pid_departures/__init__.py b/custom_components/pid_departures/__init__.py index d95d399..3c2c66d 100644 --- a/custom_components/pid_departures/__init__.py +++ b/custom_components/pid_departures/__init__.py @@ -1,8 +1,11 @@ """Prague Departure Board integration.""" from __future__ import annotations +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from .const import DOMAIN from .coordinator import PIDConfigEntry, PIDDepartureUpdateCoordinator PLATFORMS: list[str] = ["sensor", "binary_sensor", "calendar"] @@ -10,11 +13,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: PIDConfigEntry) -> bool: """Set up Departure Board from a config entry.""" + _async_set_missing_unique_id(hass, entry) + coordinator = PIDDepartureUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + _async_remove_surplus_departure_entities(hass, entry, coordinator.conn_num) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True @@ -25,6 +32,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: PIDConfigEntry) -> bool return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +def _async_set_missing_unique_id(hass: HomeAssistant, entry: PIDConfigEntry) -> None: + """Give entries created before unique IDs were introduced the stop ID as unique ID. + + Without it, adding the same stop again would not be detected as a duplicate. + """ + if entry.unique_id is not None: + return + stop_id: str = entry.data[CONF_ID] + if any( + other.unique_id == stop_id + for other in hass.config_entries.async_entries(DOMAIN) + if other.entry_id != entry.entry_id + ): + # The stop was already added twice before; keep the duplicate as it is. + return + hass.config_entries.async_update_entry(entry, unique_id=stop_id) + + +def _async_remove_surplus_departure_entities( + hass: HomeAssistant, entry: PIDConfigEntry, departures_number: int +) -> None: + """Remove departure entities left over after the number of departures was lowered.""" + registry = er.async_get(hass) + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): + # Unique IDs of departure entities: {stop_id}_{route_name|departure_time}_{n} + prefix, _, num = entity.unique_id.rpartition("_") + if ( + num.isdigit() + and int(num) > departures_number + and prefix.endswith(("_route_name", "_departure_time")) + ): + registry.async_remove(entity.entity_id) + + async def _async_update_listener(hass: HomeAssistant, entry: PIDConfigEntry) -> None: """Reload the entry when its options are updated.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/pid_departures/calendar.py b/custom_components/pid_departures/calendar.py index 2ea4bcb..7fae643 100644 --- a/custom_components/pid_departures/calendar.py +++ b/custom_components/pid_departures/calendar.py @@ -46,15 +46,16 @@ def __init__(self, coordinator: PIDDepartureUpdateCoordinator, events_count: int @override def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" - return self._create_event(self.coordinator.departures[0]) + departure = self.coordinator.departure(0) + return self._create_event(departure) if departure else None @property @override def icon(self) -> str: """Return entity icon based on the type of route.""" - if self.state == STATE_ON: - route_type = self.coordinator.departures[0].route_type - return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) + departure = self.coordinator.departure(0) + if departure and self.state == STATE_ON: + return ROUTE_TYPE_ICON.get(departure.route_type, ROUTE_TYPE_ICON[RouteType.BUS]) else: return ICON_STOP @@ -63,8 +64,9 @@ def icon(self) -> str: def extra_state_attributes(self) -> Mapping[str, Any]: # NOTE: When CONF_LATITUDE and CONF_LONGITUDE is included, HASS shows # the entity on the map. + departure = self.coordinator.departure(0) return { - **self.coordinator.departures[0].as_dict(), + **(departure.as_dict() if departure else {}), CONF_LATITUDE: self.coordinator.latitude, CONF_LONGITUDE: self.coordinator.longitude, } @@ -113,7 +115,7 @@ def _create_event(self, departure: DepartureData) -> CalendarEvent | None: start=start, end=end, summary=f"{route_type} {short_name}", - location=self.coordinator.name, + location=self.coordinator.board_name, description=f"Trip to {departure.trip_headsign}", ) diff --git a/custom_components/pid_departures/coordinator.py b/custom_components/pid_departures/coordinator.py index 0e67706..3882034 100644 --- a/custom_components/pid_departures/coordinator.py +++ b/custom_components/pid_departures/coordinator.py @@ -139,8 +139,9 @@ def board_id(self) -> str: """Stable ID for the departure board (the stop ASW ID).""" return self.stop_id + # Not `name`: DataUpdateCoordinator.__init__ assigns self.name. @property - def name(self) -> str: + def board_name(self) -> str: """Display name for the departure board.""" return (self.data.stop_name + " " + self.data.platform).strip() @@ -149,7 +150,7 @@ def device_info(self) -> DeviceInfo: """Provide device info for the departure board.""" return DeviceInfo( identifiers={(DOMAIN, self.board_id)}, - name=self.name, + name=self.board_name, manufacturer="Prague Integrated Transport", ) @@ -158,6 +159,11 @@ def departures(self) -> list[DepartureData]: """Return the fetched departures, earliest first.""" return self.data.departures + def departure(self, index: int) -> DepartureData | None: + """Return the departure at the index, None when the API returned fewer.""" + departures = self.data.departures + return departures[index] if index < len(departures) else None + @property def stop_name(self) -> str: """Name of the stop.""" diff --git a/custom_components/pid_departures/sensor.py b/custom_components/pid_departures/sensor.py index c39fead..3b0b2aa 100644 --- a/custom_components/pid_departures/sensor.py +++ b/custom_components/pid_departures/sensor.py @@ -14,6 +14,7 @@ from .const import ICON_STOP, ICON_LAT, ICON_LON, ICON_ZONE, ICON_PLATFORM, ICON_UPDATE, ROUTE_TYPE_ICON, RouteType from .coordinator import PIDConfigEntry, PIDDepartureUpdateCoordinator from .entity import BaseEntity +from .hub import DepartureData async def async_setup_entry( @@ -43,61 +44,68 @@ async def async_setup_entry( async_add_entities(new_entities) -class RouteNameSensor(BaseEntity, SensorEntity): - """Sensor for departure route name.""" - - _attr_translation_key = "route_name" +class DepartureEntity(BaseEntity): + """Base for entities showing the n-th departure of the board.""" def __init__(self, coordinator: PIDDepartureUpdateCoordinator, departure_num: int) -> None: super().__init__(coordinator) - self._departure = departure_num + self._departure_num = departure_num self._attr_unique_id = f"{coordinator.board_id}_{self.translation_key}_{departure_num + 1}" self._attr_translation_placeholders = {"num": str(departure_num + 1)} @property - def native_value(self) -> str: + def _departure(self) -> DepartureData | None: + return self.coordinator.departure(self._departure_num) + + @property + def available(self) -> bool: + """Unavailable while the API returns fewer departures than configured, e.g. at night.""" + return super().available and self._departure is not None + + @property + def icon(self) -> str: + """Returns entity icon based on the type of route""" + departure = self._departure + route_type = departure.route_type if departure else RouteType.BUS + return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) + + +class RouteNameSensor(DepartureEntity, SensorEntity): + """Sensor for departure route name.""" + + _attr_translation_key = "route_name" + + @property + def native_value(self) -> str | None: """ Returns name of the route as state.""" - return self.coordinator.departures[self._departure].route_name or "?" + departure = self._departure + return (departure.route_name or "?") if departure else None @property def extra_state_attributes(self) -> Mapping[str, Any]: """ Returns dictionary of additional state attributes""" + departure = self._departure + if departure is None: + return {} # NOTE: When CONF_LATITUDE and CONF_LONGITUDE is included, HASS shows # the entity on the map. return { - **self.coordinator.departures[self._departure].as_dict(), + **departure.as_dict(), CONF_LATITUDE: self.coordinator.latitude, CONF_LONGITUDE: self.coordinator.longitude, } - @property - def icon(self) -> str: - """Returns entity icon based on the type of route""" - route_type = self.coordinator.departures[self._departure].route_type - return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) - -class DepartureTimeSensor(BaseEntity, SensorEntity): +class DepartureTimeSensor(DepartureEntity, SensorEntity): """Sensor for the next departure time (estimated).""" _attr_translation_key = "departure_time" _attr_device_class = SensorDeviceClass.TIMESTAMP - def __init__(self, coordinator: PIDDepartureUpdateCoordinator, departure_num: int) -> None: - super().__init__(coordinator) - self._departure_num = departure_num - self._attr_unique_id = f"{coordinator.board_id}_{self.translation_key}_{departure_num + 1}" - self._attr_translation_placeholders = {"num": str(departure_num + 1)} - @property def native_value(self) -> datetime | None: - return self.coordinator.departures[self._departure_num].departure_time_est - - @property - def icon(self) -> str: - """Returns entity icon based on the type of route""" - route_type = self.coordinator.departures[self._departure_num].route_type - return ROUTE_TYPE_ICON.get(route_type, ROUTE_TYPE_ICON[RouteType.BUS]) + departure = self._departure + return departure.departure_time_est if departure else None class StopSensor(BaseEntity, SensorEntity): diff --git a/hacs.json b/hacs.json index a4f3ee9..5dae83e 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "Prague Departure Boards", "filename": "pid_departures.zip", "hide_default_branch": true, - "homeassistant": "2023.8.0", + "homeassistant": "2024.11.0", "render_readme": true, "zip_release": true -} \ No newline at end of file +} diff --git a/readme.md b/readme.md index 95c7133..dd3231e 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,8 @@ Multiple departure boards can be configured. ## Installation +Requires Home Assistant 2024.11 or newer. + ### Using [HACS](https://hacs.xyz/) [![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) @@ -45,8 +47,18 @@ If your Golemio API key stops working (e.g. it expired), Home Assistant will pro ## Dashboard -The repo includes example card based on [Flex-table-card](https://github.com/custom-cards/flex-table-card) for display on dashboard. +### PID Departures Card + +For the dashboard there is a dedicated card in a separate repository, [PID Departures Card](https://github.com/dvejsada/PID_integration_cards). It shows a live departure board with countdowns, delays, cancellations and service alerts, can merge several platforms of a stop into one list, and is installed through HACS. + +[![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_cards&category=plugin) + +![PID Departures Card](https://raw.githubusercontent.com/dvejsada/PID_integration_cards/main/assets/preview.png "PID Departures Card") + +### Flex-table-card + +The repo also includes an example card based on [Flex-table-card](https://github.com/custom-cards/flex-table-card) (`card.yaml`). Just modify the headline and the departure entity name - number in the name shall be replaced by * to include all departures. -![card](assets/card.jpg "Card") \ No newline at end of file +![card](assets/card.jpg "Card") From 996223711aeea4bcd756624fd3c3343b8d848b6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:55:18 +0000 Subject: [PATCH 3/4] Rewrite readme for version 3 Requirements, setup fields including the walking offset, options and re-authentication, a list of entities and departure attributes, when entities are unavailable, upgrading from version 2, and troubleshooting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BtKZ4PW3A6jeHULgkzuGUc --- readme.md | 108 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 23 deletions(-) diff --git a/readme.md b/readme.md index dd3231e..ce91794 100644 --- a/readme.md +++ b/readme.md @@ -1,49 +1,94 @@ # PID Departure Board integration -This custom component provides a departures board information for the selected stops of the Prague Integrated Transport [PID](http://www.pid.cz/). +A Home Assistant integration with live departure boards for stops of the Prague Integrated Transport ([PID](https://pid.cz/)): metro, trams, buses, trains, ferries and the Petřín funicular. Data come from the [Golemio API](https://api.golemio.cz/pid/docs/openapi/) and include real-time delays, cancellations and service alerts. -Multiple departure boards can be configured. +Each departure board is one stop platform and becomes a device in Home Assistant. Any number of boards can be added. -| Device page | Sensor attributes | -|:------------------------------------------------|:----------------------------------------------------:| +| Device page | Sensor attributes | +|:---------------------------------------------------|:--------------------------------------------------------------:| | ![device page](assets/device.en.png "Device page") | ![sensor attributes](assets/sensor.en.png "Sensor attributes") | -## Installation +## Requirements + +- Home Assistant 2024.11 or newer +- A free Golemio API key: [sign up here](https://api.golemio.cz/api-keys/auth/sign-up) -Requires Home Assistant 2024.11 or newer. +## Installation -### Using [HACS](https://hacs.xyz/) +### HACS (recommended) [![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) +Download **PID Departure Boards** in HACS and restart Home Assistant. + ### Manual -To install this integration manually you have to download pid_departures folder into your config/custom_components folder. +Copy the `custom_components/pid_departures` folder into the `custom_components` folder of your Home Assistant configuration and restart Home Assistant. + +## Adding a departure board -## Configuration +[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=pid_departures) -### Using UI +Or go to **Settings → Devices & services → Add integration** and search for **PID Departure Boards**. Fill in: -From the Home Assistant front page go to **Configuration** and then select **Integrations** from the list. +| Field | Description | +|:-------------------------------|:------------| +| API key | Your Golemio API key. For further boards it is prefilled from the first one. | +| Number of departures | How many upcoming departures the board shows, each as its own entities. | +| Stop | The stop and platform, e.g. *Palmovka A*. Type to search. Each platform of a stop is a separate board. | +| Number of calendar events | How many upcoming departures the calendar shows. `0` turns the calendar events off. | +| Walking time offset (minutes) | Shows only departures you can still reach: with `5`, departures leaving in the next five minutes are skipped. A negative value also shows departures that have just left (up to 30 minutes). | -Use the "plus" button in the bottom right to add a new integration called **PID Departure boards**. +Each stop platform can be added only once. -Fill in: - - - API key (if you don't have the API key, you can obtain it here: https://api.golemio.cz/api-keys/auth/sign-up), - - number of departures to be displayed, - - choose a stop from the list, and - - number of calendar events for departures to be created. +### Changing options -It is only required to fill in API key once - for additional departure boards it should be prefilled in the config dialogue. +The number of departures, the number of calendar events and the walking time offset can be changed at any time with **Configure** on the board in **Settings → Devices & services → PID Departure Boards**. The board reloads with the new settings and keeps its device and entities; departure entities above the new number are removed. -The success dialog will appear or an error will be displayed in the popup. +### Expired API key -### Changing options +When the API key stops working, Home Assistant asks to re-authenticate the board in **Settings → Devices & services**. Enter a new key there and the board reloads, no need to remove it. + +## Entities + +Each departure board is a device with these entities. `n` is the number of the departure, `1` is the next one. + +| Entity | Type | Description | +|:--------------------------|:--------------|:------------| +| Next route name (n) | sensor | Line of the n-th departure, e.g. `188` or `B`. All details are in its attributes, see below. | +| Next departure time (n) | sensor | Expected departure time of the n-th departure, delay included. | +| Vehicle at stop | calendar | On while a vehicle is at the stop. The calendar lists the upcoming departures as events. | +| Infotext | binary sensor | On when PID publishes a service alert for the stop; the text is in its attributes (`text`, `text_en`). | +| Wheelchair accessible | binary sensor | Whether the stop is wheelchair accessible. | +| Stop name, Platform, Zone, Stop location (latitude / longitude) | sensor | Information about the stop (diagnostic). | +| Updated | sensor | Time of the last successful update (diagnostic). | -The number of departures, number of calendar events, and the walking-time offset can be changed at any time via the **Configure** button on the integration entry — there is no need to delete and re-add the board. +The data are updated once a minute. -If your Golemio API key stops working (e.g. it expired), Home Assistant will prompt you to re-authenticate; just enter a new key and the board reloads automatically. +### Departure attributes + +The *Next route name* sensors carry all data of their departure as attributes: + +| Attribute | Description | +|:----------|:------------| +| `route_name`, `route_type` | Line and transport type (`tram`, `metro`, `train`, `bus`, `ferry`, `funicular`, `trolleybus`). | +| `trip_headsign` | Destination of the trip. | +| `departure_time_sched`, `departure_time_est` | Scheduled and expected departure time. | +| `arrival_time_sched`, `arrival_time_est` | Scheduled and expected arrival time. | +| `is_delay_avail`, `delay_sec` | Whether real-time data are available, and the delay in seconds. | +| `is_at_stop`, `is_canceled` | Vehicle standing at the stop; trip cancelled. | +| `is_night`, `is_regional`, `is_substitute` | Night line; regional line; substitute service. | +| `is_wheelchair_accessible`, `is_air_conditioned` | Vehicle features. | +| `stop_platform` | Platform code the departure leaves from. | +| `train_number` | Train number, for trains. | +| `last_stop_name`, `last_stop_id` | Last stop the vehicle passed. | +| `trip_id`, `trip_direction`, `stop_id` | GTFS identifiers. | +| `latitude`, `longitude` | Location of the stop, so the departure is shown on the map. | + +### Unavailable entities + +- All entities of a board are **unavailable** while the API cannot be reached; they recover with the next successful update. +- When the API returns fewer departures than configured, e.g. late at night, the departure entities without a departure are **unavailable** until there are enough departures again. ## Dashboard @@ -62,3 +107,20 @@ The repo also includes an example card based on [Flex-table-card](https://github Just modify the headline and the departure entity name - number in the name shall be replaced by * to include all departures. ![card](assets/card.jpg "Card") + +## Upgrading from version 2 + +Version 3 needs Home Assistant 2024.11 or newer. Existing boards keep their devices, entities and settings, and there is nothing to change. New in version 3: options can be changed with **Configure**, an expired API key can be replaced without removing the board, and a failed update no longer leaves old departures on display. + +## Troubleshooting + +- **Settings → System → Repairs → ⋮ → System information** shows whether the Golemio API can be reached from Home Assistant. +- To see the API requests and responses, enable debug logging on the integration (**Settings → Devices & services → PID Departure Boards → ⋮ → Enable debug logging**), or in `configuration.yaml`: + + ```yaml + logger: + logs: + custom_components.pid_departures: debug + ``` + +- Report problems in the [issues](https://github.com/dvejsada/PID_integration/issues). From 739d128cc8c78c720d11996e619d42a42a2f9ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:54:04 +0000 Subject: [PATCH 4/4] Build the HACS zip when a release is published HACS installs the integration from pid_departures.zip attached to the release (zip_release in hacs.json), which was uploaded by hand so far. The workflow checks that the release tag matches the manifest version, zips the contents of the integration folder (the same layout as previous releases) and attaches it to the release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BtKZ4PW3A6jeHULgkzuGUc --- .github/workflows/release.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..70ad81a --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + release: + name: Attach pid_departures.zip + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check that the tag matches the manifest version + run: | + version=$(jq -r .version custom_components/pid_departures/manifest.json) + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::Release tag $GITHUB_REF_NAME does not match the manifest version $version (expected v$version)" + exit 1 + fi + + # HACS installs the integration from this zip (zip_release in hacs.json). + # It holds the contents of the integration folder, not the folder itself. + - name: Build pid_departures.zip + working-directory: custom_components/pid_departures + run: zip -r "$GITHUB_WORKSPACE/pid_departures.zip" . -x "*__pycache__*" "*.pyc" + + - name: Upload pid_departures.zip + run: gh release upload "$GITHUB_REF_NAME" pid_departures.zip --clobber + env: + GH_TOKEN: ${{ github.token }}