Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 cf118cf305 Serialise time zone adoption with the missed-sweep scheduler
The wake flag compared an adoption only against the plan the loop last
recorded, so New York -> UTC -> New York during the re-plan read left
the scheduler sleeping on UTC midnight; and an adoption committing
between the pre-sweep check and the sweep still let the stale sweep run.

`MissedSweepScheduler.settings_lock` now serialises the panel's adoption
(write plus `settings_changed`) with the loop's plan read and with its
pre-sweep re-read plus sweep.  It is never held across the wait, and the
loop holds nothing else while acquiring it, so a panel request is at
most delayed by one settings read or one sweep.

`_sleep_unless_woken` now cancels and awaits both child tasks, and
re-raises a failed sleep inside the loop's retry block, so the backoff
runs instead of a sweep and nothing outlives `stop()`.

Tests (all four fail on bcf387d): the successive-zone race, an adoption
issued during the pre-sweep check, a failing sleep, and stop() waiting
for the sleep's cancellation cleanup.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:43:40 -04:00
overseerandClaude Opus 5.5 bcf387d902 Re-plan the missed sweep when the panel adopts a time zone
The scheduler loaded settings once per cycle and slept on them until the
old rollover, so a zone adopted from Home Assistant after start-up left a
UTC host sweeping at UTC midnight (20:00 in New York), marking quests
missed hours early.

`MissedSweepScheduler.settings_changed` now wakes the sleeping loop when
the stored zone or rollover time differs from the one it planned with;
the panel's household clock calls it after adopting a reported zone.
Before sweeping, the loop re-reads the settings and re-plans instead if
they no longer match its plan.

Tests: the scheduler re-plans for New York midnight when a panel request
adopts the zone mid-sleep, and skips a stale sweep when the zone changed
without a wake-up.  The admin-precedence and invalid-header tests now
prove adoption first, and the SSE client test asserts the timezone header
rides beside the Authorization header.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:21:53 -04:00
overseerandClaude Opus 5.5 09d6a55e04 Adopt Home Assistant's time zone for the API household clock
The deployed API host runs UTC and the household `timezone` setting was
never set, so the panel snapshot read UTC: morning quests showed overdue
hours early and `today_iso` rolled over at UTC midnight.

