mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4521acdf57 | ||
|
|
b440a27cdb | ||
|
|
6dbec2e0b1 | ||
|
|
e74447c65e | ||
|
|
554426e023 |
@@ -143,7 +143,9 @@ loaded and when. Nothing else keeps plugin state:
|
|||||||
`DisplayController` right after it creates the `PluginManager`, writes the
|
`DisplayController` right after it creates the `PluginManager`, writes the
|
||||||
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
||||||
(type, a redacted message of at most 200 characters, when, recoverable),
|
(type, a redacted message of at most 200 characters, when, recoverable),
|
||||||
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
|
`version`, `loaded_at` and `modes` (the display modes `DisplayController`
|
||||||
|
registered -- `plugin.modes` when the plugin computes them, else the
|
||||||
|
manifest's), plus `published_at`, `stale_after` and `running`.
|
||||||
The cache is on disk, usually the SD card, so it writes when something a
|
The cache is on disk, usually the SD card, so it writes when something a
|
||||||
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
||||||
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
||||||
@@ -159,6 +161,9 @@ truth cannot leak into a response. `/api/v3/plugins/installed` returns
|
|||||||
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
||||||
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
||||||
`/api/v3/plugins/state` returns the same beside the desired state.
|
`/api/v3/plugins/state` returns the same beside the desired state.
|
||||||
|
`PluginCatalog.get_plugin_display_modes` and `find_plugin_for_mode` prefer a
|
||||||
|
live view's `modes` to the manifest's `display_modes`, so `/display/modes`
|
||||||
|
and on-demand see modes a plugin generates from its config (#668).
|
||||||
|
|
||||||
**Reconciliation**
|
**Reconciliation**
|
||||||
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
||||||
|
|||||||
@@ -363,9 +363,11 @@ it. This is the list the force-display dialog offers.
|
|||||||
|
|
||||||
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
||||||
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
||||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
`plugin_id` is omitted. While the display is running, this list and that
|
||||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
lookup use the modes the display registered, including ones a plugin generates
|
||||||
one) returns 404 there.
|
from its config (each installed Starlark app, each soccer `custom_leagues`
|
||||||
|
entry). With the display stopped, or for a plugin it has not loaded, both see
|
||||||
|
only the modes its manifest declares.
|
||||||
|
|
||||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||||
opens the dashboard still gets the full list.
|
opens the dashboard still gets the full list.
|
||||||
|
|||||||
@@ -4624,6 +4624,15 @@ class DisplayController:
|
|||||||
display_modes = [plugin_id]
|
display_modes = [plugin_id]
|
||||||
with self._plugin_modes_lock:
|
with self._plugin_modes_lock:
|
||||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||||
|
# Into the runtime snapshot the web interface reads, so its mode
|
||||||
|
# lists and on-demand lookups see computed modes too (#668).
|
||||||
|
state_manager = getattr(self.plugin_manager, 'state_manager', None)
|
||||||
|
record_modes = getattr(state_manager, 'record_modes', None)
|
||||||
|
if callable(record_modes):
|
||||||
|
try:
|
||||||
|
record_modes(plugin_id, list(display_modes))
|
||||||
|
except Exception as e: # reporting must never break registration
|
||||||
|
logger.debug("Could not record display modes for %s: %s", plugin_id, e)
|
||||||
|
|
||||||
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
||||||
# and instance as defaults so each plugin's callback targets its own
|
# and instance as defaults so each plugin's callback targets its own
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no
|
|||||||
``load_plugin``, ``get_plugin`` or ``plugins``.
|
``load_plugin``, ``get_plugin`` or ``plugins``.
|
||||||
|
|
||||||
Runtime state -- whether the display has a plugin loaded, its health, its
|
Runtime state -- whether the display has a plugin loaded, its health, its
|
||||||
errors -- is not here either. The display process publishes what it knows to
|
errors -- is not here either, with one exception: given a ``runtime_source``,
|
||||||
|
the mode lookups prefer the modes the running display registered. The display process publishes what it knows to
|
||||||
the shared cache (health and resource metrics, the current mode, the error
|
the shared cache (health and resource metrics, the current mode, the error
|
||||||
aggregator snapshot), and the web routes read those publications. What the
|
aggregator snapshot), and the web routes read those publications. What the
|
||||||
display does not publish (which plugins it has loaded, its plugin state
|
display does not publish (which plugins it has loaded, its plugin state
|
||||||
@@ -26,8 +27,9 @@ See docs/ARCHITECTURE.md ("Web and display processes").
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Union, cast
|
from typing import Any, Callable, Dict, List, Optional, Union, cast
|
||||||
|
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions, get_plugin_dir_mode,
|
ensure_directory_permissions, get_plugin_dir_mode,
|
||||||
@@ -39,6 +41,10 @@ from src.plugin_system.plugin_dirs import (
|
|||||||
|
|
||||||
PathLike = Union[str, Path]
|
PathLike = Union[str, Path]
|
||||||
|
|
||||||
|
#: How long one read of the display's runtime view answers mode lookups. A
|
||||||
|
#: listing asks once per plugin; the cache copy is a file read each time.
|
||||||
|
_RUNTIME_VIEW_TTL_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
class PluginCatalog:
|
class PluginCatalog:
|
||||||
"""Manifests, schemas, config and versions of the installed plugins.
|
"""Manifests, schemas, config and versions of the installed plugins.
|
||||||
@@ -49,10 +55,17 @@ class PluginCatalog:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
||||||
schema_manager: Optional[Any] = None) -> None:
|
schema_manager: Optional[Any] = None,
|
||||||
|
runtime_source: Optional[Callable[[], Any]] = None) -> None:
|
||||||
self.plugins_dir: Path = Path(plugins_dir)
|
self.plugins_dir: Path = Path(plugins_dir)
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.schema_manager = schema_manager
|
self.schema_manager = schema_manager
|
||||||
|
# Returns the display's PluginRuntimeView
|
||||||
|
# (src/plugin_system/plugin_runtime.py). Its live view carries the
|
||||||
|
# modes the display registered, which the mode lookups below prefer
|
||||||
|
# to the manifest's. None: manifests only.
|
||||||
|
self.runtime_source = runtime_source
|
||||||
|
self._runtime_view_memo: Optional[tuple] = None
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
|
|
||||||
# Guards plugin_manifests/plugin_directories: request threads read
|
# Guards plugin_manifests/plugin_directories: request threads read
|
||||||
@@ -172,23 +185,67 @@ class PluginCatalog:
|
|||||||
by_manifest=False)
|
by_manifest=False)
|
||||||
return str(plugin_dir) if plugin_dir is not None else None
|
return str(plugin_dir) if plugin_dir is not None else None
|
||||||
|
|
||||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
def _runtime_view(self) -> Any:
|
||||||
"""The manifest's ``display_modes``, or [].
|
"""The display's runtime view, read at most once a second; None
|
||||||
|
without a source or when reading it fails."""
|
||||||
|
if self.runtime_source is None:
|
||||||
|
return None
|
||||||
|
now = time.monotonic()
|
||||||
|
memo = self._runtime_view_memo
|
||||||
|
if memo is not None and now - memo[0] < _RUNTIME_VIEW_TTL_SECONDS:
|
||||||
|
return memo[1]
|
||||||
|
try:
|
||||||
|
view = self.runtime_source()
|
||||||
|
except Exception as exc: # a lookup must still answer from manifests
|
||||||
|
self.logger.debug("Could not read the display's runtime view: %s", exc)
|
||||||
|
view = None
|
||||||
|
self._runtime_view_memo = (now, view)
|
||||||
|
return view
|
||||||
|
|
||||||
What the display actually rotates can differ: a plugin may compute
|
def _live_display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||||
its modes at run time (``plugin.modes``). This is the declared list.
|
"""The modes the running display registered for ``plugin_id``, or None."""
|
||||||
|
view = self._runtime_view()
|
||||||
|
if view is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
modes = view.display_modes(plugin_id)
|
||||||
|
except Exception as exc: # includes a source returning something else
|
||||||
|
self.logger.debug("Could not read display modes for %s: %s", plugin_id, exc)
|
||||||
|
return None
|
||||||
|
return list(modes) if isinstance(modes, list) and modes else None
|
||||||
|
|
||||||
|
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||||
|
"""The modes the display registered for the plugin, else the
|
||||||
|
manifest's ``display_modes``, else [].
|
||||||
|
|
||||||
|
A plugin may compute its modes at run time (``plugin.modes``): each
|
||||||
|
league soccer-scoreboard's ``custom_leagues`` adds is a mode no
|
||||||
|
manifest can list ahead of time (#668). The running display
|
||||||
|
publishes what it registered, and that wins while the display is
|
||||||
|
live and has the plugin loaded. Otherwise -- display stopped, plugin
|
||||||
|
disabled -- the declared list is the best answer there is.
|
||||||
"""
|
"""
|
||||||
|
live = self._live_display_modes(plugin_id)
|
||||||
|
if live is not None:
|
||||||
|
return live
|
||||||
with self._lock:
|
with self._lock:
|
||||||
manifest = self.plugin_manifests.get(plugin_id)
|
manifest = self.plugin_manifests.get(plugin_id)
|
||||||
modes = (manifest or {}).get('display_modes', [])
|
modes = (manifest or {}).get('display_modes', [])
|
||||||
return list(modes) if isinstance(modes, list) else []
|
return list(modes) if isinstance(modes, list) else []
|
||||||
|
|
||||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||||
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
|
"""The plugin that registered ``mode`` on the running display, else
|
||||||
|
the one whose manifest declares it (case-insensitive both ways)."""
|
||||||
wanted = mode.strip().lower()
|
wanted = mode.strip().lower()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
manifests = dict(self.plugin_manifests)
|
manifests = dict(self.plugin_manifests)
|
||||||
|
for plugin_id in manifests:
|
||||||
|
live = self._live_display_modes(plugin_id)
|
||||||
|
if live and any(m.lower() == wanted for m in live):
|
||||||
|
return plugin_id
|
||||||
for plugin_id, manifest in manifests.items():
|
for plugin_id, manifest in manifests.items():
|
||||||
|
if self._live_display_modes(plugin_id):
|
||||||
|
continue # the display's list is the truth for this plugin
|
||||||
modes = manifest.get('display_modes')
|
modes = manifest.get('display_modes')
|
||||||
if isinstance(modes, list) and any(
|
if isinstance(modes, list) and any(
|
||||||
isinstance(m, str) and m.lower() == wanted for m in modes):
|
isinstance(m, str) and m.lower() == wanted for m in modes):
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from typing import Any, Callable, Dict, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src import display_watchdog
|
from src import display_watchdog
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
@@ -100,6 +100,9 @@ _ERROR_MESSAGE_CHARS = 200
|
|||||||
_ERROR_TYPE_CHARS = 80
|
_ERROR_TYPE_CHARS = 80
|
||||||
_ID_CHARS = 100
|
_ID_CHARS = 100
|
||||||
_VERSION_CHARS = 40
|
_VERSION_CHARS = 40
|
||||||
|
#: Bounds on a plugin's published ``modes``: a plugin computes them, so a
|
||||||
|
#: runaway list must not bloat a file written to the SD card.
|
||||||
|
_MAX_MODES = 200
|
||||||
|
|
||||||
#: Reader statuses. Only LIVE carries runtime facts.
|
#: Reader statuses. Only LIVE carries runtime facts.
|
||||||
LIVE = "live"
|
LIVE = "live"
|
||||||
@@ -154,6 +157,15 @@ def summarize_error(error_info: Optional[Dict[str, Any]]) -> Optional[Dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _published_modes(modes: Any) -> Optional[List[str]]:
|
||||||
|
"""The registered display modes as a snapshot carries them, or None."""
|
||||||
|
if not isinstance(modes, list):
|
||||||
|
return None
|
||||||
|
# A name is a key the display matches exactly: drop one too long to
|
||||||
|
# carry whole rather than clip it into a different name.
|
||||||
|
return [m for m in modes if isinstance(m, str) and len(m) <= _ID_CHARS][:_MAX_MODES]
|
||||||
|
|
||||||
|
|
||||||
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||||
now: Optional[float] = None,
|
now: Optional[float] = None,
|
||||||
running: bool = True,
|
running: bool = True,
|
||||||
@@ -173,6 +185,7 @@ def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
|||||||
"error": summarize_error(record.get("error_info")),
|
"error": summarize_error(record.get("error_info")),
|
||||||
"version": _clip(version, _VERSION_CHARS) if version else None,
|
"version": _clip(version, _VERSION_CHARS) if version else None,
|
||||||
"loaded_at": _epoch(record.get("loaded_at")),
|
"loaded_at": _epoch(record.get("loaded_at")),
|
||||||
|
"modes": _published_modes(record.get("modes")),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"schema": SNAPSHOT_SCHEMA,
|
"schema": SNAPSHOT_SCHEMA,
|
||||||
@@ -416,6 +429,21 @@ class PluginRuntimeView:
|
|||||||
"loaded_at": record.get("loaded_at"),
|
"loaded_at": record.get("loaded_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||||
|
"""The display modes the display registered for ``plugin_id``: what
|
||||||
|
it rotates and accepts on-demand, including modes a plugin computes
|
||||||
|
from its config. None unless the view is live and the plugin is
|
||||||
|
loaded with its modes registered -- the caller then falls back to
|
||||||
|
the manifest's ``display_modes``."""
|
||||||
|
if not self.live:
|
||||||
|
return None
|
||||||
|
record = self.plugins.get(plugin_id)
|
||||||
|
modes = record.get("modes") if isinstance(record, dict) else None
|
||||||
|
if not isinstance(modes, list):
|
||||||
|
return None
|
||||||
|
modes = [m for m in modes if isinstance(m, str)]
|
||||||
|
return modes or None
|
||||||
|
|
||||||
def describe(self) -> Dict[str, Any]:
|
def describe(self) -> Dict[str, Any]:
|
||||||
"""The view's own status, for a response to carry beside the facts."""
|
"""The view's own status, for a response to carry beside the facts."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -231,6 +231,26 @@ class PluginStateManager:
|
|||||||
}
|
}
|
||||||
self._note_change()
|
self._note_change()
|
||||||
|
|
||||||
|
def record_modes(self, plugin_id: str, modes: List[str]) -> None:
|
||||||
|
"""Record the display modes the display registered for ``plugin_id``.
|
||||||
|
|
||||||
|
Called by the DisplayController each time it registers the plugin.
|
||||||
|
These are the modes it actually rotates and accepts on-demand --
|
||||||
|
``plugin.modes`` when the plugin computes them (a soccer league the
|
||||||
|
user added under ``custom_leagues``), else the manifest's list -- and
|
||||||
|
the web interface has no other way to learn them (#668). Kept on the
|
||||||
|
loaded record, so an unload or a reload's fresh record_loaded()
|
||||||
|
forgets them until the plugin is registered again.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
loaded = self._loaded.get(plugin_id)
|
||||||
|
if loaded is None:
|
||||||
|
return
|
||||||
|
modes = [str(m) for m in modes]
|
||||||
|
if loaded.get('modes') != modes:
|
||||||
|
loaded['modes'] = modes
|
||||||
|
self._note_change()
|
||||||
|
|
||||||
def record_unloaded(self, plugin_id: str) -> None:
|
def record_unloaded(self, plugin_id: str) -> None:
|
||||||
"""Forget the loaded record alone, keeping state and error info: for
|
"""Forget the loaded record alone, keeping state and error info: for
|
||||||
an unload that failed after the instance was already dropped."""
|
an unload that failed after the instance was already dropped."""
|
||||||
@@ -243,7 +263,8 @@ class PluginStateManager:
|
|||||||
section so a concurrent load or unload is seen whole or not at all.
|
section so a concurrent load or unload is seen whole or not at all.
|
||||||
|
|
||||||
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
||||||
``version`` and ``loaded_at`` (None unless loaded) and ``error_info``
|
``version``, ``loaded_at`` and ``modes`` (None unless loaded; ``modes``
|
||||||
|
also None until the display registers it) and ``error_info``
|
||||||
(a copy, or None).
|
(a copy, or None).
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -257,6 +278,7 @@ class PluginStateManager:
|
|||||||
'loaded': loaded is not None,
|
'loaded': loaded is not None,
|
||||||
'version': loaded['version'] if loaded else None,
|
'version': loaded['version'] if loaded else None,
|
||||||
'loaded_at': loaded['loaded_at'] if loaded else None,
|
'loaded_at': loaded['loaded_at'] if loaded else None,
|
||||||
|
'modes': list(loaded['modes']) if loaded and 'modes' in loaded else None,
|
||||||
'error_info': dict(info) if info is not None else None,
|
'error_info': dict(info) if info is not None else None,
|
||||||
}
|
}
|
||||||
return records
|
return records
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -155,3 +155,14 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
|||||||
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
||||||
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
||||||
assert 'SEC123' not in json.dumps(body)
|
assert 'SEC123' not in json.dumps(body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnDemandUsesTheRegisteredSpelling:
|
||||||
|
def test_a_mode_differing_in_case_is_sent_as_registered(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3.display._deliver_on_demand',
|
||||||
|
return_value=('socket', None)) as deliver:
|
||||||
|
response = client.post('/api/v3/display/on-demand/start',
|
||||||
|
json={'plugin_id': 'football-scoreboard',
|
||||||
|
'mode': 'NFL_LIVE', 'start_service': False})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert deliver.call_args.args[0]['mode'] == 'nfl_live'
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""The web interface sees the display modes the display actually registered (#668).
|
||||||
|
|
||||||
|
A plugin may compute its modes from its config: soccer-scoreboard registers
|
||||||
|
``soccer_<league>_live/recent/upcoming`` for every league the user adds under
|
||||||
|
``custom_leagues``, and no manifest can list those ahead of time. The display
|
||||||
|
always rotated them -- DisplayController._register_loaded_plugin prefers
|
||||||
|
``plugin.modes`` -- but the web process reads plugins as files, so its mode
|
||||||
|
listing (/display/modes, the on-demand dialog) and find_plugin_for_mode
|
||||||
|
(/display/on-demand/start with a mode and no plugin_id) saw only manifests.
|
||||||
|
|
||||||
|
The display now records each plugin's registered modes in its plugin state,
|
||||||
|
the runtime snapshot carries them, and PluginCatalog prefers them while the
|
||||||
|
snapshot is live, falling back to the manifest when it is not.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.cache_manager import CacheManager # noqa: E402
|
||||||
|
from src.plugin_system import plugin_runtime as rt # noqa: E402
|
||||||
|
from src.plugin_system.plugin_catalog import PluginCatalog # noqa: E402
|
||||||
|
from src.plugin_system.plugin_runtime import ( # noqa: E402
|
||||||
|
PluginRuntimePublisher, build_runtime_snapshot, read_plugin_runtime,
|
||||||
|
view_from_snapshot,
|
||||||
|
)
|
||||||
|
from src.plugin_system.plugin_state import PluginState, PluginStateManager # noqa: E402
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
DECLARED = ["soccer_eng.1_live", "soccer_eng.1_recent", "soccer_eng.1_upcoming"]
|
||||||
|
CUSTOM = ["soccer_sco.1_live", "soccer_sco.1_recent", "soccer_sco.1_upcoming"]
|
||||||
|
REGISTERED = DECLARED + CUSTOM
|
||||||
|
|
||||||
|
|
||||||
|
def _loaded_states(modes=None):
|
||||||
|
states = PluginStateManager()
|
||||||
|
states.set_state("soccer-scoreboard", PluginState.ENABLED)
|
||||||
|
states.record_loaded("soccer-scoreboard", "2.24.1")
|
||||||
|
if modes is not None:
|
||||||
|
states.record_modes("soccer-scoreboard", modes)
|
||||||
|
return states
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_cache(tmp_path, monkeypatch):
|
||||||
|
"""Two cache managers over one directory: the display's and the web's."""
|
||||||
|
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir",
|
||||||
|
lambda self: str(tmp_path / "cache"))
|
||||||
|
(tmp_path / "cache").mkdir()
|
||||||
|
display_cache, web_cache = CacheManager(), CacheManager()
|
||||||
|
yield display_cache, web_cache
|
||||||
|
display_cache.stop_cleanup_thread()
|
||||||
|
web_cache.stop_cleanup_thread()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugins_dir(tmp_path):
|
||||||
|
root = tmp_path / "plugins"
|
||||||
|
for plugin_id, modes in (("soccer-scoreboard", DECLARED), ("clock-simple", ["clock"])):
|
||||||
|
(root / plugin_id).mkdir(parents=True)
|
||||||
|
(root / plugin_id / "manifest.json").write_text(json.dumps({
|
||||||
|
"id": plugin_id, "name": plugin_id, "version": "1.0.0",
|
||||||
|
"class_name": "P", "display_modes": modes}), encoding="utf-8")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
# --- The display records what it registered ---------------------------------
|
||||||
|
|
||||||
|
class TestStateManagerRecordsModes:
|
||||||
|
def test_runtime_records_carry_them(self):
|
||||||
|
assert _loaded_states(REGISTERED).runtime_records()[
|
||||||
|
"soccer-scoreboard"]["modes"] == REGISTERED
|
||||||
|
|
||||||
|
def test_none_until_registered(self):
|
||||||
|
assert _loaded_states().runtime_records()["soccer-scoreboard"]["modes"] is None
|
||||||
|
|
||||||
|
def test_a_new_list_is_a_change_the_same_one_is_not(self):
|
||||||
|
"""change_count drives the publisher: re-registering an unchanged
|
||||||
|
plugin must not cost an SD-card write."""
|
||||||
|
states = _loaded_states(DECLARED)
|
||||||
|
before = states.change_count
|
||||||
|
states.record_modes("soccer-scoreboard", list(DECLARED))
|
||||||
|
assert states.change_count == before
|
||||||
|
states.record_modes("soccer-scoreboard", REGISTERED)
|
||||||
|
assert states.change_count == before + 1
|
||||||
|
|
||||||
|
def test_ignored_for_a_plugin_that_is_not_loaded(self):
|
||||||
|
states = PluginStateManager()
|
||||||
|
states.record_modes("ghost", ["ghost"])
|
||||||
|
assert "ghost" not in states.runtime_records()
|
||||||
|
|
||||||
|
def test_unload_forgets_them(self):
|
||||||
|
states = _loaded_states(REGISTERED)
|
||||||
|
states.clear_state("soccer-scoreboard")
|
||||||
|
assert "soccer-scoreboard" not in states.runtime_records()
|
||||||
|
|
||||||
|
def test_a_reload_starts_without_them_until_registered_again(self):
|
||||||
|
states = _loaded_states(REGISTERED)
|
||||||
|
states.record_loaded("soccer-scoreboard", "2.25.0")
|
||||||
|
assert states.runtime_records()["soccer-scoreboard"]["modes"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestControllerRecordsOnRegistration:
|
||||||
|
def test_plugin_modes_reach_the_state_manager(self, test_display_controller):
|
||||||
|
"""_register_loaded_plugin is the one path every load, enable and
|
||||||
|
reload goes through."""
|
||||||
|
c = test_display_controller
|
||||||
|
states = _loaded_states()
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.modes = list(REGISTERED)
|
||||||
|
c.plugin_manager.state_manager = states
|
||||||
|
c.plugin_manager.get_plugin = MagicMock(return_value=plugin)
|
||||||
|
c.plugin_manager.plugin_manifests = {"soccer-scoreboard": {"display_modes": DECLARED}}
|
||||||
|
|
||||||
|
c._register_loaded_plugin("soccer-scoreboard")
|
||||||
|
|
||||||
|
assert states.runtime_records()["soccer-scoreboard"]["modes"] == REGISTERED
|
||||||
|
|
||||||
|
def test_a_failing_state_manager_does_not_break_registration(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.modes = ["clock"]
|
||||||
|
c.plugin_manager.state_manager.record_modes = MagicMock(side_effect=RuntimeError("x"))
|
||||||
|
c.plugin_manager.get_plugin = MagicMock(return_value=plugin)
|
||||||
|
c.plugin_manager.plugin_manifests = {}
|
||||||
|
|
||||||
|
assert c._register_loaded_plugin("clock-simple") == ["clock"]
|
||||||
|
assert c.mode_to_plugin_id["clock"] == "clock-simple"
|
||||||
|
|
||||||
|
|
||||||
|
# --- The snapshot carries them; only a live view reports them ---------------
|
||||||
|
|
||||||
|
class TestSnapshotAndView:
|
||||||
|
NOW = 1_800_000_000.0
|
||||||
|
|
||||||
|
def _view(self, states, running=True, published_at=None):
|
||||||
|
snapshot = build_runtime_snapshot(states, started_at=1.0, now=self.NOW,
|
||||||
|
running=running)
|
||||||
|
if published_at is not None:
|
||||||
|
snapshot["published_at"] = published_at
|
||||||
|
return view_from_snapshot(snapshot, now=self.NOW)
|
||||||
|
|
||||||
|
def test_live_view_reports_the_registered_modes(self):
|
||||||
|
assert self._view(_loaded_states(REGISTERED)).display_modes(
|
||||||
|
"soccer-scoreboard") == REGISTERED
|
||||||
|
|
||||||
|
def test_stale_and_stopped_views_report_nothing(self):
|
||||||
|
states = _loaded_states(REGISTERED)
|
||||||
|
assert self._view(states, published_at=self.NOW - 10_000).display_modes(
|
||||||
|
"soccer-scoreboard") is None
|
||||||
|
assert self._view(states, running=False).display_modes("soccer-scoreboard") is None
|
||||||
|
|
||||||
|
def test_unregistered_or_unknown_plugins_report_nothing(self):
|
||||||
|
view = self._view(_loaded_states())
|
||||||
|
assert view.display_modes("soccer-scoreboard") is None
|
||||||
|
assert view.display_modes("not-loaded") is None
|
||||||
|
|
||||||
|
def test_a_runaway_list_is_bounded(self):
|
||||||
|
modes = [f"m{i}" for i in range(1000)] + ["x" * 500]
|
||||||
|
snapshot = build_runtime_snapshot(_loaded_states(modes), started_at=1.0, now=self.NOW)
|
||||||
|
published = snapshot["plugins"]["soccer-scoreboard"]["modes"]
|
||||||
|
assert len(published) == rt._MAX_MODES
|
||||||
|
|
||||||
|
def test_a_mode_name_is_kept_whole_or_dropped(self):
|
||||||
|
long_mode = "x" * (rt._ID_CHARS + 1)
|
||||||
|
snapshot = build_runtime_snapshot(_loaded_states(["ok", long_mode]),
|
||||||
|
started_at=1.0, now=self.NOW)
|
||||||
|
assert snapshot["plugins"]["soccer-scoreboard"]["modes"] == ["ok"]
|
||||||
|
|
||||||
|
def test_non_strings_from_a_hand_made_snapshot_are_dropped(self):
|
||||||
|
snapshot = {"schema": rt.SNAPSHOT_SCHEMA, "running": True,
|
||||||
|
"published_at": self.NOW, "plugins": {
|
||||||
|
"p": {"loaded": True, "modes": ["a", 3, None]}}}
|
||||||
|
assert view_from_snapshot(snapshot, now=self.NOW).display_modes("p") == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- The web's catalog prefers them -------------------------------------------
|
||||||
|
|
||||||
|
class TestCatalog:
|
||||||
|
def _catalog(self, plugins_dir, web_cache):
|
||||||
|
catalog = PluginCatalog(plugins_dir,
|
||||||
|
runtime_source=lambda: read_plugin_runtime(web_cache))
|
||||||
|
catalog.discover_plugins()
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
def test_live_display_modes_win_over_the_manifest(self, plugins_dir, shared_cache):
|
||||||
|
display_cache, web_cache = shared_cache
|
||||||
|
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||||
|
catalog = self._catalog(plugins_dir, web_cache)
|
||||||
|
assert catalog.get_plugin_display_modes("soccer-scoreboard") == REGISTERED
|
||||||
|
|
||||||
|
def test_a_custom_league_mode_resolves_to_its_plugin(self, plugins_dir, shared_cache):
|
||||||
|
"""What /display/on-demand/start does with a mode and no plugin_id."""
|
||||||
|
display_cache, web_cache = shared_cache
|
||||||
|
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||||
|
catalog = self._catalog(plugins_dir, web_cache)
|
||||||
|
assert catalog.find_plugin_for_mode("SOCCER_SCO.1_LIVE") == "soccer-scoreboard"
|
||||||
|
|
||||||
|
def test_a_plugin_the_display_has_not_loaded_falls_back_to_its_manifest(
|
||||||
|
self, plugins_dir, shared_cache):
|
||||||
|
display_cache, web_cache = shared_cache
|
||||||
|
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||||
|
catalog = self._catalog(plugins_dir, web_cache)
|
||||||
|
assert catalog.get_plugin_display_modes("clock-simple") == ["clock"]
|
||||||
|
assert catalog.find_plugin_for_mode("clock") == "clock-simple"
|
||||||
|
|
||||||
|
def test_a_mode_the_display_dropped_does_not_resolve_by_manifest(
|
||||||
|
self, plugins_dir, shared_cache):
|
||||||
|
display_cache, web_cache = shared_cache
|
||||||
|
PluginRuntimePublisher(display_cache, _loaded_states(CUSTOM)).tick()
|
||||||
|
catalog = self._catalog(plugins_dir, web_cache)
|
||||||
|
assert catalog.find_plugin_for_mode("soccer_eng.1_live") is None
|
||||||
|
|
||||||
|
def test_a_stopped_display_falls_back_to_manifests(self, plugins_dir, shared_cache):
|
||||||
|
display_cache, web_cache = shared_cache
|
||||||
|
publisher = PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED))
|
||||||
|
publisher.tick()
|
||||||
|
publisher.stop()
|
||||||
|
catalog = self._catalog(plugins_dir, web_cache)
|
||||||
|
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||||
|
assert catalog.find_plugin_for_mode("soccer_sco.1_live") is None
|
||||||
|
|
||||||
|
def test_no_runtime_source_is_manifests_only(self, plugins_dir):
|
||||||
|
catalog = PluginCatalog(plugins_dir)
|
||||||
|
catalog.discover_plugins()
|
||||||
|
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||||
|
|
||||||
|
def test_a_failing_runtime_source_is_manifests_only(self, plugins_dir):
|
||||||
|
def broken():
|
||||||
|
raise OSError("cache gone")
|
||||||
|
catalog = PluginCatalog(plugins_dir, runtime_source=broken)
|
||||||
|
catalog.discover_plugins()
|
||||||
|
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||||
|
|
||||||
|
def test_one_listing_reads_the_view_once(self, plugins_dir):
|
||||||
|
source = MagicMock(return_value=None)
|
||||||
|
catalog = PluginCatalog(plugins_dir, runtime_source=source)
|
||||||
|
catalog.discover_plugins()
|
||||||
|
for _ in range(10):
|
||||||
|
catalog.get_plugin_display_modes("soccer-scoreboard")
|
||||||
|
catalog.find_plugin_for_mode("clock")
|
||||||
|
assert source.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayModesRoute:
|
||||||
|
def test_lists_the_custom_league_modes(self, api_v3_module, api_v3_client, # noqa: F811
|
||||||
|
plugins_dir, shared_cache):
|
||||||
|
display_cache, web_cache = shared_cache
|
||||||
|
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
api.plugin_catalog = PluginCatalog(
|
||||||
|
plugins_dir, runtime_source=lambda: read_plugin_runtime(web_cache))
|
||||||
|
api.config_manager.load_config = MagicMock(return_value={
|
||||||
|
"soccer-scoreboard": {"enabled": True}})
|
||||||
|
|
||||||
|
response = api_v3_client.get("/api/v3/display/modes")
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)
|
||||||
|
modes = {m["mode"]: m for m in response.get_json()["data"]["modes"]}
|
||||||
|
assert set(modes) == set(REGISTERED)
|
||||||
|
assert modes["soccer_sco.1_live"]["plugin_id"] == "soccer-scoreboard"
|
||||||
@@ -162,7 +162,7 @@ class TestPublisher:
|
|||||||
assert snapshot["stale_after"] == rt.STALE_AFTER
|
assert snapshot["stale_after"] == rt.STALE_AFTER
|
||||||
assert snapshot["plugins"] == {"clock": {
|
assert snapshot["plugins"] == {"clock": {
|
||||||
"loaded": True, "state": "enabled", "error": None,
|
"loaded": True, "state": "enabled", "error": None,
|
||||||
"version": "1.0.0", "loaded_at": 10.0}}
|
"version": "1.0.0", "loaded_at": 10.0, "modes": None}}
|
||||||
|
|
||||||
def test_changes_are_throttled_and_quiet_displays_refresh(self):
|
def test_changes_are_throttled_and_quiet_displays_refresh(self):
|
||||||
cache = MagicMock()
|
cache = MagicMock()
|
||||||
|
|||||||
@@ -159,10 +159,18 @@ schema_manager = SchemaManager(
|
|||||||
# saves reach the running plugins through the display's config watcher; what
|
# saves reach the running plugins through the display's config watcher; what
|
||||||
# the display knows at run time (health, metrics, errors, current mode) it
|
# the display knows at run time (health, metrics, errors, current mode) it
|
||||||
# publishes to the shared cache. See docs/ARCHITECTURE.md.
|
# publishes to the shared cache. See docs/ARCHITECTURE.md.
|
||||||
|
def _catalog_runtime_view():
|
||||||
|
"""The display's runtime view, for the catalog's mode lookups. Imported
|
||||||
|
on call, as the startup reconciliation below imports it."""
|
||||||
|
from web_interface.blueprints.api_v3 import _plugin_runtime_view
|
||||||
|
return _plugin_runtime_view()
|
||||||
|
|
||||||
|
|
||||||
plugin_catalog = PluginCatalog(
|
plugin_catalog = PluginCatalog(
|
||||||
plugins_dir=plugins_dir,
|
plugins_dir=plugins_dir,
|
||||||
config_manager=config_manager,
|
config_manager=config_manager,
|
||||||
schema_manager=schema_manager,
|
schema_manager=schema_manager,
|
||||||
|
runtime_source=_catalog_runtime_view,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize operation queue for plugin operations
|
# Initialize operation queue for plugin operations
|
||||||
|
|||||||
@@ -153,10 +153,12 @@ def get_display_modes():
|
|||||||
same list the force-display dialog offers, from the source that owns it.
|
same list the force-display dialog offers, from the source that owns it.
|
||||||
|
|
||||||
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
||||||
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
falls back to find_plugin_for_mode when plugin_id is omitted. While the
|
||||||
lookup only sees modes declared in a static manifest -- a plugin whose
|
display is running, both that lookup and this list use the modes it
|
||||||
modes are generated (each installed Starlark app is one) 404s there.
|
registered, so modes a plugin generates from its config (each installed
|
||||||
Sending the plugin_id from this list skips the lookup entirely.
|
Starlark app, each soccer custom league) are found (#668); with the
|
||||||
|
display stopped they see only what manifests declare. Sending the
|
||||||
|
plugin_id from this list skips the lookup entirely.
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
include_disabled: '1' to list modes of disabled plugins too. They can
|
include_disabled: '1' to list modes of disabled plugins too. They can
|
||||||
@@ -277,6 +279,15 @@ def start_on_demand_display():
|
|||||||
if not resolved_plugin:
|
if not resolved_plugin:
|
||||||
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
||||||
|
|
||||||
|
# The display matches mode names exactly: pass the registered spelling
|
||||||
|
# when the caller's differs only in case.
|
||||||
|
if api_v3.plugin_catalog and resolved_plugin and resolved_mode:
|
||||||
|
wanted = resolved_mode.strip().lower()
|
||||||
|
for registered in api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin):
|
||||||
|
if isinstance(registered, str) and registered.lower() == wanted:
|
||||||
|
resolved_mode = registered
|
||||||
|
break
|
||||||
|
|
||||||
# On-demand works with disabled plugins: the running display loads one
|
# On-demand works with disabled plugins: the running display loads one
|
||||||
# for the session and unloads it afterwards, leaving config.json alone
|
# for the session and unloads it afterwards, leaving config.json alone
|
||||||
# (DisplayController._load_plugin_for_on_demand). Logged for debugging.
|
# (DisplayController._load_plugin_for_on_demand). Logged for debugging.
|
||||||
|
|||||||
@@ -150,8 +150,9 @@ def get_installed_plugins():
|
|||||||
vegas_participation, vegas_participation_source = _vegas_participation(
|
vegas_participation, vegas_participation_source = _vegas_participation(
|
||||||
plugin_id, plugin_config, plugin_info)
|
plugin_id, plugin_config, plugin_info)
|
||||||
|
|
||||||
# The modes the manifest declares, from the catalog as /display/modes
|
# The plugin's modes, from the catalog as /display/modes and
|
||||||
# and on-demand/start read them. The on-demand modal offers these;
|
# on-demand/start read them: what the running display registered,
|
||||||
|
# else what the manifest declares. The on-demand modal offers these;
|
||||||
# without them it offered only the plugin id, which the display
|
# without them it offered only the plugin id, which the display
|
||||||
# turns into the first mode. Strings only: a manifest is hand-edited.
|
# turns into the first mode. Strings only: a manifest is hand-edited.
|
||||||
declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)
|
declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user