From 77bc90b3ab8a484cd5369ab2c3f8e99479ba4850 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 08:04:20 -0400 Subject: [PATCH] Coordinator serves the last-good snapshot within a staleness threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY: any single typed fetch failure was a failed pass, so the whole board flipped unavailable on one dropped poll and stayed dark until the API answered again — even though HA's coordinator machinery keeps the last good data on a failed pass and the panel could safely keep rendering it for a while. The coordinator now caches every successful snapshot with its time.monotonic stamp; while the elapsed time since the last success is within the configured staleness threshold (CONF_SNAPSHOT_STALENESS, seconds, default 3x the update interval = 900 s) a typed failure serves that cache instead: the panel keeps rendering last-good data, entities stay available, and the outage is logged as a clear warning. Past the threshold — or before the first success — the pass fails as before (typed error logged and chained into UpdateFailed); a later successful fetch refreshes the cache and the entities recover with no config-entry reload. The threshold rides the options flow (validated as a positive int at least the update interval, with a dedicated error string) and the strings files; the clock is read through a _monotonic seam so the staleness tests drive it deterministically. --- custom_components/nestquest/__init__.py | 5 + custom_components/nestquest/coordinator.py | 91 +++++++- custom_components/nestquest/core/const.py | 15 ++ custom_components/nestquest/options_flow.py | 58 ++++- custom_components/nestquest/strings.json | 2 + .../nestquest/translations/en.json | 2 + tests/test_coordinator.py | 205 +++++++++++++++++- tests/test_options_flow.py | 114 ++++++++++ 8 files changed, 478 insertions(+), 14 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 96cbb41..50bc278 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -18,7 +18,9 @@ from homeassistant.helpers.event import async_track_time_change from .const import ( CONF_ADMIN_USER_IDS, + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, @@ -518,6 +520,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval_seconds=entry.options.get( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL ), + staleness_seconds=entry.options.get( + CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS + ), ) # The panel event subscription (the SSE task): a background # task consumes the API service's transition-event stream and diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 1d634e1..22cbb24 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -43,11 +43,24 @@ conversion matters at setup: HA's real ``async_config_entry_first_refresh`` wraps any failed pass in ``ConfigEntryNotReady``, so a setup that keys on the original exception type would never match it.) + +Last-good cache and staleness: every successful pass refreshes a cache +of the rebuilt snapshot stamped with the :func:`time.monotonic` +instant of the success. A typed failure is checked against that stamp +BEFORE the pass is declared failed: within the configured staleness +threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update +intervals) the pass SUCCEEDS with the cached snapshot — the panel +keeps rendering last-good data and the entities stay available — and +the outage is logged as a warning. Entities go unavailable only once +the cache is older than the threshold (or before the first success); +a later successful fetch refreshes the cache and the entities recover +on their own, without a config-entry reload. """ from __future__ import annotations import asyncio import datetime +import time from typing import Any from homeassistant.core import HomeAssistant @@ -59,6 +72,7 @@ from homeassistant.helpers.update_coordinator import ( from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config from .const import ( CONF_UPDATE_INTERVAL, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, @@ -176,6 +190,17 @@ class _UnconfiguredApiClient: ) +def _monotonic() -> float: + """The monotonic clock the staleness math reads. + + A one-line indirection over :func:`time.monotonic` so the staleness + tests can drive the clock deterministically (patch THIS function, + never the stdlib clock). Monotonic is deliberate: wall-clock jumps + (NTP corrections, suspends) must not shorten or extend the window. + """ + return time.monotonic() + + class NestQuestCoordinator(DataUpdateCoordinator): """Shared refresh cycle for every NestQuest entity. @@ -199,6 +224,15 @@ class NestQuestCoordinator(DataUpdateCoordinator): newer one and leave entities wrong until the next poll. Real HA's coordinator already serializes internally; the lock keeps the same guarantee on the stand-in and costs nothing upstream. + + Last-good cache: a successful pass stamps the rebuilt snapshot + with the :func:`~.coordinator._monotonic` instant of the success; + a typed failure within the configured staleness threshold + (``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves + that cached snapshot instead of failing the pass, so a short API + outage keeps the board up. Past the threshold — or before the + first success — the pass fails as above, and a later successful + pass refreshes the cache and recovers the entities on its own. """ def __init__( @@ -208,6 +242,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): entry_id: str, api_client: NestQuestApiClient, update_interval_seconds: int | None = None, + staleness_seconds: int | None = None, ) -> None: interval = update_interval_seconds if ( @@ -216,6 +251,19 @@ class NestQuestCoordinator(DataUpdateCoordinator): or interval < MIN_UPDATE_INTERVAL ): interval = DEFAULT_UPDATE_INTERVAL + staleness = staleness_seconds + if ( + not isinstance(staleness, int) + or isinstance(staleness, bool) + or staleness < interval + ): + # A missing or nonsensical threshold falls back to the + # shipped default; a configured-but-too-short one (below + # even one poll interval) falls back to whichever of + # default/interval spans at least one failed poll, so the + # cache is never dead on arrival. (The options flow + # validates the same rule at the form.) + staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval) super().__init__( hass, LOGGER, @@ -225,6 +273,14 @@ class NestQuestCoordinator(DataUpdateCoordinator): self.entry_id = entry_id self.api_client = api_client self._refresh_lock = asyncio.Lock() + #: The configured last-good window, in SECONDS (const.py: + #: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS). + self._staleness_seconds = staleness + #: The last-good cache: the snapshot from the most recent + #: SUCCESSFUL fetch and the :func:`time.monotonic` instant it + #: landed. Both stay None until the first success. + self._last_good_snapshot: NestQuestSnapshot | None = None + self._last_good_monotonic: float | None = None async def async_refresh(self) -> None: """One refresh at a time; a queued refresh runs AFTER the @@ -236,8 +292,19 @@ class NestQuestCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> NestQuestSnapshot: """One refresh pass: fetch the panel snapshot and rebuild it. + A successful pass rebuilds the snapshot and refreshes the + last-good cache (the snapshot plus the + :func:`time.monotonic` instant of the success). + A typed API failure (unconfigured token stub, transport - failure, non-2xx response) is converted to + failure, non-2xx response) is first checked against that + stamp: WITHIN the configured staleness threshold (elapsed time + since the last successful fetch, inclusive of the threshold + itself) the pass SUCCEEDS with the cached snapshot — the panel + keeps rendering last-good data, the entities stay available — + and the outage is logged as a clear warning (never silent). + With no cache yet, or past the threshold, the typed error is + converted to :class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the exception HA's coordinator machinery records as a failed pass (``last_update_success = False``, ``data`` unchanged) so @@ -248,11 +315,31 @@ class NestQuestCoordinator(DataUpdateCoordinator): try: payload = await self.api_client.get_snapshot() except NestQuestApiError as err: + now = _monotonic() + cached = self._last_good_snapshot + fetched_at = self._last_good_monotonic + if ( + cached is not None + and fetched_at is not None + and now - fetched_at <= self._staleness_seconds + ): + LOGGER.warning( + "NestQuest API refresh failed (%s); serving the " + "last-good snapshot fetched %.0f s ago, within the " + "%d s staleness threshold", + err, + now - fetched_at, + self._staleness_seconds, + ) + return cached LOGGER.error( "NestQuest coordinator refresh failed: %s", err ) raise UpdateFailed(str(err)) from err - return snapshot_from_api_payload(payload) + snapshot = snapshot_from_api_payload(payload) + self._last_good_snapshot = snapshot + self._last_good_monotonic = _monotonic() + return snapshot def coordinator_client_from_entry( diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index dfecc08..37b949d 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -81,6 +81,21 @@ CONF_UPDATE_INTERVAL = "update_interval" DEFAULT_UPDATE_INTERVAL = 300 MIN_UPDATE_INTERVAL = 30 +# Last-good snapshot staleness (Feature 18), in SECONDS: how long the +# coordinator keeps SERVING the last good snapshot after the API stops +# answering. A failed poll whose elapsed time since the last successful +# fetch is within the threshold returns the cached snapshot (the panel +# keeps rendering last-good data, entities stay available); past it the +# poll fails the HA way and the entities go unavailable. The default is +# 3x the default update interval (900 s = 15 minutes): three consecutive +# failed five-minute polls before the board is declared stale — enough +# to ride out an API service restart, short enough not to render a +# days-old board all day. Validation floor: the threshold must be at +# least the configured update interval, or the cache could never span +# even one failed poll. +CONF_SNAPSHOT_STALENESS = "snapshot_staleness" +DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL + # Notification automations (Feature 11). The notify target is a free-text # Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the # household fills in — never a hard-coded personal target; the times are diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 26cdcb9..29adee9 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -23,6 +23,7 @@ from .const import ( CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, @@ -33,6 +34,7 @@ from .const import ( DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_TOKEN, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, MIN_UPDATE_INTERVAL, @@ -50,8 +52,18 @@ def _parse_time(value: str) -> bool: return TIME_PATTERN.fullmatch(value) is not None -def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: - """Validate user input and return field-level errors instead of raising.""" +def _async_validate( + user_input: dict[str, Any], + current: dict[str, Any] | None = None, +) -> dict[str, str]: + """Validate user input and return field-level errors instead of raising. + + ``current`` carries the stored values (entry options → entry data → + defaults) so a raw submit that sets one field but omits a sibling it + is validated against (the staleness threshold vs. the update + interval) can still check the effective pair; raw submits omitting + BOTH keys keep their stored values untouched. + """ errors: dict[str, str] = {} horizon_days = user_input.get(CONF_HORIZON_DAYS) @@ -86,6 +98,33 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): errors[CONF_UPDATE_INTERVAL] = "invalid" + # The last-good staleness threshold (Feature 18) is OPTIONAL in raw + # submits the same way: a missing key keeps the current value. It + # must be a positive int, and at least the EFFECTIVE update interval + # (the one this submit carries, else the stored or default one) — a + # threshold shorter than the poll cadence could never serve even one + # cached snapshot. + staleness = user_input.get(CONF_SNAPSHOT_STALENESS) + if staleness is not None and ( + isinstance(staleness, bool) + or not isinstance(staleness, int) + or staleness < 1 + ): + errors[CONF_SNAPSHOT_STALENESS] = "invalid" + elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL): + stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL) + effective_interval = update_interval + if effective_interval is None and ( + isinstance(stored_interval, int) + and not isinstance(stored_interval, bool) + and stored_interval >= MIN_UPDATE_INTERVAL + ): + effective_interval = stored_interval + if effective_interval is None: + effective_interval = DEFAULT_UPDATE_INTERVAL + if staleness < effective_interval: + errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness" + # The notification section (Feature 11) is OPTIONAL in raw submits # the same way: a missing key keeps the current value. The three # times are strict HH:MM; the notify target is a non-empty HA @@ -179,6 +218,14 @@ def _build_schema( vol.Required( CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] ): int, + # The last-good staleness threshold (Feature 18): how long the + # coordinator keeps serving the cached snapshot after the API + # stops answering. Schema-validated submits always carry it + # (the default pre-fills from the stored value); raw callers + # omitting it keep their stored value (see _async_validate). + vol.Required( + CONF_SNAPSHOT_STALENESS, default=current[CONF_SNAPSHOT_STALENESS] + ): int, vol.Required( CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET] ): str, @@ -291,7 +338,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): ), ) - errors = _async_validate(user_input) + errors = _async_validate(user_input, self._current_values()) # The admin ids are validated only when the key is present: # schema-validated submits always carry it (vol.Optional's # default fills from the current allowlist), while raw callers @@ -354,6 +401,10 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): CONF_UPDATE_INTERVAL, self._current_values()[CONF_UPDATE_INTERVAL], ), + CONF_SNAPSHOT_STALENESS: user_input.get( + CONF_SNAPSHOT_STALENESS, + self._current_values()[CONF_SNAPSHOT_STALENESS], + ), } # The notification section (Feature 11) stores the household's # choices the same fall-back-to-current way, so raw submits @@ -398,6 +449,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + (CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS), (CONF_NOTIFY_TARGET, ""), (CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME), (CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME), diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index 3e6312a..1853eb4 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -23,6 +23,7 @@ "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" @@ -33,6 +34,7 @@ "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index 3e6312a..1853eb4 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -23,6 +23,7 @@ "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" @@ -33,6 +34,7 @@ "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 25dc0a0..e09a8ab 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -12,6 +12,7 @@ cycle day, and the unconfigured / failed-fetch behaviour. from __future__ import annotations import datetime +import logging import pytest @@ -23,7 +24,9 @@ from custom_components.nestquest.api_client import ( NestQuestApiError, ) from custom_components.nestquest.const import ( + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, ) @@ -67,6 +70,23 @@ class StubSnapshotClient: return outcome +class FakeMonotonic: + """A deterministic stand-in for the staleness clock. + + The coordinator reads elapsed time through + :func:`custom_components.nestquest.coordinator._monotonic`; tests + patch that seam with an instance of this class and advance ``now`` + by whole seconds, so the staleness window is exercised without any + real waiting. + """ + + def __init__(self, start: float = 1000.0) -> None: + self.now = start + + def __call__(self) -> float: + return self.now + + #: A fixture panel snapshot (the documented Feature 16 route shape): #: two children — Ada owes one, owes none of it yet, is away today with #: a returns date; Bo completed his only quest and is present. @@ -340,13 +360,20 @@ async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> N await async_unload_entry(hass, entry) -async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> None: - """A failed fetch (transport failure or non-2xx — both typed) is a - FAILED refresh pass: the update pass converts the typed error to - UpdateFailed (the exception HA's coordinator machinery records as a - failed pass, chaining the typed error as ``__cause__``), the refresh - records the failure instead of raising, and data keeps its last - good value.""" +async def test_coordinator_failed_fetch_marks_unavailable( + hass, make_entry, monkeypatch +) -> None: + """A failed fetch (transport failure or non-2xx — both typed) PAST + the staleness threshold is a FAILED refresh pass: the update pass + converts the typed error to UpdateFailed (the exception HA's + coordinator machinery records as a failed pass, chaining the typed + error as ``__cause__``), the refresh records the failure instead of + raising, and data keeps its last good value. (Within the threshold + the last-good cache serves instead — the staleness tests below.)""" + import custom_components.nestquest.coordinator as coordinator_module + + clock = FakeMonotonic() + monkeypatch.setattr(coordinator_module, "_monotonic", clock) error = NestQuestApiError( "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", status=503, @@ -359,8 +386,11 @@ async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> N assert coordinator.data is not None assert coordinator.last_update_success is True - # The next pass hits the scripted failure: the update pass converts - # the typed error to UpdateFailed, with the typed error chained. + # The next pass hits the scripted failure AFTER the cache went + # stale — one second past the shipped 900 s default threshold: the + # update pass converts the typed error to UpdateFailed, with the + # typed error chained. + clock.now += DEFAULT_SNAPSHOT_STALENESS + 1 with pytest.raises(UpdateFailed, match="HTTP 503") as excinfo: await coordinator._async_update_data() assert isinstance(excinfo.value.__cause__, NestQuestApiError) @@ -376,6 +406,163 @@ async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> N assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError) +async def test_coordinator_serves_cached_snapshot_within_staleness( + hass, make_entry, monkeypatch, caplog +) -> None: + """API down WITHIN the staleness threshold: the failed fetch serves + the last-good cache — the pass succeeds with the SAME snapshot, so + ``last_update_success`` stays True (entities remain available) and + the outage is logged as a clear warning, never silently.""" + import custom_components.nestquest.coordinator as coordinator_module + + clock = FakeMonotonic() + monkeypatch.setattr(coordinator_module, "_monotonic", clock) + error = NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", + status=503, + ) + # Setup's first refresh consumes the good snapshot; the two test + # passes hit the scripted failure. + client = StubSnapshotClient(FIXTURE_SNAPSHOT, error, error) + _entry, coordinator = await _wire_entry( + hass, + make_entry, + client, + options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900}, + ) + # The successful first pass cached the snapshot (stamped at the + # fake clock's start). + first = coordinator.data + assert first is not None + assert coordinator.last_update_success is True + + # The API goes down 900 s after that success — exactly the + # threshold, still WITHIN the inclusive window: the pass serves the + # cached snapshot instead of failing. + clock.now += 900 + with caplog.at_level( + logging.WARNING, logger="custom_components.nestquest" + ): + await coordinator.async_refresh() + assert coordinator.last_update_success is True + assert coordinator.data is first + assert coordinator.data.children[0].child_name == "Ada" + # The fetch WAS attempted (and failed) — the cache is a fallback, + # not a replacement for polling. + assert client.calls == 2 + assert any( + "last-good" in record.getMessage() for record in caplog.records + ) + + +async def test_coordinator_recovers_without_reload_after_outage( + hass, make_entry, monkeypatch +) -> None: + """The full outage arc: within the threshold the cache serves, past + it the entities go unavailable, and when the API comes back the + next pass succeeds with the FRESH snapshot, re-arms the cache, and + the entities recover — without any config-entry reload.""" + import custom_components.nestquest.coordinator as coordinator_module + + clock = FakeMonotonic() + monkeypatch.setattr(coordinator_module, "_monotonic", clock) + outage = NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", + status=503, + ) + post_recovery_outage = NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 502", + status=502, + ) + fresh = {**FIXTURE_SNAPSHOT, "cycle_day": 4} + client = StubSnapshotClient( + FIXTURE_SNAPSHOT, outage, outage, fresh, post_recovery_outage + ) + _entry, coordinator = await _wire_entry( + hass, + make_entry, + client, + options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900}, + ) + first = coordinator.data + assert first.cycle_day == 3 + + # Within the threshold: the outage serves the cache. + clock.now += 300 + await coordinator.async_refresh() + assert coordinator.last_update_success is True + assert coordinator.data is first + + # Past the threshold: the outage fails the pass (unavailable). + clock.now += 901 + await coordinator.async_refresh() + assert coordinator.last_update_success is False + + # The API returns: the next pass succeeds with the fresh snapshot + # and no reload was involved anywhere. + await coordinator.async_refresh() + assert coordinator.last_update_success is True + fresh_data = coordinator.data + assert fresh_data.cycle_day == 4 + assert hass.registry.reloaded == [] + + # The success re-armed the cache with the FRESH stamp: another + # outage within the threshold serves the new snapshot now, not the + # pre-outage one. + clock.now += 300 + await coordinator.async_refresh() + assert coordinator.last_update_success is True + assert coordinator.data is fresh_data + assert client.calls == 5 + + +async def test_setup_creates_coordinator_with_configured_staleness( + hass, make_entry +) -> None: + """The staleness threshold rides the entry options into the + coordinator, alongside the update interval.""" + entry = wire_entry_to_registry( + make_entry( + options={CONF_UPDATE_INTERVAL: 60, CONF_SNAPSHOT_STALENESS: 600} + ), + hass.registry, + ) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator._staleness_seconds == 600 + + +async def test_setup_staleness_below_interval_falls_back_to_default( + hass, make_entry +) -> None: + """A stored threshold below the update interval (a raw hand-edited + option) never reaches the coordinator as-is: it falls back to the + shipped default, which still spans the configured interval.""" + entry = wire_entry_to_registry( + make_entry( + options={CONF_UPDATE_INTERVAL: 600, CONF_SNAPSHOT_STALENESS: 60} + ), + hass.registry, + ) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator._staleness_seconds == DEFAULT_SNAPSHOT_STALENESS + + +async def test_coordinator_staleness_floor_never_below_interval(hass) -> None: + """A threshold below the interval falls back to whichever of + default/interval is larger, so the cache can always span at least + one failed poll (here the interval exceeds the shipped default).""" + coordinator = NestQuestCoordinator( + hass, + entry_id="floor", + api_client=StubSnapshotClient(), + update_interval_seconds=1800, + staleness_seconds=60, + ) + assert coordinator._staleness_seconds == 1800 + + async def test_failed_first_refresh_does_not_crash_setup( hass, make_entry ) -> None: diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index c2bcdce..b94b319 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -28,6 +28,7 @@ from custom_components.nestquest.const import ( CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, @@ -35,6 +36,7 @@ from custom_components.nestquest.const import ( DEFAULT_END_OF_DAY_REPORT_TIME, DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_TOKEN, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, @@ -59,6 +61,7 @@ VALID_INPUT = { FULL_INPUT = { **VALID_INPUT, CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS, CONF_NOTIFY_TARGET: "", CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME, CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME, @@ -246,6 +249,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, + CONF_SNAPSHOT_STALENESS: 450, CONF_NOTIFY_TARGET: "notify.mobile_app_test", CONF_MORNING_SUMMARY_TIME: "07:00", CONF_AFTERNOON_REMINDER_TIME: "14:00", @@ -258,6 +262,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, + CONF_SNAPSHOT_STALENESS: 450, CONF_NOTIFY_TARGET: "notify.mobile_app_test", CONF_MORNING_SUMMARY_TIME: "07:00", CONF_AFTERNOON_REMINDER_TIME: "14:00", @@ -901,3 +906,112 @@ def test_options_flow_strings_cover_api_section( assert data.get(key), f"Missing field name for '{key}'" assert strings["options"]["errors"]["invalid_url"] assert translations_en["options"] == strings["options"] + + +# --------------------------------------------------------------------------- +# Last-good snapshot staleness section (Feature 18) +# --------------------------------------------------------------------------- + + +def test_options_flow_staleness_below_update_interval_refused() -> None: + """A staleness threshold shorter than the submit's update interval + is refused with the dedicated error: such a threshold could never + serve even one cached snapshot.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 300, + CONF_SNAPSHOT_STALENESS: 60, + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"} + + +def test_options_flow_staleness_below_stored_interval_refused() -> None: + """A raw submit setting the staleness but omitting the update + interval is checked against the STORED interval, not the default.""" + entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300}) + flow = _make_flow(entry) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"} + + +def test_options_flow_staleness_must_be_positive_int() -> None: + """Non-int, boolean, and non-positive staleness values reject with + the plain invalid error.""" + flow = _make_flow(_make_entry()) + for bad_staleness in ("900", True, 0, -10): + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"} + + +def test_options_flow_staleness_equal_to_interval_saves() -> None: + """A threshold exactly as long as the update interval is valid (the + boundary is inclusive).""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 300, + CONF_SNAPSHOT_STALENESS: 300, + } + ) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_SNAPSHOT_STALENESS] == 300 + + +def test_options_flow_staleness_defaults_saved() -> None: + """A bare submit saves the staleness default (three update + intervals) alongside the other timing defaults.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS + + +def test_options_flow_staleness_stored_values_round_trip() -> None: + """A stored staleness is the form's default and a raw legacy submit + without it keeps the stored value.""" + entry = _make_entry( + options={ + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 300, + CONF_SNAPSHOT_STALENESS: 1200, + } + ) + flow = _make_flow(entry) + form = _run(flow.async_step_init(None)) + schema_defaults = form["data_schema"]({}) + assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200 + + # A raw submit carrying only the legacy keys keeps the stored + # staleness (and never trips the interval cross-check). + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200 + + +def test_options_flow_strings_cover_staleness_section( + strings: dict, translations_en: dict +) -> None: + """Both string files name the staleness field and its error.""" + data = strings["options"]["step"]["init"]["data"] + assert data.get(CONF_SNAPSHOT_STALENESS), ( + f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'" + ) + assert strings["options"]["errors"]["invalid_staleness"] + assert translations_en["options"] == strings["options"]