Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 cf118cf305 Serialise time zone adoption with the missed-sweep scheduler
The wake flag compared an adoption only against the plan the loop last
recorded, so New York -> UTC -> New York during the re-plan read left
the scheduler sleeping on UTC midnight; and an adoption committing
between the pre-sweep check and the sweep still let the stale sweep run.

`MissedSweepScheduler.settings_lock` now serialises the panel's adoption
(write plus `settings_changed`) with the loop's plan read and with its
pre-sweep re-read plus sweep.  It is never held across the wait, and the
loop holds nothing else while acquiring it, so a panel request is at
most delayed by one settings read or one sweep.

`_sleep_unless_woken` now cancels and awaits both child tasks, and
re-raises a failed sleep inside the loop's retry block, so the backoff
runs instead of a sweep and nothing outlives `stop()`.

Tests (all four fail on bcf387d): the successive-zone race, an adoption
issued during the pre-sweep check, a failing sleep, and stop() waiting
for the sleep's cancellation cleanup.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:43:40 -04:00
overseerandClaude Opus 5.5 bcf387d902 Re-plan the missed sweep when the panel adopts a time zone
The scheduler loaded settings once per cycle and slept on them until the
old rollover, so a zone adopted from Home Assistant after start-up left a
UTC host sweeping at UTC midnight (20:00 in New York), marking quests
missed hours early.

`MissedSweepScheduler.settings_changed` now wakes the sleeping loop when
the stored zone or rollover time differs from the one it planned with;
the panel's household clock calls it after adopting a reported zone.
Before sweeping, the loop re-reads the settings and re-plans instead if
they no longer match its plan.

Tests: the scheduler re-plans for New York midnight when a panel request
adopts the zone mid-sleep, and skips a stale sweep when the zone changed
without a wake-up.  The admin-precedence and invalid-header tests now
prove adoption first, and the SSE client test asserts the timezone header
rides beside the Authorization header.

