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:
1 parent
33b964f1f6
commit
9aacd4936a
5 files changed
+103
-3
No files matched your search
+38
-2
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user