From f9b34a4673f348a41ed6ea6eb78e0b1d4952cedd Mon Sep 17 00:00:00 2001 From: Fabian Seitz Date: Tue, 24 Dec 2024 16:23:42 +0100 Subject: [PATCH] Initial work on switching to deutsche-bahn-api #59 --- custom_components/deutschebahn/config_flow.py | 17 +- custom_components/deutschebahn/const.py | 6 +- custom_components/deutschebahn/manifest.json | 6 +- custom_components/deutschebahn/sensor.py | 188 +++++------------- custom_components/deutschebahn/strings.json | 18 +- .../deutschebahn/translations/de.json | 30 +-- .../deutschebahn/translations/en.json | 18 +- 7 files changed, 107 insertions(+), 176 deletions(-) diff --git a/custom_components/deutschebahn/config_flow.py b/custom_components/deutschebahn/config_flow.py index 9728a29..fbdde7b 100644 --- a/custom_components/deutschebahn/config_flow.py +++ b/custom_components/deutschebahn/config_flow.py @@ -17,13 +17,15 @@ CONF_IGNORED_PRODUCTS, CONF_IGNORED_PRODUCTS_OPTIONS, CONF_UPDATE_INTERVAL, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, DOMAIN, ) _LOGGER = logging.getLogger(__name__) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow""" + """Handle a config flow for Deutsche Bahn.""" VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL @@ -33,17 +35,19 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: - unique_id = user_input[CONF_START] + " to " + user_input[CONF_DESTINATION] + unique_id = f"{user_input[CONF_START]} to {user_input[CONF_DESTINATION]}" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - _LOGGER.debug("Initialized new deutschebahn sensor with id: %s", unique_id) + _LOGGER.debug("Initialized new Deutsche Bahn sensor with ID: %s", unique_id) return self.async_create_entry( - title=user_input[CONF_START] + " - " + user_input[CONF_DESTINATION], + title=f"{user_input[CONF_START]} - {user_input[CONF_DESTINATION]}", data=user_input ) data_schema = vol.Schema( { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, vol.Required(CONF_START): cv.string, vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_OFFSET, default=0): cv.positive_int, @@ -70,7 +74,7 @@ def async_get_options_flow( class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle an options flow""" + """Handle an options flow for Deutsche Bahn.""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" @@ -83,13 +87,14 @@ async def async_step_init( if user_input is not None: return self.async_create_entry(data=user_input) - # Retrieve current options values from config_entry.data current_options = self.config_entry.options or self.config_entry.data return self.async_show_form( step_id="init", data_schema=vol.Schema( { + vol.Required(CONF_CLIENT_ID, default=current_options.get(CONF_CLIENT_ID)): cv.string, + vol.Required(CONF_CLIENT_SECRET, default=current_options.get(CONF_CLIENT_SECRET)): cv.string, vol.Optional(CONF_OFFSET, default=current_options.get(CONF_OFFSET, 0)): cv.positive_int, vol.Optional(CONF_MAX_CONNECTIONS, default=current_options.get(CONF_MAX_CONNECTIONS, 2)): vol.All(vol.Coerce(int), vol.Range(min=1, max=6)), vol.Optional(CONF_IGNORED_PRODUCTS, default=current_options.get(CONF_IGNORED_PRODUCTS, [])): cv.multi_select(CONF_IGNORED_PRODUCTS_OPTIONS), diff --git a/custom_components/deutschebahn/const.py b/custom_components/deutschebahn/const.py index cef32d5..d3276e6 100644 --- a/custom_components/deutschebahn/const.py +++ b/custom_components/deutschebahn/const.py @@ -1,7 +1,9 @@ -"""German Train System - Deutsche Bahn""" +"""Constants for Deutsche Bahn Integration.""" DOMAIN = "deutschebahn" -ATTRIBUTION = "Data provided by bahn.de api" +ATTRIBUTION = "Data provided by Deutsche Bahn API" +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" CONF_DESTINATION = "destination" CONF_START = "start" CONF_OFFSET = "offset" diff --git a/custom_components/deutschebahn/manifest.json b/custom_components/deutschebahn/manifest.json index 0f36486..7c98091 100644 --- a/custom_components/deutschebahn/manifest.json +++ b/custom_components/deutschebahn/manifest.json @@ -9,10 +9,10 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/faserf/ha-deutschebahn/issues", "loggers": [ - "schiene" + "deutsche-bahn-api" ], "requirements": [ - "schiene==0.26" + "deutsche-bahn-api==1.0.6" ], - "version": "3.0.5" + "version": "4.0.0" } \ No newline at end of file diff --git a/custom_components/deutschebahn/sensor.py b/custom_components/deutschebahn/sensor.py index 76a7969..7dd1501 100644 --- a/custom_components/deutschebahn/sensor.py +++ b/custom_components/deutschebahn/sensor.py @@ -1,22 +1,18 @@ -"""deutschebahn sensor platform.""" from datetime import timedelta, datetime import logging from typing import Optional import async_timeout -from urllib.parse import quote - -import schiene -import homeassistant.util.dt as dt_util -import requests -from bs4 import BeautifulSoup +from deutsche_bahn_api.api_authentication import ApiAuthentication +from deutsche_bahn_api.station_helper import StationHelper +from deutsche_bahn_api.timetable_helper import TimetableHelper +#import deutsche_bahn_api from homeassistant import config_entries, core from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .const import ( ATTRIBUTION, @@ -25,16 +21,16 @@ CONF_OFFSET, CONF_ONLY_DIRECT, CONF_MAX_CONNECTIONS, - CONF_IGNORED_PRODUCTS, CONF_UPDATE_INTERVAL, - ATTR_DATA, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, DOMAIN, ) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: core.HomeAssistant, entry: ConfigType, async_add_entities + hass: core.HomeAssistant, entry: ConfigType, async_add_entities: AddEntitiesCallback ): """Setup sensors from a config entry created in the integrations UI.""" config = hass.data[DOMAIN][entry.entry_id] @@ -62,12 +58,24 @@ def __init__(self, config, hass: core.HomeAssistant, scan_interval: timedelta): self.goal = config[CONF_DESTINATION] self.offset = timedelta(minutes=config[CONF_OFFSET]) self.max_connections: int = config.get(CONF_MAX_CONNECTIONS, 2) - self.ignored_products = config.get(CONF_IGNORED_PRODUCTS, []) self.only_direct = config[CONF_ONLY_DIRECT] - self.schiene = schiene.Schiene() - self.connections = [{}] self.scan_interval = scan_interval + # Initialize API authentication + self.api_auth = ApiAuthentication( + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + ) + if not self.api_auth.test_credentials(): + raise ValueError("Invalid Deutsche Bahn API credentials.") + + # Station helpers + self.station_helper = StationHelper() + self.timetable_helper = None + + # Connections + self.connections = [] + @property def name(self): """Return the name of the sensor.""" @@ -98,141 +106,51 @@ def native_value(self): @property def extra_state_attributes(self): """Return the state attributes.""" - attributes = {} - if self.connections: - for con in self.connections: - if "departure" in con and "arrival" in con: - # Parse departure and arrival times - departure_time = dt_util.parse_time(con.get("departure")) - arrival_time = dt_util.parse_time(con.get("arrival")) - if departure_time and arrival_time: - # Create datetime objects for departure and arrival times - departure_datetime = datetime.combine(datetime.now().date(), departure_time) - arrival_datetime = datetime.combine(datetime.now().date(), arrival_time) - # Apply delays - corrected_departure_time = departure_datetime + timedelta(minutes=con.get("delay", 0)) - corrected_arrival_time = arrival_datetime + timedelta(minutes=con.get("delay_arrival", 0)) - # Format and add current departure and arrival times - con["departure_current"] = corrected_departure_time.strftime("%H:%M") - con["arrival_current"] = corrected_arrival_time.strftime("%H:%M") - attributes["departures"] = self.connections - attributes["last_update"] = datetime.now() + attributes = { + "departures": self.connections, + "last_update": datetime.now(), + } return attributes async def async_added_to_hass(self): """Call when entity is added to hass.""" await super().async_added_to_hass() - _LOGGER.warning( - f"The DeutscheBahn integration is currently non-functional due to: https://github.com/FaserF/ha-deutschebahn?tab=readme-ov-file#deprecation-warning " - f"Please disable the integration temporarily until a fix is available." + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_refresh_data, self.scan_interval + ) ) - #self.async_on_remove( - # async_track_time_interval( - # self.hass, self._async_refresh_data, self.scan_interval - # ) - #) async def _async_refresh_data(self, now=None): """Refresh the data.""" await self.async_update_ha_state(force_refresh=True) async def async_update(self): - """Skip updates and log a warning.""" - _LOGGER.warning( - "Skipping update for DeutscheBahn sensor '%s' due to known issues with the data source: https://github.com/FaserF/ha-deutschebahn?tab=readme-ov-file#breaking-warning" - % self._name - ) - - async def async_update_disabled(self): try: with async_timeout.timeout(30): - hass = self.hass - """Pull data from the bahn.de web page.""" - _LOGGER.debug(f"Update the connection data for '{self.start}' '{self.goal}'") - self.connections = await hass.async_add_executor_job( - fetch_schiene_connections, hass, self, self.ignored_products - ) + _LOGGER.debug(f"Updating data for {self.start} -> {self.goal}") + + # Find stations + start_station = self.station_helper.find_stations_by_name(self.start)[0] + goal_station = self.station_helper.find_stations_by_name(self.goal)[0] + + # Initialize timetable helper + self.timetable_helper = TimetableHelper(start_station, self.api_auth) + + # Get timetable + raw_connections = self.timetable_helper.get_timetable() + self.connections = self.timetable_helper.get_timetable_changes(raw_connections) if not self.connections: - self.connections = [{}] + self.connections = [] self._available = True - connections_count = len(self.connections) - - if connections_count > 0: - if connections_count < self.max_connections: - verb = "is" if connections_count == 1 else "are" - _LOGGER.warning( - f"{self._name} Requested {self.max_connections} connections, but only {connections_count} {verb} available." - ) - - - for con in self.connections: - if "details" in con: - #_LOGGER.debug(f"Processing connection: {con}") - con.pop("details") - delay = con.get("delay", {"delay_departure": 0, "delay_arrival": 0}) - con["delay"] = delay["delay_departure"] - con["delay_arrival"] = delay["delay_arrival"] - con["ontime"] = con.get("ontime", False) - #self.attrs[ATTR_DATA] = self.connections - #self.attrs[ATTR_ATTRIBUTION] = f"last updated {datetime.now()} \n{ATTRIBUTION}" - - if self.connections[0].get("delay", 0) != 0: - self._state = f"{self.connections[0]['departure']} + {self.connections[0]['delay']}" - else: - self._state = self.connections[0].get("departure", "Unknown") - else: - _LOGGER.exception(f"Data from DB for direction: '{self.start}' '{self.goal}' was empty, retrying at next sync run. Maybe also check if you have spelled your start and destination correct?") - self._available = False - - except: + + if self.connections: + first_connection = self.connections[0] + departure_time = first_connection.get("departure") + delay = first_connection.get("delay", 0) + self._state = f"{departure_time} (+{delay})" if delay else departure_time + + except Exception as e: self._available = False - _LOGGER.exception(f"Cannot retrieve data for direction: '{self.start}' '{self.goal}' - Most likely it is a temporary API issue from DB and will stop working after a HA restart/some time.") - -def fetch_schiene_connections(hass, self, ignored_products): - """Fetch connections from Schiene API and apply offset filter.""" - try: - raw_data = self.schiene.connections( - self.start, - self.goal, - dt_util.as_local(dt_util.utcnow() + self.offset), - self.only_direct, - ) - _LOGGER.debug(f"Fetched raw data: {raw_data}") - - current_time = dt_util.as_local(dt_util.utcnow() + self.offset) - _LOGGER.debug(f"Current time with offset: {current_time}") - - data = [] - for connection in raw_data: - departure_time = dt_util.parse_time(connection.get("departure")) - if departure_time: - # Combine date with time, then make sure datetime is aware - departure_datetime = datetime.combine(datetime.now().date(), departure_time) - # Assume timezone is the same as current_time's timezone - departure_datetime = dt_util.as_local(departure_datetime) - - delay_info = connection.get("delay", {"delay_departure": 0, "delay_arrival": 0}) - delay_departure = delay_info.get("delay_departure", 0) - delay_arrival = delay_info.get("delay_arrival", 0) - departure_datetime += timedelta(minutes=delay_departure) - _LOGGER.debug(f"Departure datetime for connection: {departure_datetime}") - - if departure_datetime < current_time: - _LOGGER.debug(f"Connection filtered out, departure time {departure_datetime} is before current time {current_time}") - continue - - if len(data) == self.max_connections: - _LOGGER.debug("Reached maximum number of connections to return") - break - elif set(connection["products"]).intersection(ignored_products): - _LOGGER.debug(f"Connection filtered out due to ignored products: {connection['products']}") - continue - - data.append(connection) - - _LOGGER.debug(f"Filtered data: {data}") - return data - except Exception as e: - _LOGGER.exception(f"Error fetching or processing connections: {e}") - return [] + _LOGGER.exception(f"Error updating Deutsche Bahn data: {e}") diff --git a/custom_components/deutschebahn/strings.json b/custom_components/deutschebahn/strings.json index 7063d7e..2d511d0 100644 --- a/custom_components/deutschebahn/strings.json +++ b/custom_components/deutschebahn/strings.json @@ -1,9 +1,9 @@ { "options": { - "flow_title": "Setup DB:{entity_name}", + "flow_title": "Setup Deutsche Bahn: {entity_name}", "step": { "init": { - "description": "Submit your train station details for the search queries.", + "description": "Configure your Deutsche Bahn sensor settings.", "data": { "offset": "Offset in minutes", "only_direct": "Only show direct connections?", @@ -15,11 +15,13 @@ } }, "config": { - "flow_title": "Setup DeutscheBahn", + "flow_title": "Setup Deutsche Bahn", "step": { "user": { - "description": "Submit your train station details for the search queries.", + "description": "Configure your Deutsche Bahn connection settings.", "data": { + "client_id": "Client ID", + "client_secret": "Client Secret", "start": "Start station", "destination": "Destination station", "offset": "Offset in minutes", @@ -31,11 +33,11 @@ } }, "abort": { - "already_configured": "This start & Destination combination is already configured" + "already_configured": "This start & destination combination is already configured." }, "error": { - "invalid_station": "Invalid Start / Destination station", - "unknown": "Unknown Error" + "invalid_station": "Invalid start or destination station.", + "unknown": "An unknown error occurred." } } -} \ No newline at end of file +} diff --git a/custom_components/deutschebahn/translations/de.json b/custom_components/deutschebahn/translations/de.json index 8babc1d..9e58533 100644 --- a/custom_components/deutschebahn/translations/de.json +++ b/custom_components/deutschebahn/translations/de.json @@ -1,41 +1,43 @@ { "options": { - "flow_title": "Einrichtung der:{entity_name}", + "flow_title": "Deutsche Bahn einrichten: {entity_name}", "step": { "init": { - "description": "Trage hier deine Zug Station Informationen ein für die Suchabfrage.", + "description": "Konfigurieren Sie die Einstellungen Ihres Deutsche-Bahn-Sensors.", "data": { "offset": "Versatz in Minuten", "only_direct": "Nur direkte Verbindungen anzeigen?", - "max_connections": "Maximale Anzahl der angezeigten Verbindungen", + "max_connections": "Maximale Verbindungen im Sensor", "ignored_products": "Zu ignorierende Produkte", - "scan_interval": "Aktualisierungsintervall in Minuten" + "scan_interval": "Abtastintervall in Minuten" } } } }, "config": { - "flow_title": "Einrichtung der DeutscheBahn Integration", + "flow_title": "Deutsche Bahn einrichten", "step": { "user": { - "description": "Trage hier deine Zug Station Informationen ein für die Suchabfrage.", + "description": "Konfigurieren Sie die Verbindungseinstellungen für Deutsche Bahn.", "data": { - "start": "Startstation", - "destination": "Zielstation", + "client_id": "Client-ID", + "client_secret": "Client-Secret", + "start": "Startbahnhof", + "destination": "Zielbahnhof", "offset": "Versatz in Minuten", "only_direct": "Nur direkte Verbindungen anzeigen?", - "max_connections": "Maximale Anzahl der angezeigten Verbindungen", + "max_connections": "Maximale Verbindungen im Sensor", "ignored_products": "Zu ignorierende Produkte", - "scan_interval": "Aktualisierungsintervall in Minuten" + "scan_interval": "Abtastintervall in Minuten" } } }, "abort": { - "already_configured": "Diese Start & Ziel Station Kombination ist bereits konfiguriert." + "already_configured": "Diese Start- und Ziel-Kombination ist bereits konfiguriert." }, "error": { - "invalid_station": "Ungültige Start/Ziel-Station", - "unknown": "Unbekannter Fehler" + "invalid_station": "Ungültiger Start- oder Zielbahnhof.", + "unknown": "Ein unbekannter Fehler ist aufgetreten." } } -} \ No newline at end of file +} diff --git a/custom_components/deutschebahn/translations/en.json b/custom_components/deutschebahn/translations/en.json index 7063d7e..2d511d0 100644 --- a/custom_components/deutschebahn/translations/en.json +++ b/custom_components/deutschebahn/translations/en.json @@ -1,9 +1,9 @@ { "options": { - "flow_title": "Setup DB:{entity_name}", + "flow_title": "Setup Deutsche Bahn: {entity_name}", "step": { "init": { - "description": "Submit your train station details for the search queries.", + "description": "Configure your Deutsche Bahn sensor settings.", "data": { "offset": "Offset in minutes", "only_direct": "Only show direct connections?", @@ -15,11 +15,13 @@ } }, "config": { - "flow_title": "Setup DeutscheBahn", + "flow_title": "Setup Deutsche Bahn", "step": { "user": { - "description": "Submit your train station details for the search queries.", + "description": "Configure your Deutsche Bahn connection settings.", "data": { + "client_id": "Client ID", + "client_secret": "Client Secret", "start": "Start station", "destination": "Destination station", "offset": "Offset in minutes", @@ -31,11 +33,11 @@ } }, "abort": { - "already_configured": "This start & Destination combination is already configured" + "already_configured": "This start & destination combination is already configured." }, "error": { - "invalid_station": "Invalid Start / Destination station", - "unknown": "Unknown Error" + "invalid_station": "Invalid start or destination station.", + "unknown": "An unknown error occurred." } } -} \ No newline at end of file +}