Task: 6351b36d-0eef-4e7d-bf5a-96b754cbc883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:21:53 -04:00
overseerandClaude Opus 5.5 09d6a55e04 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>
2026-09-28 22:05:16 -04:00
24 changed files with 745 additions and 811 deletions
-108
View File
@@ -1,113 +1,5 @@
# NestQuest Release Notes
## Version 0.9.7 — 2026-09-29
### Scope
Patch release carrying the panel-local overdue correction onto `main` (PR #279,
task fd1093b5 "Tasks are still showing over due when the local time has not
passed").
### Behaviour
- The kids' Quest Log card now decides whether an open quest is OVERDUE from
the panel's OWN local clock (the wall time where the panel physically is)
compared with the quest's due time, instead of trusting the API snapshot's
`overdue` flag. A quest therefore cannot read OVERDUE before its due time has
passed on the panel, regardless of the API host's timezone, Home Assistant's
configured `time_zone`, or whether the 0.9.6 timezone changes have been
deployed. A quest is overdue at the due minute and after (`>=`); a quest with
no due time is never overdue.
- The API's `overdue` field is unchanged; it still serves the admin plane and
automations. This is a panel display correction only.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
### Known limitations
- The comparison uses only wall-clock time. If the API is unreachable across
local midnight and the coordinator serves its last-good snapshot, a quest
from the previous day with a later due time can briefly read as not overdue
until that time passes again. On the normal path the API rolls the household
day over and the missed sweep removes the previous day's open quests.
- The Quest Log header clock still formats in Home Assistant's configured
`time_zone`; if HA is set to a zone other than the household's, that header
remains wrong. Set Home Assistant's own `time_zone` correctly — the admin/API
timezone setting does not affect the header clock.
### Rollback
- No schema change: rolling back to 0.9.6 requires no database restore.
## Version 0.9.6 — 2026-09-28
### Scope
Patch release carrying the household time-zone fix onto `main` (PR #275, task
6351b36d "Time zone errors"), plus its release-review correction (PR #277, task
45243478).
### Behaviour
- The Home Assistant integration now reports `hass.config.time_zone` to the API
in an `X-NestQuest-Timezone` header on every panel request.
- While the API's `timezone` setting has never been explicitly chosen
(`timezone_configured` false), the API adopts and persists the reported zone
(under the settings lock), so the panel's overdue/today calculation, the
admin plane and the missed-sweep scheduler are all household-local even when
the API host runs UTC. No manual setup is required.
- The missed-sweep scheduler re-plans immediately when the adopted zone changes
while it is sleeping, and re-reads the stored zone before sweeping, so it no
longer fires at the old (UTC) rollover.
- An admin-chosen zone — including an explicit choice of empty (API-host local)
— still wins over the reported zone. A timezone-only admin update is recorded
as an explicit choice (`timezone_configured` true); an explicitly supplied
`timezone_configured` value in the same update is honored as given. An invalid
reported zone is logged and ignored; the panel keeps serving on the stored
settings.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. The database schema is unchanged at version 9: no migration and no
database backup is required for this release.
- Deployment note: this fix needs BOTH the API service and the HA integration
upgraded. The integration supplies the zone and the API adopts it; upgrading
only one side leaves the previous behaviour. An install that sets or clears
its zone on 0.9.6 or later keeps that choice; see Known limitations for a zone
stored by an older release.
### Known limitations
- A zone stored by a release before 0.9.6 without the explicit-choice marker
(for example through the previously documented
`PATCH /api/v1/admin/settings {"timezone": ...}`) is not distinguishable from
an automatically adopted value, so 0.9.6 adopts Home Assistant's reported zone
over it once. If a zone different from Home Assistant's is intended, re-save
it in the admin Preferences, which now records the explicit choice.
- If `timezone` is empty and `timezone_configured` is true (an explicit
"host local" choice), that choice is respected and the API host's zone (UTC
on the standard deployment) still applies until an admin selects a zone in
the admin Preferences.
- A change to Home Assistant's own `time_zone` takes effect after the
integration reloads or Home Assistant restarts.
### Rollback
- No schema change: rolling back to 0.9.5 requires no database restore. The API
and the integration may be rolled back independently; rolling back only the
integration restores the previous stored-setting-only behaviour.
## Version 0.9.5 — 2026-09-27
### Scope
+6
View File
@@ -19,6 +19,12 @@ export interface HouseholdSettings {
celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string;
/**
* True once the admin explicitly chose `timezone` (even ""); false on a
* fresh install, where the screen auto-sets the browser's zone. API-only:
* never shown in the form.
*/
timezone_configured: boolean;
}
/** Only the supplied fields are updated; the API answers with all of them. */
+70 -25
View File
@@ -23,6 +23,7 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
end_of_day_report_enabled: true,
celebration_enabled: false,
timezone: "America/Chicago",
timezone_configured: true,
};
function child(
@@ -507,7 +508,7 @@ describe("SettingsScreen — preferences", () => {
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } },
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London", timezone_configured: true } },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
@@ -558,7 +559,7 @@ describe("SettingsScreen — preferences", () => {
});
});
describe("SettingsScreen — no timezone auto-set", () => {
describe("SettingsScreen — timezone auto-set", () => {
let zoneSpy: ReturnType<typeof vi.spyOn> | null;
function mockBrowserZone(timeZone: string | undefined) {
@@ -574,8 +575,8 @@ describe("SettingsScreen — no timezone auto-set", () => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
// A fresh install: no zone stored.
settings = { ...DEFAULT_SETTINGS, timezone: "" };
// A fresh install: no zone, and the admin never chose one.
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false };
writeResponse = null;
zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
@@ -594,15 +595,25 @@ describe("SettingsScreen — no timezone auto-set", () => {
return screen.findByRole("form", { name: "Preferences" });
}
it("an empty timezone stays empty on load and nothing is written", async () => {
it("an unset timezone is set once, on load, to the browser's zone and shown", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
expect(settings.timezone).toBe("");
expect(field(form, "Timezone").value).toBe("America/New_York");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
]);
expect(settings.timezone).toBe("America/New_York");
expect(settings.timezone_configured).toBe(true);
// A later save of another field does not re-send the timezone.
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes()).toHaveLength(2);
expect(writes()[1].body).toEqual({ horizon_days: 21 });
});
it("StrictMode's doubled effects write nothing either", async () => {
it("StrictMode's doubled effects still auto-set exactly once", async () => {
mockBrowserZone("America/New_York");
render(
<StrictMode>
@@ -610,36 +621,70 @@ describe("SettingsScreen — no timezone auto-set", () => {
</StrictMode>,
);
const form = await screen.findByRole("form", { name: "Preferences" });
expect(field(form, "Timezone").value).toBe("America/New_York");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
]);
});
it("an admin clearing the timezone is not re-set by the browser zone, even after a reopen", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([
{ timezone: "America/New_York", timezone_configured: true },
{ timezone: "", timezone_configured: true },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("");
// Close and reopen: the screen re-fetches "" but the choice persisted.
cleanup();
const reopened = await renderForm();
expect(field(reopened, "Timezone").value).toBe("");
expect(writes()).toHaveLength(2);
});
it("an empty timezone the admin chose is never auto-set", async () => {
mockBrowserZone("America/New_York");
settings = { ...settings, timezone_configured: true };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
});
it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
it("a set timezone is shown and never overwritten by the browser zone", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]);
});
it("saving another field sends only that field, never a timezone", async () => {
mockBrowserZone("America/New_York");
it.each([
["nothing", undefined],
["an empty zone", ""],
["an unknown zone", "Etc/Unknown"],
["a non-IANA zone", "not a zone"],
])("detection returning %s leaves the field empty and sends nothing", async (_case, zone) => {
mockBrowserZone(zone);
const form = await renderForm();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
expect(settings.timezone).toBe("");
expect(field(form, "Timezone").value).toBe("");
expect(screen.queryByTestId("preferences-message")).toBeNull();
expect(writes()).toEqual([]);
});
it("an admin clearing the timezone saves exactly the empty zone", async () => {
it("a rejected auto-set shows why and leaves the field empty for manual entry", async () => {
mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
writeResponse = () => jsonResponse({ detail: "timezone is not known" }, 422);
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
expect(field(form, "Timezone").value).toBe("");
expect(field(form, "Timezone").disabled).toBe(false);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"The timezone could not be set to America/New_York: timezone is not known",
);
expect(writes()).toHaveLength(1);
});
});
+44 -4
View File
@@ -5,8 +5,9 @@
* household's child profiles. Every write refetches the list.
*
* Preferences: the household settings (planning horizon, day rollover,
* timezone, notifications). Save patches only the changed fields. The
* timezone is only ever set by the admin saving it here.
* timezone, notifications). Save patches only the changed fields. A timezone
* the admin never chose is set once, on load, to the browser's own zone;
* saving the timezone (even clearing it) marks it chosen, so it sticks.
*
* One write at a time across both sections.
*/
@@ -290,6 +291,19 @@ export const COMMON_TIMEZONES = [
"Pacific/Auckland",
];
/**
* The browser's own IANA zone — the location-derived default for an unset
* household timezone — or "" when it is missing or not a usable zone name.
*/
export function detectTimezone(): string {
try {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return typeof zone === "string" && zone !== "Etc/Unknown" && IANA_ZONE.test(zone) ? zone : "";
} catch {
return "";
}
}
/** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -328,6 +342,9 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
}
// An explicit choice — including "" (the API host's local time) — must
// persist, or the next load would auto-set the browser's zone over it.
if ("timezone" in changes) changes.timezone_configured = true;
if (Object.keys(changes).length === 0) return "No changes to save.";
return changes;
}
@@ -557,6 +574,8 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
} | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false);
// The never-chosen-timezone auto-set fires at most once per screen.
const timezoneAutoSet = useRef(false);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null);
@@ -601,8 +620,29 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
let cancelled = false;
setPreferences({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setPreferences({ kind: "ready", settings });
.then(async (settings) => {
if (cancelled) return;
// Never chosen: adopt the browser's zone before the form shows, so
// nothing the admin enters can be overwritten by it. An empty zone the
// admin chose (timezone_configured) is left alone.
const unchosen = settings.timezone === "" && !settings.timezone_configured;
const detected = unchosen ? detectTimezone() : "";
if (!detected || timezoneAutoSet.current) {
setPreferences({ kind: "ready", settings });
return;
}
timezoneAutoSet.current = true;
try {
const saved = await updateSettings({ timezone: detected, timezone_configured: true });
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
} catch (error) {
if (cancelled) return;
setPreferences({ kind: "ready", settings });
setPreferencesMessage({
kind: "error",
text: errorMessage(error, `The timezone could not be set to ${detected}`),
});
}
})
.catch((error: unknown) => {
if (cancelled) return;
+12 -7
View File
@@ -165,12 +165,13 @@ integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 wires the client to the API — the two
are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all eleven
- ``GET /settings`` returns the current effective settings: all twelve
documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times, the four enable
toggles, and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local; only an
admin's ``PATCH`` ever sets it) — the defaults on a fresh database.
toggles, the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local — and
``timezone_configured``, whether the admin explicitly chose that
zone) — the defaults on a fresh database.
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the
@@ -905,7 +906,7 @@ class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all ELEVEN documented fields, resolved (every absent stored field
— all TWELVE documented fields, resolved (every absent stored field
already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with.
"""
@@ -923,6 +924,9 @@ class AdminSettingsResponse(BaseModel):
#: The household IANA time zone the API reads its clock in; ``""``
#: means the API host's local time.
timezone: str
#: Whether the admin explicitly chose ``timezone`` (even ``""``);
#: ``False`` lets the admin UI auto-set the browser's zone once.
timezone_configured: bool
class AdminMissedSweepResponse(BaseModel):
@@ -2179,7 +2183,7 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the eleven documented fields even if the core
stays pinned to the twelve documented fields even if the core
dataclass later grows another one.
"""
return AdminSettingsResponse(
@@ -2194,6 +2198,7 @@ def _settings_response(
end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone,
timezone_configured=settings.timezone_configured,
)
@@ -2291,7 +2296,7 @@ async def admin_history_csv(
response_model=AdminSettingsResponse,
)
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all eleven documented fields).
"""Return the current effective settings (all twelve documented fields).
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
+45 -11
View File
@@ -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
household ``timezone`` the admin stored — 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,17 +188,42 @@ def _local_now() -> datetime.datetime:
async def _household_clock(
database: object,
database: object, request: Request
) -> tuple[object, datetime.datetime]:
"""Return the stored settings and ONE household-local clock read.
"""Return the settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the stored settings'
``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
comes from the stored settings ONLY — nothing the request carries
can change it.
:func:`_local_now` read is re-expressed in the settings' ``timezone``
(:meth:`NestQuestSettings.household_now`). The integration reports
Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`;
until the admin chooses a zone it is adopted and persisted
(:func:`nestquest_core.settings_store.adopt_reported_timezone`), so
the admin plane follows it too, and the already-sleeping missed-sweep
scheduler is told (:meth:`MissedSweepScheduler.settings_changed`) so
it re-plans for the household rollover — under the scheduler's
``settings_lock``, so the adoption never lands between its settings
check and its sweep. An invalid
reported zone is logged and ignored — the panel keeps serving on
the stored settings.
"""
settings = await core_settings_store.load_settings(database)
reported = request.headers.get(PANEL_TIMEZONE_HEADER, "").strip()
if reported:
scheduler = request.app.state.sweep_scheduler
try:
async with scheduler.settings_lock:
settings = await core_settings_store.adopt_reported_timezone(
database, reported
)
scheduler.settings_changed(settings)
except ValueError:
LOGGER.warning(
"Ignoring invalid %s header value %r",
PANEL_TIMEZONE_HEADER,
reported,
)
settings = await core_settings_store.load_settings(database)
else:
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now())
@@ -211,7 +245,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database)
settings, now = await _household_clock(state.database, request)
snapshot = await core_snapshot.build_snapshot(
state.database, settings, now
)
@@ -298,7 +332,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child",
)
_settings, now = await _household_clock(database)
_settings, now = await _household_clock(database, request)
try:
result = await core_completion.complete_instance(
database,
+10 -10
View File
@@ -13,7 +13,7 @@ local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through).
A household zone changed while the loop is asleep
A household zone adopted from Home Assistant while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would
otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed`
@@ -21,17 +21,17 @@ wakes the sleeping loop to re-plan whenever the zone or rollover time
it planned with has moved, and the loop re-reads the settings before
sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed settings write has
replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock`
Guarantee: no sweep runs for a plan a committed adoption has replaced.
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record
the plan, clear the wake flag) and across the pre-sweep re-read plus
the sweep itself. So a write commits either before a plan read
the sweep itself. So an adoption commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between
a read and what the loop does with it. The lock is never held across
the wait, and the loop takes nothing else before it, so a waiting
writer cannot deadlock: it is delayed at most by one settings
panel request cannot deadlock: it is delayed at most by one settings
read or one sweep.
All sweep policy lives in the core (the watermark, the
@@ -150,7 +150,7 @@ class MissedSweepScheduler:
self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event()
#: Serialises settings writes with planning and sweeping (see
#: Serialises settings adoption with planning and sweeping (see
#: the module docstring's guarantee).
self.settings_lock = asyncio.Lock()
@@ -174,8 +174,8 @@ class MissedSweepScheduler:
def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. an admin's zone
change), with :attr:`settings_lock` held across the write
Called with freshly stored settings (e.g. a zone adopted from
Home Assistant), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the
@@ -194,7 +194,7 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled)."""
while True:
try:
# Under the lock no settings write can commit between this read
# Under the lock no adoption can commit between this read
# and the recorded plan, so any later one wakes the wait.
async with self.settings_lock:
self._wake.clear()
@@ -233,7 +233,7 @@ class MissedSweepScheduler:
# Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The
# lock keeps a settings write from committing between this
# lock keeps an adoption from committing between this
# check and the sweep.
async with self.settings_lock:
settings = await core_settings_store.load_settings(
+21 -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``
(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,10 +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 every panel-plane request carries."""
return {"Authorization": f"Bearer {self._panel_token}"}
"""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.
+4 -1
View File
@@ -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
+15 -2
View File
@@ -114,11 +114,24 @@ 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 is the API host's own
# local time. Only an admin sets it (manually, from the admin app);
# nothing sets it automatically. The integration itself always uses
# 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 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.
@@ -49,6 +49,7 @@ from .const import (
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_TIMEZONE,
CONF_TIMEZONE_CONFIGURED,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
@@ -56,6 +57,7 @@ from .const import (
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_TIMEZONE,
DEFAULT_TIMEZONE_CONFIGURED,
LOGGER,
TIME_PATTERN,
)
@@ -241,6 +243,12 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
_resolve_bool,
),
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
(
"timezone_configured",
CONF_TIMEZONE_CONFIGURED,
DEFAULT_TIMEZONE_CONFIGURED,
_resolve_bool,
),
)
@@ -271,6 +279,10 @@ class NestQuestSettings:
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
timezone: str = DEFAULT_TIMEZONE
#: ``True`` once the admin explicitly chose :attr:`timezone` (even
#: the empty host-local value); ``False`` on a fresh install, where
#: the admin UI may auto-set the browser's zone.
timezone_configured: bool = DEFAULT_TIMEZONE_CONFIGURED
def __post_init__(self) -> None:
# Validate STRICTLY at construction so a hand-built instance
@@ -303,6 +315,7 @@ class NestQuestSettings:
"afternoon_reminder_enabled",
"end_of_day_report_enabled",
"celebration_enabled",
"timezone_configured",
):
value = getattr(self, name)
if not isinstance(value, bool):
@@ -33,8 +33,12 @@ after the first update:
booleans,
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
zone name such as ``America/New_York``. A document stored before
this field existed simply lacks it and resolves to ``""``. Only an
admin's explicit update ever writes it; nothing sets it automatically.
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 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
stored document always round-trips through the core's option builders.
@@ -176,7 +180,7 @@ async def update_settings(
update can never leave a half-applied document behind.
The validated changes are merged onto the current effective
settings and the merged (complete, eleven-field) document is persisted
settings and the merged (complete, twelve-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving.
@@ -200,6 +204,28 @@ async def update_settings(
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,
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.9.7"
"version": "0.9.5"
}
File diff suppressed because it is too large Load Diff
+4 -26
View File
@@ -36,6 +36,7 @@ type PanelInstance = {
window: string;
due_time: string | null;
state: string;
overdue: boolean;
completed_at: string | null;
on_time: boolean | null;
};
@@ -153,17 +154,6 @@ function formatWallClock(value: string | null): string | null {
return `${hour12}:${match[2]} ${suffix}`;
}
/** Minutes past midnight of a household ``HH:MM`` wall time, or null. */
function wallClockMinutes(value: string | null): number | null {
const match = value ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null;
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours > 23 || minutes > 59 ? null : hours * 60 + minutes;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -263,6 +253,7 @@ function toPanelInstance(value: unknown): PanelInstance | null {
window: asString(row.window).toLowerCase(),
due_time: asString(row.due_time) || null,
state,
overdue: row.overdue === true,
completed_at: asString(row.completed_at) || null,
on_time: row.on_time === true ? true : row.on_time === false ? false : null,
};
@@ -1758,20 +1749,6 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
/** Overdue by the panel's own clock: the browser runs on the
household's real local time, so an open quest turns overdue once its
``due_time`` wall time has arrived there — whatever zone the API or
Home Assistant computed the API's ``overdue`` flag in. With no due
time it is never overdue (the API's rule too). ``_now`` is reactive
and ticks every 60 s, so the line flips on the minute it falls due. */
private _isOverdue(instance: PanelInstance): boolean {
const due = wallClockMinutes(instance.due_time);
if (instance.state !== "open" || due === null) {
return false;
}
return this._now.getHours() * 60 + this._now.getMinutes() >= due;
}
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
}
@@ -1856,6 +1833,7 @@ export class NestQuestQuestLogCard extends LitElement {
continue;
}
instance.state = "completed";
instance.overdue = false;
instance.completed_at = stampedAt;
}
if (settled) {
@@ -2152,7 +2130,7 @@ export class NestQuestQuestLogCard extends LitElement {
`;
}
const dueAt = formatWallClock(instance.due_time);
const meta = this._isOverdue(instance)
const meta = instance.overdue
? {
text: dueAt ? `Overdue · due ${dueAt}` : "Overdue",
late: true,
+1 -46
View File
@@ -16,12 +16,6 @@
* reports every hass.callService call as tap: {dialog_opened, service_calls}.
* With tap_return: true the harness taps the header's return control and
* reports it as returned: {rendered, label} before the final snapshot.
* With now: <ISO instant> the card's clock is pinned there (argument-less
* new Date() and Date.now() return it; the process TZ decides its local
* wall time), and with advance_to: [<ISO instant>, ...] the clock moves to
* each instant in turn and the card's own 60 s clock-tick callbacks fire,
* one snapshot per instant reported as ticks (the top-level snapshot is
* taken before the clock moves).
* The Python panel tests drive this harness so the
* assertions run against the exact bundle HACS ships.
*/
@@ -53,33 +47,6 @@ globalThis.window = dom.window;
// jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event;
// Pin the clock: argument-less construction and Date.now() read `pinned`,
// every other Date use is the real class.
let pinned = spec.now !== undefined ? new Date(spec.now).getTime() : null;
if (pinned !== null) {
const RealDate = Date;
class PinnedDate extends RealDate {
constructor(...args) {
super(...(args.length === 0 ? [pinned] : args));
}
static now() {
return pinned;
}
}
globalThis.Date = PinnedDate;
dom.window.Date = PinnedDate;
}
// Record the card's 60 s clock ticks so advance_to can fire them on demand.
const clockTicks = [];
const realSetInterval = dom.window.setInterval.bind(dom.window);
dom.window.setInterval = (callback, ms, ...rest) => {
if (ms === 60000) {
clockTicks.push(callback);
}
return realSetInterval(callback, ms, ...rest);
};
await import(pathToFileURL(spec.bundle).href);
const serviceCalls = [];
@@ -156,18 +123,6 @@ if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const settled = snapshot();
let ticks = null;
if (spec.advance_to !== undefined) {
ticks = [];
for (const instant of spec.advance_to) {
pinned = new Date(instant).getTime();
clockTicks.forEach((callback) => callback());
await element.updateComplete;
ticks.push(snapshot());
}
}
const payload = JSON.stringify({ ...settled, probe, tap, returned, ticks });
const payload = JSON.stringify({ ...snapshot(), probe, tap, returned });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.9.7"
version = "0.9.5"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+18 -63
View File
@@ -13,8 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy.
Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings
— the eleven documented fields at their defaults on a fresh database,
with no ``timezone_configured`` marker.
— the twelve documented fields at their defaults on a fresh database.
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value),
@@ -61,7 +60,7 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
SETTINGS_META_KEY = "settings"
#: The all-defaults effective settings a fresh database reports — the
#: documented eleven fields, asserted literally so the payload contract is
#: documented twelve fields, asserted literally so the payload contract is
#: pinned here, independent of the core constants.
DEFAULT_SETTINGS = {
"horizon_days": 14,
@@ -75,6 +74,7 @@ DEFAULT_SETTINGS = {
"end_of_day_report_enabled": True,
"celebration_enabled": True,
"timezone": "",
"timezone_configured": False,
}
@@ -105,7 +105,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def test_get_settings_returns_defaults_on_a_fresh_database(
settings_client: SimpleNamespace,
) -> None:
"""GET returns the eleven documented fields at their defaults."""
"""GET returns the twelve documented fields at their defaults."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
@@ -223,38 +223,27 @@ async def test_null_resets_a_field_to_its_default(
assert reread.json()["horizon_days"] == 14
async def test_default_settings_carry_no_timezone_configured_marker(
async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
settings_client: SimpleNamespace,
) -> None:
"""The removed auto-set marker is absent from the default response."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert "timezone_configured" not in response.json()
assert response.json()["timezone"] == ""
"""An explicit empty (host-local) zone persists as configured.
@pytest.mark.parametrize("zone", ["America/New_York", ""])
async def test_manual_timezone_patch_persists_and_is_returned(
settings_client: SimpleNamespace, zone: str
) -> None:
"""An admin's timezone PATCH is stored exactly as given and read back."""
The admin clearing the zone PATCHes ``timezone: ""`` together with
``timezone_configured: true``; the NEXT read — a reopened settings
screen — sees the marker, so the empty value is a persisted choice
rather than a fresh install to auto-set.
"""
client = settings_client.client
seeded = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "Europe/Dublin"},
headers=_admin_headers(),
)
assert seeded.status_code == 200
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers())
assert fresh.json()["timezone_configured"] is False
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
json={"timezone": "", "timezone_configured": True},
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone": zone}
expected = {**DEFAULT_SETTINGS, "timezone_configured": True}
assert patched.json() == expected
reread = await client.get(
@@ -265,43 +254,7 @@ async def test_manual_timezone_patch_persists_and_is_returned(
SETTINGS_META_KEY
)
assert raw is not None
stored = json.loads(raw)
assert stored["timezone"] == zone
assert "timezone_configured" not in stored
async def test_timezone_configured_is_an_unknown_field(
settings_client: SimpleNamespace,
) -> None:
"""The removed marker is rejected like any unknown field; nothing is written."""
response = await settings_client.client.patch(
"/api/v1/admin/settings",
json={"timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone_configured" in str(response.json()["detail"])
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is None
async def test_stored_legacy_timezone_configured_key_is_ignored(
settings_client: SimpleNamespace,
) -> None:
"""A document written before the marker was removed still loads."""
await _dao_meta.MetaStateDao(settings_client.database).set(
SETTINGS_META_KEY,
json.dumps(
{"timezone": "America/Chicago", "timezone_configured": True}
),
)
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"}
assert json.loads(raw)["timezone_configured"] is True
# --- validation: rejected values never change the stored settings -------
@@ -320,6 +273,8 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
({"celebration_enabled": 1}, "celebration_enabled"),
({"notify_target": 5}, "notify_target"),
({"timezone_configured": "true"}, "timezone_configured"),
({"timezone_configured": 1}, "timezone_configured"),
],
)
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
+22 -17
View File
@@ -196,27 +196,25 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_requests_carry_only_the_bearer_header() -> None:
"""No timezone is reported to the API (task 7f685179): the Bearer
credential is the only header on every request."""
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 = make_client(transport)
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}"}
def test_client_takes_no_household_timezone() -> None:
with pytest.raises(TypeError):
NestQuestApiClient(
BASE_URL, TOKEN, StubTransport(), household_timezone="UTC"
)
assert call["headers"] == {
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
}
async def test_base_url_trailing_slash_is_normalized() -> None:
@@ -415,13 +413,20 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
}
)
# A bare object: building the client must not read hass.config.
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._headers() == {"Authorization": "Bearer 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(
@@ -444,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:
+181 -144
View File
@@ -19,8 +19,6 @@ container's zone) and sets the household zone through the real admin
An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time.
Nothing sets the zone automatically: a timezone header on a panel
request never changes the stored settings (task 7f685179).
"""
from __future__ import annotations
@@ -49,7 +47,6 @@ _settings = importlib.import_module("nestquest_core.settings")
_settings_store = importlib.import_module("nestquest_core.settings_store")
_core_db = importlib.import_module("nestquest_core.db")
_core_migrations = importlib.import_module("nestquest_core.migrations")
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
@@ -87,12 +84,7 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
clock=clock,
database=runner.app.state.db.database,
)
yield SimpleNamespace(client=client, seed=seed, clock=clock)
async def _set_zone(household: SimpleNamespace, zone: str) -> None:
@@ -245,14 +237,23 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
)
def test_settings_have_no_timezone_configured_marker() -> None:
assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
with pytest.raises(TypeError):
_settings.NestQuestSettings(timezone_configured=True)
# A stored document still carrying the removed key loads harmlessly.
assert _settings.NestQuestSettings.from_options_resilient(
{"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None:
assert _settings.NestQuestSettings().timezone_configured is False
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False
assert (
_settings.NestQuestSettings.from_options(
{"timezone_configured": True}
).timezone_configured
is True
)
for value in ("true", 1, None):
with pytest.raises(ValueError, match="timezone_configured"):
_settings.NestQuestSettings(timezone_configured=value)
if value is not None:
with pytest.raises(ValueError):
_settings.NestQuestSettings.from_options(
{"timezone_configured": value}
)
# --- the missed-sweep scheduler ----------------------------------------------
@@ -328,29 +329,37 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
assert _delay_seconds(target, now) == expected
# --- nothing sets the zone automatically (task 7f685179) --------------------
# --- the Home Assistant-reported zone (task 6351b36d) ------------------------
#
# The zone is only ever set by an admin's explicit settings update. A
# timezone header on a panel request — the one Home Assistant used to
# report, or any look-alike — is ignored, and no read writes settings.
#: Timezone-looking request headers a panel request might carry.
_TIMEZONE_HEADERS = [
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
{"X-Timezone": HOUSEHOLD_ZONE},
{"Time-Zone": HOUSEHOLD_ZONE},
]
# 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.
async def _stored_document(household: SimpleNamespace) -> str | None:
"""The raw stored settings document (``None`` when never written)."""
return await _dao_meta.MetaStateDao(household.database).get(
_settings_store.SETTINGS_META_KEY
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 _admin_settings(household: SimpleNamespace) -> dict:
async def _stored_settings(household: SimpleNamespace) -> dict:
response = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
@@ -358,73 +367,99 @@ async def _admin_settings(household: SimpleNamespace) -> dict:
return response.json()
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_changes_settings(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
household.clock["now"] = _utc(13, 59, 59)
headers = {**_panel_headers(), **extra}
snapshot = await household.client.get(
"/api/v1/panel/snapshot", headers=headers
)
assert snapshot.status_code == 200
seed = household.seed
completed = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=headers,
json={"actor_child_id": seed.ada.id},
)
assert completed.status_code == 200
# Nothing was written, and the clock stayed host local (UTC): the
# 10:00 quest was overdue and the 13:59 UTC completion was late.
assert await _stored_document(household) is None
assert (await _admin_settings(household))["timezone"] == ""
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is False
@pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
async def test_panel_timezone_header_never_replaces_the_stored_zone(
household: SimpleNamespace, extra: dict[str, str]
) -> None:
"""A stored zone stays exactly as the admin saved it."""
await _set_zone(household, "America/Los_Angeles")
before = await _stored_document(household)
household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
assert await _stored_document(household) == before
_today_iso, instance = await _pack_bag(household, "panel")
assert instance["overdue"] is False
async def test_fresh_install_zone_stays_empty_across_reads(
async def test_unset_zone_adopts_reported_zone_for_overdue(
household: SimpleNamespace,
) -> None:
"""Repeated panel and admin reads never write the settings document."""
for hour in (3, 13, 23):
household.clock["now"] = _utc(hour, 0)
for extra in _TIMEZONE_HEADERS:
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
await _pack_bag(household, "admin")
settings = await _admin_settings(household)
assert settings["timezone"] == ""
assert "timezone_configured" not in settings
assert await _stored_document(household) is 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
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
async def test_unset_zone_adopts_reported_zone_for_today(
household: SimpleNamespace,
) -> None:
"""23:30 EDT is tomorrow in UTC; the household day is still today."""
household.clock["now"] = _utc(3, 30, day=27)
today_iso, _instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert today_iso == TODAY.isoformat()
async def test_reported_zone_completion_is_on_time(
household: SimpleNamespace,
) -> None:
"""A 09:30 EDT tap with the zone never set is on time."""
household.clock["now"] = _utc(13, 30)
seed = household.seed
response = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
json={"actor_child_id": seed.ada.id},
)
assert response.status_code == 200
_today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is True
async def test_reported_zone_follows_home_assistant_changes(
household: SimpleNamespace,
) -> None:
"""An automatically adopted zone tracks a later Home Assistant change."""
await _panel_pack_bag(household, "America/Chicago")
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
async def _adopt_new_york(household: SimpleNamespace) -> None:
"""Prove automatic adoption first, so later assertions can't pass
on code that ignores the reported zone altogether."""
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
stored = await _stored_settings(household)
assert stored["timezone"] == HOUSEHOLD_ZONE
assert stored["timezone_configured"] is False
@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""])
async def test_admin_chosen_zone_wins_over_reported_zone(
household: SimpleNamespace, chosen: str
) -> None:
await _adopt_new_york(household)
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": chosen, "timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 200
# A conflicting report is no longer adopted.
await _panel_pack_bag(household, "America/Chicago")
stored = await _stored_settings(household)
assert stored["timezone"] == chosen
assert stored["timezone_configured"] is True
async def test_invalid_reported_zone_is_ignored(
household: SimpleNamespace,
) -> None:
await _adopt_new_york(household)
household.clock["now"] = _utc(13, 59, 59)
_today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons")
# Still read in the adopted zone: 09:59:59 EDT is not overdue.
assert instance["overdue"] is False
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
# --- the scheduler follows an adopted zone (task 6351b36d) -------------------
#
# On a UTC host with no stored zone the scheduler first plans for UTC
# midnight; a zone stored while it sleeps must move the sweep to the
# midnight; a zone adopted while it sleeps must move the sweep to the
# household's midnight rather than sweep at 20:00 EDT.
#: 13:00 UTC (09:00 EDT) to UTC midnight.
@@ -433,20 +468,11 @@ _UTC_MIDNIGHT_DELAY = 11 * 3600
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
async def _store_zone(scheduler, database, zone: str) -> None:
"""Store ``zone`` the way a settings writer must: under the lock."""
async with scheduler.settings_lock:
settings = await _settings_store.update_settings(
database, {"timezone": zone}
)
scheduler.settings_changed(settings)
async def test_scheduler_replans_when_zone_changes_while_sleeping(
async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping(
temp_db_path: str,
) -> None:
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
async with runner as client:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
@@ -468,9 +494,11 @@ async def test_scheduler_replans_when_zone_changes_while_sleeping(
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
response = await client.get(
"/api/v1/panel/snapshot",
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
)
assert response.status_code == 200
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 2), timeout=5
@@ -508,8 +536,8 @@ async def test_scheduler_rechecks_settings_before_sweeping(
if len(delays) == 1:
# The UTC rollover "arrives" after the zone was stored
# behind the scheduler's back.
await _settings_store.update_settings(
database, {"timezone": HOUSEHOLD_ZONE}
await _settings_store.adopt_reported_timezone(
database, HOUSEHOLD_ZONE
)
return
replanned.set()
@@ -532,12 +560,12 @@ async def test_scheduler_rechecks_settings_before_sweeping(
await database.close()
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
# --- adoption is serialised with planning and sweeping (TZ-003) --------------
#
# A zone write racing the loop's own settings reads must never leave a
# An adoption racing the loop's own settings reads must never leave a
# stale plan behind. Each test holds the loop at one of its reads and
# issues a locked zone write there; when the scheduler's settings lock
# is held at that point the write provably cannot commit until the
# issues a real panel adoption there; when the scheduler's settings lock
# is held at that point the adoption provably cannot commit until the
# loop moves on, otherwise it is awaited to commit inside the window.
@@ -560,12 +588,24 @@ def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
return real_load
async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
"""Start a locked write of ``zone``; let it commit unless serialised."""
write = asyncio.create_task(_store_zone(scheduler, database, zone))
if not scheduler.settings_lock.locked():
await write
return write
async def _adopt_via(client, zone: str) -> None:
response = await client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
)
assert response.status_code == 200
async def _adopt_inside_read(client, scheduler, zone: str) -> asyncio.Task:
"""Start a panel adoption of ``zone``; let it commit unless serialised."""
adoption = asyncio.create_task(
client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
)
)
lock = getattr(scheduler, "settings_lock", None)
if lock is None or not lock.locked():
await adoption
return adoption
async def test_scheduler_follows_successive_zone_changes(
@@ -573,9 +613,8 @@ async def test_scheduler_follows_successive_zone_changes(
) -> None:
"""New York -> UTC -> New York while the re-plan read is in flight."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
async with runner as client:
app_state = runner.app.state
database = app_state.db.database
await app_state.sweep_scheduler.stop()
delays: list[float] = []
slept = asyncio.Condition()
@@ -587,15 +626,15 @@ async def test_scheduler_follows_successive_zone_changes(
await asyncio.Event().wait()
scheduler = MissedSweepScheduler(
database, clock=lambda: _utc(13, 0), sleep=sleep_stub
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
writes: list[asyncio.Task] = []
await _adopt_via(client, HOUSEHOLD_ZONE)
adoptions: list[asyncio.Task] = []
async def back_to_new_york(_settings) -> None:
writes.append(
await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
adoptions.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE)
)
# Read 1 plans New York; read 2 is the re-plan after UTC.
@@ -606,16 +645,16 @@ async def test_scheduler_follows_successive_zone_changes(
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5
)
await _store_zone(scheduler, database, "UTC")
await _adopt_via(client, "UTC")
async with slept:
await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 3), timeout=5
)
(write,) = writes
await write
(adoption,) = adoptions
assert (await adoption).status_code == 200
finally:
await scheduler.stop()
# The in-flight read planned UTC; the New York write that followed
# The in-flight read planned UTC; the New York adoption that followed
# it woke the wait, so the scheduler sleeps on New York midnight.
assert delays == [
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
@@ -624,12 +663,12 @@ async def test_scheduler_follows_successive_zone_changes(
]
async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
temp_db_path: str, monkeypatch
) -> None:
"""No sweep runs once a zone write has replaced its plan."""
"""No sweep runs once an adoption has replaced its plan."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner:
async with runner as client:
app_state = runner.app.state
await app_state.sweep_scheduler.stop()
delays: list[float] = []
@@ -646,18 +685,16 @@ async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
)
app_state.sweep_scheduler = scheduler
writes: list[asyncio.Task] = []
adoptions: list[asyncio.Task] = []
async def store_new_york(_settings) -> None:
writes.append(
await _store_inside_read(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
async def adopt_new_york(_settings) -> None:
adoptions.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE)
)
# Read 1 plans UTC; read 2 is the pre-sweep re-check.
real_load = _hold_scheduler_read(
monkeypatch, scheduler, 2, store_new_york
monkeypatch, scheduler, 2, adopt_new_york
)
swept_under: list[str] = []
@@ -671,12 +708,12 @@ async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
scheduler.start()
try:
await asyncio.wait_for(replanned.wait(), timeout=5)
(write,) = writes
await write
(adoption,) = adoptions
assert (await adoption).status_code == 200
finally:
await scheduler.stop()
# The sweep ran for the UTC plan with UTC still stored, never after
# New York had been stored; the next plan is New York midnight.
# New York had been adopted; the next plan is New York midnight.
assert swept_under == [""]
assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY),
+2 -2
View File
@@ -686,8 +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 not reported to the API (task 7f685179).
assert real_client._headers() == {"Authorization": "Bearer tok"}
# 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
+7 -99
View File
@@ -21,7 +21,6 @@ end-to-end through the quest-log harness.
from __future__ import annotations
import json
import os
import re
import subprocess
from pathlib import Path
@@ -832,9 +831,6 @@ def _render_quest_log(
probe_ms: int | None = None,
tap_complete: int | None = None,
tap_return: bool = False,
now: str | None = None,
advance_to: list[str] | None = None,
tz: str | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
@@ -844,10 +840,7 @@ def _render_quest_log(
segment — waits the optional probe window and captures one snapshot
of the render mid-wait, then the optional idle window, and prints
the final pathname and rendered title (plus the probe snapshot when
``probe_ms`` was given). ``now`` pins the card's clock to an ISO
instant, ``advance_to`` moves it through further instants firing the
card's clock tick (one snapshot each, under ``ticks``), and ``tz``
sets the node process's ``TZ`` — the panel browser's local zone.
``probe_ms`` was given).
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
@@ -865,17 +858,12 @@ def _render_quest_log(
spec["tap_complete"] = tap_complete
if tap_return:
spec["tap_return"] = True
if now is not None:
spec["now"] = now
if advance_to is not None:
spec["advance_to"] = advance_to
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "TZ": tz} if tz is not None else None,
)
assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}"
@@ -1102,13 +1090,12 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
``meta ${late ? "late" : nothing}``, Lit dropped the whole class
attribute for every quest that was not late, leaving the due line
unstyled. The rendered markup must carry ``class="meta"`` on time and
``class="meta late"`` overdue — overdue by the panel's pinned clock
(07:15 local: the 07:00 quest is late, the 08:15 one is not)."""
``class="meta late"`` overdue."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str) -> dict:
def quest(instance_id: int, title: str, overdue: bool) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
@@ -1116,9 +1103,9 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"due_time": "08:15",
"state": "open",
"overdue": False,
"overdue": overdue,
"completed_at": None,
"on_time": None,
}
@@ -1131,8 +1118,8 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
child_id=1,
present=True,
instances=[
quest(41, "Make Bed", "08:15"),
quest(42, "Brush Teeth", "07:00"),
quest(41, "Make Bed", overdue=False),
quest(42, "Brush Teeth", overdue=True),
],
),
}
@@ -1140,8 +1127,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
tz="America/New_York",
)
metas = {}
for card in result["html"].split('data-instance-id="')[1:]:
@@ -1158,83 +1143,6 @@ def test_quest_log_meta_line_keeps_its_meta_class() -> None:
assert metas["42"].strip() == 'class="meta late"', metas["42"]
def _quest_metas(html: str) -> dict[str, tuple[str, str]]:
"""Map each rendered open quest's instance id to its meta line's
attributes and text."""
metas = {}
for card in html.split('data-instance-id="')[1:]:
meta = re.search(
r'<span class="quest-title">.*?</span>'
r"\s*(?:<!--[^>]*-->\s*)*<span([^>]*)>(.*?)</span>",
card,
re.S,
)
assert meta is not None, card[:800]
text = " ".join(re.sub(r"<!--[^>]*-->", "", meta.group(2)).split())
metas[card[: card.index('"')]] = (meta.group(1).strip(), text)
return metas
def test_quest_log_overdue_follows_the_panel_local_clock() -> None:
"""Overdue is decided by the panel's own local clock, not the API's
``overdue`` flag. The owner saw 8:15 AM quests read OVERDUE at 7:15 AM
America/New_York: the API's flag had been computed on a UTC wall clock
(11:15). Here the API still says ``overdue: true`` and Home
Assistant reports ``time_zone`` UTC, yet at 07:15 local the 08:15 quest
is due, at 08:15 (and 08:16, via the card's own clock tick) it is
overdue, and a quest with no due time is never overdue."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
def quest(instance_id: int, title: str, due_time: str | None) -> dict:
return {
"id": instance_id,
"definition_id": instance_id,
"child_id": 1,
"title": title,
"icon": None,
"window": "morning",
"due_time": due_time,
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
states = {
**_three_child_states(),
"sensor.nestquest_ada_quests_due_today": _state(
"2",
child_name="Ada",
child_id=1,
present=True,
instances=[
quest(42, "Brush Teeth", "08:15"),
quest(43, "Feed Fish", None),
],
),
}
result = _render_quest_log(
generated["views"][1]["cards"][0],
states,
url="http://homeassistant.local/nestquest/ada",
now="2026-09-29T11:15:00Z",
advance_to=["2026-09-29T12:15:00Z", "2026-09-29T12:16:00Z"],
tz="America/New_York",
)
due_by = ('class="meta"', "Due by 8:15 AM")
overdue = ('class="meta late"', "Overdue · due 8:15 AM")
not_due = ('class="meta"', "Due today")
at_0715, at_0815, at_0816 = (
_quest_metas(result["html"]),
*(_quest_metas(tick["html"]) for tick in result["ticks"]),
)
assert at_0715 == {"42": due_by, "43": not_due}, at_0715
assert at_0815 == {"42": overdue, "43": not_due}, at_0815
assert at_0816 == {"42": overdue, "43": not_due}, at_0816
def test_quest_log_crest_matches_the_spec_geometry() -> None:
"""PANEL-SPEC §3 header crest: 96x110 shield, 4px ring of
rgba(255,255,255,.22) around the --nq-p-crest face, 44px/700 display
+18
View File
@@ -351,6 +351,24 @@ async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None
assert call["json"] is None
async def test_stream_events_reports_household_timezone() -> None:
"""The SSE GET carries Home Assistant's zone beside the Bearer token
(task 6351b36d), like the snapshot and completion requests."""
frame = _frame_text(*DOCUMENTED_FRAMES[0])
transport = _StreamTransport(_StreamingResponse(200, [frame]))
client = NestQuestApiClient(
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
frames = [frame async for frame in client.stream_events()]
assert frames == [DOCUMENTED_FRAMES[0]]
assert transport.calls[0]["headers"] == {
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
}
async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None:
"""Chunk-split lines parse, and multi-line data joins per SSE rules."""
first_type, first_payload = DOCUMENTED_FRAMES[0]
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.9.7"
version = "0.9.5"
source = { virtual = "." }
[package.dev-dependencies]