Files
NestQuest/custom_components/nestquest/coordinator.py
T
dev_agentandClaude Opus 5.5 474c84bf35 Publish the API's effective timezone and tolerate an older snapshot payload
TZ-002: with no zone stored the API reads its clock in the host's local
time, but the panel fell back to Home Assistant's zone and the admin app
to the browser's, so dates could disagree near midnight. The API now
names the zone it actually uses (api/host_zone.py: the stored zone, else
the host's own IANA zone from TZ, /etc/localtime or /etc/timezone) and
publishes it as the panel snapshot's `timezone` and the admin settings'
read-only `effective_timezone`. Both cards and the admin app format in
that one zone; Home Assistant's or the browser's zone applies only when
none is published.

TZ-003: snapshot_from_api_payload reads `timezone` with a "" default, so
an integration upgraded ahead of its API keeps refreshing against the
pre-change payload.

Task 7e173881-fb99-426f-a2f0-2678439ffc39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:50:25 -04:00

391 lines
17 KiB
Python

"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via
:func:`~.completion.derive_state`), each child's presence today, and
per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently.
Since Feature 18 the snapshot no longer comes from the local database:
the coordinator polls the NestQuest API service's panel plane —
``GET /api/v1/panel/snapshot`` through
:class:`~.api_client.NestQuestApiClient` on its update interval — and
RECONSTRUCTS the same core dataclasses the local builder produced
(:class:`~.core.snapshot.NestQuestSnapshot`,
:class:`~.core.snapshot.ChildDaySnapshot`,
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
``binary_sensor.py`` read ``coordinator.data`` and
:func:`~.core.snapshot.instance_payload` exactly as before, so every
entity_id and attribute payload shape is unchanged: the panel cards and
the four blueprints keep working untouched.
One loss is deliberate and documented (D-009 revisited): the panel
payload OMITS missed instances, so a snapshot rebuilt from the API can
no longer carry them — ``admin_instances`` (``include_missed=True``)
now equals ``instances`` (they can hold only open and completed rows).
The admin surface for missed quests is the PWA, which reads the API
service directly; the integration's entities never needed missed rows
for their states or counts (the counts arrive precomputed).
Failure handling: constructing the client fails fast (ValueError) when
the entry has no panel token configured, and a fetch failure raises the
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
integration setup: the constructor logs a clear message and hands back
a client-shaped stub whose refresh raises the same typed error, and
``_async_update_data`` converts every typed error into
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
exception a HA ``DataUpdateCoordinator`` records as a failed pass
(``last_update_success = False``, ``data = None``), so the entities
come up unavailable. The typed error is never swallowed silently: it
is logged with its message and chained into the UpdateFailed. (The
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
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
QuestInstanceView,
)
__all__ = [
"ChildDaySnapshot",
"NestQuestCoordinator",
"NestQuestSnapshot",
"QuestInstanceView",
]
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
"""Reconstruct the core snapshot dataclasses from the API's JSON.
The panel snapshot route (``api/routes_panel.py``) returns today's
household as plain JSON — ``today_iso``, ``cycle_day``, and the
per-child list with precomputed counts and the instances in the
documented payload shape. This function maps that shape BACK onto
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
field:
- the API's ``state`` ``"completed"`` maps back to the core view's
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
``"completed"`` again — the round-trip is identity for open and
completed rows);
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
``window``, ``due_time``, ``icon`` and the per-child counts pass
through verbatim;
- ``present`` and ``next_present`` pass through verbatim (the API
resolved them through the same presence engine server-side);
- ``timezone`` (the API's effective application timezone) passes
through verbatim for the cards' date and clock; an API older than
the field omits it, which reads as ``""`` (the cards' fallback);
- the instance list is rebuilt IN ORDER, so the panel payload the
sensors emit matches the route's order byte for byte.
Because the route omits missed instances (D-009), a snapshot built
here cannot contain any ``missed`` view — see the module docstring.
"""
children: list[ChildDaySnapshot] = []
for child in payload.get("children", []):
views = tuple(
QuestInstanceView(
instance_id=instance["id"],
definition_id=instance["definition_id"],
child_id=instance["child_id"],
title=instance["title"],
icon=instance.get("icon"),
window=instance["window"],
# The API payload does not carry due_date: every row
# is TODAY's instance (the route queries
# [today, today]), so the view's due_date is the
# snapshot's today_iso — the same value the local
# builder stamped on every today row.
due_date=payload["today_iso"],
due_time=instance.get("due_time"),
state=(
"done" if instance["state"] == "completed" else
instance["state"]
),
overdue=instance["overdue"],
completed_at=instance.get("completed_at"),
was_on_time=instance.get("on_time"),
)
for instance in child.get("instances", [])
)
children.append(
ChildDaySnapshot(
child_id=child["child_id"],
child_name=child["child_name"],
instances=views,
present=child["present"],
due_today=child["due_today"],
completed_today=child["completed_today"],
remaining_today=child["remaining_today"],
completion_pct=child["completion_pct"],
next_present=child.get("next_present"),
)
)
return NestQuestSnapshot(
today_iso=payload["today_iso"],
children=tuple(children),
cycle_day=payload["cycle_day"],
timezone=payload.get("timezone", ""),
)
class _UnconfiguredApiClient:
"""Client-shaped stand-in used when the entry has no panel token.
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
empty token raises ValueError by design (fail fast on a client that
could only ever 401). An entry that simply has not been configured
yet must not crash integration setup, so the coordinator swaps in
this stub: every refresh raises the SAME typed
:class:`~.api_client.NestQuestApiError` a transport failure would,
carrying a clear "API not configured" message;
:meth:`NestQuestCoordinator._async_update_data` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
the coordinator marks the entities unavailable and the HA standard
retry cycle applies. The error is logged, never silent.
"""
__slots__ = ()
async def get_snapshot(self) -> dict[str, Any]:
"""Always raise: there is no configured API to fetch from."""
raise NestQuestApiError(
"NestQuest API not configured: set the panel token in the "
"integration options"
)
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.
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
API client, reconstructed into the core snapshot dataclasses every
entity reads. A failed pass (unconfigured API, transport failure,
non-2xx response) raises the typed error out of the client;
``_async_update_data`` converts it to
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
exception HA's coordinator machinery records as a failed pass: the
coordinator's ``data`` keeps its last good value while
``last_update_success`` goes False — HA marks the entities
unavailable until a later pass succeeds. An unexpected
non-typed error still propagates out of ``_async_update_data``
and is logged (with its traceback) by HA's machinery, the same way.
Forced refreshes (the service paths pushing an immediate update
after a completion) are SERIALIZED per entry: overlapping
complete/uncomplete calls must not interleave their snapshot
passes, or an older pass could publish a stale snapshot AFTER a
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__(
self,
hass: HomeAssistant,
*,
entry_id: str,
api_client: NestQuestApiClient,
update_interval_seconds: int | None = None,
staleness_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
not isinstance(interval, int)
or isinstance(interval, bool)
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,
name=DOMAIN,
update_interval=datetime.timedelta(seconds=interval),
)
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
in-flight one publishes, so the last snapshot always reflects
the latest mutation."""
async with self._refresh_lock:
await super().async_refresh()
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 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
entities go unavailable and the setup-time first refresh
arrives as ``ConfigEntryNotReady`` — with the typed error
logged and chained. Anything else still propagates.
"""
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
snapshot = snapshot_from_api_payload(payload)
self._last_good_snapshot = snapshot
self._last_good_monotonic = _monotonic()
return snapshot
def coordinator_client_from_entry(
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
) -> Any:
"""Build the refresh client for a config entry, tolerating no token.
Reads ``(base_url, panel_token)`` off the entry and constructs the
real client. An entry with no panel token configured (the shipped
default is the empty string) makes the constructor raise ValueError
by design; integration setup must not crash on an unconfigured
entry, so this factory swaps in the typed-error stub instead and
logs ONE clear message. The ValueError itself is never re-raised
past this point — it is converted, with the reason logged.
``session_factory`` overrides where the HTTP session comes from
(production: HA's shared aiohttp session via
:func:`~.api_client.client_from_entry`); it exists so tests can
inject a stub transport without importing aiohttp — the mock-only
test harness has no ``homeassistant.helpers.aiohttp_client``.
"""
base_url, panel_token = resolve_api_config(entry)
if not isinstance(panel_token, str) or not panel_token.strip():
LOGGER.warning(
"NestQuest API panel token is not configured for entry %s: "
"entities will stay unavailable until a token is set in the "
"integration options",
entry.entry_id,
)
return _UnconfiguredApiClient()
if session_factory is not None:
return NestQuestApiClient(
base_url, panel_token, session_factory(hass)
)
from custom_components.nestquest.api_client import client_from_entry
return client_from_entry(hass, entry)
#: Test seam: entry_id → client, consulted by the conftest's client
#: factory BEFORE the production path. Always empty in production
#: (nothing in the integration writes to it); tests register scripted
#: clients through it and clear it between tests.
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}