mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 03:28:37 +00:00
Compare commits
9 Commits
v3.1.0
...
bc027c921d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa |
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plugin-safety:
|
||||||
|
name: Plugin safety harness + unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
|
- name: Run harness + visual rendering tests
|
||||||
|
run: |
|
||||||
|
pytest --no-cov \
|
||||||
|
test/plugins/test_harness.py \
|
||||||
|
test/plugins/test_visual_rendering.py \
|
||||||
|
test/plugins/test_plugin_matrix.py
|
||||||
136
docs/plugin-safety-harness.md
Normal file
136
docs/plugin-safety-harness.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Plugin Safety Harness
|
||||||
|
|
||||||
|
Renders a plugin across **every declared screen (mode)** and **a spread of
|
||||||
|
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
|
||||||
|
or — for plugins that ship golden images — drifts visually. The goal: change a
|
||||||
|
plugin without breaking a size or screen you didn't think to test.
|
||||||
|
|
||||||
|
## Sizes: a sample, not a fixed list
|
||||||
|
|
||||||
|
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
|
||||||
|
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
|
||||||
|
strips, tall stacks). Plugins are expected to read dimensions dynamically
|
||||||
|
(`self.display_manager.matrix.width/height`) and lay themselves out
|
||||||
|
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
||||||
|
here.
|
||||||
|
|
||||||
|
The harness therefore renders against a **representative sample** that spans the
|
||||||
|
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
|
||||||
|
not an authoritative list:
|
||||||
|
|
||||||
|
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
|
||||||
|
|
||||||
|
| Size | Grid | Why it's in the sample |
|
||||||
|
|---------|------|--------------------------------------------|
|
||||||
|
| 64×32 | 1×1 | single panel — tightest common rectangle |
|
||||||
|
| 128×32 | 2×1 | the baseline most plugins are tuned for |
|
||||||
|
| 64×64 | 1×2 | stacked — tall-narrow centering |
|
||||||
|
| 128×64 | 2×2 | block — icon scaling / vertical centering |
|
||||||
|
| 256×32 | 4×1 | long strip — wide horizontal layout |
|
||||||
|
| 128×96 | 2×3 | tall — vertical overflow |
|
||||||
|
| 256×128 | 4×4 | large block — both dimensions big at once |
|
||||||
|
|
||||||
|
**Override the sizes entirely** to test your actual hardware (or any shape):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI — one-off:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
|
||||||
|
|
||||||
|
# pytest — force every plugin onto your panel(s):
|
||||||
|
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
|
||||||
|
|
||||||
|
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
|
||||||
|
# { "sizes": [[8, 16], [64, 64]] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
|
||||||
|
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
|
||||||
|
uses — the backing canvas is padded out to the **largest** panel in the run, so
|
||||||
|
a coordinate meant for a big build is still caught when rendering a small one.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Functional + bounds check across all sizes/screens:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple
|
||||||
|
|
||||||
|
# Every discovered plugin:
|
||||||
|
python scripts/check_plugin.py --all
|
||||||
|
|
||||||
|
# Dump PNGs to eyeball each size/screen:
|
||||||
|
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
|
||||||
|
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
|
||||||
|
|
||||||
|
## What it checks (Phase 1 — always on)
|
||||||
|
|
||||||
|
1. **Loads** and builds its mode list.
|
||||||
|
2. **Renders every screen** at every size without raising. `update()` may fail
|
||||||
|
(no network in CI) and is tolerated; a crash in `display()` is a failure —
|
||||||
|
`display()` must handle the no-data state.
|
||||||
|
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
|
||||||
|
`BoundsCheckingDisplayManager`, which backs the declared panel with an
|
||||||
|
oversized canvas and flags any pixels that land in the margin. (Left/top
|
||||||
|
overflow at negative coordinates and BDF text are not flagged — golden images
|
||||||
|
cover those.)
|
||||||
|
|
||||||
|
## Golden images (Phase 2 — opt-in per plugin)
|
||||||
|
|
||||||
|
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/<id>/test/harness.json # how to render deterministically
|
||||||
|
plugins/<id>/test/fixtures/mock.json # optional cached data
|
||||||
|
plugins/<id>/test/golden/<WxH>/<mode>.png
|
||||||
|
```
|
||||||
|
|
||||||
|
`test/harness.json` keys (all optional):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { "timezone": "UTC" },
|
||||||
|
"mock_data": "fixtures/mock.json",
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"skip_update": false,
|
||||||
|
"sizes": [[128, 32], [128, 64]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate / refresh goldens after an intentional visual change, then review the
|
||||||
|
diff before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||||
|
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
|
||||||
|
```
|
||||||
|
|
||||||
|
Comparison is exact by default (`compare_images` in `harness.py` accepts a
|
||||||
|
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
|
||||||
|
and the bundled fonts — keep both stable when regenerating goldens.
|
||||||
|
|
||||||
|
## Tests & CI
|
||||||
|
|
||||||
|
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
|
||||||
|
comparison, and mode enumeration (run anywhere).
|
||||||
|
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
|
||||||
|
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
|
||||||
|
when no plugins are present (e.g. a fresh core checkout); set
|
||||||
|
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
|
||||||
|
turn an empty discovery into a hard failure instead. Point it at the monorepo
|
||||||
|
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
|
||||||
|
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
|
||||||
|
|
||||||
|
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
|
||||||
|
against changed plugins on every PR.
|
||||||
|
|
||||||
|
## Developer workflow
|
||||||
|
|
||||||
|
1. Change the plugin on a branch.
|
||||||
|
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
|
||||||
|
eyeball the PNGs.
|
||||||
|
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
|
||||||
|
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
|
||||||
|
`plugins.json`.
|
||||||
|
5. Push — CI re-runs the harness across all sizes and gates the PR.
|
||||||
9
requirements-test.txt
Normal file
9
requirements-test.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Test-only dependencies for the plugin safety harness and pytest suite.
|
||||||
|
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
#
|
||||||
|
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
|
||||||
|
# major-version caps) in requirements.txt, so they are intentionally NOT
|
||||||
|
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
|
||||||
|
# pytest>=9.0.3,<10 and made the two files impossible to install together.
|
||||||
|
# Only declare what requirements.txt doesn't already provide.
|
||||||
|
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||||
232
scripts/check_plugin.py
Normal file
232
scripts/check_plugin.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Plugin safety checker.
|
||||||
|
|
||||||
|
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||||
|
size, and fails if any screen crashes, overflows the panel, or (for plugins with
|
||||||
|
committed golden images) drifts visually.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Functional + bounds check across all sizes/modes:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple
|
||||||
|
|
||||||
|
# Every discovered plugin:
|
||||||
|
python scripts/check_plugin.py --all
|
||||||
|
|
||||||
|
# Dump PNGs for each size/mode so you can eyeball them:
|
||||||
|
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||||
|
|
||||||
|
# Refresh committed golden images after an intentional visual change:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||||
|
--mock-data plugins/clock-simple/test/fixtures/mock.json
|
||||||
|
|
||||||
|
Exit code is non-zero if any (plugin, size, mode) fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
os.environ['EMULATOR'] = 'true'
|
||||||
|
|
||||||
|
from src.logging_config import get_logger # noqa: E402
|
||||||
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
|
find_plugin_dir, load_config_defaults, load_harness_spec,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||||
|
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||||
|
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger("[Check Plugin]")
|
||||||
|
|
||||||
|
DEFAULT_SEARCH_DIRS = [
|
||||||
|
str(PROJECT_ROOT / 'plugins'),
|
||||||
|
str(PROJECT_ROOT / 'plugin-repos'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins(search_dirs: List[str]) -> List[str]:
|
||||||
|
"""All plugin ids found across the search dirs (dirs containing manifest.json)."""
|
||||||
|
found = []
|
||||||
|
for d in search_dirs:
|
||||||
|
base = Path(d)
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
for child in sorted(base.iterdir()):
|
||||||
|
if (child / 'manifest.json').exists() and child.name not in found:
|
||||||
|
found.append(child.name)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sizes(spec: Optional[str]):
|
||||||
|
if not spec:
|
||||||
|
return None
|
||||||
|
sizes = []
|
||||||
|
for token in spec.split(','):
|
||||||
|
if not token.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sizes.append(parse_size_token(token))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(str(exc)) from exc
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
|
||||||
|
def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||||
|
config: Dict, run_update: bool, out_dir: Optional[Path],
|
||||||
|
update_golden: bool, golden_dir_override: Optional[Path],
|
||||||
|
freeze_time: Optional[str]) -> List[RenderResult]:
|
||||||
|
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||||
|
if not plugin_dir:
|
||||||
|
logger.error("Plugin '%s' not found in: %s", plugin_id, search_dirs)
|
||||||
|
return [RenderResult(plugin_id, 0, 0, "<not-found>", error="plugin directory not found")]
|
||||||
|
|
||||||
|
# Per-plugin test/harness.json holds the deterministic settings the committed
|
||||||
|
# goldens were generated with (config, mock data, frozen time, sizes). Load
|
||||||
|
# them so the CLI/CI render reproduces the golden the same way the pytest
|
||||||
|
# matrix path does; explicit CLI flags still override the file.
|
||||||
|
spec = load_harness_spec(plugin_dir)
|
||||||
|
|
||||||
|
# config_schema defaults (real-install behavior), then harness.json config,
|
||||||
|
# then CLI --config — most specific wins.
|
||||||
|
full_config = {"enabled": True}
|
||||||
|
full_config.update(load_config_defaults(plugin_dir))
|
||||||
|
full_config.update(spec.get("config", {}))
|
||||||
|
full_config.update(config)
|
||||||
|
|
||||||
|
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
||||||
|
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
||||||
|
# CLI value wins when provided, else fall back to the harness.json setting.
|
||||||
|
effective_mock_data = mock_data or spec.get("mock_data_contents", {})
|
||||||
|
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||||
|
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||||
|
|
||||||
|
results = render_plugin_matrix(
|
||||||
|
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
||||||
|
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||||
|
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||||
|
)
|
||||||
|
|
||||||
|
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
||||||
|
if update_golden:
|
||||||
|
written = write_goldens(results, golden_dir)
|
||||||
|
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
||||||
|
else:
|
||||||
|
compare_to_goldens(results, golden_dir)
|
||||||
|
|
||||||
|
if out_dir:
|
||||||
|
for r in results:
|
||||||
|
if r.image is None:
|
||||||
|
continue
|
||||||
|
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||||
|
"""Print a per-plugin grid. Returns True if everything passed."""
|
||||||
|
everything_ok = True
|
||||||
|
for plugin_id, results in all_results.items():
|
||||||
|
print(f"\n=== {plugin_id} ===")
|
||||||
|
for r in results:
|
||||||
|
if r.ok:
|
||||||
|
status = "PASS"
|
||||||
|
detail = ""
|
||||||
|
if r.golden_checked:
|
||||||
|
detail = " (golden ✓)"
|
||||||
|
if r.update_error is not None:
|
||||||
|
detail += f" (update warn: {r.update_error})"
|
||||||
|
else:
|
||||||
|
everything_ok = False
|
||||||
|
if r.error is not None:
|
||||||
|
status, detail = "FAIL", f" error={r.error}"
|
||||||
|
elif r.overflow is not None:
|
||||||
|
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
||||||
|
elif r.golden_ok is False:
|
||||||
|
status = "FAIL"
|
||||||
|
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||||
|
else:
|
||||||
|
status, detail = "FAIL", ""
|
||||||
|
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||||
|
print()
|
||||||
|
return everything_ok
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Check a plugin renders safely across sizes & screens")
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument('--plugin', '-p', help='Plugin id to check')
|
||||||
|
group.add_argument('--all', action='store_true', help='Check every discovered plugin')
|
||||||
|
parser.add_argument('--plugin-dir', '-d', default=None, help='Directory to search for plugins')
|
||||||
|
parser.add_argument('--sizes', default=None, help='Comma-separated WxH list (default: all supported)')
|
||||||
|
parser.add_argument('--config', '-c', default='{}', help='Plugin config overrides as JSON')
|
||||||
|
parser.add_argument('--mock-data', '-m', default=None, help='Path to JSON file with mock cache data')
|
||||||
|
parser.add_argument('--out-dir', '-o', default=None, help='Also dump rendered PNGs here')
|
||||||
|
parser.add_argument('--skip-update', action='store_true', help='Skip calling update()')
|
||||||
|
parser.add_argument('--update-golden', action='store_true', help='Write/refresh golden images')
|
||||||
|
parser.add_argument('--golden-dir', default=None, help='Override golden dir (default: <plugin>/test/golden)')
|
||||||
|
parser.add_argument('--freeze-time', default=None,
|
||||||
|
help='Freeze wall clock, e.g. "2025-08-01 15:25:00" (for time-dependent plugins)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
search_dirs = [args.plugin_dir] if args.plugin_dir else DEFAULT_SEARCH_DIRS
|
||||||
|
sizes = parse_sizes(args.sizes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = json.loads(args.config)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Invalid --config JSON: %s", e)
|
||||||
|
return 2
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
logger.error("--config must be a JSON object, got %s", type(config).__name__)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
mock_data = {}
|
||||||
|
if args.mock_data:
|
||||||
|
mock_path = Path(args.mock_data)
|
||||||
|
if not mock_path.exists():
|
||||||
|
logger.error("Mock data file not found: %s", args.mock_data)
|
||||||
|
return 2
|
||||||
|
with open(mock_path) as f:
|
||||||
|
mock_data = json.load(f)
|
||||||
|
if not isinstance(mock_data, dict):
|
||||||
|
logger.error("--mock-data must be a JSON object (key -> cache value), got %s",
|
||||||
|
type(mock_data).__name__)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
plugin_ids = discover_plugins(search_dirs) if args.all else [args.plugin]
|
||||||
|
if not plugin_ids:
|
||||||
|
logger.error("No plugins found in: %s", search_dirs)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
out_dir = Path(args.out_dir) if args.out_dir else None
|
||||||
|
golden_dir_override = Path(args.golden_dir) if args.golden_dir else None
|
||||||
|
|
||||||
|
all_results: Dict[str, List[RenderResult]] = {}
|
||||||
|
for plugin_id in plugin_ids:
|
||||||
|
all_results[plugin_id] = check_one(
|
||||||
|
plugin_id=plugin_id, search_dirs=search_dirs, sizes=sizes,
|
||||||
|
mock_data=mock_data, config=config, run_update=not args.skip_update,
|
||||||
|
out_dir=out_dir, update_golden=args.update_golden,
|
||||||
|
golden_dir_override=golden_dir_override, freeze_time=args.freeze_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# When refreshing goldens we skip drift comparison, but a crash or overflow
|
||||||
|
# still means the plugin is broken — never let --update-golden mask that.
|
||||||
|
ok = print_report(all_results)
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -17,7 +17,6 @@ import os
|
|||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Sequence, Union
|
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -28,49 +27,15 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
# Import logger after path setup so src.logging_config is importable
|
# Import logger after path setup so src.logging_config is importable
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
|
find_plugin_dir, load_manifest, load_config_defaults,
|
||||||
|
)
|
||||||
logger = get_logger("[Render Plugin]")
|
logger = get_logger("[Render Plugin]")
|
||||||
|
|
||||||
MIN_DIMENSION = 1
|
MIN_DIMENSION = 1
|
||||||
MAX_DIMENSION = 512
|
MAX_DIMENSION = 512
|
||||||
|
|
||||||
|
|
||||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
|
||||||
"""Find a plugin directory by searching multiple paths."""
|
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
|
||||||
loader = PluginLoader()
|
|
||||||
for search_dir in search_dirs:
|
|
||||||
search_path = Path(search_dir)
|
|
||||||
if not search_path.exists():
|
|
||||||
continue
|
|
||||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
|
||||||
if result:
|
|
||||||
return Path(result)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
|
||||||
"""Load and return manifest.json from plugin directory."""
|
|
||||||
manifest_path = plugin_dir / 'manifest.json'
|
|
||||||
if not manifest_path.exists():
|
|
||||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
|
||||||
with open(manifest_path, 'r') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
|
||||||
"""Extract default values from config_schema.json."""
|
|
||||||
schema_path = plugin_dir / 'config_schema.json'
|
|
||||||
if not schema_path.exists():
|
|
||||||
return {}
|
|
||||||
with open(schema_path, 'r') as f:
|
|
||||||
schema = json.load(f)
|
|
||||||
defaults: Dict[str, Any] = {}
|
|
||||||
for key, prop in schema.get('properties', {}).items():
|
|
||||||
if 'default' in prop:
|
|
||||||
defaults[key] = prop['default']
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Cache Manager — multi-tier response cache for the LEDMatrix application.
|
||||||
|
|
||||||
|
:class:`CacheManager` provides a unified caching layer used by all plugins
|
||||||
|
to reduce external API calls and survive network outages gracefully.
|
||||||
|
|
||||||
|
Two storage tiers
|
||||||
|
-----------------
|
||||||
|
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
|
||||||
|
cache (up to 1 000 entries by default). Hit on this tier before touching
|
||||||
|
disk.
|
||||||
|
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
|
||||||
|
persistent store that survives process restarts.
|
||||||
|
|
||||||
|
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
|
||||||
|
``datetime`` objects transparently so callers don't have to pre-serialise them.
|
||||||
|
|
||||||
|
Typical plugin usage::
|
||||||
|
|
||||||
|
data = self.cache_manager.get_cached_data('my_key', max_age=300)
|
||||||
|
if data is None:
|
||||||
|
data = fetch_from_api()
|
||||||
|
self.cache_manager.save_cache('my_key', data)
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -15,7 +40,10 @@ from src.cache.cache_metrics import CacheMetrics
|
|||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
|
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
|
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
||||||
if isinstance(obj, datetime):
|
if isinstance(obj, datetime):
|
||||||
return obj.isoformat()
|
return obj.isoformat()
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
|
|||||||
@@ -347,34 +347,40 @@ class ScrollHelper:
|
|||||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||||
|
|
||||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||||
"""Fast integer pixel extraction (no interpolation)."""
|
"""Fast integer pixel extraction (no interpolation).
|
||||||
# Fast numpy array slicing for normal case (no wrap-around)
|
|
||||||
if end_x <= self.cached_image.width:
|
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
||||||
# Normal case: single slice - fastest path
|
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
||||||
frame_array = self.cached_array[:, start_x:end_x]
|
slices (128×32 = 12 KB) used here.
|
||||||
# Convert to PIL Image (minimal overhead)
|
"""
|
||||||
return Image.fromarray(frame_array)
|
_size = (self.display_width, self.display_height)
|
||||||
|
img_w = self.cached_image.width
|
||||||
|
|
||||||
|
if end_x <= img_w:
|
||||||
|
# Normal case: single contiguous slice (fastest path)
|
||||||
|
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
||||||
|
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
||||||
else:
|
else:
|
||||||
# Wrap-around case: combine two slices using numpy
|
# Ensure frame buffer is allocated for all non-simple paths
|
||||||
width1 = self.cached_image.width - start_x
|
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||||
|
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
width1 = img_w - start_x
|
||||||
if width1 > 0:
|
if width1 > 0:
|
||||||
# Use pre-allocated buffer for output
|
# Wrap-around: tail of image + head of image
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
|
||||||
|
|
||||||
# First part from end of image (fast numpy slice)
|
|
||||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||||
|
|
||||||
# Second part from beginning of image
|
|
||||||
remaining_width = self.display_width - width1
|
remaining_width = self.display_width - width1
|
||||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||||
|
|
||||||
# Convert combined buffer to PIL Image
|
|
||||||
return Image.fromarray(self._frame_buffer)
|
|
||||||
else:
|
else:
|
||||||
# Edge case: start_x >= image width, wrap to beginning
|
# Edge case: start_x at or past image end — show from beginning,
|
||||||
frame_array = self.cached_array[:, :self.display_width]
|
# clamped to available width (scroll_position should wrap before
|
||||||
return Image.fromarray(frame_array)
|
# reaching this state in normal operation).
|
||||||
|
available = min(self.display_width, img_w)
|
||||||
|
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
||||||
|
if available < self.display_width:
|
||||||
|
self._frame_buffer[:, available:] = 0
|
||||||
|
|
||||||
|
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||||
|
|
||||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Config Manager — reads, writes, and validates ``config/config.json``.
|
||||||
|
|
||||||
|
:class:`ConfigManager` is the single owner of the on-disk configuration
|
||||||
|
files:
|
||||||
|
|
||||||
|
* ``config/config.json`` — main user-editable configuration.
|
||||||
|
* ``config/config_secrets.json`` — sensitive values (API keys, tokens).
|
||||||
|
|
||||||
|
All writes go through :class:`~src.config_manager_atomic.AtomicConfigManager`
|
||||||
|
which performs a backup before overwriting, validates the result, and rolls
|
||||||
|
back on error. This makes config corruption essentially impossible.
|
||||||
|
|
||||||
|
Plugin configuration
|
||||||
|
--------------------
|
||||||
|
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||||
|
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
||||||
|
to write plugin settings; never write directly to the plugin directory.
|
||||||
|
|
||||||
|
Hot-reload
|
||||||
|
----------
|
||||||
|
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
|
||||||
|
detects file changes, broadcasting the new config to registered listeners
|
||||||
|
without requiring a restart.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -17,6 +43,13 @@ from src.common.permission_utils import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
|
"""
|
||||||
|
Reads and writes the main application configuration files.
|
||||||
|
|
||||||
|
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
|
||||||
|
atomic writes with automatic backup and rollback. Also exposes helpers
|
||||||
|
for plugin configuration persistence and secret-field masking.
|
||||||
|
"""
|
||||||
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
||||||
# Use current working directory as base
|
# Use current working directory as base
|
||||||
self.config_path: str = config_path or "config/config.json"
|
self.config_path: str = config_path or "config/config.json"
|
||||||
@@ -29,9 +62,11 @@ class ConfigManager:
|
|||||||
self._atomic_manager: Optional[AtomicConfigManager] = None
|
self._atomic_manager: Optional[AtomicConfigManager] = None
|
||||||
|
|
||||||
def get_config_path(self) -> str:
|
def get_config_path(self) -> str:
|
||||||
|
"""Return the path to the main config file (``config/config.json``)."""
|
||||||
return self.config_path
|
return self.config_path
|
||||||
|
|
||||||
def get_secrets_path(self) -> str:
|
def get_secrets_path(self) -> str:
|
||||||
|
"""Return the path to the secrets file (``config/config_secrets.json``)."""
|
||||||
return self.secrets_path
|
return self.secrets_path
|
||||||
|
|
||||||
def _get_atomic_manager(self) -> AtomicConfigManager:
|
def _get_atomic_manager(self) -> AtomicConfigManager:
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Display Controller — top-level orchestration for the LEDMatrix application.
|
||||||
|
|
||||||
|
This module owns the main run loop that drives the LED display. It ties
|
||||||
|
together every major subsystem:
|
||||||
|
|
||||||
|
- ConfigManager / ConfigService — loads config.json, hot-reloads on change
|
||||||
|
- DisplayManager — hardware (or emulator) output interface
|
||||||
|
- FontManager — TTF/BDF font loading and caching
|
||||||
|
- CacheManager — multi-tier API response cache
|
||||||
|
- PluginManager — plugin lifecycle (load, update, display)
|
||||||
|
- DisplaySyncManager — optional leader/follower multi-Pi sync
|
||||||
|
- VegasModeCoordinator — optional continuous Vegas scroll mode
|
||||||
|
|
||||||
|
The main loop inside :meth:`DisplayController.run` rotates through enabled
|
||||||
|
plugin display modes, respecting schedule windows, brightness dim schedules,
|
||||||
|
on-demand overrides, and live-priority interrupts.
|
||||||
|
|
||||||
|
Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||||
|
:meth:`~DisplayController.run`.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
@@ -28,6 +50,24 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
|||||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||||
|
|
||||||
class DisplayController:
|
class DisplayController:
|
||||||
|
"""
|
||||||
|
Top-level controller that owns the LED display run loop.
|
||||||
|
|
||||||
|
Responsibilities
|
||||||
|
----------------
|
||||||
|
* Initialise and wire together all subsystems at startup.
|
||||||
|
* Rotate through plugin display modes in :meth:`run`.
|
||||||
|
* Honour schedule windows (active/inactive hours) and dim schedules.
|
||||||
|
* Handle on-demand override requests (external callers can pin a
|
||||||
|
specific plugin/mode for a fixed duration via the cache bus).
|
||||||
|
* Coordinate with a follower Pi when multi-display sync is configured.
|
||||||
|
* Delegate all actual content to the plugin system — this class contains
|
||||||
|
no display logic of its own.
|
||||||
|
|
||||||
|
There is exactly one instance per process; call :func:`main` to create
|
||||||
|
it and start the run loop.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info("Starting DisplayController initialization")
|
logger.info("Starting DisplayController initialization")
|
||||||
@@ -138,7 +178,11 @@ class DisplayController:
|
|||||||
self.on_demand_last_event: Optional[str] = None
|
self.on_demand_last_event: Optional[str] = None
|
||||||
self.on_demand_schedule_override = False
|
self.on_demand_schedule_override = False
|
||||||
self.rotation_resume_index: Optional[int] = None
|
self.rotation_resume_index: Optional[int] = None
|
||||||
|
# Saved rotation position when a live-priority plugin preempts the
|
||||||
|
# rotation, so it resumes where it left off (not after the live plugin)
|
||||||
|
# once live priority ends.
|
||||||
|
self._live_resume_index: Optional[int] = None
|
||||||
|
|
||||||
# WiFi status message tracking
|
# WiFi status message tracking
|
||||||
global WIFI_STATUS_FILE
|
global WIFI_STATUS_FILE
|
||||||
if WIFI_STATUS_FILE is None:
|
if WIFI_STATUS_FILE is None:
|
||||||
@@ -148,7 +192,11 @@ class DisplayController:
|
|||||||
self.wifi_status_file = WIFI_STATUS_FILE
|
self.wifi_status_file = WIFI_STATUS_FILE
|
||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at: Optional[float] = None
|
self.wifi_status_expires_at: Optional[float] = None
|
||||||
|
|
||||||
|
# Plugin display() signature cache — must be initialised before the plugin
|
||||||
|
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||||
|
self._plugin_accepts_display_mode: Dict[str, bool] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Attempting to import plugin system...")
|
logger.info("Attempting to import plugin system...")
|
||||||
from src.plugin_system import PluginManager
|
from src.plugin_system import PluginManager
|
||||||
@@ -321,6 +369,8 @@ class DisplayController:
|
|||||||
self.plugin_modes[mode] = plugin_instance
|
self.plugin_modes[mode] = plugin_instance
|
||||||
self.mode_to_plugin_id[mode] = plugin_id
|
self.mode_to_plugin_id[mode] = plugin_id
|
||||||
logger.debug(" Added mode: %s", mode)
|
logger.debug(" Added mode: %s", mode)
|
||||||
|
# Invalidate signature cache so the new instance is re-inspected
|
||||||
|
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
|
||||||
# Show progress
|
# Show progress
|
||||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||||
@@ -367,11 +417,39 @@ class DisplayController:
|
|||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True # Track previous state for schedule change detection
|
self._was_display_active = True # Track previous state for schedule change detection
|
||||||
|
|
||||||
|
# --- Opt #2: cached config values ---
|
||||||
|
# Avoids chained dict.get() with temporary {} defaults on every hot path call.
|
||||||
|
# Refreshed via _refresh_config_cache() on every hot-reload.
|
||||||
|
self._normal_brightness: int = (
|
||||||
|
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||||
|
)
|
||||||
|
self._scroll_speed: float = (
|
||||||
|
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||||
|
)
|
||||||
|
|
||||||
# Brightness state tracking for dim schedule
|
# Brightness state tracking for dim schedule
|
||||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
self.current_brightness = self._normal_brightness
|
||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
self._was_dimmed = False
|
self._was_dimmed = False
|
||||||
|
|
||||||
|
# --- Opt #3: schedule minute-gate ---
|
||||||
|
# Both _check_schedule and _check_dim_schedule re-evaluated at most once per
|
||||||
|
# clock minute. Storing the (hour, minute) tuple that was last evaluated lets
|
||||||
|
# the methods skip all timezone / strptime work within the same minute.
|
||||||
|
# Reset to None on config change so the next call re-evaluates immediately.
|
||||||
|
self._tz = None # pytz timezone, lazily built from config
|
||||||
|
self._schedule_checked_minute: Optional[tuple] = None
|
||||||
|
self._dim_checked_minute: Optional[tuple] = None
|
||||||
|
self._cached_target_brightness: int = self._normal_brightness
|
||||||
|
|
||||||
|
# Register controller-level hot-reload callback so cached config values
|
||||||
|
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||||
|
# when the user saves settings via the web UI.
|
||||||
|
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||||
|
self._refresh_config_cache(new_config)
|
||||||
|
|
||||||
|
self.config_service.subscribe(_controller_config_change)
|
||||||
|
|
||||||
# Publish initial on-demand state
|
# Publish initial on-demand state
|
||||||
try:
|
try:
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
@@ -533,17 +611,24 @@ class DisplayController:
|
|||||||
logger.debug("Schedule is disabled - display always active")
|
logger.debug("Schedule is disabled - display always active")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get configured timezone, default to UTC
|
# Lazily build the timezone object once; reuse on every subsequent call.
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
if self._tz is None:
|
||||||
try:
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
tz = pytz.timezone(timezone_str)
|
try:
|
||||||
except pytz.UnknownTimeZoneError:
|
self._tz = pytz.timezone(timezone_str)
|
||||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
except pytz.UnknownTimeZoneError:
|
||||||
tz = pytz.UTC
|
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
|
||||||
|
self._tz = pytz.UTC
|
||||||
|
|
||||||
# Use timezone-aware current time
|
current_time = datetime.now(self._tz)
|
||||||
current_time = datetime.now(tz)
|
# Gate: schedule state can only change on a minute boundary, so skip
|
||||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
# all the strptime / comparison work if we already evaluated this minute.
|
||||||
|
current_minute_key = (current_time.hour, current_time.minute)
|
||||||
|
if current_minute_key == self._schedule_checked_minute:
|
||||||
|
return
|
||||||
|
self._schedule_checked_minute = current_minute_key
|
||||||
|
|
||||||
|
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||||
current_time_only = current_time.time()
|
current_time_only = current_time.time()
|
||||||
|
|
||||||
# Check if per-day schedule is configured
|
# Check if per-day schedule is configured
|
||||||
@@ -632,8 +717,8 @@ class DisplayController:
|
|||||||
Target brightness level (dim_brightness if in dim period,
|
Target brightness level (dim_brightness if in dim period,
|
||||||
normal brightness otherwise)
|
normal brightness otherwise)
|
||||||
"""
|
"""
|
||||||
# Get normal brightness from config
|
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
normal_brightness = self._normal_brightness
|
||||||
|
|
||||||
# If display is OFF via schedule, don't process dim schedule
|
# If display is OFF via schedule, don't process dim schedule
|
||||||
if not self.is_display_active:
|
if not self.is_display_active:
|
||||||
@@ -647,15 +732,21 @@ class DisplayController:
|
|||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
# Get configured timezone
|
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
if self._tz is None:
|
||||||
try:
|
timezone_str = self.config.get('timezone', 'UTC')
|
||||||
tz = pytz.timezone(timezone_str)
|
try:
|
||||||
except pytz.UnknownTimeZoneError:
|
self._tz = pytz.timezone(timezone_str)
|
||||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
except pytz.UnknownTimeZoneError:
|
||||||
tz = pytz.UTC
|
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
|
||||||
|
self._tz = pytz.UTC
|
||||||
|
|
||||||
|
current_time = datetime.now(self._tz)
|
||||||
|
current_minute_key = (current_time.hour, current_time.minute)
|
||||||
|
if current_minute_key == self._dim_checked_minute:
|
||||||
|
return self._cached_target_brightness
|
||||||
|
self._dim_checked_minute = current_minute_key
|
||||||
|
|
||||||
current_time = datetime.now(tz)
|
|
||||||
current_day = current_time.strftime('%A').lower()
|
current_day = current_time.strftime('%A').lower()
|
||||||
current_time_only = current_time.time()
|
current_time_only = current_time.time()
|
||||||
|
|
||||||
@@ -703,10 +794,12 @@ class DisplayController:
|
|||||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||||
|
|
||||||
self._was_dimmed = self.is_dimmed
|
self._was_dimmed = self.is_dimmed
|
||||||
|
self._cached_target_brightness = target_brightness # persist for minute-gate
|
||||||
return target_brightness
|
return target_brightness
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Invalid dim schedule time format: {e}")
|
logger.warning("Invalid dim schedule time format: %s", e)
|
||||||
|
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
def _update_modules(self):
|
def _update_modules(self):
|
||||||
@@ -1382,6 +1475,36 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error logging memory stats: {e}")
|
logger.debug(f"Error logging memory stats: {e}")
|
||||||
|
|
||||||
|
def _apply_live_priority(self, live_priority_mode):
|
||||||
|
"""Switch to a live-priority mode, or resume rotation when it ends.
|
||||||
|
|
||||||
|
When a live-priority plugin preempts the rotation, the position the
|
||||||
|
rotation had reached is saved so that, once live priority ends, the
|
||||||
|
rotation resumes from there instead of continuing after the live
|
||||||
|
plugin's mode (which would skip every mode between the two). The save
|
||||||
|
happens only on the initial switch, not on each re-check while the
|
||||||
|
live hold continues.
|
||||||
|
"""
|
||||||
|
if live_priority_mode:
|
||||||
|
if self.current_display_mode != live_priority_mode:
|
||||||
|
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
||||||
|
if self._live_resume_index is None:
|
||||||
|
self._live_resume_index = self.current_mode_index
|
||||||
|
self.current_display_mode = live_priority_mode
|
||||||
|
self.force_change = True
|
||||||
|
# Update mode index to match the new mode
|
||||||
|
try:
|
||||||
|
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif self._live_resume_index is not None and self.available_modes:
|
||||||
|
# Live priority ended — resume rotation where it was interrupted.
|
||||||
|
self.current_mode_index = self._live_resume_index % len(self.available_modes)
|
||||||
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
|
self.force_change = True
|
||||||
|
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||||
|
self._live_resume_index = None
|
||||||
|
|
||||||
def _check_live_priority(self):
|
def _check_live_priority(self):
|
||||||
"""
|
"""
|
||||||
Check all plugins for live priority content.
|
Check all plugins for live priority content.
|
||||||
@@ -1483,12 +1606,8 @@ class DisplayController:
|
|||||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||||
width = self.display_manager.width
|
width = self.display_manager.width
|
||||||
|
|
||||||
# Advance local position at Vegas scroll speed (px/s → px/tick)
|
# Opt #2: use pre-cached scroll speed (constant for the run)
|
||||||
vegas_speed = (
|
vegas_speed = self._scroll_speed
|
||||||
self.config.get('display', {})
|
|
||||||
.get('vegas_scroll', {})
|
|
||||||
.get('scroll_speed', 75)
|
|
||||||
)
|
|
||||||
local_x = getattr(self, '_follower_local_x', None)
|
local_x = getattr(self, '_follower_local_x', None)
|
||||||
if local_x is None:
|
if local_x is None:
|
||||||
local_x = float(width) # safe start (past pre-roll guard)
|
local_x = float(width) # safe start (past pre-roll guard)
|
||||||
@@ -1573,15 +1692,7 @@ class DisplayController:
|
|||||||
# Check for live priority content and switch to it immediately
|
# Check for live priority content and switch to it immediately
|
||||||
if not self.on_demand_active and not wifi_status_data:
|
if not self.on_demand_active and not wifi_status_data:
|
||||||
live_priority_mode = self._check_live_priority()
|
live_priority_mode = self._check_live_priority()
|
||||||
if live_priority_mode and self.current_display_mode != live_priority_mode:
|
self._apply_live_priority(live_priority_mode)
|
||||||
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
|
||||||
self.current_display_mode = live_priority_mode
|
|
||||||
self.force_change = True
|
|
||||||
# Update mode index to match the new mode
|
|
||||||
try:
|
|
||||||
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||||
@@ -1628,7 +1739,8 @@ class DisplayController:
|
|||||||
|
|
||||||
manager_to_display = None
|
manager_to_display = None
|
||||||
|
|
||||||
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
|
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
|
||||||
|
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||||
|
|
||||||
# Handle plugin-based display modes
|
# Handle plugin-based display modes
|
||||||
if active_mode in self.plugin_modes:
|
if active_mode in self.plugin_modes:
|
||||||
@@ -1664,17 +1776,22 @@ class DisplayController:
|
|||||||
try:
|
try:
|
||||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||||
if hasattr(manager_to_display, 'display'):
|
if hasattr(manager_to_display, 'display'):
|
||||||
# Check if plugin accepts display_mode parameter
|
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||||
import inspect
|
_cache_key = plugin_id
|
||||||
sig = inspect.signature(manager_to_display.display)
|
if _cache_key not in self._plugin_accepts_display_mode:
|
||||||
|
import inspect as _inspect
|
||||||
|
self._plugin_accepts_display_mode[_cache_key] = (
|
||||||
|
'display_mode' in _inspect.signature(manager_to_display.display).parameters
|
||||||
|
)
|
||||||
|
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||||
|
|
||||||
# Use PluginExecutor for safe execution with timeout
|
# Use PluginExecutor for safe execution with timeout
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||||
result = self.plugin_manager.plugin_executor.execute_display(
|
result = self.plugin_manager.plugin_executor.execute_display(
|
||||||
manager_to_display,
|
manager_to_display,
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force_clear=self.force_change,
|
force_clear=self.force_change,
|
||||||
display_mode=active_mode if 'display_mode' in sig.parameters else None
|
display_mode=active_mode if _accepts_display_mode else None
|
||||||
)
|
)
|
||||||
# execute_display returns bool, convert to expected format
|
# execute_display returns bool, convert to expected format
|
||||||
if result:
|
if result:
|
||||||
@@ -1683,7 +1800,7 @@ class DisplayController:
|
|||||||
result = False # Failed
|
result = False # Failed
|
||||||
else:
|
else:
|
||||||
# Fallback to direct call if executor not available
|
# Fallback to direct call if executor not available
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=self.force_change)
|
result = manager_to_display.display(force_clear=self.force_change)
|
||||||
@@ -1820,9 +1937,9 @@ class DisplayController:
|
|||||||
min_duration = base_duration
|
min_duration = base_duration
|
||||||
if dynamic_enabled:
|
if dynamic_enabled:
|
||||||
# Try to get plugin-calculated cycle duration first
|
# Try to get plugin-calculated cycle duration first
|
||||||
logger.info("Attempting to get cycle duration for mode %s", active_mode)
|
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
|
||||||
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
||||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||||
|
|
||||||
# Get caps for validation
|
# Get caps for validation
|
||||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||||
@@ -1962,7 +2079,7 @@ class DisplayController:
|
|||||||
if needs_high_fps:
|
if needs_high_fps:
|
||||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||||
display_interval = 0.008
|
display_interval = 0.008
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||||
active_mode,
|
active_mode,
|
||||||
display_interval,
|
display_interval,
|
||||||
@@ -1972,7 +2089,7 @@ class DisplayController:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
# Pass display_mode to maintain sticky manager state
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
@@ -2014,7 +2131,7 @@ class DisplayController:
|
|||||||
else:
|
else:
|
||||||
# Normal FPS for other plugins (1 second)
|
# Normal FPS for other plugins (1 second)
|
||||||
display_interval = 1.0
|
display_interval = 1.0
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||||
active_mode,
|
active_mode,
|
||||||
display_interval
|
display_interval
|
||||||
@@ -2036,7 +2153,7 @@ class DisplayController:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
# Pass display_mode to maintain sticky manager state
|
||||||
if 'display_mode' in sig.parameters:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
else:
|
else:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(force_clear=False)
|
||||||
@@ -2333,6 +2450,30 @@ class DisplayController:
|
|||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at = None
|
self.wifi_status_expires_at = None
|
||||||
|
|
||||||
|
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
||||||
|
"""Refresh all config-derived caches when a hot-reload fires.
|
||||||
|
|
||||||
|
Called by the controller-level ConfigService subscriber. Keeps
|
||||||
|
``_normal_brightness``, ``_scroll_speed``, the cached timezone, and the
|
||||||
|
schedule minute-gates consistent with the live config so callers never
|
||||||
|
read stale values after the user saves settings via the web UI.
|
||||||
|
"""
|
||||||
|
self.config = new_config
|
||||||
|
self._normal_brightness = (
|
||||||
|
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||||
|
)
|
||||||
|
self._scroll_speed = (
|
||||||
|
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||||
|
)
|
||||||
|
# Force the timezone to be re-derived from the new config on next schedule check
|
||||||
|
self._tz = None
|
||||||
|
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
||||||
|
self._schedule_checked_minute = None
|
||||||
|
self._dim_checked_minute = None
|
||||||
|
self._cached_target_brightness = self._normal_brightness
|
||||||
|
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
||||||
|
self._normal_brightness, self._scroll_speed)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
# Shutdown config service if it exists
|
# Shutdown config service if it exists
|
||||||
@@ -2347,6 +2488,7 @@ class DisplayController:
|
|||||||
logger.info("Cleanup complete.")
|
logger.info("Cleanup complete.")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||||
controller = DisplayController()
|
controller = DisplayController()
|
||||||
controller.run()
|
controller.run()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
"""
|
||||||
|
Display Manager — hardware abstraction layer for the RGB LED matrix.
|
||||||
|
|
||||||
|
This module provides :class:`DisplayManager`, the single interface between
|
||||||
|
application code and the physical (or emulated) LED panel.
|
||||||
|
|
||||||
|
Key responsibilities
|
||||||
|
--------------------
|
||||||
|
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
|
||||||
|
on the ``EMULATOR`` environment variable.
|
||||||
|
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
|
||||||
|
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
|
||||||
|
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
|
||||||
|
* Provide ``width`` / ``height`` properties — always use these instead of
|
||||||
|
hard-coding display dimensions.
|
||||||
|
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
|
||||||
|
web-interface live preview.
|
||||||
|
* Track scrolling state and gate deferred updates so plugins don't race with
|
||||||
|
an in-progress scroll.
|
||||||
|
|
||||||
|
Singleton: only one ``DisplayManager`` instance exists per process. The
|
||||||
|
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
||||||
|
the same object.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -18,6 +43,24 @@ logger = logging.getLogger(__name__)
|
|||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
|
"""
|
||||||
|
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||||
|
|
||||||
|
Plugins should never interact with ``RGBMatrix`` directly; they use this
|
||||||
|
class to draw content and call :meth:`update_display` to push frames to
|
||||||
|
the panel.
|
||||||
|
|
||||||
|
Typical plugin usage::
|
||||||
|
|
||||||
|
canvas = Image.new('RGB', (self.display_manager.width,
|
||||||
|
self.display_manager.height), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
# ... draw content ...
|
||||||
|
self.display_manager.image = canvas
|
||||||
|
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
|
||||||
|
self.display_manager.update_display()
|
||||||
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
@@ -33,6 +76,10 @@ class DisplayManager:
|
|||||||
self._suppress_test_pattern = suppress_test_pattern
|
self._suppress_test_pattern = suppress_test_pattern
|
||||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||||
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
|
self._text_width_cache: Dict[tuple, int] = {}
|
||||||
# Snapshot settings for web preview integration (service writes, web reads)
|
# Snapshot settings for web preview integration (service writes, web reads)
|
||||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||||
@@ -437,6 +484,9 @@ class DisplayManager:
|
|||||||
|
|
||||||
def _load_fonts(self):
|
def _load_fonts(self):
|
||||||
"""Load fonts with proper error handling."""
|
"""Load fonts with proper error handling."""
|
||||||
|
# Font objects get new id()s after reload, so the text-width cache would
|
||||||
|
# return stale measurements keyed on the old ids. Clear it here.
|
||||||
|
self._text_width_cache.clear()
|
||||||
try:
|
try:
|
||||||
# Load Press Start 2P font
|
# Load Press Start 2P font
|
||||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||||
@@ -497,22 +547,32 @@ class DisplayManager:
|
|||||||
|
|
||||||
|
|
||||||
def get_text_width(self, text, font):
|
def get_text_width(self, text, font):
|
||||||
"""Get the width of text when rendered with the given font."""
|
"""Get the width of text when rendered with the given font.
|
||||||
|
|
||||||
|
Results are cached by (text, font identity) so plugins that measure
|
||||||
|
the same string every frame (e.g. to centre a score) pay only one
|
||||||
|
measurement per unique (text, font) pair.
|
||||||
|
"""
|
||||||
|
cache_key = (text, id(font))
|
||||||
|
cached = self._text_width_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
# For FreeType faces, calculate width using freetype
|
|
||||||
width = 0
|
width = 0
|
||||||
for char in text:
|
for char in text:
|
||||||
font.load_char(char)
|
font.load_char(char)
|
||||||
width += font.glyph.advance.x >> 6
|
width += font.glyph.advance.x >> 6
|
||||||
return width
|
|
||||||
else:
|
else:
|
||||||
# For PIL fonts, use textbbox
|
|
||||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||||
return bbox[2] - bbox[0]
|
width = bbox[2] - bbox[0]
|
||||||
except Exception as e:
|
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||||
logger.error(f"Error getting text width: {e}")
|
logger.error("Error getting text width: %s", e)
|
||||||
return 0 # Return 0 as fallback
|
return 0
|
||||||
|
|
||||||
|
self._text_width_cache[cache_key] = width
|
||||||
|
return width
|
||||||
|
|
||||||
def get_font_height(self, font):
|
def get_font_height(self, font):
|
||||||
"""Get the height of the given font for line spacing purposes."""
|
"""Get the height of the given font for line spacing purposes."""
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Font Manager — TTF/BDF font loading, caching, and dynamic registration.
|
||||||
|
|
||||||
|
:class:`FontManager` serves two purposes:
|
||||||
|
|
||||||
|
1. **System fonts** — loads the configured small/medium/large TTF fonts (and
|
||||||
|
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
|
||||||
|
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
|
||||||
|
|
||||||
|
2. **Plugin fonts** — lets plugins register their own fonts at runtime via
|
||||||
|
:meth:`FontManager.register_manager_font` and resolve them later via
|
||||||
|
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
|
||||||
|
plugin ID so they cannot collide.
|
||||||
|
|
||||||
|
Font sources
|
||||||
|
------------
|
||||||
|
* Local paths relative to the project root.
|
||||||
|
* Remote URLs — downloaded once, cached to disk, and never re-fetched while
|
||||||
|
the cached copy is fresh.
|
||||||
|
|
||||||
|
BDF fallback
|
||||||
|
------------
|
||||||
|
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
|
||||||
|
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
|
||||||
|
for accurate width/height calculations.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import freetype
|
import freetype
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
import logging
|
import logging
|
||||||
from src.exceptions import PluginError
|
from src.exceptions import PluginError, ConfigError
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
from src.plugin_system.plugin_executor import PluginExecutor
|
from src.plugin_system.plugin_executor import PluginExecutor
|
||||||
@@ -81,7 +81,13 @@ class PluginManager:
|
|||||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||||
self.plugin_modules: Dict[str, Any] = {}
|
self.plugin_modules: Dict[str, Any] = {}
|
||||||
self.plugin_last_update: Dict[str, float] = {}
|
self.plugin_last_update: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# Cached data-fetch intervals per plugin_id.
|
||||||
|
# _get_plugin_update_interval falls back to config_manager.get_config()
|
||||||
|
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
||||||
|
# that copy on every 30-fps tick. Cleared on load/unload.
|
||||||
|
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||||
|
|
||||||
# Health tracking (optional, set by display_controller if available)
|
# Health tracking (optional, set by display_controller if available)
|
||||||
self.health_tracker = None
|
self.health_tracker = None
|
||||||
self.resource_monitor = None
|
self.resource_monitor = None
|
||||||
@@ -388,6 +394,8 @@ class PluginManager:
|
|||||||
# Store plugin instance
|
# Store plugin instance
|
||||||
self.plugins[plugin_id] = plugin_instance
|
self.plugins[plugin_id] = plugin_instance
|
||||||
self.plugin_last_update[plugin_id] = 0.0
|
self.plugin_last_update[plugin_id] = 0.0
|
||||||
|
# Invalidate cached interval so next tick re-derives it for this plugin
|
||||||
|
self._update_interval_cache.pop(plugin_id, None)
|
||||||
|
|
||||||
# Update state based on enabled status
|
# Update state based on enabled status
|
||||||
if config.get('enabled', True):
|
if config.get('enabled', True):
|
||||||
@@ -444,8 +452,8 @@ class PluginManager:
|
|||||||
|
|
||||||
# Remove from active plugins
|
# Remove from active plugins
|
||||||
del self.plugins[plugin_id]
|
del self.plugins[plugin_id]
|
||||||
if plugin_id in self.plugin_last_update:
|
self.plugin_last_update.pop(plugin_id, None)
|
||||||
del self.plugin_last_update[plugin_id]
|
self._update_interval_cache.pop(plugin_id, None)
|
||||||
|
|
||||||
# Remove main module from sys.modules if present
|
# Remove main module from sys.modules if present
|
||||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||||
@@ -639,41 +647,46 @@ class PluginManager:
|
|||||||
|
|
||||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Get the update interval for a plugin.
|
Get the data-fetch interval for a plugin (seconds between update() calls).
|
||||||
|
|
||||||
Args:
|
Result is cached per plugin_id after the first lookup to avoid calling
|
||||||
plugin_id: Plugin identifier
|
config_manager.get_config() — which returns a full dict copy — on every
|
||||||
plugin_instance: Plugin instance
|
tick of the 30-fps display loop. The cache is invalidated when a plugin
|
||||||
|
is loaded or unloaded.
|
||||||
Returns:
|
|
||||||
Update interval in seconds or None if not configured
|
|
||||||
"""
|
"""
|
||||||
# Check manifest first
|
if plugin_id in self._update_interval_cache:
|
||||||
|
return self._update_interval_cache[plugin_id]
|
||||||
|
|
||||||
|
interval: Optional[float] = None
|
||||||
|
|
||||||
|
# 1. Manifest (immutable after load — preferred source)
|
||||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||||
update_interval = manifest.get('update_interval')
|
raw = manifest.get('update_interval')
|
||||||
|
if raw is not None:
|
||||||
if update_interval:
|
|
||||||
try:
|
try:
|
||||||
return float(update_interval)
|
interval = float(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check plugin config
|
# 2. Plugin config (mutable; only read once and then cached)
|
||||||
if self.config_manager:
|
if interval is None and self.config_manager:
|
||||||
try:
|
try:
|
||||||
config = self.config_manager.get_config()
|
config = self.config_manager.get_config()
|
||||||
plugin_config = config.get(plugin_id, {})
|
raw = config.get(plugin_id, {}).get('update_interval')
|
||||||
update_interval = plugin_config.get('update_interval')
|
if raw is not None:
|
||||||
if update_interval:
|
|
||||||
try:
|
try:
|
||||||
return float(update_interval)
|
interval = float(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except (ConfigError, OSError, ValueError, TypeError) as e:
|
||||||
self.logger.debug("Could not get update interval from config: %s", e)
|
self.logger.debug("Could not get update interval from config: %s", e)
|
||||||
|
|
||||||
# Default: 60 seconds
|
# 3. Default
|
||||||
return 60.0
|
if interval is None:
|
||||||
|
interval = 60.0
|
||||||
|
|
||||||
|
self._update_interval_cache[plugin_id] = interval
|
||||||
|
return interval
|
||||||
|
|
||||||
def _record_update_failure(
|
def _record_update_failure(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ Provides base classes and utilities for testing LEDMatrix plugins.
|
|||||||
from .plugin_test_base import PluginTestCase
|
from .plugin_test_base import PluginTestCase
|
||||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||||
from .visual_display_manager import VisualTestDisplayManager
|
from .visual_display_manager import VisualTestDisplayManager
|
||||||
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from .sizes import (
|
||||||
|
DEFAULT_TEST_SIZES, SUPPORTED_SIZES, resolve_test_sizes, size_label,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'PluginTestCase',
|
'PluginTestCase',
|
||||||
'VisualTestDisplayManager',
|
'VisualTestDisplayManager',
|
||||||
|
'BoundsCheckingDisplayManager',
|
||||||
'MockDisplayManager',
|
'MockDisplayManager',
|
||||||
'MockCacheManager',
|
'MockCacheManager',
|
||||||
'MockConfigManager',
|
'MockConfigManager',
|
||||||
'MockPluginManager',
|
'MockPluginManager',
|
||||||
|
'DEFAULT_TEST_SIZES',
|
||||||
|
'SUPPORTED_SIZES',
|
||||||
|
'resolve_test_sizes',
|
||||||
|
'size_label',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
129
src/plugin_system/testing/bounds_display_manager.py
Normal file
129
src/plugin_system/testing/bounds_display_manager.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Bounds-checking display manager.
|
||||||
|
|
||||||
|
A VisualTestDisplayManager that draws onto an oversized canvas (the declared
|
||||||
|
panel size plus a right/bottom margin) while still reporting the declared size
|
||||||
|
to the plugin. Content that a plugin draws past the right or bottom edge lands
|
||||||
|
in the margin instead of being silently clipped by PIL, so the harness can
|
||||||
|
detect overflow — the classic symptom of hardcoded coordinates or fonts/icons
|
||||||
|
that don't scale down to a smaller panel.
|
||||||
|
|
||||||
|
Limitations (documented on purpose):
|
||||||
|
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
||||||
|
PIL and not detected here. The dominant real-world breakage is content that is
|
||||||
|
too wide/tall for a smaller panel, which this catches.
|
||||||
|
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
||||||
|
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
||||||
|
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
||||||
|
the margin can't be measured and overflow is reported as undetermined (None).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from .sizes import DEFAULT_TEST_SIZES
|
||||||
|
from .visual_display_manager import VisualTestDisplayManager, _MatrixProxy
|
||||||
|
|
||||||
|
# Smallest extra band kept on the right/bottom so a few pixels of overflow are
|
||||||
|
# still visible even on the largest panel in a run.
|
||||||
|
_BASE_MARGIN = 16
|
||||||
|
# Fallback overflow reference when a caller doesn't pass one: the largest shape
|
||||||
|
# in the default sample. We extend every (smaller) canvas out to at least this
|
||||||
|
# size so content drawn at a coordinate meant for a bigger build — e.g. x=200 on
|
||||||
|
# a 64-wide panel — lands in the padded region and is flagged, instead of being
|
||||||
|
# clipped off-canvas and read as a false pass.
|
||||||
|
_DEFAULT_EXTENT_WIDTH = max(w for w, _ in DEFAULT_TEST_SIZES)
|
||||||
|
_DEFAULT_EXTENT_HEIGHT = max(h for _, h in DEFAULT_TEST_SIZES)
|
||||||
|
|
||||||
|
|
||||||
|
class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
||||||
|
"""Detects drawing that overflows the declared panel size."""
|
||||||
|
|
||||||
|
# Kept for backwards compatibility; real padding is computed per-axis below.
|
||||||
|
MARGIN = _BASE_MARGIN
|
||||||
|
|
||||||
|
def __init__(self, width: int = 128, height: int = 32,
|
||||||
|
overflow_extent: Optional[Tuple[int, int]] = None):
|
||||||
|
self._declared_width = int(width)
|
||||||
|
self._declared_height = int(height)
|
||||||
|
# Pad the canvas out to at least `overflow_extent` (the largest panel
|
||||||
|
# this run cares about) plus a base margin, so coordinates meant for a
|
||||||
|
# bigger build are caught — not clipped — when rendering a smaller panel.
|
||||||
|
# Defaults to the largest shape in the sample when no run is known.
|
||||||
|
ext_w, ext_h = overflow_extent or (_DEFAULT_EXTENT_WIDTH, _DEFAULT_EXTENT_HEIGHT)
|
||||||
|
self._canvas_width = max(self._declared_width, int(ext_w)) + _BASE_MARGIN
|
||||||
|
self._canvas_height = max(self._declared_height, int(ext_h)) + _BASE_MARGIN
|
||||||
|
# Parent builds the (oversized) backing canvas + fonts.
|
||||||
|
super().__init__(self._canvas_width, self._canvas_height)
|
||||||
|
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||||
|
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
||||||
|
|
||||||
|
# -- declared dimensions (override parent's image-derived properties) --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
return self._declared_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
return self._declared_height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_width(self) -> int:
|
||||||
|
return self._declared_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_height(self) -> int:
|
||||||
|
return self._declared_height
|
||||||
|
|
||||||
|
# -- overflow detection --
|
||||||
|
|
||||||
|
def _canvas_is_padded(self) -> bool:
|
||||||
|
return self.image.size == (self._canvas_width, self._canvas_height)
|
||||||
|
|
||||||
|
def check_overflow(self) -> Optional[Tuple[int, int, int, int]]:
|
||||||
|
"""Bounding box (in full-canvas coords) of any drawing beyond the
|
||||||
|
declared panel, or None if nothing overflowed / undetermined."""
|
||||||
|
if not self._canvas_is_padded():
|
||||||
|
return None
|
||||||
|
|
||||||
|
exp_w = self._canvas_width
|
||||||
|
exp_h = self._canvas_height
|
||||||
|
boxes = []
|
||||||
|
|
||||||
|
right = self.image.crop((self._declared_width, 0, exp_w, exp_h)).getbbox()
|
||||||
|
if right:
|
||||||
|
boxes.append((right[0] + self._declared_width, right[1],
|
||||||
|
right[2] + self._declared_width, right[3]))
|
||||||
|
|
||||||
|
bottom = self.image.crop((0, self._declared_height, exp_w, exp_h)).getbbox()
|
||||||
|
if bottom:
|
||||||
|
boxes.append((bottom[0], bottom[1] + self._declared_height,
|
||||||
|
bottom[2], bottom[3] + self._declared_height))
|
||||||
|
|
||||||
|
if not boxes:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
min(b[0] for b in boxes), min(b[1] for b in boxes),
|
||||||
|
max(b[2] for b in boxes), max(b[3] for b in boxes),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- snapshot/image accessors return the cropped, true-panel image --
|
||||||
|
|
||||||
|
def declared_image(self):
|
||||||
|
"""The visible panel: the canvas cropped to the declared size."""
|
||||||
|
if self._canvas_is_padded():
|
||||||
|
return self.image.crop((0, 0, self._declared_width, self._declared_height))
|
||||||
|
return self.image
|
||||||
|
|
||||||
|
def save_snapshot(self, path: str) -> None:
|
||||||
|
self.declared_image().save(path, format='PNG')
|
||||||
|
|
||||||
|
def get_image(self):
|
||||||
|
return self.declared_image()
|
||||||
|
|
||||||
|
def get_image_base64(self) -> str:
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
self.declared_image().save(buffer, format='PNG')
|
||||||
|
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
314
src/plugin_system/testing/harness.py
Normal file
314
src/plugin_system/testing/harness.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
Plugin safety harness.
|
||||||
|
|
||||||
|
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||||
|
size, capturing crashes and overflow. Used by scripts/check_plugin.py and the
|
||||||
|
pytest matrix test to guarantee a plugin change doesn't break a screen at a size
|
||||||
|
the author didn't try.
|
||||||
|
|
||||||
|
The render flow mirrors scripts/render_plugin.py (same PluginLoader call), but
|
||||||
|
this module adds: multi-size iteration, per-mode rendering, overflow detection
|
||||||
|
via BoundsCheckingDisplayManager, and golden-image comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.client
|
||||||
|
import inspect
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
from src.logging_config import get_logger
|
||||||
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from .loading import load_config_defaults, load_manifest
|
||||||
|
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||||
|
|
||||||
|
logger = get_logger("[Plugin Harness]")
|
||||||
|
|
||||||
|
|
||||||
|
def _tolerated_update_errors() -> Tuple[type, ...]:
|
||||||
|
"""Exception types from update() we treat as a tolerated no-connectivity
|
||||||
|
failure (expected in CI / headless dev) rather than a real plugin bug.
|
||||||
|
|
||||||
|
Anything NOT in this set is a genuine regression — a plugin that lets a
|
||||||
|
non-network exception escape update() should fail the harness, not pass
|
||||||
|
green because display() happened to survive.
|
||||||
|
"""
|
||||||
|
types: List[type] = [
|
||||||
|
ConnectionError, TimeoutError, # builtins
|
||||||
|
socket.gaierror, socket.timeout, # DNS / socket timeouts
|
||||||
|
ssl.SSLError,
|
||||||
|
urllib.error.URLError,
|
||||||
|
http.client.HTTPException,
|
||||||
|
]
|
||||||
|
try: # requests is optional; cover its whole error tree when present
|
||||||
|
import requests
|
||||||
|
types.append(requests.exceptions.RequestException)
|
||||||
|
except ImportError: # pragma: no cover - requests not installed
|
||||||
|
logger.debug("requests not installed; its connectivity errors won't be specifically tolerated")
|
||||||
|
return tuple(types)
|
||||||
|
|
||||||
|
|
||||||
|
_TOLERATED_UPDATE_ERRORS = _tolerated_update_errors()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderResult:
|
||||||
|
"""Outcome of rendering one (size, mode) of a plugin."""
|
||||||
|
plugin_id: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
mode: str
|
||||||
|
image: Optional[Image.Image] = None
|
||||||
|
error: Optional[str] = None # fatal: load/display crash, or a non-network update() error
|
||||||
|
update_error: Optional[str] = None # tolerated: connectivity error from update() (no network in CI)
|
||||||
|
overflow: Optional[Tuple[int, int, int, int]] = None # bbox past the panel
|
||||||
|
# golden comparison (populated only when a golden was provided)
|
||||||
|
golden_checked: bool = False
|
||||||
|
golden_ok: Optional[bool] = None
|
||||||
|
golden_diff_pixels: int = 0
|
||||||
|
golden_max_delta: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_label(self) -> str:
|
||||||
|
return size_label(self.width, self.height)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
"""Phase-1 pass: rendered without crashing and without overflow, and if a
|
||||||
|
golden was checked it matched."""
|
||||||
|
if self.error is not None or self.overflow is not None:
|
||||||
|
return False
|
||||||
|
if self.golden_checked and self.golden_ok is False:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
|
||||||
|
"""Enumerate a plugin's screens: instance.modes wins, then manifest
|
||||||
|
display_modes, then the plugin id as a single mode."""
|
||||||
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
|
if modes:
|
||||||
|
return [str(m) for m in modes]
|
||||||
|
declared = manifest.get("display_modes")
|
||||||
|
if declared:
|
||||||
|
return [str(m) for m in declared]
|
||||||
|
return [plugin_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||||
|
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||||
|
display_manager: Any) -> Any:
|
||||||
|
"""Load and construct a plugin instance with mocked managers."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||||
|
|
||||||
|
cache_manager = MockCacheManager()
|
||||||
|
for key, value in (mock_data or {}).items():
|
||||||
|
cache_manager.set(key, value)
|
||||||
|
|
||||||
|
loader = PluginLoader()
|
||||||
|
plugin_instance, _module = loader.load_plugin(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
manifest=manifest,
|
||||||
|
plugin_dir=plugin_dir,
|
||||||
|
config=config,
|
||||||
|
display_manager=display_manager,
|
||||||
|
cache_manager=cache_manager,
|
||||||
|
plugin_manager=MockPluginManager(),
|
||||||
|
install_deps=False,
|
||||||
|
)
|
||||||
|
return plugin_instance
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||||
|
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||||
|
drive the plugin's internal mode state machine (first display() call renders
|
||||||
|
modes[current_mode_index] when current_display_mode is None)."""
|
||||||
|
sig = inspect.signature(plugin_instance.display)
|
||||||
|
if "display_mode" in sig.parameters:
|
||||||
|
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
|
if modes and mode in modes:
|
||||||
|
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||||
|
if hasattr(plugin_instance, "current_display_mode"):
|
||||||
|
plugin_instance.current_display_mode = None
|
||||||
|
plugin_instance.display(force_clear=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze(freeze_time: Optional[str]):
|
||||||
|
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||||
|
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||||
|
if not freeze_time:
|
||||||
|
return contextlib.nullcontext()
|
||||||
|
try:
|
||||||
|
from freezegun import freeze_time as _ft
|
||||||
|
except ImportError as e: # pragma: no cover - only hit without the dep
|
||||||
|
raise RuntimeError(
|
||||||
|
"freeze_time requires the 'freezegun' package (pip install freezegun)"
|
||||||
|
) from e
|
||||||
|
return _ft(freeze_time)
|
||||||
|
|
||||||
|
|
||||||
|
def render_plugin_matrix(
|
||||||
|
plugin_id: str,
|
||||||
|
plugin_dir: Path,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
mock_data: Optional[Dict[str, Any]] = None,
|
||||||
|
sizes: Optional[List[Tuple[int, int]]] = None,
|
||||||
|
run_update: bool = True,
|
||||||
|
freeze_time: Optional[str] = None,
|
||||||
|
) -> List[RenderResult]:
|
||||||
|
"""Render every (size, mode) combination for a plugin.
|
||||||
|
|
||||||
|
Returns a flat list of RenderResult. A fresh plugin instance is built per
|
||||||
|
(size, mode) so state never leaks between screens. Pass freeze_time (e.g.
|
||||||
|
"2025-08-01 15:25:00") to make time-dependent plugins reproducible.
|
||||||
|
"""
|
||||||
|
plugin_dir = Path(plugin_dir)
|
||||||
|
manifest = load_manifest(plugin_dir)
|
||||||
|
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||||
|
# install; explicit caller config still wins over a schema default.
|
||||||
|
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
||||||
|
sizes = sizes or DEFAULT_TEST_SIZES
|
||||||
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
|
# The largest panel in this run. Every (smaller) canvas is padded out to it
|
||||||
|
# so a coordinate meant for the biggest configuration is still caught when
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
with _freeze(freeze_time):
|
||||||
|
for width, height in sizes:
|
||||||
|
results.extend(_render_size(
|
||||||
|
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||||
|
width, height, run_update, extent,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||||
|
width, height, run_update, extent) -> List[RenderResult]:
|
||||||
|
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||||
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
|
# Discover modes once per size (instance build can depend on config).
|
||||||
|
try:
|
||||||
|
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
|
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
||||||
|
modes = list_modes(probe, manifest, plugin_id)
|
||||||
|
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||||
|
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||||
|
|
||||||
|
for mode in modes:
|
||||||
|
result = RenderResult(plugin_id, width, height, mode)
|
||||||
|
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
|
try:
|
||||||
|
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
||||||
|
if run_update:
|
||||||
|
try:
|
||||||
|
inst.update()
|
||||||
|
except _TOLERATED_UPDATE_ERRORS as e:
|
||||||
|
# Expected when CI / headless dev has no network: record it
|
||||||
|
# (surfaced in the report) but don't fail the run.
|
||||||
|
result.update_error = repr(e)
|
||||||
|
logger.debug("update() connectivity error for %s [%s]: %s", plugin_id, mode, e)
|
||||||
|
except Exception as e: # noqa: BLE001 — a non-network update() failure is a real bug
|
||||||
|
# A regression in update() must not pass green just because
|
||||||
|
# display() survives, so treat it as a failure of this render.
|
||||||
|
result.error = repr(e)
|
||||||
|
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||||
|
plugin_id, mode, e)
|
||||||
|
if result.error is None:
|
||||||
|
_render_mode(inst, mode)
|
||||||
|
result.image = dm.get_image()
|
||||||
|
result.overflow = dm.check_overflow()
|
||||||
|
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||||
|
result.error = repr(e)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Golden-image comparison
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compare_images(rendered: Image.Image, golden: Image.Image,
|
||||||
|
max_delta: int = 0, max_diff_pixels: int = 0) -> Tuple[bool, int, int]:
|
||||||
|
"""Compare two images. Returns (ok, diff_pixel_count, max_per_channel_delta).
|
||||||
|
|
||||||
|
Tolerances default to exact match; bump them only to absorb known platform
|
||||||
|
anti-aliasing noise (requires a pinned Pillow + bundled fonts for stability).
|
||||||
|
"""
|
||||||
|
if rendered.size != golden.size:
|
||||||
|
return False, rendered.size[0] * rendered.size[1], 255
|
||||||
|
a = rendered.convert("RGB")
|
||||||
|
b = golden.convert("RGB")
|
||||||
|
diff = ImageChops.difference(a, b)
|
||||||
|
bbox = diff.getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
return True, 0, 0
|
||||||
|
# Count pixels whose largest per-channel delta exceeds the allowed tolerance,
|
||||||
|
# and track the worst delta seen (for reporting).
|
||||||
|
diff_pixels = 0
|
||||||
|
observed_max = 0
|
||||||
|
for px in diff.crop(bbox).getdata():
|
||||||
|
m = max(px) if isinstance(px, tuple) else px
|
||||||
|
if m > observed_max:
|
||||||
|
observed_max = m
|
||||||
|
if m > max_delta:
|
||||||
|
diff_pixels += 1
|
||||||
|
# Pass when the number of out-of-tolerance pixels is within budget.
|
||||||
|
ok = diff_pixels <= max_diff_pixels
|
||||||
|
return ok, diff_pixels, observed_max
|
||||||
|
|
||||||
|
|
||||||
|
def golden_path(golden_dir: Path, width: int, height: int, mode: str) -> Path:
|
||||||
|
"""Location of a golden image: <golden_dir>/<WxH>/<mode>.png.
|
||||||
|
|
||||||
|
The mode is sanitized to a safe basename so a mode name with '/' or '..'
|
||||||
|
can't read or write outside the golden directory.
|
||||||
|
"""
|
||||||
|
return Path(golden_dir) / size_label(width, height) / f"{safe_mode_filename(mode)}.png"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
|
||||||
|
max_delta: int = 0, max_diff_pixels: int = 0) -> List[RenderResult]:
|
||||||
|
"""Compare rendered results against committed goldens, mutating each result's
|
||||||
|
golden_* fields. Results with no golden file on disk are left unchecked."""
|
||||||
|
for r in results:
|
||||||
|
if r.image is None:
|
||||||
|
continue
|
||||||
|
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||||
|
if not gp.exists():
|
||||||
|
continue
|
||||||
|
r.golden_checked = True
|
||||||
|
with Image.open(gp) as g:
|
||||||
|
ok, diff_pixels, observed_max = compare_images(
|
||||||
|
r.image, g, max_delta=max_delta, max_diff_pixels=max_diff_pixels)
|
||||||
|
r.golden_ok = ok
|
||||||
|
r.golden_diff_pixels = diff_pixels
|
||||||
|
r.golden_max_delta = observed_max
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
|
||||||
|
"""Write each successfully-rendered result to its golden path. Returns count."""
|
||||||
|
written = 0
|
||||||
|
for r in results:
|
||||||
|
if r.image is None or r.error is not None:
|
||||||
|
continue
|
||||||
|
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||||
|
gp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
r.image.save(gp, format="PNG")
|
||||||
|
written += 1
|
||||||
|
return written
|
||||||
82
src/plugin_system/testing/loading.py
Normal file
82
src/plugin_system/testing/loading.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Shared helpers for loading a plugin headlessly.
|
||||||
|
|
||||||
|
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
|
||||||
|
plugin discovery / manifest / config-default logic lives in exactly one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||||
|
"""Find a plugin directory by searching multiple paths."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
loader = PluginLoader()
|
||||||
|
for search_dir in search_dirs:
|
||||||
|
search_path = Path(search_dir)
|
||||||
|
if not search_path.exists():
|
||||||
|
continue
|
||||||
|
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||||
|
if result:
|
||||||
|
return Path(result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
"""Load and return manifest.json from a plugin directory."""
|
||||||
|
manifest_path = Path(plugin_dir) / 'manifest.json'
|
||||||
|
if not manifest_path.exists():
|
||||||
|
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||||
|
with open(manifest_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
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)."""
|
||||||
|
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||||
|
if not schema_path.exists():
|
||||||
|
return {}
|
||||||
|
with open(schema_path, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
defaults: Dict[str, Any] = {}
|
||||||
|
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]:
|
||||||
|
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
|
||||||
|
|
||||||
|
Lets a plugin opt into golden-image testing by declaring how to render it
|
||||||
|
deterministically. All keys optional:
|
||||||
|
{
|
||||||
|
"config": {...}, # config overrides
|
||||||
|
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"skip_update": false
|
||||||
|
}
|
||||||
|
Returns {} when no harness.json exists.
|
||||||
|
"""
|
||||||
|
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
|
||||||
|
if not spec_path.exists():
|
||||||
|
return {}
|
||||||
|
with open(spec_path, 'r') as f:
|
||||||
|
spec = json.load(f)
|
||||||
|
|
||||||
|
# Resolve mock_data path and inline its contents for convenience.
|
||||||
|
mock_rel = spec.get('mock_data')
|
||||||
|
if mock_rel:
|
||||||
|
mock_path = Path(plugin_dir) / mock_rel
|
||||||
|
if not mock_path.exists():
|
||||||
|
# A declared-but-missing fixture is a harness config error: failing
|
||||||
|
# loudly beats silently rendering the plugin with no mock data.
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"harness.json references mock_data '{mock_rel}' but "
|
||||||
|
f"{mock_path} does not exist"
|
||||||
|
)
|
||||||
|
with open(mock_path, 'r') as mf:
|
||||||
|
spec['mock_data_contents'] = json.load(mf)
|
||||||
|
return spec
|
||||||
@@ -63,11 +63,23 @@ class MockCacheManager:
|
|||||||
"""Mock cache manager for testing."""
|
"""Mock cache manager for testing."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import weakref
|
||||||
self._cache: Dict[str, Any] = {}
|
self._cache: Dict[str, Any] = {}
|
||||||
self._cache_timestamps: Dict[str, float] = {}
|
self._cache_timestamps: Dict[str, float] = {}
|
||||||
self.get_calls = []
|
self.get_calls = []
|
||||||
self.set_calls = []
|
self.set_calls = []
|
||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
|
# Real temp dir for plugins that write/read files under cache_dir.
|
||||||
|
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
||||||
|
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
||||||
|
self._finalizer = weakref.finalize(
|
||||||
|
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Remove the temp cache directory created for this instance."""
|
||||||
|
self._finalizer()
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
||||||
"""Get a value from cache."""
|
"""Get a value from cache."""
|
||||||
|
|||||||
120
src/plugin_system/testing/sizes.py
Normal file
120
src/plugin_system/testing/sizes.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
LED matrix sizes the plugin safety harness renders against.
|
||||||
|
|
||||||
|
There is no fixed set of "supported" panel sizes — an RGB matrix build can be
|
||||||
|
any width/height and configuration (square, rectangle, 2x2, 4x4, 8x2, long
|
||||||
|
strips, tall stacks, ...). Plugins are expected to read width/height
|
||||||
|
dynamically and lay themselves out accordingly, so the harness's job is to
|
||||||
|
prove a plugin survives a *spread* of shapes, not a canonical list.
|
||||||
|
|
||||||
|
`DEFAULT_TEST_SIZES` is therefore a representative SAMPLE chosen to span the
|
||||||
|
axes of variation (narrow, wide, square, tall, small, long), not an
|
||||||
|
exhaustive or authoritative list. Callers can override it entirely:
|
||||||
|
|
||||||
|
- CLI: scripts/check_plugin.py --sizes 8x16,64x64,256x32
|
||||||
|
- pytest: LEDMATRIX_TEST_SIZES="8x16,64x64" env var (all plugins), or
|
||||||
|
per-plugin test/harness.json {"sizes": [[8, 16], [64, 64]]}
|
||||||
|
|
||||||
|
so anyone can point the harness at the exact panel(s) their build uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
# A spread of real panel-grid arrangements (each module is 64x32), not a list of
|
||||||
|
# "blessed" sizes. Each entry exercises a different layout assumption a plugin
|
||||||
|
# might accidentally bake in. Annotations are the panel grid (cols x rows).
|
||||||
|
DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
||||||
|
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||||
|
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||||
|
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
||||||
|
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||||
|
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||||
|
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||||
|
(256, 128), # 4x4 — large block, both dimensions big at once
|
||||||
|
]
|
||||||
|
|
||||||
|
# Backwards-compatible alias. Prefer DEFAULT_TEST_SIZES in new code — the old
|
||||||
|
# name implied these were the only valid panel sizes, which they are not.
|
||||||
|
SUPPORTED_SIZES = DEFAULT_TEST_SIZES
|
||||||
|
|
||||||
|
|
||||||
|
def size_label(width: int, height: int) -> str:
|
||||||
|
"""Human/path-friendly label for a size, e.g. '128x32'."""
|
||||||
|
return f"{width}x{height}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_token(token: str) -> Tuple[int, int]:
|
||||||
|
"""Parse a single 'WxH' token into an (int, int) pair.
|
||||||
|
|
||||||
|
Raises ValueError (with a user-friendly message) on malformed input so
|
||||||
|
callers can surface it however they like.
|
||||||
|
"""
|
||||||
|
cleaned = token.strip().lower()
|
||||||
|
if "x" not in cleaned:
|
||||||
|
raise ValueError(f"Invalid size '{token}' (expected WxH, e.g. 128x32)")
|
||||||
|
w, h = cleaned.split("x", 1)
|
||||||
|
try:
|
||||||
|
width, height = int(w), int(h)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid size '{token}' (expected numeric WxH, e.g. 128x32)"
|
||||||
|
) from exc
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid size '{token}' (width and height must be positive, e.g. 128x32)"
|
||||||
|
)
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_sizes(
|
||||||
|
value: Union[str, Iterable[Sequence[int]], None]
|
||||||
|
) -> Optional[List[Tuple[int, int]]]:
|
||||||
|
"""Normalize a size spec into a list of (w, h) tuples, or None if empty.
|
||||||
|
|
||||||
|
Accepts a comma-separated 'WxH,WxH' string (CLI / env var) or an iterable
|
||||||
|
of [w, h] / (w, h) pairs (harness.json). Returns None when value is falsy
|
||||||
|
so callers can fall back to the default sample.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [parse_size_token(tok) for tok in value.split(",") if tok.strip()]
|
||||||
|
sizes: List[Tuple[int, int]] = []
|
||||||
|
for pair in value:
|
||||||
|
w, h = pair # raises if not a 2-element sequence
|
||||||
|
width, height = int(w), int(h)
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError(f"Invalid size pair {pair!r} (width and height must be positive)")
|
||||||
|
sizes.append((width, height))
|
||||||
|
return sizes or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_test_sizes(
|
||||||
|
spec_sizes: Union[str, Iterable[Sequence[int]], None] = None,
|
||||||
|
) -> List[Tuple[int, int]]:
|
||||||
|
"""Decide which sizes to render, by precedence:
|
||||||
|
|
||||||
|
1. LEDMATRIX_TEST_SIZES env var — a global "test on my hardware" override
|
||||||
|
that wins for every plugin.
|
||||||
|
2. spec_sizes — e.g. a per-plugin harness.json "sizes" list.
|
||||||
|
3. DEFAULT_TEST_SIZES — the representative sample.
|
||||||
|
"""
|
||||||
|
env = coerce_sizes(os.environ.get("LEDMATRIX_TEST_SIZES"))
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
spec = coerce_sizes(spec_sizes)
|
||||||
|
if spec:
|
||||||
|
return spec
|
||||||
|
return list(DEFAULT_TEST_SIZES)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_mode_filename(mode: str) -> str:
|
||||||
|
"""A filesystem-safe basename for a plugin mode.
|
||||||
|
|
||||||
|
Mode names come from plugin metadata/render state, so a value containing
|
||||||
|
'/' or '..' could otherwise escape the intended output directory. Collapse
|
||||||
|
anything that isn't alphanumeric / dash / underscore to '_'.
|
||||||
|
"""
|
||||||
|
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in mode)
|
||||||
|
return cleaned or "mode"
|
||||||
@@ -49,9 +49,10 @@ class TestBasketballScoreboardPlugin(PluginTestBase):
|
|||||||
"""Test that plugin has display modes."""
|
"""Test that plugin has display modes."""
|
||||||
manifest = self.load_plugin_manifest(plugin_id)
|
manifest = self.load_plugin_manifest(plugin_id)
|
||||||
assert 'display_modes' in manifest
|
assert 'display_modes' in manifest
|
||||||
assert 'basketball_live' in manifest['display_modes']
|
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
|
||||||
assert 'basketball_recent' in manifest['display_modes']
|
assert 'nba_live' in manifest['display_modes']
|
||||||
assert 'basketball_upcoming' in manifest['display_modes']
|
assert 'nba_recent' in manifest['display_modes']
|
||||||
|
assert 'nba_upcoming' in manifest['display_modes']
|
||||||
|
|
||||||
def test_plugin_has_get_display_modes(self, plugin_id):
|
def test_plugin_has_get_display_modes(self, plugin_id):
|
||||||
"""Test that plugin can return display modes."""
|
"""Test that plugin can return display modes."""
|
||||||
|
|||||||
255
test/plugins/test_harness.py
Normal file
255
test/plugins/test_harness.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the plugin safety harness primitives:
|
||||||
|
bounds detection, image comparison, and mode enumeration.
|
||||||
|
|
||||||
|
These don't load real plugins, so they run anywhere (including core CI where
|
||||||
|
plugin-repos is empty).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.plugin_system.testing.bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from src.plugin_system.testing.harness import (
|
||||||
|
_TOLERATED_UPDATE_ERRORS, compare_images, list_modes,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.sizes import (
|
||||||
|
DEFAULT_TEST_SIZES, coerce_sizes, parse_size_token, resolve_test_sizes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoundsDetection:
|
||||||
|
def test_reports_declared_size_not_canvas_size(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
assert dm.width == 64 and dm.height == 32
|
||||||
|
assert dm.matrix.width == 64 and dm.matrix.height == 32
|
||||||
|
# Backing canvas is padded out past the declared panel so far-overshoot
|
||||||
|
# coordinates land on-canvas and get flagged instead of clipped.
|
||||||
|
canvas_w, canvas_h = dm.image.size
|
||||||
|
assert canvas_w > 64 and canvas_h > 32
|
||||||
|
|
||||||
|
def test_far_overshoot_on_small_panel_is_detected(self):
|
||||||
|
# A coordinate meant for a wide build (x past 64) must still be caught
|
||||||
|
# when the declared panel is only 64 wide.
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
dm.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||||
|
bbox = dm.check_overflow()
|
||||||
|
assert bbox is not None
|
||||||
|
assert bbox[0] >= 64
|
||||||
|
|
||||||
|
def test_in_bounds_drawing_has_no_overflow(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
dm.draw.rectangle([0, 0, 63, 31], fill=(255, 255, 255))
|
||||||
|
assert dm.check_overflow() is None
|
||||||
|
|
||||||
|
def test_right_overflow_is_detected(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
# Draw a few pixels past the right edge.
|
||||||
|
dm.draw.rectangle([60, 5, 70, 10], fill=(255, 0, 0))
|
||||||
|
bbox = dm.check_overflow()
|
||||||
|
assert bbox is not None
|
||||||
|
assert bbox[0] >= 64 # overflow starts at or past the declared width
|
||||||
|
|
||||||
|
def test_bottom_overflow_is_detected(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
dm.draw.rectangle([5, 30, 10, 40], fill=(0, 255, 0))
|
||||||
|
bbox = dm.check_overflow()
|
||||||
|
assert bbox is not None
|
||||||
|
assert bbox[3] > 32 # overflow extends past the declared height
|
||||||
|
|
||||||
|
def test_declared_image_is_cropped_to_panel(self):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||||
|
assert dm.get_image().size == (64, 32)
|
||||||
|
|
||||||
|
def test_snapshot_saves_cropped_panel(self, tmp_path):
|
||||||
|
dm = BoundsCheckingDisplayManager(width=128, height=32)
|
||||||
|
out = tmp_path / "snap.png"
|
||||||
|
dm.save_snapshot(str(out))
|
||||||
|
with Image.open(out) as img:
|
||||||
|
assert img.size == (128, 32)
|
||||||
|
|
||||||
|
|
||||||
|
class TestArbitraryPanelSizes:
|
||||||
|
"""The harness must handle any panel shape, not a fixed supported list."""
|
||||||
|
|
||||||
|
def test_overflow_extent_pads_to_largest_in_run(self):
|
||||||
|
# A wide run (extent 256) means content at x=200 on a 64-wide panel is
|
||||||
|
# caught; the same draw with a small extent would be clipped (false pass).
|
||||||
|
wide = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(256, 32))
|
||||||
|
wide.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||||
|
assert wide.check_overflow() is not None
|
||||||
|
|
||||||
|
tight = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(64, 32))
|
||||||
|
tight.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||||
|
assert tight.check_overflow() is None # clipped beyond the small canvas
|
||||||
|
|
||||||
|
def test_unusual_shapes_report_their_declared_size(self):
|
||||||
|
for w, h in [(8, 2), (6, 6), (200, 8), (64, 96)]:
|
||||||
|
dm = BoundsCheckingDisplayManager(width=w, height=h)
|
||||||
|
assert dm.width == w and dm.height == h
|
||||||
|
assert dm.matrix.width == w and dm.matrix.height == h
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateErrorClassification:
|
||||||
|
"""update() may fail for lack of network (tolerated) but a logic bug must
|
||||||
|
not pass green just because display() survives."""
|
||||||
|
|
||||||
|
def test_connectivity_errors_are_tolerated(self):
|
||||||
|
import socket
|
||||||
|
import urllib.error
|
||||||
|
for exc in (ConnectionError("x"), TimeoutError("x"), socket.gaierror("x"),
|
||||||
|
urllib.error.URLError("x")):
|
||||||
|
assert isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||||
|
|
||||||
|
def test_logic_errors_are_not_tolerated(self):
|
||||||
|
for exc in (ValueError("x"), KeyError("x"), AttributeError("x"), TypeError("x")):
|
||||||
|
assert not isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSizeParsing:
|
||||||
|
def test_parse_size_token_ok(self):
|
||||||
|
assert parse_size_token(" 128X32 ") == (128, 32)
|
||||||
|
|
||||||
|
def test_parse_size_token_rejects_garbage(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_size_token("128xabc")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_size_token("128-32")
|
||||||
|
|
||||||
|
def test_rejects_non_positive_dimensions(self):
|
||||||
|
for bad in ("0x32", "-64x32", "64x0", "64x-1"):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_size_token(bad)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
coerce_sizes([[0, 32]])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
coerce_sizes("64x-1")
|
||||||
|
|
||||||
|
def test_coerce_sizes_from_string_and_pairs(self):
|
||||||
|
assert coerce_sizes("8x16,64x64") == [(8, 16), (64, 64)]
|
||||||
|
assert coerce_sizes([[8, 16], (64, 64)]) == [(8, 16), (64, 64)]
|
||||||
|
assert coerce_sizes(None) is None
|
||||||
|
assert coerce_sizes("") is None
|
||||||
|
|
||||||
|
def test_resolve_precedence_env_then_spec_then_default(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("LEDMATRIX_TEST_SIZES", raising=False)
|
||||||
|
assert resolve_test_sizes(None) == list(DEFAULT_TEST_SIZES)
|
||||||
|
assert resolve_test_sizes([[8, 16]]) == [(8, 16)]
|
||||||
|
monkeypatch.setenv("LEDMATRIX_TEST_SIZES", "5x5")
|
||||||
|
# env wins over a per-plugin spec
|
||||||
|
assert resolve_test_sizes([[8, 16]]) == [(5, 5)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompareImages:
|
||||||
|
def test_identical_images_match(self):
|
||||||
|
a = Image.new("RGB", (16, 16), (10, 20, 30))
|
||||||
|
b = a.copy()
|
||||||
|
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||||
|
assert ok and diff_pixels == 0 and max_delta == 0
|
||||||
|
|
||||||
|
def test_different_images_fail_at_zero_tolerance(self):
|
||||||
|
a = Image.new("RGB", (16, 16), (0, 0, 0))
|
||||||
|
b = a.copy()
|
||||||
|
b.putpixel((1, 1), (255, 255, 255))
|
||||||
|
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||||
|
assert not ok and diff_pixels == 1 and max_delta == 255
|
||||||
|
|
||||||
|
def test_tolerance_absorbs_small_noise(self):
|
||||||
|
a = Image.new("RGB", (16, 16), (100, 100, 100))
|
||||||
|
b = a.copy()
|
||||||
|
b.putpixel((2, 2), (103, 100, 100)) # delta 3
|
||||||
|
ok, _, max_delta = compare_images(a, b, max_delta=5, max_diff_pixels=0)
|
||||||
|
assert ok and max_delta == 3
|
||||||
|
|
||||||
|
def test_size_mismatch_fails(self):
|
||||||
|
a = Image.new("RGB", (16, 16))
|
||||||
|
b = Image.new("RGB", (32, 16))
|
||||||
|
ok, _, _ = compare_images(a, b)
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestListModes:
|
||||||
|
def test_instance_modes_take_precedence(self):
|
||||||
|
inst = type("P", (), {"modes": ["a", "b"]})()
|
||||||
|
assert list_modes(inst, {"display_modes": ["x"]}, "pid") == ["a", "b"]
|
||||||
|
|
||||||
|
def test_falls_back_to_manifest_display_modes(self):
|
||||||
|
inst = type("P", (), {})()
|
||||||
|
assert list_modes(inst, {"display_modes": ["x", "y"]}, "pid") == ["x", "y"]
|
||||||
|
|
||||||
|
def test_falls_back_to_plugin_id(self):
|
||||||
|
inst = type("P", (), {})()
|
||||||
|
assert list_modes(inst, {}, "pid") == ["pid"]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_check_plugin_cli():
|
||||||
|
"""Load scripts/check_plugin.py by path (it isn't an importable package)."""
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
path = root / "scripts" / "check_plugin.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("check_plugin_cli", path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fixture_plugin(tmp_path, harness):
|
||||||
|
"""Create a minimal plugin dir with a test/harness.json; return its parent
|
||||||
|
(the search dir)."""
|
||||||
|
pdir = tmp_path / "plugins" / "demo-clock"
|
||||||
|
(pdir / "test").mkdir(parents=True)
|
||||||
|
(pdir / "manifest.json").write_text(json.dumps({
|
||||||
|
"id": "demo-clock", "name": "Demo Clock", "version": "1.0.0",
|
||||||
|
"author": "test", "entry_point": "manager.py", "class_name": "DemoClock",
|
||||||
|
"display_modes": ["demo-clock"], "compatible_versions": ["*"],
|
||||||
|
}))
|
||||||
|
(pdir / "test" / "harness.json").write_text(json.dumps(harness))
|
||||||
|
return pdir.parent
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckPluginHonorsHarnessJson:
|
||||||
|
"""Regression: check_plugin.py (the CI tool) must apply test/harness.json so
|
||||||
|
its render reproduces the committed goldens — otherwise time/data-dependent
|
||||||
|
plugins drift on every CI run."""
|
||||||
|
|
||||||
|
def test_harness_json_supplies_render_settings(self, tmp_path, monkeypatch):
|
||||||
|
mod = _load_check_plugin_cli()
|
||||||
|
search = _make_fixture_plugin(tmp_path, {
|
||||||
|
"config": {"timezone": "UTC"},
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"sizes": [[128, 32]],
|
||||||
|
})
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(mod, "render_plugin_matrix",
|
||||||
|
lambda **kw: captured.update(kw) or [])
|
||||||
|
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
||||||
|
mod.check_one(
|
||||||
|
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
|
||||||
|
mock_data={}, config={}, run_update=True, out_dir=None,
|
||||||
|
update_golden=False, golden_dir_override=None, freeze_time=None,
|
||||||
|
)
|
||||||
|
assert captured["freeze_time"] == "2025-08-01 15:25:00"
|
||||||
|
assert captured["config"]["timezone"] == "UTC"
|
||||||
|
assert captured["sizes"] == [(128, 32)]
|
||||||
|
|
||||||
|
def test_cli_flags_override_harness_json(self, tmp_path, monkeypatch):
|
||||||
|
mod = _load_check_plugin_cli()
|
||||||
|
search = _make_fixture_plugin(tmp_path, {
|
||||||
|
"config": {"timezone": "UTC"},
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
})
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(mod, "render_plugin_matrix",
|
||||||
|
lambda **kw: captured.update(kw) or [])
|
||||||
|
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
||||||
|
mod.check_one(
|
||||||
|
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
|
||||||
|
mock_data={}, config={"timezone": "America/New_York"},
|
||||||
|
run_update=True, out_dir=None, update_golden=False,
|
||||||
|
golden_dir_override=None, freeze_time="2030-01-01 00:00:00",
|
||||||
|
)
|
||||||
|
assert captured["freeze_time"] == "2030-01-01 00:00:00"
|
||||||
|
assert captured["config"]["timezone"] == "America/New_York"
|
||||||
115
test/plugins/test_plugin_matrix.py
Normal file
115
test/plugins/test_plugin_matrix.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Cross-size / cross-screen plugin safety test.
|
||||||
|
|
||||||
|
For every discovered plugin, render every declared screen at every supported
|
||||||
|
matrix size and assert it: loads, renders without crashing, stays within the
|
||||||
|
panel bounds, and — for plugins that ship golden images — matches them.
|
||||||
|
|
||||||
|
Plugin discovery (first match wins):
|
||||||
|
- $LEDMATRIX_PLUGINS_DIR (os.pathsep-separated list of dirs), else
|
||||||
|
- <project_root>/plugin-repos and <project_root>/plugins
|
||||||
|
|
||||||
|
A plugin opts into golden-image checks by adding test/golden/<WxH>/<mode>.png
|
||||||
|
(and usually test/harness.json for deterministic config / mock data / time).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.testing.harness import (
|
||||||
|
render_plugin_matrix, compare_to_goldens,
|
||||||
|
)
|
||||||
|
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
|
||||||
|
from src.plugin_system.testing.sizes import resolve_test_sizes
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
# Set LEDMATRIX_REQUIRE_PLUGINS=1 in any CI/hardware pipeline where plugins are
|
||||||
|
# expected to be present, so a discovery drift (empty search path) fails loudly
|
||||||
|
# instead of silently skipping and losing this safety signal.
|
||||||
|
_REQUIRE_PLUGINS = os.environ.get("LEDMATRIX_REQUIRE_PLUGINS") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_search_dirs() -> List[Path]:
|
||||||
|
env = os.environ.get("LEDMATRIX_PLUGINS_DIR")
|
||||||
|
if env:
|
||||||
|
return [Path(p) for p in env.split(os.pathsep) if p]
|
||||||
|
return [PROJECT_ROOT / "plugin-repos", PROJECT_ROOT / "plugins"]
|
||||||
|
|
||||||
|
|
||||||
|
def _discover() -> Dict[str, Path]:
|
||||||
|
"""Map plugin_id -> plugin_dir for all plugins on the search path."""
|
||||||
|
found: Dict[str, Path] = {}
|
||||||
|
for base in _plugin_search_dirs():
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
for child in sorted(base.iterdir()):
|
||||||
|
if (child / "manifest.json").exists() and child.name not in found:
|
||||||
|
found[child.name] = child
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
_PLUGINS = _discover()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.plugin
|
||||||
|
def test_plugins_were_discovered() -> None:
|
||||||
|
"""Guard against silently skipping the whole matrix when discovery drifts.
|
||||||
|
|
||||||
|
Local dev and the plugin-less core CI legitimately have no plugins, so we
|
||||||
|
skip there; but when LEDMATRIX_REQUIRE_PLUGINS=1 an empty search path is a
|
||||||
|
hard failure rather than a green no-op.
|
||||||
|
"""
|
||||||
|
if _PLUGINS:
|
||||||
|
return
|
||||||
|
search = [str(p) for p in _plugin_search_dirs()]
|
||||||
|
if _REQUIRE_PLUGINS:
|
||||||
|
pytest.fail(
|
||||||
|
"LEDMATRIX_REQUIRE_PLUGINS=1 but no plugins were discovered on the "
|
||||||
|
f"search path: {search}"
|
||||||
|
)
|
||||||
|
pytest.skip(f"no plugins found on the search path: {search}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.plugin
|
||||||
|
@pytest.mark.skipif(not _PLUGINS, reason="no plugins found on the search path")
|
||||||
|
@pytest.mark.parametrize("plugin_id", sorted(_PLUGINS))
|
||||||
|
def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
|
||||||
|
plugin_dir = _PLUGINS[plugin_id]
|
||||||
|
spec = load_harness_spec(plugin_dir)
|
||||||
|
|
||||||
|
config = {"enabled": True}
|
||||||
|
config.update(load_config_defaults(plugin_dir))
|
||||||
|
config.update(spec.get("config", {}))
|
||||||
|
|
||||||
|
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
|
||||||
|
# plugin's own harness.json "sizes", else the default representative sample.
|
||||||
|
sizes = resolve_test_sizes(spec.get("sizes"))
|
||||||
|
|
||||||
|
results = render_plugin_matrix(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
plugin_dir=plugin_dir,
|
||||||
|
config=config,
|
||||||
|
mock_data=spec.get("mock_data_contents", {}),
|
||||||
|
sizes=sizes,
|
||||||
|
run_update=not spec.get("skip_update", False),
|
||||||
|
freeze_time=spec.get("freeze_time"),
|
||||||
|
)
|
||||||
|
compare_to_goldens(results, plugin_dir / "test" / "golden")
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for r in results:
|
||||||
|
if r.error is not None:
|
||||||
|
failures.append(f"{r.size_label} {r.mode}: crashed: {r.error}")
|
||||||
|
elif r.overflow is not None:
|
||||||
|
failures.append(f"{r.size_label} {r.mode}: overflow past panel bbox={r.overflow}")
|
||||||
|
elif r.golden_checked and r.golden_ok is False:
|
||||||
|
failures.append(
|
||||||
|
f"{r.size_label} {r.mode}: golden drift {r.golden_diff_pixels}px "
|
||||||
|
f"(max Δ={r.golden_max_delta})"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not failures, f"{plugin_id} failed:\n " + "\n ".join(failures)
|
||||||
@@ -167,6 +167,53 @@ class TestDisplayControllerLivePriority:
|
|||||||
assert controller.current_display_mode == "test_plugin_live"
|
assert controller.current_display_mode == "test_plugin_live"
|
||||||
assert controller.force_change is True
|
assert controller.force_change is True
|
||||||
|
|
||||||
|
def test_live_priority_resume_continues_rotation(self, test_display_controller):
|
||||||
|
"""Regression: when live priority ends, rotation resumes where it was
|
||||||
|
interrupted, not after the live plugin's mode.
|
||||||
|
|
||||||
|
Without the fix, _apply_live_priority left current_mode_index pointing at
|
||||||
|
the live plugin's slot, so the next rotation step skipped every mode
|
||||||
|
between the interrupted position and the live plugin (e.g. elections,
|
||||||
|
which sits just before a flights plugin in the order)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.available_modes = [
|
||||||
|
"weather", "forecast", "almanac", "election_ticker", "flight_live"
|
||||||
|
]
|
||||||
|
# Rotation is about to show the 3rd mode (index 2).
|
||||||
|
controller.current_mode_index = 2
|
||||||
|
controller.current_display_mode = "almanac"
|
||||||
|
controller._live_resume_index = None
|
||||||
|
|
||||||
|
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
|
||||||
|
controller._apply_live_priority("flight_live")
|
||||||
|
assert controller.current_display_mode == "flight_live"
|
||||||
|
assert controller.current_mode_index == 4
|
||||||
|
assert controller._live_resume_index == 2 # saved rotation position
|
||||||
|
|
||||||
|
# Re-checks while the hold continues must not move the saved position.
|
||||||
|
controller._apply_live_priority("flight_live")
|
||||||
|
assert controller._live_resume_index == 2
|
||||||
|
|
||||||
|
# Live priority ends -> resume at the saved index (almanac), so the next
|
||||||
|
# rotation step lands on election_ticker (index 3) rather than skipping it.
|
||||||
|
controller._apply_live_priority(None)
|
||||||
|
assert controller.current_mode_index == 2
|
||||||
|
assert controller.current_display_mode == "almanac"
|
||||||
|
assert controller._live_resume_index is None
|
||||||
|
|
||||||
|
def test_live_priority_no_resume_when_idle(self, test_display_controller):
|
||||||
|
"""No saved position + no live content is a no-op (normal rotation)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.available_modes = ["a", "b", "c"]
|
||||||
|
controller.current_mode_index = 1
|
||||||
|
controller.current_display_mode = "b"
|
||||||
|
controller._live_resume_index = None
|
||||||
|
|
||||||
|
controller._apply_live_priority(None)
|
||||||
|
|
||||||
|
assert controller.current_mode_index == 1
|
||||||
|
assert controller.current_display_mode == "b"
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayControllerDynamicDuration:
|
class TestDisplayControllerDynamicDuration:
|
||||||
"""Test dynamic duration handling."""
|
"""Test dynamic duration handling."""
|
||||||
@@ -229,18 +276,20 @@ class TestDisplayControllerSchedule:
|
|||||||
def test_inactive_hours(self, test_display_controller):
|
def test_inactive_hours(self, test_display_controller):
|
||||||
"""Test inactive hours check."""
|
"""Test inactive hours check."""
|
||||||
controller = test_display_controller
|
controller = test_display_controller
|
||||||
|
# Inject schedule directly into self.config (what _check_schedule actually reads)
|
||||||
|
# and reset the minute gate so the cached result from any prior call is cleared.
|
||||||
|
controller.config['schedule'] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "17:00",
|
||||||
|
}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
with patch('src.display_controller.datetime') as mock_datetime:
|
with patch('src.display_controller.datetime') as mock_datetime:
|
||||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
||||||
mock_datetime.strptime = datetime.strptime
|
mock_datetime.strptime = datetime.strptime
|
||||||
|
|
||||||
schedule_config = {
|
controller._check_schedule()
|
||||||
"schedule": {
|
assert controller.is_display_active is False
|
||||||
"enabled": True,
|
|
||||||
"start_time": "09:00",
|
|
||||||
"end_time": "17:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
|
||||||
controller._check_schedule()
|
|
||||||
assert controller.is_display_active is False
|
|
||||||
|
|||||||
322
test/test_display_controller_optimizations.py
Normal file
322
test/test_display_controller_optimizations.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Tests for the three display_controller.py optimizations:
|
||||||
|
|
||||||
|
Opt #1 — inspect.signature() caching per plugin_id
|
||||||
|
Opt #2 — pre-cached config values (_normal_brightness, _scroll_speed)
|
||||||
|
Opt #3 — schedule minute-gate (_check_schedule, _check_dim_schedule)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, patch, call
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller(test_display_controller):
|
||||||
|
"""Return a ready DisplayController from the existing suite fixture."""
|
||||||
|
return test_display_controller
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #1 — signature cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSignatureCache:
|
||||||
|
"""inspect.signature() should be called at most once per plugin_id."""
|
||||||
|
|
||||||
|
class _PluginWithMode:
|
||||||
|
"""Real class whose display() accepts display_mode — inspectable by signature."""
|
||||||
|
plugin_id = "mode_plugin"
|
||||||
|
def display(self, display_mode=None, force_clear=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
class _PluginNoMode:
|
||||||
|
"""Real class whose display() does NOT accept display_mode."""
|
||||||
|
plugin_id = "no_mode_plugin"
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def test_cache_starts_empty(self, controller):
|
||||||
|
assert controller._plugin_accepts_display_mode == {}
|
||||||
|
|
||||||
|
def test_signature_computed_and_cached(self, controller):
|
||||||
|
"""After the first cache population, the dict holds a bool and stays unchanged
|
||||||
|
if queried again without explicitly deleting the key."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginNoMode()
|
||||||
|
key = "sig_test"
|
||||||
|
if key not in controller._plugin_accepts_display_mode:
|
||||||
|
controller._plugin_accepts_display_mode[key] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
original = controller._plugin_accepts_display_mode[key]
|
||||||
|
|
||||||
|
# Accessing cache again should not change the value
|
||||||
|
second = controller._plugin_accepts_display_mode[key]
|
||||||
|
assert second == original
|
||||||
|
|
||||||
|
def test_cache_stores_false_for_no_display_mode(self, controller):
|
||||||
|
"""Plugin whose display() doesn't accept display_mode → cached False."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginNoMode()
|
||||||
|
controller._plugin_accepts_display_mode["no_mode_plugin"] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
assert controller._plugin_accepts_display_mode["no_mode_plugin"] is False
|
||||||
|
|
||||||
|
def test_cache_stores_true_for_display_mode(self, controller):
|
||||||
|
"""Plugin whose display() accepts display_mode → cached True."""
|
||||||
|
import inspect as _inspect
|
||||||
|
plugin = self._PluginWithMode()
|
||||||
|
controller._plugin_accepts_display_mode["mode_plugin"] = (
|
||||||
|
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||||
|
)
|
||||||
|
assert controller._plugin_accepts_display_mode["mode_plugin"] is True
|
||||||
|
|
||||||
|
def test_cache_cleared_on_plugin_reload(self, controller):
|
||||||
|
"""Populating plugin_modes for an id that's already cached must clear the entry."""
|
||||||
|
plugin = MagicMock()
|
||||||
|
controller._plugin_accepts_display_mode["reload_plugin"] = False
|
||||||
|
|
||||||
|
# Simulate the plugin_modes population code path (as in __init__)
|
||||||
|
plugin_id = "reload_plugin"
|
||||||
|
controller.plugin_modes["reload_plugin"] = plugin
|
||||||
|
if hasattr(controller, "_plugin_accepts_display_mode"):
|
||||||
|
controller._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||||
|
|
||||||
|
assert "reload_plugin" not in controller._plugin_accepts_display_mode
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #2 — cached config values
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCachedConfigValues:
|
||||||
|
"""_normal_brightness and _scroll_speed are populated from config at init."""
|
||||||
|
|
||||||
|
def test_normal_brightness_cached(self, controller):
|
||||||
|
"""_normal_brightness must equal what the config says."""
|
||||||
|
expected = (
|
||||||
|
controller.config
|
||||||
|
.get("display", {})
|
||||||
|
.get("hardware", {})
|
||||||
|
.get("brightness", 90)
|
||||||
|
)
|
||||||
|
assert controller._normal_brightness == expected
|
||||||
|
|
||||||
|
def test_scroll_speed_cached(self, controller):
|
||||||
|
"""_scroll_speed must equal what the config says."""
|
||||||
|
expected = (
|
||||||
|
controller.config
|
||||||
|
.get("display", {})
|
||||||
|
.get("vegas_scroll", {})
|
||||||
|
.get("scroll_speed", 75)
|
||||||
|
)
|
||||||
|
assert controller._scroll_speed == expected
|
||||||
|
|
||||||
|
def test_current_brightness_uses_cached_value(self, controller):
|
||||||
|
"""current_brightness is initialised from _normal_brightness."""
|
||||||
|
assert controller.current_brightness == controller._normal_brightness
|
||||||
|
|
||||||
|
def test_cached_target_brightness_init(self, controller):
|
||||||
|
"""_cached_target_brightness starts equal to _normal_brightness."""
|
||||||
|
assert controller._cached_target_brightness == controller._normal_brightness
|
||||||
|
|
||||||
|
def test_normal_brightness_default_is_90(self, controller):
|
||||||
|
"""If config has no brightness key the default is 90."""
|
||||||
|
controller.config = {}
|
||||||
|
controller._normal_brightness = (
|
||||||
|
controller.config.get("display", {})
|
||||||
|
.get("hardware", {})
|
||||||
|
.get("brightness", 90)
|
||||||
|
)
|
||||||
|
assert controller._normal_brightness == 90
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Opt #3 — schedule minute-gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestScheduleMinuteGate:
|
||||||
|
"""_check_schedule and _check_dim_schedule skip re-evaluation within the same minute."""
|
||||||
|
|
||||||
|
# ── _check_schedule ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_schedule_checked_minute_starts_none(self, controller):
|
||||||
|
assert controller._schedule_checked_minute is None
|
||||||
|
|
||||||
|
def test_first_call_sets_checked_minute(self, controller):
|
||||||
|
"""After the first real evaluation the minute key is stored."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller._schedule_checked_minute is not None
|
||||||
|
|
||||||
|
def test_second_call_same_minute_does_not_re_evaluate(self, controller):
|
||||||
|
"""A second call with the same (hour, minute) returns without changing state."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
|
||||||
|
# First call — evaluates and marks as active (whole-day window)
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is True
|
||||||
|
first_minute_key = controller._schedule_checked_minute
|
||||||
|
|
||||||
|
# Force is_display_active to False so we can tell if it gets re-evaluated
|
||||||
|
controller.is_display_active = False
|
||||||
|
|
||||||
|
# Second call within the same minute — gate fires, is_display_active unchanged
|
||||||
|
controller._schedule_checked_minute = first_minute_key # same minute
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is False, (
|
||||||
|
"Second call in same minute should return immediately without re-evaluation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_new_minute_forces_re_evaluation(self, controller):
|
||||||
|
"""A different (hour, minute) key causes a full re-evaluation."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
# Plant a stale minute key from yesterday
|
||||||
|
controller._schedule_checked_minute = (-1, -1)
|
||||||
|
controller.is_display_active = False # wrong value to be corrected
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller.is_display_active is True, (
|
||||||
|
"A new minute key should trigger re-evaluation and correct is_display_active"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_gate_skipped_when_schedule_disabled(self, controller):
|
||||||
|
"""When schedule.enabled=False the method returns before reaching the gate."""
|
||||||
|
controller.config["schedule"] = {"enabled": False}
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
# The early-return path doesn't set the minute key
|
||||||
|
assert controller._schedule_checked_minute is None
|
||||||
|
|
||||||
|
# ── _check_dim_schedule ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_dim_checked_minute_starts_none(self, controller):
|
||||||
|
assert controller._dim_checked_minute is None
|
||||||
|
|
||||||
|
def test_first_dim_call_sets_checked_minute(self, controller):
|
||||||
|
"""First call with dim schedule enabled stores the minute key."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
assert controller._dim_checked_minute is not None
|
||||||
|
|
||||||
|
def test_dim_second_call_returns_cached_brightness(self, controller):
|
||||||
|
"""Second call with same minute returns _cached_target_brightness immediately."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
# First call stores the result
|
||||||
|
first_result = controller._check_dim_schedule()
|
||||||
|
assert controller._cached_target_brightness == first_result
|
||||||
|
minute_key = controller._dim_checked_minute
|
||||||
|
|
||||||
|
# Corrupt cached value to something recognisable
|
||||||
|
controller._cached_target_brightness = 42
|
||||||
|
|
||||||
|
# Second call in same minute — must return the cached 42
|
||||||
|
controller._dim_checked_minute = minute_key
|
||||||
|
second_result = controller._check_dim_schedule()
|
||||||
|
assert second_result == 42, (
|
||||||
|
"Same-minute call must return cached brightness, not re-compute"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dim_gate_skipped_when_display_off(self, controller):
|
||||||
|
"""When display is off the method exits before the minute gate."""
|
||||||
|
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||||
|
controller.is_display_active = False
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
# Early-exit path does not set the minute key
|
||||||
|
assert controller._dim_checked_minute is None
|
||||||
|
|
||||||
|
def test_dim_cached_target_brightness_updated_after_full_evaluation(self, controller):
|
||||||
|
"""After a full evaluation _cached_target_brightness reflects the result."""
|
||||||
|
controller.config["dim_schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "22:00",
|
||||||
|
"end_time": "06:00",
|
||||||
|
}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._dim_checked_minute = None # force full re-evaluation
|
||||||
|
controller._tz = None
|
||||||
|
|
||||||
|
result = controller._check_dim_schedule()
|
||||||
|
assert controller._cached_target_brightness == result
|
||||||
|
|
||||||
|
# ── timezone lazy init ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_tz_starts_none(self, controller):
|
||||||
|
assert controller._tz is None
|
||||||
|
|
||||||
|
def test_tz_lazily_initialised_on_first_schedule_check(self, controller):
|
||||||
|
"""_tz is None until _check_schedule or _check_dim_schedule is called."""
|
||||||
|
controller.config["schedule"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"start_time": "00:00",
|
||||||
|
"end_time": "23:59",
|
||||||
|
}
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
assert controller._tz is not None
|
||||||
|
|
||||||
|
def test_tz_shared_between_schedule_and_dim(self, controller):
|
||||||
|
"""Both methods use the same cached _tz instance."""
|
||||||
|
controller.config["schedule"] = {"enabled": True, "start_time": "00:00", "end_time": "23:59"}
|
||||||
|
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||||
|
controller.is_display_active = True
|
||||||
|
controller._tz = None
|
||||||
|
controller._schedule_checked_minute = None
|
||||||
|
controller._dim_checked_minute = None
|
||||||
|
|
||||||
|
controller._check_schedule()
|
||||||
|
tz_after_schedule = controller._tz
|
||||||
|
|
||||||
|
controller._check_dim_schedule()
|
||||||
|
assert controller._tz is tz_after_schedule, (
|
||||||
|
"_check_dim_schedule should reuse the _tz set by _check_schedule"
|
||||||
|
)
|
||||||
@@ -58,19 +58,15 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||||
|
|
||||||
def _fake_subprocess_run(self, *args, **kwargs):
|
def _fake_subprocess_run(self, *args, **kwargs):
|
||||||
# Return different dummy values depending on which git subcommand
|
# _get_local_git_info now reads branch and remote_url directly from
|
||||||
# was invoked so the code paths that parse output all succeed.
|
# .git/HEAD and .git/config (no subprocess) and uses a single
|
||||||
|
# ``git log --format=%H%n%cI`` call that returns SHA on line 1 and
|
||||||
|
# ISO date on line 2. Adjust the fake accordingly.
|
||||||
cmd = args[0]
|
cmd = args[0]
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.returncode = 0
|
result.returncode = 0
|
||||||
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
|
if "log" in cmd:
|
||||||
result.stdout = "abcdef1234567890\n"
|
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
|
||||||
elif "--abbrev-ref" in cmd:
|
|
||||||
result.stdout = "main\n"
|
|
||||||
elif "config" in cmd:
|
|
||||||
result.stdout = "https://example.com/repo.git\n"
|
|
||||||
elif "log" in cmd:
|
|
||||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
|
||||||
else:
|
else:
|
||||||
result.stdout = ""
|
result.stdout = ""
|
||||||
return result
|
return result
|
||||||
@@ -84,7 +80,8 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
self.assertIsNotNone(first)
|
self.assertIsNotNone(first)
|
||||||
self.assertEqual(first["short_sha"], "abcdef1")
|
self.assertEqual(first["short_sha"], "abcdef1")
|
||||||
calls_after_first = mock_run.call_count
|
calls_after_first = mock_run.call_count
|
||||||
self.assertEqual(calls_after_first, 4)
|
# Production code now uses a single ``git log`` call.
|
||||||
|
self.assertEqual(calls_after_first, 1)
|
||||||
|
|
||||||
# Second call with unchanged HEAD: zero new subprocess calls.
|
# Second call with unchanged HEAD: zero new subprocess calls.
|
||||||
second = self.sm._get_local_git_info(self.plugin_path)
|
second = self.sm._get_local_git_info(self.plugin_path)
|
||||||
@@ -105,7 +102,8 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
os.utime(head, (new_time, new_time))
|
os.utime(head, (new_time, new_time))
|
||||||
|
|
||||||
self.sm._get_local_git_info(self.plugin_path)
|
self.sm._get_local_git_info(self.plugin_path)
|
||||||
self.assertEqual(mock_run.call_count, calls_after_first + 4)
|
# One new ``git log`` call after cache invalidation.
|
||||||
|
self.assertEqual(mock_run.call_count, calls_after_first + 1)
|
||||||
|
|
||||||
def test_no_git_directory_returns_none(self):
|
def test_no_git_directory_returns_none(self):
|
||||||
non_git = self.plugins_dir / "no_git"
|
non_git = self.plugins_dir / "no_git"
|
||||||
@@ -192,14 +190,11 @@ class TestGitInfoCache(unittest.TestCase):
|
|||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.returncode = 0
|
result.returncode = 0
|
||||||
cmd = args[0]
|
cmd = args[0]
|
||||||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
# Production code now uses a single ``git log --format=%H%n%cI``.
|
||||||
result.stdout = branch_file.read_text().strip() + "\n"
|
# Branch and remote_url are read directly from .git/HEAD/.git/config.
|
||||||
elif "--abbrev-ref" in cmd:
|
if "log" in cmd:
|
||||||
result.stdout = "main\n"
|
sha = branch_file.read_text().strip()
|
||||||
elif "config" in cmd:
|
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
|
||||||
result.stdout = "https://example.com/repo.git\n"
|
|
||||||
elif "log" in cmd:
|
|
||||||
result.stdout = "2026-04-08T12:00:00+00:00\n"
|
|
||||||
else:
|
else:
|
||||||
result.stdout = ""
|
result.stdout = ""
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -617,7 +617,8 @@ class TestDottedKeyNormalization:
|
|||||||
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
||||||
}
|
}
|
||||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
schema_mgr.validate_config_against_schema.return_value = []
|
# Must be a (bool, list) tuple: the endpoint does is_valid, errors = validate_config_against_schema(...)
|
||||||
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||||
api_v3.schema_manager = schema_mgr
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
@@ -679,7 +680,7 @@ class TestDottedKeyNormalization:
|
|||||||
'leagues': {'eng.1': {'favorite_teams': []}},
|
'leagues': {'eng.1': {'favorite_teams': []}},
|
||||||
}
|
}
|
||||||
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
schema_mgr.validate_config_against_schema.return_value = []
|
schema_mgr.validate_config_against_schema.return_value = (True, [])
|
||||||
api_v3.schema_manager = schema_mgr
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
|
|||||||
@@ -224,20 +224,14 @@ class TestStateReconciliation(unittest.TestCase):
|
|||||||
with open(manifest_path, 'w') as f:
|
with open(manifest_path, 'w') as f:
|
||||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||||
|
|
||||||
# Mock save_config to track calls
|
|
||||||
saved_configs = []
|
|
||||||
def save_config(config):
|
|
||||||
saved_configs.append(config)
|
|
||||||
|
|
||||||
self.config_manager.save_config = save_config
|
|
||||||
|
|
||||||
# Run reconciliation
|
# Run reconciliation
|
||||||
result = self.reconciler.reconcile_state()
|
result = self.reconciler.reconcile_state()
|
||||||
|
|
||||||
# Verify fix was attempted
|
# config.json is the source of truth for enabled state. The fix syncs
|
||||||
|
# the state manager to match config (config says True → state set True),
|
||||||
|
# rather than overwriting the config with the stale state value.
|
||||||
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
||||||
self.assertEqual(len(saved_configs), 1)
|
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
|
||||||
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
|
|
||||||
|
|
||||||
def test_multiple_inconsistencies(self):
|
def test_multiple_inconsistencies(self):
|
||||||
"""Test reconciliation with multiple inconsistencies."""
|
"""Test reconciliation with multiple inconsistencies."""
|
||||||
|
|||||||
76
test/web_interface/test_systemctl_sudoers_alignment.py
Normal file
76
test/web_interface/test_systemctl_sudoers_alignment.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Guards that every privileged systemctl call the web interface makes is
|
||||||
|
covered by a passwordless-sudo grant in configure_web_sudo.sh.
|
||||||
|
|
||||||
|
The web interface runs headless (no TTY), so any `sudo` call that is not
|
||||||
|
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
|
||||||
|
password prompt and fails with:
|
||||||
|
|
||||||
|
sudo: a terminal is required to read the password
|
||||||
|
|
||||||
|
sudo matches the command line by exact string, so `systemctl start ledmatrix`
|
||||||
|
and `systemctl start ledmatrix.service` are NOT interchangeable. This test
|
||||||
|
parses both the production blueprint and the sudoers-generator script and
|
||||||
|
asserts the (verb, unit) pairs line up, catching the suffix-mismatch class of
|
||||||
|
bug before it ships.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
||||||
|
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
|
||||||
|
"""Return (verb, unit) for every list literal beginning with
|
||||||
|
['sudo', 'systemctl', ...] passed to a subprocess call in the source."""
|
||||||
|
calls: set[tuple[str, str]] = set()
|
||||||
|
for node in ast.walk(ast.parse(source)):
|
||||||
|
if not isinstance(node, ast.List):
|
||||||
|
continue
|
||||||
|
elts = node.elts
|
||||||
|
if len(elts) < 4:
|
||||||
|
continue
|
||||||
|
if not all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in elts[:4]):
|
||||||
|
continue
|
||||||
|
if elts[0].value == "sudo" and elts[1].value == "systemctl":
|
||||||
|
calls.add((elts[2].value, elts[3].value))
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
||||||
|
"""Return (verb, unit) for each `$SYSTEMCTL_PATH <verb> <unit>` NOPASSWD
|
||||||
|
grant emitted by the sudoers-generator script."""
|
||||||
|
rules: set[tuple[str, str]] = set()
|
||||||
|
for match in re.finditer(r"\$SYSTEMCTL_PATH\s+(\S+)\s+(\S+)", script):
|
||||||
|
verb, unit = match.group(1), match.group(2).rstrip('"')
|
||||||
|
rules.add((verb, unit))
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||||
|
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||||
|
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
||||||
|
|
||||||
|
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
||||||
|
|
||||||
|
uncovered = {c for c in calls if c not in rules}
|
||||||
|
assert not uncovered, (
|
||||||
|
"These sudo systemctl calls have no matching NOPASSWD grant in "
|
||||||
|
"configure_web_sudo.sh; they will fail headless with "
|
||||||
|
"'sudo: a terminal is required to read the password': "
|
||||||
|
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_are_fully_qualified() -> None:
|
||||||
|
"""Privileged systemctl calls must name the unit as <name>.service so they
|
||||||
|
match the sudoers grants, which use the fully-qualified unit name."""
|
||||||
|
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||||
|
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
||||||
|
assert not unqualified, (
|
||||||
|
"sudo systemctl calls must use fully-qualified .service unit names: "
|
||||||
|
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(unqualified))
|
||||||
|
)
|
||||||
@@ -391,6 +391,22 @@ def captive_portal_redirect():
|
|||||||
# Redirect to lightweight captive portal setup page (not the full UI)
|
# Redirect to lightweight captive portal setup page (not the full UI)
|
||||||
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
||||||
|
|
||||||
|
# Append a content-version query param (file mtime) to every static URL so the
|
||||||
|
# long-lived `immutable` cache (see add_security_headers below) is actually safe:
|
||||||
|
# when a static file changes its URL changes, so browsers refetch it. Without
|
||||||
|
# this, edited JS/CSS were served immutable under an unchanging URL and never
|
||||||
|
# reached clients until a manual cache clear.
|
||||||
|
@app.url_defaults
|
||||||
|
def add_static_version(endpoint, values):
|
||||||
|
if endpoint == 'static' and values.get('filename'):
|
||||||
|
try:
|
||||||
|
file_path = os.path.join(app.static_folder, values['filename'])
|
||||||
|
values['v'] = int(os.path.getmtime(file_path))
|
||||||
|
except OSError:
|
||||||
|
# File missing (e.g. plugin asset not yet installed) — skip versioning.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Add security headers and caching to all responses
|
# Add security headers and caching to all responses
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def add_security_headers(response):
|
def add_security_headers(response):
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ def _ensure_display_service_running():
|
|||||||
if status.get('active'):
|
if status.get('active'):
|
||||||
status['started'] = False
|
status['started'] = False
|
||||||
return status
|
return status
|
||||||
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix'])
|
result = _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')
|
||||||
@@ -199,7 +199,7 @@ def _ensure_display_service_running():
|
|||||||
|
|
||||||
def _stop_display_service():
|
def _stop_display_service():
|
||||||
"""Stop the ledmatrix display service."""
|
"""Stop the ledmatrix display service."""
|
||||||
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix'])
|
result = _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
|
||||||
@@ -1461,7 +1461,7 @@ def execute_system_action():
|
|||||||
# For on-demand modes, we would need to integrate with the display controller
|
# For on-demand modes, we would need to integrate with the display controller
|
||||||
# For now, just start the display service
|
# For now, just start the display service
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
except subprocess.TimeoutExpired as e:
|
except subprocess.TimeoutExpired as e:
|
||||||
logger.error("start_display (%s) timed out: %s", mode, e)
|
logger.error("start_display (%s) timed out: %s", mode, e)
|
||||||
@@ -1478,16 +1478,16 @@ def execute_system_action():
|
|||||||
resp['stderr'] = result.stderr.strip()
|
resp['stderr'] = result.stderr.strip()
|
||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
else:
|
else:
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'stop_display':
|
elif action == 'stop_display':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'enable_autostart':
|
elif action == 'enable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'disable_autostart':
|
elif action == 'disable_autostart':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'reboot_system':
|
elif action == 'reboot_system':
|
||||||
result = subprocess.run(['sudo', 'reboot'],
|
result = subprocess.run(['sudo', 'reboot'],
|
||||||
@@ -1568,11 +1568,11 @@ def execute_system_action():
|
|||||||
'message': pull_message,
|
'message': pull_message,
|
||||||
})
|
})
|
||||||
elif action == 'restart_display_service':
|
elif action == 'restart_display_service':
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'restart_web_service':
|
elif action == 'restart_web_service':
|
||||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||||
@@ -2412,6 +2412,13 @@ def reconcile_plugin_state():
|
|||||||
|
|
||||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
from src.plugin_system.state_reconciliation import StateReconciliation
|
||||||
|
|
||||||
|
# Parse optional `force` flag from request body, guarding against
|
||||||
|
# non-dict bodies (bare string, array, null) that would raise AttributeError.
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
force = _coerce_to_bool(payload.get('force', False))
|
||||||
|
|
||||||
reconciler = StateReconciliation(
|
reconciler = StateReconciliation(
|
||||||
state_manager=api_v3.plugin_state_manager,
|
state_manager=api_v3.plugin_state_manager,
|
||||||
config_manager=api_v3.config_manager,
|
config_manager=api_v3.config_manager,
|
||||||
@@ -2419,7 +2426,7 @@ def reconcile_plugin_state():
|
|||||||
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = reconciler.reconcile_state()
|
result = reconciler.reconcile_state(force=force)
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
@@ -2846,6 +2853,89 @@ def update_plugin():
|
|||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _do_transactional_uninstall(plugin_id, preserve_config):
|
||||||
|
"""Execute an uninstall with snapshot-based rollback.
|
||||||
|
|
||||||
|
Order of operations:
|
||||||
|
1. Snapshot main config + secrets (abort on unexpected errors, proceed on expected I/O errors).
|
||||||
|
2. Clean up plugin config (abort with 500 if this raises — avoids orphaned files).
|
||||||
|
3. Unload plugin from runtime if loaded (rollback + 500 if this raises).
|
||||||
|
4. Remove plugin files (rollback + 500 if this returns False or raises).
|
||||||
|
5. Finish (remove state, invalidate caches).
|
||||||
|
|
||||||
|
Rollback restores the config snapshot and, if the plugin had been
|
||||||
|
loaded before unload, calls load_plugin to restore runtime state.
|
||||||
|
|
||||||
|
Returns (True, None) on success or (False, error_message) on failure.
|
||||||
|
"""
|
||||||
|
from src.exceptions import ConfigError
|
||||||
|
|
||||||
|
# --- Step 1: snapshot main + secrets ---
|
||||||
|
main_snapshot = None
|
||||||
|
secrets_snapshot = None
|
||||||
|
try:
|
||||||
|
main_snapshot = api_v3.config_manager.get_raw_file_content('main')
|
||||||
|
except (OSError, ConfigError):
|
||||||
|
pass # Proceed without snapshot; narrow catch preserves TypeError/AttributeError
|
||||||
|
try:
|
||||||
|
secrets_snapshot = api_v3.config_manager.get_raw_file_content('secrets')
|
||||||
|
except (OSError, ConfigError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Step 2: cleanup config first (abort before touching filesystem) ---
|
||||||
|
if not preserve_config:
|
||||||
|
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
||||||
|
|
||||||
|
# Record whether the plugin was running before we touch anything.
|
||||||
|
was_loaded = (
|
||||||
|
api_v3.plugin_manager is not None
|
||||||
|
and plugin_id in api_v3.plugin_manager.plugins
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rollback(reload_plugin):
|
||||||
|
if main_snapshot is not None:
|
||||||
|
try:
|
||||||
|
api_v3.config_manager.save_raw_file_content('main', main_snapshot)
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error("Failed to restore main config snapshot for %s: %s", plugin_id, restore_err)
|
||||||
|
if secrets_snapshot is not None:
|
||||||
|
try:
|
||||||
|
api_v3.config_manager.save_raw_file_content('secrets', secrets_snapshot)
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error("Failed to restore secrets snapshot for %s: %s", plugin_id, restore_err)
|
||||||
|
if reload_plugin and api_v3.plugin_manager is not None:
|
||||||
|
try:
|
||||||
|
api_v3.plugin_manager.load_plugin(plugin_id)
|
||||||
|
except Exception as reload_err:
|
||||||
|
logger.error("Failed to reload plugin %s during rollback: %s", plugin_id, reload_err)
|
||||||
|
|
||||||
|
# --- Step 3: unload ---
|
||||||
|
if was_loaded:
|
||||||
|
try:
|
||||||
|
api_v3.plugin_manager.unload_plugin(plugin_id)
|
||||||
|
except Exception as unload_err:
|
||||||
|
_rollback(reload_plugin=False) # unload failed — runtime state unchanged
|
||||||
|
return False, f"Failed to unload plugin {plugin_id}: {unload_err}"
|
||||||
|
|
||||||
|
# --- Step 4: remove files ---
|
||||||
|
try:
|
||||||
|
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
||||||
|
except Exception as remove_err:
|
||||||
|
_rollback(reload_plugin=was_loaded)
|
||||||
|
return False, f"Failed to remove plugin {plugin_id}: {remove_err}"
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
_rollback(reload_plugin=was_loaded)
|
||||||
|
return False, f"Failed to uninstall plugin {plugin_id}"
|
||||||
|
|
||||||
|
# --- Step 5: finish ---
|
||||||
|
if api_v3.schema_manager:
|
||||||
|
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||||
|
if api_v3.plugin_state_manager:
|
||||||
|
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
||||||
def uninstall_plugin():
|
def uninstall_plugin():
|
||||||
"""Uninstall plugin"""
|
"""Uninstall plugin"""
|
||||||
@@ -2865,19 +2955,13 @@ def uninstall_plugin():
|
|||||||
plugin_id = data['plugin_id']
|
plugin_id = data['plugin_id']
|
||||||
preserve_config = data.get('preserve_config', False)
|
preserve_config = data.get('preserve_config', False)
|
||||||
|
|
||||||
# Use operation queue if available
|
# Both queued and direct paths use the same transactional helper so
|
||||||
|
# snapshot/rollback behaviour is consistent regardless of deployment.
|
||||||
if api_v3.operation_queue:
|
if api_v3.operation_queue:
|
||||||
def uninstall_callback(operation):
|
def uninstall_callback(operation):
|
||||||
"""Callback to execute plugin uninstallation."""
|
"""Callback to execute plugin uninstallation via transactional helper."""
|
||||||
# Unload the plugin first if it's loaded
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
|
||||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
|
||||||
|
|
||||||
# Uninstall the plugin
|
|
||||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
error_msg = f'Failed to uninstall plugin {plugin_id}'
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2885,24 +2969,7 @@ def uninstall_plugin():
|
|||||||
status="failed",
|
status="failed",
|
||||||
error=error_msg
|
error=error_msg
|
||||||
)
|
)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}')
|
||||||
|
|
||||||
# Invalidate schema cache
|
|
||||||
if api_v3.schema_manager:
|
|
||||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
||||||
|
|
||||||
# Clean up plugin configuration if not preserving
|
|
||||||
if not preserve_config:
|
|
||||||
try:
|
|
||||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
|
||||||
except Exception as cleanup_err:
|
|
||||||
logger.warning("Failed to cleanup config after uninstall: %s", cleanup_err)
|
|
||||||
|
|
||||||
# Remove from state manager
|
|
||||||
if api_v3.plugin_state_manager:
|
|
||||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2910,7 +2977,6 @@ def uninstall_plugin():
|
|||||||
status="success",
|
status="success",
|
||||||
details={"preserve_config": preserve_config}
|
details={"preserve_config": preserve_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
||||||
|
|
||||||
# Enqueue operation
|
# Enqueue operation
|
||||||
@@ -2925,31 +2991,10 @@ def uninstall_plugin():
|
|||||||
message='Plugin uninstallation queued'
|
message='Plugin uninstallation queued'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback to direct uninstall
|
# Direct (non-queued) transactional uninstall
|
||||||
# Unload the plugin first if it's loaded
|
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||||
if api_v3.plugin_manager and plugin_id in api_v3.plugin_manager.plugins:
|
|
||||||
api_v3.plugin_manager.unload_plugin(plugin_id)
|
|
||||||
|
|
||||||
# Uninstall the plugin
|
|
||||||
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Invalidate schema cache
|
|
||||||
if api_v3.schema_manager:
|
|
||||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
|
||||||
|
|
||||||
# Clean up plugin configuration if not preserving
|
|
||||||
if not preserve_config:
|
|
||||||
try:
|
|
||||||
api_v3.config_manager.cleanup_plugin_config(plugin_id, remove_secrets=True)
|
|
||||||
except Exception as cleanup_err:
|
|
||||||
logger.warning("Failed to cleanup config after uninstall: %s", cleanup_err)
|
|
||||||
|
|
||||||
# Remove from state manager
|
|
||||||
if api_v3.plugin_state_manager:
|
|
||||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
api_v3.operation_history.record_operation(
|
api_v3.operation_history.record_operation(
|
||||||
"uninstall",
|
"uninstall",
|
||||||
@@ -2957,7 +3002,6 @@ def uninstall_plugin():
|
|||||||
status="success",
|
status="success",
|
||||||
details={"preserve_config": preserve_config}
|
details={"preserve_config": preserve_config}
|
||||||
)
|
)
|
||||||
|
|
||||||
return success_response(message='Plugin uninstalled successfully')
|
return success_response(message='Plugin uninstalled successfully')
|
||||||
else:
|
else:
|
||||||
if api_v3.operation_history:
|
if api_v3.operation_history:
|
||||||
@@ -2965,12 +3009,11 @@ def uninstall_plugin():
|
|||||||
"uninstall",
|
"uninstall",
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
error='Plugin uninstall failed'
|
error=error_msg
|
||||||
)
|
)
|
||||||
|
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
||||||
'Plugin uninstall failed',
|
error_msg or 'Plugin uninstall failed',
|
||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3666,13 +3709,14 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return prop.get('default', 0.0)
|
return prop.get('default', 0.0)
|
||||||
|
|
||||||
# Try parsing as number (fallback)
|
# Try parsing as number (fallback) — skip when schema explicitly says string
|
||||||
try:
|
if not (prop and prop.get('type') == 'string'):
|
||||||
if '.' in stripped:
|
try:
|
||||||
return float(stripped)
|
if '.' in stripped:
|
||||||
return int(stripped)
|
return float(stripped)
|
||||||
except ValueError:
|
return int(stripped)
|
||||||
pass
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Return as string
|
# Return as string
|
||||||
return value
|
return value
|
||||||
@@ -4217,7 +4261,9 @@ def save_plugin_config():
|
|||||||
nested_dict = config_dict.get(prop_key)
|
nested_dict = config_dict.get(prop_key)
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
fix_array_structures(nested_dict, prop_schema['properties'], nested_prefix)
|
# Pass no prefix: config_dict is already the navigated sub-dict,
|
||||||
|
# so path segments from the parent would mis-navigate it.
|
||||||
|
fix_array_structures(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
# Also ensure array fields that are None get converted to empty arrays
|
# Also ensure array fields that are None get converted to empty arrays
|
||||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
||||||
@@ -4277,7 +4323,8 @@ def save_plugin_config():
|
|||||||
nested_dict = config_dict[prop_key]
|
nested_dict = config_dict[prop_key]
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
ensure_array_defaults(nested_dict, prop_schema['properties'], nested_prefix)
|
# Pass no prefix: config_dict is already navigated.
|
||||||
|
ensure_array_defaults(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
if schema and 'properties' in schema:
|
if schema and 'properties' in schema:
|
||||||
# First, fix any dict structures that should be arrays
|
# First, fix any dict structures that should be arrays
|
||||||
@@ -4377,6 +4424,21 @@ def save_plugin_config():
|
|||||||
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
||||||
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
|
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
|
||||||
|
|
||||||
|
# After merging defaults, replace any None array values with their schema defaults.
|
||||||
|
# merge_with_defaults gives user config higher priority, so a None submitted by
|
||||||
|
# the client can survive the merge — this pass cleans those up.
|
||||||
|
def _fix_none_arrays(cfg, props):
|
||||||
|
for k, pschema in props.items():
|
||||||
|
if pschema.get('type') == 'array':
|
||||||
|
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
|
||||||
|
cfg[k] = pschema.get('default', [])
|
||||||
|
elif pschema.get('type') == 'object' and 'properties' in pschema:
|
||||||
|
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
|
||||||
|
_fix_none_arrays(cfg[k], pschema['properties'])
|
||||||
|
|
||||||
|
if schema and 'properties' in schema and isinstance(plugin_config, dict):
|
||||||
|
_fix_none_arrays(plugin_config, schema['properties'])
|
||||||
|
|
||||||
# Ensure enabled state is preserved after defaults merge
|
# Ensure enabled state is preserved after defaults merge
|
||||||
# Defaults should not overwrite an explicitly preserved enabled value
|
# Defaults should not overwrite an explicitly preserved enabled value
|
||||||
if preserved_enabled is not None:
|
if preserved_enabled is not None:
|
||||||
|
|||||||
@@ -440,7 +440,7 @@
|
|||||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||||
</div>`;
|
</div>`);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'px-5 py-4 space-y-4';
|
body.className = 'px-5 py-4 space-y-4';
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
||||||
<button type="button" id="array-row-editor-save"
|
<button type="button" id="array-row-editor-save"
|
||||||
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`;
|
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`);
|
||||||
|
|
||||||
// Save handler
|
// Save handler
|
||||||
footer.querySelector('#array-row-editor-save').onclick = function() {
|
footer.querySelector('#array-row-editor-save').onclick = function() {
|
||||||
|
|||||||
@@ -352,15 +352,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set data-loaded on tab containers after HTMX settles their content,
|
// Mark tab containers as loaded once their content settles, so switching
|
||||||
// preventing repeated re-fetches on every tab switch.
|
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
|
||||||
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
|
// containers only) so modals and plugin config panels can still reload.
|
||||||
// modals and plugin config panels that legitimately reload are unaffected.
|
|
||||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
document.body.addEventListener('htmx:afterSettle', function(event) {
|
||||||
if (event.detail && event.detail.target) {
|
if (event.detail && event.detail.target) {
|
||||||
var target = event.detail.target;
|
var target = event.detail.target;
|
||||||
var trigger = target.getAttribute('hx-trigger') || '';
|
var trigger = target.getAttribute('hx-trigger') || '';
|
||||||
if (trigger.includes('revealed')) {
|
if (trigger.includes('loadtab')) {
|
||||||
target.setAttribute('data-loaded', 'true');
|
target.setAttribute('data-loaded', 'true');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,7 +866,7 @@
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
|
||||||
<!-- Custom v3 styles -->
|
<!-- Custom v3 styles -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}?v=20260216b">
|
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -1030,7 +1029,7 @@
|
|||||||
<div id="tab-content" class="space-y-6">
|
<div id="tab-content" class="space-y-6">
|
||||||
<!-- Overview tab -->
|
<!-- Overview tab -->
|
||||||
<div x-show="activeTab === 'overview'" x-transition>
|
<div x-show="activeTab === 'overview'" x-transition>
|
||||||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1098,7 +1097,7 @@
|
|||||||
|
|
||||||
<!-- General tab -->
|
<!-- General tab -->
|
||||||
<div x-show="activeTab === 'general'" x-transition>
|
<div x-show="activeTab === 'general'" x-transition>
|
||||||
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1116,7 +1115,7 @@
|
|||||||
<div x-show="activeTab === 'wifi'" x-transition>
|
<div x-show="activeTab === 'wifi'" x-transition>
|
||||||
<div id="wifi-content"
|
<div id="wifi-content"
|
||||||
hx-get="/v3/partials/wifi"
|
hx-get="/v3/partials/wifi"
|
||||||
hx-trigger="revealed"
|
hx-trigger="loadtab"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-on::htmx:response-error="loadWifiDirect()">
|
hx-on::htmx:response-error="loadWifiDirect()">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
@@ -1167,7 +1166,7 @@
|
|||||||
|
|
||||||
<!-- Schedule tab -->
|
<!-- Schedule tab -->
|
||||||
<div x-show="activeTab === 'schedule'" x-transition>
|
<div x-show="activeTab === 'schedule'" x-transition>
|
||||||
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1183,7 +1182,7 @@
|
|||||||
|
|
||||||
<!-- Display tab -->
|
<!-- Display tab -->
|
||||||
<div x-show="activeTab === 'display'" x-transition>
|
<div x-show="activeTab === 'display'" x-transition>
|
||||||
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1198,7 +1197,7 @@
|
|||||||
|
|
||||||
<!-- Backup & Restore tab -->
|
<!-- Backup & Restore tab -->
|
||||||
<div x-show="activeTab === 'backup-restore'" x-transition>
|
<div x-show="activeTab === 'backup-restore'" x-transition>
|
||||||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1210,7 +1209,7 @@
|
|||||||
|
|
||||||
<!-- Config Editor tab -->
|
<!-- Config Editor tab -->
|
||||||
<div x-show="activeTab === 'config-editor'" x-transition>
|
<div x-show="activeTab === 'config-editor'" x-transition>
|
||||||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1225,7 +1224,7 @@
|
|||||||
|
|
||||||
<!-- Plugins tab -->
|
<!-- Plugins tab -->
|
||||||
<div x-show="activeTab === 'plugins'" x-transition>
|
<div x-show="activeTab === 'plugins'" x-transition>
|
||||||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
|
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
|
||||||
hx-on::response-error="loadPluginsDirect()">
|
hx-on::response-error="loadPluginsDirect()">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
@@ -1242,7 +1241,7 @@
|
|||||||
|
|
||||||
<!-- Fonts tab -->
|
<!-- Fonts tab -->
|
||||||
<div x-show="activeTab === 'fonts'" x-transition>
|
<div x-show="activeTab === 'fonts'" x-transition>
|
||||||
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1257,7 +1256,7 @@
|
|||||||
|
|
||||||
<!-- Logs tab -->
|
<!-- Logs tab -->
|
||||||
<div x-show="activeTab === 'logs'" x-transition>
|
<div x-show="activeTab === 'logs'" x-transition>
|
||||||
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1269,7 +1268,7 @@
|
|||||||
|
|
||||||
<!-- Cache tab -->
|
<!-- Cache tab -->
|
||||||
<div x-show="activeTab === 'cache'" x-transition>
|
<div x-show="activeTab === 'cache'" x-transition>
|
||||||
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1281,7 +1280,7 @@
|
|||||||
|
|
||||||
<!-- Operation History tab -->
|
<!-- Operation History tab -->
|
||||||
<div x-show="activeTab === 'operation-history'" x-transition>
|
<div x-show="activeTab === 'operation-history'" x-transition>
|
||||||
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="loadtab" hx-swap="innerHTML">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -1861,28 +1860,53 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadTabContent(tab) {
|
loadTabContent(tab) {
|
||||||
// Try to load content for the active tab
|
const contentEl = document.getElementById(tab + '-content');
|
||||||
if (typeof htmx !== 'undefined') {
|
// data-loaded: already fetched. data-loading: a fetch is queued or in
|
||||||
const contentId = tab + '-content';
|
// flight. Both guard against re-entry so a panel loads exactly once, even
|
||||||
const contentEl = document.getElementById(contentId);
|
// if the tab is reopened before an in-progress (or polling) load settles.
|
||||||
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
|
if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
|
||||||
// Trigger HTMX load
|
const url = contentEl.getAttribute('hx-get');
|
||||||
htmx.trigger(contentEl, 'revealed');
|
if (!url) return;
|
||||||
|
|
||||||
|
contentEl.setAttribute('data-loading', 'true');
|
||||||
|
|
||||||
|
// htmx.ajax issues the request and swaps the response into the panel
|
||||||
|
// directly, so it works even before htmx has wired up the element's
|
||||||
|
// hx-trigger listeners. data-loaded is stamped on success so the panel
|
||||||
|
// loads once; the activeTab check drops loads for a tab the user navigated
|
||||||
|
// away from while htmx was still loading (avoids fetching hidden panels).
|
||||||
|
const swap = contentEl.getAttribute('hx-swap') || 'innerHTML';
|
||||||
|
const load = () => {
|
||||||
|
if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) {
|
||||||
|
contentEl.removeAttribute('data-loading');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
return htmx.ajax('GET', url, { target: contentEl, swap: swap })
|
||||||
// HTMX is still loading asynchronously — retry when it signals ready,
|
.then(() => contentEl.setAttribute('data-loaded', 'true'))
|
||||||
// or fall back to direct fetch if it fails to load entirely.
|
.catch(() => {}) // leave unstamped on failure so it can retry
|
||||||
const self = this;
|
.finally(() => contentEl.removeAttribute('data-loading'));
|
||||||
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
|
};
|
||||||
function onFailed() {
|
|
||||||
window.removeEventListener('htmx:ready', onReady);
|
if (typeof htmx !== 'undefined') {
|
||||||
|
load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmx is loaded from a CDN and may not be ready yet. Poll until it is,
|
||||||
|
// then load; if it never arrives, fall back to a direct fetch.
|
||||||
|
let tries = 0;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (typeof htmx !== 'undefined') {
|
||||||
|
clearInterval(timer);
|
||||||
|
load();
|
||||||
|
} else if (++tries > 100) { // ~10s
|
||||||
|
clearInterval(timer);
|
||||||
|
contentEl.removeAttribute('data-loading');
|
||||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
||||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
||||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
||||||
}
|
}
|
||||||
window.addEventListener('htmx:ready', onReady, { once: true });
|
}, 100);
|
||||||
window.addEventListener('htmx-load-failed', onFailed, { once: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadInstalledPlugins() {
|
async loadInstalledPlugins() {
|
||||||
|
|||||||
Reference in New Issue
Block a user