From e4f5e49ff76e8e0b600d23c3ffdfe33c2b415773 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:42:27 -0400 Subject: [PATCH 1/7] 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 * test(sports): _adopted checks for a real import via the AST, not a text match Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 10 +++++++ test/test_sports_helpers.py | 54 ++++++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f729e9e..dc41ddef 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. + ## 3.8.2 The display hands freed memory back to the OS (#774), and sports consolidation 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}" From ec117a35a115391bff1b4ea5383765ab59c6df67 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:50:23 -0400 Subject: [PATCH 2/7] 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 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 Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ --------- Co-authored-by: Claude --- first_time_install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/first_time_install.sh b/first_time_install.sh index 65cd66fc..ea494c12 100755 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -91,7 +91,11 @@ if [ -r "$LM_OS_RELEASE_FILE" ]; then DESKTOP_DETECTED=0 # 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 + # 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 fi if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then From 7c5fa9cfdb57197fba26498755e66cf31c11abea Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:50:37 -0400 Subject: [PATCH 3/7] 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 --- src/background_data_service.py | 21 ++++- src/common/espn_payload.py | 97 ++++++++++++++++++++ test/test_espn_payload.py | 162 +++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/common/espn_payload.py create mode 100644 test/test_espn_payload.py diff --git a/src/background_data_service.py b/src/background_data_service.py index be860bb9..9a8f0250 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, @@ -83,6 +84,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 @@ -249,7 +254,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. @@ -265,6 +271,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 @@ -336,6 +347,7 @@ class BackgroundDataService: priority=priority, callback=callback, owner=owner, + slim_payload=slim_payload, ) with self._lock: @@ -497,6 +509,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/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/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()]} From cb06124b42c21acc339994273e0b708f2d2a755a Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:50:48 -0400 Subject: [PATCH 4/7] 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 19 ++ src/display_controller.py | 17 +- src/plugin_system/base_plugin.py | 21 ++ src/vegas_mode/coordinator.py | 52 ++++- ...est_api_v3_install_restart_installed_id.py | 123 +++++++++++ test/test_vegas_static_mode.py | 3 +- test/test_vegas_static_pause_duration.py | 197 ++++++++++++++++++ .../blueprints/api_v3/plugin_store.py | 13 +- 8 files changed, 422 insertions(+), 23 deletions(-) create mode 100644 test/test_api_v3_install_restart_installed_id.py create mode 100644 test/test_vegas_static_pause_duration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dc41ddef..eed96cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1284,6 +1284,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/src/display_controller.py b/src/display_controller.py index 44a70f84..de61f2aa 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" 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/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index 8eb0f0ab..8c296c60 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, @@ -1010,7 +1022,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: @@ -1046,6 +1058,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_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_vegas_static_mode.py b/test/test_vegas_static_mode.py index 2d3f6679..3bfe1962 100644 --- a/test/test_vegas_static_mode.py +++ b/test/test_vegas_static_mode.py @@ -219,7 +219,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/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: From 6c533d62afb2c208a875af5e06c0327f81bf15d2 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:51:15 -0400 Subject: [PATCH 5/7] 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__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 * 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 * fix(plugins): address review -- no manifest fallback for live plugins, keep mode names whole, send registered spelling Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 7 +- docs/REST_API_REFERENCE.md | 8 +- src/display_controller.py | 9 + src/plugin_system/plugin_catalog.py | 73 +++++- src/plugin_system/plugin_runtime.py | 30 ++- src/plugin_system/plugin_state.py | 26 +- test/test_api_v3_display_modes.py | 13 +- test/test_live_display_modes.py | 265 +++++++++++++++++++++ test/test_plugin_runtime_snapshot.py | 2 +- web_interface/app.py | 8 + web_interface/blueprints/api_v3/display.py | 19 +- web_interface/blueprints/api_v3/plugins.py | 5 +- 12 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 test/test_live_display_modes.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a095786..0018abb7 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/src/display_controller.py b/src/display_controller.py index de61f2aa..bdefde24 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -4611,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/plugin_catalog.py b/src/plugin_system/plugin_catalog.py index 8e0a25f0..bd80f2ab 100644 --- a/src/plugin_system/plugin_catalog.py +++ b/src/plugin_system/plugin_catalog.py @@ -15,7 +15,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 json import threading +import time 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 ( 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, 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, - 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.config_manager = config_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__) # Guards plugin_manifests/plugin_directories: request threads read @@ -172,23 +185,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 17755890..f89a9b75 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 @@ -231,6 +231,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.""" @@ -243,7 +263,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: @@ -257,6 +278,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/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_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/web_interface/app.py b/web_interface/app.py index 4f4ca235..73ae0ca2 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -159,10 +159,18 @@ 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. +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, config_manager=config_manager, schema_manager=schema_manager, + runtime_source=_catalog_runtime_view, ) # Initialize operation queue for plugin operations 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/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) From 87f255b2fa0023b816cf918a1c1d387c80d005ec Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:35:28 -0400 Subject: [PATCH 6/7] docs(common): list espn_payload in the common README (#782) #749 added src/common/espn_payload.py without a summary row or section, so test_common_readme_lists_every_module fails on main. Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ Co-authored-by: Claude --- src/common/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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). From d98b4797273cb907be93ea3da71e55b229a9a40c Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:35:49 -0400 Subject: [PATCH 7/7] fix(install): only a running desktop stops the install; detect desktops by metapackage (#781) * fix(install): detect desktops by their metapackages, not name prefixes The Lite check matched any installed package starting with gnome/kde/ xfce/lxde, so standalone parts (gnome-keyring, xfce4-terminal, lxde-icon-theme) rejected a Lite system. Match whole names of desktop metapackages and session managers instead. Also catch desktops the prefixes missed: Raspberry Pi OS Trixie replaced raspberrypi-ui-mods with rpd-wayland-core / rpd-x-core, Debian tasksel desktops (task-*-desktop), and multi-arch names (plasma-workspace:arm64). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ * 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 Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ (cherry picked from commit 8333f23e5c53e2662ddf7c1b2c35f4f98dd74552) * fix(install): only a running desktop stops the install A desktop costs the panel CPU only while it runs, so a running display manager (checked with systemctl is-active, including the generic display-manager alias, instead of a grep -q pipe under pipefail) still stops the installer. Desktop packages or session files on a Pi that boots to the console now print a warning and the install continues. Adds installer OS-check tests for running, installed-only and Lite systems, including the libblockdev and gnome-keyring false positives. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ --------- Co-authored-by: Claude --- docs/TROUBLESHOOTING.md | 5 ++- first_time_install.sh | 47 ++++++++++++++-------- test/test_install_os_support.py | 69 ++++++++++++++++++++++++++++----- 3 files changed, 94 insertions(+), 27 deletions(-) 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 ea494c12..a4af1a6b 100755 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -86,29 +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". - # 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. + # 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 +(raspberrypi-ui-mods|lxde|xfce|gnome|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 + | 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/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 ---------------------------------------------