mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user