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>
This commit is contained in:
+38
-10
@@ -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,35 @@ 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 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())
|
return settings, settings.household_now(_local_now())
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +238,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 +325,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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -326,3 +326,118 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
@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"] == ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user