"""Support for departure information for Rhein-Main public transport.""" from __future__ import annotations import asyncio from datetime import timedelta import logging from RMVtransport import RMVtransport from RMVtransport.rmvtransport import ( RMVtransportApiConnectionError, RMVtransportDataError, ) import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_TIMEOUT, TIME_MINUTES from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) CONF_NEXT_DEPARTURE = "next_departure" CONF_STATION = "station" CONF_DESTINATIONS = "destinations" CONF_DIRECTION = "direction" CONF_LINES = "lines" CONF_PRODUCTS = "products" CONF_TIME_OFFSET = "time_offset" CONF_MAX_JOURNEYS = "max_journeys" DEFAULT_NAME = "RMV Journey" VALID_PRODUCTS = ["U-Bahn", "Tram", "Bus", "S", "RB", "RE", "EC", "IC", "ICE"] ICONS = { "U-Bahn": "mdi:subway", "Tram": "mdi:tram", "Bus": "mdi:bus", "S": "mdi:train", "RB": "mdi:train", "RE": "mdi:train", "EC": "mdi:train", "IC": "mdi:train", "ICE": "mdi:train", "SEV": "mdi:checkbox-blank-circle-outline", None: "mdi:clock", } ATTRIBUTION = "Data provided by opendata.rmv.de" SCAN_INTERVAL = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_NEXT_DEPARTURE): [ { vol.Required(CONF_STATION): cv.string, vol.Optional(CONF_DESTINATIONS, default=[]): vol.All( cv.ensure_list, [cv.string] ), vol.Optional(CONF_DIRECTION): cv.string, vol.Optional(CONF_LINES, default=[]): vol.All( cv.ensure_list, [cv.positive_int, cv.string] ), vol.Optional(CONF_PRODUCTS, default=VALID_PRODUCTS): vol.All( cv.ensure_list, [vol.In(VALID_PRODUCTS)] ), vol.Optional(CONF_TIME_OFFSET, default=0): cv.positive_int, vol.Optional(CONF_MAX_JOURNEYS, default=5): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } ], vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int, } ) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the RMV departure sensor.""" timeout = config.get(CONF_TIMEOUT) sensors = [ RMVDepartureSensor( next_departure[CONF_STATION], next_departure.get(CONF_DESTINATIONS), next_departure.get(CONF_DIRECTION), next_departure.get(CONF_LINES), next_departure.get(CONF_PRODUCTS), next_departure.get(CONF_TIME_OFFSET), next_departure.get(CONF_MAX_JOURNEYS), next_departure.get(CONF_NAME), timeout, ) for next_departure in config[CONF_NEXT_DEPARTURE] ] tasks = [sensor.async_update() for sensor in sensors] if tasks: await asyncio.wait(tasks) if not any(sensor.data for sensor in sensors): raise PlatformNotReady async_add_entities(sensors) class RMVDepartureSensor(SensorEntity): """Implementation of an RMV departure sensor.""" def __init__( self, station, destinations, direction, lines, products, time_offset, max_journeys, name, timeout, ): """Initialize the sensor.""" self._station = station self._name = name self._state = None self.data = RMVDepartureData( station, destinations, direction, lines, products, time_offset, max_journeys, timeout, ) self._icon = ICONS[None] @property def name(self): """Return the name of the sensor.""" return self._name @property def available(self): """Return True if entity is available.""" return self._state is not None @property def native_value(self): """Return the next departure time.""" return self._state @property def extra_state_attributes(self): """Return the state attributes.""" try: return { "next_departures": self.data.departures[1:], "direction": self.data.departures[0].get("direction"), "line": self.data.departures[0].get("line"), "minutes": self.data.departures[0].get("minutes"), "departure_time": self.data.departures[0].get("departure_time"), "product": self.data.departures[0].get("product"), ATTR_ATTRIBUTION: ATTRIBUTION, } except IndexError: return {} @property def icon(self): """Icon to use in the frontend, if any.""" return self._icon @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" return TIME_MINUTES async def async_update(self): """Get the latest data and update the state.""" await self.data.async_update() if self._name == DEFAULT_NAME: self._name = self.data.station self._station = self.data.station if not self.data.departures: self._state = None self._icon = ICONS[None] return self._state = self.data.departures[0].get("minutes") self._icon = ICONS[self.data.departures[0].get("product")] class RMVDepartureData: """Pull data from the opendata.rmv.de web page.""" def __init__( self, station_id, destinations, direction, lines, products, time_offset, max_journeys, timeout, ): """Initialize the sensor.""" self.station = None self._station_id = station_id self._destinations = destinations self._direction = direction self._lines = lines self._products = products self._time_offset = time_offset self._max_journeys = max_journeys self.rmv = RMVtransport(timeout) self.departures = [] self._error_notification = False @Throttle(SCAN_INTERVAL) async def async_update(self): """Update the connection data.""" try: _data = await self.rmv.get_departures( self._station_id, products=self._products, direction_id=self._direction, max_journeys=50, ) except (RMVtransportApiConnectionError, RMVtransportDataError): self.departures = [] _LOGGER.warning("Could not retrieve data from rmv.de") return self.station = _data.get("station") _deps = [] _deps_not_found = set(self._destinations) for journey in _data["journeys"]: # find the first departure meeting the criteria _nextdep = {} if self._destinations: dest_found = False for dest in self._destinations: if dest in journey["stops"]: dest_found = True if dest in _deps_not_found: _deps_not_found.remove(dest) _nextdep["destination"] = dest if not dest_found: continue elif self._lines and journey["number"] not in self._lines: continue elif journey["minutes"] < self._time_offset: continue for attr in ("direction", "departure_time", "product", "minutes"): _nextdep[attr] = journey.get(attr, "") _nextdep["line"] = journey.get("number", "") _deps.append(_nextdep) if len(_deps) > self._max_journeys: break if not self._error_notification and _deps_not_found: self._error_notification = True _LOGGER.info("Destination(s) %s not found", ", ".join(_deps_not_found)) self.departures = _deps