Files
espresso_frame/server/app/quiet_hours.py
T
tfaour 9fbbb8ed2b Redesign phase A: SQLite storage, per-frame data model, device identity
Replaces the single global config.json (whole-file pydantic model under
one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/
pending-claims/battery_log tables (models.py), a per-frame lock registry
(db.frame_locked) succeeding config.locked(), and hand-rolled schema
versioning (migration.py). A pre-database deployment's config.json is
imported verbatim as frame #1 on first boot and left untouched as the
rollback path; the old single firmware.bin slot becomes per-frame
firmware/<id>.bin.

Routes split out of the 900-line main.py into routers/device.py (the
frozen /frame/* protocol) and routers/api.py (web UI, still on the old
single-frame paths for now). Device auth moves to require_device, which
already speaks the full multi-frame protocol: per-frame device tokens
pushed via /frame/config and acknowledged on first use, self-
registration of unknown device ids as unclaimed frames, pending-claim
attachment, and the legacy-token migration window that keeps the
currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving
to frame #1 -- including the one-time binding of its device id when it
first reports one after a future OTA.

Externally identical for existing deployments: same paths, same token
semantics, same response shapes -- verified with a migration fixture,
the legacy-device curl suite, a 20-way concurrent-advance smoke test,
and a mutate-restart-assert persistence check against a fake Immich.

photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList
JSON columns make its in-place list mutations dirty-track); quiet-hours
math extracted unchanged into quiet_hours.py.
2026-07-21 23:21:38 -04:00

118 lines
4.7 KiB
Python

"""Quiet-hours math, extracted verbatim from the old main.py. Everything
takes the frame-like object duck-typed on quiet_hours_enabled/start/end,
timezone, and refresh_interval_s -- both the old FrameConfig and the
Frame ORM model satisfy it."""
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones
# Populated once from the OS's zoneinfo database (installed via the
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
# web UI's "Timezone" field.
ALL_TIMEZONES = sorted(available_timezones())
def valid_hhmm(s: str) -> bool:
try:
datetime.strptime(s, "%H:%M")
return True
except ValueError:
return False
def _zoneinfo(name: str) -> ZoneInfo:
"""Falls back to UTC for an unrecognized zone name -- defensive only;
the config-save route validates against ALL_TIMEZONES before saving,
so this only matters for state hand-edited or written by an older
version of this code."""
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("UTC")
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
"""Whether `now` falls inside the quiet-hours window, and the next
boundary: if inside, when it ends; if outside, when it next starts.
Handles a window that wraps past midnight (e.g. 22:00-07:00). Returns
(False, None) for a degenerate window (start == end)."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
if start == end:
return False, None
if start < end:
# Same-day window, e.g. 13:00-15:00. End is exclusive, so being
# exactly at `end` counts as already outside the window.
if start <= now < end:
return True, end
if now < start:
return False, start
return False, start + timedelta(days=1)
# Wraps midnight, e.g. 22:00-07:00.
if now >= start:
return True, end + timedelta(days=1)
if now < end:
return True, end
return False, start
def _quiet_hours_span_s(start_str: str, end_str: str) -> int:
"""Duration of the quiet-hours window in seconds, wrap-aware."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
span_min = ((eh * 60 + em) - (sh * 60 + sm)) % (24 * 60)
return span_min * 60
def effective_refresh_interval_s(cfg) -> int:
"""The refresh interval actually handed to the device: its configured
value, unless quiet hours are enabled, in which case it's clamped so
the device sleeps through the whole window instead of waking inside
it. A device already mid-sleep when quiet hours begin can still land
one wake inside the window (nothing server-side can prevent that
without touching the firmware) -- but from that wake on, it's told to
sleep exactly until the window ends."""
if not cfg.quiet_hours_enabled:
return cfg.refresh_interval_s
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
if boundary is None:
return cfg.refresh_interval_s
seconds_to_boundary = max(1, int((boundary - now).total_seconds()))
if in_quiet:
return seconds_to_boundary
return min(cfg.refresh_interval_s, seconds_to_boundary)
def in_quiet_hours(cfg) -> bool:
"""Whether quiet hours are in effect right now -- separate from
effective_refresh_interval_s, which only shapes what the *device* is
told to sleep for. This instead gates photo_queue.get_current()'s
time-based advance, since that check runs independent of the device
(also triggered by the web UI's queue endpoint, e.g. an open browser
tab polling overnight) and would otherwise happily advance the
current photo mid-quiet-hours on raw elapsed time alone."""
if not cfg.quiet_hours_enabled:
return False
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, _ = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
return in_quiet
def max_expected_gap_s(cfg) -> int:
"""Longest gap between wakes the device might legitimately have --
normally just refresh_interval_s, but quiet hours can make the real
gap much longer, and the "overdue" check shouldn't mistake a device
quietly sleeping through the night for a dead one."""
gap = cfg.refresh_interval_s
if cfg.quiet_hours_enabled:
gap = max(gap, _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end))
return gap