1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-14 23:28:42 +00:00

Only show trains for configured time if configured in nederlandse_spoorwegen (#159261)

This commit is contained in:
Heindrich Paul
2026-02-13 00:29:20 +01:00
committed by GitHub
parent a6287731f7
commit df7c3d787d
4 changed files with 3032 additions and 14 deletions

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta
import logging
from ns_api import NSAPI, Trip
@@ -28,9 +28,17 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def _now_nl() -> datetime:
"""Return current time in Europe/Amsterdam timezone."""
return dt_util.now(AMS_TZ)
def _current_time_nl(tomorrow: bool = False) -> datetime:
"""Return current time for today or tomorrow in Europe/Amsterdam timezone."""
now = dt_util.now(AMS_TZ)
if tomorrow:
now = now + timedelta(days=1)
return now
def _format_time(dt: datetime) -> str:
"""Format datetime to NS API format (DD-MM-YYYY HH:MM)."""
return dt.strftime("%d-%m-%Y %H:%M")
type NSConfigEntry = ConfigEntry[dict[str, NSDataUpdateCoordinator]]
@@ -91,6 +99,13 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
# Filter out trips that have already departed (trips are already sorted)
future_trips = self._remove_trips_in_the_past(trips)
# If a specific time is configured, filter to only show trips at or after that time
if self.departure_time:
reference_time = self._get_time_from_route(self.departure_time)
future_trips = self._filter_trips_at_or_after_time(
future_trips, reference_time
)
# Process trips to find current and next departure
first_trip, next_trip = self._get_first_and_next_trips(future_trips)
@@ -100,20 +115,34 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
next_trip=next_trip,
)
def _get_time_from_route(self, time_str: str | None) -> str:
"""Combine today's date with a time string if needed."""
def _get_time_from_route(self, time_str: str | None) -> datetime:
"""Convert time string to datetime with automatic rollover to tomorrow if needed."""
if not time_str:
return _now_nl().strftime("%d-%m-%Y %H:%M")
return _current_time_nl()
if (
isinstance(time_str, str)
and len(time_str.split(":")) in (2, 3)
and " " not in time_str
):
today = _now_nl().strftime("%d-%m-%Y")
return f"{today} {time_str[:5]}"
# Parse time-only string (HH:MM or HH:MM:SS)
time_only = time_str[:5] # Take HH:MM only
hours, minutes = map(int, time_only.split(":"))
# Create datetime with today's date and the specified time
now = _current_time_nl()
result_dt = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
# If the time is more than 1 hour in the past, assume user meant tomorrow
if (now - result_dt).total_seconds() > 3600:
result_dt = _current_time_nl(tomorrow=True).replace(
hour=hours, minute=minutes, second=0, microsecond=0
)
return result_dt
# Fallback: use current date and time
return _now_nl().strftime("%d-%m-%Y %H:%M")
return _current_time_nl()
async def _get_trips(
self,
@@ -124,8 +153,9 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
) -> list[Trip]:
"""Get trips from NS API, sorted by departure time."""
# Convert time to full date-time string if needed and default to Dutch local time if not provided
time_str = self._get_time_from_route(departure_time)
# Convert time to datetime with rollover logic, then format for API
reference_time = self._get_time_from_route(departure_time)
time_str = _format_time(reference_time)
trips = await self.hass.async_add_executor_job(
self.nsapi.get_trips,
@@ -141,6 +171,10 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
if not trips:
return []
return self._sort_trips_by_departure(trips)
def _sort_trips_by_departure(self, trips: list[Trip]) -> list[Trip]:
"""Sort trips by departure time (actual or planned)."""
return sorted(
trips,
key=lambda trip: (
@@ -148,7 +182,7 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
if trip.departure_time_actual is not None
else trip.departure_time_planned
if trip.departure_time_planned is not None
else _now_nl()
else _current_time_nl()
),
)
@@ -170,7 +204,7 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
def _remove_trips_in_the_past(self, trips: list[Trip]) -> list[Trip]:
"""Filter out trips that have already departed."""
# Compare against Dutch local time to align with ns_api timezone handling
now = _now_nl()
now = _current_time_nl()
future_trips = []
for trip in trips:
departure_time = (
@@ -189,6 +223,41 @@ class NSDataUpdateCoordinator(DataUpdateCoordinator[NSRouteResult]):
future_trips.append(trip)
return future_trips
def _filter_trips_at_or_after_time(
self, trips: list[Trip], reference_time: datetime
) -> list[Trip]:
"""Filter trips to only those at or after the reference time (ignoring date).
The API returns trips spanning multiple days, so we simply filter
by time component to show only trips at or after the configured time.
"""
filtered_trips = []
ref_time_only = reference_time.time()
for trip in trips:
departure_time = (
trip.departure_time_actual
if trip.departure_time_actual is not None
else trip.departure_time_planned
)
if departure_time is None:
continue
# Make naive datetimes timezone-aware if needed
if (
departure_time.tzinfo is None
or departure_time.tzinfo.utcoffset(departure_time) is None
):
departure_time = departure_time.replace(tzinfo=reference_time.tzinfo)
# Compare only the time component, ignoring the date
trip_time_only = departure_time.time()
if trip_time_only >= ref_time_only:
filtered_trips.append(trip)
return filtered_trips
def _find_next_trip(
self, future_trips: list[Trip], first_trip: Trip
) -> Trip | None:

View File

@@ -71,6 +71,14 @@ def mock_no_trips_nsapi(mock_nsapi: AsyncMock) -> Generator[AsyncMock]:
return mock_nsapi
@pytest.fixture
def mock_tomorrow_trips_nsapi(mock_nsapi: AsyncMock) -> Generator[AsyncMock]:
"""Override async_setup_entry."""
trips_data = load_json_object_fixture("trip_tomorrow.json", DOMAIN)
mock_nsapi.get_trips.return_value = [Trip(trip) for trip in trips_data["trips"]]
return mock_nsapi
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock config entry."""

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
"""Test the Nederlandse Spoorwegen sensor."""
from collections.abc import Generator
from datetime import date, datetime
from unittest.mock import AsyncMock, patch
import zoneinfo
import pytest
from requests.exceptions import ConnectionError as RequestsConnectionError
@@ -208,3 +210,172 @@ async def test_sensor_with_custom_time_parsing(
route_name.lower() in friendly_name
or route_name.replace(" ", "_").lower() in state.entity_id
)
@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_with_time_filtering(
hass: HomeAssistant,
mock_nsapi: AsyncMock,
) -> None:
"""Test that the time-based window filter correctly filters trips.
This test verifies that:
1. Trips BEFORE the configured time are filtered out
2. Trips AT or AFTER the configured time are included
3. The filtering is based on time-only (ignoring date)
"""
# Create a config entry with a route that has time set to 16:00
# Test frozen at: 2025-09-15 14:30 UTC = 16:30 Amsterdam time
# The fixture includes trips at the following times:
# 16:24/16:25 (trip 0) - FILTERED OUT (departed before 16:30 now)
# 16:34/16:35 (trip 1) - INCLUDED (>= 16:00 configured time AND > 16:30 now)
# With time=16:00, only future trips at or after 16:00 are included
config_entry = MockConfigEntry(
title=INTEGRATION_TITLE,
data={CONF_API_KEY: API_KEY},
domain=DOMAIN,
subentries_data=[
ConfigSubentryDataWithId(
data={
CONF_NAME: "Afternoon commute",
CONF_FROM: "Ams",
CONF_TO: "Rot",
CONF_VIA: "Ht",
CONF_TIME: "16:00",
},
subentry_type=SUBENTRY_TYPE_ROUTE,
title="Afternoon Route",
unique_id=None,
subentry_id="test_route_time_filter",
),
],
)
await setup_integration(hass, config_entry)
await hass.async_block_till_done()
# Should create sensors for the route
sensor_states = hass.states.async_all("sensor")
assert len(sensor_states) == 13
# Find the actual departure time sensor and next departure sensor
actual_departure_sensor = hass.states.get("sensor.afternoon_commute_departure")
next_departure_sensor = hass.states.get("sensor.afternoon_commute_next_departure")
assert actual_departure_sensor is not None, "Actual departure sensor not found"
assert actual_departure_sensor.state != STATE_UNKNOWN
# The sensor state is a UTC timestamp, convert it to Amsterdam time
ams_tz = zoneinfo.ZoneInfo("Europe/Amsterdam")
departure_dt = datetime.fromisoformat(actual_departure_sensor.state)
departure_local = departure_dt.astimezone(ams_tz)
hour = departure_local.hour
minute = departure_local.minute
# Verify first trip: is NOT before 16:00 (i.e., filtered trips are excluded)
assert hour >= 16, (
f"Expected first trip at or after 16:00 Amsterdam time, but got {hour}:{minute:02d}. "
"This means trips before the configured time were NOT filtered out by the time window filter."
)
# Verify next trip also passes the filter
assert next_departure_sensor is not None, "Next departure sensor not found"
next_departure_dt = datetime.fromisoformat(next_departure_sensor.state)
next_departure_local = next_departure_dt.astimezone(ams_tz)
next_hour = next_departure_local.hour
next_minute = next_departure_local.minute
# Verify next trip is also at or after 16:00
assert next_hour >= 16, (
f"Expected next trip at or after 16:00 Amsterdam time, but got {next_hour}:{next_minute:02d}. "
"This means the window filter is not applied consistently to all trips."
)
# Verify next trip is after the first trip
assert (next_hour, next_minute) > (hour, minute), (
f"Expected next trip ({next_hour}:{next_minute:02d}) to be after first trip ({hour}:{minute:02d})"
)
@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_with_time_filtering_next_day(
hass: HomeAssistant,
mock_tomorrow_trips_nsapi: AsyncMock,
) -> None:
"""Test that time filtering automatically rolls over to next day when time is in past.
This test verifies the day boundary logic:
1. When configured time is >1 hour in the past, coordinator queries tomorrow's trips
2. The API is called with tomorrow's date + configured time
3. This ensures users get their morning commute trips even when configured in evening
Example: It's 16:30 (4:30 PM), user configured 08:00 (8:00 AM) for morning commute.
Instead of showing no trips (since 08:00 already passed today), we show tomorrow's 08:00 trips.
"""
# Current time: 16:30 Amsterdam (14:30 UTC frozen)
# Configured time: 08:00 (8.5 hours in the past, >1 hour threshold)
# Expected behavior: Query tomorrow (2025-09-16) at 08:00
config_entry = MockConfigEntry(
title=INTEGRATION_TITLE,
data={CONF_API_KEY: API_KEY},
domain=DOMAIN,
subentries_data=[
ConfigSubentryDataWithId(
data={
CONF_NAME: "Morning commute",
CONF_FROM: "Ams",
CONF_TO: "Rot",
CONF_VIA: "Ht",
CONF_TIME: "08:00",
},
subentry_type=SUBENTRY_TYPE_ROUTE,
title="Morning Route",
unique_id=None,
subentry_id="test_route_morning",
),
],
)
await setup_integration(hass, config_entry)
await hass.async_block_till_done()
# Should create sensors for the route
sensor_states = hass.states.async_all("sensor")
assert len(sensor_states) == 13
# Find the actual departure sensor
actual_departure_sensor = hass.states.get("sensor.morning_commute_departure")
assert actual_departure_sensor is not None, "Actual departure sensor not found"
# The sensor should have a valid trip
assert actual_departure_sensor.state != STATE_UNKNOWN, (
"Expected to have trips from tomorrow when configured time is in the past"
)
# Verify the first trip is tomorrow morning at or after 08:00
# The fixture has trips at 08:24, 08:34 on 2025-09-16 (tomorrow)
departure_dt = datetime.fromisoformat(actual_departure_sensor.state)
ams_tz = zoneinfo.ZoneInfo("Europe/Amsterdam")
departure_local = departure_dt.astimezone(ams_tz)
departure_hour = departure_local.hour
departure_minute = departure_local.minute
departure_date = departure_local.date()
# Verify trip is at or after 08:00 morning time
assert 8 <= departure_hour < 12, (
f"Expected morning trip (08:00-11:59) but got {departure_hour}:{departure_minute:02d}. "
"This means the rollover to tomorrow logic is not working correctly."
)
# Verify trip is from tomorrow (2025-09-16)
expected_date = date(2025, 9, 16)
assert departure_date == expected_date, (
f"Expected trip from tomorrow (2025-09-16) but got {departure_date}. "
"The coordinator should query tomorrow's trips when configured time is >1 hour in the past."
)