Compare commits

...
Author SHA1 Message Date
Claude 8333f23e5c docs(common): list espn_payload in the common README
#749 added src/common/espn_payload.py without a summary row or section,
so test_common_readme_lists_every_module fails on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
2026-10-06 13:07:25 +00:00
ChuckandClaude Opus 5.5 6c533d62af fix(plugins): web mode lookups use the modes the display registered (#668) (#769)
* fix(plugins): web mode lookups use the modes the display registered (#668)

A plugin may compute its display modes from its config: soccer-scoreboard
registers soccer_<league>_live/recent/upcoming for every custom_leagues
entry, which no manifest can list ahead of time. The display always rotated
them (_register_loaded_plugin prefers plugin.modes), but the web process
reads plugins as files, so /display/modes, the on-demand dialog and
on-demand/start with a mode and no plugin_id saw only manifests -- a custom
league's mode was missing from every list and 404'd on lookup.

- PluginStateManager.record_modes(): the controller records what it
  registered, on the loaded record (an unload or reload forgets it)
- the runtime snapshot carries it per plugin as "modes" (bounded), and
  PluginRuntimeView.display_modes() reports it only while live
- PluginCatalog takes a runtime_source; get_plugin_display_modes and
  find_plugin_for_mode prefer the live modes, falling back to the manifest
  when the display is stopped or has not loaded the plugin. The view is read
  at most once a second, so a listing is one read, not one per plugin.

No manifest or plugin change needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): call the runtime view's display_modes directly

Codacy flagged the getattr/callable indirection as 'lookup is not callable'.
The view is a PluginRuntimeView or None; anything else raises inside the
existing try and falls back to the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): address review -- no manifest fallback for live plugins, keep mode names whole, send registered spelling

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:51:15 -04:00
ChuckandClaude Opus 5.5 cb06124b42 fix: a Vegas static pause survives a non-numeric display duration; aliased store installs ask for a restart (#753)
* fix(vegas): a display duration that is not a number no longer cancels a static pause

The Vegas static pause compared plugin.get_display_duration() with the
clock. clock-simple, calendar and countdown return their display_duration
setting straight from config.json, so a value saved as "20" or null
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 straight on, at every one of its turns. inf held the
pause until something interrupted it, and NaN, False, 0 or a negative
number ended it at once.

The pause now reads the duration the way the rotation has since #739,
with the same helper, then the rotation's fallbacks: 30 s for anything
that is not a number or a get_display_duration() that raises, 15 s for a
number at or below zero. Logged once per plugin. test_vegas_static_mode.py's
pauses used 0 to mean "no wait"; they now use 0.01.

The helper moves from display_controller (_finite_seconds) to base_plugin
(finite_seconds), unchanged: the coordinator cannot import from
display_controller, which imports src.vegas_mode at module level, and a
new src module would turn ledmatrix-plugins' min-core table check red
until it was listed. base_plugin is already loaded whenever either one is.

Tests: test/test_vegas_static_pause_duration.py, on a fake clock,
including TestSameAsTheRotation, which runs every value through both the
pause and the rotation's _get_display_duration/_resolve_durations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): a store install asks for a restart by the id it installed as

POST /plugins/install decides restart_required from whether config.json
already enables the plugin: the display loads a plugin when its enabled
flag changes, so one already enabled (a reinstall, or a config carried
over) keeps running the copy it loaded until a restart. The route read
that flag under the registry id. Weather, Music, Stocks and Leaderboard
install under the id their manifests declare (weather ->
ledmatrix-weather), which is the config section's id, so reinstalling an
enabled one never reported that a restart was needed.

Both the queued and the direct path now look up the installed id once
(#746's _installed_plugin_id) and use it for the plugin_id they answer
with and for the enabled check.

Tests: test/test_api_v3_install_restart_installed_id.py, through the
Flask test client, both paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(vegas): type the static pause fallback on its own (mypy ratchet)

_static_pause_duration assigned the fallback to `seconds`, which the
except branch typed as float before finite_seconds() reassigned it as
float | None. A separate `fallback` keeps both types exact; behaviour is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:50:48 -04:00
ChuckandClaude Opus 5.5 7c5fa9cfdb perf(fetch): cache ESPN scoreboard windows without the parts nothing reads (#749)
The sports scoreboards cache their Recent/Upcoming window as the raw ESPN
response, and it stays parsed in the memory tier while fresh. Measured on
hdpi, most of it is never drawn: per-team stat leaders, athlete cards
(featuredAthletes, probables), team and event links, headlines, video
highlights and geo broadcasts. None of those keys is read by core or by any
plugin in ledmatrix-plugins.

BackgroundDataService now drops them from an ESPN /scoreboard response
before caching and delivering it (src/common/espn_payload.py), keeping
everything else. On the five hdpi windows that is 10.6MB -> 3.0MB of JSON
and ~40MB -> ~12MB of parsed objects, and parsing an expired window gets
3-4x cheaper. submit_fetch_request(slim_payload=False) caches a response
whole.


Claude-Session: https://claude.ai/code/session_01BkfgXMqqwn2w4NN7LRzhxy

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:50:37 -04:00
ChuckandClaude Opus 5.5 ec117a35a1 fix(install): stop libblockdev matching the desktop-environment check (#780)
* fix(install): stop libblockdev matching the desktop-environment check

dpkg -l | grep -E '^ii.*kde' searched the description column too, so
unrelated packages (libblockdev-*) set DESKTOP_DETECTED on Raspberry Pi OS
Lite. Match on the installed package name only, anchored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ

* docs(install): correct why the desktop check matched libblockdev

The unanchored .*kde matched the package name mid-word ("bloc-kde-v"),
not only description text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-06 08:50:23 -04:00
ChuckandClaude Opus 5.5 e4f5e49ff7 test(sports): treat an adopted sports_helpers copy as parity, not missing (#777)
* test(sports): treat an adopted sports_helpers copy as parity, not missing

The scoreboards deleted their copies of the sports_helpers bodies and
constants when they adopted SportsHelpersMixin (ledmatrix-plugins
#563/#564), so the 19 parity tests in test/test_sports_helpers.py failed
whenever LEDMATRIX_PLUGINS pointed at a plugins checkout. 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sports): _adopted checks for a real import via the AST, not a text match

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:42:27 -04:00
25 changed files with 1213 additions and 60 deletions
+29
View File
@@ -19,6 +19,16 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## 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.
## 3.8.2 ## 3.8.2
The display hands freed memory back to the OS (#774), and sports consolidation The display hands freed memory back to the OS (#774), and sports consolidation
@@ -1274,6 +1284,25 @@ policies are unchanged.
a runtime publisher that stops still goes `stale`, and a subscription that 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 goes quiet still falls back to the cache. The cache path's 120 s rule is
unchanged. 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 ### Scrolling
+6 -1
View File
@@ -143,7 +143,9 @@ loaded and when. Nothing else keeps plugin state:
`DisplayController` right after it creates the `PluginManager`, writes the `DisplayController` right after it creates the `PluginManager`, writes the
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error` cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
(type, a redacted message of at most 200 characters, when, recoverable), (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 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 reader sees changes -- throttled to once per 10 s -- and otherwise once a
minute as a heartbeat. RUNNING, which every `update()` passes through, is 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 `loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`); plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
`/api/v3/plugins/state` returns the same beside the desired state. `/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** **Reconciliation**
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py)) ([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
+5 -3
View File
@@ -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 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 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 `plugin_id` is omitted. While the display is running, this list and that
manifest — a plugin whose modes are generated (each installed Starlark app is lookup use the modes the display registered, including ones a plugin generates
one) returns 404 there. 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 Triggers plugin discovery, which is otherwise lazy — so a caller that never
opens the dashboard still gets the full list. opens the dashboard still gets the full list.
+5 -1
View File
@@ -91,7 +91,11 @@ if [ -r "$LM_OS_RELEASE_FILE" ]; then
DESKTOP_DETECTED=0 DESKTOP_DETECTED=0
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE, # grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
# and pipefail turns a found desktop into "not found". # 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 # Match installed package names from their start: the unanchored ".*kde"
# matched mid-word (libblockdev-* = "bloc-kde-v") on Lite, and `dpkg -l`
# lines also carry descriptions that could match.
if dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' 2>/dev/null \
| grep -E "^ii +(raspberrypi-ui-mods|lxde|xfce|gnome|kde)" >/dev/null; then
DESKTOP_DETECTED=1 DESKTOP_DETECTED=1
fi fi
if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then
+20 -1
View File
@@ -34,6 +34,7 @@ from src.common.fetch_service import (
plugin_scope, plugin_scope,
share_connection_pool, share_connection_pool,
) )
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
from src.common.espn_dates import ( from src.common.espn_dates import (
RANGE_RETRY_SECONDS, RANGE_RETRY_SECONDS,
_note_range_rejected, _note_range_rejected,
@@ -83,6 +84,10 @@ class FetchRequest:
# the cache with the callbacks suppressed -- joiners waiting forever for a # the cache with the callbacks suppressed -- joiners waiting forever for a
# fetch that did, in fact, succeed. # fetch that did, in fact, succeed.
commit_claimed: bool = False 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 result: Optional[Any] = None
error: Optional[str] = None error: Optional[str] = None
# The plugin that submitted the request, so the fetch service counts the # The plugin that submitted the request, so the fetch service counts the
@@ -249,7 +254,8 @@ class BackgroundDataService:
timeout: Optional[int] = None, timeout: Optional[int] = None,
max_retries: int = 3, max_retries: int = 3,
priority: int = 1, priority: int = 1,
callback: Optional[Callable] = None) -> str: callback: Optional[Callable] = None,
slim_payload: bool = True) -> str:
""" """
Submit a background fetch request. Submit a background fetch request.
@@ -265,6 +271,11 @@ class BackgroundDataService:
priority: Accepted for compatibility and ignored; requests run in priority: Accepted for compatibility and ignored; requests run in
submission order. submission order.
callback: Optional callback function when request completes 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: Returns:
Request ID for tracking the fetch operation Request ID for tracking the fetch operation
@@ -336,6 +347,7 @@ class BackgroundDataService:
priority=priority, priority=priority,
callback=callback, callback=callback,
owner=owner, owner=owner,
slim_payload=slim_payload,
) )
with self._lock: with self._lock:
@@ -497,6 +509,13 @@ class BackgroundDataService:
) )
return result 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 # Cache the data
self.cache_manager.set(request.cache_key, data) self.cache_manager.set(request.cache_key, data)
+13
View File
@@ -28,6 +28,7 @@ Rules for the package:
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — | | [`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 | | [`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_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 | | [`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 | | [`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 | | [`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 counted against the plugin that asked. Scoreboard plugins also bundle a copy
for older cores. 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
[`favorite_team_check.py`](favorite_team_check.py). [`favorite_team_check.py`](favorite_team_check.py).
+97
View File
@@ -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"]
+11 -15
View File
@@ -25,7 +25,6 @@ import os
import inspect import inspect
import signal import signal
import json import json
import math
import threading import threading
import types import types
from collections import deque from collections import deque
@@ -64,6 +63,7 @@ from src.ipc.contract import (
PluginReloadResult, PluginReloadResult,
) )
from src.ipc.server import ControlServer, QueuedCommand, StateHub, start_control_server 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 from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
# Get logger with consistent configuration # Get logger with consistent configuration
@@ -101,19 +101,6 @@ _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0
DEFAULT_DYNAMIC_DURATION_CAP = 180.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: class _PluginReloadJob:
"""A ``plugin.reload`` whose slow half runs off the render thread. """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 except Exception as err: # pylint: disable=broad-except
problem = f"get_display_duration() raised {type(err).__name__}: {err}" problem = f"get_display_duration() raised {type(err).__name__}: {err}"
else: else:
seconds = _finite_seconds(value) seconds = finite_seconds(value)
if seconds is not None: if seconds is not None:
return seconds return seconds
problem = f"display duration {value!r} is not a number" problem = f"display duration {value!r} is not a number"
@@ -4624,6 +4611,15 @@ class DisplayController:
display_modes = [plugin_id] display_modes = [plugin_id]
with self._plugin_modes_lock: with self._plugin_modes_lock:
self.plugin_display_modes[plugin_id] = list(display_modes) 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 # Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
# and instance as defaults so each plugin's callback targets its own # and instance as defaults so each plugin's callback targets its own
+21
View File
@@ -11,6 +11,7 @@ Stability: Stable - maintains backward compatibility
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum from enum import Enum
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
import math
import os import os
import sys import sys
from src.deprecation import deprecated, warn_deprecated 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) 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): class BasePlugin(ABC):
""" """
Base class that all plugins must inherit from. Base class that all plugins must inherit from.
+65 -8
View File
@@ -15,7 +15,8 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no
``load_plugin``, ``get_plugin`` or ``plugins``. ``load_plugin``, ``get_plugin`` or ``plugins``.
Runtime state -- whether the display has a plugin loaded, its health, its 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 the shared cache (health and resource metrics, the current mode, the error
aggregator snapshot), and the web routes read those publications. What the aggregator snapshot), and the web routes read those publications. What the
display does not publish (which plugins it has loaded, its plugin state 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 json import json
import threading import threading
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Union, cast from typing import Any, Callable, Dict, List, Optional, Union, cast
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, get_plugin_dir_mode, ensure_directory_permissions, get_plugin_dir_mode,
@@ -39,6 +41,10 @@ from src.plugin_system.plugin_dirs import (
PathLike = Union[str, Path] 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: class PluginCatalog:
"""Manifests, schemas, config and versions of the installed plugins. """Manifests, schemas, config and versions of the installed plugins.
@@ -49,10 +55,17 @@ class PluginCatalog:
""" """
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None, def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
schema_manager: Optional[Any] = None) -> None: schema_manager: Optional[Any] = None,
runtime_source: Optional[Callable[[], Any]] = None) -> None:
self.plugins_dir: Path = Path(plugins_dir) self.plugins_dir: Path = Path(plugins_dir)
self.config_manager = config_manager self.config_manager = config_manager
self.schema_manager = schema_manager self.schema_manager = schema_manager
# 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__) self.logger = get_logger(__name__)
# Guards plugin_manifests/plugin_directories: request threads read # Guards plugin_manifests/plugin_directories: request threads read
@@ -172,23 +185,67 @@ class PluginCatalog:
by_manifest=False) by_manifest=False)
return str(plugin_dir) if plugin_dir is not None else None return str(plugin_dir) if plugin_dir is not None else None
def get_plugin_display_modes(self, plugin_id: str) -> List[str]: def _runtime_view(self) -> Any:
"""The manifest's ``display_modes``, or []. """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 def _live_display_modes(self, plugin_id: str) -> Optional[List[str]]:
its modes at run time (``plugin.modes``). This is the declared list. """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: with self._lock:
manifest = self.plugin_manifests.get(plugin_id) manifest = self.plugin_manifests.get(plugin_id)
modes = (manifest or {}).get('display_modes', []) modes = (manifest or {}).get('display_modes', [])
return list(modes) if isinstance(modes, list) else [] return list(modes) if isinstance(modes, list) else []
def find_plugin_for_mode(self, mode: str) -> Optional[str]: 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() wanted = mode.strip().lower()
with self._lock: with self._lock:
manifests = dict(self.plugin_manifests) 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(): 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') modes = manifest.get('display_modes')
if isinstance(modes, list) and any( if isinstance(modes, list) and any(
isinstance(m, str) and m.lower() == wanted for m in modes): isinstance(m, str) and m.lower() == wanted for m in modes):
+29 -1
View File
@@ -56,7 +56,7 @@ import os
import threading import threading
import time import time
from dataclasses import dataclass, field, replace 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 import display_watchdog
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -100,6 +100,9 @@ _ERROR_MESSAGE_CHARS = 200
_ERROR_TYPE_CHARS = 80 _ERROR_TYPE_CHARS = 80
_ID_CHARS = 100 _ID_CHARS = 100
_VERSION_CHARS = 40 _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. #: Reader statuses. Only LIVE carries runtime facts.
LIVE = "live" 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, def build_runtime_snapshot(state_manager: Any, *, started_at: float,
now: Optional[float] = None, now: Optional[float] = None,
running: bool = True, running: bool = True,
@@ -173,6 +185,7 @@ def build_runtime_snapshot(state_manager: Any, *, started_at: float,
"error": summarize_error(record.get("error_info")), "error": summarize_error(record.get("error_info")),
"version": _clip(version, _VERSION_CHARS) if version else None, "version": _clip(version, _VERSION_CHARS) if version else None,
"loaded_at": _epoch(record.get("loaded_at")), "loaded_at": _epoch(record.get("loaded_at")),
"modes": _published_modes(record.get("modes")),
} }
return { return {
"schema": SNAPSHOT_SCHEMA, "schema": SNAPSHOT_SCHEMA,
@@ -416,6 +429,21 @@ class PluginRuntimeView:
"loaded_at": record.get("loaded_at"), "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]: def describe(self) -> Dict[str, Any]:
"""The view's own status, for a response to carry beside the facts.""" """The view's own status, for a response to carry beside the facts."""
return { return {
+24 -2
View File
@@ -10,7 +10,7 @@ snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
import threading import threading
import time import time
from enum import Enum from enum import Enum
from typing import Optional, Dict, Any from typing import Any, Dict, List, Optional
from datetime import datetime from datetime import datetime
import logging import logging
@@ -231,6 +231,26 @@ class PluginStateManager:
} }
self._note_change() 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: def record_unloaded(self, plugin_id: str) -> None:
"""Forget the loaded record alone, keeping state and error info: for """Forget the loaded record alone, keeping state and error info: for
an unload that failed after the instance was already dropped.""" an unload that failed after the instance was already dropped."""
@@ -243,7 +263,8 @@ class PluginStateManager:
section so a concurrent load or unload is seen whole or not at all. section so a concurrent load or unload is seen whole or not at all.
Per plugin: ``state`` (published_state()'s value), ``loaded``, 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). (a copy, or None).
""" """
with self._lock: with self._lock:
@@ -257,6 +278,7 @@ class PluginStateManager:
'loaded': loaded is not None, 'loaded': loaded is not None,
'version': loaded['version'] if loaded else None, 'version': loaded['version'] if loaded else None,
'loaded_at': loaded['loaded_at'] 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, 'error_info': dict(info) if info is not None else None,
} }
return records return records
+50 -2
View File
@@ -18,10 +18,11 @@ import math
import sys import sys
import time import time
import threading 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 import display_watchdog
from src.common import render_gate 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.config import VegasModeConfig
from src.vegas_mode.elements import LiveEpochs from src.vegas_mode.elements import LiveEpochs
from src.vegas_mode.plugin_adapter import PluginAdapter 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. #: every plugin. Game state doesn't change within a quarter second.
_LIVE_PRIORITY_CHECK_INTERVAL = 0.25 _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: def _percentile(ordered: List[float], fraction: float) -> float:
"""Nearest-rank percentile of an already-sorted list. """Nearest-rank percentile of an already-sorted list.
@@ -92,6 +101,9 @@ class VegasModeCoordinator:
_live_reason: Optional[str] = None _live_reason: Optional[str] = None
# Set only while Vegas has changed the GIL switch interval; read with getattr. # Set only while Vegas has changed the GIL switch interval; read with getattr.
_saved_switch_interval: Optional[float] _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__( def __init__(
self, self,
@@ -1010,7 +1022,7 @@ class VegasModeCoordinator:
# Wait for the plugin's display duration. Monotonic, like the # Wait for the plugin's display duration. Monotonic, like the
# iteration clock: an NTP step on an RTC-less Pi would otherwise # iteration clock: an NTP step on an RTC-less Pi would otherwise
# end the pause at once or stretch it by the correction. # 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() start = time.monotonic()
while time.monotonic() - start < duration: while time.monotonic() - start < duration:
@@ -1046,6 +1058,42 @@ class VegasModeCoordinator:
return True 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: def _end_static_pause(self) -> None:
"""End static pause and restore scroll state.""" """End static pause and restore scroll state."""
should_resume_scrolling = False should_resume_scrolling = False
+12 -1
View File
@@ -7,7 +7,7 @@ manifest.json off disk and reimplemented PluginManager's own fallbacks.
""" """
import json import json
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -155,3 +155,14 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed")) side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
body = api_v3_client.get('/api/v3/display/modes').get_json() body = api_v3_client.get('/api/v3/display/modes').get_json()
assert 'SEC123' not in json.dumps(body) 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'
@@ -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
+162
View File
@@ -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()]}
+265
View File
@@ -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_<league>_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"
+1 -1
View File
@@ -162,7 +162,7 @@ class TestPublisher:
assert snapshot["stale_after"] == rt.STALE_AFTER assert snapshot["stale_after"] == rt.STALE_AFTER
assert snapshot["plugins"] == {"clock": { assert snapshot["plugins"] == {"clock": {
"loaded": True, "state": "enabled", "error": None, "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): def test_changes_are_throttled_and_quiet_displays_refresh(self):
cache = MagicMock() cache = MagicMock()
+41 -11
View File
@@ -8,7 +8,9 @@ loses those tests with it.
The parity class is what keeps "byte-identical" true after this lands. Point 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 LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every promoted body is
compared, as a docstring-stripped AST, against every plugin copy that carries 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 plugins checkout; ledmatrix-plugins CI runs the same comparison against core
(scripts/check_sports_helpers_parity.py, ledmatrix-plugins#495). (scripts/check_sports_helpers_parity.py, ledmatrix-plugins#495).
""" """
@@ -572,6 +574,24 @@ def _core_definitions():
return out 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: class TestParityWithPlugins:
@pytest.mark.parametrize("name", sorted(PROMOTED)) @pytest.mark.parametrize("name", sorted(PROMOTED))
def test_body_matches_every_plugin_copy(self, name): def test_body_matches_every_plugin_copy(self, name):
@@ -580,10 +600,10 @@ class TestParityWithPlugins:
ours = _dump(_core_definitions()[name]) ours = _dump(_core_definitions()[name])
drifted, missing = [], [] drifted, missing = [], []
for sport in carriers: for sport in carriers:
defs = _definitions(ast.parse( source = _sports_source(root, sport)
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8"))) theirs = _definitions(ast.parse(source))[where].get(plugin_name)
theirs = defs[where].get(plugin_name)
if theirs is None: if theirs is None:
if not _adopted(source):
missing.append(sport) missing.append(sport)
elif _dump(theirs) != ours: elif _dump(theirs) != ours:
drifted.append(sport) drifted.append(sport)
@@ -594,10 +614,20 @@ class TestParityWithPlugins:
@pytest.mark.parametrize("sport", SCOREBOARDS) @pytest.mark.parametrize("sport", SCOREBOARDS)
def test_constants_match(self, sport): def test_constants_match(self, sport):
root = _plugins_root() source = _sports_source(_plugins_root(), sport)
defs = _definitions(ast.parse( defs = _definitions(ast.parse(source))
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8"))) expected = {
assert ast.literal_eval(defs["module"]["_MIN_WINDOW_DAYS"].value) == MIN_WINDOW_DAYS ("module", "_MIN_WINDOW_DAYS"): MIN_WINDOW_DAYS,
assert ast.literal_eval(defs["module"]["_MAX_WINDOW_DAYS"].value) == MAX_WINDOW_DAYS ("module", "_MAX_WINDOW_DAYS"): MAX_WINDOW_DAYS,
gap = defs["SportsCore"]["_DWELL_REENTRY_GAP_SECONDS"].value ("SportsCore", "_DWELL_REENTRY_GAP_SECONDS"):
assert math.isclose(ast.literal_eval(gap), SportsHelpersMixin._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}"
+2 -1
View File
@@ -219,7 +219,8 @@ class TestCoordinatorStaticPause:
def _plugin(self): def _plugin(self):
plugin = MagicMock() plugin = MagicMock()
plugin.plugin_id = 'clock' 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 return plugin
def test_trigger_comes_from_the_pipeline(self): def test_trigger_comes_from_the_pipeline(self):
+197
View File
@@ -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)
+8
View File
@@ -159,10 +159,18 @@ schema_manager = SchemaManager(
# saves reach the running plugins through the display's config watcher; what # saves reach the running plugins through the display's config watcher; what
# the display knows at run time (health, metrics, errors, current mode) it # the display knows at run time (health, metrics, errors, current mode) it
# publishes to the shared cache. See docs/ARCHITECTURE.md. # publishes to the shared cache. See docs/ARCHITECTURE.md.
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( plugin_catalog = PluginCatalog(
plugins_dir=plugins_dir, plugins_dir=plugins_dir,
config_manager=config_manager, config_manager=config_manager,
schema_manager=schema_manager, schema_manager=schema_manager,
runtime_source=_catalog_runtime_view,
) )
# Initialize operation queue for plugin operations # Initialize operation queue for plugin operations
+15 -4
View File
@@ -153,10 +153,12 @@ def get_display_modes():
same list the force-display dialog offers, from the source that owns it. 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 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 falls back to find_plugin_for_mode when plugin_id is omitted. While the
lookup only sees modes declared in a static manifest -- a plugin whose display is running, both that lookup and this list use the modes it
modes are generated (each installed Starlark app is one) 404s there. registered, so modes a plugin generates from its config (each installed
Sending the plugin_id from this list skips the lookup entirely. 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: Query params:
include_disabled: '1' to list modes of disabled plugins too. They can 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: if not resolved_plugin:
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404 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 # On-demand works with disabled plugins: the running display loads one
# for the session and unloads it afterwards, leaving config.json alone # for the session and unloads it afterwards, leaving config.json alone
# (DisplayController._load_plugin_for_on_demand). Logged for debugging. # (DisplayController._load_plugin_for_on_demand). Logged for debugging.
@@ -530,11 +530,13 @@ def install_plugin():
) )
branch_msg = f" (branch: {branch})" if branch else "" 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, return {'success': True,
'message': f'Plugin {plugin_id} installed successfully{branch_msg}', 'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
'plugin_id': _installed_plugin_id(plugin_id), 'plugin_id': installed_id,
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))} **_store_restart_fields('install', _plugin_enabled_in_config(installed_id))}
else: else:
error_msg = f'Failed to install plugin {plugin_id}' error_msg = f'Failed to install plugin {plugin_id}'
if branch: if branch:
@@ -588,10 +590,11 @@ def install_plugin():
) )
branch_msg = f" (branch: {branch})" if branch else "" branch_msg = f" (branch: {branch})" if branch else ""
installed_id = _installed_plugin_id(plugin_id)
return success_response( return success_response(
message=f'Plugin installed successfully{branch_msg}', message=f'Plugin installed successfully{branch_msg}',
extra={'plugin_id': _installed_plugin_id(plugin_id), extra={'plugin_id': installed_id,
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}) **_store_restart_fields('install', _plugin_enabled_in_config(installed_id))})
else: else:
error_msg = f'Failed to install plugin {plugin_id}' error_msg = f'Failed to install plugin {plugin_id}'
if branch: if branch:
+3 -2
View File
@@ -150,8 +150,9 @@ def get_installed_plugins():
vegas_participation, vegas_participation_source = _vegas_participation( vegas_participation, vegas_participation_source = _vegas_participation(
plugin_id, plugin_config, plugin_info) plugin_id, plugin_config, plugin_info)
# The modes the manifest declares, from the catalog as /display/modes # The plugin's modes, from the catalog as /display/modes and
# and on-demand/start read them. The on-demand modal offers these; # 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 # without them it offered only the plugin id, which the display
# turns into the first mode. Strings only: a manifest is hand-edited. # turns into the first mode. Strings only: a manifest is hand-edited.
declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id) declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)