Add time-based screen scheduling (e.g. Journey every morning 6-8)

New "Time-based schedule" section on the Rotation settings page: up to
4 rules, each a screen + a daily HH:MM-HH:MM window (crossing midnight
is fine, e.g. 22:00-02:00). While a rule's window is active, that
screen overrides rotation and any manual pin -- without touching
either's underlying state, so things go back to exactly how they were
once the window ends. Navidrome-playing still wins on top of a
schedule rule, same as it already does over everything else.

config.py: new schedules list (get_schedules/set_schedules), each rule
just {screen_id, start, end} -- being in the list *is* being enabled,
no separate flag (a settings row is "disabled" by leaving its screen
dropdown on "-- none --").

scheduler.py: _time_in_window() handles same-day and midnight-wrapping
windows, treats a zero-width window as never-matching (more likely a
blank settings row than an intentional all-day rule).
_active_scheduled_screen() slots into _select_screen() between
Navidrome-priority and the pin/rotation fallback.

Verified: unit tests for _time_in_window's edge cases (wraparound,
zero-width, malformed input) and the priority override, plus an
end-to-end run against the real app -- pinned a screen, added a rule
active right now, confirmed the status page switched to the scheduled
screen within one tick.
This commit is contained in:
Honey committed 2026-07-26 21:27:59 +02:00
1 parent 33b964f1f6
commit 9aacd4936a
5 files changed
+103 -3

No files matched your search

+38 -2
View File
@@ -26,6 +26,24 @@ TICK_SECONDS = 5
PREVIEW_PATH = Path(__file__).resolve().parent / "web" / "static" / "preview.png"
def _time_in_window(now_minutes: int, start: str, end: str) -> bool:
"""start/end are "HH:MM" local time. Handles windows that wrap past
midnight (e.g. 22:00-02:00); a zero-width window (start == end) never
matches rather than meaning "all day", since that's more likely a blank
or half-filled settings row than an intentional all-day rule."""
try:
sh, sm = (int(part) for part in start.split(":"))
eh, em = (int(part) for part in end.split(":"))
except (ValueError, AttributeError):
return False
start_m, end_m = sh * 60 + sm, eh * 60 + em
if start_m == end_m:
return False
if start_m < end_m:
return start_m <= now_minutes < end_m
return now_minutes >= start_m or now_minutes < end_m
class ScreenCache:
"""Remembers a screen's last check() result so we don't re-poll faster
than its own poll_interval, even if the rotation/tick cadence is tighter."""
@@ -163,14 +181,23 @@ class Scheduler:
Priority order: Navidrome-when-playing (if enabled) always wins --
that's the one thing this app promises to show reliably. Then a
manual "show this now" pin, if rotation is off because of one (set
together in Config.pin_screen()). Then normal rotation.
time-based schedule rule, if one's active right now (e.g. "show
Journey 06:00-08:00") -- this deliberately outranks a manual pin and
rotation, so it reliably surfaces even if something else was pinned
overnight, and reverts back on its own once the window ends without
needing to touch whatever pin/rotation state was already there.
Then a manual "show this now" pin, if rotation is off because of one
(set together in Config.pin_screen()). Then normal rotation.
"""
if self.config.get_navidrome_priority() and "navidrome" in self.screens:
screen_id, state, checked = self._poll_screen("navidrome")
if state is not None:
return screen_id, state, checked
scheduled = self._active_scheduled_screen()
if scheduled:
return self._poll_screen(scheduled)
rotation = self.config.get_rotation()
if not rotation.get("enabled", True):
pinned = self.config.get_pinned_screen()
@@ -179,6 +206,15 @@ class Scheduler:
return self._rotation_screen()
def _active_scheduled_screen(self) -> str | None:
now = time.localtime()
now_minutes = now.tm_hour * 60 + now.tm_min
for rule in self.config.get_schedules():
screen_id = rule.get("screen_id")
if screen_id in self.screens and _time_in_window(now_minutes, rule.get("start", ""), rule.get("end", "")):
return screen_id
return None
def _rotation_screen(self) -> tuple[str, object, bool]:
rotation = self.config.get_rotation()
screen_ids = [s for s in rotation["screens"] if s in self.screens]