mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-30 01:39:13 +00:00
feat(schedule): per-plugin schedules and hide-while-live (#799)
* feat(schedule): per-plugin schedules and hide-while-live Users asked to turn off news and stocks on gameday so only scores show. Each plugin can now have its own schedule (global or per-day window in which it may show) plus hide_when_live, which hides it whenever any plugin reports a live game. A hidden plugin is skipped in the rotation, the live takeover and the Vegas ticker; it stays loaded, and on-demand still shows it. - src/plugin_schedule.py: the window rule, now shared with the display schedule (_check_schedule calls schedule_state) - core config key plugin_schedules; GET/POST /api/v3/config/plugin-schedules - Schedule page: Plugin Schedules card, one picker per enabled plugin Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): tolerate wrong-shaped schedules, quiet skipped modes - schedule_state treats a non-string mode, non-dict days or a non-dict day entry as missing instead of raising in the display loop - the run loop no longer warns "No plugin manager found" for a mode it skips: _plugin_to_draw / _plugin_for_mode already log why (a scheduled-off plugin at DEBUG) - Schedule page DOM test: every form is marked data-reports-result, not exactly two (each plugin adds one) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): guard plugin_schedules shape and the cross-thread cache - a plugin_schedules that is not a dict (list, string) hides nothing instead of raising in the display loop - the hidden-set cache keys on the schedules themselves and is published as one tuple, so a Vegas-thread call still holding the pre-reload config cannot leave its answer standing after a reload; the separate invalidation in _refresh_config_cache is gone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): no live hold on a scheduled-off mode; blank an empty rotation - _still_live ignores a plugin its schedule hides, so a rotation landing on a scheduled-off live mode advances instead of holding a mode it skips - once a whole rotation has had nothing to show (every plugin empty or scheduled off), the panel is cleared once instead of leaving the last screen up Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): push the empty-rotation blank to the web preview clear() alone blanked the matrix but left the preview snapshot on the last frame (seen on ledpi with every plugin scheduled off). Follow it with update_display(), as the scheduled-off blank does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): write the empty-rotation blank to the preview The preview throttle skipped the blank frame (written 12 s after the last snapshot), and write_owed_snapshot() only runs after a display() call, which an all-empty rotation never makes: on ledpi the preview kept the startup splash while the panel was dark. Flush the owed snapshot on each paused empty pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): quiet the once-a-second logs when nothing can show With every plugin scheduled off (seen on ledpi), the loop retries once a second and journaled three to four lines each time: Vegas's "No plugins available" WARNING, the coordinator's "Failed to initialize stream manager" ERROR, the 0-plugin rotation summary and "Switching to mode". Vegas now warns once until its list fills again, the coordinator line is DEBUG (initialize only fails for an empty list), and the mode-switch line is DEBUG once a whole rotation has been empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(schedule): end the scroll before the empty blank; Vegas sees hidden changes sooner - the empty-rotation blank is controller-drawn, so it ends the last scroll's state first (_end_scroll_before_core_screen), as the scheduled-off blank does - StreamManager.refresh() rebuilds the plugin list when the hidden set changed, even inside its 30 s interval, so a newly hidden plugin leaves at the next scroll group rather than staying for later ones Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+109
-103
@@ -20,6 +20,7 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||
:meth:`~DisplayController.run`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import inspect
|
||||
@@ -37,6 +38,7 @@ import pytz
|
||||
|
||||
from src import display_watchdog
|
||||
from src.malloc_tuning import MallocTrimmer
|
||||
from src.plugin_schedule import hidden_plugins, in_window, schedule_state, wants_live_scan
|
||||
from src.display_arbiter import (
|
||||
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
|
||||
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
|
||||
@@ -705,6 +707,11 @@ class DisplayController:
|
||||
self._schedule_checked_minute: Optional[tuple] = None
|
||||
self._dim_checked_minute: Optional[tuple] = None
|
||||
self._cached_target_brightness: int = self._normal_brightness
|
||||
# Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py):
|
||||
# (key, hidden set), see _hidden_plugins.
|
||||
self._plugin_hidden_cache: Tuple[Optional[tuple], FrozenSet[str]] = (None, frozenset())
|
||||
self._game_live = False
|
||||
self._game_live_at = float('-inf')
|
||||
|
||||
# Register controller-level hot-reload callback so cached config values
|
||||
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||
@@ -751,6 +758,8 @@ class DisplayController:
|
||||
|
||||
# Set up live priority checker
|
||||
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
|
||||
# Plugins scheduled off sit out the ticker too.
|
||||
self.vegas_coordinator.stream_manager.hidden_plugins = self._hidden_plugins
|
||||
|
||||
# Set up interrupt checker for on-demand/wifi status and follower mode
|
||||
def _vegas_interrupt():
|
||||
@@ -912,38 +921,19 @@ class DisplayController:
|
||||
self._tz = pytz.UTC
|
||||
return self._tz
|
||||
|
||||
@staticmethod
|
||||
def _in_window(start, end, now) -> bool:
|
||||
"""Whether ``now`` is within [start, end), a window that may span midnight.
|
||||
|
||||
Half-open: on from the start minute, off at exactly the end minute.
|
||||
A closed end made the end minute count as inside, and because ``now``
|
||||
carries seconds, only a check at hh:mm:00.000 saw it that way -- so
|
||||
whether the panel went off at the start or the end of that minute
|
||||
depended on when the minute's one check ran. ``start == end`` is an
|
||||
empty window, as it effectively was before.
|
||||
"""
|
||||
if start <= end:
|
||||
return start <= now < end
|
||||
return now >= start or now < end
|
||||
# The window rule, shared with the dim schedule and plugin schedules.
|
||||
_in_window = staticmethod(in_window)
|
||||
|
||||
def _check_schedule(self):
|
||||
"""Check if display should be active based on schedule."""
|
||||
schedule_config = self.config.get('schedule', {})
|
||||
|
||||
# No schedule configured: always active.
|
||||
if not schedule_config:
|
||||
# No schedule, or a disabled one: always active.
|
||||
if not schedule_config or not schedule_config.get('enabled', True):
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True
|
||||
return
|
||||
|
||||
# A schedule without an 'enabled' key counts as enabled.
|
||||
if 'enabled' in schedule_config and not schedule_config.get('enabled', True):
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True
|
||||
logger.debug("Schedule is disabled - display always active")
|
||||
return
|
||||
|
||||
current_time = datetime.now(self._timezone())
|
||||
# Gate: schedule state can only change on a minute boundary, so skip
|
||||
# all the strptime / comparison work if we already evaluated this minute.
|
||||
@@ -952,81 +942,15 @@ class DisplayController:
|
||||
return
|
||||
self._schedule_checked_minute = current_minute_key
|
||||
|
||||
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||
current_time_only = current_time.time()
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
days_config = schedule_config.get('days')
|
||||
|
||||
# Determine which schedule to use. Respect an explicit 'mode' field
|
||||
# (like the dim schedule does) so a stray/legacy 'days' dict left over
|
||||
# from config migration or a prior per-day setup can't silently
|
||||
# override a user's Global schedule selection.
|
||||
mode = schedule_config.get('mode')
|
||||
mode_normalized = mode.replace('_', '-') if mode else None
|
||||
|
||||
use_per_day = False
|
||||
if mode_normalized == 'global':
|
||||
use_per_day = False
|
||||
elif mode_normalized == 'per-day':
|
||||
use_per_day = bool(days_config and current_day in days_config)
|
||||
elif days_config:
|
||||
# No explicit mode recorded (legacy config) - fall back to
|
||||
# inferring from presence of a 'days' dict for the current day.
|
||||
if current_day in days_config:
|
||||
use_per_day = True
|
||||
else:
|
||||
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
|
||||
|
||||
if use_per_day:
|
||||
day_config = days_config[current_day]
|
||||
|
||||
if not day_config.get('enabled', True):
|
||||
was_active = self._was_display_active
|
||||
self.is_display_active = False
|
||||
if was_active:
|
||||
logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day)
|
||||
else:
|
||||
logger.debug("Display inactive - %s is disabled in schedule", current_day)
|
||||
self._was_display_active = self.is_display_active
|
||||
return
|
||||
|
||||
start_time_str = day_config.get('start_time', '07:00')
|
||||
end_time_str = day_config.get('end_time', '23:00')
|
||||
schedule_type = f"per-day ({current_day})"
|
||||
active, why = schedule_state(schedule_config, current_time)
|
||||
if active != self._was_display_active:
|
||||
logger.info("Schedule activated: Display is now %s (%s)%s",
|
||||
"ACTIVE" if active else "INACTIVE", why,
|
||||
"" if active else ". Display will be blanked.")
|
||||
else:
|
||||
start_time_str = schedule_config.get('start_time', '07:00')
|
||||
end_time_str = schedule_config.get('end_time', '23:00')
|
||||
schedule_type = "global"
|
||||
|
||||
try:
|
||||
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||
self.is_display_active = self._in_window(start_time, end_time, current_time_only)
|
||||
|
||||
was_active = self._was_display_active
|
||||
if not self.is_display_active:
|
||||
if was_active:
|
||||
logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.",
|
||||
schedule_type, start_time_str, end_time_str)
|
||||
else:
|
||||
logger.debug("Display inactive - outside %s schedule window (%s - %s)",
|
||||
schedule_type, start_time_str, end_time_str)
|
||||
else:
|
||||
if not was_active:
|
||||
logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)",
|
||||
schedule_type, start_time_str, end_time_str)
|
||||
else:
|
||||
logger.debug("Display active - within %s schedule window (%s - %s)",
|
||||
schedule_type, start_time_str, end_time_str)
|
||||
|
||||
self._was_display_active = self.is_display_active
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.",
|
||||
schedule_type, e, start_time_str, end_time_str)
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True
|
||||
logger.debug("Display %s - %s", "active" if active else "inactive", why)
|
||||
self.is_display_active = active
|
||||
self._was_display_active = active
|
||||
|
||||
def _check_dim_schedule(self) -> int:
|
||||
"""
|
||||
@@ -1505,8 +1429,18 @@ class DisplayController:
|
||||
if self._empty_pass_streak < rotation:
|
||||
return
|
||||
if self._empty_pass_streak == rotation:
|
||||
# Blank once: with every plugin empty or scheduled off, the last
|
||||
# screen drawn would otherwise stay up indefinitely. A blank the
|
||||
# controller draws, so the last scroll's state ends first.
|
||||
self._end_scroll_before_core_screen()
|
||||
self.display_manager.clear()
|
||||
self.display_manager.update_display()
|
||||
logger.info("No mode has anything to show; checking one mode every %.0fs "
|
||||
"until one does", self.EMPTY_ROTATION_PAUSE)
|
||||
# The preview throttle may have skipped the blank frame, and with no
|
||||
# display() call to follow, nothing else writes it (see
|
||||
# DisplayManager.write_owed_snapshot). Cheap when nothing is owed.
|
||||
self.display_manager.write_owed_snapshot()
|
||||
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
|
||||
|
||||
#: Plugins already warned about a display duration that is not a number,
|
||||
@@ -2990,6 +2924,54 @@ class DisplayController:
|
||||
self.force_change = True
|
||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||
|
||||
#: How long a "is any game live?" answer is reused for plugin schedules.
|
||||
GAME_LIVE_TTL = 5.0
|
||||
|
||||
def _any_game_live(self) -> bool:
|
||||
"""Whether any plugin reports live content, asked at most every GAME_LIVE_TTL s.
|
||||
|
||||
Unlike _collect_live_modes this asks every plugin, live priority or
|
||||
not: a scoreboard without live priority still makes it gameday.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if now - self._game_live_at >= self.GAME_LIVE_TTL:
|
||||
self._game_live_at = now
|
||||
live = False
|
||||
for plugin in {id(p): p for p in list(self.plugin_modes.values())}.values():
|
||||
try:
|
||||
if hasattr(plugin, 'has_live_content') and plugin.has_live_content():
|
||||
live = True
|
||||
break
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.warning("Error checking live content for %s: %s",
|
||||
getattr(plugin, 'plugin_id', plugin), e)
|
||||
self._game_live = live
|
||||
return self._game_live
|
||||
|
||||
def _hidden_plugins(self) -> FrozenSet[str]:
|
||||
"""Plugin ids their plugin_schedules entry keeps off the panel right now.
|
||||
|
||||
Recomputed when the schedules, the minute or the live-game answer
|
||||
change. Also Vegas's filter (StreamManager.hidden_plugins), from its
|
||||
own thread: the schedules are part of the key and the cache is one
|
||||
tuple, so a call still holding the config from before a reload
|
||||
cannot leave its answer standing for the new one.
|
||||
"""
|
||||
schedules = self.config.get('plugin_schedules')
|
||||
if not isinstance(schedules, dict) or not schedules:
|
||||
return frozenset()
|
||||
now = datetime.now(self._timezone())
|
||||
game_live = wants_live_scan(schedules) and self._any_game_live()
|
||||
key = (schedules, now.hour, now.minute, game_live)
|
||||
cached_key, hidden = self._plugin_hidden_cache
|
||||
if key != cached_key:
|
||||
previous = hidden
|
||||
hidden = hidden_plugins(schedules, now, game_live)
|
||||
if hidden != previous:
|
||||
logger.info("Plugin schedules: hidden now %s", sorted(hidden) or "none")
|
||||
self._plugin_hidden_cache = (key, hidden)
|
||||
return hidden
|
||||
|
||||
def _collect_live_modes(self):
|
||||
"""Return every currently live-priority mode, in registration order.
|
||||
|
||||
@@ -3006,10 +2988,14 @@ class DisplayController:
|
||||
# Asked once per plugin per scan, not once per mode key: a scoreboard
|
||||
# registered under several modes computes the same answer each time.
|
||||
is_live: Dict[int, bool] = {}
|
||||
# A scoreboard scheduled off does not win the panel by going live.
|
||||
hidden = self._hidden_plugins()
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||
and hasattr(plugin_instance, 'has_live_content')):
|
||||
continue
|
||||
if getattr(plugin_instance, 'plugin_id', mode_name) in hidden:
|
||||
continue
|
||||
try:
|
||||
key = id(plugin_instance)
|
||||
if key not in is_live:
|
||||
@@ -3468,6 +3454,20 @@ class DisplayController:
|
||||
logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}")
|
||||
return plugin_instance
|
||||
|
||||
def _plugin_to_draw(self, plan: ScreenPlan):
|
||||
"""The plugin to draw ``plan``'s mode, or None to skip it this pass.
|
||||
|
||||
_plugin_for_mode, and None for a plugin its plugin_schedules entry
|
||||
keeps off the panel right now: skipped like an empty screen. An
|
||||
on-demand plan is the user asking, so it still shows.
|
||||
"""
|
||||
plugin = self._plugin_for_mode(plan.mode)
|
||||
if (plugin is not None and plan.source is not Source.ON_DEMAND
|
||||
and getattr(plugin, 'plugin_id', plan.mode) in self._hidden_plugins()):
|
||||
logger.debug("%s is scheduled off, skipping", plan.mode)
|
||||
return None
|
||||
return plugin
|
||||
|
||||
def _start_screen_handover(self, plugin, active_mode: str) -> bool:
|
||||
"""Before a screen's first dispatch: if the screen is static, keep
|
||||
the last scroll's leftovers off its first frame.
|
||||
@@ -3920,14 +3920,22 @@ class DisplayController:
|
||||
elif nxt is not state:
|
||||
self._adopt_state(nxt)
|
||||
self.force_change = True
|
||||
logger.info("Switching to mode: %s", self.current_display_mode)
|
||||
# DEBUG once a whole rotation has been empty (_note_empty_pass):
|
||||
# it then steps once a second, and every plugin scheduled off
|
||||
# would otherwise journal a line a second.
|
||||
logger.log(logging.DEBUG if self._empty_pass_streak >= max(1, len(self.available_modes))
|
||||
else logging.INFO,
|
||||
"Switching to mode: %s", self.current_display_mode)
|
||||
|
||||
def _still_live(self, mode: Optional[str]) -> bool:
|
||||
"""The live hold: ``mode``'s plugin has live priority and live content,
|
||||
so the rotation stays on it."""
|
||||
so the rotation stays on it. Never for a plugin scheduled off: the
|
||||
hold would keep the rotation on a mode it skips."""
|
||||
if mode not in self.plugin_modes:
|
||||
return False
|
||||
plugin_instance = self.plugin_modes[mode]
|
||||
if getattr(plugin_instance, 'plugin_id', mode) in self._hidden_plugins():
|
||||
return False
|
||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||
and hasattr(plugin_instance, 'has_live_content')):
|
||||
return False
|
||||
@@ -4201,10 +4209,8 @@ class DisplayController:
|
||||
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||
|
||||
# Handle plugin-based display modes
|
||||
manager_to_display = self._plugin_for_mode(active_mode)
|
||||
if not manager_to_display:
|
||||
logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode")
|
||||
manager_to_display = None
|
||||
# None skips the mode; _plugin_to_draw has logged why.
|
||||
manager_to_display = self._plugin_to_draw(plan)
|
||||
|
||||
# One screen: the first frame, the frame loop, the make-up
|
||||
# dwell (src/screen_runner.py).
|
||||
|
||||
Reference in New Issue
Block a user