Task 6e70d166: Coordinator polls the snapshot and builds the sensors (#167)
This commit was merged in pull request #167.
This commit is contained in:
+164
-6
@@ -52,6 +52,7 @@ _HA_MODULES = (
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.helpers.aiohttp_client",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
@@ -99,6 +100,7 @@ for _parent, _child in (
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant.helpers", "aiohttp_client"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
@@ -365,6 +367,15 @@ class BinarySensorEntity(Entity):
|
||||
return self._attr_device_class
|
||||
|
||||
|
||||
class UpdateFailed(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed.
|
||||
|
||||
The exception an update pass raises to report a failed fetch: the
|
||||
coordinator records ``last_update_success = False`` instead of
|
||||
propagating it out of the refresh.
|
||||
"""
|
||||
|
||||
|
||||
class DataUpdateCoordinator:
|
||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||
|
||||
@@ -373,8 +384,20 @@ class DataUpdateCoordinator:
|
||||
``async_add_listener``/``async_update_listeners`` for entity
|
||||
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||
and ``data``. Interval scheduling is NOT simulated (no real clock
|
||||
ticks in tests) — tests drive refreshes directly.
|
||||
``last_exception``, and ``data``. Interval scheduling is NOT
|
||||
simulated (no real clock ticks in tests) — tests drive refreshes
|
||||
directly.
|
||||
|
||||
The failure contract mirrors real HA exactly (it is the trap this
|
||||
harness once hid): an update failure NEVER propagates out of
|
||||
``async_refresh`` — the coordinator records
|
||||
``last_update_success = False``, keeps its last good ``data``, and
|
||||
still notifies listeners so entities re-render unavailable — and
|
||||
``async_config_entry_first_refresh`` wraps ANY failed pass in
|
||||
:class:`ConfigEntryNotReady`, with the original error only
|
||||
surviving as ``__cause__``. A stand-in that re-raised the raw
|
||||
update error would let setup code key on the original exception
|
||||
type — a clause real HA can never reach.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||
@@ -384,6 +407,7 @@ class DataUpdateCoordinator:
|
||||
self.update_interval = update_interval
|
||||
self.data = None
|
||||
self.last_update_success = False
|
||||
self.last_exception = None
|
||||
self._listeners = []
|
||||
|
||||
def async_add_listener(self, update_callback, context=None):
|
||||
@@ -403,18 +427,35 @@ class DataUpdateCoordinator:
|
||||
callback()
|
||||
|
||||
async def async_refresh(self):
|
||||
"""Run one update pass and notify listeners."""
|
||||
"""Run one update pass and notify listeners.
|
||||
|
||||
Mirrors HA: a failed update pass is RECORDED, never raised —
|
||||
``last_update_success`` goes False, the exception lands on
|
||||
``last_exception``, and listeners still fire; only cancellation
|
||||
propagates.
|
||||
"""
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
self.last_update_success = True
|
||||
except BaseException:
|
||||
except Exception as err:
|
||||
self.last_update_success = False
|
||||
raise
|
||||
self.last_exception = err
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_config_entry_first_refresh(self):
|
||||
"""First refresh at config-entry setup (alias of refresh)."""
|
||||
"""First refresh at config-entry setup, per HA's real contract.
|
||||
|
||||
HA's real ``async_config_entry_first_refresh`` wraps ANY failed
|
||||
update pass in ``ConfigEntryNotReady`` — the original error
|
||||
survives only as ``__cause__`` — so setup code that must
|
||||
tolerate a failed first refresh keys on ConfigEntryNotReady,
|
||||
never on the original exception type.
|
||||
"""
|
||||
await self.async_refresh()
|
||||
if not self.last_update_success:
|
||||
raise ConfigEntryNotReady(
|
||||
f"error fetching {self.name} data: {self.last_exception}"
|
||||
) from self.last_exception
|
||||
|
||||
async def async_shutdown(self):
|
||||
"""Release listeners on teardown."""
|
||||
@@ -424,6 +465,7 @@ class DataUpdateCoordinator:
|
||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||
_update_coordinator_mock.UpdateFailed = UpdateFailed
|
||||
|
||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||
_entity_mock.DeviceInfo = DeviceInfo
|
||||
@@ -700,6 +742,122 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
||||
return entry
|
||||
|
||||
|
||||
class LazyLocalSnapshotClient:
|
||||
"""A test client that serves the snapshot built LOCALLY.
|
||||
|
||||
Feature 18 moved the coordinator's refresh onto the API service's
|
||||
panel snapshot, but most integration tests still seed the local
|
||||
database directly (the DB removal is a later task). This stub
|
||||
bridges the two: ``get_snapshot`` resolves the entry's runtime
|
||||
record (its database and settings) and runs the SAME pure core
|
||||
builder the API route runs
|
||||
(:func:`~custom_components.nestquest.core.snapshot.build_snapshot`),
|
||||
shaping the payload exactly like ``api/routes_panel.py`` —
|
||||
instances via ``instance_payload(include_missed=False)`` — so the
|
||||
coordinator reconstructs from a byte-identical payload without any
|
||||
network or FastAPI machinery.
|
||||
|
||||
The resolution is LAZY on purpose: the coordinator's factory runs
|
||||
mid-setup, before the runtime record exists, so the first refresh
|
||||
(and any refresh while the entry is torn down) serves an EMPTY but
|
||||
valid snapshot; every later refresh reads the seeded database.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, entry_id: str) -> None:
|
||||
self._hass = hass
|
||||
self._entry_id = entry_id
|
||||
self.calls = 0
|
||||
|
||||
async def get_snapshot(self):
|
||||
import datetime
|
||||
import zoneinfo
|
||||
|
||||
from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN
|
||||
from custom_components.nestquest.core import snapshot as core_snapshot
|
||||
|
||||
self.calls += 1
|
||||
runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id)
|
||||
database = getattr(runtime, "database", None)
|
||||
if database is None or not getattr(database, "connected", True):
|
||||
return {
|
||||
"today_iso": datetime.date.today().isoformat(),
|
||||
"cycle_day": 0,
|
||||
"children": [],
|
||||
}
|
||||
settings = getattr(runtime, "settings", None)
|
||||
# The HA-LOCAL now, the same single clock read the production
|
||||
# coordinator used to hand the builder: the hass stub's
|
||||
# configured time zone, never the host clock.
|
||||
now = datetime.datetime.now(
|
||||
zoneinfo.ZoneInfo(self._hass.config.time_zone)
|
||||
)
|
||||
snapshot = await core_snapshot.build_snapshot(database, settings, now)
|
||||
return {
|
||||
"today_iso": snapshot.today_iso,
|
||||
"cycle_day": snapshot.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": core_snapshot.instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
}
|
||||
for child in snapshot.children
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def coordinator_serves_local_snapshot():
|
||||
"""Default every test entry's coordinator to the local-snapshot client.
|
||||
|
||||
The integration's setup builds its API client through
|
||||
``coordinator_client_from_entry``; the fixture swaps that factory
|
||||
for a test one handing out :class:`LazyLocalSnapshotClient` (so
|
||||
DB-seeded tests exercise the coordinator's API path against local
|
||||
data). Tests that script their own client register it with
|
||||
:func:`set_coordinator_client`, which the factory honours first.
|
||||
"""
|
||||
import custom_components.nestquest as nq_mod
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES = (
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES
|
||||
)
|
||||
|
||||
def _factory(hass, entry, **kwargs):
|
||||
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
|
||||
if override is not None:
|
||||
return override
|
||||
return LazyLocalSnapshotClient(hass, entry.entry_id)
|
||||
|
||||
import unittest.mock as mock
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
patcher = mock.patch.object(
|
||||
nq_mod, "coordinator_client_from_entry", _factory
|
||||
)
|
||||
patcher.start()
|
||||
yield
|
||||
patcher.stop()
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
|
||||
|
||||
def set_coordinator_client(entry, client):
|
||||
"""Register a scripted client for ``entry``'s coordinator factory."""
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
|
||||
return client
|
||||
|
||||
|
||||
class _HassNamespace(SimpleNamespace):
|
||||
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user