mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
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>
This commit is contained in:
@@ -19,6 +19,16 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
### Removed: the cache-key mailboxes (control socket stage 5) -- breaking
|
||||||
|
|
||||||
The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the
|
The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugin_schedules": {},
|
||||||
"dim_schedule": {
|
"dim_schedule": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"dim_brightness": 30,
|
"dim_brightness": 30,
|
||||||
|
|||||||
@@ -55,6 +55,40 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
|||||||
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
|
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
|
||||||
`end_time`.
|
`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 at the start of the next scroll cycle in Vegas mode, which on a long
|
||||||
|
ticker can be several minutes (about 6 on ledpi with 8 plugins).
|
||||||
|
|
||||||
|
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
|
## `display.hardware` — matrix panel hardware
|
||||||
|
|
||||||
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
||||||
|
|||||||
@@ -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
|
enabled dim schedule needs at least one day enabled and a disabled one may
|
||||||
have every day off.
|
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
|
## Integrations
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ CORE_CONFIG_KEYS = frozenset({
|
|||||||
'auto_update',
|
'auto_update',
|
||||||
'schedule',
|
'schedule',
|
||||||
'dim_schedule',
|
'dim_schedule',
|
||||||
|
'plugin_schedules',
|
||||||
'timezone',
|
'timezone',
|
||||||
'target_fps',
|
'target_fps',
|
||||||
'location',
|
'location',
|
||||||
|
|||||||
+85
-98
@@ -37,6 +37,7 @@ import pytz
|
|||||||
|
|
||||||
from src import display_watchdog
|
from src import display_watchdog
|
||||||
from src.malloc_tuning import MallocTrimmer
|
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 (
|
from src.display_arbiter import (
|
||||||
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
|
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
|
||||||
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
|
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
|
||||||
@@ -705,6 +706,12 @@ class DisplayController:
|
|||||||
self._schedule_checked_minute: Optional[tuple] = None
|
self._schedule_checked_minute: Optional[tuple] = None
|
||||||
self._dim_checked_minute: Optional[tuple] = None
|
self._dim_checked_minute: Optional[tuple] = None
|
||||||
self._cached_target_brightness: int = self._normal_brightness
|
self._cached_target_brightness: int = self._normal_brightness
|
||||||
|
# Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py):
|
||||||
|
# the hidden set, recomputed when the minute or the live answer changes.
|
||||||
|
self._plugin_hidden_key: Optional[tuple] = None
|
||||||
|
self._plugin_hidden: FrozenSet[str] = frozenset()
|
||||||
|
self._game_live = False
|
||||||
|
self._game_live_at = float('-inf')
|
||||||
|
|
||||||
# Register controller-level hot-reload callback so cached config values
|
# Register controller-level hot-reload callback so cached config values
|
||||||
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||||
@@ -751,6 +758,8 @@ class DisplayController:
|
|||||||
|
|
||||||
# Set up live priority checker
|
# Set up live priority checker
|
||||||
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
|
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
|
# Set up interrupt checker for on-demand/wifi status and follower mode
|
||||||
def _vegas_interrupt():
|
def _vegas_interrupt():
|
||||||
@@ -912,38 +921,19 @@ class DisplayController:
|
|||||||
self._tz = pytz.UTC
|
self._tz = pytz.UTC
|
||||||
return self._tz
|
return self._tz
|
||||||
|
|
||||||
@staticmethod
|
# The window rule, shared with the dim schedule and plugin schedules.
|
||||||
def _in_window(start, end, now) -> bool:
|
_in_window = staticmethod(in_window)
|
||||||
"""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 _check_schedule(self):
|
def _check_schedule(self):
|
||||||
"""Check if display should be active based on schedule."""
|
"""Check if display should be active based on schedule."""
|
||||||
schedule_config = self.config.get('schedule', {})
|
schedule_config = self.config.get('schedule', {})
|
||||||
|
|
||||||
# No schedule configured: always active.
|
# No schedule, or a disabled one: always active.
|
||||||
if not schedule_config:
|
if not schedule_config or not schedule_config.get('enabled', True):
|
||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True
|
self._was_display_active = True
|
||||||
return
|
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())
|
current_time = datetime.now(self._timezone())
|
||||||
# Gate: schedule state can only change on a minute boundary, so skip
|
# Gate: schedule state can only change on a minute boundary, so skip
|
||||||
# all the strptime / comparison work if we already evaluated this minute.
|
# all the strptime / comparison work if we already evaluated this minute.
|
||||||
@@ -952,81 +942,15 @@ class DisplayController:
|
|||||||
return
|
return
|
||||||
self._schedule_checked_minute = current_minute_key
|
self._schedule_checked_minute = current_minute_key
|
||||||
|
|
||||||
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
active, why = schedule_state(schedule_config, current_time)
|
||||||
current_time_only = current_time.time()
|
if active != self._was_display_active:
|
||||||
|
logger.info("Schedule activated: Display is now %s (%s)%s",
|
||||||
# Check if per-day schedule is configured
|
"ACTIVE" if active else "INACTIVE", why,
|
||||||
days_config = schedule_config.get('days')
|
"" if active else ". Display will be blanked.")
|
||||||
|
|
||||||
# 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})"
|
|
||||||
else:
|
else:
|
||||||
start_time_str = schedule_config.get('start_time', '07:00')
|
logger.debug("Display %s - %s", "active" if active else "inactive", why)
|
||||||
end_time_str = schedule_config.get('end_time', '23:00')
|
self.is_display_active = active
|
||||||
schedule_type = "global"
|
self._was_display_active = active
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def _check_dim_schedule(self) -> int:
|
def _check_dim_schedule(self) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -2990,6 +2914,50 @@ class DisplayController:
|
|||||||
self.force_change = True
|
self.force_change = True
|
||||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
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 minute or the live-game answer changes. Also
|
||||||
|
Vegas's filter (StreamManager.hidden_plugins), from its own thread.
|
||||||
|
"""
|
||||||
|
schedules = self.config.get('plugin_schedules') or {}
|
||||||
|
if not schedules:
|
||||||
|
return frozenset()
|
||||||
|
now = datetime.now(self._timezone())
|
||||||
|
game_live = wants_live_scan(schedules) and self._any_game_live()
|
||||||
|
key = (now.hour, now.minute, game_live)
|
||||||
|
if key != self._plugin_hidden_key:
|
||||||
|
hidden = hidden_plugins(schedules, now, game_live)
|
||||||
|
if hidden != self._plugin_hidden:
|
||||||
|
logger.info("Plugin schedules: hidden now %s", sorted(hidden) or "none")
|
||||||
|
self._plugin_hidden = hidden
|
||||||
|
self._plugin_hidden_key = key
|
||||||
|
return self._plugin_hidden
|
||||||
|
|
||||||
def _collect_live_modes(self):
|
def _collect_live_modes(self):
|
||||||
"""Return every currently live-priority mode, in registration order.
|
"""Return every currently live-priority mode, in registration order.
|
||||||
|
|
||||||
@@ -3006,10 +2974,14 @@ class DisplayController:
|
|||||||
# Asked once per plugin per scan, not once per mode key: a scoreboard
|
# Asked once per plugin per scan, not once per mode key: a scoreboard
|
||||||
# registered under several modes computes the same answer each time.
|
# registered under several modes computes the same answer each time.
|
||||||
is_live: Dict[int, bool] = {}
|
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():
|
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||||
and hasattr(plugin_instance, 'has_live_content')):
|
and hasattr(plugin_instance, 'has_live_content')):
|
||||||
continue
|
continue
|
||||||
|
if getattr(plugin_instance, 'plugin_id', mode_name) in hidden:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
key = id(plugin_instance)
|
key = id(plugin_instance)
|
||||||
if key not in is_live:
|
if key not in is_live:
|
||||||
@@ -3468,6 +3440,20 @@ class DisplayController:
|
|||||||
logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}")
|
logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}")
|
||||||
return plugin_instance
|
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:
|
def _start_screen_handover(self, plugin, active_mode: str) -> bool:
|
||||||
"""Before a screen's first dispatch: if the screen is static, keep
|
"""Before a screen's first dispatch: if the screen is static, keep
|
||||||
the last scroll's leftovers off its first frame.
|
the last scroll's leftovers off its first frame.
|
||||||
@@ -4201,7 +4187,7 @@ class DisplayController:
|
|||||||
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||||
|
|
||||||
# Handle plugin-based display modes
|
# Handle plugin-based display modes
|
||||||
manager_to_display = self._plugin_for_mode(active_mode)
|
manager_to_display = self._plugin_to_draw(plan)
|
||||||
if not manager_to_display:
|
if not manager_to_display:
|
||||||
logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode")
|
logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode")
|
||||||
manager_to_display = None
|
manager_to_display = None
|
||||||
@@ -4811,6 +4797,7 @@ class DisplayController:
|
|||||||
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
||||||
self._schedule_checked_minute = None
|
self._schedule_checked_minute = None
|
||||||
self._dim_checked_minute = None
|
self._dim_checked_minute = None
|
||||||
|
self._plugin_hidden_key = None
|
||||||
self._cached_target_brightness = self._normal_brightness
|
self._cached_target_brightness = self._normal_brightness
|
||||||
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
||||||
self._normal_brightness, self._scroll_speed)
|
self._normal_brightness, self._scroll_speed)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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()
|
||||||
|
days = schedule.get('days') or {}
|
||||||
|
mode = schedule.get('mode')
|
||||||
|
mode = mode.replace('_', '-') if mode else None
|
||||||
|
# An explicit Global mode beats a stray 'days' dict left from a past
|
||||||
|
# per-day setup.
|
||||||
|
per_day = mode != 'global' and day in days
|
||||||
|
|
||||||
|
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]))
|
||||||
@@ -16,7 +16,7 @@ BasePlugin.get_vegas_participation):
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
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 collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -75,6 +75,11 @@ class StreamManager:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
self.plugin_adapter = plugin_adapter
|
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.
|
||||||
|
# ponytail: applies from the next cycle (minutes on a long ticker);
|
||||||
|
# drop the plugin's cached segments when the set changes if that shows.
|
||||||
|
self.hidden_plugins: Optional[Callable[[], FrozenSet[str]]] = None
|
||||||
|
|
||||||
# Segments composed into the current cycle (swap mode only).
|
# Segments composed into the current cycle (swap mode only).
|
||||||
self._active_buffer: Deque[ContentSegment] = deque()
|
self._active_buffer: Deque[ContentSegment] = deque()
|
||||||
@@ -295,10 +300,14 @@ class StreamManager:
|
|||||||
# own thread (DisplayController._start_plugin_reload).
|
# own thread (DisplayController._start_plugin_reload).
|
||||||
plugins = list(self.plugin_manager.plugins.items())
|
plugins = list(self.plugin_manager.plugins.items())
|
||||||
loaded = len(plugins)
|
loaded = len(plugins)
|
||||||
|
hidden = self.hidden_plugins() if self.hidden_plugins else frozenset()
|
||||||
for plugin_id, plugin in plugins:
|
for plugin_id, plugin in plugins:
|
||||||
if not getattr(plugin, 'enabled', False):
|
if not getattr(plugin, 'enabled', False):
|
||||||
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
|
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
|
||||||
continue
|
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
|
# 'pause' plugins stay in the rotation: they pause the scroll
|
||||||
# for their turn rather than contributing to it.
|
# for their turn rather than contributing to it.
|
||||||
|
|||||||
Vendored
+17
@@ -159,6 +159,23 @@
|
|||||||
"POST"
|
"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/config/raw/main",
|
||||||
"api_v3.save_raw_main_config",
|
"api_v3.save_raw_main_config",
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""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]
|
||||||
|
|
||||||
|
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_key = None
|
||||||
|
dc._plugin_hidden = 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 = {}
|
||||||
|
dc._plugin_hidden_key = None
|
||||||
|
assert dc._collect_live_modes() == ['nfl_live']
|
||||||
|
|
||||||
|
|
||||||
|
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'}
|
||||||
|
|
||||||
|
|
||||||
|
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']
|
||||||
@@ -224,6 +224,105 @@ def get_refresh_rate():
|
|||||||
}})
|
}})
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
if isinstance(enabled_value, str):
|
||||||
|
enabled_value = enabled_value.lower() in ('true', 'on', '1')
|
||||||
|
schedule_config = {
|
||||||
|
'enabled': enabled_value
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = data.get('mode', 'global')
|
||||||
|
schedule_config['mode'] = mode
|
||||||
|
|
||||||
|
if mode == 'global':
|
||||||
|
# Simple global schedule
|
||||||
|
start_time = data.get('start_time', '07:00')
|
||||||
|
end_time = data.get('end_time', '23:00')
|
||||||
|
|
||||||
|
# Validate time formats
|
||||||
|
is_valid, error_msg = _validate_time_format(start_time)
|
||||||
|
if not is_valid:
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
is_valid, error_msg = _validate_time_format(end_time)
|
||||||
|
if not is_valid:
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
schedule_config['start_time'] = start_time
|
||||||
|
schedule_config['end_time'] = end_time
|
||||||
|
# Remove days config when switching to global mode
|
||||||
|
schedule_config.pop('days', None)
|
||||||
|
else:
|
||||||
|
# Per-day schedule
|
||||||
|
schedule_config['days'] = {}
|
||||||
|
# Remove global times when switching to per-day mode
|
||||||
|
schedule_config.pop('start_time', None)
|
||||||
|
schedule_config.pop('end_time', None)
|
||||||
|
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
|
enabled_days_count = 0
|
||||||
|
|
||||||
|
for day in days:
|
||||||
|
day_config = {}
|
||||||
|
enabled_key = f'{day}_enabled'
|
||||||
|
start_key = f'{day}_start'
|
||||||
|
end_key = f'{day}_end'
|
||||||
|
|
||||||
|
# Check if day is enabled
|
||||||
|
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
|
||||||
|
if has_enabled:
|
||||||
|
# Handle checkbox values that may come as 'on', True, or False
|
||||||
|
if isinstance(enabled_val, str):
|
||||||
|
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
|
||||||
|
else:
|
||||||
|
day_config['enabled'] = bool(enabled_val)
|
||||||
|
else:
|
||||||
|
# Default to enabled if not specified
|
||||||
|
day_config['enabled'] = True
|
||||||
|
|
||||||
|
# Only add times if day is enabled
|
||||||
|
if day_config.get('enabled', True):
|
||||||
|
enabled_days_count += 1
|
||||||
|
start_time = None
|
||||||
|
end_time = None
|
||||||
|
|
||||||
|
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
|
||||||
|
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
|
||||||
|
|
||||||
|
# Validate time formats
|
||||||
|
is_valid, error_msg = _validate_time_format(start_time)
|
||||||
|
if not is_valid:
|
||||||
|
return None, f"Invalid start time for {day}: {error_msg}"
|
||||||
|
|
||||||
|
is_valid, error_msg = _validate_time_format(end_time)
|
||||||
|
if not is_valid:
|
||||||
|
return None, f"Invalid end time for {day}: {error_msg}"
|
||||||
|
|
||||||
|
day_config['start_time'] = start_time
|
||||||
|
day_config['end_time'] = end_time
|
||||||
|
else:
|
||||||
|
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
||||||
|
|
||||||
|
schedule_config['days'][day] = day_config
|
||||||
|
|
||||||
|
# An enabled per-day schedule needs a day to be on. A disabled
|
||||||
|
# one does not: every day off with the schedule off is what
|
||||||
|
# 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'])
|
@api_v3.route('/config/schedule', methods=['GET'])
|
||||||
def get_schedule_config():
|
def get_schedule_config():
|
||||||
"""Get current schedule configuration"""
|
"""Get current schedule configuration"""
|
||||||
@@ -261,114 +360,9 @@ def save_schedule_config():
|
|||||||
# Load current config
|
# Load current config
|
||||||
current_config = api_v3.config_manager.load_config()
|
current_config = api_v3.config_manager.load_config()
|
||||||
|
|
||||||
# Build schedule configuration
|
schedule_config, error_msg = _build_schedule(data)
|
||||||
# Handle enabled checkbox - can be True, False, or 'on'
|
if error_msg:
|
||||||
enabled_value = data.get('enabled', False)
|
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
|
||||||
if isinstance(enabled_value, str):
|
|
||||||
enabled_value = enabled_value.lower() in ('true', 'on', '1')
|
|
||||||
schedule_config = {
|
|
||||||
'enabled': enabled_value
|
|
||||||
}
|
|
||||||
|
|
||||||
mode = data.get('mode', 'global')
|
|
||||||
schedule_config['mode'] = mode
|
|
||||||
|
|
||||||
if mode == 'global':
|
|
||||||
# Simple global schedule
|
|
||||||
start_time = data.get('start_time', '07:00')
|
|
||||||
end_time = data.get('end_time', '23:00')
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
is_valid, error_msg = _validate_time_format(end_time)
|
|
||||||
if not is_valid:
|
|
||||||
return error_response(
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
error_msg,
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
schedule_config['start_time'] = start_time
|
|
||||||
schedule_config['end_time'] = end_time
|
|
||||||
# Remove days config when switching to global mode
|
|
||||||
schedule_config.pop('days', None)
|
|
||||||
else:
|
|
||||||
# Per-day schedule
|
|
||||||
schedule_config['days'] = {}
|
|
||||||
# Remove global times when switching to per-day mode
|
|
||||||
schedule_config.pop('start_time', None)
|
|
||||||
schedule_config.pop('end_time', None)
|
|
||||||
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
|
||||||
enabled_days_count = 0
|
|
||||||
|
|
||||||
for day in days:
|
|
||||||
day_config = {}
|
|
||||||
enabled_key = f'{day}_enabled'
|
|
||||||
start_key = f'{day}_start'
|
|
||||||
end_key = f'{day}_end'
|
|
||||||
|
|
||||||
# Check if day is enabled
|
|
||||||
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
|
|
||||||
if has_enabled:
|
|
||||||
# Handle checkbox values that may come as 'on', True, or False
|
|
||||||
if isinstance(enabled_val, str):
|
|
||||||
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
|
|
||||||
else:
|
|
||||||
day_config['enabled'] = bool(enabled_val)
|
|
||||||
else:
|
|
||||||
# Default to enabled if not specified
|
|
||||||
day_config['enabled'] = True
|
|
||||||
|
|
||||||
# Only add times if day is enabled
|
|
||||||
if day_config.get('enabled', True):
|
|
||||||
enabled_days_count += 1
|
|
||||||
start_time = None
|
|
||||||
end_time = None
|
|
||||||
|
|
||||||
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
|
|
||||||
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
day_config['start_time'] = start_time
|
|
||||||
day_config['end_time'] = end_time
|
|
||||||
else:
|
|
||||||
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
|
||||||
|
|
||||||
schedule_config['days'][day] = day_config
|
|
||||||
|
|
||||||
# An enabled per-day schedule needs a day to be on. A disabled
|
|
||||||
# one does not: every day off with the schedule off is what
|
|
||||||
# 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 error_response(
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
"At least one day must be enabled in per-day schedule mode",
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update and save config using atomic save
|
# Update and save config using atomic save
|
||||||
current_config['schedule'] = schedule_config
|
current_config['schedule'] = schedule_config
|
||||||
@@ -391,6 +385,67 @@ def save_schedule_config():
|
|||||||
|
|
||||||
status_code=500, details=describe_exception(e)
|
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'])
|
@api_v3.route('/config/dim-schedule', methods=['GET'])
|
||||||
def get_dim_schedule_config():
|
def get_dim_schedule_config():
|
||||||
"""Get current dim schedule configuration"""
|
"""Get current dim schedule configuration"""
|
||||||
|
|||||||
@@ -615,7 +615,28 @@ def _load_schedule_partial():
|
|||||||
return render_template('v3/partials/schedule.html',
|
return render_template('v3/partials/schedule.html',
|
||||||
schedule_config=schedule_config,
|
schedule_config=schedule_config,
|
||||||
dim_schedule_config=dim_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():
|
def _load_plugins_partial():
|
||||||
|
|||||||
@@ -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 = {
|
const FORMS = {
|
||||||
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
||||||
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim 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 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).
|
// The mounted page, for the deprecated aliases (boot.js).
|
||||||
let active = null;
|
let active = null;
|
||||||
@@ -126,6 +140,11 @@ function drawPickers(root, ctx) {
|
|||||||
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
||||||
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
|
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();
|
attempt();
|
||||||
}
|
}
|
||||||
@@ -135,7 +154,7 @@ export function init(root, ctx) {
|
|||||||
|
|
||||||
root.addEventListener('htmx:afterRequest', function(event) {
|
root.addEventListener('htmx:afterRequest', function(event) {
|
||||||
const form = event.target && event.target.closest ? event.target.closest('form') : null;
|
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);
|
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
|
||||||
}, on);
|
}, on);
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
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>
|
<span class="ml-2 text-sm font-medium text-gray-900">Enable Schedule</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,4 +85,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user