Feature 18: HA Integration as API Client (#173)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #173.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
|
||||
"""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
|
||||
@@ -7,54 +7,81 @@ 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.
|
||||
|
||||
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
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.
|
||||
|
||||
The snapshot assembly itself lives in the Home-Assistant-free
|
||||
:mod:`custom_components.nestquest.core.snapshot` module
|
||||
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
|
||||
HA wrapper that resolves the local timezone and reads the settings handed
|
||||
into its constructor, then delegates to that builder, passing the SAME
|
||||
HA-local ``now`` it resolved in one clock read. The API service's panel
|
||||
snapshot route shares the same builder, so both consumers render ONE
|
||||
snapshot shape.
|
||||
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).
|
||||
|
||||
Presence is resolved through the SAME engine the materializer uses
|
||||
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
||||
with the public DAO methods and the engine's anchor-date arithmetic
|
||||
answers ``is this child at this house today`` (D-004 — never ISO week
|
||||
parity).
|
||||
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.)
|
||||
|
||||
``cycle_day`` reports where today falls in the custody cycle of the
|
||||
first active child (display order) that HAS a schedule — 1-based day
|
||||
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
||||
anchor date. ``0`` means no active child has a schedule at all, so
|
||||
the admin header degrades to a harmless zero instead of guessing.
|
||||
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
|
||||
from zoneinfo import ZoneInfo
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
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.db import NestQuestDatabase
|
||||
from .core.settings import NestQuestSettings
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -65,9 +92,131 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
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);
|
||||
- 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"],
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -75,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__(
|
||||
@@ -82,9 +240,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
api_client: NestQuestApiClient,
|
||||
update_interval_seconds: int | None = None,
|
||||
staleness_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
@@ -93,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,
|
||||
@@ -100,9 +271,16 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
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
|
||||
@@ -111,12 +289,98 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
async with self._refresh_lock:
|
||||
await super().async_refresh()
|
||||
|
||||
def _local_now(self) -> datetime.datetime:
|
||||
time_zone = ZoneInfo(self.hass.config.time_zone)
|
||||
return datetime.datetime.now(time_zone)
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
now = self._local_now()
|
||||
return await build_snapshot(
|
||||
self.database, self.settings, now
|
||||
"""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] = {}
|
||||
|
||||
Reference in New Issue
Block a user