fix(plugins): web mode lookups use the modes the display registered (#668)

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

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

No manifest or plugin change needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-05 08:33:47 -04:00
co-authored by Claude Opus 5.5
parent 3f920f2899
commit 554426e023
11 changed files with 405 additions and 22 deletions
+64 -8
View File
@@ -15,7 +15,8 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no
``load_plugin``, ``get_plugin`` or ``plugins``.
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(
+27 -1
View File
@@ -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 {
+24 -2
View File
@@ -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