diff --git a/api/routes_panel.py b/api/routes_panel.py index 200c431..872dba8 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,35 @@ 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 scheduler and the admin plane follow it too. 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: + try: + settings = await core_settings_store.adopt_reported_timezone( + database, reported + ) + 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 +238,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 +325,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/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..586dbbc 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 @@ -200,18 +201,49 @@ async def update_settings( ) 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/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..80e5404 100644 --- a/tests/test_api_household_clock.py +++ b/tests/test_api_household_clock.py @@ -326,3 +326,118 @@ 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 + + +@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""]) +async def test_admin_chosen_zone_wins_over_reported_zone( + household: SimpleNamespace, chosen: str +) -> None: + response = await household.client.patch( + "/api/v1/admin/settings", + json={"timezone": chosen, "timezone_configured": True}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + await _panel_pack_bag(household, HOUSEHOLD_ZONE) + 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: + household.clock["now"] = _utc(13, 59, 59) + _today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons") + # Host-local (UTC) behaviour, unchanged store. + assert instance["overdue"] is True + assert (await _stored_settings(household))["timezone"] == "" 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