From 1c733352b54654e2676ed96725d58490f48dff26 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:29:29 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 10 + config/config.template.json | 1 + docs/CONFIG_REFERENCE.md | 34 +++ docs/REST_API_REFERENCE.md | 14 + src/core_config_keys.py | 1 + src/display_controller.py | 183 ++++++------ src/plugin_schedule.py | 100 +++++++ src/vegas_mode/stream_manager.py | 11 +- test/fixtures/api_v3_url_map.json | 17 ++ test/test_plugin_schedule.py | 168 +++++++++++ web_interface/blueprints/api_v3/config.py | 271 +++++++++++------- web_interface/blueprints/pages_v3.py | 23 +- web_interface/static/v3/js/pages/schedule.js | 21 +- .../static/v3/js/widgets/schedule-picker.js | 2 +- .../templates/v3/partials/schedule.html | 47 +++ 15 files changed, 693 insertions(+), 210 deletions(-) create mode 100644 src/plugin_schedule.py create mode 100644 test/test_plugin_schedule.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4933b3..9dd7243d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/config.template.json b/config/config.template.json index f3541cf1..58ffae14 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -47,6 +47,7 @@ } } }, + "plugin_schedules": {}, "dim_schedule": { "enabled": false, "dim_brightness": 30, diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index fd33d14b..8bb6d1b8 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -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, `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 All keys map to the corresponding `rpi-rgb-led-matrix` options and are read diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 5dc7f836..3c3731a4 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -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 diff --git a/src/core_config_keys.py b/src/core_config_keys.py index 42e75969..81f78913 100644 --- a/src/core_config_keys.py +++ b/src/core_config_keys.py @@ -23,6 +23,7 @@ CORE_CONFIG_KEYS = frozenset({ 'auto_update', 'schedule', 'dim_schedule', + 'plugin_schedules', 'timezone', 'target_fps', 'location', diff --git a/src/display_controller.py b/src/display_controller.py index b5c8356a..1d68bf2e 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -37,6 +37,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 +706,12 @@ 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): + # 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 # (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync @@ -751,6 +758,8 @@ class DisplayController: # Set up live priority checker self.vegas_coordinator.set_live_priority_checker(self._check_live_priority) + # Plugins scheduled off sit out the ticker too. + self.vegas_coordinator.stream_manager.hidden_plugins = self._hidden_plugins # Set up interrupt checker for on-demand/wifi status and follower mode def _vegas_interrupt(): @@ -912,38 +921,19 @@ class DisplayController: self._tz = pytz.UTC return self._tz - @staticmethod - def _in_window(start, end, now) -> bool: - """Whether ``now`` is within [start, end), a window that may span midnight. - - Half-open: on from the start minute, off at exactly the end minute. - A closed end made the end minute count as inside, and because ``now`` - carries seconds, only a check at hh:mm:00.000 saw it that way -- so - whether the panel went off at the start or the end of that minute - depended on when the minute's one check ran. ``start == end`` is an - empty window, as it effectively was before. - """ - if start <= end: - return start <= now < end - return now >= start or now < end + # The window rule, shared with the dim schedule and plugin schedules. + _in_window = staticmethod(in_window) def _check_schedule(self): """Check if display should be active based on schedule.""" schedule_config = self.config.get('schedule', {}) - # No schedule configured: always active. - if not schedule_config: + # No schedule, or a disabled one: always active. + if not schedule_config or not schedule_config.get('enabled', True): self.is_display_active = True self._was_display_active = True return - # A schedule without an 'enabled' key counts as enabled. - if 'enabled' in schedule_config and not schedule_config.get('enabled', True): - self.is_display_active = True - self._was_display_active = True - logger.debug("Schedule is disabled - display always active") - return - current_time = datetime.now(self._timezone()) # Gate: schedule state can only change on a minute boundary, so skip # all the strptime / comparison work if we already evaluated this minute. @@ -952,81 +942,15 @@ class DisplayController: return self._schedule_checked_minute = current_minute_key - current_day = current_time.strftime('%A').lower() # e.g. 'monday' - current_time_only = current_time.time() - - # Check if per-day schedule is configured - days_config = schedule_config.get('days') - - # Determine which schedule to use. Respect an explicit 'mode' field - # (like the dim schedule does) so a stray/legacy 'days' dict left over - # from config migration or a prior per-day setup can't silently - # override a user's Global schedule selection. - mode = schedule_config.get('mode') - mode_normalized = mode.replace('_', '-') if mode else None - - use_per_day = False - if mode_normalized == 'global': - use_per_day = False - elif mode_normalized == 'per-day': - use_per_day = bool(days_config and current_day in days_config) - elif days_config: - # No explicit mode recorded (legacy config) - fall back to - # inferring from presence of a 'days' dict for the current day. - if current_day in days_config: - use_per_day = True - else: - logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day) - - if use_per_day: - day_config = days_config[current_day] - - if not day_config.get('enabled', True): - was_active = self._was_display_active - self.is_display_active = False - if was_active: - logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day) - else: - logger.debug("Display inactive - %s is disabled in schedule", current_day) - self._was_display_active = self.is_display_active - return - - start_time_str = day_config.get('start_time', '07:00') - end_time_str = day_config.get('end_time', '23:00') - schedule_type = f"per-day ({current_day})" + active, why = schedule_state(schedule_config, current_time) + if active != self._was_display_active: + logger.info("Schedule activated: Display is now %s (%s)%s", + "ACTIVE" if active else "INACTIVE", why, + "" if active else ". Display will be blanked.") else: - start_time_str = schedule_config.get('start_time', '07:00') - end_time_str = schedule_config.get('end_time', '23:00') - schedule_type = "global" - - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - end_time = datetime.strptime(end_time_str, '%H:%M').time() - self.is_display_active = self._in_window(start_time, end_time, current_time_only) - - was_active = self._was_display_active - if not self.is_display_active: - if was_active: - logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.", - schedule_type, start_time_str, end_time_str) - else: - logger.debug("Display inactive - outside %s schedule window (%s - %s)", - schedule_type, start_time_str, end_time_str) - else: - if not was_active: - logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)", - schedule_type, start_time_str, end_time_str) - else: - logger.debug("Display active - within %s schedule window (%s - %s)", - schedule_type, start_time_str, end_time_str) - - self._was_display_active = self.is_display_active - - except ValueError as e: - logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.", - schedule_type, e, start_time_str, end_time_str) - self.is_display_active = True - self._was_display_active = True + logger.debug("Display %s - %s", "active" if active else "inactive", why) + self.is_display_active = active + self._was_display_active = active def _check_dim_schedule(self) -> int: """ @@ -2990,6 +2914,50 @@ 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 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): """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 # 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 +3440,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. @@ -4201,7 +4187,7 @@ 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) + manager_to_display = self._plugin_to_draw(plan) 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 @@ -4811,6 +4797,7 @@ class DisplayController: # Invalidate minute-gates so the new schedule/dim times take effect immediately self._schedule_checked_minute = None self._dim_checked_minute = None + self._plugin_hidden_key = None self._cached_target_brightness = self._normal_brightness logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)", self._normal_brightness, self._scroll_speed) diff --git a/src/plugin_schedule.py b/src/plugin_schedule.py new file mode 100644 index 00000000..9f3482e0 --- /dev/null +++ b/src/plugin_schedule.py @@ -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])) diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index ccbf7db2..d377ea9f 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -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,11 @@ 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. + # 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). self._active_buffer: Deque[ContentSegment] = deque() @@ -295,10 +300,14 @@ 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() 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. diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index 7beb1c09..b9a9ffcf 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -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", diff --git a/test/test_plugin_schedule.py b/test/test_plugin_schedule.py new file mode 100644 index 00000000..f6942ecc --- /dev/null +++ b/test/test_plugin_schedule.py @@ -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'] diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index 943ace53..fb01ecf9 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -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']) def get_schedule_config(): """Get current schedule configuration""" @@ -261,114 +360,9 @@ def save_schedule_config(): # Load current config current_config = api_v3.config_manager.load_config() - # 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 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 - ) + 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 @@ -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""" diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 1de29c11..58d1e9a6 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -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(): diff --git a/web_interface/static/v3/js/pages/schedule.js b/web_interface/static/v3/js/pages/schedule.js index 758d1de6..6ca6c587 100644 --- a/web_interface/static/v3/js/pages/schedule.js +++ b/web_interface/static/v3/js/pages/schedule.js @@ -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); diff --git a/web_interface/static/v3/js/widgets/schedule-picker.js b/web_interface/static/v3/js/widgets/schedule-picker.js index d4cb13e9..15083c59 100644 --- a/web_interface/static/v3/js/widgets/schedule-picker.js +++ b/web_interface/static/v3/js/widgets/schedule-picker.js @@ -188,7 +188,7 @@ class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> Enable Schedule -

When enabled, the display will only operate during specified hours.

+

${window.LEDEscape.html(xOptions.enableDescription || 'When enabled, the display will only operate during specified hours.')}

`; } diff --git a/web_interface/templates/v3/partials/schedule.html b/web_interface/templates/v3/partials/schedule.html index 5ed53ea6..ccbe3e98 100644 --- a/web_interface/templates/v3/partials/schedule.html +++ b/web_interface/templates/v3/partials/schedule.html @@ -85,4 +85,51 @@ + + +
+
+

Plugin Schedules

+

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.

+
+ + {% if plugin_schedule_rows %} +
+ {% for row in plugin_schedule_rows %} +
+ + {{ row.name }} + {% if row.schedule.enabled or row.schedule.hide_when_live %} + scheduled + {% endif %} + +
+ + +
+
+ +
+
+
+ {% endfor %} +
+ {% else %} +

No enabled plugins.

+ {% endif %} +