mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 12:59:12 +00:00
Compare commits
7
Commits
v3.8.0
...
d9f6b110bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f6b110bc | ||
|
|
31bd0cb40a | ||
|
|
c07dbb0bff | ||
|
|
371bc69bd8 | ||
|
|
e50a100814 | ||
|
|
a645327c52 | ||
|
|
48a004a0ac |
@@ -681,12 +681,18 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
if app.is_enabled() and app.should_render(current_time):
|
if app.is_enabled() and app.should_render(current_time):
|
||||||
self._render_app(app, force=False)
|
self._render_app(app, force=False)
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> None:
|
def display(self, force_clear: bool = False) -> bool:
|
||||||
"""
|
"""
|
||||||
Display current Starlark app.
|
Display current Starlark app.
|
||||||
|
|
||||||
This method is called during the display rotation.
|
This method is called during the display rotation.
|
||||||
Displays frames from the currently active app.
|
Displays frames from the currently active app.
|
||||||
|
|
||||||
|
Returns False when there is no app to show -- which is the state of
|
||||||
|
every install without Pixlet, and of a fresh one before any app is
|
||||||
|
added. The display controller only skips a mode on a boolean False
|
||||||
|
(it checks isinstance(result, bool)), so returning None held a black
|
||||||
|
panel for the full display_duration instead of rotating on.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if force_clear:
|
if force_clear:
|
||||||
@@ -699,20 +705,22 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
if not self.current_app:
|
if not self.current_app:
|
||||||
# No apps available
|
# No apps available
|
||||||
self.logger.debug("No Starlark apps to display")
|
self.logger.debug("No Starlark apps to display")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Render app if needed
|
# Render app if needed
|
||||||
if not self.current_app.frames:
|
if not self.current_app.frames:
|
||||||
success = self._render_app(self.current_app, force=True)
|
success = self._render_app(self.current_app, force=True)
|
||||||
if not success:
|
if not success:
|
||||||
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Display current frame
|
# Display current frame
|
||||||
self._display_frame()
|
self._display_frame()
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error displaying Starlark app: {e}")
|
self.logger.error(f"Error displaying Starlark app: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _select_next_app(self) -> None:
|
def _select_next_app(self) -> None:
|
||||||
"""Select the next enabled app for display."""
|
"""Select the next enabled app for display."""
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ Discovers and runs tests for LEDMatrix plugins.
|
|||||||
Supports both unittest and pytest.
|
Supports both unittest and pytest.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -68,6 +71,65 @@ def _find_tests_in_dir(directory: Path) -> list:
|
|||||||
return sorted(set(test_files))
|
return sorted(set(test_files))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_script_style(path) -> bool:
|
||||||
|
"""True when a test file is a standalone script, not a pytest module.
|
||||||
|
|
||||||
|
Most plugin tests are written as `def main()` plus an `if __name__ ==
|
||||||
|
"__main__"` guard and signal through an exit code. pytest collects zero
|
||||||
|
items from those, so handing them to pytest printed "no tests ran" and this
|
||||||
|
runner reported success over work it had not done -- 151 of 248 files on a
|
||||||
|
fully populated rig.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
src = Path(path).read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
has_pytest_items = re.search(r"^\s*(def test_|class Test|async def test_)", src, re.M)
|
||||||
|
has_main_guard = "__main__" in src and "__name__" in src
|
||||||
|
return bool(has_main_guard and not has_pytest_items)
|
||||||
|
|
||||||
|
|
||||||
|
def run_script_tests(test_files: list, verbose: bool = False) -> int:
|
||||||
|
"""Run standalone test scripts, honouring the 0 pass / 2 skip / 1 fail
|
||||||
|
convention that ledmatrix-plugins' own runner established.
|
||||||
|
|
||||||
|
Scripts opt into skipping by printing "SKIP: <reason>" and exiting 2 --
|
||||||
|
a script that needs a tty or an LED matrix is not a regression.
|
||||||
|
"""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.setdefault("PYTHONPATH", str(PROJECT_ROOT))
|
||||||
|
env.setdefault("LEDMATRIX_CORE", str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
passed = skipped = failed = 0
|
||||||
|
failures = []
|
||||||
|
for path in test_files:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run([sys.executable, str(path)], cwd=str(Path(path).parent),
|
||||||
|
capture_output=True, text=True, env=env,
|
||||||
|
stdin=subprocess.DEVNULL, timeout=300)
|
||||||
|
rc = proc.returncode
|
||||||
|
tail = " | ".join((proc.stdout or proc.stderr or "").strip().splitlines()[-2:])[:200]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rc, tail = 1, "timed out after 300s"
|
||||||
|
if rc == 0:
|
||||||
|
passed += 1
|
||||||
|
label = "pass"
|
||||||
|
elif rc == 2:
|
||||||
|
skipped += 1
|
||||||
|
label = "SKIP"
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
label = "FAIL"
|
||||||
|
failures.append(f"{Path(path).name}: exit {rc} | {tail}")
|
||||||
|
if verbose or rc != 0:
|
||||||
|
print(f" [{label}] {Path(path).name}" + (f" -- {tail}" if rc != 0 else ""))
|
||||||
|
|
||||||
|
print(f"\n{passed} passed, {skipped} skipped, {failed} failed (scripts)")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
||||||
"""
|
"""
|
||||||
Run tests using unittest.
|
Run tests using unittest.
|
||||||
@@ -186,11 +248,16 @@ def main():
|
|||||||
print("No test files found in plugins directory")
|
print("No test files found in plugins directory")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"Found {len(test_files)} test file(s)")
|
scripts = [f for f in test_files if _is_script_style(f)]
|
||||||
|
modules = [f for f in test_files if f not in scripts]
|
||||||
|
|
||||||
|
print(f"Found {len(test_files)} test file(s)"
|
||||||
|
+ (f" -- {len(modules)} collectable, {len(scripts)} standalone script(s)"
|
||||||
|
if scripts else ""))
|
||||||
for test_file in test_files:
|
for test_file in test_files:
|
||||||
print(f" - {test_file}")
|
print(f" - {test_file}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Determine runner
|
# Determine runner
|
||||||
runner = args.runner
|
runner = args.runner
|
||||||
if runner == 'auto':
|
if runner == 'auto':
|
||||||
@@ -199,12 +266,21 @@ def main():
|
|||||||
runner = 'pytest'
|
runner = 'pytest'
|
||||||
except ImportError:
|
except ImportError:
|
||||||
runner = 'unittest'
|
runner = 'unittest'
|
||||||
|
|
||||||
# Run tests
|
# Standalone scripts cannot be collected by pytest or unittest -- run them
|
||||||
if runner == 'pytest':
|
# as the scripts they are. Doing this rather than silently collecting zero
|
||||||
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
# items is the whole point: this runner used to report success having
|
||||||
else:
|
# executed nothing.
|
||||||
return run_unittest_tests(test_files, args.verbose)
|
rc = 0
|
||||||
|
if scripts:
|
||||||
|
rc |= run_script_tests(scripts, args.verbose)
|
||||||
|
|
||||||
|
if modules:
|
||||||
|
if runner == 'pytest':
|
||||||
|
rc |= run_pytest_tests(modules, args.verbose, args.coverage)
|
||||||
|
else:
|
||||||
|
rc |= run_unittest_tests(modules, args.verbose)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1271,8 +1271,16 @@ class DisplayController:
|
|||||||
"""Poll cache for new on-demand requests from external controllers."""
|
"""Poll cache for new on-demand requests from external controllers."""
|
||||||
try:
|
try:
|
||||||
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
||||||
# The request_id check prevents duplicate processing
|
# The request_id check prevents duplicate processing.
|
||||||
request = self.cache_manager.get('display_on_demand_request', max_age=3600)
|
#
|
||||||
|
# memory_ttl=0 is required, not optional: this key is a mailbox the
|
||||||
|
# web process writes and this process reads. get() defaults the
|
||||||
|
# in-memory TTL to max_age, so without it the first request read was
|
||||||
|
# pinned in memory for the full hour and every later poll returned
|
||||||
|
# that stale copy -- meaning no second on-demand request was honoured
|
||||||
|
# for an hour, while the API still reported success.
|
||||||
|
request = self.cache_manager.get('display_on_demand_request',
|
||||||
|
max_age=3600, memory_ttl=0)
|
||||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||||
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
||||||
return
|
return
|
||||||
@@ -1318,6 +1326,17 @@ class DisplayController:
|
|||||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||||
self.on_demand_request_id = request_id
|
self.on_demand_request_id = request_id
|
||||||
|
# Consume the mailbox entry. Leaving it on disk meant a restart replayed
|
||||||
|
# the previous request: the fresh controller read it, activated it and
|
||||||
|
# cached it, so the request the caller had just made was ignored and the
|
||||||
|
# panel silently showed the earlier plugin. processed_id still guards
|
||||||
|
# against double-processing if this delete fails.
|
||||||
|
try:
|
||||||
|
self.cache_manager.delete('display_on_demand_request')
|
||||||
|
except (OSError, AttributeError, KeyError) as err:
|
||||||
|
# Best-effort: processed_id still guards against reprocessing if the
|
||||||
|
# mailbox cannot be cleared.
|
||||||
|
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
||||||
|
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||||
|
|||||||
+21
-3
@@ -1453,12 +1453,30 @@ class DisplayManager:
|
|||||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||||
self._snapshot_dir_prepared = True
|
self._snapshot_dir_prepared = True
|
||||||
# Write atomically: temp then replace
|
# Write atomically: temp then replace. The temp name must be
|
||||||
tmp_path = f"{self._snapshot_path}.tmp"
|
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
||||||
self.image.save(tmp_path, format='PNG')
|
# and this file is written by whichever user the display service
|
||||||
|
# runs as while tests and tooling run as someone else. A leftover
|
||||||
|
# fixed-name temp owned by another user is then unopenable even by
|
||||||
|
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
||||||
|
# sticky dir), which froze the preview and the health check's
|
||||||
|
# liveness proxy until somebody deleted it by hand. Same pattern as
|
||||||
|
# the hardware-status write above.
|
||||||
|
_fd, tmp_path = tempfile.mkstemp(
|
||||||
|
dir=str(snapshot_path_obj.parent),
|
||||||
|
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||||
try:
|
try:
|
||||||
|
with os.fdopen(_fd, "wb") as _f:
|
||||||
|
self.image.save(_f, format='PNG')
|
||||||
|
os.chmod(tmp_path, 0o644)
|
||||||
os.replace(tmp_path, self._snapshot_path)
|
os.replace(tmp_path, self._snapshot_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Never leave the temp behind -- that is what made the failure
|
||||||
|
# permanent rather than transient.
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
# Fallback to direct save if replace not supported
|
# Fallback to direct save if replace not supported
|
||||||
self.image.save(self._snapshot_path, format='PNG')
|
self.image.save(self._snapshot_path, format='PNG')
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
|
|||||||
+2
-1
@@ -96,7 +96,8 @@ class FontManager:
|
|||||||
self.common_fonts = {
|
self.common_fonts = {
|
||||||
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
|
"press_start": "assets/fonts/PressStart2P-Regular.ttf",
|
||||||
"four_by_six": "assets/fonts/4x6-font.ttf",
|
"four_by_six": "assets/fonts/4x6-font.ttf",
|
||||||
"five_by_seven": "assets/fonts/5x7.bdf"
|
"five_by_seven": "assets/fonts/5x7.bdf",
|
||||||
|
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
||||||
# Note: cozette_bdf removed - font file not available
|
# Note: cozette_bdf removed - font file not available
|
||||||
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
|
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
|
||||||
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
|
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ via BoundsCheckingDisplayManager, and golden-image comparison.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import http.client
|
import http.client
|
||||||
import inspect
|
import inspect
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -25,7 +27,7 @@ from PIL import Image, ImageChops
|
|||||||
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
from .loading import load_config_defaults, load_manifest
|
from .loading import load_config_defaults, load_manifest, merge_config
|
||||||
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||||
|
|
||||||
logger = get_logger("[Plugin Harness]")
|
logger = get_logger("[Plugin Harness]")
|
||||||
@@ -116,14 +118,23 @@ def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -
|
|||||||
|
|
||||||
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||||
config: Dict[str, Any], mock_data: Dict[str, Any],
|
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||||
display_manager: Any) -> Any:
|
display_manager: Any, cache_manager: Any = None) -> Any:
|
||||||
"""Load and construct a plugin instance with mocked managers."""
|
"""Load and construct a plugin instance with mocked managers.
|
||||||
|
|
||||||
|
Pass ``cache_manager`` to share one cache across the renders of a plugin.
|
||||||
|
Building a fresh one per (size, mode) made every render a cold start, so a
|
||||||
|
plugin that fetches per game or per player re-fetched everything N times --
|
||||||
|
baseball-scoreboard took 840s for nine renders where ~72s was the arithmetic
|
||||||
|
-- and the cache-hit path, which is what a running rig executes almost
|
||||||
|
always, was never exercised.
|
||||||
|
"""
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||||
|
|
||||||
cache_manager = MockCacheManager()
|
if cache_manager is None:
|
||||||
for key, value in (mock_data or {}).items():
|
cache_manager = MockCacheManager()
|
||||||
cache_manager.set(key, value)
|
for key, value in (mock_data or {}).items():
|
||||||
|
cache_manager.set(key, value)
|
||||||
|
|
||||||
loader = PluginLoader()
|
loader = PluginLoader()
|
||||||
plugin_instance, _module = loader.load_plugin(
|
plugin_instance, _module = loader.load_plugin(
|
||||||
@@ -160,6 +171,104 @@ def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
|||||||
return plugin_instance.display(force_clear=False)
|
return plugin_instance.display(force_clear=False)
|
||||||
|
|
||||||
|
|
||||||
|
# How many extra frames to drive before believing a mode really draws nothing.
|
||||||
|
# A scroll starts with its content off-panel, so frame 1 is legitimately blank;
|
||||||
|
# measured across the fleet, content appears by frame 2-4 (f1-scoreboard),
|
||||||
|
# frame 4 (ledmatrix-elections) and frame 38 at 64px (ledmatrix-leaderboard).
|
||||||
|
EMPTY_RECHECK_FRAMES = 48
|
||||||
|
# Seconds to advance the clock between those frames. Scroll position is usually
|
||||||
|
# driven by elapsed time, which a frozen clock never provides.
|
||||||
|
EMPTY_RECHECK_STEP = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _has_content(image) -> bool:
|
||||||
|
"""True when any pixel is lit above the threshold."""
|
||||||
|
if image is None:
|
||||||
|
return False
|
||||||
|
return image.convert("L").point(
|
||||||
|
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mode_again(plugin_instance: Any, mode: str) -> Any:
|
||||||
|
"""Draw one more frame WITHOUT force_clear.
|
||||||
|
|
||||||
|
_render_mode passes force_clear=True, which for a scrolling plugin means
|
||||||
|
"reset the scroll to the start" -- so repeating it would redraw frame 1 for
|
||||||
|
ever. The re-check needs the plugin to advance.
|
||||||
|
"""
|
||||||
|
sig = inspect.signature(plugin_instance.display)
|
||||||
|
if "display_mode" in sig.parameters:
|
||||||
|
return plugin_instance.display(force_clear=False, display_mode=mode)
|
||||||
|
return plugin_instance.display(force_clear=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _settle_empty_frame(inst, mode, dm, result, freezer) -> None:
|
||||||
|
"""Give an apparently-empty mode a few frames to draw before believing it.
|
||||||
|
|
||||||
|
One frame is not evidence: a scroll's first frame is its blank scroll-in
|
||||||
|
buffer. Without this, every scrolling plugin was warned about -- 60 of 76
|
||||||
|
warnings on a 44-plugin rig were false, which is the rate at which people
|
||||||
|
stop reading a warning.
|
||||||
|
"""
|
||||||
|
if result.error is not None or result.display_returned is False:
|
||||||
|
return
|
||||||
|
if _has_content(result.image):
|
||||||
|
return
|
||||||
|
# The frozen clock is shared by every render in the matrix, so any time this
|
||||||
|
# probe borrows has to be given back -- otherwise a mode that scrolls in
|
||||||
|
# leaves the clock advanced and every later mode renders at the wrong
|
||||||
|
# instant, drifting its golden. Seen as 5 spurious f1_upcoming drifts.
|
||||||
|
resume_at = None
|
||||||
|
if freezer is not None:
|
||||||
|
try:
|
||||||
|
resume_at = freezer()
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
# Not a freezegun factory, or a version whose factory is not
|
||||||
|
# callable. Only used to restore the clock, never load-bearing.
|
||||||
|
resume_at = None
|
||||||
|
try:
|
||||||
|
_settle_loop(inst, mode, dm, result, freezer)
|
||||||
|
finally:
|
||||||
|
if resume_at is not None:
|
||||||
|
try:
|
||||||
|
freezer.move_to(resume_at)
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _settle_loop(inst, mode, dm, result, freezer) -> None:
|
||||||
|
tick = getattr(freezer, "tick", None) if freezer is not None else None
|
||||||
|
for _ in range(EMPTY_RECHECK_FRAMES):
|
||||||
|
if tick is not None:
|
||||||
|
# timedelta rather than a bare float: freezegun has accepted a
|
||||||
|
# number only since 1.x, and a stale pin would raise here.
|
||||||
|
try:
|
||||||
|
tick(timedelta(seconds=EMPTY_RECHECK_STEP))
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
# Pacing is best-effort; a freezegun that will not take a
|
||||||
|
# timedelta just means this probe runs without advancing time.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# No frozen clock, so the real one has to do the advancing. Without
|
||||||
|
# this the 48 frames run in microseconds, elapsed time stays ~0, and
|
||||||
|
# a scroll driven by elapsed time never moves -- which is exactly
|
||||||
|
# the plugin this check is trying not to slander.
|
||||||
|
time.sleep(EMPTY_RECHECK_STEP)
|
||||||
|
try:
|
||||||
|
result.display_returned = _render_mode_again(inst, mode)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# Deliberately broad: this calls a plugin's display(), which can
|
||||||
|
# raise anything. The first frame already rendered, so whatever
|
||||||
|
# happens on a re-draw must not turn a good result into an error --
|
||||||
|
# keep the frame we have and stop probing.
|
||||||
|
return
|
||||||
|
image = dm.get_image()
|
||||||
|
if _has_content(image):
|
||||||
|
result.image = image
|
||||||
|
result.overflow = dm.check_overflow()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _freeze(freeze_time: Optional[str]):
|
def _freeze(freeze_time: Optional[str]):
|
||||||
"""Context manager that freezes wall-clock time when freeze_time is given,
|
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||||
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||||
@@ -193,7 +302,9 @@ def render_plugin_matrix(
|
|||||||
manifest = load_manifest(plugin_dir)
|
manifest = load_manifest(plugin_dir)
|
||||||
# Start from config_schema.json defaults so the plugin behaves like a real
|
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||||
# install; explicit caller config still wins over a schema default.
|
# install; explicit caller config still wins over a schema default.
|
||||||
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
config = merge_config(
|
||||||
|
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
|
||||||
|
config or {})
|
||||||
sizes = sizes or DEFAULT_TEST_SIZES
|
sizes = sizes or DEFAULT_TEST_SIZES
|
||||||
results: List[RenderResult] = []
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
@@ -202,25 +313,35 @@ def render_plugin_matrix(
|
|||||||
# rendering a smaller one, instead of being clipped into a false pass.
|
# rendering a smaller one, instead of being clipped into a false pass.
|
||||||
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
||||||
|
|
||||||
with _freeze(freeze_time):
|
# One cache for the whole matrix: see _instantiate. The display manager
|
||||||
|
# stays per-render (the bounds checking depends on that); only fetched data
|
||||||
|
# is shared.
|
||||||
|
from src.plugin_system.testing import MockCacheManager
|
||||||
|
cache_manager = MockCacheManager()
|
||||||
|
for key, value in (mock_data or {}).items():
|
||||||
|
cache_manager.set(key, value)
|
||||||
|
|
||||||
|
with _freeze(freeze_time) as freezer:
|
||||||
for width, height in sizes:
|
for width, height in sizes:
|
||||||
results.extend(_render_size(
|
results.extend(_render_size(
|
||||||
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||||
width, height, run_update, extent,
|
width, height, run_update, extent, cache_manager, freezer,
|
||||||
))
|
))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||||
width, height, run_update, extent) -> List[RenderResult]:
|
width, height, run_update, extent,
|
||||||
|
cache_manager=None, freezer=None) -> List[RenderResult]:
|
||||||
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||||
results: List[RenderResult] = []
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
# Discover modes once per size (instance build can depend on config).
|
# Discover modes once per size (instance build can depend on config).
|
||||||
try:
|
try:
|
||||||
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm,
|
||||||
|
cache_manager)
|
||||||
modes = list_modes(probe, manifest, plugin_id)
|
modes = list_modes(probe, manifest, plugin_id)
|
||||||
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||||
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||||
@@ -229,7 +350,8 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
|||||||
result = RenderResult(plugin_id, width, height, mode)
|
result = RenderResult(plugin_id, width, height, mode)
|
||||||
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
try:
|
try:
|
||||||
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm,
|
||||||
|
cache_manager)
|
||||||
if run_update:
|
if run_update:
|
||||||
try:
|
try:
|
||||||
inst.update()
|
inst.update()
|
||||||
@@ -248,6 +370,9 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
|||||||
result.display_returned = _render_mode(inst, mode)
|
result.display_returned = _render_mode(inst, mode)
|
||||||
result.image = dm.get_image()
|
result.image = dm.get_image()
|
||||||
result.overflow = dm.check_overflow()
|
result.overflow = dm.check_overflow()
|
||||||
|
# A blank first frame is not proof of a blank mode; see
|
||||||
|
# _settle_empty_frame.
|
||||||
|
_settle_empty_frame(inst, mode, dm, result, freezer)
|
||||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||||
result.error = repr(e)
|
result.error = repr(e)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|||||||
@@ -33,6 +33,45 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Defaults for one `properties` block, recursing into nested objects.
|
||||||
|
|
||||||
|
An object property carries its defaults on its children, not on itself, so
|
||||||
|
reading only the top level dropped everything nested. That is most of the
|
||||||
|
fleet: config organised by league, or under customization/display_options,
|
||||||
|
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
|
||||||
|
539 of 565 -- and the harness rendered them with a config no install would
|
||||||
|
ever have.
|
||||||
|
"""
|
||||||
|
defaults: Dict[str, Any] = {}
|
||||||
|
for key, prop in (properties or {}).items():
|
||||||
|
if not isinstance(prop, dict):
|
||||||
|
continue
|
||||||
|
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
|
||||||
|
nested = _defaults_from_properties(prop['properties'])
|
||||||
|
if nested:
|
||||||
|
defaults[key] = nested
|
||||||
|
elif 'default' in prop:
|
||||||
|
defaults[key] = prop['default']
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Deep-merge override onto base, without dropping sibling defaults.
|
||||||
|
|
||||||
|
A shallow merge would let `-c '{"nhl": {"enabled": true}}'` replace the whole
|
||||||
|
nhl subtree and silently discard every other nhl default -- the same class of
|
||||||
|
bug this function exists to fix.
|
||||||
|
"""
|
||||||
|
merged = dict(base)
|
||||||
|
for key, value in (override or {}).items():
|
||||||
|
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||||
|
merged[key] = merge_config(merged[key], value)
|
||||||
|
else:
|
||||||
|
merged[key] = value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||||
@@ -40,11 +79,7 @@ def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
with open(schema_path, 'r') as f:
|
with open(schema_path, 'r') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
defaults: Dict[str, Any] = {}
|
return _defaults_from_properties(schema.get('properties', {}))
|
||||||
for key, prop in schema.get('properties', {}).items():
|
|
||||||
if isinstance(prop, dict) and 'default' in prop:
|
|
||||||
defaults[key] = prop['default']
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -506,9 +506,16 @@ class VisualTestDisplayManager:
|
|||||||
# Scrolling state (no-op interface compat)
|
# Scrolling state (no-op interface compat)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def set_scrolling_state(self, is_scrolling: bool):
|
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
|
||||||
"""Set the current scrolling state (no-op for testing)."""
|
"""Set the current scrolling state (no-op for testing).
|
||||||
|
|
||||||
|
``frame_hold`` mirrors DisplayManager.set_scrolling_state so a plugin
|
||||||
|
that paces its scroll can be rendered here. Without it every such
|
||||||
|
plugin raised TypeError at render time and failed every size, which is
|
||||||
|
invisible until a plugin happens to pass the argument.
|
||||||
|
"""
|
||||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||||
|
self._scrolling_state['frame_hold'] = frame_hold
|
||||||
if is_scrolling:
|
if is_scrolling:
|
||||||
self._scrolling_state['last_scroll_activity'] = time.time()
|
self._scrolling_state['last_scroll_activity'] = time.time()
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,11 @@ def _scrub_git_remote_url(url: str) -> str:
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
# Will be initialized when blueprint is registered
|
# Will be initialized when blueprint is registered
|
||||||
config_manager = None
|
# NOTE: the managers live on the blueprint object (app.py sets
|
||||||
plugin_manager = None
|
# api_v3.config_manager / api_v3.plugin_manager). Deliberately not
|
||||||
|
# mirrored as module globals: a bare `config_manager` used to resolve to
|
||||||
|
# a None that was never assigned, which silently disabled the /health
|
||||||
|
# checks and made /display/current fall back to a hardcoded 128x64.
|
||||||
plugin_store_manager = None
|
plugin_store_manager = None
|
||||||
saved_repositories_manager = None
|
saved_repositories_manager = None
|
||||||
cache_manager = None
|
cache_manager = None
|
||||||
@@ -1598,11 +1601,16 @@ def get_health():
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Check web interface service
|
# Check web interface service
|
||||||
|
# Stamp the start time before measuring against it -- reading it with a
|
||||||
|
# fallback of time.time() and only assigning afterwards made the very
|
||||||
|
# first call subtract two separate clock reads, reporting a small
|
||||||
|
# negative uptime.
|
||||||
|
if not hasattr(get_health, '_start_time'):
|
||||||
|
get_health._start_time = time.time()
|
||||||
health_status['services']['web_interface'] = {
|
health_status['services']['web_interface'] = {
|
||||||
'status': 'running',
|
'status': 'running',
|
||||||
'uptime_seconds': time.time() - (getattr(get_health, '_start_time', time.time()))
|
'uptime_seconds': time.time() - get_health._start_time
|
||||||
}
|
}
|
||||||
get_health._start_time = getattr(get_health, '_start_time', time.time())
|
|
||||||
|
|
||||||
# Check display service
|
# Check display service
|
||||||
display_service_status = _get_display_service_status()
|
display_service_status = _get_display_service_status()
|
||||||
@@ -1613,8 +1621,8 @@ def get_health():
|
|||||||
|
|
||||||
# Check config file accessibility
|
# Check config file accessibility
|
||||||
try:
|
try:
|
||||||
if config_manager:
|
if api_v3.config_manager:
|
||||||
test_config = config_manager.load_config()
|
test_config = api_v3.config_manager.load_config()
|
||||||
health_status['checks']['config_file'] = {
|
health_status['checks']['config_file'] = {
|
||||||
'status': 'accessible',
|
'status': 'accessible',
|
||||||
'readable': True
|
'readable': True
|
||||||
@@ -1633,9 +1641,9 @@ def get_health():
|
|||||||
|
|
||||||
# Check plugin system
|
# Check plugin system
|
||||||
try:
|
try:
|
||||||
if plugin_manager:
|
if api_v3.plugin_manager:
|
||||||
# Try to discover plugins (lightweight check)
|
# Try to discover plugins (lightweight check)
|
||||||
plugin_count = len(plugin_manager.get_available_plugins()) if hasattr(plugin_manager, 'get_available_plugins') else 0
|
plugin_count = len(api_v3.plugin_manager.get_available_plugins()) if hasattr(api_v3.plugin_manager, 'get_available_plugins') else 0
|
||||||
health_status['checks']['plugin_system'] = {
|
health_status['checks']['plugin_system'] = {
|
||||||
'status': 'operational',
|
'status': 'operational',
|
||||||
'plugin_count': plugin_count
|
'plugin_count': plugin_count
|
||||||
@@ -2468,8 +2476,8 @@ def get_display_current():
|
|||||||
|
|
||||||
# Get display dimensions from config
|
# Get display dimensions from config
|
||||||
try:
|
try:
|
||||||
if config_manager:
|
if api_v3.config_manager:
|
||||||
main_config = config_manager.load_config()
|
main_config = api_v3.config_manager.load_config()
|
||||||
hardware_config = main_config.get('display', {}).get('hardware', {})
|
hardware_config = main_config.get('display', {}).get('hardware', {})
|
||||||
cols = hardware_config.get('cols', 64)
|
cols = hardware_config.get('cols', 64)
|
||||||
chain_length = hardware_config.get('chain_length', 2)
|
chain_length = hardware_config.get('chain_length', 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user