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

+17
View File
@@ -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"])
+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]
+13
View File
@@ -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
+1 -1
View File
@@ -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;
+34
View File
@@ -35,6 +35,40 @@
</div>
</div>
<div class="card">
<div class="section-label">Time-based schedule</div>
<p class="hint" style="margin: -0.25rem 0 1rem;">
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.
</p>
{% for i in range(1, max_schedule_rules + 1) %}
{% set rule = schedules[i - 1] if i - 1 < schedules|length else None %}
<div class="field" style="display: flex; gap: 0.6rem; align-items: flex-end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 160px;">
<label for="schedule_screen_{{ i }}">Screen</label>
<select id="schedule_screen_{{ i }}" name="schedule_screen_{{ i }}">
<option value="">-- none --</option>
{% for screen_id, screen in screens.items() | sort(attribute="1.display_name") %}
<option value="{{ screen_id }}" {{ "selected" if rule and rule.screen_id == screen_id }}>{{ screen.display_name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="schedule_start_{{ i }}">From</label>
<input type="time" id="schedule_start_{{ i }}" name="schedule_start_{{ i }}" value="{{ rule.start if rule else '' }}">
</div>
<div>
<label for="schedule_end_{{ i }}">To</label>
<input type="time" id="schedule_end_{{ i }}" name="schedule_end_{{ i }}" value="{{ rule.end if rule else '' }}">
</div>
</div>
{% endfor %}
</div>
<button type="submit">Save</button>
</form>
{% endblock %}