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["rotation"].setdefault("enabled", DEFAULT_ROTATION["enabled"])
|
||||||
data.setdefault("navidrome_priority", True)
|
data.setdefault("navidrome_priority", True)
|
||||||
data.setdefault("pinned_screen", None)
|
data.setdefault("pinned_screen", None)
|
||||||
|
data.setdefault("schedules", [])
|
||||||
data.setdefault("settings", {})
|
data.setdefault("settings", {})
|
||||||
for screen_id, screen in screens.items():
|
for screen_id, screen in screens.items():
|
||||||
data["settings"].setdefault(screen_id, {})
|
data["settings"].setdefault(screen_id, {})
|
||||||
@@ -108,6 +109,22 @@ class Config:
|
|||||||
self._data["pinned_screen"] = None
|
self._data["pinned_screen"] = None
|
||||||
self.save()
|
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:
|
def get_navidrome_priority(self) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return bool(self._data["navidrome_priority"])
|
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"
|
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:
|
class ScreenCache:
|
||||||
"""Remembers a screen's last check() result so we don't re-poll faster
|
"""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."""
|
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 --
|
Priority order: Navidrome-when-playing (if enabled) always wins --
|
||||||
that's the one thing this app promises to show reliably. Then a
|
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
|
time-based schedule rule, if one's active right now (e.g. "show
|
||||||
together in Config.pin_screen()). Then normal rotation.
|
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:
|
if self.config.get_navidrome_priority() and "navidrome" in self.screens:
|
||||||
screen_id, state, checked = self._poll_screen("navidrome")
|
screen_id, state, checked = self._poll_screen("navidrome")
|
||||||
if state is not None:
|
if state is not None:
|
||||||
return screen_id, state, checked
|
return screen_id, state, checked
|
||||||
|
|
||||||
|
scheduled = self._active_scheduled_screen()
|
||||||
|
if scheduled:
|
||||||
|
return self._poll_screen(scheduled)
|
||||||
|
|
||||||
rotation = self.config.get_rotation()
|
rotation = self.config.get_rotation()
|
||||||
if not rotation.get("enabled", True):
|
if not rotation.get("enabled", True):
|
||||||
pinned = self.config.get_pinned_screen()
|
pinned = self.config.get_pinned_screen()
|
||||||
@@ -179,6 +206,15 @@ class Scheduler:
|
|||||||
|
|
||||||
return self._rotation_screen()
|
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]:
|
def _rotation_screen(self) -> tuple[str, object, bool]:
|
||||||
rotation = self.config.get_rotation()
|
rotation = self.config.get_rotation()
|
||||||
screen_ids = [s for s in rotation["screens"] if s in self.screens]
|
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"}
|
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"}
|
||||||
PIL_FORMAT_TO_EXT = {"JPEG": "jpg", "PNG": "png", "GIF": "gif", "BMP": "bmp", "WEBP": "webp"}
|
PIL_FORMAT_TO_EXT = {"JPEG": "jpg", "PNG": "png", "GIF": "gif", "BMP": "bmp", "WEBP": "webp"}
|
||||||
PICTURE_URL_TIMEOUT = 15
|
PICTURE_URL_TIMEOUT = 15
|
||||||
|
MAX_SCHEDULE_RULES = 4
|
||||||
PICTURE_URL_HEADERS = {
|
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",
|
"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
|
enabled = "rotation_enabled" in request.form
|
||||||
config.set_rotation(selected, interval_minutes, enabled)
|
config.set_rotation(selected, interval_minutes, enabled)
|
||||||
config.set_navidrome_priority("navidrome_priority" in request.form)
|
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 redirect(url_for("settings_rotation", saved=1))
|
||||||
return render_template(
|
return render_template(
|
||||||
"settings_rotation.html",
|
"settings_rotation.html",
|
||||||
screens=screens,
|
screens=screens,
|
||||||
rotation=config.get_rotation(),
|
rotation=config.get_rotation(),
|
||||||
navidrome_priority=config.get_navidrome_priority(),
|
navidrome_priority=config.get_navidrome_priority(),
|
||||||
|
schedules=config.get_schedules(),
|
||||||
|
max_schedule_rules=MAX_SCHEDULE_RULES,
|
||||||
)
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
label { display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; }
|
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; }
|
.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%;
|
width: 100%;
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -35,6 +35,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in new issue
Block a user