From 1fe7965f5c5de9390c1b8220e9deb0329aea00af Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:16:47 -0400 Subject: [PATCH] feat(schedule): per-plugin schedules and hide-while-live (#799) * feat(schedule): per-plugin schedules and hide-while-live Users asked to turn off news and stocks on gameday so only scores show. Each plugin can now have its own schedule (global or per-day window in which it may show) plus hide_when_live, which hides it whenever any plugin reports a live game. A hidden plugin is skipped in the rotation, the live takeover and the Vegas ticker; it stays loaded, and on-demand still shows it. - src/plugin_schedule.py: the window rule, now shared with the display schedule (_check_schedule calls schedule_state) - core config key plugin_schedules; GET/POST /api/v3/config/plugin-schedules - Schedule page: Plugin Schedules card, one picker per enabled plugin Co-Authored-By: Claude Opus 5.5 * fix(schedule): tolerate wrong-shaped schedules, quiet skipped modes - schedule_state treats a non-string mode, non-dict days or a non-dict day entry as missing instead of raising in the display loop - the run loop no longer warns "No plugin manager found" for a mode it skips: _plugin_to_draw / _plugin_for_mode already log why (a scheduled-off plugin at DEBUG) - Schedule page DOM test: every form is marked data-reports-result, not exactly two (each plugin adds one) Co-Authored-By: Claude Opus 5.5 * fix(schedule): guard plugin_schedules shape and the cross-thread cache - a plugin_schedules that is not a dict (list, string) hides nothing instead of raising in the display loop - the hidden-set cache keys on the schedules themselves and is published as one tuple, so a Vegas-thread call still holding the pre-reload config cannot leave its answer standing after a reload; the separate invalidation in _refresh_config_cache is gone Co-Authored-By: Claude Opus 5.5 * fix(schedule): no live hold on a scheduled-off mode; blank an empty rotation - _still_live ignores a plugin its schedule hides, so a rotation landing on a scheduled-off live mode advances instead of holding a mode it skips - once a whole rotation has had nothing to show (every plugin empty or scheduled off), the panel is cleared once instead of leaving the last screen up Co-Authored-By: Claude Opus 5.5 * fix(schedule): push the empty-rotation blank to the web preview clear() alone blanked the matrix but left the preview snapshot on the last frame (seen on ledpi with every plugin scheduled off). Follow it with update_display(), as the scheduled-off blank does. Co-Authored-By: Claude Opus 5.5 * fix(schedule): write the empty-rotation blank to the preview The preview throttle skipped the blank frame (written 12 s after the last snapshot), and write_owed_snapshot() only runs after a display() call, which an all-empty rotation never makes: on ledpi the preview kept the startup splash while the panel was dark. Flush the owed snapshot on each paused empty pass. Co-Authored-By: Claude Opus 5.5 * fix(schedule): quiet the once-a-second logs when nothing can show With every plugin scheduled off (seen on ledpi), the loop retries once a second and journaled three to four lines each time: Vegas's "No plugins available" WARNING, the coordinator's "Failed to initialize stream manager" ERROR, the 0-plugin rotation summary and "Switching to mode". Vegas now warns once until its list fills again, the coordinator line is DEBUG (initialize only fails for an empty list), and the mode-switch line is DEBUG once a whole rotation has been empty. Co-Authored-By: Claude Opus 5.5 * fix(schedule): end the scroll before the empty blank; Vegas sees hidden changes sooner - the empty-rotation blank is controller-drawn, so it ends the last scroll's state first (_end_scroll_before_core_screen), as the scheduled-off blank does - StreamManager.refresh() rebuilds the plugin list when the hidden set changed, even inside its 30 s interval, so a newly hidden plugin leaves at the next scroll group rather than staying for later ones Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 10 + config/config.template.json | 1 + docs/CONFIG_REFERENCE.md | 35 +++ docs/REST_API_REFERENCE.md | 14 + src/core_config_keys.py | 1 + src/display_controller.py | 212 +++++++------- src/plugin_schedule.py | 104 +++++++ src/vegas_mode/coordinator.py | 4 +- src/vegas_mode/stream_manager.py | 29 +- test/fixtures/api_v3_url_map.json | 17 ++ test/js/dom/test_schedule_page.js | 7 +- .../test_display_controller_empty_rotation.py | 15 + test/test_plugin_schedule.py | 205 +++++++++++++ test/test_vegas_stream_log_volume.py | 19 ++ 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 +++ 19 files changed, 816 insertions(+), 221 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..2563af09 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -55,6 +55,41 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to boundaries as `schedule`: dimmed from `start_time` up to, but not including, `end_time`. +## `plugin_schedules` — when each plugin may show + +Maps a plugin id to a schedule shaped like `schedule` (the window in which +that plugin may show), plus: + +| Key | Type / default | Meaning | +|---|---|---| +| `hide_when_live` | bool, `false` | Hide the plugin whenever any plugin reports a live game (its `has_live_content()`), live priority or not | + +A plugin with no entry always shows. Outside its window, or during a live +game with `hide_when_live`, a plugin is skipped in the rotation, in the live +takeover and in Vegas mode; it stays loaded, and an on-demand request still shows it. Example, news off on +Sundays and during any live game: + +```json +"plugin_schedules": { + "news": {"enabled": true, "mode": "per-day", "hide_when_live": true, + "start_time": "00:00", "end_time": "23:59", + "days": {"sunday": {"enabled": false}}} +} +``` + +As in `schedule`, a day missing from `days` uses the top-level times +(`07:00`–`23:00` when those are missing too). The window ends at `end_time`, +so `00:00`–`23:59` leaves the plugin out for that last minute. + +A change takes effect at the next screen in the rotation or live takeover, +and in Vegas mode at its next plugin-list refresh: the next scroll group, +or at the latest the next scroll cycle (about 6 minutes on ledpi with 8 +plugins). Content already composed into the ticker scrolls out first. + +Read by `src/plugin_schedule.py` through `DisplayController._hidden_plugins()`. +Managed in the web UI under Schedule → Plugin Schedules, saved via +`POST /api/v3/config/plugin-schedules`. + ## `display.hardware` — matrix panel hardware All keys map to the corresponding `rpi-rgb-led-matrix` options and are read 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..d04115ac 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -20,6 +20,7 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls :meth:`~DisplayController.run`. """ +import logging import time import os import inspect @@ -37,6 +38,7 @@ import pytz from src import display_watchdog from src.malloc_tuning import MallocTrimmer +from src.plugin_schedule import hidden_plugins, in_window, schedule_state, wants_live_scan from src.display_arbiter import ( Arbiter, ArbiterInputs, ArbiterState, FramePolicy, ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan, @@ -705,6 +707,11 @@ class DisplayController: self._schedule_checked_minute: Optional[tuple] = None self._dim_checked_minute: Optional[tuple] = None self._cached_target_brightness: int = self._normal_brightness + # Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py): + # (key, hidden set), see _hidden_plugins. + self._plugin_hidden_cache: Tuple[Optional[tuple], FrozenSet[str]] = (None, frozenset()) + self._game_live = False + self._game_live_at = float('-inf') # Register controller-level hot-reload callback so cached config values # (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync @@ -751,6 +758,8 @@ class DisplayController: # Set up live priority checker self.vegas_coordinator.set_live_priority_checker(self._check_live_priority) + # Plugins scheduled off sit out the ticker too. + self.vegas_coordinator.stream_manager.hidden_plugins = self._hidden_plugins # Set up interrupt checker for on-demand/wifi status and follower mode def _vegas_interrupt(): @@ -912,38 +921,19 @@ class DisplayController: self._tz = pytz.UTC return self._tz - @staticmethod - def _in_window(start, end, now) -> bool: - """Whether ``now`` is within [start, end), a window that may span midnight. - - Half-open: on from the start minute, off at exactly the end minute. - A closed end made the end minute count as inside, and because ``now`` - carries seconds, only a check at hh:mm:00.000 saw it that way -- so - whether the panel went off at the start or the end of that minute - depended on when the minute's one check ran. ``start == end`` is an - empty window, as it effectively was before. - """ - if start <= end: - return start <= now < end - return now >= start or now < end + # The window rule, shared with the dim schedule and plugin schedules. + _in_window = staticmethod(in_window) def _check_schedule(self): """Check if display should be active based on schedule.""" schedule_config = self.config.get('schedule', {}) - # No schedule configured: always active. - if not schedule_config: + # No schedule, or a disabled one: always active. + if not schedule_config or not schedule_config.get('enabled', True): self.is_display_active = True self._was_display_active = True return - # A schedule without an 'enabled' key counts as enabled. - if 'enabled' in schedule_config and not schedule_config.get('enabled', True): - self.is_display_active = True - self._was_display_active = True - logger.debug("Schedule is disabled - display always active") - return - current_time = datetime.now(self._timezone()) # Gate: schedule state can only change on a minute boundary, so skip # all the strptime / comparison work if we already evaluated this minute. @@ -952,81 +942,15 @@ class DisplayController: return self._schedule_checked_minute = current_minute_key - current_day = current_time.strftime('%A').lower() # e.g. 'monday' - current_time_only = current_time.time() - - # Check if per-day schedule is configured - days_config = schedule_config.get('days') - - # Determine which schedule to use. Respect an explicit 'mode' field - # (like the dim schedule does) so a stray/legacy 'days' dict left over - # from config migration or a prior per-day setup can't silently - # override a user's Global schedule selection. - mode = schedule_config.get('mode') - mode_normalized = mode.replace('_', '-') if mode else None - - use_per_day = False - if mode_normalized == 'global': - use_per_day = False - elif mode_normalized == 'per-day': - use_per_day = bool(days_config and current_day in days_config) - elif days_config: - # No explicit mode recorded (legacy config) - fall back to - # inferring from presence of a 'days' dict for the current day. - if current_day in days_config: - use_per_day = True - else: - logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day) - - if use_per_day: - day_config = days_config[current_day] - - if not day_config.get('enabled', True): - was_active = self._was_display_active - self.is_display_active = False - if was_active: - logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day) - else: - logger.debug("Display inactive - %s is disabled in schedule", current_day) - self._was_display_active = self.is_display_active - return - - start_time_str = day_config.get('start_time', '07:00') - end_time_str = day_config.get('end_time', '23:00') - schedule_type = f"per-day ({current_day})" + active, why = schedule_state(schedule_config, current_time) + if active != self._was_display_active: + logger.info("Schedule activated: Display is now %s (%s)%s", + "ACTIVE" if active else "INACTIVE", why, + "" if active else ". Display will be blanked.") else: - start_time_str = schedule_config.get('start_time', '07:00') - end_time_str = schedule_config.get('end_time', '23:00') - schedule_type = "global" - - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - end_time = datetime.strptime(end_time_str, '%H:%M').time() - self.is_display_active = self._in_window(start_time, end_time, current_time_only) - - was_active = self._was_display_active - if not self.is_display_active: - if was_active: - logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.", - schedule_type, start_time_str, end_time_str) - else: - logger.debug("Display inactive - outside %s schedule window (%s - %s)", - schedule_type, start_time_str, end_time_str) - else: - if not was_active: - logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)", - schedule_type, start_time_str, end_time_str) - else: - logger.debug("Display active - within %s schedule window (%s - %s)", - schedule_type, start_time_str, end_time_str) - - self._was_display_active = self.is_display_active - - except ValueError as e: - logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.", - schedule_type, e, start_time_str, end_time_str) - self.is_display_active = True - self._was_display_active = True + logger.debug("Display %s - %s", "active" if active else "inactive", why) + self.is_display_active = active + self._was_display_active = active def _check_dim_schedule(self) -> int: """ @@ -1505,8 +1429,18 @@ class DisplayController: if self._empty_pass_streak < rotation: return if self._empty_pass_streak == rotation: + # Blank once: with every plugin empty or scheduled off, the last + # screen drawn would otherwise stay up indefinitely. A blank the + # controller draws, so the last scroll's state ends first. + self._end_scroll_before_core_screen() + self.display_manager.clear() + self.display_manager.update_display() logger.info("No mode has anything to show; checking one mode every %.0fs " "until one does", self.EMPTY_ROTATION_PAUSE) + # The preview throttle may have skipped the blank frame, and with no + # display() call to follow, nothing else writes it (see + # DisplayManager.write_owed_snapshot). Cheap when nothing is owed. + self.display_manager.write_owed_snapshot() self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE) #: Plugins already warned about a display duration that is not a number, @@ -2990,6 +2924,54 @@ class DisplayController: self.force_change = True logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode) + #: How long a "is any game live?" answer is reused for plugin schedules. + GAME_LIVE_TTL = 5.0 + + def _any_game_live(self) -> bool: + """Whether any plugin reports live content, asked at most every GAME_LIVE_TTL s. + + Unlike _collect_live_modes this asks every plugin, live priority or + not: a scoreboard without live priority still makes it gameday. + """ + now = time.monotonic() + if now - self._game_live_at >= self.GAME_LIVE_TTL: + self._game_live_at = now + live = False + for plugin in {id(p): p for p in list(self.plugin_modes.values())}.values(): + try: + if hasattr(plugin, 'has_live_content') and plugin.has_live_content(): + live = True + break + except Exception as e: # pylint: disable=broad-except + logger.warning("Error checking live content for %s: %s", + getattr(plugin, 'plugin_id', plugin), e) + self._game_live = live + return self._game_live + + def _hidden_plugins(self) -> FrozenSet[str]: + """Plugin ids their plugin_schedules entry keeps off the panel right now. + + Recomputed when the schedules, the minute or the live-game answer + change. Also Vegas's filter (StreamManager.hidden_plugins), from its + own thread: the schedules are part of the key and the cache is one + tuple, so a call still holding the config from before a reload + cannot leave its answer standing for the new one. + """ + schedules = self.config.get('plugin_schedules') + if not isinstance(schedules, dict) or not schedules: + return frozenset() + now = datetime.now(self._timezone()) + game_live = wants_live_scan(schedules) and self._any_game_live() + key = (schedules, now.hour, now.minute, game_live) + cached_key, hidden = self._plugin_hidden_cache + if key != cached_key: + previous = hidden + hidden = hidden_plugins(schedules, now, game_live) + if hidden != previous: + logger.info("Plugin schedules: hidden now %s", sorted(hidden) or "none") + self._plugin_hidden_cache = (key, hidden) + return hidden + def _collect_live_modes(self): """Return every currently live-priority mode, in registration order. @@ -3006,10 +2988,14 @@ class DisplayController: # Asked once per plugin per scan, not once per mode key: a scoreboard # registered under several modes computes the same answer each time. is_live: Dict[int, bool] = {} + # A scoreboard scheduled off does not win the panel by going live. + hidden = self._hidden_plugins() for mode_name, plugin_instance in self.plugin_modes.items(): if not (hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content')): continue + if getattr(plugin_instance, 'plugin_id', mode_name) in hidden: + continue try: key = id(plugin_instance) if key not in is_live: @@ -3468,6 +3454,20 @@ class DisplayController: logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}") return plugin_instance + def _plugin_to_draw(self, plan: ScreenPlan): + """The plugin to draw ``plan``'s mode, or None to skip it this pass. + + _plugin_for_mode, and None for a plugin its plugin_schedules entry + keeps off the panel right now: skipped like an empty screen. An + on-demand plan is the user asking, so it still shows. + """ + plugin = self._plugin_for_mode(plan.mode) + if (plugin is not None and plan.source is not Source.ON_DEMAND + and getattr(plugin, 'plugin_id', plan.mode) in self._hidden_plugins()): + logger.debug("%s is scheduled off, skipping", plan.mode) + return None + return plugin + def _start_screen_handover(self, plugin, active_mode: str) -> bool: """Before a screen's first dispatch: if the screen is static, keep the last scroll's leftovers off its first frame. @@ -3920,14 +3920,22 @@ class DisplayController: elif nxt is not state: self._adopt_state(nxt) self.force_change = True - logger.info("Switching to mode: %s", self.current_display_mode) + # DEBUG once a whole rotation has been empty (_note_empty_pass): + # it then steps once a second, and every plugin scheduled off + # would otherwise journal a line a second. + logger.log(logging.DEBUG if self._empty_pass_streak >= max(1, len(self.available_modes)) + else logging.INFO, + "Switching to mode: %s", self.current_display_mode) def _still_live(self, mode: Optional[str]) -> bool: """The live hold: ``mode``'s plugin has live priority and live content, - so the rotation stays on it.""" + so the rotation stays on it. Never for a plugin scheduled off: the + hold would keep the rotation on a mode it skips.""" if mode not in self.plugin_modes: return False plugin_instance = self.plugin_modes[mode] + if getattr(plugin_instance, 'plugin_id', mode) in self._hidden_plugins(): + return False if not (hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content')): return False @@ -4201,10 +4209,8 @@ class DisplayController: logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys())) # Handle plugin-based display modes - manager_to_display = self._plugin_for_mode(active_mode) - if not manager_to_display: - logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode") - manager_to_display = None + # None skips the mode; _plugin_to_draw has logged why. + manager_to_display = self._plugin_to_draw(plan) # One screen: the first frame, the frame loop, the make-up # dwell (src/screen_runner.py). diff --git a/src/plugin_schedule.py b/src/plugin_schedule.py new file mode 100644 index 00000000..34efd233 --- /dev/null +++ b/src/plugin_schedule.py @@ -0,0 +1,104 @@ +"""Schedule windows: the display's on/off schedule and per-plugin schedules. + +A schedule is the dict config.json keeps under ``schedule``:: + + {"enabled": true, "mode": "global" | "per-day", + "start_time": "07:00", "end_time": "23:00", + "days": {"monday": {"enabled": true, "start_time": ..., "end_time": ...}, ...}} + +``config['plugin_schedules']`` maps a plugin id to one of these, the window in +which that plugin may take a turn on the panel, plus ``hide_when_live``: hide +the plugin while any plugin reports a live game ("no news or stocks on +gameday"). A plugin without an entry always shows. Hiding is a skip at +display time, never the plugin's ``enabled`` flag: flipping that unloads and +reloads the plugin and throws away its data. +""" + +import logging +from datetime import datetime, time +from typing import Any, FrozenSet, Mapping, Tuple + +logger = logging.getLogger(__name__) + + +def in_window(start: time, end: time, now: time) -> bool: + """Whether ``now`` is within [start, end), a window that may span midnight. + + Half-open: on from the start minute, off at exactly the end minute. + A closed end made the end minute count as inside, and because ``now`` + carries seconds, only a check at hh:mm:00.000 saw it that way -- so + whether the panel went off at the start or the end of that minute + depended on when the minute's one check ran. ``start == end`` is an + empty window, as it effectively was before. + """ + if start <= end: + return start <= now < end + return now >= start or now < end + + +def schedule_state(schedule: Mapping[str, Any], now: datetime) -> Tuple[bool, str]: + """Whether ``now`` is inside ``schedule``, and a phrase saying why. + + No schedule, or ``enabled: false``, is always inside; a schedule without + an ``enabled`` key counts as enabled. ``mode`` "global" ignores ``days``; + "per-day" (or "per_day") uses today's entry and falls back to the global + times when today has none; with no ``mode`` (older configs) a ``days`` + entry for today wins. A disabled day is outside all day. A time that does + not parse is logged and counts as inside, so a bad edit never blanks the + panel. + """ + if not schedule or not schedule.get('enabled', True): + return True, 'no schedule' + + day = now.strftime('%A').lower() + # Wrong-shaped values (the raw config editor allows any) are treated as + # missing rather than raised: this runs in the display loop. + days = schedule.get('days') + if not isinstance(days, Mapping): + days = {} + mode = schedule.get('mode') + mode = mode.replace('_', '-') if isinstance(mode, str) else None + # An explicit Global mode beats a stray 'days' dict left from a past + # per-day setup. + per_day = mode != 'global' and isinstance(days.get(day), Mapping) + + if per_day: + day_config = days[day] + if not day_config.get('enabled', True): + return False, f'{day} is disabled' + start_str = day_config.get('start_time', '07:00') + end_str = day_config.get('end_time', '23:00') + kind = f'per-day ({day})' + else: + start_str = schedule.get('start_time', '07:00') + end_str = schedule.get('end_time', '23:00') + kind = 'global' + + try: + start = datetime.strptime(start_str, '%H:%M').time() + end = datetime.strptime(end_str, '%H:%M').time() + except (TypeError, ValueError) as e: + logger.warning("Invalid %s schedule: %s (start: %s, end: %s). Treating as inside.", + kind, e, start_str, end_str) + return True, f'invalid {kind} schedule' + return in_window(start, end, now.time()), f'{kind} window {start_str} - {end_str}' + + +def wants_live_scan(plugin_schedules: Mapping[str, Any]) -> bool: + """Whether any plugin hides during live games, so live content is worth asking about.""" + return any(isinstance(s, Mapping) and s.get('hide_when_live') + for s in plugin_schedules.values()) + + +def hidden_plugins(plugin_schedules: Mapping[str, Any], now: datetime, + game_live: bool) -> FrozenSet[str]: + """The plugin ids that may not show at ``now``. + + A plugin is hidden outside its schedule window, or while ``game_live`` + when its entry sets ``hide_when_live``. + """ + return frozenset( + plugin_id for plugin_id, schedule in plugin_schedules.items() + if isinstance(schedule, Mapping) + and ((game_live and schedule.get('hide_when_live')) + or not schedule_state(schedule, now)[0])) diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index e7467636..b3789a3a 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -303,7 +303,9 @@ class VegasModeCoordinator: # Initialize stream manager if not self.stream_manager.initialize(): - logger.error("Failed to initialize stream manager") + # Only fails with no plugin to show (every one excluded or + # scheduled off), which initialize() has logged once. + logger.debug("Vegas not started: no plugins to scroll") return False # Compose initial content diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index ccbf7db2..252e7f4e 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,16 @@ class StreamManager: self.config = config self.plugin_manager = plugin_manager self.plugin_adapter = plugin_adapter + # Plugin ids to leave out right now (config['plugin_schedules']); + # the display controller sets it. Read at each list refresh. + # A change to it rebuilds the list at the next refresh() even inside + # the refresh interval; segments already composed still scroll out. + self.hidden_plugins: Optional[Callable[[], FrozenSet[str]]] = None + self._applied_hidden: FrozenSet[str] = frozenset() + # True once "no plugins" has been logged, until the list fills again: + # with every plugin scheduled off the display retries every second, + # and three log lines a second is a full journal on an SD card. + self._empty_logged = False # Segments composed into the current cycle (swap mode only). self._active_buffer: Deque[ContentSegment] = deque() @@ -106,8 +116,11 @@ class StreamManager: self._refresh_plugin_list() if not self._ordered_plugins: - logger.warning("No plugins available for Vegas scroll") + if not self._empty_logged: + logger.warning("No plugins available for Vegas scroll") + self._empty_logged = True return False + self._empty_logged = False # Fill the buffer to a whole cycle's worth of plugins. This used to be # buffer_ahead + 1, which conflated prefetch depth with cycle size and @@ -268,7 +281,9 @@ class StreamManager: Called periodically to pick up new plugins or config changes. """ current_time = time.time() - if current_time - self._last_refresh < self._refresh_interval: + hidden = self.hidden_plugins() if self.hidden_plugins else frozenset() + if (current_time - self._last_refresh < self._refresh_interval + and hidden == self._applied_hidden): return self._last_refresh = current_time @@ -295,10 +310,15 @@ class StreamManager: # own thread (DisplayController._start_plugin_reload). plugins = list(self.plugin_manager.plugins.items()) loaded = len(plugins) + hidden = self.hidden_plugins() if self.hidden_plugins else frozenset() + self._applied_hidden = hidden for plugin_id, plugin in plugins: if not getattr(plugin, 'enabled', False): logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id) continue + if plugin_id in hidden: + logger.debug("[%s] Vegas: skipped (scheduled off)", plugin_id) + continue # 'pause' plugins stay in the rotation: they pause the scroll # for their turn rather than contributing to it. @@ -329,7 +349,8 @@ class StreamManager: slots = (f", {len(ordered_plugins)} slots" if len(ordered_plugins) != len(set(ordered_plugins)) else "") - logger.info( + logger.log( + logging.DEBUG if (self._empty_logged and not ordered_plugins) else logging.INFO, "Vegas rotation: %d of %d loaded plugin(s)%s: %s", len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins) ) 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/js/dom/test_schedule_page.js b/test/js/dom/test_schedule_page.js index 4bb21d16..bc6387d4 100644 --- a/test/js/dom/test_schedule_page.js +++ b/test/js/dom/test_schedule_page.js @@ -39,8 +39,11 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l)) ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial)); ok('the forms carry no hx-on handler', !/hx-on/i.test(partial)); ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial)); - ok('both forms are marked data-reports-result', - (partial.match(/]*data-reports-result/g) || []).length === 2); + // The two schedule forms, plus one per plugin in the Plugin Schedules card. + ok('every form is marked data-reports-result', + (partial.match(/]*data-reports-result/g) || []).length + === (partial.match(/]/g) || []).length + && (partial.match(/]/g) || []).length >= 2); const errs = []; const logged = []; diff --git a/test/test_display_controller_empty_rotation.py b/test/test_display_controller_empty_rotation.py index 1ef7b6b6..dec002fe 100644 --- a/test/test_display_controller_empty_rotation.py +++ b/test/test_display_controller_empty_rotation.py @@ -8,6 +8,7 @@ now pauses between passes, and it stops pausing as soon as something shows. """ import os +from unittest.mock import MagicMock os.environ.setdefault("EMULATOR", "true") @@ -21,6 +22,7 @@ def _bare_controller(modes, on_demand_modes=None): dc.on_demand_modes = list(on_demand_modes or []) dc.sleeps = [] dc._sleep_with_plugin_updates = lambda duration, tick_interval=1.0: dc.sleeps.append(duration) + dc.display_manager = MagicMock() return dc @@ -37,6 +39,19 @@ class TestNoteEmptyPass: dc._note_empty_pass() # Passes 3, 4 and 5 are at or past one full rotation. assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE] * 3 + # Blanked once, so the last screen drawn does not stay up. + assert dc.display_manager.clear.call_count == 1 + # A controller-drawn blank: the last scroll's state is ended first. + dc.display_manager.set_scrolling_state.assert_called_once_with(False) + assert dc.display_manager.update_display.call_count == 1 + # ...and the preview gets the blank frame however the throttle fell. + assert dc.display_manager.write_owed_snapshot.call_count == 3 + + def test_a_partly_empty_rotation_never_blanks(self): + dc = _bare_controller(["a", "b", "c"]) + dc._note_empty_pass() + dc._note_empty_pass() + dc.display_manager.clear.assert_not_called() def test_content_resets_the_streak(self): dc = _bare_controller(["a", "b"]) diff --git a/test/test_plugin_schedule.py b/test/test_plugin_schedule.py new file mode 100644 index 00000000..98309277 --- /dev/null +++ b/test/test_plugin_schedule.py @@ -0,0 +1,205 @@ +"""Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py). + +The window rule itself is the display schedule's, covered case by case in +test_display_controller_schedule.py through _check_schedule; here: the +plugin-level answer, and the three places it is applied -- the rotation, +the live scan, and the Vegas ticker -- plus the save endpoint. +""" + +import copy +import os +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +os.environ.setdefault("EMULATOR", "true") + +from src.display_arbiter import ScreenPlan, Source # noqa: E402 +from src.display_controller import DisplayController # noqa: E402 +from src.plugin_schedule import hidden_plugins, schedule_state, wants_live_scan # noqa: E402 +from src.vegas_mode.config import VegasModeConfig # noqa: E402 +from src.vegas_mode.stream_manager import StreamManager # noqa: E402 +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: E402,F401 + +DAYS = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday') +SUNDAY_1PM = datetime(2026, 10, 18, 13, 0) # a Sunday +MONDAY_1PM = datetime(2026, 10, 19, 13, 0) + + +def sundays_off(**extra): + """The gameday schedule: every day all day, except Sunday.""" + days = {d: {'enabled': d != 'sunday', 'start_time': '00:00', 'end_time': '23:59'} + for d in DAYS} + return {'enabled': True, 'mode': 'per-day', 'days': days, **extra} + + +ALWAYS_OFF = {'enabled': True, 'mode': 'per-day', + 'days': {d: {'enabled': False} for d in DAYS}} + + +class TestHiddenPlugins: + def test_outside_window_is_hidden(self): + schedules = {'news': sundays_off(), 'stocks': sundays_off()} + assert hidden_plugins(schedules, SUNDAY_1PM, False) == {'news', 'stocks'} + assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset() + + def test_hide_when_live_only_while_live(self): + schedules = {'news': {'enabled': False, 'hide_when_live': True}} + assert hidden_plugins(schedules, MONDAY_1PM, True) == {'news'} + assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset() + + def test_disabled_schedule_and_junk_entries_hide_nothing(self): + schedules = {'news': {**ALWAYS_OFF, 'enabled': False}, 'bad': 'x', 'none': None} + assert hidden_plugins(schedules, SUNDAY_1PM, True) == frozenset() + + def test_bad_time_shows_rather_than_hides(self): + assert schedule_state({'mode': 'global', 'start_time': '25:00'}, MONDAY_1PM)[0] + + @pytest.mark.parametrize('schedule', [ + {'mode': 1}, + {'mode': 'per-day', 'days': 5}, + {'mode': 'per-day', 'days': {'sunday': False}}, + {'mode': 'per-day', 'days': ['sunday']}, + ]) + def test_wrong_shapes_show_rather_than_raise(self, schedule): + # Falls back to the global 07:00-23:00 window, which 13:00 is inside. + assert schedule_state(schedule, SUNDAY_1PM)[0] + + def test_global_mode_ignores_days(self): + schedule = {**sundays_off(), 'mode': 'global', 'start_time': '12:00', 'end_time': '14:00'} + assert schedule_state(schedule, SUNDAY_1PM)[0] + + def test_wants_live_scan(self): + assert not wants_live_scan({'news': sundays_off()}) + assert wants_live_scan({'news': sundays_off(), 'stocks': {'hide_when_live': True}}) + + +def plugin(plugin_id, live=False, priority=False): + return SimpleNamespace(plugin_id=plugin_id, display=lambda **_: True, + has_live_content=lambda: live, + has_live_priority=lambda: priority, + get_live_modes=lambda: [f'{plugin_id}_live']) + + +def controller(schedules, plugin_modes): + dc = object.__new__(DisplayController) + dc.config = {'plugin_schedules': schedules} + dc._tz = None + dc.plugin_modes = plugin_modes + dc._plugin_hidden_cache = (None, frozenset()) + dc._game_live = False + dc._game_live_at = float('-inf') + dc._health_tracker = lambda: None + return dc + + +class TestController: + def test_rotation_skips_hidden_but_on_demand_shows_it(self): + news = plugin('news') + dc = controller({'news': ALWAYS_OFF}, {'news': news}) + assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is None + assert dc._plugin_to_draw(ScreenPlan(Source.ON_DEMAND, mode='news')) is news + + def test_no_schedules_changes_nothing(self): + news = plugin('news') + dc = controller({}, {'news': news}) + assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is news + + def test_any_live_game_hides_news_even_without_live_priority(self): + modes = {'news': plugin('news'), 'nfl_live': plugin('nfl', live=True)} + dc = controller({'news': {'enabled': False, 'hide_when_live': True}}, modes) + assert dc._hidden_plugins() == {'news'} + + def test_scoreboard_scheduled_off_does_not_win_live(self): + modes = {'nfl_live': plugin('nfl', live=True, priority=True)} + dc = controller({'nfl': ALWAYS_OFF}, modes) + assert dc._collect_live_modes() == [] + dc.config = {} + assert dc._collect_live_modes() == ['nfl_live'] + + def test_a_scheduled_off_live_mode_does_not_hold_the_rotation(self): + modes = {'nfl_live': plugin('nfl', live=True, priority=True)} + dc = controller({'nfl': ALWAYS_OFF}, modes) + assert not dc._still_live('nfl_live') + dc.config = {} + assert dc._still_live('nfl_live') + + @pytest.mark.parametrize('schedules', [['news'], 'news', 5]) + def test_wrong_shaped_plugin_schedules_hide_nothing(self, schedules): + dc = controller(schedules, {'news': plugin('news')}) + assert dc._hidden_plugins() == frozenset() + + def test_a_reload_is_never_answered_from_the_old_schedules(self): + dc = controller({'news': ALWAYS_OFF}, {'news': plugin('news')}) + assert dc._hidden_plugins() == {'news'} + # A new config in the same minute, with no cache reset. + dc.config = {'plugin_schedules': {'news': {'enabled': False}}} + assert dc._hidden_plugins() == frozenset() + + +class TestVegas: + def test_hidden_plugins_sit_out_the_ticker(self): + plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')} + stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock()) + stream._refresh_plugin_list() + assert set(stream._ordered_plugins) == {'news', 'nfl'} + stream.hidden_plugins = lambda: frozenset({'news'}) + stream._refresh_plugin_list() + assert set(stream._ordered_plugins) == {'nfl'} + + def test_a_changed_hidden_set_skips_the_refresh_interval(self): + plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')} + stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock()) + stream.refresh() # the interval's refresh + assert set(stream._ordered_plugins) == {'news', 'nfl'} + stream.refresh() # inside the interval, nothing changed: no rebuild + stream.hidden_plugins = lambda: frozenset({'news'}) + stream.refresh() # still inside the interval, but the set changed + assert set(stream._ordered_plugins) == {'nfl'} + + +class TestSaveEndpoint: + @pytest.fixture + def store(self, api_v3_module, monkeypatch): + state = {'config': {}} + api_v3_module.api_v3.config_manager.load_config.side_effect = \ + lambda *a, **k: copy.deepcopy(state['config']) + api_v3_module.api_v3.plugin_catalog.plugin_manifests = {'news': {}} + + def fake_save(_manager, config, **_kwargs): + state['config'] = copy.deepcopy(config) + return True, '' + + monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save) + return state + + def test_round_trip(self, api_v3_client, store): + body = {'plugin_id': 'news', 'hide_when_live': 'on', **sundays_off()} + resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body) + assert resp.status_code == 200, resp.get_json() + saved = store['config']['plugin_schedules']['news'] + assert saved['hide_when_live'] is True + assert saved['days']['sunday']['enabled'] is False + got = api_v3_client.get('/api/v3/config/plugin-schedules').get_json()['data'] + assert got == {'news': saved} + + def test_schedule_that_does_nothing_is_removed(self, api_v3_client, store): + store['config'] = {'plugin_schedules': {'news': sundays_off()}} + resp = api_v3_client.post('/api/v3/config/plugin-schedules', + json={'plugin_id': 'news', 'enabled': False}) + assert resp.status_code == 200, resp.get_json() + assert store['config']['plugin_schedules'] == {} + + @pytest.mark.parametrize('body,status', [ + ({'plugin_id': 'display', 'enabled': True}, 400), # a core section + ({'plugin_id': 'nope', 'enabled': True}, 404), # not installed + ({'plugin_id': 'news', 'enabled': True, 'mode': 'global', + 'start_time': '25:00', 'end_time': '23:00'}, 400), # bad time + ({'enabled': True}, 400), # no id + ]) + def test_refusals(self, api_v3_client, store, body, status): + resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body) + assert resp.status_code == status, resp.get_json() + assert 'plugin_schedules' not in store['config'] diff --git a/test/test_vegas_stream_log_volume.py b/test/test_vegas_stream_log_volume.py index 3a975362..8d37fdd5 100644 --- a/test/test_vegas_stream_log_volume.py +++ b/test/test_vegas_stream_log_volume.py @@ -59,6 +59,25 @@ def test_a_refresh_logs_a_single_info_summary(caplog): assert any('off' in m and 'not enabled' in m for m in debug) +def test_an_empty_list_is_logged_once_until_it_fills(caplog): + # Every plugin scheduled off: the display retries every second. + stream = _stream({'clock': _Plugin()}) + stream.hidden_plugins = lambda: frozenset({'clock'}) + with caplog.at_level(logging.DEBUG, logger=LOGGER): + for _ in range(3): + assert not stream.initialize() + loud = [r for r in caplog.records if r.name == LOGGER and r.levelno >= logging.INFO] + assert len(loud) == 2, [r.getMessage() for r in loud] # one summary, one warning + caplog.clear() + stream.hidden_plugins = lambda: frozenset() + with caplog.at_level(logging.DEBUG, logger=LOGGER): + assert stream.initialize() + stream.hidden_plugins = lambda: frozenset({'clock'}) + assert not stream.initialize() + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 # warned again after the list had filled + + def test_fetching_content_logs_nothing_at_info(caplog): stream = _stream({'clock': _Plugin()}) with caplog.at_level(logging.DEBUG, logger=LOGGER): 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 %} +