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:
2026-09-28 22:05:16 -04:00
co-authored by Claude Opus 5.5
parent 07f2eec3bf
commit 09d6a55e04
9 changed files with 285 additions and 45 deletions
+38 -10
View File
@@ -9,8 +9,9 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``, NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
stored household ``timezone`` setting — the API host may run UTC while household ``timezone`` — the API host may run UTC while the household
the household does not), calls the pure does not; until the admin chooses a zone it is the Home Assistant zone
the integration reports in :data:`PANEL_TIMEZONE_HEADER`), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's :func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the ``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -53,6 +54,7 @@ from __future__ import annotations
import datetime import datetime
import importlib import importlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -62,6 +64,7 @@ from api.database import DatabaseState
from api.dependencies import require_panel_token from api.dependencies import require_panel_token
from api.nestquest_core import ( from api.nestquest_core import (
core_completion, core_completion,
core_const,
core_events, core_events,
core_settings_store, core_settings_store,
core_snapshot, core_snapshot,
@@ -69,6 +72,12 @@ from api.nestquest_core import (
from api import transitions as api_transitions from api import transitions as api_transitions
from api.sse import transition_event_stream from api.sse import transition_event_stream
LOGGER = logging.getLogger(__name__)
#: The request header the integration reports Home Assistant's
#: configured IANA ``time_zone`` in.
PANEL_TIMEZONE_HEADER = core_const.PANEL_TIMEZONE_HEADER
#: The instance DAO, imported on the app's core copy for the #: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route. #: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances") _dao_instances = importlib.import_module("nestquest_core.dao_instances")
@@ -179,16 +188,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,
+25 -4
View File
@@ -6,6 +6,9 @@ speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete`` household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the (completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request. static panel service token as a Bearer credential on every request.
Every request also reports Home Assistant's configured ``time_zone`` in
the ``X-NestQuest-Timezone`` header, so the API (which may run UTC)
reads the household's clock until the admin chooses a zone there.
Transport-injected by construction: the constructor takes the base URL, Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the the panel token, and the HTTP session to use. The session speaks the
@@ -54,6 +57,7 @@ from .const import (
CONF_PANEL_TOKEN, CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL, DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN, DEFAULT_PANEL_TOKEN,
PANEL_TIMEZONE_HEADER,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -156,7 +160,8 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry. """Build the production client for a config entry.
Reads the configured base URL and panel token off the entry Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`) and hands the client Home Assistant's (:func:`resolve_api_config`), reports ``hass.config.time_zone`` as
the household zone, and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection .async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose: pool. The helper import is deferred into this function on purpose:
@@ -168,7 +173,10 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
base_url, panel_token = resolve_api_config(entry) base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient( return NestQuestApiClient(
base_url, panel_token, async_get_clientsession(hass) base_url,
panel_token,
async_get_clientsession(hass),
household_timezone=hass.config.time_zone,
) )
@@ -189,9 +197,14 @@ class NestQuestApiClient:
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
*, *,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
household_timezone: str | None = None,
) -> None: ) -> None:
"""Store the connection settings; the session is injected. """Store the connection settings; the session is injected.
``household_timezone`` is Home Assistant's configured IANA
``time_zone``; when given it rides every request in the
``X-NestQuest-Timezone`` header.
``session`` is an aiohttp ``ClientSession`` in production and a ``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s) stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built URL and ``panel_token`` must be non-empty — a client built
@@ -216,6 +229,14 @@ class NestQuestApiClient:
self._panel_token = panel_token self._panel_token = panel_token
self._session = session self._session = session
self._timeout_seconds = float(timeout_seconds) self._timeout_seconds = float(timeout_seconds)
self._household_timezone = household_timezone
def _headers(self) -> dict[str, str]:
"""The Bearer credential plus the reported household zone."""
headers = {"Authorization": f"Bearer {self._panel_token}"}
if self._household_timezone:
headers[PANEL_TIMEZONE_HEADER] = self._household_timezone
return headers
async def get_snapshot(self) -> dict[str, Any]: async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot. """Fetch and parse today's household snapshot.
@@ -275,7 +296,7 @@ class NestQuestApiClient:
cleanly and closes the underlying response in every path. cleanly and closes the underlying response in every path.
""" """
url = f"{self._base_url}{EVENTS_PATH}" url = f"{self._base_url}{EVENTS_PATH}"
headers = {"Authorization": f"Bearer {self._panel_token}"} headers = self._headers()
# Opening the response gets ONE request timeout, exactly like # Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a # ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream. # body-wide timeout would kill a healthy long-lived stream.
@@ -379,7 +400,7 @@ class NestQuestApiClient:
raises typed too, carrying the status it arrived with. raises typed too, carrying the status it arrived with.
""" """
url = f"{self._base_url}{path}" url = f"{self._base_url}{path}"
headers = {"Authorization": f"Bearer {self._panel_token}"} headers = self._headers()
try: try:
async with asyncio.timeout(self._timeout_seconds): async with asyncio.timeout(self._timeout_seconds):
async with self._session.request( async with self._session.request(
+4 -1
View File
@@ -372,7 +372,10 @@ def coordinator_client_from_entry(
return _UnconfiguredApiClient() return _UnconfiguredApiClient()
if session_factory is not None: if session_factory is not None:
return NestQuestApiClient( return NestQuestApiClient(
base_url, panel_token, session_factory(hass) base_url,
panel_token,
session_factory(hass),
household_timezone=hass.config.time_zone,
) )
from custom_components.nestquest.api_client import client_from_entry from custom_components.nestquest.api_client import client_from_entry
+14 -7
View File
@@ -113,18 +113,25 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled" CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API # The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string keeps the historical # service reads its clock in. The empty string is the API host's own
# behaviour — the API host's own local time — so an existing install is # local time. While the admin has not chosen a zone
# unchanged until the owner sets it. The integration itself always uses # (``timezone_configured`` is ``False``) the API adopts the zone the
# Home Assistant's configured ``time_zone`` and ignores this field. # integration reports from Home Assistant's configured ``time_zone``
# (:data:`PANEL_TIMEZONE_HEADER`), so the default is the household's
# zone without any manual setup. The integration itself always uses
# Home Assistant's ``time_zone`` and ignores this field.
CONF_TIMEZONE = "timezone" CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = "" DEFAULT_TIMEZONE = ""
# Whether the admin has made an explicit ``timezone`` choice. ``False`` # Whether the admin has made an explicit ``timezone`` choice. ``False``
# (a fresh install) lets the admin UI auto-set the browser's zone once; # (a fresh install) lets the API follow Home Assistant's reported zone
# ``True`` pins the stored value — including an explicitly-chosen empty # and the admin UI auto-set the browser's zone once while none is
# ``timezone`` (the API host's local time) — so it is never auto-set again. # stored; ``True`` pins the stored value — including an explicitly-chosen
# empty ``timezone`` (the API host's local time) — so neither changes it.
CONF_TIMEZONE_CONFIGURED = "timezone_configured" CONF_TIMEZONE_CONFIGURED = "timezone_configured"
DEFAULT_TIMEZONE_CONFIGURED = False DEFAULT_TIMEZONE_CONFIGURED = False
# The panel-plane request header carrying Home Assistant's configured
# IANA ``time_zone`` from the integration to the API service.
PANEL_TIMEZONE_HEADER = "X-NestQuest-Timezone"
# Panel-plane API client (Feature 18): the integration reaches the # Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database. # NestQuest API service (Feature 16) over HTTP instead of the database.
+7 -5
View File
@@ -327,12 +327,14 @@ class NestQuestSettings:
def household_now(self, instant: datetime.datetime) -> datetime.datetime: def household_now(self, instant: datetime.datetime) -> datetime.datetime:
"""Return the aware ``instant`` expressed in the household's zone. """Return the aware ``instant`` expressed in the household's zone.
The API service has no Home Assistant ``time_zone``, so its one The API service does not run inside Home Assistant, so its one
clock read (an aware host-local ``now``) is converted here: with clock read (an aware host-local ``now``) is converted here: with
:attr:`timezone` set the SAME instant is re-expressed in that :attr:`timezone` set — the admin's choice, or else the zone the
zone, so ``.date()`` and ``.time()`` are the household's wall integration reports from Home Assistant — the SAME instant is
clock even when the host runs UTC. With :attr:`timezone` empty re-expressed in that zone, so ``.date()`` and ``.time()`` are the
the instant is returned unchanged (the host's local time). household's wall clock even when the host runs UTC. With
:attr:`timezone` empty the instant is returned unchanged (the
host's local time).
""" """
if not self.timezone: if not self.timezone:
return instant return instant
@@ -36,7 +36,8 @@ after the first update:
this field existed simply lacks it and resolves to ``""``, this field existed simply lacks it and resolves to ``""``,
- ``timezone_configured`` — a real boolean: ``True`` once the admin - ``timezone_configured`` — a real boolean: ``True`` once the admin
explicitly chose ``timezone`` (including the empty host-local value), explicitly chose ``timezone`` (including the empty host-local value),
so the admin UI never auto-sets it again. A document stored before so neither Home Assistant's reported zone
(:func:`adopt_reported_timezone`) nor the admin UI changes it again. A document stored before
this field existed lacks it and resolves to ``False``. this field existed lacks it and resolves to ``False``.
Only values whose core validators ACCEPTED are ever written, so a Only values whose core validators ACCEPTED are ever written, so a
@@ -200,18 +201,49 @@ async def update_settings(
) )
async with _settings_lock(database): async with _settings_lock(database):
current = await load_settings(database) current = await load_settings(database)
document: dict[str, Any] = { return await _write_merged(database, current, changes)
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items(): async def adopt_reported_timezone(
default, validator = _RESOLUTIONS[name] database: NestQuestDatabase, zone: str
try: ) -> NestQuestSettings:
document[name] = validator( """Adopt Home Assistant's reported ``zone`` unless the admin chose one.
default if raw_value is None else raw_value
) While ``timezone_configured`` is ``False`` the stored ``timezone``
except ValueError as error: follows the zone the integration reports, so the API's clock is the
raise ValueError(_named(name, error)) from error household's without any manual setup; ``timezone_configured`` stays
await MetaStateDao(database).set( ``False`` (the value is automatic, not an admin choice). Once the
SETTINGS_META_KEY, json.dumps(document, sort_keys=True) admin has chosen — including an explicitly-chosen empty zone — the
) stored settings are returned untouched. The check and the write
share the settings lock, so a concurrent admin choice is never
overwritten. An invalid ``zone`` raises a ``ValueError`` naming
``timezone`` and writes nothing.
"""
async with _settings_lock(database):
current = await load_settings(database)
if current.timezone_configured or current.timezone == zone:
return current
return await _write_merged(database, current, {"timezone": zone})
async def _write_merged(
database: NestQuestDatabase,
current: NestQuestSettings,
changes: Mapping[str, Any],
) -> NestQuestSettings:
"""Validate ``changes``, merge onto ``current``, persist; lock held."""
document: dict[str, Any] = {
field: getattr(current, field) for field in _FIELD_NAMES
}
for name, raw_value in changes.items():
default, validator = _RESOLUTIONS[name]
try:
document[name] = validator(
default if raw_value is None else raw_value
)
except ValueError as error:
raise ValueError(_named(name, error)) from error
await MetaStateDao(database).set(
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
)
return NestQuestSettings(**document) return NestQuestSettings(**document)
+33 -3
View File
@@ -196,6 +196,27 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}" assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_household_timezone_rides_every_request_header() -> None:
"""Home Assistant's zone is reported so the API reads the household
clock (task 6351b36d); it is sent only when the client has one."""
transport = StubTransport(
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
StubResponse(200, '{"status": "done"}'),
)
client = NestQuestApiClient(
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
await client.get_snapshot()
await client.complete_instance(42, 3)
for call in transport.calls:
assert call["headers"] == {
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
}
async def test_base_url_trailing_slash_is_normalized() -> None: async def test_base_url_trailing_slash_is_normalized() -> None:
"""A base URL with a trailing slash must not double the path slash.""" """A base URL with a trailing slash must not double the path slash."""
transport = StubTransport(StubResponse(200, "{}")) transport = StubTransport(StubResponse(200, "{}"))
@@ -392,11 +413,20 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
} }
) )
client = api_client.client_from_entry(object(), entry) hass = types.SimpleNamespace(
config=types.SimpleNamespace(time_zone="America/New_York")
)
client = api_client.client_from_entry(hass, entry)
assert client._session is shared_session assert client._session is shared_session
assert client._base_url == "http://panel.lan:8443" assert client._base_url == "http://panel.lan:8443"
assert client._panel_token == "entry-token" assert client._panel_token == "entry-token"
assert client._household_timezone == "America/New_York"
#: A minimal hass stand-in carrying the configured time zone.
_HASS_UTC = types.SimpleNamespace(config=types.SimpleNamespace(time_zone="UTC"))
def test_client_from_entry_unconfigured_token_raises_at_construction( def test_client_from_entry_unconfigured_token_raises_at_construction(
@@ -419,12 +449,12 @@ def test_client_from_entry_unconfigured_token_raises_at_construction(
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(object(), entry) api_client.client_from_entry(_HASS_UTC, entry)
# Same for a whitespace-only stored token: whitespace is not a token. # Same for a whitespace-only stored token: whitespace is not a token.
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(object(), entry_ws) api_client.client_from_entry(_HASS_UTC, entry_ws)
async def test_client_logs_nothing() -> None: async def test_client_logs_nothing() -> None:
+115
View File
@@ -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"] == ""
+2
View File
@@ -686,6 +686,8 @@ async def test_coordinator_configured_token_builds_real_client(
hass, entry, session_factory=_session_factory hass, entry, session_factory=_session_factory
) )
assert isinstance(real_client, NestQuestApiClient) assert isinstance(real_client, NestQuestApiClient)
# Home Assistant's zone is reported to the API (task 6351b36d).
assert real_client._household_timezone == hass.config.time_zone
set_coordinator_client(entry, real_client) set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True