Files
Paperwall/scheduler.py
T
Honey 9aacd4936a 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.
2026-07-26 21:27:59 +02:00

265 lines
10 KiB
Python

"""Decides what should be on screen right now, renders it, and only pushes to
the panel when the rendered image actually changed.
Design goals (in reaction to the InkyPi issues that motivated this rewrite):
- One short tick (a few seconds), not a long sleep-until-interval -- config
changes and manual actions take effect almost immediately, no signaling
machinery needed.
- Navidrome can preempt the rotation the instant something's playing,
without needing to be "its turn" in a round-robin.
- A screen is only ever fully re-rendered+pushed to hardware when its
rendered image differs from what's currently displayed.
"""
import hashlib
import logging
import threading
import time
from datetime import date
from pathlib import Path
from PIL import Image
logger = logging.getLogger(__name__)
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."""
def __init__(self):
self.state = None
self.checked_at: float = 0.0
self.checked_date: date | None = None
def is_stale(self, poll_interval: int) -> bool:
# Also stale across a local-midnight rollover, independent of
# poll_interval -- otherwise a screen whose data is inherently
# date-bound (weather's daily forecast labels, its "today" hourly
# graph) keeps showing yesterday's data for up to poll_interval
# after midnight, since the cached state was genuinely correct
# when fetched, just not anymore now that the date has turned over.
if self.checked_date != date.today():
return True
return (time.time() - self.checked_at) >= poll_interval
class Scheduler:
def __init__(self, config, screens: dict, display):
self.config = config
self.screens = screens
self.display = display
self._caches = {screen_id: ScreenCache() for screen_id in screens}
self._rotation_index = 0
self._rotation_switched_at = time.time()
self._last_image_hash: str | None = None
self._force_once = False
self._wake = threading.Event()
self._running = False
self._thread: threading.Thread | None = None
self._status_lock = threading.Lock()
self._status = {"current_screen": None, "last_render_at": None, "last_display_write_at": None}
# -------------------------------------------------------------- public
def start(self):
if self._thread and self._thread.is_alive():
return
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
logger.info("Scheduler started")
def stop(self):
self._running = False
self._wake.set()
if self._thread:
self._thread.join()
def request_manual_refresh(self):
self._force_once = True
self._wake.set()
def request_switch(self, screen_id: str):
"""Pin a screen indefinitely (persisted in config, turns rotation
off). Navidrome priority can still preempt it if something starts
playing -- that's the one thing this app promises to always show
reliably -- but rotation won't, until it's explicitly re-enabled."""
self.config.pin_screen(screen_id)
self._wake.set()
def request_resume_rotation(self):
self.config.resume_rotation()
self._wake.set()
def invalidate_screen(self, screen_id: str):
"""Force the next tick to re-check() this screen instead of reusing
its cached state, however much of its poll_interval is left.
Without this, a settings change (e.g. weather's forecast_days) sits
in config.json immediately but the screen keeps rendering whatever
it last fetched under the *old* settings until its own poll_interval
happens to elapse -- up to 30 minutes for weather's default."""
cache = self._caches.get(screen_id)
if cache is not None:
cache.checked_at = 0.0
self._wake.set()
def get_status(self) -> dict:
with self._status_lock:
status = dict(self._status)
rotation = self.config.get_rotation()
pinned = self.config.get_pinned_screen()
status["pinned_screen"] = pinned if (pinned and not rotation.get("enabled", True)) else None
return status
# ------------------------------------------------------------- internal
def _run(self):
while self._running:
try:
self._tick()
except Exception:
logger.exception("Scheduler tick failed")
self._wake.wait(timeout=TICK_SECONDS)
self._wake.clear()
def _tick(self):
screen_id, state, did_check = self._select_screen()
screen = self.screens[screen_id]
settings = self.config.get_screen_settings(screen_id)
resolution = self.display.resolution
image = screen.render(state, settings, resolution)
image_hash = hashlib.sha256(image.tobytes()).hexdigest()
self._save_preview(image)
with self._status_lock:
self._status["current_screen"] = screen_id
self._status["last_render_at"] = time.time()
if image_hash != self._last_image_hash or self._force_once:
logger.info("Content changed (or forced) -- writing to display. screen=%s", screen_id)
self.display.show(image)
self.display.sleep()
self._last_image_hash = image_hash
self._force_once = False
with self._status_lock:
self._status["last_display_write_at"] = time.time()
else:
logger.debug("No change for screen=%s, skipping display write", screen_id)
if did_check:
self.config.save()
def _select_screen(self) -> tuple[str, object, bool]:
"""Returns (screen_id, state, did_check_happen).
Priority order: Navidrome-when-playing (if enabled) always wins --
that's the one thing this app promises to show reliably. Then a
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()
if pinned and pinned in self.screens:
return self._poll_screen(pinned)
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]
if not screen_ids:
# Nothing configured to rotate through -- fall back to whatever
# exists so we're never stuck showing nothing.
screen_ids = list(self.screens.keys())
now = time.time()
# With rotation disabled, just pin to whatever's currently selected --
# never auto-advance.
if rotation.get("enabled", True):
interval_seconds = rotation["interval_minutes"] * 60
if now - self._rotation_switched_at >= interval_seconds:
self._rotation_index = (self._rotation_index + 1) % len(screen_ids)
self._rotation_switched_at = now
screen_id = screen_ids[self._rotation_index % len(screen_ids)]
return self._poll_screen(screen_id)
def _poll_screen(self, screen_id: str) -> tuple[str, object, bool]:
cache = self._caches[screen_id]
screen = self.screens[screen_id]
settings = self.config.get_screen_settings(screen_id)
checked = False
if cache.is_stale(screen.get_poll_interval(settings)):
cache.state = self._check(screen_id)
cache.checked_at = time.time()
cache.checked_date = date.today()
checked = True
return screen_id, cache.state, checked
def _check(self, screen_id: str):
screen = self.screens[screen_id]
settings = self.config.get_screen_settings(screen_id)
try:
return screen.check(settings)
except Exception:
logger.exception("check() failed for screen=%s", screen_id)
return None
def _save_preview(self, image: Image.Image):
try:
PREVIEW_PATH.parent.mkdir(parents=True, exist_ok=True)
image.save(PREVIEW_PATH)
except OSError:
logger.exception("Could not write preview image")