mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 15:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42455f6c4d | ||
|
|
4411bb5760 | ||
|
|
6c533d62af | ||
|
|
cb06124b42 | ||
|
|
7c5fa9cfdb | ||
|
|
ec117a35a1 | ||
|
|
e4f5e49ff7 |
+28
-9
@@ -19,15 +19,15 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- Web API error responses no longer carry an exception's message (CodeQL
|
### Tooling
|
||||||
`py/stack-trace-exposure`). `describe_exception()` now returns a reason
|
|
||||||
code -- the exception type, plus the errno for an `OSError`
|
- `test/test_sports_helpers.py`'s parity tests pass again with
|
||||||
(`OSError:EIO`, `PermissionError:EACCES`) -- and logs the message
|
`LEDMATRIX_PLUGINS` set. The scoreboards deleted their copies of the
|
||||||
instead, so `details` still names the fault without quoting paths, URLs
|
`sports_helpers` bodies and constants when they adopted `SportsHelpersMixin`
|
||||||
or library internals. The display service status and the on-demand
|
(ledmatrix-plugins #563/#564), and the 19 tests still expected them. A copy
|
||||||
start/stop `service` results keep `active`, `returncode` and `started`
|
that is gone now counts as adopted when the plugin imports
|
||||||
but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and
|
`src.common.sports_helpers`, as the stage 3/4 and game-over parity tests
|
||||||
config-save failures say what failed and point at the log.
|
already do; a copy that remains must still match.
|
||||||
|
|
||||||
## 3.8.2
|
## 3.8.2
|
||||||
|
|
||||||
@@ -1284,6 +1284,25 @@ policies are unchanged.
|
|||||||
a runtime publisher that stops still goes `stale`, and a subscription that
|
a runtime publisher that stops still goes `stale`, and a subscription that
|
||||||
goes quiet still falls back to the cache. The cache path's 120 s rule is
|
goes quiet still falls back to the cache. The cache path's 120 s rule is
|
||||||
unchanged.
|
unchanged.
|
||||||
|
- A plugin that pauses the Vegas scroll gets its pause when its display
|
||||||
|
duration is not a plain number. Several plugins (clock-simple, calendar,
|
||||||
|
countdown) return `display_duration` as it is in config.json, so a value
|
||||||
|
saved as `"20"` or `null` (the raw config editor, a hand edit) reached the
|
||||||
|
pause as a string or None; comparing it with the clock raised, and the
|
||||||
|
plugin flashed up and the scroll went straight on, at every one of its
|
||||||
|
turns. `inf` held the pause until something interrupted it, and 0, a
|
||||||
|
negative number or NaN ended it at once. The pause now reads the duration
|
||||||
|
as the rotation does (`finite_seconds()` in `base_plugin`): a numeric
|
||||||
|
string counts, anything else that is not a finite number (or a
|
||||||
|
`get_display_duration()` that raises) pauses for 30 s, and a number at or
|
||||||
|
below zero for 15 s, with one warning per plugin.
|
||||||
|
- Reinstalling Weather, Music, Stocks or Leaderboard from the Plugin Store
|
||||||
|
while it is enabled asks for a display restart, as reinstalling any other
|
||||||
|
enabled plugin does. `POST /api/v3/plugins/install` looked for the
|
||||||
|
plugin's `enabled` flag under the store id (`weather`), but its config
|
||||||
|
section is under the id its manifest declares (`ledmatrix-weather`), so
|
||||||
|
`restart_required` was always false and the display kept running the
|
||||||
|
copy it had loaded. The check now uses the installed id.
|
||||||
|
|
||||||
### Scrolling
|
### Scrolling
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+10
-1
@@ -91,7 +91,16 @@ if [ -r "$LM_OS_RELEASE_FILE" ]; then
|
|||||||
DESKTOP_DETECTED=0
|
DESKTOP_DETECTED=0
|
||||||
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
|
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
|
||||||
# and pipefail turns a found desktop into "not found".
|
# and pipefail turns a found desktop into "not found".
|
||||||
if dpkg -l | grep -E "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde" >/dev/null; then
|
# Desktop metapackages and session managers, matched as whole installed
|
||||||
|
# package names: an unanchored ".*kde" matched libblockdev-* ("bloc-kde-v"),
|
||||||
|
# and a "gnome" prefix matched standalone parts such as gnome-keyring.
|
||||||
|
# Trixie replaced raspberrypi-ui-mods with the rpd-*-core metapackages.
|
||||||
|
DESKTOP_PACKAGES='raspberrypi-ui-mods|rpd-wayland-core|rpd-x-core'
|
||||||
|
DESKTOP_PACKAGES+='|lxde|lxde-core|lxsession|xfce4|xfce4-session'
|
||||||
|
DESKTOP_PACKAGES+='|gnome-shell|gnome-session|kde-plasma-desktop|plasma-desktop'
|
||||||
|
DESKTOP_PACKAGES+='|plasma-workspace|task-desktop|task-[a-z0-9]+-desktop'
|
||||||
|
if dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' 2>/dev/null \
|
||||||
|
| grep -E "^ii +(${DESKTOP_PACKAGES})(:[a-z0-9]+)?$" >/dev/null; then
|
||||||
DESKTOP_DETECTED=1
|
DESKTOP_DETECTED=1
|
||||||
fi
|
fi
|
||||||
if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then
|
if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from src.common.fetch_service import (
|
|||||||
plugin_scope,
|
plugin_scope,
|
||||||
share_connection_pool,
|
share_connection_pool,
|
||||||
)
|
)
|
||||||
|
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
|
||||||
from src.common.espn_dates import (
|
from src.common.espn_dates import (
|
||||||
RANGE_RETRY_SECONDS,
|
RANGE_RETRY_SECONDS,
|
||||||
_note_range_rejected,
|
_note_range_rejected,
|
||||||
@@ -83,6 +84,10 @@ class FetchRequest:
|
|||||||
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
||||||
# fetch that did, in fact, succeed.
|
# fetch that did, in fact, succeed.
|
||||||
commit_claimed: bool = False
|
commit_claimed: bool = False
|
||||||
|
# Trim an ESPN scoreboard response before it is cached and delivered
|
||||||
|
# (src/common/espn_payload.py). Set by whoever created the request; a
|
||||||
|
# submitter that joins the fetch gets the same payload.
|
||||||
|
slim_payload: bool = True
|
||||||
result: Optional[Any] = None
|
result: Optional[Any] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
# The plugin that submitted the request, so the fetch service counts the
|
# The plugin that submitted the request, so the fetch service counts the
|
||||||
@@ -249,7 +254,8 @@ class BackgroundDataService:
|
|||||||
timeout: Optional[int] = None,
|
timeout: Optional[int] = None,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
priority: int = 1,
|
priority: int = 1,
|
||||||
callback: Optional[Callable] = None) -> str:
|
callback: Optional[Callable] = None,
|
||||||
|
slim_payload: bool = True) -> str:
|
||||||
"""
|
"""
|
||||||
Submit a background fetch request.
|
Submit a background fetch request.
|
||||||
|
|
||||||
@@ -265,6 +271,11 @@ class BackgroundDataService:
|
|||||||
priority: Accepted for compatibility and ignored; requests run in
|
priority: Accepted for compatibility and ignored; requests run in
|
||||||
submission order.
|
submission order.
|
||||||
callback: Optional callback function when request completes
|
callback: Optional callback function when request completes
|
||||||
|
slim_payload: Drop the parts of an ESPN scoreboard response no
|
||||||
|
scoreboard reads (stat leaders, athlete cards, links,
|
||||||
|
headlines, highlights) before caching it; see
|
||||||
|
src/common/espn_payload.py. Only ESPN /scoreboard URLs are
|
||||||
|
touched. Pass False to cache the response whole.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Request ID for tracking the fetch operation
|
Request ID for tracking the fetch operation
|
||||||
@@ -336,6 +347,7 @@ class BackgroundDataService:
|
|||||||
priority=priority,
|
priority=priority,
|
||||||
callback=callback,
|
callback=callback,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
slim_payload=slim_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -497,6 +509,13 @@ class BackgroundDataService:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Most of an ESPN scoreboard response is never drawn, and the
|
||||||
|
# cached copy stays parsed in the memory tier while it is fresh.
|
||||||
|
# Trimmed before the write so the cache, request.result and the
|
||||||
|
# callbacks all see the same payload. See src/common/espn_payload.py.
|
||||||
|
if request.slim_payload and is_espn_scoreboard_url(request.url):
|
||||||
|
slim_scoreboard_payload(data)
|
||||||
|
|
||||||
# Cache the data
|
# Cache the data
|
||||||
self.cache_manager.set(request.cache_key, data)
|
self.cache_manager.set(request.cache_key, data)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Rules for the package:
|
|||||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||||
|
| [`espn_payload`](#espn_payload) | Drop the parts of an ESPN scoreboard payload no scoreboard reads | No, core-internal (used by `BackgroundDataService`) | n/a |
|
||||||
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||||
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
|
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
|
||||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||||
@@ -120,6 +121,18 @@ Every request goes through [`fetch_service`](#fetch_service), the chunks
|
|||||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||||
for older cores.
|
for older cores.
|
||||||
|
|
||||||
|
### espn_payload
|
||||||
|
|
||||||
|
[`espn_payload.py`](espn_payload.py). Core-internal. ESPN scoreboard
|
||||||
|
responses carry stat leaders, athlete cards, links, headlines and highlights
|
||||||
|
that no scoreboard draws. `slim_scoreboard_payload(payload)` removes exactly
|
||||||
|
those keys, in place, and leaves everything it does not know about alone;
|
||||||
|
`is_espn_scoreboard_url(url)` says whether a URL is an ESPN site-API
|
||||||
|
scoreboard. `BackgroundDataService` slims each scoreboard window before
|
||||||
|
caching it, which cuts the five sports windows from ~40MB to ~12MB of parsed
|
||||||
|
objects. Adding a key to the drop lists means first checking that nothing
|
||||||
|
reads it.
|
||||||
|
|
||||||
### favorite_team_check
|
### favorite_team_check
|
||||||
|
|
||||||
[`favorite_team_check.py`](favorite_team_check.py).
|
[`favorite_team_check.py`](favorite_team_check.py).
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Drop the parts of an ESPN scoreboard payload no scoreboard reads.
|
||||||
|
|
||||||
|
The sports scoreboards cache their Recent/Upcoming window (14 days back, 7
|
||||||
|
ahead) as the raw ESPN response, and that record stays parsed in the memory
|
||||||
|
cache for as long as it is fresh. Most of it is never drawn. Measured on hdpi
|
||||||
|
(2026-10-02) the MLB window was 3.35MB of JSON and 13.5MB of Python objects,
|
||||||
|
and the five windows together ~40MB, mostly in:
|
||||||
|
|
||||||
|
* ``competitors[].leaders`` / ``competitions[].leaders`` -- per-team and
|
||||||
|
per-game stat leaders (28% of the MLB window)
|
||||||
|
* ``competitors[].team.links`` / ``event.links`` -- web and app URLs
|
||||||
|
* ``status.featuredAthletes`` and ``competitors[].probables`` -- athlete
|
||||||
|
cards with headshots and season stats
|
||||||
|
* ``competitions[].headlines`` / ``highlights`` -- article and video blurbs
|
||||||
|
(28% of the college-football window)
|
||||||
|
* ``competitions[].geoBroadcasts``
|
||||||
|
|
||||||
|
None of those keys is read by core or by any plugin in ledmatrix-plugins
|
||||||
|
(checked 2026-10-02 across every scoreboard, the odds ticker and the
|
||||||
|
leaderboard), while everything that is read -- odds, records, linescores,
|
||||||
|
situation, statistics, notes, broadcasts, venue -- is kept. Dropping them
|
||||||
|
takes the five windows from ~40MB to ~12MB of parsed objects and the files from
|
||||||
|
10.6MB to 3.0MB, so the reads that parse an expired window on the render
|
||||||
|
thread get 3-4x cheaper too.
|
||||||
|
|
||||||
|
:func:`slim_scoreboard_payload` changes the payload in place, and only ever
|
||||||
|
removes the keys listed here: anything it does not know about is left alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
# Per level of the payload, the keys removed. Kept deliberately explicit:
|
||||||
|
# adding a key here means checking that nothing reads it first.
|
||||||
|
_EVENT_DROP = ("links",)
|
||||||
|
_COMPETITION_DROP = ("leaders", "headlines", "highlights", "geoBroadcasts")
|
||||||
|
_STATUS_DROP = ("featuredAthletes",)
|
||||||
|
_COMPETITOR_DROP = ("leaders", "probables")
|
||||||
|
_TEAM_DROP = ("links",)
|
||||||
|
|
||||||
|
|
||||||
|
def is_espn_scoreboard_url(url: Any) -> bool:
|
||||||
|
"""Whether ``url`` is an ESPN site-API scoreboard endpoint."""
|
||||||
|
if not isinstance(url, str):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
host = (parts.hostname or "").lower()
|
||||||
|
if host != "espn.com" and not host.endswith(".espn.com"):
|
||||||
|
return False
|
||||||
|
return parts.path.rstrip("/").endswith("/scoreboard")
|
||||||
|
|
||||||
|
|
||||||
|
def _drop(obj: Any, keys) -> None:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for key in keys:
|
||||||
|
obj.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def slim_scoreboard_payload(payload: Any) -> Any:
|
||||||
|
"""Remove the unread parts of an ESPN scoreboard payload, in place.
|
||||||
|
|
||||||
|
Returns ``payload`` for convenience. Anything that is not shaped like a
|
||||||
|
scoreboard (not a dict, no ``events`` list, odd entries) is passed over
|
||||||
|
untouched rather than raising.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
events = payload.get("events")
|
||||||
|
if not isinstance(events, list):
|
||||||
|
return payload
|
||||||
|
for event in events:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
continue
|
||||||
|
_drop(event, _EVENT_DROP)
|
||||||
|
competitions = event.get("competitions")
|
||||||
|
if not isinstance(competitions, list):
|
||||||
|
continue
|
||||||
|
for competition in competitions:
|
||||||
|
if not isinstance(competition, dict):
|
||||||
|
continue
|
||||||
|
_drop(competition, _COMPETITION_DROP)
|
||||||
|
_drop(competition.get("status"), _STATUS_DROP)
|
||||||
|
competitors = competition.get("competitors")
|
||||||
|
if not isinstance(competitors, list):
|
||||||
|
continue
|
||||||
|
for competitor in competitors:
|
||||||
|
if not isinstance(competitor, dict):
|
||||||
|
continue
|
||||||
|
_drop(competitor, _COMPETITOR_DROP)
|
||||||
|
_drop(competitor.get("team"), _TEAM_DROP)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["is_espn_scoreboard_url", "slim_scoreboard_payload"]
|
||||||
+11
-15
@@ -25,7 +25,6 @@ import os
|
|||||||
import inspect
|
import inspect
|
||||||
import signal
|
import signal
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -64,6 +63,7 @@ from src.ipc.contract import (
|
|||||||
PluginReloadResult,
|
PluginReloadResult,
|
||||||
)
|
)
|
||||||
from src.ipc.server import ControlServer, QueuedCommand, StateHub, start_control_server
|
from src.ipc.server import ControlServer, QueuedCommand, StateHub, start_control_server
|
||||||
|
from src.plugin_system.base_plugin import finite_seconds
|
||||||
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
|
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
|
||||||
|
|
||||||
# Get logger with consistent configuration
|
# Get logger with consistent configuration
|
||||||
@@ -101,19 +101,6 @@ _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0
|
|||||||
DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||||
|
|
||||||
|
|
||||||
def _finite_seconds(value: Any) -> Optional[float]:
|
|
||||||
"""``value`` as seconds when it is a finite number or a numeric string,
|
|
||||||
else None. A bool is not a number here, though it is an int: True would
|
|
||||||
read as a one-second screen."""
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
seconds = float(value)
|
|
||||||
except (TypeError, ValueError, OverflowError):
|
|
||||||
return None
|
|
||||||
return seconds if math.isfinite(seconds) else None
|
|
||||||
|
|
||||||
|
|
||||||
class _PluginReloadJob:
|
class _PluginReloadJob:
|
||||||
"""A ``plugin.reload`` whose slow half runs off the render thread.
|
"""A ``plugin.reload`` whose slow half runs off the render thread.
|
||||||
|
|
||||||
@@ -1569,7 +1556,7 @@ class DisplayController:
|
|||||||
except Exception as err: # pylint: disable=broad-except
|
except Exception as err: # pylint: disable=broad-except
|
||||||
problem = f"get_display_duration() raised {type(err).__name__}: {err}"
|
problem = f"get_display_duration() raised {type(err).__name__}: {err}"
|
||||||
else:
|
else:
|
||||||
seconds = _finite_seconds(value)
|
seconds = finite_seconds(value)
|
||||||
if seconds is not None:
|
if seconds is not None:
|
||||||
return seconds
|
return seconds
|
||||||
problem = f"display duration {value!r} is not a number"
|
problem = f"display duration {value!r} is not a number"
|
||||||
@@ -4624,6 +4611,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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Stability: Stable - maintains backward compatibility
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from src.deprecation import deprecated, warn_deprecated
|
from src.deprecation import deprecated, warn_deprecated
|
||||||
@@ -240,6 +241,26 @@ def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) ->
|
|||||||
return legacy_vegas_participation(plugin)
|
return legacy_vegas_participation(plugin)
|
||||||
|
|
||||||
|
|
||||||
|
def finite_seconds(value: Any) -> Optional[float]:
|
||||||
|
"""``value`` as seconds when it is a finite number or a numeric string,
|
||||||
|
else None. A bool is not a number here, though it is an int: True would
|
||||||
|
read as a one-second screen.
|
||||||
|
|
||||||
|
How the core reads a plugin's get_display_duration() -- the rotation
|
||||||
|
(DisplayController._get_display_duration) and the Vegas static pause --
|
||||||
|
which several plugins answer straight from config.json, so a value saved
|
||||||
|
as "20" or null arrives as a string or None. A number at or below zero is
|
||||||
|
returned as it is; each caller has its own rule for that.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
seconds = float(value)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
return seconds if math.isfinite(seconds) else None
|
||||||
|
|
||||||
|
|
||||||
class BasePlugin(ABC):
|
class BasePlugin(ABC):
|
||||||
"""
|
"""
|
||||||
Base class that all plugins must inherit from.
|
Base class that all plugins must inherit from.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import math
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
from typing import Optional, Dict, Any, FrozenSet, List, Callable, TYPE_CHECKING
|
||||||
|
|
||||||
from src import display_watchdog
|
from src import display_watchdog
|
||||||
from src.common import render_gate
|
from src.common import render_gate
|
||||||
|
from src.plugin_system.base_plugin import finite_seconds
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
from src.vegas_mode.config import VegasModeConfig
|
||||||
from src.vegas_mode.elements import LiveEpochs
|
from src.vegas_mode.elements import LiveEpochs
|
||||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||||
@@ -53,6 +54,14 @@ _FPS_HEARTBEAT_INTERVAL = 300.0
|
|||||||
#: every plugin. Game state doesn't change within a quarter second.
|
#: every plugin. Game state doesn't change within a quarter second.
|
||||||
_LIVE_PRIORITY_CHECK_INTERVAL = 0.25
|
_LIVE_PRIORITY_CHECK_INTERVAL = 0.25
|
||||||
|
|
||||||
|
#: Seconds a static pause shows a plugin whose display duration can't be
|
||||||
|
#: used, as long as the rotation shows it: 30 when get_display_duration()
|
||||||
|
#: raises or answers something that is not a number
|
||||||
|
#: (DisplayController._get_display_duration), 15 when it answers a number at
|
||||||
|
#: or below zero (DisplayController._resolve_durations).
|
||||||
|
_UNREADABLE_DURATION = 30.0
|
||||||
|
_NOT_POSITIVE_DURATION = 15.0
|
||||||
|
|
||||||
|
|
||||||
def _percentile(ordered: List[float], fraction: float) -> float:
|
def _percentile(ordered: List[float], fraction: float) -> float:
|
||||||
"""Nearest-rank percentile of an already-sorted list.
|
"""Nearest-rank percentile of an already-sorted list.
|
||||||
@@ -92,6 +101,9 @@ class VegasModeCoordinator:
|
|||||||
_live_reason: Optional[str] = None
|
_live_reason: Optional[str] = None
|
||||||
# Set only while Vegas has changed the GIL switch interval; read with getattr.
|
# Set only while Vegas has changed the GIL switch interval; read with getattr.
|
||||||
_saved_switch_interval: Optional[float]
|
_saved_switch_interval: Optional[float]
|
||||||
|
#: Plugins already warned about a display duration the pause can't use,
|
||||||
|
#: so a bad setting logs once, not at every turn. Replaced, not mutated.
|
||||||
|
_duration_warned: FrozenSet[str] = frozenset()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1010,7 +1022,7 @@ class VegasModeCoordinator:
|
|||||||
# Wait for the plugin's display duration. Monotonic, like the
|
# Wait for the plugin's display duration. Monotonic, like the
|
||||||
# iteration clock: an NTP step on an RTC-less Pi would otherwise
|
# iteration clock: an NTP step on an RTC-less Pi would otherwise
|
||||||
# end the pause at once or stretch it by the correction.
|
# end the pause at once or stretch it by the correction.
|
||||||
duration = plugin.get_display_duration()
|
duration = self._static_pause_duration(plugin)
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
|
||||||
while time.monotonic() - start < duration:
|
while time.monotonic() - start < duration:
|
||||||
@@ -1046,6 +1058,42 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _static_pause_duration(self, plugin: 'BasePlugin') -> float:
|
||||||
|
"""Seconds a static pause shows ``plugin``: its display duration,
|
||||||
|
read the way the rotation reads it.
|
||||||
|
|
||||||
|
Several plugins return their display_duration setting straight from
|
||||||
|
config.json, so one saved as "20" or null came back as a string or
|
||||||
|
None; comparing it with the clock raised, and the pause's broad
|
||||||
|
except ended the pause at every one of the plugin's turns. inf
|
||||||
|
paused until something interrupted it, and NaN, False, 0 or a
|
||||||
|
negative number ended the pause at once. A numeric string counts
|
||||||
|
(finite_seconds); anything else, or a raise, gets
|
||||||
|
_UNREADABLE_DURATION, and a number at or below zero
|
||||||
|
_NOT_POSITIVE_DURATION, logged once per plugin.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = plugin.get_display_duration()
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
problem = f"get_display_duration() raised {type(err).__name__}: {err}"
|
||||||
|
fallback = _UNREADABLE_DURATION
|
||||||
|
else:
|
||||||
|
seconds = finite_seconds(value)
|
||||||
|
if seconds is not None and seconds > 0:
|
||||||
|
return seconds
|
||||||
|
if seconds is None:
|
||||||
|
problem = f"display duration {value!r} is not a number"
|
||||||
|
fallback = _UNREADABLE_DURATION
|
||||||
|
else:
|
||||||
|
problem = f"display duration {value!r} is not above zero"
|
||||||
|
fallback = _NOT_POSITIVE_DURATION
|
||||||
|
plugin_id = plugin.plugin_id
|
||||||
|
if plugin_id not in self._duration_warned:
|
||||||
|
self._duration_warned = self._duration_warned | {plugin_id}
|
||||||
|
logger.warning("[%s] %s; its static pause lasts %.0fs (logged once)",
|
||||||
|
plugin_id, problem, fallback)
|
||||||
|
return fallback
|
||||||
|
|
||||||
def _end_static_pause(self) -> None:
|
def _end_static_pause(self) -> None:
|
||||||
"""End static pause and restore scroll state."""
|
"""End static pause and restore scroll state."""
|
||||||
should_resume_scrolling = False
|
should_resume_scrolling = False
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Centralized error handling for web interface.
|
|||||||
Provides helpers for consistent error responses across API endpoints.
|
Provides helpers for consistent error responses across API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import errno
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
@@ -21,9 +20,10 @@ logger = get_logger(__name__)
|
|||||||
_MAX_DETAIL_LENGTH = 400
|
_MAX_DETAIL_LENGTH = 400
|
||||||
|
|
||||||
|
|
||||||
def describe_exception(exc: BaseException) -> str:
|
def describe_exception(exc: BaseException,
|
||||||
|
max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||||
"""
|
"""
|
||||||
Machine-readable reason code for an exception, safe to return over HTTP.
|
One-line, safe-to-return description of an exception.
|
||||||
|
|
||||||
The generic "an error occurred; see logs for details" tells a user nothing
|
The generic "an error occurred; see logs for details" tells a user nothing
|
||||||
and, when the failure is bad enough, the logs are unreachable too: a device
|
and, when the failure is bad enough, the logs are unreachable too: a device
|
||||||
@@ -31,24 +31,20 @@ def describe_exception(exc: BaseException) -> str:
|
|||||||
*including* the log viewer, because journalctl could not be executed. The
|
*including* the log viewer, because journalctl could not be executed. The
|
||||||
underlying `[Errno 5] Input/output error` named the fault immediately.
|
underlying `[Errno 5] Input/output error` named the fault immediately.
|
||||||
|
|
||||||
So the type and errno still go back -- "OSError:EIO", "PermissionError:
|
Returns "TypeName: message", credentials redacted and length capped. The
|
||||||
EACCES", "TimeoutExpired" -- but never the exception's message, which can
|
type alone is worth carrying -- a bare PermissionError says more than any
|
||||||
quote paths, URLs, credentials or a library's internals (CodeQL
|
generic sentence.
|
||||||
py/stack-trace-exposure). The message is logged here instead, so every
|
|
||||||
reason code a client sees has its full text in the log.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
exc: The exception to describe
|
exc: The exception to describe
|
||||||
|
max_length: Truncate beyond this many characters
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
"TypeName" or "TypeName:ERRNO", never empty
|
A single-line description, never empty
|
||||||
"""
|
"""
|
||||||
code = type(exc).__name__
|
message = str(exc).strip()
|
||||||
exc_errno = getattr(exc, 'errno', None)
|
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
||||||
if isinstance(exc_errno, int) and exc_errno in errno.errorcode:
|
return redact_text(text, max_length)
|
||||||
code = f"{code}:{errno.errorcode[exc_errno]}"
|
|
||||||
logger.warning("Error reported to the client as %s: %s", code, redact_text(str(exc)))
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||||
|
|||||||
+9
-9
@@ -1369,7 +1369,7 @@ class WiFiManager:
|
|||||||
self.enable_ap_mode(force=True)
|
self.enable_ap_mode(force=True)
|
||||||
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
||||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||||
return False, f"Connection failed ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
|
|
||||||
def _failsafe_ap(self, enabled_msg: str, failed_msg: str) -> Tuple[bool, str]:
|
def _failsafe_ap(self, enabled_msg: str, failed_msg: str) -> Tuple[bool, str]:
|
||||||
"""Force the setup AP up after a connect that left no working network,
|
"""Force the setup AP up after a connect that left no working network,
|
||||||
@@ -1585,7 +1585,7 @@ class WiFiManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error connecting with nmcli: {e}")
|
logger.error(f"Error connecting with nmcli: {e}")
|
||||||
self._show_led_message("Connection error", duration=5)
|
self._show_led_message("Connection error", duration=5)
|
||||||
return False, f"Connection failed ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
|
|
||||||
# 802.11 caps an SSID at 32 octets. Control characters cannot appear in a
|
# 802.11 caps an SSID at 32 octets. Control characters cannot appear in a
|
||||||
# real one, and a leading "-" would be read by nmcli as an option rather
|
# real one, and a leading "-" would be read by nmcli as an option rather
|
||||||
@@ -1725,7 +1725,7 @@ class WiFiManager:
|
|||||||
return False, "nmcli is required to disconnect from WiFi"
|
return False, "nmcli is required to disconnect from WiFi"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error disconnecting from WiFi: {e}")
|
logger.error(f"Error disconnecting from WiFi: {e}")
|
||||||
return False, f"Disconnect failed ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
|
|
||||||
def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool:
|
def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -2004,7 +2004,7 @@ class WiFiManager:
|
|||||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in enable_ap_mode: {e}")
|
logger.error(f"Error in enable_ap_mode: {e}")
|
||||||
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
|
|
||||||
def _mark_forced(self) -> None:
|
def _mark_forced(self) -> None:
|
||||||
"""Record that AP mode was forced on, so the periodic check leaves it
|
"""Record that AP mode was forced on, so the periodic check leaves it
|
||||||
@@ -2099,10 +2099,10 @@ class WiFiManager:
|
|||||||
return True, "AP mode enabled"
|
return True, "AP mode enabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting AP services: {e}")
|
logger.error(f"Error starting AP services: {e}")
|
||||||
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error enabling AP mode: {e}")
|
logger.error(f"Error enabling AP mode: {e}")
|
||||||
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
|
|
||||||
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
@@ -2227,7 +2227,7 @@ class WiFiManager:
|
|||||||
logger.error(f"Error starting AP mode with nmcli: {e}")
|
logger.error(f"Error starting AP mode with nmcli: {e}")
|
||||||
self._remove_nm_dnsmasq_captive_conf()
|
self._remove_nm_dnsmasq_captive_conf()
|
||||||
self._show_led_message("Setup mode error", duration=5)
|
self._show_led_message("Setup mode error", duration=5)
|
||||||
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
|
|
||||||
def _get_ap_status_nmcli(self) -> Dict:
|
def _get_ap_status_nmcli(self) -> Dict:
|
||||||
"""
|
"""
|
||||||
@@ -2409,10 +2409,10 @@ class WiFiManager:
|
|||||||
return True, "AP mode disabled"
|
return True, "AP mode disabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping AP services: {e}")
|
logger.error(f"Error stopping AP services: {e}")
|
||||||
return False, f"Could not disable AP mode ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error disabling AP mode: {e}")
|
logger.error(f"Error disabling AP mode: {e}")
|
||||||
return False, f"Could not disable AP mode ({type(e).__name__}); see logs for details"
|
return False, str(e)
|
||||||
|
|
||||||
def _create_hostapd_config(self):
|
def _create_hostapd_config(self):
|
||||||
"""Create hostapd configuration file"""
|
"""Create hostapd configuration file"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -147,8 +147,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
|||||||
side_effect=RuntimeError("disk is gone"))
|
side_effect=RuntimeError("disk is gone"))
|
||||||
resp = api_v3_client.get('/api/v3/display/modes')
|
resp = api_v3_client.get('/api/v3/display/modes')
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
assert resp.get_json()['details'] == 'RuntimeError'
|
assert 'disk is gone' in resp.get_json()['details']
|
||||||
assert 'disk is gone' not in json.dumps(resp.get_json())
|
|
||||||
|
|
||||||
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
||||||
"""describe_exception is what makes returning detail safe."""
|
"""describe_exception is what makes returning detail safe."""
|
||||||
@@ -156,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,123 @@
|
|||||||
|
"""POST /plugins/install asks for a restart by the id the plugin installed as.
|
||||||
|
|
||||||
|
A store install needs a display restart when config.json already enables the
|
||||||
|
plugin (a reinstall, or a config carried over): the display loads a plugin
|
||||||
|
when its ``enabled`` flag changes, and this flag did not. The route read the
|
||||||
|
flag under the registry id it was given. An aliased entry installs under
|
||||||
|
another id -- ``weather`` installs a directory whose manifest declares
|
||||||
|
``ledmatrix-weather``, and its config section is ``ledmatrix-weather`` -- so
|
||||||
|
reinstalling an enabled Weather never reported that a restart was needed,
|
||||||
|
and the display kept running the old copy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||||
|
|
||||||
|
INSTALL = "/api/v3/plugins/install"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(api_v3_module, tmp_path):
|
||||||
|
"""The store installs registry entry ``weather`` as ``installed_id``."""
|
||||||
|
manager = api_v3_module.api_v3.plugin_store_manager
|
||||||
|
manager.install_plugin.return_value = True
|
||||||
|
manager.get_registry_info.return_value = None
|
||||||
|
manager._find_plugin_path.return_value = None
|
||||||
|
|
||||||
|
def installs_as(installed_id):
|
||||||
|
path = tmp_path / installed_id
|
||||||
|
path.mkdir()
|
||||||
|
(path / "manifest.json").write_text(json.dumps({"id": installed_id}),
|
||||||
|
encoding="utf-8")
|
||||||
|
manager._find_plugin_path.side_effect = (
|
||||||
|
lambda pid: path if pid == "weather" else None)
|
||||||
|
|
||||||
|
manager.installs_as = installs_as
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(api_v3_module):
|
||||||
|
"""config.json with an ``enabled`` flag for each plugin id given."""
|
||||||
|
def sections(enabled):
|
||||||
|
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||||
|
plugin_id: {"enabled": flag} for plugin_id, flag in enabled.items()}
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queued(api_v3_module):
|
||||||
|
queue = MagicMock()
|
||||||
|
|
||||||
|
def enqueue(operation_type, plugin_id, operation_callback=None):
|
||||||
|
queue.callback_result = operation_callback(MagicMock())
|
||||||
|
return "op-1"
|
||||||
|
|
||||||
|
queue.enqueue_operation.side_effect = enqueue
|
||||||
|
api_v3_module.api_v3.operation_queue = queue
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
def _direct(client):
|
||||||
|
return client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _queued(client, queue):
|
||||||
|
client.post(INSTALL, json={"plugin_id": "weather"})
|
||||||
|
return queue.callback_result
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectInstall:
|
||||||
|
def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart(
|
||||||
|
self, api_v3_client, store, config):
|
||||||
|
store.installs_as("ledmatrix-weather")
|
||||||
|
config({"ledmatrix-weather": True})
|
||||||
|
body = _direct(api_v3_client)
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert body["restart_required"] is True
|
||||||
|
assert body["restart_message"]
|
||||||
|
|
||||||
|
def test_an_enabled_section_under_the_registry_id_alone_does_not(
|
||||||
|
self, api_v3_client, store, config):
|
||||||
|
"""The display knows the plugin as ledmatrix-weather; nothing runs
|
||||||
|
under a section called weather."""
|
||||||
|
store.installs_as("ledmatrix-weather")
|
||||||
|
config({"weather": True})
|
||||||
|
assert _direct(api_v3_client)["restart_required"] is False
|
||||||
|
|
||||||
|
def test_an_aliased_install_that_is_not_enabled_needs_no_restart(
|
||||||
|
self, api_v3_client, store, config):
|
||||||
|
store.installs_as("ledmatrix-weather")
|
||||||
|
config({"ledmatrix-weather": False})
|
||||||
|
assert _direct(api_v3_client)["restart_required"] is False
|
||||||
|
|
||||||
|
def test_an_install_under_its_own_id_is_unchanged(self, api_v3_client, store, config):
|
||||||
|
store.installs_as("weather")
|
||||||
|
config({"weather": True})
|
||||||
|
assert _direct(api_v3_client)["restart_required"] is True
|
||||||
|
|
||||||
|
def test_an_install_that_cannot_be_found_uses_the_requested_id(
|
||||||
|
self, api_v3_client, store, config):
|
||||||
|
config({"weather": True})
|
||||||
|
assert _direct(api_v3_client)["restart_required"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueuedInstall:
|
||||||
|
def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart(
|
||||||
|
self, api_v3_client, store, config, queued):
|
||||||
|
store.installs_as("ledmatrix-weather")
|
||||||
|
config({"ledmatrix-weather": True})
|
||||||
|
result = _queued(api_v3_client, queued)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["restart_required"] is True
|
||||||
|
assert result["restart_message"]
|
||||||
|
|
||||||
|
def test_an_enabled_section_under_the_registry_id_alone_does_not(
|
||||||
|
self, api_v3_client, store, config, queued):
|
||||||
|
store.installs_as("ledmatrix-weather")
|
||||||
|
config({"weather": True})
|
||||||
|
assert _queued(api_v3_client, queued)["restart_required"] is False
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""No API response carries an exception's message (CodeQL py/stack-trace-exposure).
|
|
||||||
|
|
||||||
One representative route per file that had open alerts. Each forces a failure
|
|
||||||
whose message holds a marker and asserts the marker is nowhere in the body:
|
|
||||||
the message goes to the log, the client gets a fixed message plus a reason
|
|
||||||
code (describe_exception: the type, and the errno for an OSError).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
||||||
|
|
||||||
LEAK = "LEAKED-/home/pi/secret token=abc123"
|
|
||||||
API = "web_interface.blueprints.api_v3"
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_leak(response):
|
|
||||||
body = response.get_data(as_text=True)
|
|
||||||
assert "LEAKED" not in body, body
|
|
||||||
assert "abc123" not in body, body
|
|
||||||
return json.loads(body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_display_service_status_drops_systemctl_output(api_v3_module, api_v3_client,
|
|
||||||
monkeypatch):
|
|
||||||
"""display.py: the on-demand routes return the service status verbatim."""
|
|
||||||
api_v3_module.api_v3.cache_manager.get.return_value = None
|
|
||||||
monkeypatch.setattr(f"{API}.display.display_state.read_state", lambda: None)
|
|
||||||
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
|
|
||||||
body = _assert_no_leak(api_v3_client.get("/api/v3/display/on-demand/status"))
|
|
||||||
assert body["data"]["service"] == {"active": False, "returncode": -1}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("helper", ["_ensure_display_service_running",
|
|
||||||
"_stop_display_service"])
|
|
||||||
def test_service_results_keep_returncode_but_not_output(api_v3_module, helper):
|
|
||||||
"""display.py start/stop: returncode/active/started stay, stdout/stderr go."""
|
|
||||||
failed = MagicMock(returncode=1, stdout=LEAK, stderr=LEAK)
|
|
||||||
with patch(f"{API}.subprocess.run", return_value=failed):
|
|
||||||
result = getattr(api_v3_module, helper)()
|
|
||||||
assert "LEAKED" not in json.dumps(result)
|
|
||||||
assert result["returncode"] == 1 and result["active"] is False
|
|
||||||
assert "stdout" not in result and "stderr" not in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_wifi_connect_failure(api_v3_client):
|
|
||||||
"""wifi.py: a raising connect, and the attempt /wifi/status reports after."""
|
|
||||||
with patch("src.wifi_manager.WiFiManager") as cls:
|
|
||||||
cls.return_value._is_ap_mode_active.return_value = False
|
|
||||||
cls.return_value.connect_to_network.side_effect = RuntimeError(LEAK)
|
|
||||||
body = _assert_no_leak(api_v3_client.post(
|
|
||||||
"/api/v3/wifi/connect", json={"ssid": "HomeNet", "password": "pw"}))
|
|
||||||
assert body["details"] == "RuntimeError"
|
|
||||||
cls.return_value.get_wifi_status.return_value = MagicMock(
|
|
||||||
connected=False, ssid=None, ip_address=None, signal=0, ap_mode_active=False)
|
|
||||||
cls.return_value.config = {}
|
|
||||||
status = _assert_no_leak(api_v3_client.get("/api/v3/wifi/status"))
|
|
||||||
assert status["data"]["last_connect_attempt"]["message"] == (
|
|
||||||
"Failed to connect to network (RuntimeError)")
|
|
||||||
|
|
||||||
|
|
||||||
def test_wifi_manager_messages_carry_no_exception_text():
|
|
||||||
"""src/wifi_manager.py: its (success, message) is what the wifi routes return."""
|
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
manager = WiFiManager.__new__(WiFiManager) # no __init__: no host access
|
|
||||||
manager.get_wifi_status = MagicMock(side_effect=OSError(5, LEAK))
|
|
||||||
success, message = manager.disconnect_from_network()
|
|
||||||
assert success is False
|
|
||||||
assert "LEAKED" not in message and "OSError" in message
|
|
||||||
|
|
||||||
|
|
||||||
def test_system_action_exception(api_v3_client):
|
|
||||||
"""system.py: execute_system_action's catch-all."""
|
|
||||||
with patch("subprocess.run", side_effect=OSError(5, LEAK)):
|
|
||||||
body = _assert_no_leak(api_v3_client.post(
|
|
||||||
"/api/v3/system/action", json={"action": "stop_display"}))
|
|
||||||
assert body["details"] == "OSError:EIO"
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_registration_failure(api_v3_client, tmp_path, monkeypatch):
|
|
||||||
"""plugin_calendar.py: the auth script could not be run."""
|
|
||||||
plugin_dir = tmp_path / "calendar"
|
|
||||||
plugin_dir.mkdir()
|
|
||||||
(plugin_dir / "credentials.json").write_text("{}", encoding="utf-8")
|
|
||||||
(plugin_dir / "calendar_registration.py").write_text("", encoding="utf-8")
|
|
||||||
monkeypatch.setattr(f"{API}._calendar_plugin_dir", lambda: plugin_dir)
|
|
||||||
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
|
|
||||||
body = _assert_no_leak(api_v3_client.post(
|
|
||||||
"/api/v3/plugins/calendar/authenticate", json={"code": "x"}))
|
|
||||||
assert "EACCES" in body["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_failure(api_v3_client, monkeypatch):
|
|
||||||
"""misc.py: get_health's catch-all."""
|
|
||||||
def boom():
|
|
||||||
raise RuntimeError(LEAK)
|
|
||||||
monkeypatch.setattr(f"{API}.misc._get_display_service_status", boom)
|
|
||||||
body = _assert_no_leak(api_v3_client.get("/api/v3/health"))
|
|
||||||
assert body["details"] == "RuntimeError"
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_route_failure(api_v3_module, api_v3_client):
|
|
||||||
"""error_handler.py: create_error_response, as config.py's routes use it."""
|
|
||||||
api_v3_module.api_v3.config_manager.load_config.side_effect = RuntimeError(LEAK)
|
|
||||||
body = _assert_no_leak(api_v3_client.get("/api/v3/config/schedule"))
|
|
||||||
assert body["details"] == "RuntimeError"
|
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_route_failure(api_v3_module, api_v3_client):
|
|
||||||
"""plugins.py: an unhandled error in a plugin route."""
|
|
||||||
api_v3_module.api_v3.plugin_catalog.get_all_plugin_info.side_effect = RuntimeError(LEAK)
|
|
||||||
body = _assert_no_leak(api_v3_client.get("/api/v3/plugins/installed"))
|
|
||||||
assert body["details"] == "RuntimeError"
|
|
||||||
|
|
||||||
|
|
||||||
def test_starlark_route_failure(api_v3_client):
|
|
||||||
"""starlark.py: one of its catch-alls."""
|
|
||||||
with patch(f"{API}._get_starlark_plugin", side_effect=RuntimeError(LEAK)):
|
|
||||||
body = _assert_no_leak(api_v3_client.get("/api/v3/starlark/status"))
|
|
||||||
assert body["details"] == "RuntimeError"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unit_refresh_failure(monkeypatch):
|
|
||||||
"""system.py git_pull: perform_core_update appends unit_refresh's message."""
|
|
||||||
from web_interface import unit_refresh
|
|
||||||
|
|
||||||
def boom(*_a, **_k):
|
|
||||||
raise RuntimeError(LEAK)
|
|
||||||
monkeypatch.setattr(unit_refresh, "stale_units", boom)
|
|
||||||
result = unit_refresh.refresh_after_update()
|
|
||||||
assert result["status"] == unit_refresh.FAILED
|
|
||||||
assert "LEAKED" not in result["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_base_requirements_failure(api_v3_client):
|
|
||||||
"""system.py: a pip install that could not start, in the action's output."""
|
|
||||||
with patch(f"{API}.system._pip_install_requirements", side_effect=OSError(5, LEAK)):
|
|
||||||
body = _assert_no_leak(api_v3_client.post(
|
|
||||||
"/api/v3/system/action", json={"action": "install_base_requirements"}))
|
|
||||||
assert "Failed: OSError:EIO" in body["output"]
|
|
||||||
@@ -95,10 +95,9 @@ class TestRefreshPluginStore:
|
|||||||
RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42"))
|
RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42"))
|
||||||
body = api_v3_client.post(self.URL, json={}).get_json()
|
body = api_v3_client.post(self.URL, json={}).get_json()
|
||||||
assert "Traceback" not in str(body)
|
assert "Traceback" not in str(body)
|
||||||
# `details` is describe_exception output: the type, never the
|
# `details` is describe_exception output: one line, type-named,
|
||||||
# message or a stack.
|
# credential-redacted. It may quote the message, but never a stack.
|
||||||
assert body["details"] == "RuntimeError"
|
assert body["details"].startswith("RuntimeError:")
|
||||||
assert "secret.py" not in str(body)
|
|
||||||
assert "\n" not in body["details"]
|
assert "\n" not in body["details"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Tests for src/common/espn_payload.py and its use by BackgroundDataService."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.background_data_service import BackgroundDataService, shutdown_background_service
|
||||||
|
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
|
||||||
|
|
||||||
|
SCOREBOARD = "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard"
|
||||||
|
|
||||||
|
|
||||||
|
def _event():
|
||||||
|
"""One event carrying every key the slimming drops and a sample of the
|
||||||
|
keys scoreboards read, at the depth ESPN puts them."""
|
||||||
|
competitor = {
|
||||||
|
"id": "10",
|
||||||
|
"homeAway": "home",
|
||||||
|
"score": "5",
|
||||||
|
"team": {"abbreviation": "NYY", "logo": "https://a/l.png",
|
||||||
|
"links": [{"href": "https://espn.com/team"}]},
|
||||||
|
"records": [{"summary": "90-60"}],
|
||||||
|
"linescores": [{"value": 1}],
|
||||||
|
"statistics": [{"name": "hits", "displayValue": "9"}],
|
||||||
|
"leaders": [{"name": "avg", "leaders": [{"athlete": {"id": "1"}}]}],
|
||||||
|
"probables": [{"athlete": {"id": "2"}, "statistics": []}],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"id": "401",
|
||||||
|
"date": "2026-10-01T23:05Z",
|
||||||
|
"links": [{"href": "https://espn.com/game"}],
|
||||||
|
"status": {"type": {"state": "post"}},
|
||||||
|
"competitions": [{
|
||||||
|
"status": {"type": {"state": "post", "shortDetail": "Final"},
|
||||||
|
"featuredAthletes": [{"athlete": {"id": "3"}}]},
|
||||||
|
"competitors": [competitor, dict(copy.deepcopy(competitor), homeAway="away")],
|
||||||
|
"odds": [{"details": "NYY -150", "overUnder": 8.5}],
|
||||||
|
"situation": {"outs": 2},
|
||||||
|
"notes": [{"headline": "Game 1"}],
|
||||||
|
"broadcasts": [{"names": ["FOX"]}],
|
||||||
|
"venue": {"fullName": "Yankee Stadium"},
|
||||||
|
"leaders": [{"name": "hits"}],
|
||||||
|
"headlines": [{"description": "recap"}],
|
||||||
|
"highlights": [{"links": {"source": {}}}],
|
||||||
|
"geoBroadcasts": [{"media": {"shortName": "FOX"}}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlimScoreboardPayload:
|
||||||
|
def test_drops_exactly_the_listed_keys(self):
|
||||||
|
payload = {"leagues": [{"id": "10"}], "events": [_event()]}
|
||||||
|
slim_scoreboard_payload(payload)
|
||||||
|
event = payload["events"][0]
|
||||||
|
competition = event["competitions"][0]
|
||||||
|
assert "links" not in event
|
||||||
|
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
|
||||||
|
assert key not in competition
|
||||||
|
assert "featuredAthletes" not in competition["status"]
|
||||||
|
for competitor in competition["competitors"]:
|
||||||
|
assert "leaders" not in competitor
|
||||||
|
assert "probables" not in competitor
|
||||||
|
assert "links" not in competitor["team"]
|
||||||
|
|
||||||
|
def test_keeps_everything_else_unchanged(self):
|
||||||
|
"""Removing the dropped keys from the original by hand gives exactly
|
||||||
|
the slimmed payload: nothing else moved, changed or went missing."""
|
||||||
|
original = {"leagues": [{"id": "10"}], "events": [_event(), _event()]}
|
||||||
|
expected = copy.deepcopy(original)
|
||||||
|
for event in expected["events"]:
|
||||||
|
del event["links"]
|
||||||
|
competition = event["competitions"][0]
|
||||||
|
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
|
||||||
|
del competition[key]
|
||||||
|
del competition["status"]["featuredAthletes"]
|
||||||
|
for competitor in competition["competitors"]:
|
||||||
|
del competitor["leaders"], competitor["probables"]
|
||||||
|
del competitor["team"]["links"]
|
||||||
|
assert slim_scoreboard_payload(original) == expected
|
||||||
|
|
||||||
|
def test_in_place_and_returns_payload(self):
|
||||||
|
payload = {"events": [_event()]}
|
||||||
|
assert slim_scoreboard_payload(payload) is payload
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [
|
||||||
|
None, [], "x", {}, {"events": None}, {"events": "x"},
|
||||||
|
{"events": [None, 1, "x", {"competitions": None}]},
|
||||||
|
{"events": [{"competitions": [None, {"status": None, "competitors": None}]}]},
|
||||||
|
{"events": [{"competitions": [{"competitors": [None, {"team": None}]}]}]},
|
||||||
|
])
|
||||||
|
def test_odd_shapes_pass_through(self, payload):
|
||||||
|
before = copy.deepcopy(payload)
|
||||||
|
assert slim_scoreboard_payload(payload) == before
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsEspnScoreboardUrl:
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
SCOREBOARD,
|
||||||
|
SCOREBOARD + "/",
|
||||||
|
"http://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard",
|
||||||
|
])
|
||||||
|
def test_scoreboards(self, url):
|
||||||
|
assert is_espn_scoreboard_url(url)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
None, "", 12,
|
||||||
|
"https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams",
|
||||||
|
"https://site.api.espn.com/apis/site/v2/sports/football/nfl/summary",
|
||||||
|
"https://example.com/scoreboard",
|
||||||
|
"https://espn.com.evil.example/apis/x/scoreboard",
|
||||||
|
"https://notespn.com/apis/x/scoreboard",
|
||||||
|
])
|
||||||
|
def test_not_scoreboards(self, url):
|
||||||
|
assert not is_espn_scoreboard_url(url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
shutdown_background_service()
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.return_value = None
|
||||||
|
svc = BackgroundDataService(cache, max_workers=1, request_timeout=5)
|
||||||
|
yield svc
|
||||||
|
svc.shutdown(wait=False)
|
||||||
|
shutdown_background_service()
|
||||||
|
|
||||||
|
|
||||||
|
def _run(service, url, **kwargs):
|
||||||
|
response = Mock(status_code=200)
|
||||||
|
response.json.return_value = {"events": [_event()]}
|
||||||
|
response.raise_for_status.return_value = None
|
||||||
|
delivered = []
|
||||||
|
with patch.object(service.session, "get", return_value=response):
|
||||||
|
req_id = service.submit_fetch_request(
|
||||||
|
sport="mlb", year=2026, url=url, cache_key="mlb_schedule_window_14_7",
|
||||||
|
callback=lambda result: delivered.append(result.data), **kwargs)
|
||||||
|
deadline = time.time() + 5
|
||||||
|
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
cached = service.cache_manager.set.call_args[0][1]
|
||||||
|
return cached, delivered
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackgroundServiceSlims:
|
||||||
|
def test_espn_scoreboard_is_cached_and_delivered_slimmed(self, service):
|
||||||
|
cached, delivered = _run(service, SCOREBOARD)
|
||||||
|
competition = cached["events"][0]["competitions"][0]
|
||||||
|
assert "leaders" not in competition
|
||||||
|
assert "probables" not in competition["competitors"][0]
|
||||||
|
assert competition["odds"] and competition["situation"]
|
||||||
|
# The callback sees the very payload that was cached.
|
||||||
|
assert delivered and delivered[0] is cached
|
||||||
|
|
||||||
|
def test_opt_out_caches_whole_response(self, service):
|
||||||
|
cached, _ = _run(service, SCOREBOARD, slim_payload=False)
|
||||||
|
assert cached == {"events": [_event()]}
|
||||||
|
|
||||||
|
def test_other_urls_untouched(self, service):
|
||||||
|
cached, _ = _run(service, "https://example.com/feed")
|
||||||
|
assert cached == {"events": [_event()]}
|
||||||
@@ -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()
|
||||||
|
|||||||
+42
-12
@@ -8,7 +8,9 @@ loses those tests with it.
|
|||||||
The parity class is what keeps "byte-identical" true after this lands. Point
|
The parity class is what keeps "byte-identical" true after this lands. Point
|
||||||
LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every promoted body is
|
LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every promoted body is
|
||||||
compared, as a docstring-stripped AST, against every plugin copy that carries
|
compared, as a docstring-stripped AST, against every plugin copy that carries
|
||||||
it. Without the variable it skips rather than fails, since core CI has no
|
it. A copy that is gone counts as adopted when the plugin imports
|
||||||
|
src.common.sports_helpers (plugins#563/#564 did that for every scoreboard).
|
||||||
|
Without the variable it skips rather than fails, since core CI has no
|
||||||
plugins checkout; ledmatrix-plugins CI runs the same comparison against core
|
plugins checkout; ledmatrix-plugins CI runs the same comparison against core
|
||||||
(scripts/check_sports_helpers_parity.py, ledmatrix-plugins#495).
|
(scripts/check_sports_helpers_parity.py, ledmatrix-plugins#495).
|
||||||
"""
|
"""
|
||||||
@@ -572,6 +574,24 @@ def _core_definitions():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sports_source(root, sport):
|
||||||
|
return (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _adopted(source):
|
||||||
|
"""Gone is fine once the plugin uses the module; otherwise the finder is
|
||||||
|
not seeing its copy."""
|
||||||
|
name = sports_helpers.__name__
|
||||||
|
for node in ast.walk(ast.parse(source)):
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
if node.module == name or any(
|
||||||
|
f"{node.module}.{a.name}" == name for a in node.names):
|
||||||
|
return True
|
||||||
|
elif isinstance(node, ast.Import) and any(a.name == name for a in node.names):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class TestParityWithPlugins:
|
class TestParityWithPlugins:
|
||||||
@pytest.mark.parametrize("name", sorted(PROMOTED))
|
@pytest.mark.parametrize("name", sorted(PROMOTED))
|
||||||
def test_body_matches_every_plugin_copy(self, name):
|
def test_body_matches_every_plugin_copy(self, name):
|
||||||
@@ -580,11 +600,11 @@ class TestParityWithPlugins:
|
|||||||
ours = _dump(_core_definitions()[name])
|
ours = _dump(_core_definitions()[name])
|
||||||
drifted, missing = [], []
|
drifted, missing = [], []
|
||||||
for sport in carriers:
|
for sport in carriers:
|
||||||
defs = _definitions(ast.parse(
|
source = _sports_source(root, sport)
|
||||||
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")))
|
theirs = _definitions(ast.parse(source))[where].get(plugin_name)
|
||||||
theirs = defs[where].get(plugin_name)
|
|
||||||
if theirs is None:
|
if theirs is None:
|
||||||
missing.append(sport)
|
if not _adopted(source):
|
||||||
|
missing.append(sport)
|
||||||
elif _dump(theirs) != ours:
|
elif _dump(theirs) != ours:
|
||||||
drifted.append(sport)
|
drifted.append(sport)
|
||||||
assert missing == [], f"{plugin_name} no longer in: {missing}"
|
assert missing == [], f"{plugin_name} no longer in: {missing}"
|
||||||
@@ -594,10 +614,20 @@ class TestParityWithPlugins:
|
|||||||
|
|
||||||
@pytest.mark.parametrize("sport", SCOREBOARDS)
|
@pytest.mark.parametrize("sport", SCOREBOARDS)
|
||||||
def test_constants_match(self, sport):
|
def test_constants_match(self, sport):
|
||||||
root = _plugins_root()
|
source = _sports_source(_plugins_root(), sport)
|
||||||
defs = _definitions(ast.parse(
|
defs = _definitions(ast.parse(source))
|
||||||
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")))
|
expected = {
|
||||||
assert ast.literal_eval(defs["module"]["_MIN_WINDOW_DAYS"].value) == MIN_WINDOW_DAYS
|
("module", "_MIN_WINDOW_DAYS"): MIN_WINDOW_DAYS,
|
||||||
assert ast.literal_eval(defs["module"]["_MAX_WINDOW_DAYS"].value) == MAX_WINDOW_DAYS
|
("module", "_MAX_WINDOW_DAYS"): MAX_WINDOW_DAYS,
|
||||||
gap = defs["SportsCore"]["_DWELL_REENTRY_GAP_SECONDS"].value
|
("SportsCore", "_DWELL_REENTRY_GAP_SECONDS"):
|
||||||
assert math.isclose(ast.literal_eval(gap), SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS)
|
SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS,
|
||||||
|
}
|
||||||
|
missing = []
|
||||||
|
for (where, name), value in expected.items():
|
||||||
|
node = defs[where].get(name)
|
||||||
|
if node is None:
|
||||||
|
if not _adopted(source):
|
||||||
|
missing.append(name)
|
||||||
|
else:
|
||||||
|
assert math.isclose(ast.literal_eval(node.value), value), name
|
||||||
|
assert missing == [], f"not found in {sport}: {missing}"
|
||||||
|
|||||||
@@ -219,7 +219,8 @@ class TestCoordinatorStaticPause:
|
|||||||
def _plugin(self):
|
def _plugin(self):
|
||||||
plugin = MagicMock()
|
plugin = MagicMock()
|
||||||
plugin.plugin_id = 'clock'
|
plugin.plugin_id = 'clock'
|
||||||
plugin.get_display_duration.return_value = 0
|
# A moment: zero would pause 15 s, as the rotation shows it.
|
||||||
|
plugin.get_display_duration.return_value = 0.01
|
||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
def test_trigger_comes_from_the_pipeline(self):
|
def test_trigger_comes_from_the_pipeline(self):
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""A Vegas static pause lasts as long as the rotation shows the plugin.
|
||||||
|
|
||||||
|
The pause asked the plugin for get_display_duration() and compared the
|
||||||
|
answer with the clock. Several plugins (clock-simple, calendar, countdown)
|
||||||
|
return their display_duration setting as it is in config.json, so one saved
|
||||||
|
as "20" or null -- the raw config editor, a hand edit -- reached that
|
||||||
|
comparison as a string or None. The TypeError went to the pause's broad
|
||||||
|
except, which ended the pause: the plugin flashed up and the scroll went on,
|
||||||
|
at every one of its turns. inf paused until something interrupted it, and
|
||||||
|
NaN, False, 0 or a negative number ended the pause at once.
|
||||||
|
|
||||||
|
The pause now reads the answer the way the rotation does since #739, with
|
||||||
|
the same helper (base_plugin.finite_seconds): a numeric string counts;
|
||||||
|
anything else that is not a finite number, or a raise, gets the rotation's
|
||||||
|
30 s; a number at or below zero gets its 15 s.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("EMULATOR", "true")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.vegas_mode import coordinator
|
||||||
|
|
||||||
|
NOT_NUMBERS = [None, '', 'twenty', True, False, float('nan'), float('inf'),
|
||||||
|
'inf', '1e400', [20], {'seconds': 20}]
|
||||||
|
NOT_ABOVE_ZERO = [0, -5, '-5', '0']
|
||||||
|
NUMBERS = [('20', 20.0), (' 7.5 ', 7.5), (12, 12.0), (12.5, 12.5)]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
"""time.monotonic/time.sleep for the pause loop: sleeping moves the clock."""
|
||||||
|
|
||||||
|
#: A pause still going after this long never ends (inf did that).
|
||||||
|
LIMIT = 3600.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 0.0
|
||||||
|
|
||||||
|
def monotonic(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def sleep(self, seconds):
|
||||||
|
self.now += seconds
|
||||||
|
if self.now > self.LIMIT:
|
||||||
|
raise RuntimeError("the static pause never ended")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clock(monkeypatch):
|
||||||
|
fake = FakeClock()
|
||||||
|
monkeypatch.setattr(coordinator, 'time', fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin(duration, plugin_id='clock-simple'):
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.plugin_id = plugin_id
|
||||||
|
plugin.get_display_duration.return_value = duration
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
def _coord(*plugins):
|
||||||
|
coord = coordinator.VegasModeCoordinator.__new__(coordinator.VegasModeCoordinator)
|
||||||
|
coord.render_pipeline = MagicMock()
|
||||||
|
coord.render_pipeline.get_scroll_position.return_value = 0
|
||||||
|
coord.display_manager = MagicMock()
|
||||||
|
locks = {plugin.plugin_id: threading.Lock() for plugin in plugins}
|
||||||
|
coord.plugin_manager = SimpleNamespace(get_plugin_lock=locks.__getitem__)
|
||||||
|
coord._state_lock = threading.Lock()
|
||||||
|
coord._static_pause_active = False
|
||||||
|
coord._saved_scroll_position = None
|
||||||
|
coord._should_stop = False
|
||||||
|
coord._live_priority_active = False
|
||||||
|
coord._live_priority_check = None
|
||||||
|
coord._interrupt_check = None
|
||||||
|
coord.stats = {'static_pauses': 0}
|
||||||
|
return coord
|
||||||
|
|
||||||
|
|
||||||
|
def _pause(coord, plugin, clock):
|
||||||
|
"""One static pause: (whether it completed, how long it lasted)."""
|
||||||
|
start = clock.now
|
||||||
|
completed = coord._handle_static_pause(plugin)
|
||||||
|
return completed, clock.now - start
|
||||||
|
|
||||||
|
|
||||||
|
class TestPauseLength:
|
||||||
|
@pytest.mark.parametrize('value, seconds', NUMBERS)
|
||||||
|
def test_numbers_and_numeric_strings_are_used(self, clock, value, seconds):
|
||||||
|
plugin = _plugin(value)
|
||||||
|
completed, lasted = _pause(_coord(plugin), plugin, clock)
|
||||||
|
assert completed is True
|
||||||
|
assert lasted == pytest.approx(seconds, abs=0.15)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', NOT_NUMBERS, ids=repr)
|
||||||
|
def test_anything_but_a_finite_number_pauses_for_30s(self, clock, value):
|
||||||
|
plugin = _plugin(value)
|
||||||
|
completed, lasted = _pause(_coord(plugin), plugin, clock)
|
||||||
|
assert completed is True
|
||||||
|
assert lasted == pytest.approx(30.0, abs=0.15)
|
||||||
|
plugin.display.assert_called_once_with(force_clear=True)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', NOT_ABOVE_ZERO, ids=repr)
|
||||||
|
def test_a_number_not_above_zero_pauses_for_15s(self, clock, value):
|
||||||
|
plugin = _plugin(value)
|
||||||
|
completed, lasted = _pause(_coord(plugin), plugin, clock)
|
||||||
|
assert completed is True
|
||||||
|
assert lasted == pytest.approx(15.0, abs=0.15)
|
||||||
|
|
||||||
|
def test_a_raising_get_display_duration_pauses_for_30s(self, clock):
|
||||||
|
plugin = _plugin(None)
|
||||||
|
plugin.get_display_duration.side_effect = KeyError('display_duration')
|
||||||
|
completed, lasted = _pause(_coord(plugin), plugin, clock)
|
||||||
|
assert completed is True
|
||||||
|
assert lasted == pytest.approx(30.0, abs=0.15)
|
||||||
|
|
||||||
|
def test_a_good_value_after_a_bad_one_is_used(self, clock):
|
||||||
|
plugin = _plugin(None)
|
||||||
|
coord = _coord(plugin)
|
||||||
|
assert _pause(coord, plugin, clock)[1] == pytest.approx(30.0, abs=0.15)
|
||||||
|
plugin.get_display_duration.return_value = 45
|
||||||
|
assert _pause(coord, plugin, clock)[1] == pytest.approx(45.0, abs=0.15)
|
||||||
|
|
||||||
|
def test_the_pause_can_still_be_interrupted(self, clock):
|
||||||
|
plugin = _plugin('twenty')
|
||||||
|
coord = _coord(plugin)
|
||||||
|
coord._interrupt_check = lambda: clock.now >= 5
|
||||||
|
completed, lasted = _pause(coord, plugin, clock)
|
||||||
|
assert completed is False
|
||||||
|
assert lasted == pytest.approx(5.0, abs=0.15)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWarning:
|
||||||
|
def test_logged_once_per_plugin(self, clock, caplog):
|
||||||
|
clock_plugin = _plugin('twenty')
|
||||||
|
calendar = _plugin(None, plugin_id='calendar')
|
||||||
|
coord = _coord(clock_plugin, calendar)
|
||||||
|
with caplog.at_level(logging.WARNING, logger='src.vegas_mode.coordinator'):
|
||||||
|
for _ in range(3):
|
||||||
|
for plugin in (clock_plugin, calendar):
|
||||||
|
coord._handle_static_pause(plugin)
|
||||||
|
warnings = [r.getMessage() for r in caplog.records
|
||||||
|
if 'display duration' in r.getMessage()]
|
||||||
|
assert len(warnings) == 2
|
||||||
|
assert any('clock-simple' in m and "'twenty'" in m for m in warnings)
|
||||||
|
assert any('calendar' in m and 'None' in m for m in warnings)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFiniteSeconds:
|
||||||
|
"""The shared rule: what counts as a number of seconds."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value, seconds', NUMBERS + [(0, 0.0), ('-5', -5.0)])
|
||||||
|
def test_numbers_and_numeric_strings(self, value, seconds):
|
||||||
|
from src.plugin_system.base_plugin import finite_seconds
|
||||||
|
result = finite_seconds(value)
|
||||||
|
assert result == seconds and isinstance(result, float)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', NOT_NUMBERS + [pytest.param(10 ** 400, id='10**400')],
|
||||||
|
ids=repr)
|
||||||
|
def test_anything_else_is_none(self, value):
|
||||||
|
from src.plugin_system.base_plugin import finite_seconds
|
||||||
|
assert finite_seconds(value) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _rotation_seconds(plugin):
|
||||||
|
"""How long the rotation shows ``plugin`` (no dynamic duration, no
|
||||||
|
Rotation & Durations override): the two calls run() makes for a screen.
|
||||||
|
"""
|
||||||
|
from src.display_controller import DisplayController
|
||||||
|
dc = object.__new__(DisplayController)
|
||||||
|
dc.config = {}
|
||||||
|
dc.plugin_modes = {'mode': plugin}
|
||||||
|
return dc._resolve_durations(plugin, 'mode', dc._get_display_duration('mode'), False)[1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSameAsTheRotation:
|
||||||
|
"""The pause and the rotation share finite_seconds; this pins their
|
||||||
|
fallbacks (30 s, 15 s) to each other too."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [value for value, _ in NUMBERS]
|
||||||
|
+ NOT_NUMBERS + NOT_ABOVE_ZERO, ids=repr)
|
||||||
|
def test_the_pause_lasts_as_long_as_the_rotation_shows_it(self, clock, value):
|
||||||
|
plugin = _plugin(value)
|
||||||
|
expected = _rotation_seconds(plugin)
|
||||||
|
assert _pause(_coord(plugin), plugin, clock)[1] == pytest.approx(expected, abs=0.15)
|
||||||
|
|
||||||
|
def test_a_raise_too(self, clock):
|
||||||
|
plugin = _plugin(None)
|
||||||
|
plugin.get_display_duration.side_effect = KeyError('display_duration')
|
||||||
|
expected = _rotation_seconds(plugin)
|
||||||
|
assert _pause(_coord(plugin), plugin, clock)[1] == pytest.approx(expected, abs=0.15)
|
||||||
@@ -11,32 +11,26 @@ than even logging it.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.web_interface.error_handler import describe_exception, redact_text
|
from src.web_interface.error_handler import describe_exception
|
||||||
|
|
||||||
|
|
||||||
class TestDescribeException:
|
class TestDescribeException:
|
||||||
"""describe_exception is a reason code: type and errno, never the message.
|
def test_names_the_type_and_message(self):
|
||||||
|
detail = describe_exception(OSError(5, "Input/output error", "systemctl"))
|
||||||
|
assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'"
|
||||||
|
|
||||||
The message can quote paths, URLs or credentials (CodeQL
|
def test_the_reported_failure_is_legible(self):
|
||||||
py/stack-trace-exposure), so it goes to the log; the code still names the
|
# The whole point: this string is the diagnosis.
|
||||||
fault, as "[Errno 5]" did.
|
assert "Input/output error" in describe_exception(
|
||||||
"""
|
OSError(5, "Input/output error", "systemctl"))
|
||||||
|
|
||||||
def test_an_oserror_names_its_errno(self):
|
|
||||||
assert describe_exception(
|
|
||||||
OSError(5, "Input/output error", "systemctl")) == "OSError:EIO"
|
|
||||||
|
|
||||||
def test_a_bare_exception_still_names_its_type(self):
|
def test_a_bare_exception_still_names_its_type(self):
|
||||||
|
# A PermissionError with no message still says more than "unknown".
|
||||||
assert describe_exception(PermissionError()) == "PermissionError"
|
assert describe_exception(PermissionError()) == "PermissionError"
|
||||||
assert describe_exception(Exception()) == "Exception"
|
assert describe_exception(Exception()) == "Exception"
|
||||||
|
|
||||||
def test_the_message_never_reaches_the_code(self):
|
def test_message_is_kept_when_present(self):
|
||||||
assert describe_exception(ValueError("bad port /etc/secret")) == "ValueError"
|
assert describe_exception(ValueError("bad port")) == "ValueError: bad port"
|
||||||
|
|
||||||
def test_the_message_is_logged_instead(self, caplog):
|
|
||||||
describe_exception(RuntimeError("disk on fire token=abc123"))
|
|
||||||
assert "disk on fire" in caplog.text
|
|
||||||
assert "abc123" not in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
class TestCredentialRedaction:
|
class TestCredentialRedaction:
|
||||||
@@ -62,44 +56,47 @@ class TestCredentialRedaction:
|
|||||||
("authorization: barecredential", "barecredential"),
|
("authorization: barecredential", "barecredential"),
|
||||||
])
|
])
|
||||||
def test_credentials_never_reach_the_response(self, secret_text, leaked):
|
def test_credentials_never_reach_the_response(self, secret_text, leaked):
|
||||||
detail = redact_text(secret_text)
|
detail = describe_exception(RuntimeError(secret_text))
|
||||||
assert leaked not in detail
|
assert leaked not in detail
|
||||||
assert "<redacted>" in detail
|
assert "<redacted>" in detail
|
||||||
|
|
||||||
def test_the_parameter_name_survives_redaction(self):
|
def test_the_parameter_name_survives_redaction(self):
|
||||||
# Knowing *which* credential was involved is part of the diagnosis.
|
# Knowing *which* credential was involved is part of the diagnosis.
|
||||||
detail = redact_text("https://x/y?api_key=SEC123")
|
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123"))
|
||||||
assert "api_key" in detail
|
assert "api_key" in detail
|
||||||
|
|
||||||
def test_unknown_schemes_keep_their_name(self):
|
def test_unknown_schemes_keep_their_name(self):
|
||||||
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
|
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
|
||||||
detail = redact_text("Authorization: %s SECRETVALUE" % scheme)
|
detail = describe_exception(
|
||||||
|
RuntimeError("Authorization: %s SECRETVALUE" % scheme))
|
||||||
assert scheme in detail, detail
|
assert scheme in detail, detail
|
||||||
assert "SECRETVALUE" not in detail, detail
|
assert "SECRETVALUE" not in detail, detail
|
||||||
|
|
||||||
def test_auth_scheme_and_username_survive(self):
|
def test_auth_scheme_and_username_survive(self):
|
||||||
# Which kind of credential, and whose, without the credential itself.
|
# Which kind of credential, and whose, without the credential itself.
|
||||||
assert "Bearer" in redact_text("Authorization: Bearer eyJ.SECRET.sig")
|
assert "Bearer" in describe_exception(
|
||||||
assert "user" in redact_text("https://user:hunter2@example.com")
|
RuntimeError("Authorization: Bearer eyJ.SECRET.sig"))
|
||||||
|
assert "user" in describe_exception(
|
||||||
|
RuntimeError("https://user:hunter2@example.com"))
|
||||||
|
|
||||||
def test_non_secret_context_is_preserved(self):
|
def test_non_secret_context_is_preserved(self):
|
||||||
detail = redact_text("https://api.x.com/v1?city=Tampa")
|
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa"))
|
||||||
assert "city=Tampa" in detail
|
assert "city=Tampa" in detail
|
||||||
assert "<redacted>" not in detail
|
assert "<redacted>" not in detail
|
||||||
|
|
||||||
|
|
||||||
class TestBounds:
|
class TestBounds:
|
||||||
def test_long_messages_are_truncated(self):
|
def test_long_messages_are_truncated(self):
|
||||||
detail = redact_text("x" * 5000)
|
detail = describe_exception(ValueError("x" * 5000))
|
||||||
assert len(detail) <= 400
|
assert len(detail) <= 400
|
||||||
|
|
||||||
def test_newlines_are_collapsed_to_one_line(self):
|
def test_newlines_are_collapsed_to_one_line(self):
|
||||||
detail = redact_text("line one\nline two\tthree")
|
detail = describe_exception(ValueError("line one\nline two\tthree"))
|
||||||
assert "\n" not in detail and "\t" not in detail
|
assert "\n" not in detail and "\t" not in detail
|
||||||
assert detail == "line one line two three"
|
assert detail == "ValueError: line one line two three"
|
||||||
|
|
||||||
def test_custom_length_is_honoured(self):
|
def test_custom_length_is_honoured(self):
|
||||||
assert len(redact_text("y" * 500, max_length=50)) <= 50
|
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50
|
||||||
|
|
||||||
|
|
||||||
class TestHandlersCarryDetail:
|
class TestHandlersCarryDetail:
|
||||||
@@ -322,10 +319,10 @@ class TestHandlersCarryDetail:
|
|||||||
assert resp.status_code == 405, "a wrong method must stay a 405"
|
assert resp.status_code == 405, "a wrong method must stay a 405"
|
||||||
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
|
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
|
||||||
|
|
||||||
# A genuine server fault still reports as one, with its reason code.
|
# A genuine server fault still reports as one, with its detail.
|
||||||
resp = client.get("/boom")
|
resp = client.get("/boom")
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
assert resp.get_json()["details"] == "OSError:EIO"
|
assert "Input/output error" in resp.get_json()["details"]
|
||||||
|
|
||||||
def test_global_handler_reports_the_underlying_error(self):
|
def test_global_handler_reports_the_underlying_error(self):
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify
|
||||||
@@ -348,5 +345,4 @@ class TestHandlersCarryDetail:
|
|||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
body = client.get("/boom").get_json()
|
body = client.get("/boom").get_json()
|
||||||
assert body["error_code"] == "UNKNOWN_ERROR"
|
assert body["error_code"] == "UNKNOWN_ERROR"
|
||||||
assert body["details"] == "OSError:EIO"
|
assert "Input/output error" in body["details"]
|
||||||
assert "Input/output error" not in str(body)
|
|
||||||
|
|||||||
@@ -114,11 +114,12 @@ def test_the_answer_is_what_the_catch_all_returned(client, caplog, method, url,
|
|||||||
assert records[-1].exc_info[1] is FORCED
|
assert records[-1].exc_info[1] is FORCED
|
||||||
|
|
||||||
|
|
||||||
def test_the_exception_message_never_reaches_the_detail(client):
|
def test_credentials_are_redacted_from_the_detail(client):
|
||||||
body = client.get("/api/v3/plugins/installed").get_json()
|
body = client.get("/api/v3/plugins/installed").get_json()
|
||||||
for secret in ("SECRET123", "pw1", "K1", "forced failure"):
|
for secret in ("SECRET123", "pw1", "K1"):
|
||||||
assert secret not in str(body)
|
assert secret not in body["details"]
|
||||||
assert body["details"] == "RuntimeError"
|
assert "<redacted>" in body["details"]
|
||||||
|
assert body["details"].startswith("RuntimeError: forced failure")
|
||||||
|
|
||||||
|
|
||||||
def _raise_415():
|
def _raise_415():
|
||||||
@@ -217,7 +218,7 @@ class TestPluginActionStep1:
|
|||||||
encoding="utf-8")
|
encoding="utf-8")
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def test_the_script_error_is_reported_by_type(self, plugin_dir, monkeypatch):
|
def test_the_script_error_reaches_the_response(self, plugin_dir, monkeypatch):
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
manager = MagicMock()
|
manager = MagicMock()
|
||||||
manager.get_plugin_directory.return_value = str(plugin_dir)
|
manager.get_plugin_directory.return_value = str(plugin_dir)
|
||||||
@@ -231,6 +232,5 @@ class TestPluginActionStep1:
|
|||||||
|
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
body = resp.get_json()
|
body = resp.get_json()
|
||||||
assert body["details"] == "RuntimeError"
|
assert body["details"] == "RuntimeError: the auth script failed"
|
||||||
assert "the auth script failed" not in str(body)
|
|
||||||
assert body["message"] == 'An error occurred; see logs for details'
|
assert body["message"] == 'An error occurred; see logs for details'
|
||||||
|
|||||||
@@ -946,27 +946,21 @@ class TestTheStoreReportsWhyItIsEmpty:
|
|||||||
|
|
||||||
|
|
||||||
class TestACrashCarriesItsDetail:
|
class TestACrashCarriesItsDetail:
|
||||||
"""Seventeen Starlark handlers answered 5xx with no detail at all.
|
"""Seventeen Starlark handlers answered 5xx with no detail at all."""
|
||||||
|
|
||||||
The detail is a reason code (the exception type), not the exception's
|
def test_browse_returns_the_exception_detail(self, client):
|
||||||
message, which stays in the log (CodeQL py/stack-trace-exposure).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_browse_returns_the_reason_code(self, client):
|
|
||||||
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
||||||
side_effect=ImportError("No module named 'yaml'")):
|
side_effect=ImportError("No module named 'yaml'")):
|
||||||
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
||||||
|
|
||||||
assert body.get('details') == 'ImportError', body
|
assert 'yaml' in body.get('details', ''), body
|
||||||
assert 'yaml' not in str(body), body
|
|
||||||
|
|
||||||
def test_status_returns_the_reason_code(self, client):
|
def test_status_returns_the_exception_detail(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
||||||
side_effect=RuntimeError("plugin manager is not attached")):
|
side_effect=RuntimeError("plugin manager is not attached")):
|
||||||
body = client.get('/api/v3/starlark/status').get_json()
|
body = client.get('/api/v3/starlark/status').get_json()
|
||||||
|
|
||||||
assert body.get('details') == 'RuntimeError', body
|
assert 'plugin manager is not attached' in body.get('details', ''), body
|
||||||
assert 'plugin manager is not attached' not in str(body), body
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheListingIsNotCappedAtOneThousand:
|
class TestTheListingIsNotCappedAtOneThousand:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ def _save_config_atomic(config_manager, config_data, create_backup=True):
|
|||||||
config_manager.save_config(config_data)
|
config_manager.save_config(config_data)
|
||||||
return True, None
|
return True, None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Failed to save configuration ({describe_exception(e)})"
|
return False, str(e)
|
||||||
def _coerce_to_bool(value):
|
def _coerce_to_bool(value):
|
||||||
"""
|
"""
|
||||||
Coerce a form value to a proper Python boolean.
|
Coerce a form value to a proper Python boolean.
|
||||||
@@ -246,11 +246,7 @@ def _coerce_to_bool(value):
|
|||||||
return value.lower() in ('true', 'on', '1', 'yes')
|
return value.lower() in ('true', 'on', '1', 'yes')
|
||||||
return False
|
return False
|
||||||
def _get_display_service_status():
|
def _get_display_service_status():
|
||||||
"""Return status information about the ledmatrix service.
|
"""Return status information about the ledmatrix service."""
|
||||||
|
|
||||||
active/returncode only: this goes back in API responses, and systemctl's
|
|
||||||
output (or an exception's text) is logged rather than returned.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['systemctl', 'is-active', 'ledmatrix'],
|
['systemctl', 'is-active', 'ledmatrix'],
|
||||||
@@ -258,18 +254,26 @@ def _get_display_service_status():
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=3
|
timeout=3
|
||||||
)
|
)
|
||||||
if result.stderr.strip():
|
|
||||||
logger.debug('systemctl is-active ledmatrix: %s', result.stderr.strip())
|
|
||||||
return {
|
return {
|
||||||
'active': result.stdout.strip() == 'active',
|
'active': result.stdout.strip() == 'active',
|
||||||
'returncode': result.returncode,
|
'returncode': result.returncode,
|
||||||
|
'stdout': result.stdout.strip(),
|
||||||
|
'stderr': result.stderr.strip()
|
||||||
}
|
}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.warning('systemctl is-active ledmatrix timed out')
|
return {
|
||||||
return {'active': False, 'returncode': -1}
|
'active': False,
|
||||||
except Exception:
|
'returncode': -1,
|
||||||
logger.warning('Could not query ledmatrix.service status', exc_info=True)
|
'stdout': '',
|
||||||
return {'active': False, 'returncode': -1}
|
'stderr': 'timeout'
|
||||||
|
}
|
||||||
|
except Exception as err:
|
||||||
|
return {
|
||||||
|
'active': False,
|
||||||
|
'returncode': -1,
|
||||||
|
'stdout': '',
|
||||||
|
'stderr': str(err)
|
||||||
|
}
|
||||||
def _run_systemctl_command(args):
|
def _run_systemctl_command(args):
|
||||||
"""Run a systemctl command safely."""
|
"""Run a systemctl command safely."""
|
||||||
try:
|
try:
|
||||||
@@ -291,26 +295,18 @@ def _run_systemctl_command(args):
|
|||||||
'stderr': 'timeout'
|
'stderr': 'timeout'
|
||||||
}
|
}
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.warning('%s failed', ' '.join(args), exc_info=True)
|
|
||||||
return {
|
return {
|
||||||
'returncode': -1,
|
'returncode': -1,
|
||||||
'stdout': '',
|
'stdout': '',
|
||||||
'stderr': describe_exception(err)
|
'stderr': str(err)
|
||||||
}
|
}
|
||||||
def _public_service_result(result):
|
|
||||||
"""A _run_systemctl_command result fit for a response: no stdout/stderr."""
|
|
||||||
if result.get('returncode') != 0:
|
|
||||||
logger.error('systemctl exited %s: %s', result.get('returncode'),
|
|
||||||
(result.get('stderr') or '').strip())
|
|
||||||
return {k: v for k, v in result.items() if k not in ('stdout', 'stderr')}
|
|
||||||
def _ensure_display_service_running():
|
def _ensure_display_service_running():
|
||||||
"""Ensure the ledmatrix display service is running."""
|
"""Ensure the ledmatrix display service is running."""
|
||||||
status = _get_display_service_status()
|
status = _get_display_service_status()
|
||||||
if status.get('active'):
|
if status.get('active'):
|
||||||
status['started'] = False
|
status['started'] = False
|
||||||
return status
|
return status
|
||||||
result = _public_service_result(
|
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service'])
|
||||||
_run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']))
|
|
||||||
service_status = _get_display_service_status()
|
service_status = _get_display_service_status()
|
||||||
result['started'] = result.get('returncode') == 0
|
result['started'] = result.get('returncode') == 0
|
||||||
result['active'] = service_status.get('active')
|
result['active'] = service_status.get('active')
|
||||||
@@ -318,8 +314,7 @@ def _ensure_display_service_running():
|
|||||||
return result
|
return result
|
||||||
def _stop_display_service():
|
def _stop_display_service():
|
||||||
"""Stop the ledmatrix display service."""
|
"""Stop the ledmatrix display service."""
|
||||||
result = _public_service_result(
|
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service'])
|
||||||
_run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']))
|
|
||||||
status = _get_display_service_status()
|
status = _get_display_service_status()
|
||||||
result['active'] = status.get('active')
|
result['active'] = status.get('active')
|
||||||
result['status'] = status
|
result['status'] = status
|
||||||
@@ -717,7 +712,7 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
|
|||||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||||
except Exception as remove_err:
|
except Exception as remove_err:
|
||||||
_rollback()
|
_rollback()
|
||||||
return False, f"Failed to remove plugin {plugin_id} ({describe_exception(remove_err)})"
|
return False, f"Failed to remove plugin {plugin_id}: {remove_err}"
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
_rollback()
|
_rollback()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -530,11 +530,13 @@ def install_plugin():
|
|||||||
)
|
)
|
||||||
|
|
||||||
branch_msg = f" (branch: {branch})" if branch else ""
|
branch_msg = f" (branch: {branch})" if branch else ""
|
||||||
# plugin_id: the id to enable it by (see _installed_plugin_id).
|
# plugin_id: the id to enable it by, and the id its config
|
||||||
|
# section is under (see _installed_plugin_id).
|
||||||
|
installed_id = _installed_plugin_id(plugin_id)
|
||||||
return {'success': True,
|
return {'success': True,
|
||||||
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
|
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
|
||||||
'plugin_id': _installed_plugin_id(plugin_id),
|
'plugin_id': installed_id,
|
||||||
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
|
**_store_restart_fields('install', _plugin_enabled_in_config(installed_id))}
|
||||||
else:
|
else:
|
||||||
error_msg = f'Failed to install plugin {plugin_id}'
|
error_msg = f'Failed to install plugin {plugin_id}'
|
||||||
if branch:
|
if branch:
|
||||||
@@ -588,10 +590,11 @@ def install_plugin():
|
|||||||
)
|
)
|
||||||
|
|
||||||
branch_msg = f" (branch: {branch})" if branch else ""
|
branch_msg = f" (branch: {branch})" if branch else ""
|
||||||
|
installed_id = _installed_plugin_id(plugin_id)
|
||||||
return success_response(
|
return success_response(
|
||||||
message=f'Plugin installed successfully{branch_msg}',
|
message=f'Plugin installed successfully{branch_msg}',
|
||||||
extra={'plugin_id': _installed_plugin_id(plugin_id),
|
extra={'plugin_id': installed_id,
|
||||||
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))})
|
**_store_restart_fields('install', _plugin_enabled_in_config(installed_id))})
|
||||||
else:
|
else:
|
||||||
error_msg = f'Failed to install plugin {plugin_id}'
|
error_msg = f'Failed to install plugin {plugin_id}'
|
||||||
if branch:
|
if branch:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -983,6 +983,7 @@ def stop_pixlet_editor():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Editor force-stopped, but the display could not be '
|
'message': 'Editor force-stopped, but the display could not be '
|
||||||
'restarted automatically - start it manually.',
|
'restarted automatically - start it manually.',
|
||||||
|
'details': (result.get('stderr') or '').strip(),
|
||||||
'data': {'running': False}}), 500
|
'data': {'running': False}}), 500
|
||||||
return jsonify({'status': 'success',
|
return jsonify({'status': 'success',
|
||||||
'message': 'Editor force-stopped; the display has been '
|
'message': 'Editor force-stopped; the display has been '
|
||||||
|
|||||||
@@ -689,7 +689,7 @@ def execute_system_action():
|
|||||||
logger.warning("install_base_requirements timed out for %s", label)
|
logger.warning("install_base_requirements timed out for %s", label)
|
||||||
except OSError as install_err:
|
except OSError as install_err:
|
||||||
all_ok = False
|
all_ok = False
|
||||||
outputs.append(f"== {label} ==\nFailed: {describe_exception(install_err)}")
|
outputs.append(f"== {label} ==\nFailed: {install_err}")
|
||||||
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
|
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if all_ok else 'error',
|
'status': 'success' if all_ok else 'error',
|
||||||
@@ -784,10 +784,11 @@ def execute_system_action():
|
|||||||
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||||
|
detail = describe_exception(e)
|
||||||
resp = {
|
resp = {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': _sudo_hint_for(str(e)) or 'Action failed; see logs for details',
|
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
|
||||||
'details': describe_exception(e),
|
'details': detail,
|
||||||
}
|
}
|
||||||
return jsonify(resp), 500
|
return jsonify(resp), 500
|
||||||
@api_v3.route('/system/git-info', methods=['GET'])
|
@api_v3.route('/system/git-info', methods=['GET'])
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def _run_background_connect(ssid, password):
|
|||||||
payload = _connect_result_payload(ssid, success, message)
|
payload = _connect_result_payload(ssid, success, message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Background WiFi connect failed", exc_info=True)
|
logger.error("Background WiFi connect failed", exc_info=True)
|
||||||
payload = {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'}
|
payload = {'status': 'error', 'message': describe_exception(e)}
|
||||||
_record_connect_result(ssid, payload)
|
_record_connect_result(ssid, payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ def connect_wifi():
|
|||||||
try:
|
try:
|
||||||
success, message = wifi_manager.connect_to_network(ssid, password)
|
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_record_connect_result(ssid, {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'})
|
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
|
||||||
raise
|
raise
|
||||||
payload = _connect_result_payload(ssid, success, message)
|
payload = _connect_result_payload(ssid, success, message)
|
||||||
_record_connect_result(ssid, payload)
|
_record_connect_result(ssid, payload)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def refresh_after_update(run=None, systemd_dir=None, helper_source=None, helper_
|
|||||||
except Exception as e: # a broken template must not fail the update itself
|
except Exception as e: # a broken template must not fail the update itself
|
||||||
if type(e).__name__ != 'UnitsUnreadable':
|
if type(e).__name__ != 'UnitsUnreadable':
|
||||||
logger.warning("Could not compare the installed systemd units with the new templates: %s", e)
|
logger.warning("Could not compare the installed systemd units with the new templates: %s", e)
|
||||||
return _result(FAILED, 'The service settings could not be checked; see logs for details.')
|
return _result(FAILED, f'The service settings could not be checked: {e}.')
|
||||||
# Units installed mode 0600 (install_service.sh run on its own, before
|
# Units installed mode 0600 (install_service.sh run on its own, before
|
||||||
# it set 0644): only root can compare them, so let the helper decide.
|
# it set 0644): only root can compare them, so let the helper decide.
|
||||||
stale = []
|
stale = []
|
||||||
@@ -110,7 +110,7 @@ def refresh_after_update(run=None, systemd_dir=None, helper_source=None, helper_
|
|||||||
timeout=TIMEOUT_SECONDS)
|
timeout=TIMEOUT_SECONDS)
|
||||||
except (subprocess.SubprocessError, OSError) as e:
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
logger.warning("Refreshing the systemd units failed: %s", e)
|
logger.warning("Refreshing the systemd units failed: %s", e)
|
||||||
return _result(FAILED, f'Updating the service settings ({names}) failed; see logs for details.', stale)
|
return _result(FAILED, f'Updating the service settings ({names}) failed: {e}.', stale)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# The helper says what it did: "units refreshed: a b" or "units: up to date".
|
# The helper says what it did: "units refreshed: a b" or "units: up to date".
|
||||||
done = next((line.split(':', 1)[1].split() for line in (result.stdout or '').splitlines()
|
done = next((line.split(':', 1)[1].split() for line in (result.stdout or '').splitlines()
|
||||||
|
|||||||
Reference in New Issue
Block a user