From 759c042f92ca0e0ccfbbead5f4fbc890e58092e4 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 21 Jul 2026 00:05:46 -0400 Subject: [PATCH] Server: stop the photo from advancing during quiet hours too Quiet hours only clamped what refresh_interval_s the *device* is told to sleep for -- the separate elapsed-time check in photo_queue.get_current() (used by /frame/image, /frame/photo-info, /frame/face-labels, and /api/queue) had no quiet-hours awareness at all, since it runs independent of the device. An open web UI tab polling /api/queue overnight, or just loading the page during a quiet window, would silently advance which photo is "current" on raw elapsed time alone -- nothing reaches the panel until the device wakes after quiet hours end, but the pointer moving mid-window still isn't what "don't do anything overnight" implies. get_current() now takes an in_quiet_hours flag that suppresses only the elapsed-time trigger; an unset/invalid current photo is still picked regardless (showing nothing is worse than showing something even at 3am). New _in_quiet_hours() helper in main.py, passed at all four call sites. --- server/app/main.py | 23 +++++++++++++++++++---- server/app/photo_queue.py | 21 ++++++++++++++------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/server/app/main.py b/server/app/main.py index 163c099..fc6d7a2 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -129,6 +129,21 @@ def _effective_refresh_interval_s(cfg: config.FrameConfig) -> int: return min(cfg.refresh_interval_s, seconds_to_boundary) +def _in_quiet_hours(cfg: config.FrameConfig) -> 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 /api/queue, 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: config.FrameConfig) -> int: """Longest gap between wakes the device might legitimately have -- normally just refresh_interval_s, but quiet hours can make the real @@ -350,7 +365,7 @@ def frame_image(): with config.locked(): cfg = config.load() # re-read: state may have changed since the unlocked read above - if photo_queue.get_current(cfg, assets): + if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)): config.save(cfg) return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream") @@ -593,7 +608,7 @@ def frame_photo_info(): with config.locked(): cfg = config.load() # re-read: state may have changed since the unlocked read above - if photo_queue.get_current(cfg, assets): + if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)): config.save(cfg) if not cfg.current_asset_id: @@ -662,7 +677,7 @@ def frame_face_labels(): with config.locked(): cfg = config.load() # re-read: state may have changed since the unlocked read above - if photo_queue.get_current(cfg, assets): + if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)): config.save(cfg) if not cfg.current_asset_id: @@ -703,7 +718,7 @@ def api_queue(): with config.locked(): cfg = config.load() # re-read: state may have changed since the unlocked read above - current_changed = photo_queue.get_current(cfg, assets) + current_changed = photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)) queue_before = list(cfg.queue) photo_queue.sync_queue_length(cfg, assets) if current_changed or cfg.queue != queue_before: diff --git a/server/app/photo_queue.py b/server/app/photo_queue.py index 101195a..120d456 100644 --- a/server/app/photo_queue.py +++ b/server/app/photo_queue.py @@ -180,7 +180,7 @@ def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None: _top_up(cfg, assets) -def get_current(cfg: FrameConfig, assets: list[dict]) -> bool: +def get_current(cfg: FrameConfig, assets: list[dict], in_quiet_hours: bool = False) -> bool: """Time-based, idempotent path used by GET /frame/image. Advances only if the current photo is unset/invalid or refresh_interval_s has elapsed since it was set. Returns whether it changed anything, so the @@ -188,13 +188,20 @@ def get_current(cfg: FrameConfig, assets: list[dict]) -> bool: the interval is a no-op both times -- what makes an unplanned device reboot safe: it just re-reads the current photo instead of skipping ahead, while a wake that lands after the interval has elapsed still - advances exactly once, even after a long time offline.""" + advances exactly once, even after a long time offline. + + in_quiet_hours suppresses *only* the elapsed-time trigger -- an + unset/invalid current photo still gets picked regardless, since + showing nothing is worse than showing something even at 3am. This + check runs independent of the device (also triggered by the web UI's + /api/queue), so without this an open browser tab polling overnight + would silently advance the current photo on raw elapsed time alone, + even though the device itself is correctly asleep through the + window (see main.py's _effective_refresh_interval_s).""" valid_ids = {a["id"] for a in assets} - stale = ( - not cfg.current_asset_id - or cfg.current_asset_id not in valid_ids - or (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s - ) + needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids + time_elapsed = (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s + stale = needs_pick or (time_elapsed and not in_quiet_hours) if not stale: return False advance_forced(cfg, assets)