Coordinator polls the API snapshot and rebuilds the sensors' data

Feature 18: the shared coordinator's refresh pass now fetches
GET /api/v1/panel/snapshot through the NestQuestApiClient and
reconstructs the exact core dataclasses (NestQuestSnapshot,
ChildDaySnapshot, QuestInstanceView) the Feature 10 entities read, so
sensor.py and binary_sensor.py are untouched and every entity_id and
attribute payload keeps its shape.

Why: the integration becomes an API client of the NestQuest service
instead of a second reader of the SQLite file, and the panel cards and
the four blueprints keep working without changes.

- snapshot_from_api_payload maps the route's JSON back onto the
  dataclasses field by field (state 'completed' -> 'done' so
  instance_payload renders 'completed' again; counts, presence,
  next_present, overdue, on_time, completed_at pass through verbatim;
  due_date is stamped from today_iso, the only date the route serves).
- The panel payload omits missed instances (D-009), so
  admin_instances can no longer carry missed rows — the admin surface
  for missed quests is the PWA reading the API directly. Documented in
  the module docstring and pinned by tests.
- An unconfigured panel token (the shipped default) gets a typed-error
  stub instead of a construction crash: setup succeeds, the first
  refresh fails cleanly, entities come up unavailable and HA's retry
  cycle applies. Fetch failures log the typed error and mark the
  entities unavailable while data keeps its last good snapshot.
- The runtime record registers before the first refresh so the refresh
  path always finds the live entry state; the update interval and the
  serialized forced-refresh behaviour are unchanged.
- Tests: a fixture snapshot drives the coordinator (stub client, no
  network) asserting per-child values, household rollups, cycle day,
  and the exact instance payload shape, plus unconfigured and
  failed-fetch cases; DB-seeded tests ride a lazy local-snapshot
  client through the same JSON path.
This commit is contained in:
2026-09-23 06:02:26 -04:00
parent 78fd75ec45
commit ec7c8d4bc2
7 changed files with 825 additions and 317 deletions
+37 -10
View File
@@ -123,9 +123,11 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
database = entry.runtime_data.database
settings = entry.runtime_data.settings
# Single clock read for the seed/snapshot anchor (no midnight skew).
today = coordinator._local_now().date()
# Single clock read for the seed/snapshot anchor (no midnight skew);
# the coordinator no longer owns a clock read (the API serves the
# snapshot), so the test resolves the HA-local zone directly.
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
pinned_now = datetime.datetime(
@@ -294,13 +296,38 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
== "missed"
)
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
# pinned_now the builder was called with must reproduce the builder's
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
# deep compare — a wrapper that mis-passed today vs now, or stopped
# calling the builder, would diverge here even though the literal
# assertions above still pass.
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
# REQUIRED wrapper tie-in: since Feature 18 the coordinator's
# refresh polls the snapshot through its API client, so pinning the
# client to serve the payload of the SAME snapshot built with
# pinned_now must reproduce the builder's snapshot byte-for-byte.
# The dataclasses are frozen, so == is a full deep compare — a
# reconstruction that dropped or reshaped a field would diverge
# here even though the literal assertions above still pass.
from conftest import set_coordinator_client
class _PinnedPayloadClient:
async def get_snapshot(self):
return {
"today_iso": built.today_iso,
"cycle_day": built.cycle_day,
"children": [
{
"child_id": child.child_id,
"child_name": child.child_name,
"present": child.present,
"next_present": child.next_present,
"due_today": child.due_today,
"completed_today": child.completed_today,
"remaining_today": child.remaining_today,
"completion_pct": child.completion_pct,
"instances": instance_payload(
child.instances, include_missed=False
),
}
for child in built.children
],
}
set_coordinator_client(entry, _PinnedPayloadClient())
await coordinator.async_refresh()
assert coordinator.data == built