Skip to content

Commit 2812d7c

Browse files
heindrichpaulCopilotjoostlek
authored
Add the coordinator pattern to the NS integration (home-assistant#154149)
Signed-off-by: Heindrich Paul <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent c0fc7b6 commit 2812d7c

File tree

12 files changed

+617
-203
lines changed

12 files changed

+617
-203
lines changed

homeassistant/components/nederlandse_spoorwegen/__init__.py

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,39 @@
44

55
import logging
66

7-
from ns_api import NSAPI, RequestParametersError
8-
import requests
9-
10-
from homeassistant.config_entries import ConfigEntry
11-
from homeassistant.const import CONF_API_KEY, Platform
7+
from homeassistant.const import Platform
128
from homeassistant.core import HomeAssistant
13-
from homeassistant.exceptions import ConfigEntryNotReady
149

15-
_LOGGER = logging.getLogger(__name__)
10+
from .const import SUBENTRY_TYPE_ROUTE
11+
from .coordinator import NSConfigEntry, NSDataUpdateCoordinator
1612

13+
_LOGGER = logging.getLogger(__name__)
1714

18-
type NSConfigEntry = ConfigEntry[NSAPI]
1915

2016
PLATFORMS = [Platform.SENSOR]
2117

2218

2319
async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool:
2420
"""Set up Nederlandse Spoorwegen from a config entry."""
25-
api_key = entry.data[CONF_API_KEY]
26-
27-
client = NSAPI(api_key)
28-
29-
try:
30-
await hass.async_add_executor_job(client.get_stations)
31-
except (
32-
requests.exceptions.ConnectionError,
33-
requests.exceptions.HTTPError,
34-
) as error:
35-
_LOGGER.error("Could not connect to the internet: %s", error)
36-
raise ConfigEntryNotReady from error
37-
except RequestParametersError as error:
38-
_LOGGER.error("Could not fetch stations, please check configuration: %s", error)
39-
raise ConfigEntryNotReady from error
40-
41-
entry.runtime_data = client
21+
coordinators: dict[str, NSDataUpdateCoordinator] = {}
22+
23+
# Set up coordinators for all existing routes
24+
for subentry_id, subentry in entry.subentries.items():
25+
if subentry.subentry_type == SUBENTRY_TYPE_ROUTE:
26+
coordinator = NSDataUpdateCoordinator(
27+
hass,
28+
entry,
29+
subentry_id,
30+
subentry,
31+
)
32+
coordinators[subentry_id] = coordinator
33+
await coordinator.async_config_entry_first_refresh()
34+
35+
entry.runtime_data = coordinators
4236

4337
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
44-
4538
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
39+
4640
return True
4741

4842

homeassistant/components/nederlandse_spoorwegen/config_flow.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
ConfigSubentryFlow,
2222
SubentryFlowResult,
2323
)
24-
from homeassistant.const import CONF_API_KEY
24+
from homeassistant.const import CONF_API_KEY, CONF_NAME
2525
from homeassistant.core import callback
2626
from homeassistant.helpers.selector import (
2727
SelectOptionDict,
@@ -32,12 +32,12 @@
3232

3333
from .const import (
3434
CONF_FROM,
35-
CONF_NAME,
3635
CONF_ROUTES,
3736
CONF_TIME,
3837
CONF_TO,
3938
CONF_VIA,
4039
DOMAIN,
40+
INTEGRATION_TITLE,
4141
)
4242

4343
_LOGGER = logging.getLogger(__name__)
@@ -68,7 +68,7 @@ async def async_step_user(
6868
errors["base"] = "unknown"
6969
if not errors:
7070
return self.async_create_entry(
71-
title="Nederlandse Spoorwegen",
71+
title=INTEGRATION_TITLE,
7272
data={CONF_API_KEY: user_input[CONF_API_KEY]},
7373
)
7474
return self.async_show_form(
@@ -113,7 +113,7 @@ async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResu
113113
)
114114

115115
return self.async_create_entry(
116-
title="Nederlandse Spoorwegen",
116+
title=INTEGRATION_TITLE,
117117
data={CONF_API_KEY: import_data[CONF_API_KEY]},
118118
subentries=subentries,
119119
)

homeassistant/components/nederlandse_spoorwegen/const.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
"""Constants for the Nederlandse Spoorwegen integration."""
22

3+
from datetime import timedelta
4+
from zoneinfo import ZoneInfo
5+
36
DOMAIN = "nederlandse_spoorwegen"
7+
INTEGRATION_TITLE = "Nederlandse Spoorwegen"
8+
SUBENTRY_TYPE_ROUTE = "route"
9+
ROUTE_MODEL = "Route"
10+
# Europe/Amsterdam timezone for Dutch rail API expectations
11+
AMS_TZ = ZoneInfo("Europe/Amsterdam")
12+
# Update every 2 minutes
13+
SCAN_INTERVAL = timedelta(minutes=2)
414

515
CONF_ROUTES = "routes"
616
CONF_FROM = "from"
717
CONF_TO = "to"
818
CONF_VIA = "via"
919
CONF_TIME = "time"
10-
CONF_NAME = "name"
1120

1221
# Attribute and schema keys
1322
ATTR_ROUTE = "route"
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""DataUpdateCoordinator for Nederlandse Spoorwegen."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from datetime import datetime
7+
import logging
8+
9+
from ns_api import NSAPI, Trip
10+
from requests.exceptions import ConnectionError, HTTPError, Timeout
11+
12+
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
13+
from homeassistant.const import CONF_API_KEY, CONF_NAME
14+
from homeassistant.core import HomeAssistant
15+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
16+
from homeassistant.util import dt as dt_util
17+
18+
from .const import (
19+
AMS_TZ,
20+
CONF_FROM,
21+
CONF_TIME,
22+
CONF_TO,
23+
CONF_VIA,
24+
DOMAIN,
25+
SCAN_INTERVAL,
26+
)
27+
28+
_LOGGER = logging.getLogger(__name__)
29+
30+
31+
def _now_nl() -> datetime:
32+
"""Return current time in Europe/Amsterdam timezone."""
33+
return dt_util.now(AMS_TZ)
34+
35+
36+
type NSConfigEntry = ConfigEntry[dict[str, NSDataUpdateCoordinator]]
37+
38+
39+
@dataclass
40+
class NSRouteResult:
41+
"""Data class for Nederlandse Spoorwegen API results."""
42+
43+
trips: list[Trip]
44+
first_trip: Trip | None = None
45+
next_trip: Trip | None = None
46+
47+
48+
class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
49+
"""Class to manage fetching Nederlandse Spoorwegen data from the API for a single route."""
50+
51+
def __init__(
52+
self,
53+
hass: HomeAssistant,
54+
config_entry: NSConfigEntry,
55+
route_id: str,
56+
subentry: ConfigSubentry,
57+
) -> None:
58+
"""Initialize the coordinator for a specific route."""
59+
super().__init__(
60+
hass,
61+
_LOGGER,
62+
name=f"{DOMAIN}_{route_id}",
63+
update_interval=SCAN_INTERVAL,
64+
config_entry=config_entry,
65+
)
66+
self.id = route_id
67+
self.nsapi = NSAPI(config_entry.data[CONF_API_KEY])
68+
self.name = subentry.data[CONF_NAME]
69+
self.departure = subentry.data[CONF_FROM]
70+
self.destination = subentry.data[CONF_TO]
71+
self.via = subentry.data.get(CONF_VIA)
72+
self.departure_time = subentry.data.get(CONF_TIME) # str | None
73+
74+
async def _async_update_data(self) -> NSRouteResult:
75+
"""Fetch data from NS API for this specific route."""
76+
trips: list[Trip] = []
77+
first_trip: Trip | None = None
78+
next_trip: Trip | None = None
79+
try:
80+
trips = await self._get_trips(
81+
self.departure,
82+
self.destination,
83+
self.via,
84+
departure_time=self.departure_time,
85+
)
86+
87+
except (ConnectionError, Timeout, HTTPError, ValueError) as err:
88+
# Surface API failures to Home Assistant so the entities become unavailable
89+
raise UpdateFailed(f"API communication error: {err}") from err
90+
91+
# Filter out trips that have already departed (trips are already sorted)
92+
future_trips = self._remove_trips_in_the_past(trips)
93+
94+
# Process trips to find current and next departure
95+
first_trip, next_trip = self._get_first_and_next_trips(future_trips)
96+
97+
return NSRouteResult(
98+
trips=trips,
99+
first_trip=first_trip,
100+
next_trip=next_trip,
101+
)
102+
103+
def _get_time_from_route(self, time_str: str | None) -> str:
104+
"""Combine today's date with a time string if needed."""
105+
if not time_str:
106+
return _now_nl().strftime("%d-%m-%Y %H:%M")
107+
108+
if (
109+
isinstance(time_str, str)
110+
and len(time_str.split(":")) in (2, 3)
111+
and " " not in time_str
112+
):
113+
today = _now_nl().strftime("%d-%m-%Y")
114+
return f"{today} {time_str[:5]}"
115+
# Fallback: use current date and time
116+
return _now_nl().strftime("%d-%m-%Y %H:%M")
117+
118+
async def _get_trips(
119+
self,
120+
departure: str,
121+
destination: str,
122+
via: str | None = None,
123+
departure_time: str | None = None,
124+
) -> list[Trip]:
125+
"""Get trips from NS API, sorted by departure time."""
126+
127+
# Convert time to full date-time string if needed and default to Dutch local time if not provided
128+
time_str = self._get_time_from_route(departure_time)
129+
130+
trips = await self.hass.async_add_executor_job(
131+
self.nsapi.get_trips,
132+
time_str, # trip_time
133+
departure, # departure
134+
via, # via
135+
destination, # destination
136+
True, # exclude_high_speed
137+
0, # year_card
138+
2, # max_number_of_transfers
139+
)
140+
141+
if not trips:
142+
return []
143+
144+
return sorted(
145+
trips,
146+
key=lambda trip: (
147+
trip.departure_time_actual
148+
if trip.departure_time_actual is not None
149+
else trip.departure_time_planned
150+
if trip.departure_time_planned is not None
151+
else _now_nl()
152+
),
153+
)
154+
155+
def _get_first_and_next_trips(
156+
self, trips: list[Trip]
157+
) -> tuple[Trip | None, Trip | None]:
158+
"""Process trips to find the first and next departure."""
159+
if not trips:
160+
return None, None
161+
162+
# First trip is the earliest future trip
163+
first_trip = trips[0]
164+
165+
# Find next trip with different departure time
166+
next_trip = self._find_next_trip(trips, first_trip)
167+
168+
return first_trip, next_trip
169+
170+
def _remove_trips_in_the_past(self, trips: list[Trip]) -> list[Trip]:
171+
"""Filter out trips that have already departed."""
172+
# Compare against Dutch local time to align with ns_api timezone handling
173+
now = _now_nl()
174+
future_trips = []
175+
for trip in trips:
176+
departure_time = (
177+
trip.departure_time_actual
178+
if trip.departure_time_actual is not None
179+
else trip.departure_time_planned
180+
)
181+
if departure_time is not None and (
182+
departure_time.tzinfo is None
183+
or departure_time.tzinfo.utcoffset(departure_time) is None
184+
):
185+
# Make naive datetimes timezone-aware using current reference tz
186+
departure_time = departure_time.replace(tzinfo=now.tzinfo)
187+
188+
if departure_time and departure_time > now:
189+
future_trips.append(trip)
190+
return future_trips
191+
192+
def _find_next_trip(
193+
self, future_trips: list[Trip], first_trip: Trip
194+
) -> Trip | None:
195+
"""Find the next trip with a different departure time than the first trip."""
196+
next_trip = None
197+
if len(future_trips) > 1:
198+
first_time = (
199+
first_trip.departure_time_actual
200+
if first_trip.departure_time_actual is not None
201+
else first_trip.departure_time_planned
202+
)
203+
for trip in future_trips[1:]:
204+
trip_time = (
205+
trip.departure_time_actual
206+
if trip.departure_time_actual is not None
207+
else trip.departure_time_planned
208+
)
209+
if trip_time and first_time and trip_time > first_time:
210+
next_trip = trip
211+
break
212+
return next_trip

0 commit comments

Comments
 (0)