Coordinator serves the last-good snapshot within a staleness threshold

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.
This commit is contained in:
2026-09-23 08:04:20 -04:00
parent b0bbb5f8c5
commit 77bc90b3ab
8 changed files with 478 additions and 14 deletions
+5
View File
@@ -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
+89 -2
View File
@@ -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(
+15
View File
@@ -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
+55 -3
View File
@@ -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),
+2
View File
@@ -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."
}
@@ -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."
}
+196 -9
View File
@@ -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:
+114
View File
@@ -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"]