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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user