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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user