Server: quiet hours (no wake overnight), zero firmware changes needed
Build and push server image / build-and-push (push) Successful in 36s

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.
This commit is contained in:
2026-07-20 23:25:43 -04:00
parent a7b6c6d77a
commit fd9516f5d1
6 changed files with 143 additions and 5 deletions
+7
View File
@@ -2,6 +2,13 @@ FROM python:3.12-slim
WORKDIR /app 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+17 -2
View File
@@ -51,7 +51,8 @@ algorithm itself -- it just streams the response straight to the panel.
toggle, upcoming-photos count, now-displaying + drag-to-reorder toggle, upcoming-photos count, now-displaying + drag-to-reorder
upcoming grid -- not Immich URL/API key, see Setup above) upcoming grid -- not Immich URL/API key, see Setup above)
- `GET /api/albums` -- lists Immich albums (used by the config UI) - `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`, `orientation` (`landscape`, `portrait`, `landscape_flipped`,
`portrait_flipped`) matches how the frame is physically hung: photos `portrait_flipped`) matches how the frame is physically hung: photos
are composed/cropped for that shape (portrait crops at 480x800), then 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 (QRs, text, battery indicator, face labels) still renders in native
panel orientation, so on a portrait-hung frame it appears rotated 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 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 - `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free** (`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
+10
View File
@@ -26,6 +26,16 @@ class FrameConfig(BaseModel):
album_id: str = "" album_id: str = ""
order: str = "sequential" # or "shuffle" order: str = "sequential" # or "shuffle"
refresh_interval_s: int = 3600 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 smart_crop_faces: bool = True
# How the physical frame is hung: landscape (native), portrait, # How the physical frame is hung: landscape (native), portrait,
# landscape_flipped, portrait_flipped. Purely a server-side render # landscape_flipped, portrait_flipped. Purely a server-side render
+88 -3
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import io import io
import logging import logging
import time import time
from datetime import datetime from datetime import datetime, timedelta
import httpx import httpx
from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile 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 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: def _touch_last_seen() -> None:
"""Records that the device just made contact. Called by every """Records that the device just made contact. Called by every
/frame/* route -- a handful of extra config writes per wake cycle, /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 cfg.device_firmware_version = reported_version
config.save(cfg) config.save(cfg)
return { return {
"refresh_interval_s": cfg.refresh_interval_s, "refresh_interval_s": _effective_refresh_interval_s(cfg),
"firmware_version": cfg.firmware_available_version or None, "firmware_version": cfg.firmware_available_version or None,
} }
@@ -154,6 +231,9 @@ def api_config_save(
smart_crop_faces: bool = Form(True), smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20), queue_target_len: int = Form(20),
orientation: str = Form("landscape"), 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 # Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see
# docker-compose.yml.example) -- config.load() already applies them, # 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.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) 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.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) config.save(cfg)
return {"status": "saved"} return {"status": "saved"}
@@ -598,7 +683,7 @@ def api_queue():
"upcoming": [entry(asset_id) for asset_id in cfg.queue], "upcoming": [entry(asset_id) for asset_id in cfg.queue],
"device": { "device": {
"last_seen": cfg.last_seen or None, "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_version": cfg.device_firmware_version or None,
"firmware_available": cfg.firmware_available_version or None, "firmware_available": cfg.firmware_available_version or None,
"battery": ( "battery": (
+17
View File
@@ -99,6 +99,20 @@
<input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}> <input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}>
<label for="smart_crop_faces">Center faces in crop</label> <label for="smart_crop_faces">Center faces in crop</label>
</div> </div>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if cfg.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ cfg.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
</label>
<p class="sub">Uses the server's local timezone (see <code>TZ</code> in
<code>docker-compose.yml.example</code>). 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.</p>
<label>Upcoming photos to show <label>Upcoming photos to show
<select id="queue_target_len"> <select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %} {% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
@@ -151,6 +165,9 @@
refresh_interval_s: String(minutes * 60), refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked), smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
queue_target_len: document.getElementById('queue_target_len').value, queue_target_len: document.getElementById('queue_target_len').value,
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
}); });
const resp = await fetch('/api/config', { const resp = await fetch('/api/config', {
method: 'POST', method: 'POST',
+4
View File
@@ -10,6 +10,10 @@ services:
- CONFIG_PATH=/data/config.json - CONFIG_PATH=/data/config.json
- IMMICH_URL=http://your-immich-host:2283 - IMMICH_URL=http://your-immich-host:2283
- IMMICH_API_KEY=your-immich-api-key-here - IMMICH_API_KEY=your-immich-api-key-here
# Set this to your local timezone (e.g. America/New_York) if you use
# the "Quiet hours" setting -- without it, the container defaults to
# UTC, and quiet hours would run on UTC clock time instead of yours.
- TZ=UTC
# Optional: gates the entire server -- the web UI (/, /api/*) AND # Optional: gates the entire server -- the web UI (/, /api/*) AND
# every device-facing /frame/* endpoint -- behind this shared secret. # every device-facing /frame/* endpoint -- behind this shared secret.
# Leave unset to keep it all open on a trusted LAN, same as before. # Leave unset to keep it all open on a trusted LAN, same as before.