Weather's daily forecast starts with "today" as of when check() last ran -- correct at fetch time, but that cached state sits until the next poll (up to update_minutes later, 30 min by default). Right after local midnight, that means the forecast keeps showing the old day first (e.g. Fr Sa So Mo Di at 00:30 Saturday) until the next scheduled poll happens to land after the rollover. ScreenCache now also tracks the calendar date it was last checked on and treats itself as stale on a date change, independent of poll_interval -- so the very next tick after midnight re-checks weather (and any other date-sensitive screen) immediately instead of waiting out the rest of the interval. Verified with a unit test simulating a cache populated "yesterday".
229 lines
8.8 KiB
Python
229 lines
8.8 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"
|
|
|
|
|
|
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
|
|
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
|
|
|
|
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 _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")
|