From 554426e023f3ae72020083ed45cfa69798b0a512 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:33:45 -0400 Subject: [PATCH] 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 --- docs/ARCHITECTURE.md | 7 +- docs/REST_API_REFERENCE.md | 8 +- src/display_controller.py | 9 + src/plugin_system/plugin_catalog.py | 72 +++++- src/plugin_system/plugin_runtime.py | 28 ++- src/plugin_system/plugin_state.py | 26 ++- test/test_live_display_modes.py | 252 +++++++++++++++++++++ test/test_plugin_runtime_snapshot.py | 2 +- web_interface/app.py | 8 + web_interface/blueprints/api_v3/display.py | 10 +- web_interface/blueprints/api_v3/plugins.py | 5 +- 11 files changed, 405 insertions(+), 22 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 84bfbe8c..746b1adf 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -4617,6 +4617,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..18d947e6 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,22 +185,65 @@ 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() + lookup = getattr(view, 'display_modes', None) + if not callable(lookup): + return None + try: + modes = lookup(plugin_id) + except Exception as exc: + 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(): modes = manifest.get('display_modes') if isinstance(modes, list) and any( diff --git a/src/plugin_system/plugin_runtime.py b/src/plugin_system/plugin_runtime.py index 2a541396..643de58c 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,13 @@ 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 + return [_clip(m, _ID_CHARS) for m in modes[:_MAX_MODES] if isinstance(m, str)] + + def build_runtime_snapshot(state_manager: Any, *, started_at: float, now: Optional[float] = None, running: bool = True, @@ -173,6 +183,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 +427,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_live_display_modes.py b/test/test_live_display_modes.py new file mode 100644 index 00000000..fb09f091 --- /dev/null +++ b/test/test_live_display_modes.py @@ -0,0 +1,252 @@ +"""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_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_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..a071d1c8 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 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)