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:
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
2770
tests/components/nederlandse_spoorwegen/fixtures/trip_tomorrow.json
Normal file
2770
tests/components/nederlandse_spoorwegen/fixtures/trip_tomorrow.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user