Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0474ef71 | ||
|
|
e287c145b0 | ||
|
|
7adee7c3c0 |
@@ -370,7 +370,7 @@ describe("SettingsScreen — preferences", () => {
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
}
|
||||
|
||||
it("renders the current value of all ten fields", async () => {
|
||||
it("renders the current value of the ten settings form fields (timezone is API-only)", async () => {
|
||||
const { form } = await renderPreferences();
|
||||
expect(field(form, "Planning horizon (days)").value).toBe("14");
|
||||
expect(field(form, "Day rollover").value).toBe("03:00");
|
||||
|
||||
+25
-19
@@ -165,10 +165,12 @@ 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 ten
|
||||
- ``GET /settings`` returns the current effective settings: all eleven
|
||||
documented fields (the rolling horizon, the day rollover time, the
|
||||
notify target, the three notification times and the four enable
|
||||
toggles) — the defaults on a fresh database.
|
||||
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) — 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
|
||||
@@ -184,18 +186,19 @@ The missed-sweep trigger (task 058c7b69) follows the same pattern over
|
||||
:mod:`nestquest_core.sweep` — the HA-free sweep policy the
|
||||
integration's rollover listener shares:
|
||||
|
||||
- ``POST /missed-sweep`` runs the sweep for the API host's local
|
||||
``today`` (ONE :func:`_local_now` clock read threaded into the core
|
||||
call) and publishes each returned ``(event_type, payload)`` pair
|
||||
DIRECTLY on the app's ONE transition publisher — the sweep-built
|
||||
payload, never re-fetched (the core commits its watermark before
|
||||
returning, so a failed re-fetch after it would lose the missed event
|
||||
forever), the same publish-what-the-core-built shape
|
||||
api/scheduler.py uses. The watermark rule
|
||||
(a same-day rerun announces nothing, a post-downtime run sweeps the
|
||||
accumulated window once), the no-completion-event rule and the
|
||||
read-only-over-the-domain-tables guarantee all live in the core; the
|
||||
response reports how many transitions were published.
|
||||
- ``POST /missed-sweep`` runs the sweep for the household-local
|
||||
``today`` (ONE :func:`_local_now` clock read re-expressed in the
|
||||
configured household ``timezone`` — empty falls back to host local —
|
||||
threaded into the core call) and publishes each returned
|
||||
``(event_type, payload)`` pair DIRECTLY on the app's ONE transition
|
||||
publisher — the sweep-built payload, never re-fetched (the core
|
||||
commits its watermark before returning, so a failed re-fetch after it
|
||||
would lose the missed event forever), the same
|
||||
publish-what-the-core-built shape api/scheduler.py uses. The
|
||||
watermark rule (a same-day rerun announces nothing, a post-downtime
|
||||
run sweeps the accumulated window once), the no-completion-event rule
|
||||
and the read-only-over-the-domain-tables guarantee all live in the
|
||||
core; the response reports how many transitions were published.
|
||||
|
||||
The occurrence-preview route (task 5f843564) lets the admin PWA's
|
||||
Definitions edit sheet show the next dates a rule fires on WITHOUT
|
||||
@@ -205,9 +208,10 @@ reimplementing the recurrence rules client-side:
|
||||
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
|
||||
through the SAME :func:`_rule_from_request` / core ``from_dict``
|
||||
validation, so a rejected rule is 422 exactly as on create), an
|
||||
optional ``start_date`` (default: the API host's local ``today``,
|
||||
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
|
||||
default 10). The dates come from ONE
|
||||
optional ``start_date`` (default: the household-local ``today`` —
|
||||
ONE :func:`_local_now` clock read re-expressed in the configured
|
||||
household ``timezone``, empty falling back to host local) and an
|
||||
optional ``count`` (1..50, default 10). The dates come from ONE
|
||||
:func:`nestquest_core.recurrence.occurrences_between` call — the
|
||||
same engine the materializer walks — so the preview can only agree
|
||||
with what would be generated. The scan is BOUNDED server-side: the
|
||||
@@ -2281,7 +2285,9 @@ async def admin_run_missed_sweep(
|
||||
handler performs NO auth of its own). Thin adapter over the SAME
|
||||
HA-free sweep policy the integration's rollover listener runs
|
||||
(:mod:`nestquest_core.sweep`): ONE timezone-aware clock read
|
||||
(:func:`_local_now`) pins the API host's local ``today``, and ONE
|
||||
(:func:`_local_now`, re-expressed in the configured household
|
||||
``timezone``; empty falls back to host local) pins the
|
||||
household-local ``today``, and ONE
|
||||
:func:`nestquest_core.sweep.run_missed_sweep` call does everything
|
||||
else — the past-due open-instance query, the documented payload
|
||||
build and the watermark update (a same-day rerun is an empty
|
||||
|
||||
+47
-10
@@ -44,19 +44,56 @@ LOGGER = logging.getLogger(__name__)
|
||||
_RETRY_BACKOFF_SECONDS = 60.0
|
||||
|
||||
|
||||
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
|
||||
"""Seconds from ``now`` until the next occurrence of ``target``.
|
||||
#: Upper bound on the minute-by-minute search past a nonexistent wall
|
||||
#: time; real DST gaps are an hour, the largest ever skipped a day.
|
||||
_MAX_GAP_MINUTES = 2 * 24 * 60
|
||||
|
||||
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
|
||||
occurrence strictly AFTER ``now`` (an equal time schedules for
|
||||
tomorrow, so the just-run sweep is never instantly re-run).
|
||||
|
||||
def _resolve_wall_time(
|
||||
day: datetime.date, target: datetime.time, zone: datetime.tzinfo
|
||||
) -> datetime.datetime:
|
||||
"""The aware instant ``target`` (``HH:MM``) names on ``day`` in ``zone``.
|
||||
|
||||
A nonexistent wall time (inside a spring-forward gap) resolves to
|
||||
the first valid wall minute at/after it — the gap's end, e.g. 02:30
|
||||
on a 02:00→03:00 day becomes 03:00. An ambiguous wall time (the
|
||||
repeated fall-back hour) resolves to its FIRST occurrence
|
||||
(``fold=0``).
|
||||
"""
|
||||
scheduled = now.replace(
|
||||
hour=target.hour, minute=target.minute, second=0, microsecond=0
|
||||
wall = datetime.datetime.combine(
|
||||
day, datetime.time(target.hour, target.minute)
|
||||
)
|
||||
if scheduled <= now:
|
||||
scheduled += datetime.timedelta(days=1)
|
||||
return (scheduled - now).total_seconds()
|
||||
for _ in range(_MAX_GAP_MINUTES):
|
||||
candidate = wall.replace(tzinfo=zone)
|
||||
round_trip = candidate.astimezone(datetime.timezone.utc).astimezone(
|
||||
zone
|
||||
)
|
||||
if round_trip.replace(tzinfo=None) == wall:
|
||||
return candidate
|
||||
wall += datetime.timedelta(minutes=1)
|
||||
raise ValueError(f"no valid wall time at/after {target} on {day}")
|
||||
|
||||
|
||||
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
|
||||
"""Real seconds from ``now`` until the next occurrence of ``target``.
|
||||
|
||||
``target`` is a plain ``HH:MM`` household time of day, resolved in
|
||||
``now``'s zone by :func:`_resolve_wall_time` (nonexistent → first
|
||||
valid instant after the gap; ambiguous → first occurrence). The
|
||||
result is the next such instant strictly AFTER ``now`` (an equal
|
||||
time schedules for tomorrow, so the just-run sweep is never
|
||||
instantly re-run). Both instants are compared and subtracted in
|
||||
UTC: same-``tzinfo`` aware arithmetic is wall-clock arithmetic and
|
||||
would be an hour off across a DST transition.
|
||||
"""
|
||||
now_utc = now.astimezone(datetime.timezone.utc)
|
||||
day = now.date()
|
||||
while True:
|
||||
scheduled = _resolve_wall_time(day, target, now.tzinfo)
|
||||
scheduled_utc = scheduled.astimezone(datetime.timezone.utc)
|
||||
if scheduled_utc > now_utc:
|
||||
return (scheduled_utc - now_utc).total_seconds()
|
||||
day += datetime.timedelta(days=1)
|
||||
|
||||
|
||||
class MissedSweepScheduler:
|
||||
|
||||
@@ -40,8 +40,8 @@ announces nothing.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
the integration or from the API host's local clock by the API. This
|
||||
module never reads a timezone database or config of its own.
|
||||
the integration or from the configured household timezone by the API.
|
||||
This module never reads a timezone database or config of its own.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -98,7 +98,8 @@ async def run_missed_sweep(
|
||||
|
||||
``today`` is the household-local calendar date the sweep runs for
|
||||
(the caller owns the clock: the integration reads it from
|
||||
``hass.config.time_zone``, the API from its host's local time).
|
||||
``hass.config.time_zone``, the API from the configured household
|
||||
timezone, falling back to its host's local time when unset).
|
||||
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
|
||||
when the watermark already covers ``today`` (a same-night rerun) —
|
||||
the caller decides how to publish them and announces nothing on
|
||||
|
||||
@@ -32,7 +32,7 @@ import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from api.database import _executor
|
||||
from api.scheduler import MissedSweepScheduler
|
||||
from api.scheduler import MissedSweepScheduler, _delay_seconds
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
@@ -270,3 +270,40 @@ async def test_scheduler_waits_for_rollover_in_household_time(
|
||||
assert delays == [pytest.approx(65 * 60)]
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the rollover delay across DST (America/New_York) ------------------------
|
||||
|
||||
NEW_YORK = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def _ny(*args: int, fold: int = 0) -> datetime.datetime:
|
||||
return datetime.datetime(*args, tzinfo=NEW_YORK, fold=fold)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("now", "target", "expected"),
|
||||
[
|
||||
# Ordinary EDT day: 09:00 -> 10:05 is 65 minutes.
|
||||
(_ny(2026, 6, 15, 9, 0), datetime.time(10, 5), 65 * 60),
|
||||
# An equal time schedules for tomorrow (strictly after now).
|
||||
(_ny(2026, 6, 15, 10, 5), datetime.time(10, 5), 86400),
|
||||
# Spring-forward: 01:00 EST -> 03:00 EDT is one real hour.
|
||||
(_ny(2026, 3, 8, 1, 0), datetime.time(3, 0), 3600),
|
||||
# Fall-back: 00:30 EDT -> 02:00 EST is two and a half real hours.
|
||||
(_ny(2026, 11, 1, 0, 30), datetime.time(2, 0), 9000),
|
||||
# Nonexistent 02:30 on spring-forward day: the first valid instant
|
||||
# at/after it is 03:00 EDT, one real hour after 01:00 EST.
|
||||
(_ny(2026, 3, 8, 1, 0), datetime.time(2, 30), 3600),
|
||||
# Ambiguous 01:30 on fall-back day resolves to the FIRST (EDT)
|
||||
# occurrence: from 00:30 EDT that is one real hour.
|
||||
(_ny(2026, 11, 1, 0, 30), datetime.time(1, 30), 3600),
|
||||
# Already past the first 01:30 (now is 01:15 EST, the repeated
|
||||
# hour): not the second occurrence, but tomorrow's 01:30 EST.
|
||||
(_ny(2026, 11, 1, 1, 15, fold=1), datetime.time(1, 30), 87300),
|
||||
],
|
||||
)
|
||||
def test_delay_seconds_is_true_elapsed_time_across_dst(
|
||||
now: datetime.datetime, target: datetime.time, expected: float
|
||||
) -> None:
|
||||
assert _delay_seconds(target, now) == expected
|
||||
|
||||
Reference in New Issue
Block a user