diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5f77c7a..30b8fbc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,67 @@ # NestQuest Release Notes +## Version 0.9.6 — 2026-09-28 + +### Scope + +Patch release carrying the household time-zone fix onto `main` (PR #275, task +6351b36d "Time zone errors"), plus its release-review correction (PR #277, task +45243478). + +### Behaviour + +- The Home Assistant integration now reports `hass.config.time_zone` to the API + in an `X-NestQuest-Timezone` header on every panel request. +- While the API's `timezone` setting has never been explicitly chosen + (`timezone_configured` false), the API adopts and persists the reported zone + (under the settings lock), so the panel's overdue/today calculation, the + admin plane and the missed-sweep scheduler are all household-local even when + the API host runs UTC. No manual setup is required. +- The missed-sweep scheduler re-plans immediately when the adopted zone changes + while it is sleeping, and re-reads the stored zone before sweeping, so it no + longer fires at the old (UTC) rollover. +- An admin-chosen zone — including an explicit choice of empty (API-host local) + — still wins over the reported zone. A timezone-only admin update is recorded + as an explicit choice (`timezone_configured` true); an explicitly supplied + `timezone_configured` value in the same update is honored as given. An invalid + reported zone is logged and ignored; the panel keeps serving on the stored + settings. + +### Requirements + +- Home Assistant floor 2024.6.0 (unchanged). + +### Breaking changes + +- None. The database schema is unchanged at version 9: no migration and no + database backup is required for this release. +- Deployment note: this fix needs BOTH the API service and the HA integration + upgraded. The integration supplies the zone and the API adopts it; upgrading + only one side leaves the previous behaviour. An install that sets or clears + its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone + stored by an older release. + +### Known limitations + +- A zone stored by a release before 0.9.6 without the explicit-choice marker + (for example through the previously documented + `PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from + an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone + over it once. If a zone different from Home Assistant's is intended, re-save + it in the admin Preferences, which now records the explicit choice. +- If `timezone` is empty and `timezone_configured` is true (an explicit + "host local" choice), that choice is respected and the API host's zone (UTC + on the standard deployment) still applies until an admin selects a zone in + the admin Preferences. +- A change to Home Assistant's own `time_zone` takes effect after the + integration reloads or Home Assistant restarts. + +### Rollback + +- No schema change: rolling back to 0.9.5 requires no database restore. The API + and the integration may be rolled back independently; rolling back only the + integration restores the previous stored-setting-only behaviour. + ## Version 0.9.5 — 2026-09-27 ### Scope diff --git a/api/routes_panel.py b/api/routes_panel.py index 200c431..16676e1 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -9,8 +9,9 @@ this router inherits the check). 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``, resolves ONE timezone-aware ``now`` (the host clock re-expressed in the -stored household ``timezone`` setting — the API host may run UTC while -the household does not), calls the pure +household ``timezone`` — the API host may run UTC while the household +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 instances through :func:`nestquest_core.snapshot.instance_payload` with ``include_missed=False`` (D-009: missed instances are OMITTED from the @@ -53,6 +54,7 @@ from __future__ import annotations import datetime import importlib +import logging from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse @@ -62,6 +64,7 @@ from api.database import DatabaseState from api.dependencies import require_panel_token from api.nestquest_core import ( core_completion, + core_const, core_events, core_settings_store, core_snapshot, @@ -69,6 +72,12 @@ from api.nestquest_core import ( from api import transitions as api_transitions 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 #: child-mismatch guard on the complete route. _dao_instances = importlib.import_module("nestquest_core.dao_instances") @@ -179,16 +188,42 @@ def _local_now() -> datetime.datetime: async def _household_clock( - database: object, + database: object, request: Request ) -> 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 - :func:`_local_now` read is re-expressed in the stored settings' - ``timezone`` (:meth:`NestQuestSettings.household_now`); an unset - zone keeps the host's local time. + :func:`_local_now` read is re-expressed in the settings' ``timezone`` + (:meth:`NestQuestSettings.household_now`). The integration reports + 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()) @@ -210,7 +245,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse: :class:`PanelSnapshotResponse`. """ 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( state.database, settings, now ) @@ -297,7 +332,7 @@ async def panel_complete_instance( detail="Quest instance not found for this child", ) - _settings, now = await _household_clock(database) + _settings, now = await _household_clock(database, request) try: result = await core_completion.complete_instance( database, diff --git a/api/scheduler.py b/api/scheduler.py index 30498ea..e755a7c 100644 --- a/api/scheduler.py +++ b/api/scheduler.py @@ -13,6 +13,27 @@ local date when unset) and publishes each returned ``nestquest_quest_missed`` event on the app's ONE transition publisher (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 ``[watermark, today)`` window, the no-completion-event rule): this 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._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: """Start the background loop (idempotent: a running one stays).""" @@ -142,6 +171,20 @@ class MissedSweepScheduler: pass 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 def running(self) -> bool: """Whether the background loop task is alive.""" @@ -151,12 +194,18 @@ class MissedSweepScheduler: """Schedule → sweep → repeat, forever (until cancelled).""" while True: try: - settings = await core_settings_store.load_settings( - self._database - ) - rollover = datetime.time.fromisoformat( - settings.day_rollover_time - ) + # Under the lock no adoption can commit between this read + # and the recorded plan, so any later one wakes the wait. + async with self.settings_lock: + self._wake.clear() + 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: # Not silent: the failure is logged and retried after # the backoff, so one bad settings document or a @@ -178,15 +227,30 @@ class MissedSweepScheduler: settings.day_rollover_time, delay, ) - await self._sleep(delay) try: - await self._run_once( - settings.household_now(self._clock()).date() - ) + if not await self._sleep_unless_woken(delay): + 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: - # Not silent either: the sweep error is logged and the - # loop backs off before the next cycle, whose watermark - # re-read makes an already-swept day a no-op. + # Not silent either: a failed wait or sweep is logged and + # the loop backs off before the next cycle, whose + # watermark re-read makes an already-swept day a no-op. LOGGER.exception( "NestQuest scheduled missed sweep failed; retrying in " "%gs", @@ -194,6 +258,29 @@ class MissedSweepScheduler: ) 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: """Run ONE sweep for ``today`` and publish what it built. diff --git a/custom_components/nestquest/api_client.py b/custom_components/nestquest/api_client.py index 7dbf157..b258be5 100644 --- a/custom_components/nestquest/api_client.py +++ b/custom_components/nestquest/api_client.py @@ -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`` (completion as the tapped child profile) — authenticated with the 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, the panel token, and the HTTP session to use. The session speaks the @@ -54,6 +57,7 @@ from .const import ( CONF_PANEL_TOKEN, DEFAULT_API_BASE_URL, DEFAULT_PANEL_TOKEN, + PANEL_TIMEZONE_HEADER, ) if TYPE_CHECKING: @@ -156,7 +160,8 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient: """Build the production client for a config 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 .async_get_clientsession``) so panel calls ride HA's one connection 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) 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, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + household_timezone: str | None = None, ) -> None: """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 stub transport in tests. ``base_url`` must be a full http(s) URL and ``panel_token`` must be non-empty — a client built @@ -216,6 +229,14 @@ class NestQuestApiClient: self._panel_token = panel_token self._session = session 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]: """Fetch and parse today's household snapshot. @@ -275,7 +296,7 @@ class NestQuestApiClient: cleanly and closes the underlying response in every 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 # ``_request_json``; the read loop below runs WITHOUT one — a # 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. """ url = f"{self._base_url}{path}" - headers = {"Authorization": f"Bearer {self._panel_token}"} + headers = self._headers() try: async with asyncio.timeout(self._timeout_seconds): async with self._session.request( diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 22cbb24..88bebf6 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -372,7 +372,10 @@ def coordinator_client_from_entry( return _UnconfiguredApiClient() if session_factory is not None: 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 diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index e1b1625..96f51af 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -113,18 +113,25 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" CONF_CELEBRATION_ENABLED = "celebration_enabled" DEFAULT_AUTOMATION_ENABLED = True # The household's IANA time zone (e.g. ``America/New_York``) the API -# service reads its clock in. The empty string keeps the historical -# behaviour — the API host's own local time — so an existing install is -# unchanged until the owner sets it. The integration itself always uses -# Home Assistant's configured ``time_zone`` and ignores this field. +# service reads its clock in. The empty string is the API host's own +# local time. While the admin has not chosen a zone +# (``timezone_configured`` is ``False``) the API adopts the zone the +# 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" DEFAULT_TIMEZONE = "" # Whether the admin has made an explicit ``timezone`` choice. ``False`` -# (a fresh install) lets the admin UI auto-set the browser's zone once; -# ``True`` pins the stored value — including an explicitly-chosen empty -# ``timezone`` (the API host's local time) — so it is never auto-set again. +# (a fresh install) lets the API follow Home Assistant's reported zone +# and the admin UI auto-set the browser's zone once while none is +# 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" 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 # NestQuest API service (Feature 16) over HTTP instead of the database. diff --git a/custom_components/nestquest/core/settings.py b/custom_components/nestquest/core/settings.py index 7ce0e1b..878a668 100644 --- a/custom_components/nestquest/core/settings.py +++ b/custom_components/nestquest/core/settings.py @@ -327,12 +327,14 @@ class NestQuestSettings: def household_now(self, instant: datetime.datetime) -> datetime.datetime: """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 - :attr:`timezone` set the SAME instant is re-expressed in that - zone, so ``.date()`` and ``.time()`` are the household's wall - clock even when the host runs UTC. With :attr:`timezone` empty - the instant is returned unchanged (the host's local time). + :attr:`timezone` set — the admin's choice, or else the zone the + integration reports from Home Assistant — the SAME instant is + re-expressed in that zone, so ``.date()`` and ``.time()`` are the + 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: return instant diff --git a/custom_components/nestquest/core/settings_store.py b/custom_components/nestquest/core/settings_store.py index b19800e..ac6cda8 100644 --- a/custom_components/nestquest/core/settings_store.py +++ b/custom_components/nestquest/core/settings_store.py @@ -36,7 +36,8 @@ after the first update: this field existed simply lacks it and resolves to ``""``, - ``timezone_configured`` — a real boolean: ``True`` once the admin 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``. Only values whose core validators ACCEPTED are ever written, so a @@ -183,6 +184,11 @@ async def update_settings( in one atomic upsert; the updated settings are returned. The whole read → merge → write span runs inside the per-database settings lock, so concurrent updates queue instead of interleaving. + + Supplying ``timezone`` without ``timezone_configured`` records the + zone as the admin's explicit choice (``timezone_configured`` is + persisted ``True`` in the same write); an explicitly supplied + ``timezone_configured`` is kept exactly as given. """ if not isinstance(changes, Mapping): raise ValueError( @@ -198,20 +204,55 @@ async def update_settings( raise ValueError( f"unknown settings field(s): {listed}; known fields: {known}" ) + if "timezone" in changes and "timezone_configured" not in changes: + # An admin-supplied zone is an explicit choice, so Home + # Assistant's reported zone must no longer replace it. + changes = {**changes, "timezone_configured": True} async with _settings_lock(database): current = await load_settings(database) - 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 await _write_merged(database, current, changes) + + +async def adopt_reported_timezone( + database: NestQuestDatabase, zone: str +) -> NestQuestSettings: + """Adopt Home Assistant's reported ``zone`` unless the admin chose one. + + While ``timezone_configured`` is ``False`` the stored ``timezone`` + follows the zone the integration reports, so the API's clock is the + household's without any manual setup; ``timezone_configured`` stays + ``False`` (the value is automatic, not an admin choice). Once the + 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) diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index 3ad819a..f20fcd1 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues", "requirements": [], - "version": "0.9.5" + "version": "0.9.6" } diff --git a/pyproject.toml b/pyproject.toml index e43c4c0..dc033c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.9.5" +version = "0.9.6" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 4e9d5a9..9dcc893 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -196,6 +196,27 @@ async def test_complete_instance_posts_body_and_headers() -> None: 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: """A base URL with a trailing slash must not double the path slash.""" 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._base_url == "http://panel.lan:8443" 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( @@ -419,12 +449,12 @@ def test_client_from_entry_unconfigured_token_raises_at_construction( assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" 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. entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) 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: diff --git a/tests/test_api_household_clock.py b/tests/test_api_household_clock.py index 613263d..1a94307 100644 --- a/tests/test_api_household_clock.py +++ b/tests/test_api_household_clock.py @@ -31,6 +31,7 @@ from zoneinfo import ZoneInfo import pytest from api import routes_admin, routes_panel +from api import scheduler as scheduler_module from api.database import _executor from api.scheduler import MissedSweepScheduler, _delay_seconds from tests.admin_jwt_harness import ( @@ -326,3 +327,519 @@ def test_delay_seconds_is_true_elapsed_time_across_dst( now: datetime.datetime, target: datetime.time, expected: float ) -> None: 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 + + +# --- a timezone-only admin update is an explicit choice (task 45243478) ------ + + +@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""]) +async def test_timezone_only_admin_update_is_an_explicit_choice( + household: SimpleNamespace, chosen: str +) -> None: + """``PATCH {"timezone": ...}`` alone marks the zone admin-chosen.""" + await _adopt_new_york(household) + response = await household.client.patch( + "/api/v1/admin/settings", + json={"timezone": chosen}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json()["timezone_configured"] is True + # 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_explicit_timezone_configured_false_is_respected( + household: SimpleNamespace, +) -> None: + """A supplied ``timezone_configured`` is persisted exactly as given.""" + response = await household.client.patch( + "/api/v1/admin/settings", + json={"timezone": "America/Chicago", "timezone_configured": False}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json()["timezone_configured"] is False + await _panel_pack_bag(household, HOUSEHOLD_ZONE) + assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE + + +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() diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index e09a8ab..9d9ee72 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -686,6 +686,8 @@ async def test_coordinator_configured_token_builds_real_client( hass, entry, session_factory=_session_factory ) 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) assert await async_setup_entry(hass, entry) is True diff --git a/tests/test_sse_subscription.py b/tests/test_sse_subscription.py index 0a5c26d..4b1552a 100644 --- a/tests/test_sse_subscription.py +++ b/tests/test_sse_subscription.py @@ -351,6 +351,24 @@ async def test_stream_events_yields_documented_frames_and_sends_bearer() -> 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: """Chunk-split lines parse, and multi-line data joins per SSE rules.""" first_type, first_payload = DOCUMENTED_FRAMES[0] diff --git a/uv.lock b/uv.lock index 7d8909a..5894754 100644 --- a/uv.lock +++ b/uv.lock @@ -373,7 +373,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.9.5" +version = "0.9.6" source = { virtual = "." } [package.dev-dependencies]