Task d25b875f: Last-good snapshot cache and staleness handling #170
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user