From 9aacd4936a78f66b628a4e62f6aed340a823fd0e Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 26 Jul 2026 21:27:59 +0200 Subject: [PATCH] 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. --- config.py | 17 ++++++++++++ scheduler.py | 40 ++++++++++++++++++++++++++-- web/app.py | 13 +++++++++ web/templates/base.html | 2 +- web/templates/settings_rotation.html | 34 +++++++++++++++++++++++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/config.py b/config.py index 458eabd..8bbdd2a 100644 --- a/config.py +++ b/config.py @@ -43,6 +43,7 @@ class Config: data["rotation"].setdefault("enabled", DEFAULT_ROTATION["enabled"]) data.setdefault("navidrome_priority", True) data.setdefault("pinned_screen", None) + data.setdefault("schedules", []) data.setdefault("settings", {}) for screen_id, screen in screens.items(): data["settings"].setdefault(screen_id, {}) @@ -108,6 +109,22 @@ class Config: self._data["pinned_screen"] = None self.save() + # ----------------------------------------------------------- schedules + + def get_schedules(self) -> list[dict]: + """Each rule: {"screen_id", "start", "end"} with start/end as + "HH:MM" local time. A rule being in this list *is* it being + enabled -- there's no separate flag, disabling one is just removing + it (the settings form does this by leaving its screen dropdown on + "-- none --").""" + with self._lock: + return list(self._data["schedules"]) + + def set_schedules(self, schedules: list[dict]) -> None: + with self._lock: + self._data["schedules"] = schedules + self.save() + def get_navidrome_priority(self) -> bool: with self._lock: return bool(self._data["navidrome_priority"]) diff --git a/scheduler.py b/scheduler.py index fb93aa9..0cf968d 100644 --- a/scheduler.py +++ b/scheduler.py @@ -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] diff --git a/web/app.py b/web/app.py index 8fa8bdc..4abec5b 100644 --- a/web/app.py +++ b/web/app.py @@ -12,6 +12,7 @@ from screens.picture import UPLOAD_DIR ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"} PIL_FORMAT_TO_EXT = {"JPEG": "jpg", "PNG": "png", "GIF": "gif", "BMP": "bmp", "WEBP": "webp"} PICTURE_URL_TIMEOUT = 15 +MAX_SCHEDULE_RULES = 4 PICTURE_URL_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", } @@ -289,12 +290,24 @@ def create_app(config, screens: dict, scheduler) -> Flask: enabled = "rotation_enabled" in request.form config.set_rotation(selected, interval_minutes, enabled) config.set_navidrome_priority("navidrome_priority" in request.form) + + schedule_rules = [] + for i in range(1, MAX_SCHEDULE_RULES + 1): + rule_screen = request.form.get(f"schedule_screen_{i}", "").strip() + start = request.form.get(f"schedule_start_{i}", "").strip() + end = request.form.get(f"schedule_end_{i}", "").strip() + if rule_screen in screens and start and end: + schedule_rules.append({"screen_id": rule_screen, "start": start, "end": end}) + config.set_schedules(schedule_rules) + return redirect(url_for("settings_rotation", saved=1)) return render_template( "settings_rotation.html", screens=screens, rotation=config.get_rotation(), navidrome_priority=config.get_navidrome_priority(), + schedules=config.get_schedules(), + max_schedule_rules=MAX_SCHEDULE_RULES, ) return app diff --git a/web/templates/base.html b/web/templates/base.html index 91fc3ea..18bb171 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -99,7 +99,7 @@ label { display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; } .hint { font-size: 0.78rem; color: var(--text-muted); margin: 0.35rem 0 0; } - input[type=text], input[type=password], input[type=number], select, input[type=file] { + input[type=text], input[type=password], input[type=number], input[type=time], select, input[type=file] { width: 100%; padding: 0.6rem 0.7rem; font-size: 0.95rem; diff --git a/web/templates/settings_rotation.html b/web/templates/settings_rotation.html index ffe4acf..b86edac 100644 --- a/web/templates/settings_rotation.html +++ b/web/templates/settings_rotation.html @@ -35,6 +35,40 @@ +
+ +

+ Show a specific screen automatically during a daily time window -- + handy for e.g. checking bus departures every morning. Takes priority + over rotation and any manual pin while active (Navidrome priority + above still wins over this if enabled), and reverts back on its own + once the window ends. A window can cross midnight (e.g. 22:00-02:00). + Leave a row's screen on "-- none --" to disable it. +

+ {% for i in range(1, max_schedule_rules + 1) %} + {% set rule = schedules[i - 1] if i - 1 < schedules|length else None %} +
+
+ + +
+
+ + +
+
+ + +
+
+ {% endfor %} +
+ {% endblock %}