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:
Chuck
2026-10-18 16:16:47 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7c51c93707
commit 1fe7965f5c
19 changed files with 816 additions and 221 deletions
+10
View File
@@ -19,6 +19,16 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
### Added: plugin schedules
Schedule → Plugin Schedules sets when each plugin may show, per day or with
one time range, and can hide a plugin whenever any game is live ("no news or
stocks on gameday"). A hidden plugin is skipped in the rotation, the live
takeover and Vegas mode, but stays loaded. New core config key
`plugin_schedules`, new `GET/POST /api/v3/config/plugin-schedules`, new
module `src/plugin_schedule.py` (the display schedule's window rule now
lives there too).
### Removed: the cache-key mailboxes (control socket stage 5) -- breaking
The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the
+1
View File
@@ -47,6 +47,7 @@
}
}
},
"plugin_schedules": {},
"dim_schedule": {
"enabled": false,
"dim_brightness": 30,
+35
View File
@@ -55,6 +55,41 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
`end_time`.
## `plugin_schedules` — when each plugin may show
Maps a plugin id to a schedule shaped like `schedule` (the window in which
that plugin may show), plus:
| Key | Type / default | Meaning |
|---|---|---|
| `hide_when_live` | bool, `false` | Hide the plugin whenever any plugin reports a live game (its `has_live_content()`), live priority or not |
A plugin with no entry always shows. Outside its window, or during a live
game with `hide_when_live`, a plugin is skipped in the rotation, in the live
takeover and in Vegas mode; it stays loaded, and an on-demand request still shows it. Example, news off on
Sundays and during any live game:
```json
"plugin_schedules": {
"news": {"enabled": true, "mode": "per-day", "hide_when_live": true,
"start_time": "00:00", "end_time": "23:59",
"days": {"sunday": {"enabled": false}}}
}
```
As in `schedule`, a day missing from `days` uses the top-level times
(`07:00`–`23:00` when those are missing too). The window ends at `end_time`,
so `00:00`–`23:59` leaves the plugin out for that last minute.
A change takes effect at the next screen in the rotation or live takeover,
and in Vegas mode at its next plugin-list refresh: the next scroll group,
or at the latest the next scroll cycle (about 6 minutes on ledpi with 8
plugins). Content already composed into the ticker scrolls out first.
Read by `src/plugin_schedule.py` through `DisplayController._hidden_plugins()`.
Managed in the web UI under Schedule → Plugin Schedules, saved via
`POST /api/v3/config/plugin-schedules`.
## `display.hardware` — matrix panel hardware
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
+14
View File
@@ -2409,6 +2409,20 @@ enabled with default times `20:00`-`07:00`. As for the schedule above, an
enabled dim schedule needs at least one day enabled and a disabled one may
have every day off.
### Plugin Schedules
**GET** `/api/v3/config/plugin-schedules`
Every plugin's schedule (`plugin_schedules` in `docs/CONFIG_REFERENCE.md`),
keyed by plugin id.
**POST** `/api/v3/config/plugin-schedules`
Save one plugin's schedule: `plugin_id`, `hide_when_live`, and the same
fields as `POST /api/v3/config/schedule`. A schedule that is off and does
not hide during live games is removed. 400 for a core section name or a bad
time, 404 for a plugin that is not installed.
---
## Integrations
+1
View File
@@ -23,6 +23,7 @@ CORE_CONFIG_KEYS = frozenset({
'auto_update',
'schedule',
'dim_schedule',
'plugin_schedules',
'timezone',
'target_fps',
'location',
+109 -103
View File
@@ -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
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:
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})"
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).
+104
View File
@@ -0,0 +1,104 @@
"""Schedule windows: the display's on/off schedule and per-plugin schedules.
A schedule is the dict config.json keeps under ``schedule``::
{"enabled": true, "mode": "global" | "per-day",
"start_time": "07:00", "end_time": "23:00",
"days": {"monday": {"enabled": true, "start_time": ..., "end_time": ...}, ...}}
``config['plugin_schedules']`` maps a plugin id to one of these, the window in
which that plugin may take a turn on the panel, plus ``hide_when_live``: hide
the plugin while any plugin reports a live game ("no news or stocks on
gameday"). A plugin without an entry always shows. Hiding is a skip at
display time, never the plugin's ``enabled`` flag: flipping that unloads and
reloads the plugin and throws away its data.
"""
import logging
from datetime import datetime, time
from typing import Any, FrozenSet, Mapping, Tuple
logger = logging.getLogger(__name__)
def in_window(start: time, end: time, now: time) -> 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
def schedule_state(schedule: Mapping[str, Any], now: datetime) -> Tuple[bool, str]:
"""Whether ``now`` is inside ``schedule``, and a phrase saying why.
No schedule, or ``enabled: false``, is always inside; a schedule without
an ``enabled`` key counts as enabled. ``mode`` "global" ignores ``days``;
"per-day" (or "per_day") uses today's entry and falls back to the global
times when today has none; with no ``mode`` (older configs) a ``days``
entry for today wins. A disabled day is outside all day. A time that does
not parse is logged and counts as inside, so a bad edit never blanks the
panel.
"""
if not schedule or not schedule.get('enabled', True):
return True, 'no schedule'
day = now.strftime('%A').lower()
# Wrong-shaped values (the raw config editor allows any) are treated as
# missing rather than raised: this runs in the display loop.
days = schedule.get('days')
if not isinstance(days, Mapping):
days = {}
mode = schedule.get('mode')
mode = mode.replace('_', '-') if isinstance(mode, str) else None
# An explicit Global mode beats a stray 'days' dict left from a past
# per-day setup.
per_day = mode != 'global' and isinstance(days.get(day), Mapping)
if per_day:
day_config = days[day]
if not day_config.get('enabled', True):
return False, f'{day} is disabled'
start_str = day_config.get('start_time', '07:00')
end_str = day_config.get('end_time', '23:00')
kind = f'per-day ({day})'
else:
start_str = schedule.get('start_time', '07:00')
end_str = schedule.get('end_time', '23:00')
kind = 'global'
try:
start = datetime.strptime(start_str, '%H:%M').time()
end = datetime.strptime(end_str, '%H:%M').time()
except (TypeError, ValueError) as e:
logger.warning("Invalid %s schedule: %s (start: %s, end: %s). Treating as inside.",
kind, e, start_str, end_str)
return True, f'invalid {kind} schedule'
return in_window(start, end, now.time()), f'{kind} window {start_str} - {end_str}'
def wants_live_scan(plugin_schedules: Mapping[str, Any]) -> bool:
"""Whether any plugin hides during live games, so live content is worth asking about."""
return any(isinstance(s, Mapping) and s.get('hide_when_live')
for s in plugin_schedules.values())
def hidden_plugins(plugin_schedules: Mapping[str, Any], now: datetime,
game_live: bool) -> FrozenSet[str]:
"""The plugin ids that may not show at ``now``.
A plugin is hidden outside its schedule window, or while ``game_live``
when its entry sets ``hide_when_live``.
"""
return frozenset(
plugin_id for plugin_id, schedule in plugin_schedules.items()
if isinstance(schedule, Mapping)
and ((game_live and schedule.get('hide_when_live'))
or not schedule_state(schedule, now)[0]))
+3 -1
View File
@@ -303,7 +303,9 @@ class VegasModeCoordinator:
# Initialize stream manager
if not self.stream_manager.initialize():
logger.error("Failed to initialize stream manager")
# Only fails with no plugin to show (every one excluded or
# scheduled off), which initialize() has logged once.
logger.debug("Vegas not started: no plugins to scroll")
return False
# Compose initial content
+24 -3
View File
@@ -16,7 +16,7 @@ BasePlugin.get_vegas_participation):
import logging
import threading
import time
from typing import Optional, List, Dict, Deque, Tuple, TYPE_CHECKING
from typing import Callable, Optional, List, Dict, Deque, FrozenSet, Tuple, TYPE_CHECKING
from collections import deque
from dataclasses import dataclass, field
from PIL import Image
@@ -75,6 +75,16 @@ class StreamManager:
self.config = config
self.plugin_manager = plugin_manager
self.plugin_adapter = plugin_adapter
# Plugin ids to leave out right now (config['plugin_schedules']);
# the display controller sets it. Read at each list refresh.
# A change to it rebuilds the list at the next refresh() even inside
# the refresh interval; segments already composed still scroll out.
self.hidden_plugins: Optional[Callable[[], FrozenSet[str]]] = None
self._applied_hidden: FrozenSet[str] = frozenset()
# True once "no plugins" has been logged, until the list fills again:
# with every plugin scheduled off the display retries every second,
# and three log lines a second is a full journal on an SD card.
self._empty_logged = False
# Segments composed into the current cycle (swap mode only).
self._active_buffer: Deque[ContentSegment] = deque()
@@ -106,8 +116,11 @@ class StreamManager:
self._refresh_plugin_list()
if not self._ordered_plugins:
if not self._empty_logged:
logger.warning("No plugins available for Vegas scroll")
self._empty_logged = True
return False
self._empty_logged = False
# Fill the buffer to a whole cycle's worth of plugins. This used to be
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
@@ -268,7 +281,9 @@ class StreamManager:
Called periodically to pick up new plugins or config changes.
"""
current_time = time.time()
if current_time - self._last_refresh < self._refresh_interval:
hidden = self.hidden_plugins() if self.hidden_plugins else frozenset()
if (current_time - self._last_refresh < self._refresh_interval
and hidden == self._applied_hidden):
return
self._last_refresh = current_time
@@ -295,10 +310,15 @@ class StreamManager:
# own thread (DisplayController._start_plugin_reload).
plugins = list(self.plugin_manager.plugins.items())
loaded = len(plugins)
hidden = self.hidden_plugins() if self.hidden_plugins else frozenset()
self._applied_hidden = hidden
for plugin_id, plugin in plugins:
if not getattr(plugin, 'enabled', False):
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
continue
if plugin_id in hidden:
logger.debug("[%s] Vegas: skipped (scheduled off)", plugin_id)
continue
# 'pause' plugins stay in the rotation: they pause the scroll
# for their turn rather than contributing to it.
@@ -329,7 +349,8 @@ class StreamManager:
slots = (f", {len(ordered_plugins)} slots"
if len(ordered_plugins) != len(set(ordered_plugins)) else "")
logger.info(
logger.log(
logging.DEBUG if (self._empty_logged and not ordered_plugins) else logging.INFO,
"Vegas rotation: %d of %d loaded plugin(s)%s: %s",
len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins)
)
+17
View File
@@ -159,6 +159,23 @@
"POST"
]
],
[
"/api/v3/config/plugin-schedules",
"api_v3.get_plugin_schedules",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/plugin-schedules",
"api_v3.save_plugin_schedule",
[
"OPTIONS",
"POST"
]
],
[
"/api/v3/config/raw/main",
"api_v3.save_raw_main_config",
+5 -2
View File
@@ -39,8 +39,11 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
ok('both forms are marked data-reports-result',
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
// The two schedule forms, plus one per plugin in the Plugin Schedules card.
ok('every form is marked data-reports-result',
(partial.match(/<form[^>]*data-reports-result/g) || []).length
=== (partial.match(/<form[\s>]/g) || []).length
&& (partial.match(/<form[\s>]/g) || []).length >= 2);
const errs = [];
const logged = [];
@@ -8,6 +8,7 @@ now pauses between passes, and it stops pausing as soon as something shows.
"""
import os
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
@@ -21,6 +22,7 @@ def _bare_controller(modes, on_demand_modes=None):
dc.on_demand_modes = list(on_demand_modes or [])
dc.sleeps = []
dc._sleep_with_plugin_updates = lambda duration, tick_interval=1.0: dc.sleeps.append(duration)
dc.display_manager = MagicMock()
return dc
@@ -37,6 +39,19 @@ class TestNoteEmptyPass:
dc._note_empty_pass()
# Passes 3, 4 and 5 are at or past one full rotation.
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE] * 3
# Blanked once, so the last screen drawn does not stay up.
assert dc.display_manager.clear.call_count == 1
# A controller-drawn blank: the last scroll's state is ended first.
dc.display_manager.set_scrolling_state.assert_called_once_with(False)
assert dc.display_manager.update_display.call_count == 1
# ...and the preview gets the blank frame however the throttle fell.
assert dc.display_manager.write_owed_snapshot.call_count == 3
def test_a_partly_empty_rotation_never_blanks(self):
dc = _bare_controller(["a", "b", "c"])
dc._note_empty_pass()
dc._note_empty_pass()
dc.display_manager.clear.assert_not_called()
def test_content_resets_the_streak(self):
dc = _bare_controller(["a", "b"])
+205
View File
@@ -0,0 +1,205 @@
"""Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py).
The window rule itself is the display schedule's, covered case by case in
test_display_controller_schedule.py through _check_schedule; here: the
plugin-level answer, and the three places it is applied -- the rotation,
the live scan, and the Vegas ticker -- plus the save endpoint.
"""
import copy
import os
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("EMULATOR", "true")
from src.display_arbiter import ScreenPlan, Source # noqa: E402
from src.display_controller import DisplayController # noqa: E402
from src.plugin_schedule import hidden_plugins, schedule_state, wants_live_scan # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.stream_manager import StreamManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: E402,F401
DAYS = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
SUNDAY_1PM = datetime(2026, 10, 18, 13, 0) # a Sunday
MONDAY_1PM = datetime(2026, 10, 19, 13, 0)
def sundays_off(**extra):
"""The gameday schedule: every day all day, except Sunday."""
days = {d: {'enabled': d != 'sunday', 'start_time': '00:00', 'end_time': '23:59'}
for d in DAYS}
return {'enabled': True, 'mode': 'per-day', 'days': days, **extra}
ALWAYS_OFF = {'enabled': True, 'mode': 'per-day',
'days': {d: {'enabled': False} for d in DAYS}}
class TestHiddenPlugins:
def test_outside_window_is_hidden(self):
schedules = {'news': sundays_off(), 'stocks': sundays_off()}
assert hidden_plugins(schedules, SUNDAY_1PM, False) == {'news', 'stocks'}
assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset()
def test_hide_when_live_only_while_live(self):
schedules = {'news': {'enabled': False, 'hide_when_live': True}}
assert hidden_plugins(schedules, MONDAY_1PM, True) == {'news'}
assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset()
def test_disabled_schedule_and_junk_entries_hide_nothing(self):
schedules = {'news': {**ALWAYS_OFF, 'enabled': False}, 'bad': 'x', 'none': None}
assert hidden_plugins(schedules, SUNDAY_1PM, True) == frozenset()
def test_bad_time_shows_rather_than_hides(self):
assert schedule_state({'mode': 'global', 'start_time': '25:00'}, MONDAY_1PM)[0]
@pytest.mark.parametrize('schedule', [
{'mode': 1},
{'mode': 'per-day', 'days': 5},
{'mode': 'per-day', 'days': {'sunday': False}},
{'mode': 'per-day', 'days': ['sunday']},
])
def test_wrong_shapes_show_rather_than_raise(self, schedule):
# Falls back to the global 07:00-23:00 window, which 13:00 is inside.
assert schedule_state(schedule, SUNDAY_1PM)[0]
def test_global_mode_ignores_days(self):
schedule = {**sundays_off(), 'mode': 'global', 'start_time': '12:00', 'end_time': '14:00'}
assert schedule_state(schedule, SUNDAY_1PM)[0]
def test_wants_live_scan(self):
assert not wants_live_scan({'news': sundays_off()})
assert wants_live_scan({'news': sundays_off(), 'stocks': {'hide_when_live': True}})
def plugin(plugin_id, live=False, priority=False):
return SimpleNamespace(plugin_id=plugin_id, display=lambda **_: True,
has_live_content=lambda: live,
has_live_priority=lambda: priority,
get_live_modes=lambda: [f'{plugin_id}_live'])
def controller(schedules, plugin_modes):
dc = object.__new__(DisplayController)
dc.config = {'plugin_schedules': schedules}
dc._tz = None
dc.plugin_modes = plugin_modes
dc._plugin_hidden_cache = (None, frozenset())
dc._game_live = False
dc._game_live_at = float('-inf')
dc._health_tracker = lambda: None
return dc
class TestController:
def test_rotation_skips_hidden_but_on_demand_shows_it(self):
news = plugin('news')
dc = controller({'news': ALWAYS_OFF}, {'news': news})
assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is None
assert dc._plugin_to_draw(ScreenPlan(Source.ON_DEMAND, mode='news')) is news
def test_no_schedules_changes_nothing(self):
news = plugin('news')
dc = controller({}, {'news': news})
assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is news
def test_any_live_game_hides_news_even_without_live_priority(self):
modes = {'news': plugin('news'), 'nfl_live': plugin('nfl', live=True)}
dc = controller({'news': {'enabled': False, 'hide_when_live': True}}, modes)
assert dc._hidden_plugins() == {'news'}
def test_scoreboard_scheduled_off_does_not_win_live(self):
modes = {'nfl_live': plugin('nfl', live=True, priority=True)}
dc = controller({'nfl': ALWAYS_OFF}, modes)
assert dc._collect_live_modes() == []
dc.config = {}
assert dc._collect_live_modes() == ['nfl_live']
def test_a_scheduled_off_live_mode_does_not_hold_the_rotation(self):
modes = {'nfl_live': plugin('nfl', live=True, priority=True)}
dc = controller({'nfl': ALWAYS_OFF}, modes)
assert not dc._still_live('nfl_live')
dc.config = {}
assert dc._still_live('nfl_live')
@pytest.mark.parametrize('schedules', [['news'], 'news', 5])
def test_wrong_shaped_plugin_schedules_hide_nothing(self, schedules):
dc = controller(schedules, {'news': plugin('news')})
assert dc._hidden_plugins() == frozenset()
def test_a_reload_is_never_answered_from_the_old_schedules(self):
dc = controller({'news': ALWAYS_OFF}, {'news': plugin('news')})
assert dc._hidden_plugins() == {'news'}
# A new config in the same minute, with no cache reset.
dc.config = {'plugin_schedules': {'news': {'enabled': False}}}
assert dc._hidden_plugins() == frozenset()
class TestVegas:
def test_hidden_plugins_sit_out_the_ticker(self):
plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')}
stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock())
stream._refresh_plugin_list()
assert set(stream._ordered_plugins) == {'news', 'nfl'}
stream.hidden_plugins = lambda: frozenset({'news'})
stream._refresh_plugin_list()
assert set(stream._ordered_plugins) == {'nfl'}
def test_a_changed_hidden_set_skips_the_refresh_interval(self):
plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')}
stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock())
stream.refresh() # the interval's refresh
assert set(stream._ordered_plugins) == {'news', 'nfl'}
stream.refresh() # inside the interval, nothing changed: no rebuild
stream.hidden_plugins = lambda: frozenset({'news'})
stream.refresh() # still inside the interval, but the set changed
assert set(stream._ordered_plugins) == {'nfl'}
class TestSaveEndpoint:
@pytest.fixture
def store(self, api_v3_module, monkeypatch):
state = {'config': {}}
api_v3_module.api_v3.config_manager.load_config.side_effect = \
lambda *a, **k: copy.deepcopy(state['config'])
api_v3_module.api_v3.plugin_catalog.plugin_manifests = {'news': {}}
def fake_save(_manager, config, **_kwargs):
state['config'] = copy.deepcopy(config)
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
return state
def test_round_trip(self, api_v3_client, store):
body = {'plugin_id': 'news', 'hide_when_live': 'on', **sundays_off()}
resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body)
assert resp.status_code == 200, resp.get_json()
saved = store['config']['plugin_schedules']['news']
assert saved['hide_when_live'] is True
assert saved['days']['sunday']['enabled'] is False
got = api_v3_client.get('/api/v3/config/plugin-schedules').get_json()['data']
assert got == {'news': saved}
def test_schedule_that_does_nothing_is_removed(self, api_v3_client, store):
store['config'] = {'plugin_schedules': {'news': sundays_off()}}
resp = api_v3_client.post('/api/v3/config/plugin-schedules',
json={'plugin_id': 'news', 'enabled': False})
assert resp.status_code == 200, resp.get_json()
assert store['config']['plugin_schedules'] == {}
@pytest.mark.parametrize('body,status', [
({'plugin_id': 'display', 'enabled': True}, 400), # a core section
({'plugin_id': 'nope', 'enabled': True}, 404), # not installed
({'plugin_id': 'news', 'enabled': True, 'mode': 'global',
'start_time': '25:00', 'end_time': '23:00'}, 400), # bad time
({'enabled': True}, 400), # no id
])
def test_refusals(self, api_v3_client, store, body, status):
resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body)
assert resp.status_code == status, resp.get_json()
assert 'plugin_schedules' not in store['config']
+19
View File
@@ -59,6 +59,25 @@ def test_a_refresh_logs_a_single_info_summary(caplog):
assert any('off' in m and 'not enabled' in m for m in debug)
def test_an_empty_list_is_logged_once_until_it_fills(caplog):
# Every plugin scheduled off: the display retries every second.
stream = _stream({'clock': _Plugin()})
stream.hidden_plugins = lambda: frozenset({'clock'})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
for _ in range(3):
assert not stream.initialize()
loud = [r for r in caplog.records if r.name == LOGGER and r.levelno >= logging.INFO]
assert len(loud) == 2, [r.getMessage() for r in loud] # one summary, one warning
caplog.clear()
stream.hidden_plugins = lambda: frozenset()
with caplog.at_level(logging.DEBUG, logger=LOGGER):
assert stream.initialize()
stream.hidden_plugins = lambda: frozenset({'clock'})
assert not stream.initialize()
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1 # warned again after the list had filled
def test_fetching_content_logs_nothing_at_info(caplog):
stream = _stream({'clock': _Plugin()})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
+114 -59
View File
@@ -224,43 +224,13 @@ def get_refresh_rate():
}})
@api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config():
"""Get current schedule configuration"""
try:
if not api_v3.config_manager:
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
'Config manager not initialized',
status_code=500
)
config = api_v3.config_manager.load_config()
schedule_config = config.get('schedule', {})
return success_response(data=schedule_config)
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details",
details=describe_exception(e),
status_code=500
)
@api_v3.route('/config/schedule', methods=['POST'])
def save_schedule_config():
"""Save schedule configuration"""
try:
if not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
data = request.get_json(silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Load current config
current_config = api_v3.config_manager.load_config()
def _build_schedule(data):
"""``(schedule, None)`` from a schedule picker's POST body, or ``(None, message)``.
The display schedule and each plugin schedule (``plugin_schedules``)
share it. Times are validated; a per-day schedule that is on needs a day
that is on.
"""
# Build schedule configuration
# Handle enabled checkbox - can be True, False, or 'on'
enabled_value = data.get('enabled', False)
@@ -281,19 +251,11 @@ def save_schedule_config():
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
error_msg,
status_code=400
)
return None, error_msg
is_valid, error_msg = _validate_time_format(end_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
error_msg,
status_code=400
)
return None, error_msg
schedule_config['start_time'] = start_time
schedule_config['end_time'] = end_time
@@ -338,19 +300,11 @@ def save_schedule_config():
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
f"Invalid start time for {day}: {error_msg}",
status_code=400
)
return None, f"Invalid start time for {day}: {error_msg}"
is_valid, error_msg = _validate_time_format(end_time)
if not is_valid:
return error_response(
ErrorCode.VALIDATION_ERROR,
f"Invalid end time for {day}: {error_msg}",
status_code=400
)
return None, f"Invalid end time for {day}: {error_msg}"
day_config['start_time'] = start_time
day_config['end_time'] = end_time
@@ -364,12 +318,52 @@ def save_schedule_config():
# config.template.json ships, so refusing it meant a fresh
# install could not post back the schedule GET returned.
if enabled_days_count == 0 and enabled_value:
return None, "At least one day must be enabled in per-day schedule mode"
return schedule_config, None
@api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config():
"""Get current schedule configuration"""
try:
if not api_v3.config_manager:
return error_response(
ErrorCode.VALIDATION_ERROR,
"At least one day must be enabled in per-day schedule mode",
status_code=400
ErrorCode.CONFIG_LOAD_FAILED,
'Config manager not initialized',
status_code=500
)
config = api_v3.config_manager.load_config()
schedule_config = config.get('schedule', {})
return success_response(data=schedule_config)
except Exception as e:
logger.error("%s failed", request.path, exc_info=True)
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
"An error occurred; see logs for details",
details=describe_exception(e),
status_code=500
)
@api_v3.route('/config/schedule', methods=['POST'])
def save_schedule_config():
"""Save schedule configuration"""
try:
if not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
data = request.get_json(silent=True)
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# Load current config
current_config = api_v3.config_manager.load_config()
schedule_config, error_msg = _build_schedule(data)
if error_msg:
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
# Update and save config using atomic save
current_config['schedule'] = schedule_config
success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config, create_backup=True)
@@ -391,6 +385,67 @@ def save_schedule_config():
status_code=500, details=describe_exception(e)
)
@api_v3.route('/config/plugin-schedules', methods=['GET'])
def get_plugin_schedules():
"""Every plugin's schedule (config['plugin_schedules']), by plugin id."""
if not api_v3.config_manager:
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
'Config manager not initialized', status_code=500)
config = api_v3.config_manager.load_config()
return success_response(data=config.get('plugin_schedules') or {})
@api_v3.route('/config/plugin-schedules', methods=['POST'])
def save_plugin_schedule():
"""Save one plugin's schedule (src/plugin_schedule.py).
The body is a schedule picker's fields plus ``plugin_id`` and
``hide_when_live``. A schedule that is off and does not hide during live
games changes nothing, so it is removed rather than stored.
"""
try:
if not api_v3.config_manager:
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
'Config manager not initialized', status_code=500)
data = request.get_json(silent=True)
if not isinstance(data, dict):
return error_response(ErrorCode.VALIDATION_ERROR, 'No data provided', status_code=400)
plugin_id = data.get('plugin_id')
if not isinstance(plugin_id, str) or not plugin_id:
return error_response(ErrorCode.VALIDATION_ERROR, 'plugin_id is required', status_code=400)
not_plugin = _pkg._non_plugin_id_error(plugin_id)
if not_plugin is not None:
return not_plugin
if plugin_id not in _pkg._discovered_plugin_manifests(plugin_id):
return error_response(ErrorCode.PLUGIN_NOT_FOUND,
f"Plugin '{plugin_id}' is not installed", status_code=404)
schedule, error_msg = _build_schedule(data)
if error_msg:
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
schedule['hide_when_live'] = _coerce_to_bool(data.get('hide_when_live'))
current_config = api_v3.config_manager.load_config()
schedules = dict(current_config.get('plugin_schedules') or {})
if schedule['enabled'] or schedule['hide_when_live']:
schedules[plugin_id] = schedule
else:
schedules.pop(plugin_id, None)
current_config['plugin_schedules'] = schedules
success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config,
create_backup=True)
if not success:
return error_response(ErrorCode.CONFIG_SAVE_FAILED,
f"Failed to save plugin schedule: {error_msg}", status_code=500)
invalidate_cache()
return success_response(message=f"Schedule for {plugin_id} saved")
except Exception as e:
logger.error("Error saving plugin schedule", exc_info=True)
return error_response(ErrorCode.CONFIG_SAVE_FAILED,
"An error occurred; see logs for details",
status_code=500, details=describe_exception(e))
@api_v3.route('/config/dim-schedule', methods=['GET'])
def get_dim_schedule_config():
"""Get current dim schedule configuration"""
+22 -1
View File
@@ -615,7 +615,28 @@ def _load_schedule_partial():
return render_template('v3/partials/schedule.html',
schedule_config=schedule_config,
dim_schedule_config=dim_schedule_config,
normal_brightness=normal_brightness)
normal_brightness=normal_brightness,
plugin_schedule_rows=_plugin_schedule_rows(main_config))
def _plugin_schedule_rows(main_config):
"""One row per enabled plugin, or plugin with a saved schedule, for the
Plugin Schedules card: ``{id, name, schedule}``, sorted by name."""
schedules = main_config.get('plugin_schedules') or {}
if not pages_v3.plugin_catalog:
return []
try:
pages_v3.plugin_catalog.discover_plugins()
infos = pages_v3.plugin_catalog.get_all_plugin_info()
except Exception:
logger.warning("schedule: could not enumerate plugins", exc_info=True)
return []
rows = [{'id': info['id'], 'name': info.get('name') or info['id'],
'schedule': schedules.get(info['id']) or {}}
for info in infos
if info.get('id') and ((main_config.get(info['id']) or {}).get('enabled', False)
or info['id'] in schedules)]
return sorted(rows, key=lambda r: r['name'].lower())
def _load_plugins_partial():
+20 -1
View File
@@ -39,12 +39,26 @@ const PICKERS = [
},
];
// Each plugin's picker (the Plugin Schedules card): the window in which the
// plugin may show. Unset, it offers the whole day.
const PLUGIN_PICKER = { label: 'PluginSchedule', start: '00:00', end: '23:59', fillEmptyDays: true };
const FORMS = {
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
};
const PLUGIN_FORM = { saved: 'Plugin schedule saved', failed: 'Error saving plugin schedule' };
/** The notification labels for a form on this page, or null. */
function formLabels(form) {
if (FORMS[form.id]) return FORMS[form.id];
return form.classList.contains('plugin-schedule-form') ? PLUGIN_FORM : null;
}
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
const PLUGIN_WIDGET_OPTIONS = Object.assign({}, WIDGET_OPTIONS, {
enableDescription: 'When enabled, this plugin only shows during the hours below.',
});
// The mounted page, for the deprecated aliases (boot.js).
let active = null;
@@ -126,6 +140,11 @@ function drawPickers(root, ctx) {
widget.render(container, { 'x-options': WIDGET_OPTIONS },
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
});
root.querySelectorAll('[data-plugin-schedule-config]').forEach(function(container) {
widget.render(container, { 'x-options': PLUGIN_WIDGET_OPTIONS },
widgetValue(readConfig(container, 'data-plugin-schedule-config'), PLUGIN_PICKER),
{ fieldId: container.id });
});
}
attempt();
}
@@ -135,7 +154,7 @@ export function init(root, ctx) {
root.addEventListener('htmx:afterRequest', function(event) {
const form = event.target && event.target.closest ? event.target.closest('form') : null;
const labels = form && root.contains(form) ? FORMS[form.id] : null;
const labels = form && root.contains(form) ? formLabels(form) : null;
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
}, on);
@@ -188,7 +188,7 @@
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Enable Schedule</span>
</label>
<p class="mt-1 text-sm text-gray-600">When enabled, the display will only operate during specified hours.</p>
<p class="mt-1 text-sm text-gray-600">${window.LEDEscape.html(xOptions.enableDescription || 'When enabled, the display will only operate during specified hours.')}</p>
</div>
`;
}
@@ -85,4 +85,51 @@
</div>
</form>
</div>
<!-- Plugin Schedules Section (config plugin_schedules, src/plugin_schedule.py) -->
<div class="bg-white rounded-lg shadow p-6 mt-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Plugin Schedules</h2>
<p class="mt-1 text-sm text-gray-600">Choose when each plugin may show. Outside its schedule a plugin is skipped in the rotation and in Vegas mode; it stays loaded, and on-demand still shows it. "Hide while a game is live" skips it whenever any plugin has a live game, so on gameday you only see scores.</p>
</div>
{% if plugin_schedule_rows %}
<div class="space-y-3">
{% for row in plugin_schedule_rows %}
<details class="border border-gray-200 rounded-md">
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-gray-900">
{{ row.name }}
{% if row.schedule.enabled or row.schedule.hide_when_live %}
<span class="ml-2 text-xs text-blue-700">scheduled</span>
{% endif %}
</summary>
<form class="plugin-schedule-form px-4 pb-4 space-y-4"
hx-post="/api/v3/config/plugin-schedules"
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
data-reports-result>
<input type="hidden" name="plugin_id" value="{{ row.id }}">
<label class="flex items-center text-sm text-gray-700">
<input type="checkbox" name="hide_when_live" class="mr-2"
{% if row.schedule.hide_when_live %}checked{% endif %}>
Hide while a game is live
</label>
<div id="plugin_schedule_picker_{{ loop.index }}"
data-plugin-schedule-config='{{ row.schedule | tojson }}'></div>
<div class="flex justify-end">
<button type="submit"
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-save mr-2"></i>
Save {{ row.name }} Schedule
</button>
</div>
</form>
</details>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-600">No enabled plugins.</p>
{% endif %}
</div>
</div>