diff --git a/CHANGELOG.md b/CHANGELOG.md index 4757292f..41b08ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Tooling + +- `test/test_sports_helpers.py`'s parity tests pass again with + `LEDMATRIX_PLUGINS` set. The scoreboards deleted their copies of the + `sports_helpers` bodies and constants when they adopted `SportsHelpersMixin` + (ledmatrix-plugins #563/#564), and the 19 tests still expected them. A copy + that is gone now counts as adopted when the plugin imports + `src.common.sports_helpers`, as the stage 3/4 and game-over parity tests + already do; a copy that remains must still match. + ### Dead code removed, unused plugin APIs deprecated An over-engineering audit of the whole tree. Every symbol below was checked @@ -1338,6 +1348,25 @@ policies are unchanged. a runtime publisher that stops still goes `stale`, and a subscription that goes quiet still falls back to the cache. The cache path's 120 s rule is unchanged. +- A plugin that pauses the Vegas scroll gets its pause when its display + duration is not a plain number. Several plugins (clock-simple, calendar, + countdown) return `display_duration` as it is in config.json, so a value + saved as `"20"` or `null` (the raw config editor, a hand edit) reached the + pause as a string or None; comparing it with the clock raised, and the + plugin flashed up and the scroll went straight on, at every one of its + turns. `inf` held the pause until something interrupted it, and 0, a + negative number or NaN ended it at once. The pause now reads the duration + as the rotation does (`finite_seconds()` in `base_plugin`): a numeric + string counts, anything else that is not a finite number (or a + `get_display_duration()` that raises) pauses for 30 s, and a number at or + below zero for 15 s, with one warning per plugin. +- Reinstalling Weather, Music, Stocks or Leaderboard from the Plugin Store + while it is enabled asks for a display restart, as reinstalling any other + enabled plugin does. `POST /api/v3/plugins/install` looked for the + plugin's `enabled` flag under the store id (`weather`), but its config + section is under the id its manifest declares (`ledmatrix-weather`), so + `restart_required` was always false and the display kept running the + copy it had loaded. The check now uses the installed id. ### Scrolling diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5e294d0b..20882ad6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -143,7 +143,9 @@ loaded and when. Nothing else keeps plugin state: `DisplayController` right after it creates the `PluginManager`, writes the cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error` (type, a redacted message of at most 200 characters, when, recoverable), -`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`. +`version`, `loaded_at` and `modes` (the display modes `DisplayController` +registered -- `plugin.modes` when the plugin computes them, else the +manifest's), plus `published_at`, `stale_after` and `running`. The cache is on disk, usually the SD card, so it writes when something a reader sees changes -- throttled to once per 10 s -- and otherwise once a minute as a heartbeat. RUNNING, which every `update()` passes through, is @@ -159,6 +161,9 @@ truth cannot leak into a response. `/api/v3/plugins/installed` returns `loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per plugin and `data.runtime` (`status`, `published_at`, `age_seconds`); `/api/v3/plugins/state` returns the same beside the desired state. +`PluginCatalog.get_plugin_display_modes` and `find_plugin_for_mode` prefer a +live view's `modes` to the manifest's `display_modes`, so `/display/modes` +and on-demand see modes a plugin generates from its config (#668). **Reconciliation** ([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py)) diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 46693f7f..57d79ceb 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -363,9 +363,11 @@ it. This is the list the force-display dialog offers. Send the reported `plugin_id` alongside `mode` when starting an on-demand display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when -`plugin_id` is omitted, and that lookup only sees modes declared in a static -manifest — a plugin whose modes are generated (each installed Starlark app is -one) returns 404 there. +`plugin_id` is omitted. While the display is running, this list and that +lookup use the modes the display registered, including ones a plugin generates +from its config (each installed Starlark app, each soccer `custom_leagues` +entry). With the display stopped, or for a plugin it has not loaded, both see +only the modes its manifest declares. Triggers plugin discovery, which is otherwise lazy — so a caller that never opens the dashboard still gets the full list. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7721e70e..e1e37510 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -101,7 +101,10 @@ python3 --version Imager, choosing Raspberry Pi OS Lite (64-bit). Trixie is recommended; Bookworm (Legacy) also works. An in-place upgrade from Bullseye is not supported by Raspberry Pi and is not worth the risk. -- "Desktop environment detected": use the Lite image, not the desktop one. +- "A desktop is running": use the Lite image, not the desktop one, or boot + to the console with `sudo systemctl set-default multi-user.target` and + reboot. Desktop packages that are installed but not running only produce a + warning, and the install continues. - "python3 is Python 3.x; LEDMatrix needs Python 3.11 or newer": something has replaced the system `python3`. Point it back at the OS's own Python (`/usr/bin/python3` should be 3.11 on Bookworm, 3.13 on Trixie). diff --git a/first_time_install.sh b/first_time_install.sh index 65cd66fc..a4af1a6b 100755 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -86,25 +86,44 @@ if [ -r "$LM_OS_RELEASE_FILE" ]; then OS_CHECK_FAILED=1 fi - # Check if it's the Lite version (no desktop environment) - # Check for desktop packages or desktop services - DESKTOP_DETECTED=0 + # Check for a desktop. A desktop only competes with the panel for CPU while + # it runs, so a running display manager stops the install; desktop packages + # or session files on a Pi that boots to the console are only a warning. + DESKTOP_RUNNING=0 + DESKTOP_INSTALLED=0 + # display-manager is the alias every Debian display manager registers. + for dm in display-manager lightdm gdm gdm3 sddm lxdm; do + if systemctl is-active --quiet "$dm" 2>/dev/null; then + DESKTOP_RUNNING=1 + fi + done # grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE, # and pipefail turns a found desktop into "not found". - if dpkg -l | grep -E "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde" >/dev/null; then - DESKTOP_DETECTED=1 - fi - if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then - DESKTOP_DETECTED=1 + # Desktop metapackages and session managers, matched as whole installed + # package names: an unanchored ".*kde" matched libblockdev-* ("bloc-kde-v"), + # and a "gnome" prefix matched standalone parts such as gnome-keyring. + # Trixie replaced raspberrypi-ui-mods with the rpd-*-core metapackages. + DESKTOP_PACKAGES='raspberrypi-ui-mods|rpd-wayland-core|rpd-x-core' + DESKTOP_PACKAGES+='|lxde|lxde-core|lxsession|xfce4|xfce4-session' + DESKTOP_PACKAGES+='|gnome-shell|gnome-session|kde-plasma-desktop|plasma-desktop' + DESKTOP_PACKAGES+='|plasma-workspace|task-desktop|task-[a-z0-9]+-desktop' + if dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' 2>/dev/null \ + | grep -E "^ii +(${DESKTOP_PACKAGES})(:[a-z0-9]+)?$" >/dev/null; then + DESKTOP_INSTALLED=1 fi if [ -d /usr/share/raspberrypi-ui-mods ] || [ -d /usr/share/xsessions ]; then - DESKTOP_DETECTED=1 + DESKTOP_INSTALLED=1 fi - - if [ "$DESKTOP_DETECTED" -eq 1 ]; then - echo "✗ ERROR: Desktop environment detected - this script requires Raspberry Pi OS Lite" - echo " Please use Raspberry Pi OS Lite (not the full desktop version)" + + if [ "$DESKTOP_RUNNING" -eq 1 ]; then + echo "✗ ERROR: A desktop is running - this script requires Raspberry Pi OS Lite" + echo " Please use Raspberry Pi OS Lite (not the full desktop version), or boot" + echo " to the console: sudo systemctl set-default multi-user.target && sudo reboot" OS_CHECK_FAILED=1 + elif [ "$DESKTOP_INSTALLED" -eq 1 ]; then + echo "⚠ WARNING: Desktop packages are installed, but no desktop is running." + echo " Continuing. Keep the Pi booting to the console: a running desktop" + echo " competes with the LED panel for CPU and can make it flicker." else echo "✓ Lite version confirmed (no desktop environment)" fi diff --git a/src/background_data_service.py b/src/background_data_service.py index 414a0300..2c670ad9 100644 --- a/src/background_data_service.py +++ b/src/background_data_service.py @@ -34,6 +34,7 @@ from src.common.fetch_service import ( plugin_scope, share_connection_pool, ) +from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload from src.common.espn_dates import ( RANGE_RETRY_SECONDS, _note_range_rejected, @@ -84,6 +85,10 @@ class FetchRequest: # the cache with the callbacks suppressed -- joiners waiting forever for a # fetch that did, in fact, succeed. commit_claimed: bool = False + # Trim an ESPN scoreboard response before it is cached and delivered + # (src/common/espn_payload.py). Set by whoever created the request; a + # submitter that joins the fetch gets the same payload. + slim_payload: bool = True result: Optional[Any] = None error: Optional[str] = None # The plugin that submitted the request, so the fetch service counts the @@ -250,7 +255,8 @@ class BackgroundDataService: timeout: Optional[int] = None, max_retries: int = 3, priority: int = 1, - callback: Optional[Callable] = None) -> str: + callback: Optional[Callable] = None, + slim_payload: bool = True) -> str: """ Submit a background fetch request. @@ -266,6 +272,11 @@ class BackgroundDataService: priority: Accepted for compatibility and ignored; requests run in submission order. callback: Optional callback function when request completes + slim_payload: Drop the parts of an ESPN scoreboard response no + scoreboard reads (stat leaders, athlete cards, links, + headlines, highlights) before caching it; see + src/common/espn_payload.py. Only ESPN /scoreboard URLs are + touched. Pass False to cache the response whole. Returns: Request ID for tracking the fetch operation @@ -337,6 +348,7 @@ class BackgroundDataService: priority=priority, callback=callback, owner=owner, + slim_payload=slim_payload, ) with self._lock: @@ -498,6 +510,13 @@ class BackgroundDataService: ) return result + # Most of an ESPN scoreboard response is never drawn, and the + # cached copy stays parsed in the memory tier while it is fresh. + # Trimmed before the write so the cache, request.result and the + # callbacks all see the same payload. See src/common/espn_payload.py. + if request.slim_payload and is_espn_scoreboard_url(request.url): + slim_scoreboard_payload(data) + # Cache the data self.cache_manager.set(request.cache_key, data) diff --git a/src/common/README.md b/src/common/README.md index 66140ede..6dbb924b 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -28,6 +28,7 @@ Rules for the package: | [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — | | [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 | | [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 | +| [`espn_payload`](#espn_payload) | Drop the parts of an ESPN scoreboard payload no scoreboard reads | No, core-internal (used by `BackgroundDataService`) | n/a | | [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 | | [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a | | [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 | @@ -120,6 +121,18 @@ Every request goes through [`fetch_service`](#fetch_service), the chunks counted against the plugin that asked. Scoreboard plugins also bundle a copy for older cores. +### espn_payload + +[`espn_payload.py`](espn_payload.py). Core-internal. ESPN scoreboard +responses carry stat leaders, athlete cards, links, headlines and highlights +that no scoreboard draws. `slim_scoreboard_payload(payload)` removes exactly +those keys, in place, and leaves everything it does not know about alone; +`is_espn_scoreboard_url(url)` says whether a URL is an ESPN site-API +scoreboard. `BackgroundDataService` slims each scoreboard window before +caching it, which cuts the five sports windows from ~40MB to ~12MB of parsed +objects. Adding a key to the drop lists means first checking that nothing +reads it. + ### favorite_team_check [`favorite_team_check.py`](favorite_team_check.py). diff --git a/src/common/espn_payload.py b/src/common/espn_payload.py new file mode 100644 index 00000000..00ff118c --- /dev/null +++ b/src/common/espn_payload.py @@ -0,0 +1,97 @@ +"""Drop the parts of an ESPN scoreboard payload no scoreboard reads. + +The sports scoreboards cache their Recent/Upcoming window (14 days back, 7 +ahead) as the raw ESPN response, and that record stays parsed in the memory +cache for as long as it is fresh. Most of it is never drawn. Measured on hdpi +(2026-10-02) the MLB window was 3.35MB of JSON and 13.5MB of Python objects, +and the five windows together ~40MB, mostly in: + +* ``competitors[].leaders`` / ``competitions[].leaders`` -- per-team and + per-game stat leaders (28% of the MLB window) +* ``competitors[].team.links`` / ``event.links`` -- web and app URLs +* ``status.featuredAthletes`` and ``competitors[].probables`` -- athlete + cards with headshots and season stats +* ``competitions[].headlines`` / ``highlights`` -- article and video blurbs + (28% of the college-football window) +* ``competitions[].geoBroadcasts`` + +None of those keys is read by core or by any plugin in ledmatrix-plugins +(checked 2026-10-02 across every scoreboard, the odds ticker and the +leaderboard), while everything that is read -- odds, records, linescores, +situation, statistics, notes, broadcasts, venue -- is kept. Dropping them +takes the five windows from ~40MB to ~12MB of parsed objects and the files from +10.6MB to 3.0MB, so the reads that parse an expired window on the render +thread get 3-4x cheaper too. + +:func:`slim_scoreboard_payload` changes the payload in place, and only ever +removes the keys listed here: anything it does not know about is left alone. +""" + +from typing import Any, Dict +from urllib.parse import urlsplit + +# Per level of the payload, the keys removed. Kept deliberately explicit: +# adding a key here means checking that nothing reads it first. +_EVENT_DROP = ("links",) +_COMPETITION_DROP = ("leaders", "headlines", "highlights", "geoBroadcasts") +_STATUS_DROP = ("featuredAthletes",) +_COMPETITOR_DROP = ("leaders", "probables") +_TEAM_DROP = ("links",) + + +def is_espn_scoreboard_url(url: Any) -> bool: + """Whether ``url`` is an ESPN site-API scoreboard endpoint.""" + if not isinstance(url, str): + return False + try: + parts = urlsplit(url) + except ValueError: + return False + host = (parts.hostname or "").lower() + if host != "espn.com" and not host.endswith(".espn.com"): + return False + return parts.path.rstrip("/").endswith("/scoreboard") + + +def _drop(obj: Any, keys) -> None: + if isinstance(obj, dict): + for key in keys: + obj.pop(key, None) + + +def slim_scoreboard_payload(payload: Any) -> Any: + """Remove the unread parts of an ESPN scoreboard payload, in place. + + Returns ``payload`` for convenience. Anything that is not shaped like a + scoreboard (not a dict, no ``events`` list, odd entries) is passed over + untouched rather than raising. + """ + if not isinstance(payload, dict): + return payload + events = payload.get("events") + if not isinstance(events, list): + return payload + for event in events: + if not isinstance(event, dict): + continue + _drop(event, _EVENT_DROP) + competitions = event.get("competitions") + if not isinstance(competitions, list): + continue + for competition in competitions: + if not isinstance(competition, dict): + continue + _drop(competition, _COMPETITION_DROP) + _drop(competition.get("status"), _STATUS_DROP) + competitors = competition.get("competitors") + if not isinstance(competitors, list): + continue + for competitor in competitors: + if not isinstance(competitor, dict): + continue + _drop(competitor, _COMPETITOR_DROP) + _drop(competitor.get("team"), _TEAM_DROP) + return payload + + +__all__ = ["is_espn_scoreboard_url", "slim_scoreboard_payload"] diff --git a/src/display_controller.py b/src/display_controller.py index 44a70f84..bdefde24 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -25,7 +25,6 @@ import os import inspect import signal import json -import math import threading import types from collections import deque @@ -64,6 +63,7 @@ from src.ipc.contract import ( PluginReloadResult, ) from src.ipc.server import ControlServer, QueuedCommand, StateHub, start_control_server +from src.plugin_system.base_plugin import finite_seconds from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL # Get logger with consistent configuration @@ -101,19 +101,6 @@ _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0 DEFAULT_DYNAMIC_DURATION_CAP = 180.0 -def _finite_seconds(value: Any) -> Optional[float]: - """``value`` as seconds when it is a finite number or a numeric string, - else None. A bool is not a number here, though it is an int: True would - read as a one-second screen.""" - if isinstance(value, bool): - return None - try: - seconds = float(value) - except (TypeError, ValueError, OverflowError): - return None - return seconds if math.isfinite(seconds) else None - - class _PluginReloadJob: """A ``plugin.reload`` whose slow half runs off the render thread. @@ -1569,7 +1556,7 @@ class DisplayController: except Exception as err: # pylint: disable=broad-except problem = f"get_display_duration() raised {type(err).__name__}: {err}" else: - seconds = _finite_seconds(value) + seconds = finite_seconds(value) if seconds is not None: return seconds problem = f"display duration {value!r} is not a number" @@ -4624,6 +4611,15 @@ class DisplayController: display_modes = [plugin_id] with self._plugin_modes_lock: self.plugin_display_modes[plugin_id] = list(display_modes) + # Into the runtime snapshot the web interface reads, so its mode + # lists and on-demand lookups see computed modes too (#668). + state_manager = getattr(self.plugin_manager, 'state_manager', None) + record_modes = getattr(state_manager, 'record_modes', None) + if callable(record_modes): + try: + record_modes(plugin_id, list(display_modes)) + except Exception as e: # reporting must never break registration + logger.debug("Could not record display modes for %s: %s", plugin_id, e) # Subscribe to config changes for per-plugin hot-reload. Bind plugin_id # and instance as defaults so each plugin's callback targets its own diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index 2b676eb1..9acdaecf 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -11,6 +11,7 @@ Stability: Stable - maintains backward compatibility from abc import ABC, abstractmethod from enum import Enum from typing import Dict, Any, Optional, List +import math import os import sys from src.deprecation import deprecated, warn_deprecated @@ -240,6 +241,26 @@ def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) -> return legacy_vegas_participation(plugin) +def finite_seconds(value: Any) -> Optional[float]: + """``value`` as seconds when it is a finite number or a numeric string, + else None. A bool is not a number here, though it is an int: True would + read as a one-second screen. + + How the core reads a plugin's get_display_duration() -- the rotation + (DisplayController._get_display_duration) and the Vegas static pause -- + which several plugins answer straight from config.json, so a value saved + as "20" or null arrives as a string or None. A number at or below zero is + returned as it is; each caller has its own rule for that. + """ + if isinstance(value, bool): + return None + try: + seconds = float(value) + except (TypeError, ValueError, OverflowError): + return None + return seconds if math.isfinite(seconds) else None + + class BasePlugin(ABC): """ Base class that all plugins must inherit from. diff --git a/src/plugin_system/plugin_catalog.py b/src/plugin_system/plugin_catalog.py index a2fbabf6..ec6c7c23 100644 --- a/src/plugin_system/plugin_catalog.py +++ b/src/plugin_system/plugin_catalog.py @@ -16,7 +16,8 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no ``load_plugin``, ``get_plugin`` or ``plugins``. Runtime state -- whether the display has a plugin loaded, its health, its -errors -- is not here either. The display process publishes what it knows to +errors -- is not here either, with one exception: given a ``runtime_source``, +the mode lookups prefer the modes the running display registered. The display process publishes what it knows to the shared cache (health and resource metrics, the current mode, the error aggregator snapshot), and the web routes read those publications. What the display does not publish (which plugins it has loaded, its plugin state @@ -26,8 +27,9 @@ See docs/ARCHITECTURE.md ("Web and display processes"). """ import threading +import time from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from src.common.permission_utils import ( ensure_directory_permissions, get_plugin_dir_mode, @@ -39,6 +41,10 @@ from src.plugin_system.plugin_dirs import ( PathLike = Union[str, Path] +#: How long one read of the display's runtime view answers mode lookups. A +#: listing asks once per plugin; the cache copy is a file read each time. +_RUNTIME_VIEW_TTL_SECONDS = 1.0 + class PluginCatalog: """Manifests and directories of the installed plugins. @@ -48,8 +54,15 @@ class PluginCatalog: uninstalled plugin disappears and a new one appears. """ - def __init__(self, plugins_dir: PathLike) -> None: + def __init__(self, plugins_dir: PathLike, + runtime_source: Optional[Callable[[], Any]] = None) -> None: self.plugins_dir: Path = Path(plugins_dir) + # Returns the display's PluginRuntimeView + # (src/plugin_system/plugin_runtime.py). Its live view carries the + # modes the display registered, which the mode lookups below prefer + # to the manifest's. None: manifests only. + self.runtime_source = runtime_source + self._runtime_view_memo: Optional[tuple] = None self.logger = get_logger(__name__) # Guards plugin_manifests/plugin_directories: request threads read @@ -145,23 +158,67 @@ class PluginCatalog: by_manifest=False) return str(plugin_dir) if plugin_dir is not None else None - def get_plugin_display_modes(self, plugin_id: str) -> List[str]: - """The manifest's ``display_modes``, or []. + def _runtime_view(self) -> Any: + """The display's runtime view, read at most once a second; None + without a source or when reading it fails.""" + if self.runtime_source is None: + return None + now = time.monotonic() + memo = self._runtime_view_memo + if memo is not None and now - memo[0] < _RUNTIME_VIEW_TTL_SECONDS: + return memo[1] + try: + view = self.runtime_source() + except Exception as exc: # a lookup must still answer from manifests + self.logger.debug("Could not read the display's runtime view: %s", exc) + view = None + self._runtime_view_memo = (now, view) + return view - What the display actually rotates can differ: a plugin may compute - its modes at run time (``plugin.modes``). This is the declared list. + def _live_display_modes(self, plugin_id: str) -> Optional[List[str]]: + """The modes the running display registered for ``plugin_id``, or None.""" + view = self._runtime_view() + if view is None: + return None + try: + modes = view.display_modes(plugin_id) + except Exception as exc: # includes a source returning something else + self.logger.debug("Could not read display modes for %s: %s", plugin_id, exc) + return None + return list(modes) if isinstance(modes, list) and modes else None + + def get_plugin_display_modes(self, plugin_id: str) -> List[str]: + """The modes the display registered for the plugin, else the + manifest's ``display_modes``, else []. + + A plugin may compute its modes at run time (``plugin.modes``): each + league soccer-scoreboard's ``custom_leagues`` adds is a mode no + manifest can list ahead of time (#668). The running display + publishes what it registered, and that wins while the display is + live and has the plugin loaded. Otherwise -- display stopped, plugin + disabled -- the declared list is the best answer there is. """ + live = self._live_display_modes(plugin_id) + if live is not None: + return live with self._lock: manifest = self.plugin_manifests.get(plugin_id) modes = (manifest or {}).get('display_modes', []) return list(modes) if isinstance(modes, list) else [] def find_plugin_for_mode(self, mode: str) -> Optional[str]: - """The plugin whose manifest declares ``mode`` (case-insensitive).""" + """The plugin that registered ``mode`` on the running display, else + the one whose manifest declares it (case-insensitive both ways).""" wanted = mode.strip().lower() with self._lock: manifests = dict(self.plugin_manifests) + for plugin_id in manifests: + live = self._live_display_modes(plugin_id) + if live and any(m.lower() == wanted for m in live): + return plugin_id for plugin_id, manifest in manifests.items(): + if self._live_display_modes(plugin_id): + continue # the display's list is the truth for this plugin modes = manifest.get('display_modes') if isinstance(modes, list) and any( isinstance(m, str) and m.lower() == wanted for m in modes): diff --git a/src/plugin_system/plugin_runtime.py b/src/plugin_system/plugin_runtime.py index 2a541396..25f79468 100644 --- a/src/plugin_system/plugin_runtime.py +++ b/src/plugin_system/plugin_runtime.py @@ -56,7 +56,7 @@ import os import threading import time from dataclasses import dataclass, field, replace -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, List, Optional from src import display_watchdog from src.logging_config import get_logger @@ -100,6 +100,9 @@ _ERROR_MESSAGE_CHARS = 200 _ERROR_TYPE_CHARS = 80 _ID_CHARS = 100 _VERSION_CHARS = 40 +#: Bounds on a plugin's published ``modes``: a plugin computes them, so a +#: runaway list must not bloat a file written to the SD card. +_MAX_MODES = 200 #: Reader statuses. Only LIVE carries runtime facts. LIVE = "live" @@ -154,6 +157,15 @@ def summarize_error(error_info: Optional[Dict[str, Any]]) -> Optional[Dict[str, } +def _published_modes(modes: Any) -> Optional[List[str]]: + """The registered display modes as a snapshot carries them, or None.""" + if not isinstance(modes, list): + return None + # A name is a key the display matches exactly: drop one too long to + # carry whole rather than clip it into a different name. + return [m for m in modes if isinstance(m, str) and len(m) <= _ID_CHARS][:_MAX_MODES] + + def build_runtime_snapshot(state_manager: Any, *, started_at: float, now: Optional[float] = None, running: bool = True, @@ -173,6 +185,7 @@ def build_runtime_snapshot(state_manager: Any, *, started_at: float, "error": summarize_error(record.get("error_info")), "version": _clip(version, _VERSION_CHARS) if version else None, "loaded_at": _epoch(record.get("loaded_at")), + "modes": _published_modes(record.get("modes")), } return { "schema": SNAPSHOT_SCHEMA, @@ -416,6 +429,21 @@ class PluginRuntimeView: "loaded_at": record.get("loaded_at"), } + def display_modes(self, plugin_id: str) -> Optional[List[str]]: + """The display modes the display registered for ``plugin_id``: what + it rotates and accepts on-demand, including modes a plugin computes + from its config. None unless the view is live and the plugin is + loaded with its modes registered -- the caller then falls back to + the manifest's ``display_modes``.""" + if not self.live: + return None + record = self.plugins.get(plugin_id) + modes = record.get("modes") if isinstance(record, dict) else None + if not isinstance(modes, list): + return None + modes = [m for m in modes if isinstance(m, str)] + return modes or None + def describe(self) -> Dict[str, Any]: """The view's own status, for a response to carry beside the facts.""" return { diff --git a/src/plugin_system/plugin_state.py b/src/plugin_system/plugin_state.py index bb0dc771..f7504b8f 100644 --- a/src/plugin_system/plugin_state.py +++ b/src/plugin_system/plugin_state.py @@ -10,7 +10,7 @@ snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it. import threading import time from enum import Enum -from typing import Optional, Dict, Any +from typing import Any, Dict, List, Optional from datetime import datetime import logging @@ -236,6 +236,26 @@ class PluginStateManager: } self._note_change() + def record_modes(self, plugin_id: str, modes: List[str]) -> None: + """Record the display modes the display registered for ``plugin_id``. + + Called by the DisplayController each time it registers the plugin. + These are the modes it actually rotates and accepts on-demand -- + ``plugin.modes`` when the plugin computes them (a soccer league the + user added under ``custom_leagues``), else the manifest's list -- and + the web interface has no other way to learn them (#668). Kept on the + loaded record, so an unload or a reload's fresh record_loaded() + forgets them until the plugin is registered again. + """ + with self._lock: + loaded = self._loaded.get(plugin_id) + if loaded is None: + return + modes = [str(m) for m in modes] + if loaded.get('modes') != modes: + loaded['modes'] = modes + self._note_change() + def record_unloaded(self, plugin_id: str) -> None: """Forget the loaded record alone, keeping state and error info: for an unload that failed after the instance was already dropped.""" @@ -248,7 +268,8 @@ class PluginStateManager: section so a concurrent load or unload is seen whole or not at all. Per plugin: ``state`` (published_state()'s value), ``loaded``, - ``version`` and ``loaded_at`` (None unless loaded) and ``error_info`` + ``version``, ``loaded_at`` and ``modes`` (None unless loaded; ``modes`` + also None until the display registers it) and ``error_info`` (a copy, or None). """ with self._lock: @@ -262,6 +283,7 @@ class PluginStateManager: 'loaded': loaded is not None, 'version': loaded['version'] if loaded else None, 'loaded_at': loaded['loaded_at'] if loaded else None, + 'modes': list(loaded['modes']) if loaded and 'modes' in loaded else None, 'error_info': dict(info) if info is not None else None, } return records diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index c76128a0..bcdb3794 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -18,10 +18,11 @@ import math import sys import time import threading -from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING +from typing import Optional, Dict, Any, FrozenSet, List, Callable, TYPE_CHECKING from src import display_watchdog from src.common import render_gate +from src.plugin_system.base_plugin import finite_seconds from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.elements import LiveEpochs from src.vegas_mode.plugin_adapter import PluginAdapter @@ -53,6 +54,14 @@ _FPS_HEARTBEAT_INTERVAL = 300.0 #: every plugin. Game state doesn't change within a quarter second. _LIVE_PRIORITY_CHECK_INTERVAL = 0.25 +#: Seconds a static pause shows a plugin whose display duration can't be +#: used, as long as the rotation shows it: 30 when get_display_duration() +#: raises or answers something that is not a number +#: (DisplayController._get_display_duration), 15 when it answers a number at +#: or below zero (DisplayController._resolve_durations). +_UNREADABLE_DURATION = 30.0 +_NOT_POSITIVE_DURATION = 15.0 + def _percentile(ordered: List[float], fraction: float) -> float: """Nearest-rank percentile of an already-sorted list. @@ -92,6 +101,9 @@ class VegasModeCoordinator: _live_reason: Optional[str] = None # Set only while Vegas has changed the GIL switch interval; read with getattr. _saved_switch_interval: Optional[float] + #: Plugins already warned about a display duration the pause can't use, + #: so a bad setting logs once, not at every turn. Replaced, not mutated. + _duration_warned: FrozenSet[str] = frozenset() def __init__( self, @@ -973,7 +985,7 @@ class VegasModeCoordinator: # Wait for the plugin's display duration. Monotonic, like the # iteration clock: an NTP step on an RTC-less Pi would otherwise # end the pause at once or stretch it by the correction. - duration = plugin.get_display_duration() + duration = self._static_pause_duration(plugin) start = time.monotonic() while time.monotonic() - start < duration: @@ -1009,6 +1021,42 @@ class VegasModeCoordinator: return True + def _static_pause_duration(self, plugin: 'BasePlugin') -> float: + """Seconds a static pause shows ``plugin``: its display duration, + read the way the rotation reads it. + + Several plugins return their display_duration setting straight from + config.json, so one saved as "20" or null came back as a string or + None; comparing it with the clock raised, and the pause's broad + except ended the pause at every one of the plugin's turns. inf + paused until something interrupted it, and NaN, False, 0 or a + negative number ended the pause at once. A numeric string counts + (finite_seconds); anything else, or a raise, gets + _UNREADABLE_DURATION, and a number at or below zero + _NOT_POSITIVE_DURATION, logged once per plugin. + """ + try: + value = plugin.get_display_duration() + except Exception as err: # pylint: disable=broad-except + problem = f"get_display_duration() raised {type(err).__name__}: {err}" + fallback = _UNREADABLE_DURATION + else: + seconds = finite_seconds(value) + if seconds is not None and seconds > 0: + return seconds + if seconds is None: + problem = f"display duration {value!r} is not a number" + fallback = _UNREADABLE_DURATION + else: + problem = f"display duration {value!r} is not above zero" + fallback = _NOT_POSITIVE_DURATION + plugin_id = plugin.plugin_id + if plugin_id not in self._duration_warned: + self._duration_warned = self._duration_warned | {plugin_id} + logger.warning("[%s] %s; its static pause lasts %.0fs (logged once)", + plugin_id, problem, fallback) + return fallback + def _end_static_pause(self) -> None: """End static pause and restore scroll state.""" should_resume_scrolling = False diff --git a/test/test_api_v3_display_modes.py b/test/test_api_v3_display_modes.py index a59913f0..5258c043 100644 --- a/test/test_api_v3_display_modes.py +++ b/test/test_api_v3_display_modes.py @@ -7,7 +7,7 @@ manifest.json off disk and reimplemented PluginManager's own fallbacks. """ import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -155,3 +155,14 @@ class TestOneBadConfigSectionDoesNotBlankTheList: side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed")) body = api_v3_client.get('/api/v3/display/modes').get_json() assert 'SEC123' not in json.dumps(body) + + +class TestOnDemandUsesTheRegisteredSpelling: + def test_a_mode_differing_in_case_is_sent_as_registered(self, client): + with patch('web_interface.blueprints.api_v3.display._deliver_on_demand', + return_value=('socket', None)) as deliver: + response = client.post('/api/v3/display/on-demand/start', + json={'plugin_id': 'football-scoreboard', + 'mode': 'NFL_LIVE', 'start_service': False}) + assert response.status_code == 200, response.get_json() + assert deliver.call_args.args[0]['mode'] == 'nfl_live' diff --git a/test/test_api_v3_install_restart_installed_id.py b/test/test_api_v3_install_restart_installed_id.py new file mode 100644 index 00000000..d3508063 --- /dev/null +++ b/test/test_api_v3_install_restart_installed_id.py @@ -0,0 +1,123 @@ +"""POST /plugins/install asks for a restart by the id the plugin installed as. + +A store install needs a display restart when config.json already enables the +plugin (a reinstall, or a config carried over): the display loads a plugin +when its ``enabled`` flag changes, and this flag did not. The route read the +flag under the registry id it was given. An aliased entry installs under +another id -- ``weather`` installs a directory whose manifest declares +``ledmatrix-weather``, and its config section is ``ledmatrix-weather`` -- so +reinstalling an enabled Weather never reported that a restart was needed, +and the display kept running the old copy. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401 + +INSTALL = "/api/v3/plugins/install" + + +@pytest.fixture +def store(api_v3_module, tmp_path): + """The store installs registry entry ``weather`` as ``installed_id``.""" + manager = api_v3_module.api_v3.plugin_store_manager + manager.install_plugin.return_value = True + manager.get_registry_info.return_value = None + manager._find_plugin_path.return_value = None + + def installs_as(installed_id): + path = tmp_path / installed_id + path.mkdir() + (path / "manifest.json").write_text(json.dumps({"id": installed_id}), + encoding="utf-8") + manager._find_plugin_path.side_effect = ( + lambda pid: path if pid == "weather" else None) + + manager.installs_as = installs_as + return manager + + +@pytest.fixture +def config(api_v3_module): + """config.json with an ``enabled`` flag for each plugin id given.""" + def sections(enabled): + api_v3_module.api_v3.config_manager.load_config.return_value = { + plugin_id: {"enabled": flag} for plugin_id, flag in enabled.items()} + return sections + + +@pytest.fixture +def queued(api_v3_module): + queue = MagicMock() + + def enqueue(operation_type, plugin_id, operation_callback=None): + queue.callback_result = operation_callback(MagicMock()) + return "op-1" + + queue.enqueue_operation.side_effect = enqueue + api_v3_module.api_v3.operation_queue = queue + return queue + + +def _direct(client): + return client.post(INSTALL, json={"plugin_id": "weather"}).get_json() + + +def _queued(client, queue): + client.post(INSTALL, json={"plugin_id": "weather"}) + return queue.callback_result + + +class TestDirectInstall: + def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart( + self, api_v3_client, store, config): + store.installs_as("ledmatrix-weather") + config({"ledmatrix-weather": True}) + body = _direct(api_v3_client) + assert body["status"] == "success" + assert body["restart_required"] is True + assert body["restart_message"] + + def test_an_enabled_section_under_the_registry_id_alone_does_not( + self, api_v3_client, store, config): + """The display knows the plugin as ledmatrix-weather; nothing runs + under a section called weather.""" + store.installs_as("ledmatrix-weather") + config({"weather": True}) + assert _direct(api_v3_client)["restart_required"] is False + + def test_an_aliased_install_that_is_not_enabled_needs_no_restart( + self, api_v3_client, store, config): + store.installs_as("ledmatrix-weather") + config({"ledmatrix-weather": False}) + assert _direct(api_v3_client)["restart_required"] is False + + def test_an_install_under_its_own_id_is_unchanged(self, api_v3_client, store, config): + store.installs_as("weather") + config({"weather": True}) + assert _direct(api_v3_client)["restart_required"] is True + + def test_an_install_that_cannot_be_found_uses_the_requested_id( + self, api_v3_client, store, config): + config({"weather": True}) + assert _direct(api_v3_client)["restart_required"] is True + + +class TestQueuedInstall: + def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart( + self, api_v3_client, store, config, queued): + store.installs_as("ledmatrix-weather") + config({"ledmatrix-weather": True}) + result = _queued(api_v3_client, queued) + assert result["success"] is True + assert result["restart_required"] is True + assert result["restart_message"] + + def test_an_enabled_section_under_the_registry_id_alone_does_not( + self, api_v3_client, store, config, queued): + store.installs_as("ledmatrix-weather") + config({"weather": True}) + assert _queued(api_v3_client, queued)["restart_required"] is False diff --git a/test/test_espn_payload.py b/test/test_espn_payload.py new file mode 100644 index 00000000..1605c586 --- /dev/null +++ b/test/test_espn_payload.py @@ -0,0 +1,162 @@ +"""Tests for src/common/espn_payload.py and its use by BackgroundDataService.""" + +import copy +import time +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from src.background_data_service import BackgroundDataService, shutdown_background_service +from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload + +SCOREBOARD = "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard" + + +def _event(): + """One event carrying every key the slimming drops and a sample of the + keys scoreboards read, at the depth ESPN puts them.""" + competitor = { + "id": "10", + "homeAway": "home", + "score": "5", + "team": {"abbreviation": "NYY", "logo": "https://a/l.png", + "links": [{"href": "https://espn.com/team"}]}, + "records": [{"summary": "90-60"}], + "linescores": [{"value": 1}], + "statistics": [{"name": "hits", "displayValue": "9"}], + "leaders": [{"name": "avg", "leaders": [{"athlete": {"id": "1"}}]}], + "probables": [{"athlete": {"id": "2"}, "statistics": []}], + } + return { + "id": "401", + "date": "2026-10-01T23:05Z", + "links": [{"href": "https://espn.com/game"}], + "status": {"type": {"state": "post"}}, + "competitions": [{ + "status": {"type": {"state": "post", "shortDetail": "Final"}, + "featuredAthletes": [{"athlete": {"id": "3"}}]}, + "competitors": [competitor, dict(copy.deepcopy(competitor), homeAway="away")], + "odds": [{"details": "NYY -150", "overUnder": 8.5}], + "situation": {"outs": 2}, + "notes": [{"headline": "Game 1"}], + "broadcasts": [{"names": ["FOX"]}], + "venue": {"fullName": "Yankee Stadium"}, + "leaders": [{"name": "hits"}], + "headlines": [{"description": "recap"}], + "highlights": [{"links": {"source": {}}}], + "geoBroadcasts": [{"media": {"shortName": "FOX"}}], + }], + } + + +class TestSlimScoreboardPayload: + def test_drops_exactly_the_listed_keys(self): + payload = {"leagues": [{"id": "10"}], "events": [_event()]} + slim_scoreboard_payload(payload) + event = payload["events"][0] + competition = event["competitions"][0] + assert "links" not in event + for key in ("leaders", "headlines", "highlights", "geoBroadcasts"): + assert key not in competition + assert "featuredAthletes" not in competition["status"] + for competitor in competition["competitors"]: + assert "leaders" not in competitor + assert "probables" not in competitor + assert "links" not in competitor["team"] + + def test_keeps_everything_else_unchanged(self): + """Removing the dropped keys from the original by hand gives exactly + the slimmed payload: nothing else moved, changed or went missing.""" + original = {"leagues": [{"id": "10"}], "events": [_event(), _event()]} + expected = copy.deepcopy(original) + for event in expected["events"]: + del event["links"] + competition = event["competitions"][0] + for key in ("leaders", "headlines", "highlights", "geoBroadcasts"): + del competition[key] + del competition["status"]["featuredAthletes"] + for competitor in competition["competitors"]: + del competitor["leaders"], competitor["probables"] + del competitor["team"]["links"] + assert slim_scoreboard_payload(original) == expected + + def test_in_place_and_returns_payload(self): + payload = {"events": [_event()]} + assert slim_scoreboard_payload(payload) is payload + + @pytest.mark.parametrize("payload", [ + None, [], "x", {}, {"events": None}, {"events": "x"}, + {"events": [None, 1, "x", {"competitions": None}]}, + {"events": [{"competitions": [None, {"status": None, "competitors": None}]}]}, + {"events": [{"competitions": [{"competitors": [None, {"team": None}]}]}]}, + ]) + def test_odd_shapes_pass_through(self, payload): + before = copy.deepcopy(payload) + assert slim_scoreboard_payload(payload) == before + + +class TestIsEspnScoreboardUrl: + @pytest.mark.parametrize("url", [ + SCOREBOARD, + SCOREBOARD + "/", + "http://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard", + ]) + def test_scoreboards(self, url): + assert is_espn_scoreboard_url(url) + + @pytest.mark.parametrize("url", [ + None, "", 12, + "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams", + "https://site.api.espn.com/apis/site/v2/sports/football/nfl/summary", + "https://example.com/scoreboard", + "https://espn.com.evil.example/apis/x/scoreboard", + "https://notespn.com/apis/x/scoreboard", + ]) + def test_not_scoreboards(self, url): + assert not is_espn_scoreboard_url(url) + + +@pytest.fixture +def service(): + shutdown_background_service() + cache = MagicMock() + cache.get.return_value = None + svc = BackgroundDataService(cache, max_workers=1, request_timeout=5) + yield svc + svc.shutdown(wait=False) + shutdown_background_service() + + +def _run(service, url, **kwargs): + response = Mock(status_code=200) + response.json.return_value = {"events": [_event()]} + response.raise_for_status.return_value = None + delivered = [] + with patch.object(service.session, "get", return_value=response): + req_id = service.submit_fetch_request( + sport="mlb", year=2026, url=url, cache_key="mlb_schedule_window_14_7", + callback=lambda result: delivered.append(result.data), **kwargs) + deadline = time.time() + 5 + while not service.is_request_complete(req_id) and time.time() < deadline: + time.sleep(0.02) + cached = service.cache_manager.set.call_args[0][1] + return cached, delivered + + +class TestBackgroundServiceSlims: + def test_espn_scoreboard_is_cached_and_delivered_slimmed(self, service): + cached, delivered = _run(service, SCOREBOARD) + competition = cached["events"][0]["competitions"][0] + assert "leaders" not in competition + assert "probables" not in competition["competitors"][0] + assert competition["odds"] and competition["situation"] + # The callback sees the very payload that was cached. + assert delivered and delivered[0] is cached + + def test_opt_out_caches_whole_response(self, service): + cached, _ = _run(service, SCOREBOARD, slim_payload=False) + assert cached == {"events": [_event()]} + + def test_other_urls_untouched(self, service): + cached, _ = _run(service, "https://example.com/feed") + assert cached == {"events": [_event()]} diff --git a/test/test_install_os_support.py b/test/test_install_os_support.py index 95f1fe43..4a53d5da 100644 --- a/test/test_install_os_support.py +++ b/test/test_install_os_support.py @@ -50,9 +50,11 @@ def _stub(bin_dir: Path, name: str, body: str) -> None: path.chmod(0o755) -def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager") -> Path: +def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager", + active=(), packages=()) -> Path: """python3 reports ``python_version`` (None: not installed); systemctl - reports ``network`` as the only active unit; dpkg lists no desktop.""" + reports ``network`` and ``active`` as the only active units; dpkg-query + lists ``packages`` as installed (none by default, so no desktop).""" bin_dir = tmp_path / "bin" bin_dir.mkdir(exist_ok=True) if python_version is None: @@ -61,10 +63,14 @@ def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager") -> P else: _stub(bin_dir, "python3", f'case "$*" in *"%d.%d.%d"*) echo "{python_version}.1" ;; ' f'*) echo "{python_version}" ;; esac\n') - _stub(bin_dir, "systemctl", - f'case "$*" in *"is-active --quiet {network}") exit 0 ;; esac\nexit 3\n') + units = "|".join(f'*"is-active --quiet {unit}"' for unit in (network, *active)) + _stub(bin_dir, "systemctl", f'case "$*" in {units}) exit 0 ;; esac\nexit 3\n') _stub(bin_dir, "dpkg", "exit 0\n") - _stub(bin_dir, "dpkg-query", "exit 1\n") + if packages: + listing = "".join(f"ii {name}\\n" for name in packages) + _stub(bin_dir, "dpkg-query", f'printf "{listing}"\n') + else: + _stub(bin_dir, "dpkg-query", "exit 1\n") _stub(bin_dir, "ping", "exit 0\n") return bin_dir @@ -149,20 +155,22 @@ class TestLibrary: # --- first_time_install.sh's OS check ------------------------------------------ -def _os_check_section() -> str: +def _os_check_section(marker_root: str = "/nonexistent") -> str: """first_time_install.sh from the OS check up to the next section, with - the desktop-marker directories pointed somewhere that cannot exist.""" + the desktop-marker directories moved under ``marker_root`` (by default + somewhere that cannot exist).""" text = FIRST_TIME.read_text(encoding="utf-8").replace("\r\n", "\n") start = text.index("# Check OS version") end = text.index("# The user who ran the installer") section = text[start:end] for marker in ("/usr/share/raspberrypi-ui-mods", "/usr/share/xsessions"): assert marker in section - section = section.replace(marker, "/nonexistent" + marker) + section = section.replace(marker, marker_root + marker) return section -def run_os_check(tmp_path: Path, release: str, **stub_args) -> subprocess.CompletedProcess: +def run_os_check(tmp_path: Path, release: str, marker_root: str = "/nonexistent", + **stub_args) -> subprocess.CompletedProcess: """Run the OS check as the installer would, from a copy of the project layout so ``$(dirname "$0")/scripts/install/lib_os.sh`` resolves.""" project = tmp_path / "project" @@ -171,7 +179,7 @@ def run_os_check(tmp_path: Path, release: str, **stub_args) -> subprocess.Comple script = project / "first_time_install.sh" script.write_text("set -Eeuo pipefail\n" "trap 'echo ERR-TRAP line $LINENO >&2; exit 99' ERR\n" - + _os_check_section() + '\necho "SECTION-DONE"\n', + + _os_check_section(marker_root) + '\necho "SECTION-DONE"\n', encoding="utf-8", newline="\n") env = _env(tmp_path, release, _stubs(tmp_path, **stub_args)) return subprocess.run(["bash", str(script)], capture_output=True, text=True, env=env) @@ -230,6 +238,47 @@ class TestInstallerOsCheck: result = run_os_check(tmp_path, "trixie", python_version="3.13") assert "✓ NetworkManager is managing the network" in result.stdout + # A running desktop stops the install; one that is only installed warns. + + @pytest.mark.parametrize("unit", ["display-manager", "lightdm", "gdm", "sddm"]) + def test_running_desktop_stops(self, tmp_path, unit): + result = run_os_check(tmp_path, "trixie", python_version="3.13", active=(unit,)) + assert result.returncode == 1, result.stdout + result.stderr + assert "A desktop is running" in result.stdout + assert "multi-user.target" in result.stdout + assert "SECTION-DONE" not in result.stdout + + @pytest.mark.parametrize("package", [ + "raspberrypi-ui-mods", "rpd-wayland-core", "rpd-x-core", "xfce4", + "lxde-core", "gnome-shell", "kde-plasma-desktop", "plasma-workspace:arm64", + "task-desktop", "task-mate-desktop", + ]) + def test_installed_desktop_that_is_not_running_warns(self, tmp_path, package): + result = run_os_check(tmp_path, "trixie", python_version="3.13", + packages=("bash", package)) + assert result.returncode == 0, result.stdout + result.stderr + assert "Desktop packages are installed, but no desktop is running" in result.stdout + assert "✓ OS requirements met" in result.stdout + + def test_desktop_session_files_warn(self, tmp_path): + (tmp_path / "markers" / "usr" / "share" / "xsessions").mkdir(parents=True) + result = run_os_check(tmp_path, "trixie", python_version="3.13", + marker_root=str(tmp_path / "markers")) + assert result.returncode == 0, result.stdout + result.stderr + assert "Desktop packages are installed, but no desktop is running" in result.stdout + + @pytest.mark.parametrize("packages", [ + # libblockdev contains "kde" mid-word; the old check stopped on it. + ("libblockdev-crypto3", "libblockdev3:arm64"), + ("gnome-keyring", "xfce4-terminal", "xfconf", "lxde-icon-theme", + "kde-cli-tools", "gnome-session-common", "task-ssh-server", "rpd-plym-splash"), + ]) + def test_lite_with_desktop_named_parts_is_lite(self, tmp_path, packages): + result = run_os_check(tmp_path, "trixie", python_version="3.13", packages=packages) + assert result.returncode == 0, result.stdout + result.stderr + assert "✓ Lite version confirmed" in result.stdout + assert "WARNING: Desktop" not in result.stdout + # --- check_system_compatibility.sh --------------------------------------------- diff --git a/test/test_live_display_modes.py b/test/test_live_display_modes.py new file mode 100644 index 00000000..05e695fe --- /dev/null +++ b/test/test_live_display_modes.py @@ -0,0 +1,265 @@ +"""The web interface sees the display modes the display actually registered (#668). + +A plugin may compute its modes from its config: soccer-scoreboard registers +``soccer__live/recent/upcoming`` for every league the user adds under +``custom_leagues``, and no manifest can list those ahead of time. The display +always rotated them -- DisplayController._register_loaded_plugin prefers +``plugin.modes`` -- but the web process reads plugins as files, so its mode +listing (/display/modes, the on-demand dialog) and find_plugin_for_mode +(/display/on-demand/start with a mode and no plugin_id) saw only manifests. + +The display now records each plugin's registered modes in its plugin state, +the runtime snapshot carries them, and PluginCatalog prefers them while the +snapshot is live, falling back to the manifest when it is not. +""" +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.cache_manager import CacheManager # noqa: E402 +from src.plugin_system import plugin_runtime as rt # noqa: E402 +from src.plugin_system.plugin_catalog import PluginCatalog # noqa: E402 +from src.plugin_system.plugin_runtime import ( # noqa: E402 + PluginRuntimePublisher, build_runtime_snapshot, read_plugin_runtime, + view_from_snapshot, +) +from src.plugin_system.plugin_state import PluginState, PluginStateManager # noqa: E402 +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + +DECLARED = ["soccer_eng.1_live", "soccer_eng.1_recent", "soccer_eng.1_upcoming"] +CUSTOM = ["soccer_sco.1_live", "soccer_sco.1_recent", "soccer_sco.1_upcoming"] +REGISTERED = DECLARED + CUSTOM + + +def _loaded_states(modes=None): + states = PluginStateManager() + states.set_state("soccer-scoreboard", PluginState.ENABLED) + states.record_loaded("soccer-scoreboard", "2.24.1") + if modes is not None: + states.record_modes("soccer-scoreboard", modes) + return states + + +@pytest.fixture +def shared_cache(tmp_path, monkeypatch): + """Two cache managers over one directory: the display's and the web's.""" + monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", + lambda self: str(tmp_path / "cache")) + (tmp_path / "cache").mkdir() + display_cache, web_cache = CacheManager(), CacheManager() + yield display_cache, web_cache + display_cache.stop_cleanup_thread() + web_cache.stop_cleanup_thread() + + +@pytest.fixture +def plugins_dir(tmp_path): + root = tmp_path / "plugins" + for plugin_id, modes in (("soccer-scoreboard", DECLARED), ("clock-simple", ["clock"])): + (root / plugin_id).mkdir(parents=True) + (root / plugin_id / "manifest.json").write_text(json.dumps({ + "id": plugin_id, "name": plugin_id, "version": "1.0.0", + "class_name": "P", "display_modes": modes}), encoding="utf-8") + return root + + +# --- The display records what it registered --------------------------------- + +class TestStateManagerRecordsModes: + def test_runtime_records_carry_them(self): + assert _loaded_states(REGISTERED).runtime_records()[ + "soccer-scoreboard"]["modes"] == REGISTERED + + def test_none_until_registered(self): + assert _loaded_states().runtime_records()["soccer-scoreboard"]["modes"] is None + + def test_a_new_list_is_a_change_the_same_one_is_not(self): + """change_count drives the publisher: re-registering an unchanged + plugin must not cost an SD-card write.""" + states = _loaded_states(DECLARED) + before = states.change_count + states.record_modes("soccer-scoreboard", list(DECLARED)) + assert states.change_count == before + states.record_modes("soccer-scoreboard", REGISTERED) + assert states.change_count == before + 1 + + def test_ignored_for_a_plugin_that_is_not_loaded(self): + states = PluginStateManager() + states.record_modes("ghost", ["ghost"]) + assert "ghost" not in states.runtime_records() + + def test_unload_forgets_them(self): + states = _loaded_states(REGISTERED) + states.clear_state("soccer-scoreboard") + assert "soccer-scoreboard" not in states.runtime_records() + + def test_a_reload_starts_without_them_until_registered_again(self): + states = _loaded_states(REGISTERED) + states.record_loaded("soccer-scoreboard", "2.25.0") + assert states.runtime_records()["soccer-scoreboard"]["modes"] is None + + +class TestControllerRecordsOnRegistration: + def test_plugin_modes_reach_the_state_manager(self, test_display_controller): + """_register_loaded_plugin is the one path every load, enable and + reload goes through.""" + c = test_display_controller + states = _loaded_states() + plugin = MagicMock() + plugin.modes = list(REGISTERED) + c.plugin_manager.state_manager = states + c.plugin_manager.get_plugin = MagicMock(return_value=plugin) + c.plugin_manager.plugin_manifests = {"soccer-scoreboard": {"display_modes": DECLARED}} + + c._register_loaded_plugin("soccer-scoreboard") + + assert states.runtime_records()["soccer-scoreboard"]["modes"] == REGISTERED + + def test_a_failing_state_manager_does_not_break_registration(self, test_display_controller): + c = test_display_controller + plugin = MagicMock() + plugin.modes = ["clock"] + c.plugin_manager.state_manager.record_modes = MagicMock(side_effect=RuntimeError("x")) + c.plugin_manager.get_plugin = MagicMock(return_value=plugin) + c.plugin_manager.plugin_manifests = {} + + assert c._register_loaded_plugin("clock-simple") == ["clock"] + assert c.mode_to_plugin_id["clock"] == "clock-simple" + + +# --- The snapshot carries them; only a live view reports them --------------- + +class TestSnapshotAndView: + NOW = 1_800_000_000.0 + + def _view(self, states, running=True, published_at=None): + snapshot = build_runtime_snapshot(states, started_at=1.0, now=self.NOW, + running=running) + if published_at is not None: + snapshot["published_at"] = published_at + return view_from_snapshot(snapshot, now=self.NOW) + + def test_live_view_reports_the_registered_modes(self): + assert self._view(_loaded_states(REGISTERED)).display_modes( + "soccer-scoreboard") == REGISTERED + + def test_stale_and_stopped_views_report_nothing(self): + states = _loaded_states(REGISTERED) + assert self._view(states, published_at=self.NOW - 10_000).display_modes( + "soccer-scoreboard") is None + assert self._view(states, running=False).display_modes("soccer-scoreboard") is None + + def test_unregistered_or_unknown_plugins_report_nothing(self): + view = self._view(_loaded_states()) + assert view.display_modes("soccer-scoreboard") is None + assert view.display_modes("not-loaded") is None + + def test_a_runaway_list_is_bounded(self): + modes = [f"m{i}" for i in range(1000)] + ["x" * 500] + snapshot = build_runtime_snapshot(_loaded_states(modes), started_at=1.0, now=self.NOW) + published = snapshot["plugins"]["soccer-scoreboard"]["modes"] + assert len(published) == rt._MAX_MODES + + def test_a_mode_name_is_kept_whole_or_dropped(self): + long_mode = "x" * (rt._ID_CHARS + 1) + snapshot = build_runtime_snapshot(_loaded_states(["ok", long_mode]), + started_at=1.0, now=self.NOW) + assert snapshot["plugins"]["soccer-scoreboard"]["modes"] == ["ok"] + + def test_non_strings_from_a_hand_made_snapshot_are_dropped(self): + snapshot = {"schema": rt.SNAPSHOT_SCHEMA, "running": True, + "published_at": self.NOW, "plugins": { + "p": {"loaded": True, "modes": ["a", 3, None]}}} + assert view_from_snapshot(snapshot, now=self.NOW).display_modes("p") == ["a"] + + +# --- The web's catalog prefers them ------------------------------------------- + +class TestCatalog: + def _catalog(self, plugins_dir, web_cache): + catalog = PluginCatalog(plugins_dir, + runtime_source=lambda: read_plugin_runtime(web_cache)) + catalog.discover_plugins() + return catalog + + def test_live_display_modes_win_over_the_manifest(self, plugins_dir, shared_cache): + display_cache, web_cache = shared_cache + PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick() + catalog = self._catalog(plugins_dir, web_cache) + assert catalog.get_plugin_display_modes("soccer-scoreboard") == REGISTERED + + def test_a_custom_league_mode_resolves_to_its_plugin(self, plugins_dir, shared_cache): + """What /display/on-demand/start does with a mode and no plugin_id.""" + display_cache, web_cache = shared_cache + PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick() + catalog = self._catalog(plugins_dir, web_cache) + assert catalog.find_plugin_for_mode("SOCCER_SCO.1_LIVE") == "soccer-scoreboard" + + def test_a_plugin_the_display_has_not_loaded_falls_back_to_its_manifest( + self, plugins_dir, shared_cache): + display_cache, web_cache = shared_cache + PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick() + catalog = self._catalog(plugins_dir, web_cache) + assert catalog.get_plugin_display_modes("clock-simple") == ["clock"] + assert catalog.find_plugin_for_mode("clock") == "clock-simple" + + def test_a_mode_the_display_dropped_does_not_resolve_by_manifest( + self, plugins_dir, shared_cache): + display_cache, web_cache = shared_cache + PluginRuntimePublisher(display_cache, _loaded_states(CUSTOM)).tick() + catalog = self._catalog(plugins_dir, web_cache) + assert catalog.find_plugin_for_mode("soccer_eng.1_live") is None + + def test_a_stopped_display_falls_back_to_manifests(self, plugins_dir, shared_cache): + display_cache, web_cache = shared_cache + publisher = PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)) + publisher.tick() + publisher.stop() + catalog = self._catalog(plugins_dir, web_cache) + assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED + assert catalog.find_plugin_for_mode("soccer_sco.1_live") is None + + def test_no_runtime_source_is_manifests_only(self, plugins_dir): + catalog = PluginCatalog(plugins_dir) + catalog.discover_plugins() + assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED + + def test_a_failing_runtime_source_is_manifests_only(self, plugins_dir): + def broken(): + raise OSError("cache gone") + catalog = PluginCatalog(plugins_dir, runtime_source=broken) + catalog.discover_plugins() + assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED + + def test_one_listing_reads_the_view_once(self, plugins_dir): + source = MagicMock(return_value=None) + catalog = PluginCatalog(plugins_dir, runtime_source=source) + catalog.discover_plugins() + for _ in range(10): + catalog.get_plugin_display_modes("soccer-scoreboard") + catalog.find_plugin_for_mode("clock") + assert source.call_count == 1 + + +class TestDisplayModesRoute: + def test_lists_the_custom_league_modes(self, api_v3_module, api_v3_client, # noqa: F811 + plugins_dir, shared_cache): + display_cache, web_cache = shared_cache + PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick() + api = api_v3_module.api_v3 + api.plugin_catalog = PluginCatalog( + plugins_dir, runtime_source=lambda: read_plugin_runtime(web_cache)) + api.config_manager.load_config = MagicMock(return_value={ + "soccer-scoreboard": {"enabled": True}}) + + response = api_v3_client.get("/api/v3/display/modes") + + assert response.status_code == 200, response.get_data(as_text=True) + modes = {m["mode"]: m for m in response.get_json()["data"]["modes"]} + assert set(modes) == set(REGISTERED) + assert modes["soccer_sco.1_live"]["plugin_id"] == "soccer-scoreboard" diff --git a/test/test_plugin_runtime_snapshot.py b/test/test_plugin_runtime_snapshot.py index 30dda43c..f937b3dd 100644 --- a/test/test_plugin_runtime_snapshot.py +++ b/test/test_plugin_runtime_snapshot.py @@ -162,7 +162,7 @@ class TestPublisher: assert snapshot["stale_after"] == rt.STALE_AFTER assert snapshot["plugins"] == {"clock": { "loaded": True, "state": "enabled", "error": None, - "version": "1.0.0", "loaded_at": 10.0}} + "version": "1.0.0", "loaded_at": 10.0, "modes": None}} def test_changes_are_throttled_and_quiet_displays_refresh(self): cache = MagicMock() diff --git a/test/test_sports_helpers.py b/test/test_sports_helpers.py index f2ef99a6..1d6fb951 100644 --- a/test/test_sports_helpers.py +++ b/test/test_sports_helpers.py @@ -8,7 +8,9 @@ loses those tests with it. The parity class is what keeps "byte-identical" true after this lands. Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every promoted body is compared, as a docstring-stripped AST, against every plugin copy that carries -it. Without the variable it skips rather than fails, since core CI has no +it. A copy that is gone counts as adopted when the plugin imports +src.common.sports_helpers (plugins#563/#564 did that for every scoreboard). +Without the variable it skips rather than fails, since core CI has no plugins checkout; ledmatrix-plugins CI runs the same comparison against core (scripts/check_sports_helpers_parity.py, ledmatrix-plugins#495). """ @@ -572,6 +574,24 @@ def _core_definitions(): return out +def _sports_source(root, sport): + return (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8") + + +def _adopted(source): + """Gone is fine once the plugin uses the module; otherwise the finder is + not seeing its copy.""" + name = sports_helpers.__name__ + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.ImportFrom): + if node.module == name or any( + f"{node.module}.{a.name}" == name for a in node.names): + return True + elif isinstance(node, ast.Import) and any(a.name == name for a in node.names): + return True + return False + + class TestParityWithPlugins: @pytest.mark.parametrize("name", sorted(PROMOTED)) def test_body_matches_every_plugin_copy(self, name): @@ -580,11 +600,11 @@ class TestParityWithPlugins: ours = _dump(_core_definitions()[name]) drifted, missing = [], [] for sport in carriers: - defs = _definitions(ast.parse( - (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8"))) - theirs = defs[where].get(plugin_name) + source = _sports_source(root, sport) + theirs = _definitions(ast.parse(source))[where].get(plugin_name) if theirs is None: - missing.append(sport) + if not _adopted(source): + missing.append(sport) elif _dump(theirs) != ours: drifted.append(sport) assert missing == [], f"{plugin_name} no longer in: {missing}" @@ -594,10 +614,20 @@ class TestParityWithPlugins: @pytest.mark.parametrize("sport", SCOREBOARDS) def test_constants_match(self, sport): - root = _plugins_root() - defs = _definitions(ast.parse( - (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8"))) - assert ast.literal_eval(defs["module"]["_MIN_WINDOW_DAYS"].value) == MIN_WINDOW_DAYS - assert ast.literal_eval(defs["module"]["_MAX_WINDOW_DAYS"].value) == MAX_WINDOW_DAYS - gap = defs["SportsCore"]["_DWELL_REENTRY_GAP_SECONDS"].value - assert math.isclose(ast.literal_eval(gap), SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS) + source = _sports_source(_plugins_root(), sport) + defs = _definitions(ast.parse(source)) + expected = { + ("module", "_MIN_WINDOW_DAYS"): MIN_WINDOW_DAYS, + ("module", "_MAX_WINDOW_DAYS"): MAX_WINDOW_DAYS, + ("SportsCore", "_DWELL_REENTRY_GAP_SECONDS"): + SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS, + } + missing = [] + for (where, name), value in expected.items(): + node = defs[where].get(name) + if node is None: + if not _adopted(source): + missing.append(name) + else: + assert math.isclose(ast.literal_eval(node.value), value), name + assert missing == [], f"not found in {sport}: {missing}" diff --git a/test/test_vegas_static_mode.py b/test/test_vegas_static_mode.py index 33089c7b..f8da3382 100644 --- a/test/test_vegas_static_mode.py +++ b/test/test_vegas_static_mode.py @@ -214,7 +214,8 @@ class TestCoordinatorStaticPause: def _plugin(self): plugin = MagicMock() plugin.plugin_id = 'clock' - plugin.get_display_duration.return_value = 0 + # A moment: zero would pause 15 s, as the rotation shows it. + plugin.get_display_duration.return_value = 0.01 return plugin def test_trigger_comes_from_the_pipeline(self): diff --git a/test/test_vegas_static_pause_duration.py b/test/test_vegas_static_pause_duration.py new file mode 100644 index 00000000..957e45f7 --- /dev/null +++ b/test/test_vegas_static_pause_duration.py @@ -0,0 +1,197 @@ +"""A Vegas static pause lasts as long as the rotation shows the plugin. + +The pause asked the plugin for get_display_duration() and compared the +answer with the clock. Several plugins (clock-simple, calendar, countdown) +return their display_duration setting as it is in config.json, so one saved +as "20" or null -- the raw config editor, a hand edit -- reached that +comparison as a string or None. The TypeError went to the pause's broad +except, which ended the pause: the plugin flashed up and the scroll went on, +at every one of its turns. inf paused until something interrupted it, and +NaN, False, 0 or a negative number ended the pause at once. + +The pause now reads the answer the way the rotation does since #739, with +the same helper (base_plugin.finite_seconds): a numeric string counts; +anything else that is not a finite number, or a raise, gets the rotation's +30 s; a number at or below zero gets its 15 s. +""" + +import logging +import os +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock + +os.environ.setdefault("EMULATOR", "true") + +import pytest + +from src.vegas_mode import coordinator + +NOT_NUMBERS = [None, '', 'twenty', True, False, float('nan'), float('inf'), + 'inf', '1e400', [20], {'seconds': 20}] +NOT_ABOVE_ZERO = [0, -5, '-5', '0'] +NUMBERS = [('20', 20.0), (' 7.5 ', 7.5), (12, 12.0), (12.5, 12.5)] + + +class FakeClock: + """time.monotonic/time.sleep for the pause loop: sleeping moves the clock.""" + + #: A pause still going after this long never ends (inf did that). + LIMIT = 3600.0 + + def __init__(self): + self.now = 0.0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + if self.now > self.LIMIT: + raise RuntimeError("the static pause never ended") + + +@pytest.fixture +def clock(monkeypatch): + fake = FakeClock() + monkeypatch.setattr(coordinator, 'time', fake) + return fake + + +def _plugin(duration, plugin_id='clock-simple'): + plugin = MagicMock() + plugin.plugin_id = plugin_id + plugin.get_display_duration.return_value = duration + return plugin + + +def _coord(*plugins): + coord = coordinator.VegasModeCoordinator.__new__(coordinator.VegasModeCoordinator) + coord.render_pipeline = MagicMock() + coord.render_pipeline.get_scroll_position.return_value = 0 + coord.display_manager = MagicMock() + locks = {plugin.plugin_id: threading.Lock() for plugin in plugins} + coord.plugin_manager = SimpleNamespace(get_plugin_lock=locks.__getitem__) + coord._state_lock = threading.Lock() + coord._static_pause_active = False + coord._saved_scroll_position = None + coord._should_stop = False + coord._live_priority_active = False + coord._live_priority_check = None + coord._interrupt_check = None + coord.stats = {'static_pauses': 0} + return coord + + +def _pause(coord, plugin, clock): + """One static pause: (whether it completed, how long it lasted).""" + start = clock.now + completed = coord._handle_static_pause(plugin) + return completed, clock.now - start + + +class TestPauseLength: + @pytest.mark.parametrize('value, seconds', NUMBERS) + def test_numbers_and_numeric_strings_are_used(self, clock, value, seconds): + plugin = _plugin(value) + completed, lasted = _pause(_coord(plugin), plugin, clock) + assert completed is True + assert lasted == pytest.approx(seconds, abs=0.15) + + @pytest.mark.parametrize('value', NOT_NUMBERS, ids=repr) + def test_anything_but_a_finite_number_pauses_for_30s(self, clock, value): + plugin = _plugin(value) + completed, lasted = _pause(_coord(plugin), plugin, clock) + assert completed is True + assert lasted == pytest.approx(30.0, abs=0.15) + plugin.display.assert_called_once_with(force_clear=True) + + @pytest.mark.parametrize('value', NOT_ABOVE_ZERO, ids=repr) + def test_a_number_not_above_zero_pauses_for_15s(self, clock, value): + plugin = _plugin(value) + completed, lasted = _pause(_coord(plugin), plugin, clock) + assert completed is True + assert lasted == pytest.approx(15.0, abs=0.15) + + def test_a_raising_get_display_duration_pauses_for_30s(self, clock): + plugin = _plugin(None) + plugin.get_display_duration.side_effect = KeyError('display_duration') + completed, lasted = _pause(_coord(plugin), plugin, clock) + assert completed is True + assert lasted == pytest.approx(30.0, abs=0.15) + + def test_a_good_value_after_a_bad_one_is_used(self, clock): + plugin = _plugin(None) + coord = _coord(plugin) + assert _pause(coord, plugin, clock)[1] == pytest.approx(30.0, abs=0.15) + plugin.get_display_duration.return_value = 45 + assert _pause(coord, plugin, clock)[1] == pytest.approx(45.0, abs=0.15) + + def test_the_pause_can_still_be_interrupted(self, clock): + plugin = _plugin('twenty') + coord = _coord(plugin) + coord._interrupt_check = lambda: clock.now >= 5 + completed, lasted = _pause(coord, plugin, clock) + assert completed is False + assert lasted == pytest.approx(5.0, abs=0.15) + + +class TestWarning: + def test_logged_once_per_plugin(self, clock, caplog): + clock_plugin = _plugin('twenty') + calendar = _plugin(None, plugin_id='calendar') + coord = _coord(clock_plugin, calendar) + with caplog.at_level(logging.WARNING, logger='src.vegas_mode.coordinator'): + for _ in range(3): + for plugin in (clock_plugin, calendar): + coord._handle_static_pause(plugin) + warnings = [r.getMessage() for r in caplog.records + if 'display duration' in r.getMessage()] + assert len(warnings) == 2 + assert any('clock-simple' in m and "'twenty'" in m for m in warnings) + assert any('calendar' in m and 'None' in m for m in warnings) + + +class TestFiniteSeconds: + """The shared rule: what counts as a number of seconds.""" + + @pytest.mark.parametrize('value, seconds', NUMBERS + [(0, 0.0), ('-5', -5.0)]) + def test_numbers_and_numeric_strings(self, value, seconds): + from src.plugin_system.base_plugin import finite_seconds + result = finite_seconds(value) + assert result == seconds and isinstance(result, float) + + @pytest.mark.parametrize('value', NOT_NUMBERS + [pytest.param(10 ** 400, id='10**400')], + ids=repr) + def test_anything_else_is_none(self, value): + from src.plugin_system.base_plugin import finite_seconds + assert finite_seconds(value) is None + + +def _rotation_seconds(plugin): + """How long the rotation shows ``plugin`` (no dynamic duration, no + Rotation & Durations override): the two calls run() makes for a screen. + """ + from src.display_controller import DisplayController + dc = object.__new__(DisplayController) + dc.config = {} + dc.plugin_modes = {'mode': plugin} + return dc._resolve_durations(plugin, 'mode', dc._get_display_duration('mode'), False)[1] + + +class TestSameAsTheRotation: + """The pause and the rotation share finite_seconds; this pins their + fallbacks (30 s, 15 s) to each other too.""" + + @pytest.mark.parametrize('value', [value for value, _ in NUMBERS] + + NOT_NUMBERS + NOT_ABOVE_ZERO, ids=repr) + def test_the_pause_lasts_as_long_as_the_rotation_shows_it(self, clock, value): + plugin = _plugin(value) + expected = _rotation_seconds(plugin) + assert _pause(_coord(plugin), plugin, clock)[1] == pytest.approx(expected, abs=0.15) + + def test_a_raise_too(self, clock): + plugin = _plugin(None) + plugin.get_display_duration.side_effect = KeyError('display_duration') + expected = _rotation_seconds(plugin) + assert _pause(_coord(plugin), plugin, clock)[1] == pytest.approx(expected, abs=0.15) diff --git a/web_interface/app.py b/web_interface/app.py index dcaa417e..d2d4e067 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -159,7 +159,17 @@ schema_manager = SchemaManager( # saves reach the running plugins through the display's config watcher; what # the display knows at run time (health, metrics, errors, current mode) it # publishes to the shared cache. See docs/ARCHITECTURE.md. -plugin_catalog = PluginCatalog(plugins_dir=plugins_dir) +def _catalog_runtime_view(): + """The display's runtime view, for the catalog's mode lookups. Imported + on call, as the startup reconciliation below imports it.""" + from web_interface.blueprints.api_v3 import _plugin_runtime_view + return _plugin_runtime_view() + + +plugin_catalog = PluginCatalog( + plugins_dir=plugins_dir, + runtime_source=_catalog_runtime_view, +) # Initialize operation queue for plugin operations operation_queue = PluginOperationQueue(max_history=500) diff --git a/web_interface/blueprints/api_v3/display.py b/web_interface/blueprints/api_v3/display.py index 3cbe6c60..74652433 100644 --- a/web_interface/blueprints/api_v3/display.py +++ b/web_interface/blueprints/api_v3/display.py @@ -153,10 +153,12 @@ def get_display_modes(): same list the force-display dialog offers, from the source that owns it. Knowing each mode's plugin_id also matters because /display/on-demand/start - falls back to find_plugin_for_mode when plugin_id is omitted, and that - lookup only sees modes declared in a static manifest -- a plugin whose - modes are generated (each installed Starlark app is one) 404s there. - Sending the plugin_id from this list skips the lookup entirely. + falls back to find_plugin_for_mode when plugin_id is omitted. While the + display is running, both that lookup and this list use the modes it + registered, so modes a plugin generates from its config (each installed + Starlark app, each soccer custom league) are found (#668); with the + display stopped they see only what manifests declare. Sending the + plugin_id from this list skips the lookup entirely. Query params: include_disabled: '1' to list modes of disabled plugins too. They can @@ -277,6 +279,15 @@ def start_on_demand_display(): if not resolved_plugin: return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404 + # The display matches mode names exactly: pass the registered spelling + # when the caller's differs only in case. + if api_v3.plugin_catalog and resolved_plugin and resolved_mode: + wanted = resolved_mode.strip().lower() + for registered in api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin): + if isinstance(registered, str) and registered.lower() == wanted: + resolved_mode = registered + break + # On-demand works with disabled plugins: the running display loads one # for the session and unloads it afterwards, leaving config.json alone # (DisplayController._load_plugin_for_on_demand). Logged for debugging. diff --git a/web_interface/blueprints/api_v3/plugin_store.py b/web_interface/blueprints/api_v3/plugin_store.py index d0f31feb..c1c70e84 100644 --- a/web_interface/blueprints/api_v3/plugin_store.py +++ b/web_interface/blueprints/api_v3/plugin_store.py @@ -530,11 +530,13 @@ def install_plugin(): ) branch_msg = f" (branch: {branch})" if branch else "" - # plugin_id: the id to enable it by (see _installed_plugin_id). + # plugin_id: the id to enable it by, and the id its config + # section is under (see _installed_plugin_id). + installed_id = _installed_plugin_id(plugin_id) return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}', - 'plugin_id': _installed_plugin_id(plugin_id), - **_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))} + 'plugin_id': installed_id, + **_store_restart_fields('install', _plugin_enabled_in_config(installed_id))} else: error_msg = f'Failed to install plugin {plugin_id}' if branch: @@ -588,10 +590,11 @@ def install_plugin(): ) branch_msg = f" (branch: {branch})" if branch else "" + installed_id = _installed_plugin_id(plugin_id) return success_response( message=f'Plugin installed successfully{branch_msg}', - extra={'plugin_id': _installed_plugin_id(plugin_id), - **_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}) + extra={'plugin_id': installed_id, + **_store_restart_fields('install', _plugin_enabled_in_config(installed_id))}) else: error_msg = f'Failed to install plugin {plugin_id}' if branch: diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 1cd2e394..f2b5bb1b 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -150,8 +150,9 @@ def get_installed_plugins(): vegas_participation, vegas_participation_source = _vegas_participation( plugin_id, plugin_config, plugin_info) - # The modes the manifest declares, from the catalog as /display/modes - # and on-demand/start read them. The on-demand modal offers these; + # The plugin's modes, from the catalog as /display/modes and + # on-demand/start read them: what the running display registered, + # else what the manifest declares. The on-demand modal offers these; # without them it offered only the plugin id, which the display # turns into the first mode. Strings only: a manifest is hand-edited. declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)