From fd9516f5d11d80b4191200e87028247669f50723 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 20 Jul 2026 23:25:43 -0400 Subject: [PATCH] Server: quiet hours (no wake overnight), zero firmware changes needed Purely a server-side decision: GET /frame/config hands back a longer refresh_interval_s while quiet hours are in effect (exactly the seconds until they end), and clamps the normal interval so the device's next wake lands at the boundary instead of wandering into the window, when outside it but approaching. A device already mid-sleep when quiet hours begin can still land one wake inside the window -- unavoidable without touching the firmware, since it has no wall-clock awareness -- but from that wake on it sleeps straight through to the end. Window is "HH:MM"-"HH:MM", wrap-past-midnight aware (e.g. 22:00-07:00), in the server's local timezone -- added tzdata to the Dockerfile since python:3.12-slim doesn't include it and TZ would otherwise silently resolve to nothing and fall back to UTC. Also fixed the "overdue" device-status check to account for quiet hours: without this it would falsely flag a device sleeping through a long quiet window as unreachable. --- server/Dockerfile | 7 +++ server/README.md | 19 ++++++- server/app/config.py | 10 ++++ server/app/main.py | 91 ++++++++++++++++++++++++++++++- server/app/templates/index.html | 17 ++++++ server/docker-compose.yml.example | 4 ++ 6 files changed, 143 insertions(+), 5 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 7917124..95bb660 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -2,6 +2,13 @@ FROM python:3.12-slim WORKDIR /app +# tzdata: python:3.12-slim doesn't include it by default, so a TZ +# environment variable (see docker-compose.yml.example -- used by the +# "Quiet hours" setting) would silently fail to resolve and fall back to +# UTC without this. +RUN apt-get update && apt-get install -y --no-install-recommends tzdata \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/server/README.md b/server/README.md index f14afb4..133dc7f 100644 --- a/server/README.md +++ b/server/README.md @@ -51,7 +51,8 @@ algorithm itself -- it just streams the response straight to the panel. toggle, upcoming-photos count, now-displaying + drag-to-reorder upcoming grid -- not Immich URL/API key, see Setup above) - `GET /api/albums` -- lists Immich albums (used by the config UI) -- `POST /api/config` -- saves album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len. +- `POST /api/config` -- saves + album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*. `orientation` (`landscape`, `portrait`, `landscape_flipped`, `portrait_flipped`) matches how the frame is physically hung: photos are composed/cropped for that shape (portrait crops at 480x800), then @@ -60,7 +61,21 @@ algorithm itself -- it just streams the response straight to the panel. (QRs, text, battery indicator, face labels) still renders in native panel orientation, so on a portrait-hung frame it appears rotated 90° to the viewer -- QR codes scan fine at any rotation, but the text - reads sideways. A known limitation, not planned to change soon + reads sideways. A known limitation, not planned to change soon. + `quiet_hours_enabled`/`quiet_hours_start`/`quiet_hours_end` + (`"HH:MM"`, may wrap past midnight, e.g. `22:00`-`07:00`) don't touch + the device at all -- purely a server decision about what + `refresh_interval_s` to hand back from `GET /frame/config` below, + computed in `_effective_refresh_interval_s`. Uses the server's local + timezone (`TZ` in `docker-compose.yml.example` -- the image needs + `tzdata` for a named zone to actually resolve, already installed in + the provided `Dockerfile`). The device can still land one wake right + at the start of the window (nothing server-side can prevent that + without touching the firmware, since the device doesn't know wall-clock + time), but from that wake on it's told to sleep exactly until the + window ends. The "overdue" indicator in `/api/queue`'s `device` object + also accounts for this -- it won't falsely flag a device that's + legitimately sleeping through a long quiet-hours window - `GET /frame/image` -- returns the current photo pre-processed into the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format (`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free** diff --git a/server/app/config.py b/server/app/config.py index 1bd4e26..b4ad416 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -26,6 +26,16 @@ class FrameConfig(BaseModel): album_id: str = "" order: str = "sequential" # or "shuffle" refresh_interval_s: int = 3600 + # Quiet hours: no point waking the device overnight just to swap a + # photo nobody's looking at. Times are "HH:MM" in the server's local + # timezone (see docker-compose.yml.example's TZ note) and may wrap + # past midnight (e.g. start=22:00, end=07:00). Purely a server-side + # decision -- the device is unaware, it just gets told a longer + # refresh_interval_s by GET /frame/config while quiet hours are in + # effect (see main.py's _effective_refresh_interval_s). + quiet_hours_enabled: bool = False + quiet_hours_start: str = "22:00" + quiet_hours_end: str = "07:00" smart_crop_faces: bool = True # How the physical frame is hung: landscape (native), portrait, # landscape_flipped, portrait_flipped. Purely a server-side render diff --git a/server/app/main.py b/server/app/main.py index fbf46fc..119fe97 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -6,7 +6,7 @@ from __future__ import annotations import io import logging import time -from datetime import datetime +from datetime import datetime, timedelta import httpx from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile @@ -46,6 +46,83 @@ MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating OVERDUE_FACTOR = 1.5 +def _valid_hhmm(s: str) -> bool: + try: + datetime.strptime(s, "%H:%M") + return True + except ValueError: + return False + + +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: config.FrameConfig) -> 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() + 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 _max_expected_gap_s(cfg: config.FrameConfig) -> 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 (see api_queue) 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 + + def _touch_last_seen() -> None: """Records that the device just made contact. Called by every /frame/* route -- a handful of extra config writes per wake cycle, @@ -108,7 +185,7 @@ def frame_config(request: Request): cfg.device_firmware_version = reported_version config.save(cfg) return { - "refresh_interval_s": cfg.refresh_interval_s, + "refresh_interval_s": _effective_refresh_interval_s(cfg), "firmware_version": cfg.firmware_available_version or None, } @@ -154,6 +231,9 @@ def api_config_save( smart_crop_faces: bool = Form(True), queue_target_len: int = Form(20), orientation: str = Form("landscape"), + quiet_hours_enabled: bool = Form(False), + quiet_hours_start: str = Form("22:00"), + quiet_hours_end: str = Form("07:00"), ): # Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see # docker-compose.yml.example) -- config.load() already applies them, @@ -176,6 +256,11 @@ def api_config_save( cfg.smart_crop_faces = smart_crop_faces cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape" + cfg.quiet_hours_enabled = quiet_hours_enabled + if _valid_hhmm(quiet_hours_start): + cfg.quiet_hours_start = quiet_hours_start + if _valid_hhmm(quiet_hours_end): + cfg.quiet_hours_end = quiet_hours_end config.save(cfg) return {"status": "saved"} @@ -598,7 +683,7 @@ def api_queue(): "upcoming": [entry(asset_id) for asset_id in cfg.queue], "device": { "last_seen": cfg.last_seen or None, - "overdue": bool(cfg.last_seen and now - cfg.last_seen > cfg.refresh_interval_s * OVERDUE_FACTOR), + "overdue": bool(cfg.last_seen and now - cfg.last_seen > _max_expected_gap_s(cfg) * OVERDUE_FACTOR), "firmware_version": cfg.device_firmware_version or None, "firmware_available": cfg.firmware_available_version or None, "battery": ( diff --git a/server/app/templates/index.html b/server/app/templates/index.html index 887f49c..8fa8275 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -99,6 +99,20 @@ +
+ + +
+ + +

Uses the server's local timezone (see TZ in + docker-compose.yml.example). The device may still + wake once right at the start of quiet hours -- it can't know + ahead of time -- but goes right back to sleep until they end.