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 }} diff --git a/custom_components/pid_departures/__init__.py b/custom_components/pid_departures/__init__.py index a5ae663..3c2c66d 100644 --- a/custom_components/pid_departures/__init__.py +++ b/custom_components/pid_departures/__init__.py @@ -1,51 +1,71 @@ """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.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, CONF_DEP_NUM, CONF_WALKING_OFFSET -from .errors import CannotConnect, StopNotFound, WrongApiKey -from .hub import DepartureBoard +from .const import DOMAIN +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.""" + _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 -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) + + +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/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..7fae643 100644 --- a/custom_components/pid_departures/calendar.py +++ b/custom_components/pid_departures/calendar.py @@ -4,71 +4,58 @@ 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]) + 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._departure_board.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 @@ -77,10 +64,11 @@ 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._departure_board.departures[0].as_dict(), - CONF_LATITUDE: self._departure_board.latitude, - CONF_LONGITUDE: self._departure_board.longitude, + **(departure.as_dict() if departure else {}), + CONF_LATITUDE: self.coordinator.latitude, + CONF_LONGITUDE: self.coordinator.longitude, } @override @@ -97,17 +85,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 +115,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.board_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..3882034 --- /dev/null +++ b/custom_components/pid_departures/coordinator.py @@ -0,0 +1,202 @@ +"""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 + + # Not `name`: DataUpdateCoordinator.__init__ assigns self.name. + @property + def board_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.board_name, + manufacturer="Prague Integrated Transport", + ) + + @property + 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.""" + 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..3b0b2aa 100644 --- a/custom_components/pid_departures/sensor.py +++ b/custom_components/pid_departures/sensor.py @@ -2,128 +2,110 @@ 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) +from .hub import DepartureData 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) -class RouteNameSensor(BaseEntity, SensorEntity): +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_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 _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" - _attr_should_poll = False - - def __init__(self, departure_board: DepartureBoard, departure_num: int) -> None: - super().__init__(departure_board) - self._departure = departure_num - self._attr_unique_id = f"{departure_board.board_id}_{self.translation_key}_{departure_num + 1}" - self._attr_translation_placeholders = {"num": str(departure_num + 1)} @property - def native_value(self) -> str: + def native_value(self) -> str | None: """ Returns name of the route as state.""" - return self._departure_board.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._departure_board.departures[self._departure].as_dict(), - CONF_LATITUDE: self._departure_board.latitude, - CONF_LONGITUDE: self._departure_board.longitude, + **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 - 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): +class DepartureTimeSensor(DepartureEntity, 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) - self._departure_num = departure_num - self._attr_unique_id = f"{departure_board.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 - - @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 - 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) + departure = self._departure + return departure.departure_time_est if departure else None class StopSensor(BaseEntity, SensorEntity): @@ -132,11 +114,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 +126,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 +138,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 +150,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 +162,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/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 5064efa..ce91794 100644 --- a/readme.md +++ b/readme.md @@ -1,46 +1,126 @@ # 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") | +## Requirements + +- Home Assistant 2024.11 or newer +- A free Golemio API key: [sign up here](https://api.golemio.cz/api-keys/auth/sign-up) + ## 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 + +[![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) + +Or go to **Settings → Devices & services → Add integration** and search for **PID Departure Boards**. Fill in: + +| 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). | + +Each stop platform can be added only once. + +### Changing options + +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. + +### Expired API key + +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 -## Configuration +Each departure board is a device with these entities. `n` is the number of the departure, `1` is the next one. -### Using UI +| 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). | -From the Home Assistant front page go to **Configuration** and then select **Integrations** from the list. +The data are updated once a minute. -Use the "plus" button in the bottom right to add a new integration called **PID Departure boards**. +### Departure attributes -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. +The *Next route name* sensors carry all data of their departure as attributes: -It is only required to fill in API key once - for additional departure boards it should be prefilled in the config dialogue. +| 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. | -The success dialog will appear or an error will be displayed in the popup. +### 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 -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") + +## 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).