Add the shared NestQuest data update coordinator

One refresh pass builds the whole entity snapshot: active children,
today's instances with derived open/done/missed states, per-child
presence via the PresenceEngine (records converted to model objects),
rollup counts, and the custody cycle day. Interval configurable via
the new update_interval option, default five minutes.
This commit is contained in:
2026-09-19 11:41:12 -04:00
parent 3ac1d32e1b
commit 979eafc5a2
8 changed files with 649 additions and 5 deletions
+25
View File
@@ -20,11 +20,14 @@ from .const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
)
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
@@ -238,6 +241,7 @@ class NestQuestRuntimeData:
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
@@ -493,6 +497,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass, database, _configured_horizon_days(dict(entry.options))
)
_register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below
# unwinds them through the except path's listener handling.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
database=database,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
)
await coordinator.async_config_entry_first_refresh()
except BaseException:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
@@ -521,6 +538,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
)
entry.runtime_data = runtime_data
hass.data[DOMAIN][entry.entry_id] = runtime_data
@@ -565,6 +583,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Tear the shared coordinator down after the DB close: no
# listener may outlive the connection it reads.
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
+15
View File
@@ -128,6 +128,21 @@ def _derived_state(
)
def derive_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Public state derivation for the Feature 10 coordinator.
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
"""
return _derived_state(instance, latest, today)
def _was_on_time(
instance: QuestInstanceRecord,
now: datetime.datetime | None,
+7
View File
@@ -55,6 +55,13 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300
# list as a disaster-recovery copy so a lost database can be re-seeded.
CONF_ADMIN_USER_IDS = "admin_user_ids"
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
# entity reads one snapshot per refresh; completions push an immediate
# refresh so this poll is only the fallback cadence.
CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
+254
View File
@@ -0,0 +1,254 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
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.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
host clock and never UTC, matching the materialization and completion
layers.
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).
``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.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .completion import derive_state
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .dao_children import ChildrenDao
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .db import NestQuestDatabase
from .presence import (
PresenceEngine,
PresenceOverride,
PresenceSchedule,
)
@dataclass(frozen=True)
class QuestInstanceView:
"""One instance plus everything the entities render from it."""
instance_id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_date: str
due_time: str | None
state: str # open | done | missed
overdue: bool
completed_at: str | None
was_on_time: bool | None
@dataclass(frozen=True)
class ChildDaySnapshot:
"""One active child's day: instances, presence, and rollup counts."""
child_id: int
child_name: str
instances: tuple[QuestInstanceView, ...]
present: bool
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity."""
def __init__(
self,
hass: HomeAssistant,
*,
entry_id: str,
database: NestQuestDatabase,
update_interval_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
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=datetime.timedelta(seconds=interval),
)
self.entry_id = entry_id
self.database = database
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()
today = now.date()
today_iso = today.isoformat()
children = await ChildrenDao(self.database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(self.database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
todays = await PresenceOverridesDao(
self.database
).list_by_child_and_range(child.id, today_iso, today_iso)
if todays:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
overrides[child.id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in todays
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(self.database)
events_dao = CompletionEventsDao(self.database)
definitions_dao = QuestDefinitionsDao(self.database)
snapshots: list[ChildDaySnapshot] = []
for child in children:
instances = await instances_dao.list_by_date_range(
child.id, today_iso, today_iso
)
views: list[QuestInstanceView] = []
for instance in instances:
latest = await events_dao.get_latest_for_instance(instance.id)
definition = await definitions_dao.get(instance.definition_id)
state = derive_state(instance, latest, today)
overdue = False
if state == "open" and instance.due_date == today_iso:
if instance.due_time is not None:
due_moment = datetime.datetime.strptime(
instance.due_time, "%H:%M"
).time()
overdue = now.time() > due_moment
views.append(
QuestInstanceView(
instance_id=instance.id,
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
state=state,
overdue=overdue,
completed_at=(
latest.occurred_at
if latest is not None
and latest.event_type == "completed"
else None
),
was_on_time=(
latest.was_on_time
if latest is not None
and latest.event_type == "completed"
else None
),
)
)
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=engine.is_present(child.id, today),
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
)
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
@@ -14,10 +14,13 @@ from .const import (
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
@@ -56,6 +59,18 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
):
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
# The coordinator refresh interval (Feature 10) is OPTIONAL in raw
# submits: a missing key keeps the current value, so pre-Feature-10
# form payloads (and API callers) are unaffected. Schema-validated
# submits always carry it.
update_interval = user_input.get(CONF_UPDATE_INTERVAL)
if update_interval is not None and (
isinstance(update_interval, bool)
or not isinstance(update_interval, int)
or update_interval < MIN_UPDATE_INTERVAL
):
errors[CONF_UPDATE_INTERVAL] = "invalid"
return errors
@@ -82,6 +97,9 @@ def _build_schema(
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
@@ -210,6 +228,10 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
CONF_UPDATE_INTERVAL: user_input.get(
CONF_UPDATE_INTERVAL,
self._current_values()[CONF_UPDATE_INTERVAL],
),
}
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
@@ -231,6 +253,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
):
if key in options:
values[key] = options[key]