The integration now reports `hass.config.time_zone` on every panel
request (`X-NestQuest-Timezone`).  While the admin has not chosen a zone
(`timezone_configured` is false) the API adopts and persists it, so the
panel, the admin plane, and the missed-sweep scheduler all read the
household clock out of the box.  An explicit admin choice — including an
explicitly chosen empty zone — still wins; an invalid reported zone is
logged and ignored.  No schema change.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:05:16 -04:00
11 changed files with 774 additions and 58 deletions
+45 -10
View File
@@ -9,8 +9,9 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``, NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
stored household ``timezone`` setting — the API host may run UTC while household ``timezone`` — the API host may run UTC while the household
the household does not), calls the pure does not; until the admin chooses a zone it is the Home Assistant zone
the integration reports in :data:`PANEL_TIMEZONE_HEADER`), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's :func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the ``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -53,6 +54,7 @@ from __future__ import annotations
import datetime import datetime
import importlib import importlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -62,6 +64,7 @@ from api.database import DatabaseState
from api.dependencies import require_panel_token from api.dependencies import require_panel_token
from api.nestquest_core import ( from api.nestquest_core import (
core_completion, core_completion,
core_const,
core_events, core_events,
core_settings_store, core_settings_store,
core_snapshot, core_snapshot,
@@ -69,6 +72,12 @@ from api.nestquest_core import (
from api import transitions as api_transitions from api import transitions as api_transitions
from api.sse import transition_event_stream from api.sse import transition_event_stream
LOGGER = logging.getLogger(__name__)
#: The request header the integration reports Home Assistant's
#: configured IANA ``time_zone`` in.
PANEL_TIMEZONE_HEADER = core_const.PANEL_TIMEZONE_HEADER
#: The instance DAO, imported on the app's core copy for the #: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route. #: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances") _dao_instances = importlib.import_module("nestquest_core.dao_instances")
@@ -179,16 +188,42 @@ def _local_now() -> datetime.datetime:
async def _household_clock( async def _household_clock(
database: object, database: object, request: Request
) -> tuple[object, datetime.datetime]: ) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read. """Return the settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings' :func:`_local_now` read is re-expressed in the settings' ``timezone``
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset (:meth:`NestQuestSettings.household_now`). The integration reports
zone keeps the host's local time. Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`;
until the admin chooses a zone it is adopted and persisted
(:func:`nestquest_core.settings_store.adopt_reported_timezone`), so
the admin plane follows it too, and the already-sleeping missed-sweep
scheduler is told (:meth:`MissedSweepScheduler.settings_changed`) so
it re-plans for the household rollover — under the scheduler's
``settings_lock``, so the adoption never lands between its settings
check and its sweep. An invalid
reported zone is logged and ignored — the panel keeps serving on
the stored settings.
""" """
settings = await core_settings_store.load_settings(database) reported = request.headers.get(PANEL_TIMEZONE_HEADER, "").strip()
if reported:
scheduler = request.app.state.sweep_scheduler
try:
async with scheduler.settings_lock:
settings = await core_settings_store.adopt_reported_timezone(
database, reported
)
scheduler.settings_changed(settings)
except ValueError:
LOGGER.warning(
"Ignoring invalid %s header value %r",
PANEL_TIMEZONE_HEADER,
reported,
)
settings = await core_settings_store.load_settings(database)
else:
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now()) return settings, settings.household_now(_local_now())
@@ -210,7 +245,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`. :class:`PanelSnapshotResponse`.
""" """
state: DatabaseState = request.app.state.db state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database) settings, now = await _household_clock(state.database, request)
snapshot = await core_snapshot.build_snapshot( snapshot = await core_snapshot.build_snapshot(
state.database, settings, now state.database, settings, now
) )
@@ -297,7 +332,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child", detail="Quest instance not found for this child",
) )
_settings, now = await _household_clock(database) _settings, now = await _household_clock(database, request)
try: try:
result = await core_completion.complete_instance( result = await core_completion.complete_instance(
database, database,
+100 -13
View File
@@ -13,6 +13,27 @@ local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher ``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through). (the same SSE stream every route publishes through).
A household zone adopted from Home Assistant while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
wakes the sleeping loop to re-plan whenever the zone or rollover time
it planned with has moved, and the loop re-reads the settings before
sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed adoption has replaced.
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record
the plan, clear the wake flag) and across the pre-sweep re-read plus
the sweep itself. So an adoption commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between
a read and what the loop does with it. The lock is never held across
the wait, and the loop takes nothing else before it, so a waiting
panel request cannot deadlock: it is delayed at most by one settings
read or one sweep.
All sweep policy lives in the core (the watermark, the All sweep policy lives in the core (the watermark, the
``[watermark, today)`` window, the no-completion-event rule): this ``[watermark, today)`` window, the no-completion-event rule): this
scheduler only decides WHEN. A same-night rerun — e.g. two workers, scheduler only decides WHEN. A same-night rerun — e.g. two workers,
@@ -124,6 +145,14 @@ class MissedSweepScheduler:
) )
self._sleep = sleep if sleep is not None else asyncio.sleep self._sleep = sleep if sleep is not None else asyncio.sleep
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
#: ``(timezone, day_rollover_time)`` the pending sweep was
#: planned with; ``None`` until the first plan.
self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event()
#: Serialises settings adoption with planning and sweeping (see
#: the module docstring's guarantee).
self.settings_lock = asyncio.Lock()
def start(self) -> None: def start(self) -> None:
"""Start the background loop (idempotent: a running one stays).""" """Start the background loop (idempotent: a running one stays)."""
@@ -142,6 +171,20 @@ class MissedSweepScheduler:
pass pass
self._task = None self._task = None
def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. a zone adopted from
Home Assistant), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the
settings and schedules the next sweep for the new household
rollover; an unchanged plan is left sleeping.
"""
if (settings.timezone, settings.day_rollover_time) != self._planned:
self._wake.set()
@property @property
def running(self) -> bool: def running(self) -> bool:
"""Whether the background loop task is alive.""" """Whether the background loop task is alive."""
@@ -151,12 +194,18 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled).""" """Schedule → sweep → repeat, forever (until cancelled)."""
while True: while True:
try: try:
settings = await core_settings_store.load_settings( # Under the lock no adoption can commit between this read
self._database # and the recorded plan, so any later one wakes the wait.
) async with self.settings_lock:
rollover = datetime.time.fromisoformat( self._wake.clear()
settings.day_rollover_time settings = await core_settings_store.load_settings(
) self._database
)
rollover = datetime.time.fromisoformat(
settings.day_rollover_time
)
plan = (settings.timezone, settings.day_rollover_time)
self._planned = plan
except Exception: except Exception:
# Not silent: the failure is logged and retried after # Not silent: the failure is logged and retried after
# the backoff, so one bad settings document or a # the backoff, so one bad settings document or a
@@ -178,15 +227,30 @@ class MissedSweepScheduler:
settings.day_rollover_time, settings.day_rollover_time,
delay, delay,
) )
await self._sleep(delay)
try: try:
await self._run_once( if not await self._sleep_unless_woken(delay):
settings.household_now(self._clock()).date() continue
) # Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The
# lock keeps an adoption from committing between this
# check and the sweep.
async with self.settings_lock:
settings = await core_settings_store.load_settings(
self._database
)
if (
settings.timezone,
settings.day_rollover_time,
) != plan:
continue
await self._run_once(
settings.household_now(self._clock()).date()
)
except Exception: except Exception:
# Not silent either: the sweep error is logged and the # Not silent either: a failed wait or sweep is logged and
# loop backs off before the next cycle, whose watermark # the loop backs off before the next cycle, whose
# re-read makes an already-swept day a no-op. # watermark re-read makes an already-swept day a no-op.
LOGGER.exception( LOGGER.exception(
"NestQuest scheduled missed sweep failed; retrying in " "NestQuest scheduled missed sweep failed; retrying in "
"%gs", "%gs",
@@ -194,6 +258,29 @@ class MissedSweepScheduler:
) )
await self._sleep(_RETRY_BACKOFF_SECONDS) await self._sleep(_RETRY_BACKOFF_SECONDS)
async def _sleep_unless_woken(self, delay: float) -> bool:
"""Wait ``delay``; ``False`` if :meth:`settings_changed` woke it.
Both children are cancelled AND awaited before returning (or
before a cancellation propagates), so none outlives :meth:`stop`;
a sleep that failed re-raises its exception here for the loop's
retry policy.
"""
sleeper = asyncio.ensure_future(self._sleep(delay))
waker = asyncio.ensure_future(self._wake.wait())
try:
await asyncio.wait(
{sleeper, waker}, return_when=asyncio.FIRST_COMPLETED
)
finally:
slept = sleeper.done()
sleeper.cancel()
waker.cancel()
await asyncio.gather(sleeper, waker, return_exceptions=True)
if slept:
sleeper.result()
return slept
async def _run_once(self, today: datetime.date) -> None: async def _run_once(self, today: datetime.date) -> None:
"""Run ONE sweep for ``today`` and publish what it built. """Run ONE sweep for ``today`` and publish what it built.
+25 -4
View File
@@ -6,6 +6,9 @@ speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete`` household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the (completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request. static panel service token as a Bearer credential on every request.
Every request also reports Home Assistant's configured ``time_zone`` in
the ``X-NestQuest-Timezone`` header, so the API (which may run UTC)
reads the household's clock until the admin chooses a zone there.
Transport-injected by construction: the constructor takes the base URL, Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the the panel token, and the HTTP session to use. The session speaks the
@@ -54,6 +57,7 @@ from .const import (
CONF_PANEL_TOKEN, CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL, DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN, DEFAULT_PANEL_TOKEN,
PANEL_TIMEZONE_HEADER,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -156,7 +160,8 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry. """Build the production client for a config entry.
Reads the configured base URL and panel token off the entry Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`) and hands the client Home Assistant's (:func:`resolve_api_config`), reports ``hass.config.time_zone`` as
the household zone, and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection .async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose: pool. The helper import is deferred into this function on purpose:
@@ -168,7 +173,10 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
base_url, panel_token = resolve_api_config(entry) base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient( return NestQuestApiClient(
base_url, panel_token, async_get_clientsession(hass) base_url,
panel_token,
async_get_clientsession(hass),
household_timezone=hass.config.time_zone,
) )
@@ -189,9 +197,14 @@ class NestQuestApiClient:
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
*, *,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
household_timezone: str | None = None,
) -> None: ) -> None:
"""Store the connection settings; the session is injected. """Store the connection settings; the session is injected.
``household_timezone`` is Home Assistant's configured IANA
``time_zone``; when given it rides every request in the
``X-NestQuest-Timezone`` header.
``session`` is an aiohttp ``ClientSession`` in production and a ``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s) stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built URL and ``panel_token`` must be non-empty — a client built
@@ -216,6 +229,14 @@ class NestQuestApiClient:
self._panel_token = panel_token self._panel_token = panel_token
self._session = session self._session = session
self._timeout_seconds = float(timeout_seconds) self._timeout_seconds = float(timeout_seconds)
self._household_timezone = household_timezone
def _headers(self) -> dict[str, str]:
"""The Bearer credential plus the reported household zone."""
headers = {"Authorization": f"Bearer {self._panel_token}"}
if self._household_timezone:
headers[PANEL_TIMEZONE_HEADER] = self._household_timezone
return headers
async def get_snapshot(self) -> dict[str, Any]: async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot. """Fetch and parse today's household snapshot.
@@ -275,7 +296,7 @@ class NestQuestApiClient:
cleanly and closes the underlying response in every path. cleanly and closes the underlying response in every path.
""" """
url = f"{self._base_url}{EVENTS_PATH}" url = f"{self._base_url}{EVENTS_PATH}"
headers = {"Authorization": f"Bearer {self._panel_token}"} headers = self._headers()
# Opening the response gets ONE request timeout, exactly like # Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a # ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream. # body-wide timeout would kill a healthy long-lived stream.
@@ -379,7 +400,7 @@ class NestQuestApiClient:
raises typed too, carrying the status it arrived with. raises typed too, carrying the status it arrived with.
""" """
url = f"{self._base_url}{path}" url = f"{self._base_url}{path}"
headers = {"Authorization": f"Bearer {self._panel_token}"} headers = self._headers()
try: try:
async with asyncio.timeout(self._timeout_seconds): async with asyncio.timeout(self._timeout_seconds):
async with self._session.request( async with self._session.request(
+4 -1
View File
@@ -372,7 +372,10 @@ def coordinator_client_from_entry(
return _UnconfiguredApiClient() return _UnconfiguredApiClient()
if session_factory is not None: if session_factory is not None:
return NestQuestApiClient( return NestQuestApiClient(
base_url, panel_token, session_factory(hass) base_url,
panel_token,
session_factory(hass),
household_timezone=hass.config.time_zone,
) )
from custom_components.nestquest.api_client import client_from_entry from custom_components.nestquest.api_client import client_from_entry
+14 -7
View File
@@ -113,18 +113,25 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled" CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API # The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string keeps the historical # service reads its clock in. The empty string is the API host's own
# behaviour — the API host's own local time — so an existing install is # local time. While the admin has not chosen a zone
# unchanged until the owner sets it. The integration itself always uses # (``timezone_configured`` is ``False``) the API adopts the zone the
# Home Assistant's configured ``time_zone`` and ignores this field. # integration reports from Home Assistant's configured ``time_zone``
# (:data:`PANEL_TIMEZONE_HEADER`), so the default is the household's
# zone without any manual setup. The integration itself always uses
# Home Assistant's ``time_zone`` and ignores this field.
CONF_TIMEZONE = "timezone" CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = "" DEFAULT_TIMEZONE = ""
# Whether the admin has made an explicit ``timezone`` choice. ``False`` # Whether the admin has made an explicit ``timezone`` choice. ``False``
# (a fresh install) lets the admin UI auto-set the browser's zone once; # (a fresh install) lets the API follow Home Assistant's reported zone
# ``True`` pins the stored value — including an explicitly-chosen empty # and the admin UI auto-set the browser's zone once while none is
# ``timezone`` (the API host's local time) — so it is never auto-set again. # stored; ``True`` pins the stored value — including an explicitly-chosen
# empty ``timezone`` (the API host's local time) — so neither changes it.
CONF_TIMEZONE_CONFIGURED = "timezone_configured" CONF_TIMEZONE_CONFIGURED = "timezone_configured"
DEFAULT_TIMEZONE_CONFIGURED = False DEFAULT_TIMEZONE_CONFIGURED = False
# The panel-plane request header carrying Home Assistant's configured
# IANA ``time_zone`` from the integration to the API service.
PANEL_TIMEZONE_HEADER = "X-NestQuest-Timezone"
# Panel-plane API client (Feature 18): the integration reaches the # Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database. # NestQuest API service (Feature 16) over HTTP instead of the database.
+7 -5
View File
@@ -327,12 +327,14 @@ class NestQuestSettings:
def household_now(self, instant: datetime.datetime) -> datetime.datetime: def household_now(self, instant: datetime.datetime) -> datetime.datetime:
"""Return the aware ``instant`` expressed in the household's zone. """Return the aware ``instant`` expressed in the household's zone.
The API service has no Home Assistant ``time_zone``, so its one The API service does not run inside Home Assistant, so its one
clock read (an aware host-local ``now``) is converted here: with clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set the SAME instant is re-expressed in that :attr:`timezone` set — the admin's choice, or else the zone the
zone, so ``.date()`` and ``.time()`` are the household's wall integration reports from Home Assistant — the SAME instant is
clock even when the host runs UTC. With :attr:`timezone` empty re-expressed in that zone, so ``.date()`` and ``.time()`` are the
the instant is returned unchanged (the host's local time). household's wall clock even when the host runs UTC. With
:attr:`timezone` empty the instant is returned unchanged (the
host's local time).
""" """
if not self.timezone: if not self.timezone:
return instant return instant
@@ -36,7 +36,8 @@ after the first update:
this field existed simply lacks it and resolves to ``""``, this field existed simply lacks it and resolves to ``""``,
- ``timezone_configured`` — a real boolean: ``True`` once the admin - ``timezone_configured`` — a real boolean: ``True`` once the admin
explicitly chose ``timezone`` (including the empty host-local value), explicitly chose ``timezone`` (including the empty host-local value),
so the admin UI never auto-sets it again. A document stored before so neither Home Assistant's reported zone
(:func:`adopt_reported_timezone`) nor the admin UI changes it again. A document stored before
this field existed lacks it and resolves to ``False``. this field existed lacks it and resolves to ``False``.
Only values whose core validators ACCEPTED are ever written, so a Only values whose core validators ACCEPTED are ever written, so a
@@ -200,18 +201,49 @@ async def update_settings(
) )
async with _settings_lock(database): async with _settings_lock(database):
current = await load_settings(database) current = await load_settings(database)
document: dict[str, Any] = { return await _write_merged(database, current, changes)
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items(): async def adopt_reported_timezone(
default, validator = _RESOLUTIONS[name] database: NestQuestDatabase, zone: str
try: ) -> NestQuestSettings:
document[name] = validator( """Adopt Home Assistant's reported ``zone`` unless the admin chose one.
default if raw_value is None else raw_value
) While ``timezone_configured`` is ``False`` the stored ``timezone``
except ValueError as error: follows the zone the integration reports, so the API's clock is the
raise ValueError(_named(name, error)) from error household's without any manual setup; ``timezone_configured`` stays
await MetaStateDao(database).set( ``False`` (the value is automatic, not an admin choice). Once the
SETTINGS_META_KEY, json.dumps(document, sort_keys=True) admin has chosen — including an explicitly-chosen empty zone — the
) stored settings are returned untouched. The check and the write
share the settings lock, so a concurrent admin choice is never
overwritten. An invalid ``zone`` raises a ``ValueError`` naming
``timezone`` and writes nothing.
"""
async with _settings_lock(database):
current = await load_settings(database)
if current.timezone_configured or current.timezone == zone:
return current
return await _write_merged(database, current, {"timezone": zone})
async def _write_merged(
database: NestQuestDatabase,
current: NestQuestSettings,
changes: Mapping[str, Any],
) -> NestQuestSettings:
"""Validate ``changes``, merge onto ``current``, persist; lock held."""
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document) return NestQuestSettings(**document)
+33 -3
View File
@@ -196,6 +196,27 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}" assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_household_timezone_rides_every_request_header() -> None:
"""Home Assistant's zone is reported so the API reads the household
clock (task 6351b36d); it is sent only when the client has one."""
transport = StubTransport(
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
StubResponse(200, '{"status": "done"}'),
)
client = NestQuestApiClient(
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
await client.get_snapshot()
await client.complete_instance(42, 3)
for call in transport.calls:
assert call["headers"] == {
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
}
async def test_base_url_trailing_slash_is_normalized() -> None: async def test_base_url_trailing_slash_is_normalized() -> None:
"""A base URL with a trailing slash must not double the path slash.""" """A base URL with a trailing slash must not double the path slash."""
transport = StubTransport(StubResponse(200, "{}")) transport = StubTransport(StubResponse(200, "{}"))
@@ -392,11 +413,20 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
} }
) )
client = api_client.client_from_entry(object(), entry) hass = types.SimpleNamespace(
config=types.SimpleNamespace(time_zone="America/New_York")
)
client = api_client.client_from_entry(hass, entry)
assert client._session is shared_session assert client._session is shared_session
assert client._base_url == "http://panel.lan:8443" assert client._base_url == "http://panel.lan:8443"
assert client._panel_token == "entry-token" assert client._panel_token == "entry-token"
assert client._household_timezone == "America/New_York"
#: A minimal hass stand-in carrying the configured time zone.
_HASS_UTC = types.SimpleNamespace(config=types.SimpleNamespace(time_zone="UTC"))
def test_client_from_entry_unconfigured_token_raises_at_construction( def test_client_from_entry_unconfigured_token_raises_at_construction(
@@ -419,12 +449,12 @@ def test_client_from_entry_unconfigured_token_raises_at_construction(
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(object(), entry) api_client.client_from_entry(_HASS_UTC, entry)
# Same for a whitespace-only stored token: whitespace is not a token. # Same for a whitespace-only stored token: whitespace is not a token.
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(object(), entry_ws) api_client.client_from_entry(_HASS_UTC, entry_ws)
async def test_client_logs_nothing() -> None: async def test_client_logs_nothing() -> None:
+479
View File
@@ -31,6 +31,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from api import routes_admin, routes_panel from api import routes_admin, routes_panel
from api import scheduler as scheduler_module
from api.database import _executor from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds from api.scheduler import MissedSweepScheduler, _delay_seconds
from tests.admin_jwt_harness import ( from tests.admin_jwt_harness import (
@@ -326,3 +327,481 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
now: datetime.datetime, target: datetime.time, expected: float now: datetime.datetime, target: datetime.time, expected: float
) -> None: ) -> None:
assert _delay_seconds(target, now) == expected assert _delay_seconds(target, now) == expected
# --- the Home Assistant-reported zone (task 6351b36d) ------------------------
#
# The deployed household never set ``timezone``, so the UTC API host marked
# morning quests overdue. The integration reports Home Assistant's
# ``time_zone`` on every panel request; while the admin has not chosen a
# zone the API adopts it (persisted, so the scheduler and the admin plane
# follow too), and an explicit admin choice still wins.
def _reporting_panel_headers(zone: str) -> dict[str, str]:
return {**_panel_headers(), "X-NestQuest-Timezone": zone}
async def _panel_pack_bag(
household: SimpleNamespace, zone: str
) -> tuple[str, dict]:
response = await household.client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
)
assert response.status_code == 200
body = response.json()
ada = next(
child
for child in body["children"]
if child["child_id"] == household.seed.ada.id
)
(instance,) = ada["instances"]
return body["today_iso"], instance
async def _stored_settings(household: SimpleNamespace) -> dict:
response = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
return response.json()
async def test_unset_zone_adopts_reported_zone_for_overdue(
household: SimpleNamespace,
) -> None:
"""09:59:59 EDT on a UTC host, zone never set: NOT overdue."""
household.clock["now"] = _utc(13, 59, 59)
today_iso, instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert today_iso == TODAY.isoformat()
assert instance["overdue"] is False
# Persisted as the automatic (not admin-chosen) household zone, so
# the admin plane and the missed-sweep scheduler read it too.
stored = await _stored_settings(household)
assert stored["timezone"] == HOUSEHOLD_ZONE
assert stored["timezone_configured"] is False
_today, admin_instance = await _pack_bag(household, "admin")
assert admin_instance["overdue"] is False
async def test_unset_zone_adopts_reported_zone_for_today(
household: SimpleNamespace,
) -> None:
"""23:30 EDT is tomorrow in UTC; the household day is still today."""
household.clock["now"] = _utc(3, 30, day=27)
today_iso, _instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert today_iso == TODAY.isoformat()
async def test_reported_zone_completion_is_on_time(
household: SimpleNamespace,
) -> None:
"""A 09:30 EDT tap with the zone never set is on time."""
household.clock["now"] = _utc(13, 30)
seed = household.seed
response = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
json={"actor_child_id": seed.ada.id},
)
assert response.status_code == 200
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is True
async def test_reported_zone_follows_home_assistant_changes(
household: SimpleNamespace,
) -> None:
"""An automatically adopted zone tracks a later Home Assistant change."""
await _panel_pack_bag(household, "America/Chicago")
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
async def _adopt_new_york(household: SimpleNamespace) -> None:
"""Prove automatic adoption first, so later assertions can't pass
on code that ignores the reported zone altogether."""
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
stored = await _stored_settings(household)
assert stored["timezone"] == HOUSEHOLD_ZONE
assert stored["timezone_configured"] is False
@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""])
async def test_admin_chosen_zone_wins_over_reported_zone(
household: SimpleNamespace, chosen: str
) -> None:
await _adopt_new_york(household)
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": chosen, "timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 200
# A conflicting report is no longer adopted.
await _panel_pack_bag(household, "America/Chicago")
stored = await _stored_settings(household)
assert stored["timezone"] == chosen
assert stored["timezone_configured"] is True
async def test_invalid_reported_zone_is_ignored(
household: SimpleNamespace,
) -> None:
await _adopt_new_york(household)
household.clock["now"] = _utc(13, 59, 59)
_today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons")
# Still read in the adopted zone: 09:59:59 EDT is not overdue.
assert instance["overdue"] is False
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
# --- the scheduler follows an adopted zone (task 6351b36d) -------------------
#
# On a UTC host with no stored zone the scheduler first plans for UTC
# midnight; a zone adopted while it sleeps must move the sweep to the
# household's midnight rather than sweep at 20:00 EDT.
#: 13:00 UTC (09:00 EDT) to UTC midnight.
_UTC_MIDNIGHT_DELAY = 11 * 3600
#: 13:00 UTC (09:00 EDT) to New York midnight (04:00 UTC).
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping(
temp_db_path: str,
) -> None:
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
response = await client.get(
"/api/v1/panel/snapshot",
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
)
assert response.status_code == 200
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 2), timeout=5
)
finally:
await scheduler.stop()
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_scheduler_rechecks_settings_before_sweeping(
temp_db_path: str, monkeypatch
) -> None:
"""A zone stored without a wake-up still stops the stale sweep."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
# The UTC rollover "arrives" after the zone was stored
# behind the scheduler's back.
await _settings_store.adopt_reported_timezone(
database, HOUSEHOLD_ZONE
)
return
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
finally:
await database.close()
# --- adoption is serialised with planning and sweeping (TZ-003) --------------
#
# An adoption racing the loop's own settings reads must never leave a
# stale plan behind. Each test holds the loop at one of its reads and
# issues a real panel adoption there; when the scheduler's settings lock
# is held at that point the adoption provably cannot commit until the
# loop moves on, otherwise it is awaited to commit inside the window.
def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
"""Run ``during(settings)`` inside the scheduler task's ``nth`` read."""
real_load = _settings_store.load_settings
reads = {"count": 0}
async def load_settings(database):
settings = await real_load(database)
if asyncio.current_task() is scheduler._task:
reads["count"] += 1
if reads["count"] == nth:
await during(settings)
return settings
monkeypatch.setattr(
scheduler_module.core_settings_store, "load_settings", load_settings
)
return real_load
async def _adopt_via(client, zone: str) -> None:
response = await client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
)
assert response.status_code == 200
async def _adopt_inside_read(client, scheduler, zone: str) -> asyncio.Task:
"""Start a panel adoption of ``zone``; let it commit unless serialised."""
adoption = asyncio.create_task(
client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
)
)
lock = getattr(scheduler, "settings_lock", None)
if lock is None or not lock.locked():
await adoption
return adoption
async def test_scheduler_follows_successive_zone_changes(
temp_db_path: str, monkeypatch
) -> None:
"""New York -> UTC -> New York while the re-plan read is in flight."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
async def sleep_stub(seconds: float) -> None:
async with slept:
delays.append(seconds)
slept.notify_all()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
await _adopt_via(client, HOUSEHOLD_ZONE)
adoptions: list[asyncio.Task] = []
async def back_to_new_york(_settings) -> None:
adoptions.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE)
)
# Read 1 plans New York; read 2 is the re-plan after UTC.
_hold_scheduler_read(monkeypatch, scheduler, 2, back_to_new_york)
scheduler.start()
try:
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _adopt_via(client, "UTC")
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 3), timeout=5
)
(adoption,) = adoptions
assert (await adoption).status_code == 200
finally:
await scheduler.stop()
# The in-flight read planned UTC; the New York adoption that followed
# it woke the wait, so the scheduler sleeps on New York midnight.
assert delays == [
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
temp_db_path: str, monkeypatch
) -> None:
"""No sweep runs once an adoption has replaced its plan."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
return # the UTC rollover arrives
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
adoptions: list[asyncio.Task] = []
async def adopt_new_york(_settings) -> None:
adoptions.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE)
)
# Read 1 plans UTC; read 2 is the pre-sweep re-check.
real_load = _hold_scheduler_read(
monkeypatch, scheduler, 2, adopt_new_york
)
swept_under: list[str] = []
async def sweep_stub(database, *, today):
swept_under.append((await real_load(database)).timezone)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
(adoption,) = adoptions
assert (await adoption).status_code == 200
finally:
await scheduler.stop()
# The sweep ran for the UTC plan with UTC still stored, never after
# New York had been adopted; the next plan is New York midnight.
assert swept_under == [""]
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
]
# --- the wait's child tasks (TZ-004) ------------------------------------------
async def test_failed_sleep_is_retried_not_swept(
temp_db_path: str, monkeypatch
) -> None:
"""A sleep that raises backs off and re-plans; nothing is swept."""
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sweeps: list[datetime.date] = []
async def sweep_stub(_database, *, today):
sweeps.append(today)
return []
monkeypatch.setattr(
scheduler_module.core_sweep, "run_missed_sweep", sweep_stub
)
delays: list[float] = []
replanned = asyncio.Event()
async def sleep_stub(seconds: float) -> None:
delays.append(seconds)
if len(delays) == 1:
raise RuntimeError("timer failed")
if len(delays) == 2:
return # the retry backoff
replanned.set()
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
finally:
await scheduler.stop()
assert sweeps == []
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
scheduler_module._RETRY_BACKOFF_SECONDS,
pytest.approx(_UTC_MIDNIGHT_DELAY),
]
finally:
await database.close()
async def test_stop_waits_for_the_sleep_to_finish_cancelling(
temp_db_path: str,
) -> None:
database = _core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path)
try:
await _core_migrations.apply_migrations(database)
sleeping = asyncio.Event()
sleepers: list[asyncio.Task] = []
cleaned_up: list[bool] = []
async def sleep_stub(_seconds: float) -> None:
sleepers.append(asyncio.current_task())
sleeping.set()
try:
await asyncio.Event().wait()
finally:
# Cancellation cleanup that takes a loop turn.
await asyncio.sleep(0)
cleaned_up.append(True)
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
scheduler.start()
try:
await asyncio.wait_for(sleeping.wait(), timeout=5)
finally:
await scheduler.stop()
(sleeper,) = sleepers
assert sleeper.done()
assert cleaned_up == [True]
finally:
await database.close()
+2
View File
@@ -686,6 +686,8 @@ async def test_coordinator_configured_token_builds_real_client(
hass, entry, session_factory=_session_factory hass, entry, session_factory=_session_factory
) )
assert isinstance(real_client, NestQuestApiClient) assert isinstance(real_client, NestQuestApiClient)
# Home Assistant's zone is reported to the API (task 6351b36d).
assert real_client._household_timezone == hass.config.time_zone
set_coordinator_client(entry, real_client) set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
+18
View File
@@ -351,6 +351,24 @@ async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None
assert call["json"] is None assert call["json"] is None
async def test_stream_events_reports_household_timezone() -> None:
"""The SSE GET carries Home Assistant's zone beside the Bearer token
(task 6351b36d), like the snapshot and completion requests."""
frame = _frame_text(*DOCUMENTED_FRAMES[0])
transport = _StreamTransport(_StreamingResponse(200, [frame]))
client = NestQuestApiClient(
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
frames = [frame async for frame in client.stream_events()]
assert frames == [DOCUMENTED_FRAMES[0]]
assert transport.calls[0]["headers"] == {
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
}
async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None: async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None:
"""Chunk-split lines parse, and multi-line data joins per SSE rules.""" """Chunk-split lines parse, and multi-line data joins per SSE rules."""
first_type, first_payload = DOCUMENTED_FRAMES[0] first_type, first_payload = DOCUMENTED_FRAMES[0]