mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-19 02:58:37 +00:00
Compare commits
18 Commits
b1af068f7a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 | ||
|
|
d22d0a3754 | ||
|
|
5beef0aa01 | ||
|
|
cf28a8c0d5 | ||
|
|
a06682981c | ||
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa | ||
|
|
4961697251 | ||
|
|
cac9644b6d |
7
.codacy.yml
Normal file
7
.codacy.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "plugin-repos/**"
|
||||
- "plugins/**"
|
||||
- "assets/**"
|
||||
- "test/**"
|
||||
- "scripts/debug/**"
|
||||
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ config/config_secrets.json
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
config/uninstalled_plugins.json
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
|
||||
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.
|
||||
@@ -47,7 +47,7 @@ Full inline file management UI for plugins that manage files via the `web_ui_act
|
||||
}
|
||||
```
|
||||
|
||||
Not all 7 actions are required — omit any key to hide the corresponding UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
||||
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
||||
|
||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ on_error() {
|
||||
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
||||
if [ -n "${LOG_FILE:-}" ]; then
|
||||
echo "See the log for details: $LOG_FILE" >&2
|
||||
echo "-- Last 50 lines from log --" >&2
|
||||
tail -n 50 "$LOG_FILE" >&2 || true
|
||||
echo "-- Last 100 lines from log --" >&2
|
||||
tail -n 100 "$LOG_FILE" >&2 || true
|
||||
fi
|
||||
echo "\nCommon fixes:" >&2
|
||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||
@@ -202,8 +202,33 @@ retry() {
|
||||
done
|
||||
}
|
||||
|
||||
apt_update() { retry apt update; }
|
||||
apt_install() { retry apt install -y "$@"; }
|
||||
# Wait for another apt/dpkg process (commonly unattended-upgrades running
|
||||
# shortly after first boot) to release its lock before we try apt ourselves.
|
||||
# Without this, apt_update/apt_install can fail outright in the first couple
|
||||
# minutes after a fresh Pi OS boot with a generic "Command failed after 3
|
||||
# attempts" error.
|
||||
wait_for_apt_lock() {
|
||||
command -v flock >/dev/null 2>&1 || return 0
|
||||
local lock_file="/var/lib/dpkg/lock-frontend"
|
||||
local max_wait=180
|
||||
local waited=0
|
||||
local printed=0
|
||||
while ! flock -n "$lock_file" -c true 2>/dev/null; do
|
||||
if [ "$printed" -eq 0 ]; then
|
||||
echo "⚠ Waiting for another apt/dpkg process to finish (e.g. unattended-upgrades on first boot)..."
|
||||
printed=1
|
||||
fi
|
||||
if [ "$waited" -ge "$max_wait" ]; then
|
||||
echo "⚠ Still waiting after ${max_wait}s; proceeding anyway."
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited+5))
|
||||
done
|
||||
}
|
||||
|
||||
apt_update() { wait_for_apt_lock; retry apt update; }
|
||||
apt_install() { wait_for_apt_lock; retry apt install -y "$@"; }
|
||||
apt_remove() { apt-get remove -y "$@" || true; }
|
||||
|
||||
check_network() {
|
||||
@@ -222,6 +247,22 @@ check_network() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_disk_space() {
|
||||
command -v df >/dev/null 2>&1 || return 0
|
||||
local available_mb
|
||||
available_mb=$(df -m "$PROJECT_ROOT_DIR" | awk 'NR==2{print $4}')
|
||||
available_mb=${available_mb:-0}
|
||||
if [ "$available_mb" -lt 500 ]; then
|
||||
echo "✗ ERROR: Insufficient disk space: ${available_mb}MB available (need at least 500MB)"
|
||||
echo " Free up space first, e.g.: sudo apt clean && sudo apt autoremove"
|
||||
exit 1
|
||||
elif [ "$available_mb" -lt 1024 ]; then
|
||||
echo "⚠ Limited disk space: ${available_mb}MB available (recommend at least 1GB for the rpi-rgb-led-matrix build in Step 6)"
|
||||
else
|
||||
echo "✓ Disk space sufficient: ${available_mb}MB available"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "This script will perform the following steps:"
|
||||
echo "1. Install system dependencies"
|
||||
@@ -271,8 +312,9 @@ CURRENT_STEP="Install system dependencies"
|
||||
echo "Step 1: Installing system dependencies..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Ensure network is available before APT operations
|
||||
# Pre-flight checks before APT operations
|
||||
check_network
|
||||
check_disk_space
|
||||
|
||||
# Update package list
|
||||
apt_update
|
||||
@@ -822,14 +864,14 @@ else
|
||||
# Try to initialize submodule if .gitmodules exists
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
|
||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
else
|
||||
# Fallback: clone directly if submodule not configured
|
||||
echo "Submodule not configured, cloning directly from GitHub..."
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -841,23 +883,34 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
rm -rf rpi-rgb-led-matrix-master
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
else
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
||||
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
||||
echo " Build deps required: python-dev-is-python3 cmake"
|
||||
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
||||
if ! python3 -m pip install --break-system-packages .; then
|
||||
BUILD_OUTPUT=$(mktemp)
|
||||
BUILD_SUCCESS=false
|
||||
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
|
||||
BUILD_SUCCESS=true
|
||||
fi
|
||||
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
||||
if [ "$BUILD_SUCCESS" != true ]; then
|
||||
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
||||
echo " Ensure build tools are installed:"
|
||||
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
|
||||
echo ""
|
||||
echo "-- Last 50 lines of build output --"
|
||||
tail -n 50 "$BUILD_OUTPUT"
|
||||
rm -f "$BUILD_OUTPUT"
|
||||
popd >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$BUILD_OUTPUT"
|
||||
popd >/dev/null
|
||||
else
|
||||
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
||||
@@ -912,7 +965,9 @@ else
|
||||
# Try to install dependencies using the smart installer if available
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||
echo "Using smart dependency installer..."
|
||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
||||
# real time and in order relative to this script's own echo statements
|
||||
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
else
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
|
||||
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())
|
||||
@@ -340,9 +340,14 @@ main() {
|
||||
echo ""
|
||||
|
||||
# Execute with proper error handling and non-interactive mode
|
||||
# Temporarily disable errexit to capture exit code instead of exiting immediately
|
||||
# Temporarily disable errexit AND the ERR trap to capture exit code instead of
|
||||
# exiting immediately. `set +e` alone does not suppress the ERR trap, so without
|
||||
# `trap '' ERR` a non-zero exit from first_time_install.sh would trigger on_error
|
||||
# here with the generic "Main installation" message instead of the detailed
|
||||
# if/else handling below.
|
||||
set +e
|
||||
|
||||
trap '' ERR
|
||||
|
||||
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
||||
# When running manually, /tmp usually has correct permissions (1777)
|
||||
TMP_PERMS=$(stat -c '%a' /tmp 2>/dev/null || echo "unknown")
|
||||
@@ -370,6 +375,7 @@ main() {
|
||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
||||
fi
|
||||
INSTALL_EXIT_CODE=$?
|
||||
trap 'on_error $LINENO' ERR # Re-enable ERR trap
|
||||
set -e # Re-enable errexit
|
||||
|
||||
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
||||
|
||||
@@ -6,46 +6,67 @@ then falls back to pip with --break-system-packages
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
# How many trailing lines of a failed command's output to keep for the
|
||||
# end-of-run failure summary. Keeps the root cause near the end of the log,
|
||||
# which is where first_time_install.sh's error handler tails from.
|
||||
ERROR_TAIL_LINES = 15
|
||||
|
||||
|
||||
def _run(cmd):
|
||||
"""Run a command, streaming combined stdout/stderr to a temp file.
|
||||
|
||||
Returns (success, output) instead of raising, so callers can report
|
||||
*why* a command failed rather than just that it failed. `output` is
|
||||
bounded to the last ERROR_TAIL_LINES lines so failures from very
|
||||
chatty commands (e.g. pip build logs) don't get buffered in memory.
|
||||
"""
|
||||
with tempfile.TemporaryFile(mode='w+b') as f:
|
||||
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
|
||||
f.seek(0)
|
||||
# Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
|
||||
# in memory, regardless of how much output the command produced.
|
||||
tail = deque(
|
||||
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
|
||||
maxlen=ERROR_TAIL_LINES,
|
||||
)
|
||||
return result.returncode == 0, '\n'.join(tail)
|
||||
|
||||
|
||||
def install_via_apt(package_name):
|
||||
"""Try to install a package via apt."""
|
||||
try:
|
||||
# Map pip package names to apt package names
|
||||
apt_package_map = {
|
||||
'flask': 'python3-flask',
|
||||
'PIL': 'python3-pil',
|
||||
'freetype': 'python3-freetype',
|
||||
'psutil': 'python3-psutil',
|
||||
'werkzeug': 'python3-werkzeug',
|
||||
'numpy': 'python3-numpy',
|
||||
'requests': 'python3-requests',
|
||||
'python-dateutil': 'python3-dateutil',
|
||||
'pytz': 'python3-tz',
|
||||
'geopy': 'python3-geopy',
|
||||
'unidecode': 'python3-unidecode',
|
||||
'websockets': 'python3-websockets',
|
||||
'websocket-client': 'python3-websocket-client'
|
||||
}
|
||||
|
||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||
|
||||
print(f"Trying to install {apt_package} via apt...")
|
||||
subprocess.check_call([
|
||||
'sudo', 'apt', 'update'
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
subprocess.check_call([
|
||||
'sudo', 'apt', 'install', '-y', apt_package
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
"""Try to install a package via apt. Returns (success, output)."""
|
||||
# Map pip package names to apt package names
|
||||
apt_package_map = {
|
||||
'flask': 'python3-flask',
|
||||
'PIL': 'python3-pil',
|
||||
'freetype': 'python3-freetype',
|
||||
'psutil': 'python3-psutil',
|
||||
'werkzeug': 'python3-werkzeug',
|
||||
'numpy': 'python3-numpy',
|
||||
'requests': 'python3-requests',
|
||||
'python-dateutil': 'python3-dateutil',
|
||||
'pytz': 'python3-tz',
|
||||
'geopy': 'python3-geopy',
|
||||
'unidecode': 'python3-unidecode',
|
||||
'websockets': 'python3-websockets',
|
||||
'websocket-client': 'python3-websocket-client'
|
||||
}
|
||||
|
||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||
|
||||
print(f"Trying to install {apt_package} via apt...")
|
||||
success, output = _run(['sudo', 'apt', 'install', '-y', apt_package])
|
||||
if success:
|
||||
print(f"Successfully installed {apt_package} via apt")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Failed to install {package_name} via apt, will try pip")
|
||||
return False
|
||||
return True, ""
|
||||
|
||||
print(f"Failed to install {apt_package} via apt, will try pip")
|
||||
return False, output
|
||||
|
||||
|
||||
def install_via_pip(package_name):
|
||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
||||
@@ -54,34 +75,73 @@ def install_via_pip(package_name):
|
||||
Debian/Ubuntu-based systems without a virtual environment.
|
||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||
exhausting /tmp space during compilation.
|
||||
--ignore-installed stops pip from trying to *uninstall* packages that were
|
||||
installed by apt (e.g. python3-requests). Those Debian packages ship no
|
||||
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
||||
and aborts the whole install. With --ignore-installed, pip lays the new
|
||||
version down in /usr/local where it shadows the apt copy instead of removing
|
||||
it. This matters when a pip dependency (google-api-python-client pulls a
|
||||
newer requests) needs to upgrade an apt-managed package.
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
try:
|
||||
print(f"Installing {package_name} via pip...")
|
||||
subprocess.check_call([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||
])
|
||||
print(f"Installing {package_name} via pip...")
|
||||
success, output = _run([
|
||||
sys.executable, '-m', 'pip', 'install',
|
||||
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
||||
])
|
||||
if success:
|
||||
print(f"Successfully installed {package_name} via pip")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to install {package_name} via pip: {e}")
|
||||
return False
|
||||
return True, ""
|
||||
|
||||
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
|
||||
return False, output
|
||||
|
||||
|
||||
# Distribution (pip/apt) names whose importable module name differs.
|
||||
IMPORT_NAME_MAP = {
|
||||
'python-dateutil': 'dateutil',
|
||||
'websocket-client': 'websocket',
|
||||
}
|
||||
|
||||
|
||||
def check_package_installed(package_name):
|
||||
"""Check if a package is already installed."""
|
||||
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
||||
# Suppress deprecation warnings when checking if packages are installed
|
||||
# (we're just checking, not using them)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
try:
|
||||
__import__(package_name)
|
||||
__import__(import_name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def print_failure_summary(failed_packages, failure_details):
|
||||
print("\n" + "=" * 60)
|
||||
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
||||
print("=" * 60)
|
||||
for package in failed_packages:
|
||||
print(f"\nPackage: {package}")
|
||||
print("-" * 40)
|
||||
output = failure_details.get(package, "").strip()
|
||||
if not output:
|
||||
print(" (no output captured)")
|
||||
continue
|
||||
for line in output.splitlines()[-ERROR_TAIL_LINES:]:
|
||||
print(f" {line}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main installation function."""
|
||||
print("Installing dependencies for LED Matrix Web Interface V2...")
|
||||
|
||||
|
||||
print("Refreshing apt package index...")
|
||||
_run(['sudo', 'apt', 'update']) # best-effort; individual installs surface their own errors
|
||||
|
||||
# List of required packages
|
||||
required_packages = [
|
||||
'flask',
|
||||
@@ -98,19 +158,23 @@ def main():
|
||||
'websockets',
|
||||
'websocket-client'
|
||||
]
|
||||
|
||||
|
||||
failed_packages = []
|
||||
|
||||
failure_details = {}
|
||||
|
||||
for package in required_packages:
|
||||
if check_package_installed(package):
|
||||
print(f"{package} is already installed")
|
||||
continue
|
||||
|
||||
|
||||
# Try apt first, then pip
|
||||
if not install_via_apt(package):
|
||||
if not install_via_pip(package):
|
||||
ok, apt_output = install_via_apt(package)
|
||||
if not ok:
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
failed_packages.append(package)
|
||||
|
||||
failure_details[package] = pip_output or apt_output
|
||||
|
||||
# Install packages that don't have apt equivalents
|
||||
special_packages = [
|
||||
'timezonefinder>=6.5.0,<7.0.0',
|
||||
@@ -122,47 +186,49 @@ def main():
|
||||
'python-socketio>=5.11.0,<6.0.0',
|
||||
'python-engineio>=4.9.0,<5.0.0'
|
||||
]
|
||||
|
||||
|
||||
for package in special_packages:
|
||||
if not install_via_pip(package):
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
failed_packages.append(package)
|
||||
|
||||
failure_details[package] = pip_output
|
||||
|
||||
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
||||
# Check if already installed first
|
||||
if check_package_installed('rgbmatrix'):
|
||||
print("rgbmatrix module already installed, skipping...")
|
||||
else:
|
||||
print("Installing rgbmatrix module from local source...")
|
||||
try:
|
||||
# Get project root (parent of scripts directory)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||
if rgbmatrix_path.exists():
|
||||
# Check if the module has been built (look for setup.py)
|
||||
setup_py = rgbmatrix_path / 'setup.py'
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
subprocess.check_call([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
# Get project root (parent of scripts directory)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||
if rgbmatrix_path.exists():
|
||||
# Check if the module has been built (look for setup.py)
|
||||
setup_py = rgbmatrix_path / 'setup.py'
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', str(rgbmatrix_path)])
|
||||
if ok:
|
||||
print("rgbmatrix module installed successfully")
|
||||
else:
|
||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||
print(" This is normal if Step 6 hasn't completed yet.")
|
||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||
# and should be installed in Step 6 of first_time_install.sh
|
||||
print("Warning: Failed to install rgbmatrix module:")
|
||||
for line in output.strip().splitlines()[-ERROR_TAIL_LINES:]:
|
||||
print(f" {line}")
|
||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||
print(" The web interface will work without it.")
|
||||
else:
|
||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||
# and should be installed in Step 6 of first_time_install.sh
|
||||
print(f"Warning: Failed to install rgbmatrix module: {e}")
|
||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||
print(" The web interface will work without it.")
|
||||
# Don't add to failed_packages since it's optional
|
||||
|
||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||
print(" This is normal if Step 6 hasn't completed yet.")
|
||||
else:
|
||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||
|
||||
if failed_packages:
|
||||
print(f"\nFailed to install the following packages: {failed_packages}")
|
||||
print("You may need to install them manually or check your system configuration.")
|
||||
print_failure_summary(failed_packages, failure_details)
|
||||
return False
|
||||
else:
|
||||
print("\nAll dependencies installed successfully!")
|
||||
|
||||
@@ -17,7 +17,6 @@ import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
# Add project root to path
|
||||
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
|
||||
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]")
|
||||
|
||||
MIN_DIMENSION = 1
|
||||
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:
|
||||
"""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')
|
||||
|
||||
@@ -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 os
|
||||
import time
|
||||
@@ -15,7 +40,10 @@ from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
||||
|
||||
def default(self, obj):
|
||||
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
|
||||
@@ -347,34 +347,40 @@ class ScrollHelper:
|
||||
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:
|
||||
"""Fast integer pixel extraction (no interpolation)."""
|
||||
# Fast numpy array slicing for normal case (no wrap-around)
|
||||
if end_x <= self.cached_image.width:
|
||||
# Normal case: single slice - fastest path
|
||||
frame_array = self.cached_array[:, start_x:end_x]
|
||||
# Convert to PIL Image (minimal overhead)
|
||||
return Image.fromarray(frame_array)
|
||||
"""Fast integer pixel extraction (no interpolation).
|
||||
|
||||
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
||||
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
||||
slices (128×32 = 12 KB) used here.
|
||||
"""
|
||||
_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:
|
||||
# Wrap-around case: combine two slices using numpy
|
||||
width1 = self.cached_image.width - start_x
|
||||
# Ensure frame buffer is allocated for all non-simple paths
|
||||
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:
|
||||
# Use pre-allocated buffer for output
|
||||
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)
|
||||
# Wrap-around: tail of image + head of image
|
||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||
|
||||
# Second part from beginning of image
|
||||
remaining_width = self.display_width - width1
|
||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||
|
||||
# Convert combined buffer to PIL Image
|
||||
return Image.fromarray(self._frame_buffer)
|
||||
else:
|
||||
# Edge case: start_x >= image width, wrap to beginning
|
||||
frame_array = self.cached_array[:, :self.display_width]
|
||||
return Image.fromarray(frame_array)
|
||||
# Edge case: start_x at or past image end — show from beginning,
|
||||
# clamped to available width (scroll_position should wrap before
|
||||
# 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:
|
||||
"""
|
||||
|
||||
@@ -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 os
|
||||
import logging
|
||||
@@ -17,6 +43,13 @@ from src.common.permission_utils import (
|
||||
)
|
||||
|
||||
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:
|
||||
# Use current working directory as base
|
||||
self.config_path: str = config_path or "config/config.json"
|
||||
@@ -29,9 +62,11 @@ class ConfigManager:
|
||||
self._atomic_manager: Optional[AtomicConfigManager] = None
|
||||
|
||||
def get_config_path(self) -> str:
|
||||
"""Return the path to the main config file (``config/config.json``)."""
|
||||
return self.config_path
|
||||
|
||||
def get_secrets_path(self) -> str:
|
||||
"""Return the path to the secrets file (``config/config_secrets.json``)."""
|
||||
return self.secrets_path
|
||||
|
||||
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 os
|
||||
import json
|
||||
@@ -28,6 +50,24 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||
|
||||
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):
|
||||
start_time = time.time()
|
||||
logger.info("Starting DisplayController initialization")
|
||||
@@ -138,7 +178,11 @@ class DisplayController:
|
||||
self.on_demand_last_event: Optional[str] = None
|
||||
self.on_demand_schedule_override = False
|
||||
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
|
||||
global WIFI_STATUS_FILE
|
||||
if WIFI_STATUS_FILE is None:
|
||||
@@ -148,7 +192,11 @@ class DisplayController:
|
||||
self.wifi_status_file = WIFI_STATUS_FILE
|
||||
self.wifi_status_active = False
|
||||
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:
|
||||
logger.info("Attempting to import plugin system...")
|
||||
from src.plugin_system import PluginManager
|
||||
@@ -321,6 +369,8 @@ class DisplayController:
|
||||
self.plugin_modes[mode] = plugin_instance
|
||||
self.mode_to_plugin_id[mode] = plugin_id
|
||||
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
|
||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||
@@ -367,11 +417,39 @@ class DisplayController:
|
||||
self.is_display_active = True
|
||||
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
|
||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
self.current_brightness = self._normal_brightness
|
||||
self.is_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
|
||||
try:
|
||||
self._publish_on_demand_state()
|
||||
@@ -533,17 +611,24 @@ class DisplayController:
|
||||
logger.debug("Schedule is disabled - display always active")
|
||||
return
|
||||
|
||||
# Get configured timezone, default to UTC
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
||||
tz = pytz.UTC
|
||||
# Lazily build the timezone object once; reuse on every subsequent call.
|
||||
if self._tz is None:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
|
||||
self._tz = pytz.UTC
|
||||
|
||||
# Use timezone-aware current time
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
||||
current_time = datetime.now(self._tz)
|
||||
# Gate: schedule state can only change on a minute boundary, so skip
|
||||
# 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()
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
@@ -632,8 +717,8 @@ class DisplayController:
|
||||
Target brightness level (dim_brightness if in dim period,
|
||||
normal brightness otherwise)
|
||||
"""
|
||||
# Get normal brightness from config
|
||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||
normal_brightness = self._normal_brightness
|
||||
|
||||
# If display is OFF via schedule, don't process dim schedule
|
||||
if not self.is_display_active:
|
||||
@@ -647,15 +732,21 @@ class DisplayController:
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
# Get configured timezone
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
||||
tz = pytz.UTC
|
||||
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
|
||||
if self._tz is None:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
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_time_only = current_time.time()
|
||||
|
||||
@@ -703,10 +794,12 @@ class DisplayController:
|
||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||
|
||||
self._was_dimmed = self.is_dimmed
|
||||
self._cached_target_brightness = target_brightness # persist for minute-gate
|
||||
return target_brightness
|
||||
|
||||
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
|
||||
|
||||
def _update_modules(self):
|
||||
@@ -1382,30 +1475,98 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.debug(f"Error logging memory stats: {e}")
|
||||
|
||||
def _check_live_priority(self):
|
||||
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.
|
||||
"""
|
||||
Check all plugins for live priority content.
|
||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
||||
"""
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
|
||||
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:
|
||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
||||
# Get the specific live mode from the plugin if available
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
live_modes = plugin_instance.get_live_modes()
|
||||
if live_modes and len(live_modes) > 0:
|
||||
# Verify the mode actually exists before returning it
|
||||
for suggested_mode in live_modes:
|
||||
if suggested_mode in self.plugin_modes:
|
||||
return suggested_mode
|
||||
# If suggested modes don't exist, fall through to check current mode
|
||||
# Fallback: if this mode ends with _live, return it
|
||||
if mode_name.endswith('_live'):
|
||||
return mode_name
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return None
|
||||
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 _collect_live_modes(self):
|
||||
"""Return every currently live-priority mode, in registration order.
|
||||
|
||||
Scans all registered plugin modes; for each plugin that has live
|
||||
priority *and* live content, collects the specific live mode(s) it
|
||||
reports via get_live_modes() (only those actually registered), falling
|
||||
back to the scanned mode name when it ends in '_live'. Deduplicated,
|
||||
preserving order. A plugin registered under several mode keys (the
|
||||
sports plugins register one per league) contributes each live mode once.
|
||||
"""
|
||||
live = []
|
||||
seen = set()
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||
and hasattr(plugin_instance, 'has_live_content')):
|
||||
continue
|
||||
try:
|
||||
if not (plugin_instance.has_live_priority()
|
||||
and plugin_instance.has_live_content()):
|
||||
continue
|
||||
resolved = []
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||
if suggested_mode in self.plugin_modes:
|
||||
resolved.append(suggested_mode)
|
||||
if not resolved and mode_name.endswith('_live'):
|
||||
resolved.append(mode_name)
|
||||
for m in resolved:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
live.append(m)
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
When several plugins report live content at once (e.g. a baseball game
|
||||
and a soccer match), this round-robins between them so the display
|
||||
alternates each dwell instead of pinning to whichever plugin is first in
|
||||
registration order.
|
||||
|
||||
advance=False (default): a non-advancing peek — returns the live mode
|
||||
already on screen if it is still live, otherwise the first live mode.
|
||||
Used by the Vegas coordinator and the vegas-active check, which only
|
||||
need to know whether *any* game is live (and must not spin the cursor).
|
||||
|
||||
advance=True: the rotation pick — returns the live mode *after* the one
|
||||
currently shown, so each dwell advances to the next live game. The
|
||||
currently-displayed mode is the cursor, so this stays correct as games
|
||||
start and end (no separate index to keep in sync).
|
||||
"""
|
||||
live_modes = self._collect_live_modes()
|
||||
if not live_modes:
|
||||
return None
|
||||
if self.current_display_mode in live_modes:
|
||||
if advance:
|
||||
idx = live_modes.index(self.current_display_mode)
|
||||
return live_modes[(idx + 1) % len(live_modes)]
|
||||
return self.current_display_mode
|
||||
return live_modes[0]
|
||||
|
||||
def run(self):
|
||||
"""Run the display controller, switching between displays."""
|
||||
@@ -1483,12 +1644,8 @@ class DisplayController:
|
||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||
width = self.display_manager.width
|
||||
|
||||
# Advance local position at Vegas scroll speed (px/s → px/tick)
|
||||
vegas_speed = (
|
||||
self.config.get('display', {})
|
||||
.get('vegas_scroll', {})
|
||||
.get('scroll_speed', 75)
|
||||
)
|
||||
# Opt #2: use pre-cached scroll speed (constant for the run)
|
||||
vegas_speed = self._scroll_speed
|
||||
local_x = getattr(self, '_follower_local_x', None)
|
||||
if local_x is None:
|
||||
local_x = float(width) # safe start (past pre-roll guard)
|
||||
@@ -1570,18 +1727,12 @@ class DisplayController:
|
||||
# Display failed, clear the status and continue normally
|
||||
wifi_status_data = None
|
||||
|
||||
# Check for live priority content and switch to it immediately
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
live_priority_mode = self._check_live_priority()
|
||||
if live_priority_mode and self.current_display_mode != 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
|
||||
live_priority_mode = self._check_live_priority(advance=True)
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
@@ -1628,7 +1779,8 @@ class DisplayController:
|
||||
|
||||
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
|
||||
if active_mode in self.plugin_modes:
|
||||
@@ -1664,17 +1816,22 @@ class DisplayController:
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
if hasattr(manager_to_display, 'display'):
|
||||
# Check if plugin accepts display_mode parameter
|
||||
import inspect
|
||||
sig = inspect.signature(manager_to_display.display)
|
||||
|
||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||
_cache_key = plugin_id
|
||||
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
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
result = self.plugin_manager.plugin_executor.execute_display(
|
||||
manager_to_display,
|
||||
plugin_id,
|
||||
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
|
||||
if result:
|
||||
@@ -1683,7 +1840,7 @@ class DisplayController:
|
||||
result = False # Failed
|
||||
else:
|
||||
# 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)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=self.force_change)
|
||||
@@ -1820,9 +1977,9 @@ class DisplayController:
|
||||
min_duration = base_duration
|
||||
if dynamic_enabled:
|
||||
# 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)
|
||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
||||
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||
|
||||
# Get caps for validation
|
||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||
@@ -1962,7 +2119,7 @@ class DisplayController:
|
||||
if needs_high_fps:
|
||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||
display_interval = 0.008
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||
active_mode,
|
||||
display_interval,
|
||||
@@ -1972,7 +2129,7 @@ class DisplayController:
|
||||
while True:
|
||||
try:
|
||||
# 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)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2014,7 +2171,7 @@ class DisplayController:
|
||||
else:
|
||||
# Normal FPS for other plugins (1 second)
|
||||
display_interval = 1.0
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||
active_mode,
|
||||
display_interval
|
||||
@@ -2036,7 +2193,7 @@ class DisplayController:
|
||||
|
||||
try:
|
||||
# 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)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2069,6 +2226,23 @@ class DisplayController:
|
||||
loop_completed = True
|
||||
break
|
||||
|
||||
# LOAD-BEARING: if current_display_mode changed mid-loop (on-demand
|
||||
# activation, live priority, etc.), restart the main loop now instead
|
||||
# of falling into the "honour minimum duration" sleep below. That sleep
|
||||
# can run for up to the *previous* mode's full display_duration (default
|
||||
# 30s) and doesn't poll on-demand requests or re-check the mode, so a
|
||||
# freshly-requested mode switch would sit invisible for up to 30s — or
|
||||
# get clobbered by a queued stop request — before ever rendering.
|
||||
#
|
||||
# This guard was added in #298 (live priority interrupting long display
|
||||
# durations) and was accidentally dropped in #330 as collateral damage of
|
||||
# an unrelated time.monotonic() -> time.time() cleanup in the same hunk.
|
||||
# Removing it again will silently reintroduce both issues. _activate_on_demand
|
||||
# already sets force_change=True and clears the display, so the next loop
|
||||
# iteration renders the new mode immediately.
|
||||
if self.current_display_mode != active_mode:
|
||||
continue
|
||||
|
||||
# Ensure we honour minimum duration when not dynamic and loop ended early
|
||||
if (
|
||||
not dynamic_enabled
|
||||
@@ -2333,6 +2507,30 @@ class DisplayController:
|
||||
self.wifi_status_active = False
|
||||
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):
|
||||
"""Clean up resources."""
|
||||
# Shutdown config service if it exists
|
||||
@@ -2347,6 +2545,7 @@ class DisplayController:
|
||||
logger.info("Cleanup complete.")
|
||||
|
||||
def main():
|
||||
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||
controller = DisplayController()
|
||||
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 os
|
||||
import tempfile
|
||||
@@ -18,6 +43,24 @@ logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
|
||||
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
|
||||
_initialized = False
|
||||
|
||||
@@ -33,6 +76,10 @@ class DisplayManager:
|
||||
self._suppress_test_pattern = suppress_test_pattern
|
||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||
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)
|
||||
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
|
||||
@@ -437,6 +484,9 @@ class DisplayManager:
|
||||
|
||||
def _load_fonts(self):
|
||||
"""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:
|
||||
# Load Press Start 2P font
|
||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
@@ -497,22 +547,32 @@ class DisplayManager:
|
||||
|
||||
|
||||
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:
|
||||
if isinstance(font, freetype.Face):
|
||||
# For FreeType faces, calculate width using freetype
|
||||
width = 0
|
||||
for char in text:
|
||||
font.load_char(char)
|
||||
width += font.glyph.advance.x >> 6
|
||||
return width
|
||||
else:
|
||||
# For PIL fonts, use textbbox
|
||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting text width: {e}")
|
||||
return 0 # Return 0 as fallback
|
||||
width = bbox[2] - bbox[0]
|
||||
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||
logger.error("Error getting text width: %s", e)
|
||||
return 0
|
||||
|
||||
self._text_width_cache[cache_key] = width
|
||||
return width
|
||||
|
||||
def get_font_height(self, font):
|
||||
"""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 logging
|
||||
import freetype
|
||||
|
||||
@@ -15,7 +15,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
from src.exceptions import PluginError
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
@@ -81,7 +81,13 @@ class PluginManager:
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
self.plugin_modules: Dict[str, Any] = {}
|
||||
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)
|
||||
self.health_tracker = None
|
||||
self.resource_monitor = None
|
||||
@@ -388,6 +394,8 @@ class PluginManager:
|
||||
# Store plugin instance
|
||||
self.plugins[plugin_id] = plugin_instance
|
||||
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
|
||||
if config.get('enabled', True):
|
||||
@@ -444,8 +452,8 @@ class PluginManager:
|
||||
|
||||
# Remove from active plugins
|
||||
del self.plugins[plugin_id]
|
||||
if plugin_id in self.plugin_last_update:
|
||||
del self.plugin_last_update[plugin_id]
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
|
||||
# Remove main module from sys.modules if present
|
||||
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]:
|
||||
"""
|
||||
Get the update interval for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_instance: Plugin instance
|
||||
|
||||
Returns:
|
||||
Update interval in seconds or None if not configured
|
||||
Get the data-fetch interval for a plugin (seconds between update() calls).
|
||||
|
||||
Result is cached per plugin_id after the first lookup to avoid calling
|
||||
config_manager.get_config() — which returns a full dict copy — on every
|
||||
tick of the 30-fps display loop. The cache is invalidated when a plugin
|
||||
is loaded or unloaded.
|
||||
"""
|
||||
# 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, {})
|
||||
update_interval = manifest.get('update_interval')
|
||||
|
||||
if update_interval:
|
||||
raw = manifest.get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(update_interval)
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Check plugin config
|
||||
if self.config_manager:
|
||||
|
||||
# 2. Plugin config (mutable; only read once and then cached)
|
||||
if interval is None and self.config_manager:
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
plugin_config = config.get(plugin_id, {})
|
||||
update_interval = plugin_config.get('update_interval')
|
||||
if update_interval:
|
||||
raw = config.get(plugin_id, {}).get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(update_interval)
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
except (ConfigError, OSError, ValueError, TypeError) as e:
|
||||
self.logger.debug("Could not get update interval from config: %s", e)
|
||||
|
||||
# Default: 60 seconds
|
||||
return 60.0
|
||||
|
||||
# 3. Default
|
||||
if interval is None:
|
||||
interval = 60.0
|
||||
|
||||
self._update_interval_cache[plugin_id] = interval
|
||||
return interval
|
||||
|
||||
def _record_update_failure(
|
||||
self,
|
||||
|
||||
@@ -322,10 +322,19 @@ class StateReconciliation:
|
||||
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||
and self.store_manager.was_recently_uninstalled(plugin_id)
|
||||
)
|
||||
# Also refuse to resurrect a plugin the user has persistently
|
||||
# uninstalled. Unlike the in-memory race guard above, this record
|
||||
# survives restarts, so the user's removal sticks across updates.
|
||||
persistently_uninstalled = (
|
||||
self.store_manager is not None
|
||||
and hasattr(self.store_manager, 'is_plugin_uninstalled')
|
||||
and self.store_manager.is_plugin_uninstalled(plugin_id)
|
||||
)
|
||||
can_repair = (
|
||||
self.store_manager is not None
|
||||
and not previously_unrecoverable
|
||||
and not recently_uninstalled
|
||||
and not persistently_uninstalled
|
||||
)
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
|
||||
@@ -7,6 +7,7 @@ from both the official registry and custom GitHub repositories.
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
@@ -19,7 +20,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
from typing import List, Dict, Optional, Any, Tuple, Set
|
||||
import logging
|
||||
|
||||
from urllib.parse import urlparse
|
||||
@@ -43,13 +44,24 @@ class PluginStoreManager:
|
||||
"""
|
||||
|
||||
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
|
||||
|
||||
# A valid plugin id is a single path component: starts alphanumeric, then
|
||||
# alphanumerics / dot / dash / underscore. Used to keep the uninstall
|
||||
# registry from ever turning a corrupt or hand-edited entry (e.g. "",
|
||||
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
|
||||
# would delete — an empty id resolves to the plugins root itself.
|
||||
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
def __init__(self, plugins_dir: str = "plugins"):
|
||||
def __init__(self, plugins_dir: str = "plugins",
|
||||
uninstalled_registry_path: Optional[str] = None):
|
||||
"""
|
||||
Initialize the plugin store manager.
|
||||
|
||||
Args:
|
||||
plugins_dir: Directory where plugins are installed
|
||||
uninstalled_registry_path: Path to the JSON file recording plugins
|
||||
the user has uninstalled. Defaults to
|
||||
``config/uninstalled_plugins.json`` under the project root.
|
||||
"""
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
@@ -84,6 +96,25 @@ class PluginStoreManager:
|
||||
self._uninstall_tombstones: Dict[str, float] = {}
|
||||
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
||||
|
||||
# Persistent record of plugins the user has uninstalled. Unlike the
|
||||
# in-memory tombstones above (a short-lived race guard), this survives
|
||||
# restarts so that a core ``git pull`` update cannot resurrect a
|
||||
# built-in plugin the user removed. Built-in plugins (e.g.
|
||||
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
|
||||
# ``plugin-repos/``, so a plain ``git pull`` restores their files even
|
||||
# after the user deleted them. ``purge_uninstalled_plugins`` re-removes
|
||||
# any such resurrected directory; ``install_plugin`` clears the record
|
||||
# when the user deliberately reinstalls. The file is gitignored.
|
||||
if uninstalled_registry_path is not None:
|
||||
self._uninstalled_registry_path = Path(uninstalled_registry_path)
|
||||
else:
|
||||
self._uninstalled_registry_path = (
|
||||
Path(__file__).parent.parent.parent / "config" / "uninstalled_plugins.json"
|
||||
)
|
||||
# Serializes read-modify-write of the registry file so concurrent
|
||||
# install/uninstall requests can't lose updates.
|
||||
self._uninstalled_registry_lock = threading.Lock()
|
||||
|
||||
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
||||
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||
# head_contents) so a fast-forward update to the current branch
|
||||
@@ -143,6 +174,135 @@ class PluginStoreManager:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
|
||||
"""Return True if ``plugin_id`` is a safe single-component plugin id.
|
||||
|
||||
Rejects empty strings, anything with a path separator, and traversal
|
||||
sequences like ``..`` so a registry entry can never escape (or target
|
||||
the root of) ``self.plugins_dir`` during a purge.
|
||||
"""
|
||||
return isinstance(plugin_id, str) and bool(self._PLUGIN_ID_RE.match(plugin_id))
|
||||
|
||||
def _read_uninstalled_registry(self) -> Set[str]:
|
||||
"""Read the persistent set of uninstalled plugin IDs.
|
||||
|
||||
Returns an empty set if the file is missing, unreadable, or corrupt —
|
||||
a broken registry must never block normal plugin operations. Invalid
|
||||
ids are dropped here so callers never turn them into paths.
|
||||
"""
|
||||
try:
|
||||
if not self._uninstalled_registry_path.exists():
|
||||
return set()
|
||||
with open(self._uninstalled_registry_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
self.logger.warning(
|
||||
"Uninstalled-plugin registry at %s is not a list; ignoring it",
|
||||
self._uninstalled_registry_path,
|
||||
)
|
||||
return set()
|
||||
valid: Set[str] = set()
|
||||
for pid in data:
|
||||
if self._is_valid_plugin_id(pid):
|
||||
valid.add(pid)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Ignoring invalid plugin id in uninstall registry: %r", pid
|
||||
)
|
||||
return valid
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(
|
||||
"Could not read uninstalled-plugin registry at %s: %s",
|
||||
self._uninstalled_registry_path, e,
|
||||
)
|
||||
return set()
|
||||
|
||||
def _write_uninstalled_registry(self, plugin_ids: Set[str]) -> None:
|
||||
"""Persist the set of uninstalled plugin IDs (sorted, atomically)."""
|
||||
path = self._uninstalled_registry_path
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(sorted(plugin_ids), f, indent=2)
|
||||
os.replace(tmp_path, path)
|
||||
except OSError as e:
|
||||
self.logger.error(
|
||||
"Failed to write uninstalled-plugin registry at %s: %s", path, e
|
||||
)
|
||||
|
||||
def record_uninstalled_plugin(self, plugin_id: str) -> None:
|
||||
"""Persistently record that the user uninstalled ``plugin_id``.
|
||||
|
||||
Survives restarts so a core update cannot resurrect the plugin.
|
||||
"""
|
||||
if not self._is_valid_plugin_id(plugin_id):
|
||||
self.logger.error("Refusing to record invalid plugin id: %r", plugin_id)
|
||||
return
|
||||
with self._uninstalled_registry_lock:
|
||||
recorded = self._read_uninstalled_registry()
|
||||
if plugin_id not in recorded:
|
||||
recorded.add(plugin_id)
|
||||
self._write_uninstalled_registry(recorded)
|
||||
self.logger.info("Recorded %s as uninstalled (persistent)", plugin_id)
|
||||
|
||||
def forget_uninstalled_plugin(self, *plugin_ids: str) -> None:
|
||||
"""Drop ``plugin_ids`` from the persistent uninstall registry.
|
||||
|
||||
Called when a plugin is deliberately (re)installed so future updates
|
||||
keep it.
|
||||
"""
|
||||
with self._uninstalled_registry_lock:
|
||||
recorded = self._read_uninstalled_registry()
|
||||
to_remove = {pid for pid in plugin_ids if pid in recorded}
|
||||
if to_remove:
|
||||
self._write_uninstalled_registry(recorded - to_remove)
|
||||
self.logger.info(
|
||||
"Cleared uninstall record for %s", ", ".join(sorted(to_remove))
|
||||
)
|
||||
|
||||
def get_uninstalled_plugins(self) -> Set[str]:
|
||||
"""Return the persistent set of user-uninstalled plugin IDs."""
|
||||
return self._read_uninstalled_registry()
|
||||
|
||||
def is_plugin_uninstalled(self, plugin_id: str) -> bool:
|
||||
"""Return True if ``plugin_id`` is in the persistent uninstall registry."""
|
||||
return plugin_id in self._read_uninstalled_registry()
|
||||
|
||||
def purge_uninstalled_plugins(self) -> List[str]:
|
||||
"""Remove on-disk directories for plugins the user has uninstalled.
|
||||
|
||||
Built-in plugins committed into the repo are restored on disk by a
|
||||
core ``git pull``; this re-removes any that the user previously
|
||||
uninstalled. The registry entries are kept so the purge is idempotent
|
||||
across every future update (until the user reinstalls). Returns the
|
||||
list of plugin IDs whose directories were actually removed.
|
||||
"""
|
||||
removed: List[str] = []
|
||||
plugins_root = self.plugins_dir.resolve()
|
||||
for plugin_id in sorted(self._read_uninstalled_registry()):
|
||||
plugin_path = self.plugins_dir / plugin_id
|
||||
# Defense in depth: ids are already validated on read, but never
|
||||
# remove anything that isn't a direct child of the plugins root.
|
||||
resolved = plugin_path.resolve()
|
||||
if resolved == plugins_root or resolved.parent != plugins_root:
|
||||
self.logger.error(
|
||||
"Refusing to purge unsafe plugin path for id %r", plugin_id
|
||||
)
|
||||
continue
|
||||
if not plugin_path.exists():
|
||||
continue
|
||||
self.logger.info(
|
||||
"Purging resurrected uninstalled plugin: %s", plugin_id
|
||||
)
|
||||
if self._safe_remove_directory(plugin_path):
|
||||
removed.append(plugin_id)
|
||||
else:
|
||||
self.logger.error(
|
||||
"Failed to purge resurrected plugin directory: %s", plugin_path
|
||||
)
|
||||
return removed
|
||||
|
||||
def _load_github_token(self) -> Optional[str]:
|
||||
"""
|
||||
Load GitHub API token from config_secrets.json if available.
|
||||
@@ -1024,6 +1184,10 @@ class PluginStoreManager:
|
||||
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
|
||||
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
|
||||
|
||||
# Remember the originally-requested id so we can clear its uninstall
|
||||
# record on success even if the manifest renames the directory below.
|
||||
requested_id = plugin_id
|
||||
|
||||
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if not plugin_info:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
@@ -1162,6 +1326,9 @@ class PluginStoreManager:
|
||||
|
||||
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
|
||||
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
|
||||
# User deliberately (re)installed this plugin — clear any persistent
|
||||
# uninstall record so future core updates keep it.
|
||||
self.forget_uninstalled_plugin(requested_id, plugin_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -7,13 +7,22 @@ Provides base classes and utilities for testing LEDMatrix plugins.
|
||||
from .plugin_test_base import PluginTestCase
|
||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||
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__ = [
|
||||
'PluginTestCase',
|
||||
'VisualTestDisplayManager',
|
||||
'BoundsCheckingDisplayManager',
|
||||
'MockDisplayManager',
|
||||
'MockCacheManager',
|
||||
'MockConfigManager',
|
||||
'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."""
|
||||
|
||||
def __init__(self):
|
||||
import shutil
|
||||
import tempfile
|
||||
import weakref
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_timestamps: Dict[str, float] = {}
|
||||
self.get_calls = []
|
||||
self.set_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]:
|
||||
"""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."""
|
||||
manifest = self.load_plugin_manifest(plugin_id)
|
||||
assert 'display_modes' in manifest
|
||||
assert 'basketball_live' in manifest['display_modes']
|
||||
assert 'basketball_recent' in manifest['display_modes']
|
||||
assert 'basketball_upcoming' in manifest['display_modes']
|
||||
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
|
||||
assert 'nba_live' 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):
|
||||
"""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,151 @@ class TestDisplayControllerLivePriority:
|
||||
assert controller.current_display_mode == "test_plugin_live"
|
||||
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"
|
||||
|
||||
# --- Round-robin between multiple simultaneous live games --------------
|
||||
|
||||
@staticmethod
|
||||
def _live_plugin(live_modes):
|
||||
"""A mock plugin that is live and reports the given live mode names."""
|
||||
p = MagicMock()
|
||||
p.has_live_priority = MagicMock(return_value=True)
|
||||
p.has_live_content = MagicMock(return_value=True)
|
||||
p.get_live_modes = MagicMock(return_value=list(live_modes))
|
||||
return p
|
||||
|
||||
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
|
||||
"""A sports plugin registered under several mode keys (one per league)
|
||||
contributes each live mode once, in registration order; plugins with no
|
||||
live content are skipped."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"baseball_recent": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
"soccer_usa.1_live": soccer,
|
||||
"soccer_recent": soccer,
|
||||
"clock": idle,
|
||||
}
|
||||
assert controller._collect_live_modes() == [
|
||||
"baseball_live", "soccer_fifa.world_live"
|
||||
]
|
||||
|
||||
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
|
||||
"""Regression: with two games live at once, the live-priority pick
|
||||
round-robins each dwell instead of pinning to the first plugin in
|
||||
registration order (the bug where a baseball game hid a live World Cup
|
||||
match)."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
}
|
||||
# First entry into live priority from an ambient mode -> first live game.
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
# The controller switches to it; the next dwell advances to the other.
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
|
||||
# And wraps back again.
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_single_live_game_holds_without_flipping(self, test_display_controller):
|
||||
"""One live game: advancing returns the same mode, so the hold is stable."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
|
||||
"""The default (advance=False) peek used by the Vegas coordinator must
|
||||
not spin the cursor: it returns the live mode already on screen."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": self._live_plugin(["baseball_live"]),
|
||||
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
|
||||
}
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
# From an ambient mode the peek reports the first live game (truthy).
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority() == "baseball_live"
|
||||
|
||||
def test_no_live_content_returns_none(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {"clock": idle}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) is None
|
||||
|
||||
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
|
||||
"""A live plugin whose get_live_modes returns nothing registered falls
|
||||
back to its own '_live' mode name (legacy behavior preserved)."""
|
||||
controller = test_display_controller
|
||||
legacy = MagicMock()
|
||||
legacy.has_live_priority = MagicMock(return_value=True)
|
||||
legacy.has_live_content = MagicMock(return_value=True)
|
||||
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
|
||||
controller.plugin_modes = {"hockey_live": legacy}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "hockey_live"
|
||||
|
||||
|
||||
class TestDisplayControllerDynamicDuration:
|
||||
"""Test dynamic duration handling."""
|
||||
@@ -229,18 +374,20 @@ class TestDisplayControllerSchedule:
|
||||
def test_inactive_hours(self, test_display_controller):
|
||||
"""Test inactive hours check."""
|
||||
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:
|
||||
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.strptime = datetime.strptime
|
||||
|
||||
schedule_config = {
|
||||
"schedule": {
|
||||
"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
|
||||
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"
|
||||
)
|
||||
@@ -43,6 +43,115 @@ class TestUninstallTombstone(unittest.TestCase):
|
||||
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
||||
|
||||
|
||||
class TestPersistentUninstallRegistry(unittest.TestCase):
|
||||
"""Regression tests for the persistent uninstall registry that stops a
|
||||
core `git pull` update from resurrecting built-in plugins the user
|
||||
removed (plugins committed under plugin-repos/)."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.plugins_dir = Path(self._tmp.name) / "plugin-repos"
|
||||
self.plugins_dir.mkdir()
|
||||
self.registry_path = Path(self._tmp.name) / "config" / "uninstalled_plugins.json"
|
||||
self.sm = PluginStoreManager(
|
||||
plugins_dir=str(self.plugins_dir),
|
||||
uninstalled_registry_path=str(self.registry_path),
|
||||
)
|
||||
|
||||
def _make_plugin_dir(self, plugin_id):
|
||||
"""Simulate a built-in plugin restored on disk (e.g. by git pull)."""
|
||||
d = self.plugins_dir / plugin_id
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.json").write_text('{"id": "%s"}' % plugin_id)
|
||||
return d
|
||||
|
||||
def test_unrecorded_plugin_is_not_uninstalled(self):
|
||||
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
|
||||
def test_record_persists_across_instances(self):
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.assertTrue(self.registry_path.exists())
|
||||
# A fresh manager (simulating a service restart after update) still sees it.
|
||||
fresh = PluginStoreManager(
|
||||
plugins_dir=str(self.plugins_dir),
|
||||
uninstalled_registry_path=str(self.registry_path),
|
||||
)
|
||||
self.assertTrue(fresh.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def test_forget_clears_record(self):
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.sm.forget_uninstalled_plugin("web-ui-info")
|
||||
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def test_purge_removes_resurrected_plugin(self):
|
||||
# The bug: user removed web-ui-info, then a git pull restored its
|
||||
# committed files. Recorded uninstall + purge must re-remove it.
|
||||
self._make_plugin_dir("web-ui-info")
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.assertTrue((self.plugins_dir / "web-ui-info").exists())
|
||||
|
||||
removed = self.sm.purge_uninstalled_plugins()
|
||||
|
||||
self.assertEqual(removed, ["web-ui-info"])
|
||||
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
|
||||
# Record is kept so the purge stays idempotent across future updates.
|
||||
self.assertTrue(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def test_purge_leaves_non_uninstalled_plugins_alone(self):
|
||||
self._make_plugin_dir("baseball-scoreboard") # present, not recorded
|
||||
self._make_plugin_dir("web-ui-info")
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
|
||||
self.sm.purge_uninstalled_plugins()
|
||||
|
||||
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
|
||||
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
|
||||
|
||||
def test_purge_noop_when_plugin_absent(self):
|
||||
# Recorded but never restored on disk — nothing to remove.
|
||||
self.sm.record_uninstalled_plugin("web-ui-info")
|
||||
self.assertEqual(self.sm.purge_uninstalled_plugins(), [])
|
||||
|
||||
def test_corrupt_registry_is_ignored(self):
|
||||
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.registry_path.write_text("{ not valid json")
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
|
||||
|
||||
def _write_raw_registry(self, value):
|
||||
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import json as _json
|
||||
self.registry_path.write_text(_json.dumps(value))
|
||||
|
||||
def test_empty_id_does_not_wipe_plugins_root(self):
|
||||
# An empty id resolves to plugins_dir itself; purge must never delete it.
|
||||
self._make_plugin_dir("baseball-scoreboard")
|
||||
self._write_raw_registry([""])
|
||||
|
||||
removed = self.sm.purge_uninstalled_plugins()
|
||||
|
||||
self.assertEqual(removed, [])
|
||||
self.assertTrue(self.plugins_dir.exists())
|
||||
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
|
||||
# Invalid id is filtered out entirely.
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
|
||||
def test_traversal_ids_are_ignored(self):
|
||||
for bad in ["..", "../evil", "a/b", "."]:
|
||||
with self.subTest(bad=bad):
|
||||
self.assertFalse(self.sm._is_valid_plugin_id(bad))
|
||||
self._write_raw_registry(["../evil", "..", "web-ui-info"])
|
||||
# Only the safe id survives the read.
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), {"web-ui-info"})
|
||||
|
||||
def test_record_rejects_invalid_id(self):
|
||||
self.sm.record_uninstalled_plugin("")
|
||||
self.sm.record_uninstalled_plugin("../escape")
|
||||
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
|
||||
|
||||
|
||||
class TestGitInfoCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = TemporaryDirectory()
|
||||
@@ -58,19 +167,15 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
(self.plugin_path / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
|
||||
def _fake_subprocess_run(self, *args, **kwargs):
|
||||
# Return different dummy values depending on which git subcommand
|
||||
# was invoked so the code paths that parse output all succeed.
|
||||
# _get_local_git_info now reads branch and remote_url directly from
|
||||
# .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]
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
if "rev-parse" in cmd and "HEAD" in cmd and "--abbrev-ref" not in cmd:
|
||||
result.stdout = "abcdef1234567890\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"
|
||||
if "log" in cmd:
|
||||
result.stdout = "abcdef1234567890\n2026-04-08T12:00:00+00:00\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
return result
|
||||
@@ -84,7 +189,8 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
self.assertIsNotNone(first)
|
||||
self.assertEqual(first["short_sha"], "abcdef1")
|
||||
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 = self.sm._get_local_git_info(self.plugin_path)
|
||||
@@ -105,7 +211,8 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
os.utime(head, (new_time, new_time))
|
||||
|
||||
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):
|
||||
non_git = self.plugins_dir / "no_git"
|
||||
@@ -192,14 +299,11 @@ class TestGitInfoCache(unittest.TestCase):
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
cmd = args[0]
|
||||
if "rev-parse" in cmd and "--abbrev-ref" not in cmd:
|
||||
result.stdout = branch_file.read_text().strip() + "\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"
|
||||
# Production code now uses a single ``git log --format=%H%n%cI``.
|
||||
# Branch and remote_url are read directly from .git/HEAD/.git/config.
|
||||
if "log" in cmd:
|
||||
sha = branch_file.read_text().strip()
|
||||
result.stdout = f"{sha}\n2026-04-08T12:00:00+00:00\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
return result
|
||||
|
||||
@@ -617,7 +617,8 @@ class TestDottedKeyNormalization:
|
||||
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
||||
}
|
||||
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
|
||||
|
||||
request_data = {
|
||||
@@ -679,7 +680,7 @@ class TestDottedKeyNormalization:
|
||||
'leagues': {'eng.1': {'favorite_teams': []}},
|
||||
}
|
||||
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
|
||||
|
||||
request_data = {
|
||||
|
||||
93
test/web_interface/test_dotted_league_keys.py
Normal file
93
test/web_interface/test_dotted_league_keys.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Regression test for saving plugin config fields whose schema keys contain dots
|
||||
(e.g. soccer league keys like "fifa.world", "eng.1", "usa.1").
|
||||
|
||||
Bug: the web config form posts form-data with dotted paths such as
|
||||
"leagues.fifa.world.enabled". The helpers that resolve those paths split on every
|
||||
dot, so the dotted league key "fifa.world" was mistaken for nested "fifa" ->
|
||||
"world" objects. Per-league edits (enable, favorite_teams, nested booleans) were
|
||||
written to a fabricated "leagues.fifa.world" branch while the real league object
|
||||
was never updated, so the save silently dropped the change and the saved config
|
||||
came out byte-identical.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_get_schema_property,
|
||||
_set_nested_value,
|
||||
_parse_form_value_with_schema,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leagues": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fifa.world": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"favorite_teams": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"display_modes": {
|
||||
"type": "object",
|
||||
"properties": {"live": {"type": "boolean"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestDottedLeagueKeys(unittest.TestCase):
|
||||
def test_schema_lookup_resolves_dotted_league_key(self):
|
||||
prop = _get_schema_property(SCHEMA, "leagues.fifa.world.favorite_teams")
|
||||
self.assertIsNotNone(prop, "dotted league key path should resolve")
|
||||
self.assertEqual(prop.get("type"), "array")
|
||||
|
||||
def test_schema_lookup_resolves_nested_object_beneath_dotted_key(self):
|
||||
live = _get_schema_property(SCHEMA, "leagues.fifa.world.display_modes.live")
|
||||
self.assertIsNotNone(live)
|
||||
self.assertEqual(live.get("type"), "boolean")
|
||||
|
||||
def test_parse_typed_value_for_dotted_key(self):
|
||||
# Comma-separated text input "USA" must become an array, not the raw string.
|
||||
parsed = _parse_form_value_with_schema(
|
||||
"USA", "leagues.fifa.world.favorite_teams", SCHEMA
|
||||
)
|
||||
self.assertEqual(parsed, ["USA"])
|
||||
|
||||
def test_set_value_updates_real_league_not_fabricated_branch(self):
|
||||
config = {"leagues": {"fifa.world": {"enabled": False, "favorite_teams": []}}}
|
||||
_set_nested_value(config, "leagues.fifa.world.enabled", True)
|
||||
_set_nested_value(config, "leagues.fifa.world.favorite_teams", ["USA"])
|
||||
|
||||
self.assertTrue(config["leagues"]["fifa.world"]["enabled"])
|
||||
self.assertEqual(config["leagues"]["fifa.world"]["favorite_teams"], ["USA"])
|
||||
# The real league must be updated and no fabricated "fifa" branch created.
|
||||
self.assertNotIn("fifa", config["leagues"])
|
||||
|
||||
def test_set_value_into_missing_leaf_lands_in_real_league(self):
|
||||
# A leaf that does not exist yet still resolves into the real dotted league.
|
||||
config = {"leagues": {"fifa.world": {"enabled": False}}}
|
||||
_set_nested_value(config, "leagues.fifa.world.display_modes.live", True)
|
||||
self.assertTrue(
|
||||
config["leagues"]["fifa.world"]["display_modes"]["live"]
|
||||
)
|
||||
self.assertNotIn("fifa", config["leagues"])
|
||||
|
||||
def test_plain_nested_paths_still_work(self):
|
||||
config = {}
|
||||
_set_nested_value(config, "customization.text.font", "small")
|
||||
self.assertEqual(config["customization"]["text"]["font"], "small")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -224,20 +224,14 @@ class TestStateReconciliation(unittest.TestCase):
|
||||
with open(manifest_path, 'w') as 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
|
||||
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(saved_configs), 1)
|
||||
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
|
||||
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
|
||||
|
||||
def test_multiple_inconsistencies(self):
|
||||
"""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))
|
||||
)
|
||||
@@ -79,6 +79,21 @@ plugin_manager = PluginManager(
|
||||
cache_manager=None # Not needed for web interface
|
||||
)
|
||||
plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||
# A core `git pull` update (or any checkout) restores built-in plugins
|
||||
# committed under plugin-repos/, even ones the user uninstalled. Re-remove any
|
||||
# the user previously uninstalled at startup so a manual update on the Pi
|
||||
# doesn't resurrect them.
|
||||
try:
|
||||
_purged = plugin_store_manager.purge_uninstalled_plugins()
|
||||
if _purged:
|
||||
logging.getLogger(__name__).info(
|
||||
"Re-removed %d uninstalled plugin(s) restored since last run: %s",
|
||||
len(_purged), ", ".join(_purged),
|
||||
)
|
||||
except (OSError, RuntimeError) as _purge_err:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Startup plugin purge failed: %s", _purge_err
|
||||
)
|
||||
saved_repositories_manager = SavedRepositoriesManager()
|
||||
|
||||
# Initialize schema manager
|
||||
@@ -391,6 +406,22 @@ def captive_portal_redirect():
|
||||
# Redirect to lightweight captive portal setup page (not the full UI)
|
||||
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
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
|
||||
@@ -190,7 +190,7 @@ def _ensure_display_service_running():
|
||||
if status.get('active'):
|
||||
status['started'] = False
|
||||
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()
|
||||
result['started'] = result.get('returncode') == 0
|
||||
result['active'] = service_status.get('active')
|
||||
@@ -199,7 +199,7 @@ def _ensure_display_service_running():
|
||||
|
||||
def _stop_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()
|
||||
result['active'] = status.get('active')
|
||||
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 now, just start the display service
|
||||
try:
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
logger.error("start_display (%s) timed out: %s", mode, e)
|
||||
@@ -1478,16 +1478,16 @@ def execute_system_action():
|
||||
resp['stderr'] = result.stderr.strip()
|
||||
return jsonify(resp)
|
||||
else:
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
elif action == 'reboot_system':
|
||||
result = subprocess.run(['sudo', 'reboot'],
|
||||
@@ -1559,6 +1559,20 @@ def execute_system_action():
|
||||
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
||||
if result.stdout and "Already up to date" not in result.stdout:
|
||||
pull_message = f"Code updated successfully.{stash_info}"
|
||||
# A `git pull` restores built-in plugins (committed under
|
||||
# plugin-repos/) even if the user uninstalled them. Re-remove
|
||||
# any the user previously uninstalled so the update doesn't
|
||||
# resurrect them.
|
||||
if api_v3.plugin_store_manager:
|
||||
try:
|
||||
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
|
||||
if purged:
|
||||
logger.info(
|
||||
"Re-removed %d uninstalled plugin(s) restored by update: %s",
|
||||
len(purged), ", ".join(purged),
|
||||
)
|
||||
except (OSError, RuntimeError) as purge_err:
|
||||
logger.warning("Post-update plugin purge failed: %s", purge_err)
|
||||
else:
|
||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||
pull_message = "Update failed; check logs for details"
|
||||
@@ -1568,11 +1582,11 @@ def execute_system_action():
|
||||
'message': pull_message,
|
||||
})
|
||||
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)
|
||||
elif action == 'restart_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)
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||
@@ -2412,6 +2426,13 @@ def reconcile_plugin_state():
|
||||
|
||||
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(
|
||||
state_manager=api_v3.plugin_state_manager,
|
||||
config_manager=api_v3.config_manager,
|
||||
@@ -2419,7 +2440,7 @@ def reconcile_plugin_state():
|
||||
plugins_dir=Path(api_v3.plugin_manager.plugins_dir)
|
||||
)
|
||||
|
||||
result = reconciler.reconcile_state()
|
||||
result = reconciler.reconcile_state(force=force)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -2846,6 +2867,96 @@ def update_plugin():
|
||||
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)
|
||||
# Persistently record the uninstall so a later core `git pull` update
|
||||
# cannot resurrect a built-in plugin (committed under plugin-repos/) that
|
||||
# the user removed. Best-effort: never fail the uninstall over this.
|
||||
try:
|
||||
api_v3.plugin_store_manager.record_uninstalled_plugin(plugin_id)
|
||||
except Exception as record_err:
|
||||
logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err)
|
||||
return True, None
|
||||
|
||||
|
||||
@api_v3.route('/plugins/uninstall', methods=['POST'])
|
||||
def uninstall_plugin():
|
||||
"""Uninstall plugin"""
|
||||
@@ -2865,19 +2976,13 @@ def uninstall_plugin():
|
||||
plugin_id = data['plugin_id']
|
||||
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:
|
||||
def uninstall_callback(operation):
|
||||
"""Callback to execute plugin uninstallation."""
|
||||
# Unload the plugin first if it's loaded
|
||||
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)
|
||||
|
||||
"""Callback to execute plugin uninstallation via transactional helper."""
|
||||
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||
if not success:
|
||||
error_msg = f'Failed to uninstall plugin {plugin_id}'
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
@@ -2885,24 +2990,7 @@ def uninstall_plugin():
|
||||
status="failed",
|
||||
error=error_msg
|
||||
)
|
||||
raise Exception(error_msg)
|
||||
|
||||
# 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
|
||||
raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}')
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
@@ -2910,7 +2998,6 @@ def uninstall_plugin():
|
||||
status="success",
|
||||
details={"preserve_config": preserve_config}
|
||||
)
|
||||
|
||||
return {'success': True, 'message': 'Plugin uninstalled successfully'}
|
||||
|
||||
# Enqueue operation
|
||||
@@ -2925,31 +3012,10 @@ def uninstall_plugin():
|
||||
message='Plugin uninstallation queued'
|
||||
)
|
||||
else:
|
||||
# Fallback to direct uninstall
|
||||
# Unload the plugin first if it's loaded
|
||||
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)
|
||||
# Direct (non-queued) transactional uninstall
|
||||
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
|
||||
|
||||
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:
|
||||
api_v3.operation_history.record_operation(
|
||||
"uninstall",
|
||||
@@ -2957,7 +3023,6 @@ def uninstall_plugin():
|
||||
status="success",
|
||||
details={"preserve_config": preserve_config}
|
||||
)
|
||||
|
||||
return success_response(message='Plugin uninstalled successfully')
|
||||
else:
|
||||
if api_v3.operation_history:
|
||||
@@ -2965,12 +3030,11 @@ def uninstall_plugin():
|
||||
"uninstall",
|
||||
plugin_id=plugin_id,
|
||||
status="failed",
|
||||
error='Plugin uninstall failed'
|
||||
error=error_msg
|
||||
)
|
||||
|
||||
return error_response(
|
||||
ErrorCode.PLUGIN_UNINSTALL_FAILED,
|
||||
'Plugin uninstall failed',
|
||||
error_msg or 'Plugin uninstall failed',
|
||||
status_code=500
|
||||
)
|
||||
|
||||
@@ -3494,21 +3558,29 @@ def _get_schema_property(schema, key_path):
|
||||
|
||||
parts = key_path.split('.')
|
||||
current = schema['properties']
|
||||
i = 0
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if part not in current:
|
||||
return None
|
||||
|
||||
prop = current[part]
|
||||
|
||||
# If this is the last part, return the property
|
||||
if i == len(parts) - 1:
|
||||
return prop
|
||||
|
||||
# If this is an object with properties, navigate deeper
|
||||
if isinstance(prop, dict) and 'properties' in prop:
|
||||
current = prop['properties']
|
||||
else:
|
||||
while i < len(parts):
|
||||
# Try progressively longer candidates, longest first, so schema keys that
|
||||
# themselves contain dots (e.g. league keys like "fifa.world") are matched
|
||||
# instead of being mistaken for nested "fifa" -> "world" objects.
|
||||
matched = False
|
||||
for j in range(len(parts), i, -1):
|
||||
candidate = '.'.join(parts[i:j])
|
||||
if isinstance(current, dict) and candidate in current:
|
||||
prop = current[candidate]
|
||||
# Consumed all remaining parts — this is the target property.
|
||||
if j == len(parts):
|
||||
return prop
|
||||
# Navigate deeper through an object with properties.
|
||||
if isinstance(prop, dict) and 'properties' in prop:
|
||||
current = prop['properties']
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
# Matched a non-object before consuming the path — can't go deeper.
|
||||
return None
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
return None
|
||||
@@ -3666,13 +3738,14 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
except ValueError:
|
||||
return prop.get('default', 0.0)
|
||||
|
||||
# Try parsing as number (fallback)
|
||||
try:
|
||||
if '.' in stripped:
|
||||
return float(stripped)
|
||||
return int(stripped)
|
||||
except ValueError:
|
||||
pass
|
||||
# Try parsing as number (fallback) — skip when schema explicitly says string
|
||||
if not (prop and prop.get('type') == 'string'):
|
||||
try:
|
||||
if '.' in stripped:
|
||||
return float(stripped)
|
||||
return int(stripped)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return as string
|
||||
return value
|
||||
@@ -3680,10 +3753,45 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_key_segments(key_path, config):
|
||||
"""Split a dot-notation path into segments, greedily preserving keys that
|
||||
themselves contain dots (e.g. league keys like "fifa.world").
|
||||
|
||||
At each level the longest candidate that matches a key already present in the
|
||||
config wins; otherwise the path splits on the next dot (the normal
|
||||
nested-create case). Because dotted keys such as ``leagues."fifa.world"``
|
||||
always exist in the saved config being updated, this routes the value to the
|
||||
real league object instead of fabricating a ``leagues.fifa.world`` tree.
|
||||
"""
|
||||
parts = key_path.split('.')
|
||||
segments = []
|
||||
node = config
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
matched = False
|
||||
if isinstance(node, dict):
|
||||
for j in range(len(parts), i, -1):
|
||||
candidate = '.'.join(parts[i:j])
|
||||
if candidate in node:
|
||||
segments.append(candidate)
|
||||
node = node[candidate]
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
part = parts[i]
|
||||
segments.append(part)
|
||||
node = node.get(part) if isinstance(node, dict) else None
|
||||
i += 1
|
||||
return segments
|
||||
|
||||
|
||||
def _set_nested_value(config, key_path, value):
|
||||
"""
|
||||
Set a value in a nested dict using dot notation path.
|
||||
Handles existing nested dicts correctly by merging instead of replacing.
|
||||
Keys containing dots (e.g. league keys like "fifa.world") are preserved when
|
||||
they already exist in the config rather than being split into nested objects.
|
||||
|
||||
Args:
|
||||
config: The config dict to modify
|
||||
@@ -3693,22 +3801,22 @@ def _set_nested_value(config, key_path, value):
|
||||
# Skip setting if value is the sentinel
|
||||
if value is _SKIP_FIELD:
|
||||
return
|
||||
|
||||
parts = key_path.split('.')
|
||||
|
||||
segments = _resolve_key_segments(key_path, config)
|
||||
current = config
|
||||
|
||||
# Navigate/create intermediate dicts
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
elif not isinstance(current[part], dict):
|
||||
for seg in segments[:-1]:
|
||||
if seg not in current:
|
||||
current[seg] = {}
|
||||
elif not isinstance(current[seg], dict):
|
||||
# If the existing value is not a dict, replace it with a dict
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[seg] = {}
|
||||
current = current[seg]
|
||||
|
||||
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
||||
if value is not None or parts[-1] not in current:
|
||||
current[parts[-1]] = value
|
||||
if value is not None or segments[-1] not in current:
|
||||
current[segments[-1]] = value
|
||||
|
||||
|
||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||
@@ -4217,7 +4325,9 @@ def save_plugin_config():
|
||||
nested_dict = config_dict.get(prop_key)
|
||||
|
||||
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
|
||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
||||
@@ -4277,7 +4387,8 @@ def save_plugin_config():
|
||||
nested_dict = config_dict[prop_key]
|
||||
|
||||
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:
|
||||
# First, fix any dict structures that should be arrays
|
||||
@@ -4377,6 +4488,21 @@ def save_plugin_config():
|
||||
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
||||
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
|
||||
# Defaults should not overwrite an explicitly preserved enabled value
|
||||
if preserved_enabled is not None:
|
||||
|
||||
@@ -3,8 +3,13 @@ from markupsafe import escape
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Strict allowlists for URL-derived values used in path and script operations.
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -110,23 +115,61 @@ def serve_plugin_web_ui(plugin_id, filename):
|
||||
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
||||
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
||||
"""
|
||||
# Validate URL-derived values against strict allowlists before any path or
|
||||
# script operations.
|
||||
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
||||
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
||||
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
# os.path.basename() is the CodeQL-recognised path sanitizer used throughout
|
||||
# this codebase (see plugin_loader.py). Applying it here breaks the taint
|
||||
# chain even though the allowlist above already prevents path separators.
|
||||
safe_id = os.path.basename(plugin_id)
|
||||
safe_fn = os.path.basename(filename)
|
||||
if not safe_id or not safe_fn:
|
||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
if not pages_v3.plugin_manager:
|
||||
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
|
||||
|
||||
try:
|
||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
||||
# Path traversal guard — plugin_dir must be inside plugins base
|
||||
_plugin_dir.relative_to(_plugins_base)
|
||||
|
||||
web_ui_path = (_plugin_dir / 'web_ui' / filename).resolve()
|
||||
# Second guard — web_ui_path must stay inside web_ui/
|
||||
web_ui_path.relative_to(_plugin_dir / 'web_ui')
|
||||
# Reconstruct from sanitised basename — CodeQL-approved pattern.
|
||||
_plugin_dir = (_plugins_base / safe_id).resolve()
|
||||
_plugin_dir.relative_to(_plugins_base) # containment guard
|
||||
|
||||
# Mirror PluginManager's ledmatrix- prefix fallback.
|
||||
if not _plugin_dir.exists():
|
||||
_alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
||||
_alt = (_plugins_base / _alt_id).resolve()
|
||||
try:
|
||||
_alt.relative_to(_plugins_base)
|
||||
_plugin_dir = _alt
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve()
|
||||
web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard
|
||||
|
||||
if not web_ui_path.exists():
|
||||
return f'web_ui file not found: {filename}', 404
|
||||
if web_ui_path.suffix.lower() != '.html':
|
||||
return 'Only .html files may be served here', 403
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
fragment = web_ui_path.read_text(encoding='utf-8')
|
||||
|
||||
# json.dumps wraps the value in quotes. Replace HTML meta-chars with
|
||||
# their JS Unicode escape sequences so the value cannot close or escape
|
||||
# the enclosing <script> tag.
|
||||
# r'<' is the 6-char literal string <, which JavaScript
|
||||
# interprets as <. This is the standard JSON-in-HTML hardening pattern.
|
||||
safe_plugin_id_js = (
|
||||
json.dumps(safe_id)
|
||||
.replace('<', '\\u003c')
|
||||
.replace('>', '\\u003e')
|
||||
.replace('&', '\\u0026')
|
||||
)
|
||||
|
||||
page = (
|
||||
'<!DOCTYPE html>\n'
|
||||
'<html lang="en">\n'
|
||||
@@ -134,8 +177,10 @@ def serve_plugin_web_ui(plugin_id, filename):
|
||||
'<meta charset="UTF-8">\n'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
||||
'<script>\n'
|
||||
# Inject plugin context before the fragment runs
|
||||
f' window.PLUGIN_ID = {json.dumps(plugin_id)};\n'
|
||||
# Inject plugin context before the fragment runs.
|
||||
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
|
||||
# but we also Unicode-escape HTML meta-chars as defence in depth.
|
||||
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
|
||||
'</script>\n'
|
||||
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
||||
'<link rel="stylesheet" '
|
||||
@@ -150,10 +195,10 @@ def serve_plugin_web_ui(plugin_id, filename):
|
||||
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
except ValueError:
|
||||
return 'Forbidden', 403
|
||||
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||
except Exception:
|
||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||
return 'Error serving file', 500
|
||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||
|
||||
def _load_overview_partial():
|
||||
"""Load overview partial with system stats"""
|
||||
|
||||
@@ -111,20 +111,40 @@
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
// Keys that must never be assigned to prevent prototype pollution.
|
||||
const _FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
|
||||
function setNestedValue(obj, path, value) {
|
||||
const parts = path.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
|
||||
cur[parts[i]] = {};
|
||||
const key = parts[i];
|
||||
if (_FORBIDDEN_KEYS.has(key)) return;
|
||||
// Use hasOwnProperty to avoid reading inherited prototype properties,
|
||||
// and defineProperty to write without triggering prototype setters.
|
||||
if (!Object.hasOwn(cur, key) ||
|
||||
typeof Object.getOwnPropertyDescriptor(cur, key).value !== 'object') {
|
||||
Object.defineProperty(cur, key, {
|
||||
value: Object.create(null), writable: true,
|
||||
enumerable: true, configurable: true
|
||||
});
|
||||
}
|
||||
cur = cur[parts[i]];
|
||||
cur = Object.getOwnPropertyDescriptor(cur, key).value;
|
||||
}
|
||||
const lastKey = parts[parts.length - 1];
|
||||
if (!_FORBIDDEN_KEYS.has(lastKey)) {
|
||||
Object.defineProperty(cur, lastKey, {
|
||||
value: value, writable: true, enumerable: true, configurable: true
|
||||
});
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function getNestedValue(obj, path) {
|
||||
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
|
||||
}
|
||||
|
||||
function coerceValue(strVal, typeHint) {
|
||||
@@ -399,11 +419,7 @@
|
||||
const advancedCell = row.querySelector('.array-table-advanced-data');
|
||||
if (!advancedCell) return;
|
||||
|
||||
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
||||
const tbody = row.closest('tbody');
|
||||
const fieldId = tbody ? tbody.id.replace('_tbody', '') : '';
|
||||
const rowIndex = parseInt(row.dataset.index, 10);
|
||||
|
||||
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
||||
// Close any existing modal
|
||||
const existing = document.getElementById('array-row-editor-modal');
|
||||
if (existing) existing.remove();
|
||||
@@ -419,12 +435,12 @@
|
||||
dialog.className = 'bg-white rounded-lg shadow-xl max-w-lg w-full max-h-screen overflow-y-auto';
|
||||
|
||||
// Header
|
||||
dialog.innerHTML = `
|
||||
safeSetHTML(dialog, `
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>`;
|
||||
</div>`);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'px-5 py-4 space-y-4';
|
||||
@@ -441,7 +457,10 @@
|
||||
// Section for nested object
|
||||
const section = document.createElement('div');
|
||||
section.className = 'border border-gray-200 rounded-lg p-3';
|
||||
section.innerHTML = `<h4 class="text-sm font-medium text-gray-700 mb-3">${escapeHtml(label)}</h4>`;
|
||||
const _secH4 = document.createElement('h4');
|
||||
_secH4.className = 'text-sm font-medium text-gray-700 mb-3';
|
||||
_secH4.textContent = label;
|
||||
section.appendChild(_secH4);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'grid grid-cols-2 gap-3';
|
||||
@@ -457,7 +476,11 @@
|
||||
const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : '');
|
||||
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.innerHTML = `<label class="block text-xs font-medium text-gray-600 mb-1" title="${escapeHtml(subDesc)}">${escapeHtml(subLabel)}</label>`;
|
||||
const _subLbl = document.createElement('label');
|
||||
_subLbl.className = 'block text-xs font-medium text-gray-600 mb-1';
|
||||
_subLbl.title = subDesc;
|
||||
_subLbl.textContent = subLabel;
|
||||
fieldDiv.appendChild(_subLbl);
|
||||
fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal));
|
||||
grid.appendChild(fieldDiv);
|
||||
});
|
||||
@@ -470,7 +493,11 @@
|
||||
const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : '');
|
||||
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.innerHTML = `<label class="block text-sm font-medium text-gray-700 mb-1" title="${escapeHtml(desc)}">${escapeHtml(label)}</label>`;
|
||||
const _flatLbl = document.createElement('label');
|
||||
_flatLbl.className = 'block text-sm font-medium text-gray-700 mb-1';
|
||||
_flatLbl.title = desc;
|
||||
_flatLbl.textContent = label;
|
||||
fieldDiv.appendChild(_flatLbl);
|
||||
fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal));
|
||||
body.appendChild(fieldDiv);
|
||||
}
|
||||
@@ -481,11 +508,11 @@
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'flex justify-end gap-3 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg';
|
||||
footer.innerHTML = `
|
||||
safeSetHTML(footer, `
|
||||
<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>
|
||||
<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
|
||||
footer.querySelector('#array-row-editor-save').onclick = function() {
|
||||
@@ -664,11 +691,6 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str || '');
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ─── In-cell image upload ────────────────────────────────────────────────
|
||||
|
||||
@@ -739,9 +761,9 @@
|
||||
let displayColumns = [];
|
||||
let fullItemProperties = {};
|
||||
|
||||
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(e) {}
|
||||
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(e) {}
|
||||
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(e) { fullItemProperties = itemProperties; }
|
||||
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(_e) {}
|
||||
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(_e) {}
|
||||
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(_e) { fullItemProperties = itemProperties; }
|
||||
|
||||
const tbody = document.getElementById(fieldId + '_tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
@@ -62,6 +62,14 @@
|
||||
return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path);
|
||||
}
|
||||
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
window.LEDMatrixWidgets.register('file-upload-single', {
|
||||
name: 'File Upload Single Widget',
|
||||
version: '1.0.0',
|
||||
@@ -90,7 +98,7 @@
|
||||
</div>`;
|
||||
html += `<div class="flex-1 min-w-0">
|
||||
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
|
||||
<p class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
|
||||
<p id="${fieldId}_fullpath" class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
|
||||
</div>`;
|
||||
html += `<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
||||
@@ -99,12 +107,15 @@
|
||||
</button>`;
|
||||
html += '</div>';
|
||||
|
||||
// Upload drop zone (always shown, acts as change button when value is set)
|
||||
// Upload drop zone — keyboard accessible via tabindex + Enter/Space
|
||||
html += `<div id="${fieldId}_drop_zone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
role="button" tabindex="0"
|
||||
aria-label="${hasImage ? 'Replace image' : 'Upload image'}"
|
||||
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
|
||||
ondragover="event.preventDefault()"
|
||||
onclick="document.getElementById('${fieldId}_file_input').click()">
|
||||
onclick="document.getElementById('${fieldId}_file_input').click()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('${fieldId}_file_input').click();}">
|
||||
<input type="file"
|
||||
id="${fieldId}_file_input"
|
||||
accept="${escapeHtml(allowedTypes)}"
|
||||
@@ -123,7 +134,7 @@
|
||||
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
safeSetHTML(container, html);
|
||||
},
|
||||
|
||||
getValue: function(fieldId) {
|
||||
@@ -151,6 +162,8 @@
|
||||
if (thumbPlaceholder) thumbPlaceholder.style.display = 'none';
|
||||
}
|
||||
if (filename) filename.textContent = hasImage ? value.split('/').pop() : '';
|
||||
const fullpath = document.getElementById(`${safeId}_fullpath`);
|
||||
if (fullpath) fullpath.textContent = value || '';
|
||||
|
||||
// Update drop zone hint text
|
||||
const hint = dropZone ? dropZone.querySelector('p') : null;
|
||||
@@ -211,10 +224,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Show uploading status
|
||||
// Show uploading status — use DOM methods to avoid innerHTML with dynamic data
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-1 text-xs text-gray-500';
|
||||
statusDiv.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Uploading...';
|
||||
statusDiv.textContent = '';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.className = 'fas fa-spinner fa-spin mr-1';
|
||||
statusDiv.appendChild(spinner);
|
||||
statusDiv.appendChild(document.createTextNode('Uploading…'));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -242,8 +259,12 @@
|
||||
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-1 text-xs text-green-600';
|
||||
statusDiv.innerHTML = '<i class="fas fa-check-circle mr-1"></i>Uploaded successfully';
|
||||
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.innerHTML = ''; }, 3000);
|
||||
statusDiv.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-check-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
|
||||
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
|
||||
}
|
||||
notifyFn('Image uploaded successfully', 'success');
|
||||
} else {
|
||||
@@ -252,7 +273,11 @@
|
||||
} catch (error) {
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-1 text-xs text-red-600';
|
||||
statusDiv.innerHTML = `<i class="fas fa-exclamation-circle mr-1"></i>${escapeHtml(error.message)}`;
|
||||
statusDiv.textContent = '';
|
||||
const errIcon = document.createElement('i');
|
||||
errIcon.className = 'fas fa-exclamation-circle mr-1';
|
||||
statusDiv.appendChild(errIcon);
|
||||
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
|
||||
}
|
||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
|
||||
@@ -162,6 +162,21 @@
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ─── Safe HTML helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse html in a sandboxed DOMParser document (scripts never execute) and
|
||||
* replace target's children with the result. All dynamic values in html
|
||||
* must be escaped by the caller before passing here.
|
||||
*/
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
// ─── Per-instance state ───────────────────────────────────────────────────
|
||||
|
||||
const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal }
|
||||
@@ -214,11 +229,11 @@
|
||||
const root = document.getElementById(`${fieldId}_pfm`);
|
||||
if (!root) return;
|
||||
const grid = root.querySelector('.pfm-grid');
|
||||
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>';
|
||||
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>');
|
||||
|
||||
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
|
||||
if (!data || data.status !== 'success') {
|
||||
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>';
|
||||
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>');
|
||||
return;
|
||||
}
|
||||
st.files = data.files || [];
|
||||
@@ -235,41 +250,114 @@
|
||||
if (!grid) return;
|
||||
|
||||
if (!st.files.length) {
|
||||
grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>';
|
||||
safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = st.files.map(f => `
|
||||
<div class="pfm-card${f.enabled === false ? ' disabled' : ''}" data-filename="${escHtml(f.filename)}" data-category="${escHtml(f.category_name)}">
|
||||
<div class="pfm-card-top">
|
||||
<span class="pfm-toggle-label">${f.enabled !== false ? 'Enabled' : 'Disabled'}</span>
|
||||
${st.actions.toggle ? `
|
||||
<label class="pfm-toggle-cb" title="${f.enabled !== false ? 'Click to disable' : 'Click to enable'}">
|
||||
<input type="checkbox" ${f.enabled !== false ? 'checked' : ''}
|
||||
onchange="window._pfmToggle('${fieldId}','${escHtml(f.category_name)}',this.checked)">
|
||||
<span class="pfm-toggle-slider"></span>
|
||||
</label>` : ''}
|
||||
</div>
|
||||
<div class="pfm-card-icon"><i class="fas fa-file-code"></i></div>
|
||||
<div class="pfm-card-name">${escHtml(f.display_name || f.filename)}</div>
|
||||
<div class="pfm-card-meta">
|
||||
${escHtml(f.filename)}<br>
|
||||
${f.entry_count != null ? escHtml(f.entry_count) + ' entries' : ''} • ${formatSize(f.size)}<br>
|
||||
${formatDate(f.modified)}
|
||||
</div>
|
||||
<div class="pfm-card-actions">
|
||||
${st.actions.get && st.actions.save ? `
|
||||
<button class="pfm-btn pfm-btn-primary"
|
||||
onclick="window._pfmOpenEdit('${fieldId}','${escHtml(f.filename)}')">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</button>` : ''}
|
||||
${st.actions.delete ? `
|
||||
<button class="pfm-btn pfm-btn-danger pfm-btn-sm"
|
||||
onclick="window._pfmOpenDelete('${fieldId}','${escHtml(f.filename)}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
// Remove any existing delegated listener before re-render
|
||||
if (st._gridClickHandler) grid.removeEventListener('click', st._gridClickHandler);
|
||||
if (st._gridChangeHandler) grid.removeEventListener('change', st._gridChangeHandler);
|
||||
|
||||
// Event delegation: handles edit/delete/toggle via data attributes so
|
||||
// filenames and category names are never interpolated into JS string literals.
|
||||
st._gridClickHandler = function(e) {
|
||||
const btn = e.target.closest('[data-pfm-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.pfmAction;
|
||||
const fId = btn.dataset.pfmField;
|
||||
if (action === 'edit') window._pfmOpenEdit(fId, btn.dataset.pfmFile);
|
||||
if (action === 'delete') window._pfmOpenDelete(fId, btn.dataset.pfmFile);
|
||||
};
|
||||
st._gridChangeHandler = function(e) {
|
||||
const inp = e.target.closest('[data-pfm-action="toggle"]');
|
||||
if (!inp) return;
|
||||
window._pfmToggle(inp.dataset.pfmField, inp.dataset.pfmCategory, inp.checked);
|
||||
};
|
||||
grid.addEventListener('click', st._gridClickHandler);
|
||||
grid.addEventListener('change', st._gridChangeHandler);
|
||||
|
||||
// Build cards with DOM methods so no user-derived data flows through innerHTML.
|
||||
grid.textContent = '';
|
||||
const frag = document.createDocumentFragment();
|
||||
st.files.forEach(function(f) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'pfm-card' + (f.enabled === false ? ' disabled' : '');
|
||||
card.dataset.filename = f.filename;
|
||||
card.dataset.category = f.category_name;
|
||||
|
||||
// Top row: label + optional toggle
|
||||
const top = document.createElement('div');
|
||||
top.className = 'pfm-card-top';
|
||||
const lbl = document.createElement('span');
|
||||
lbl.className = 'pfm-toggle-label';
|
||||
lbl.textContent = f.enabled !== false ? 'Enabled' : 'Disabled';
|
||||
top.appendChild(lbl);
|
||||
if (st.actions.toggle) {
|
||||
const tglLabel = document.createElement('label');
|
||||
tglLabel.className = 'pfm-toggle-cb';
|
||||
tglLabel.title = f.enabled !== false ? 'Click to disable' : 'Click to enable';
|
||||
const tglInput = document.createElement('input');
|
||||
tglInput.type = 'checkbox';
|
||||
tglInput.checked = f.enabled !== false;
|
||||
tglInput.dataset.pfmAction = 'toggle';
|
||||
tglInput.dataset.pfmField = fieldId;
|
||||
tglInput.dataset.pfmCategory = f.category_name;
|
||||
const tglSlider = document.createElement('span');
|
||||
tglSlider.className = 'pfm-toggle-slider';
|
||||
tglLabel.appendChild(tglInput);
|
||||
tglLabel.appendChild(tglSlider);
|
||||
top.appendChild(tglLabel);
|
||||
}
|
||||
card.appendChild(top);
|
||||
|
||||
// Icon (static markup)
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'pfm-card-icon';
|
||||
icon.innerHTML = '<i class="fas fa-file-code"></i>';
|
||||
card.appendChild(icon);
|
||||
|
||||
// Name & meta — textContent avoids any HTML injection
|
||||
const name = document.createElement('div');
|
||||
name.className = 'pfm-card-name';
|
||||
name.textContent = f.display_name || f.filename;
|
||||
card.appendChild(name);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'pfm-card-meta';
|
||||
meta.appendChild(document.createTextNode(f.filename));
|
||||
meta.appendChild(document.createElement('br'));
|
||||
if (f.entry_count != null) {
|
||||
meta.appendChild(document.createTextNode(f.entry_count + ' entries · ' + formatSize(f.size)));
|
||||
}
|
||||
meta.appendChild(document.createElement('br'));
|
||||
meta.appendChild(document.createTextNode(formatDate(f.modified)));
|
||||
card.appendChild(meta);
|
||||
|
||||
// Action buttons
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'pfm-card-actions';
|
||||
if (st.actions.get && st.actions.save) {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'pfm-btn pfm-btn-primary';
|
||||
editBtn.dataset.pfmAction = 'edit';
|
||||
editBtn.dataset.pfmField = fieldId;
|
||||
editBtn.dataset.pfmFile = f.filename;
|
||||
editBtn.innerHTML = '<i class="fas fa-edit"></i> Edit'; // static
|
||||
actions.appendChild(editBtn);
|
||||
}
|
||||
if (st.actions.delete) {
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'pfm-btn pfm-btn-danger pfm-btn-sm';
|
||||
delBtn.dataset.pfmAction = 'delete';
|
||||
delBtn.dataset.pfmField = fieldId;
|
||||
delBtn.dataset.pfmFile = f.filename;
|
||||
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
frag.appendChild(card);
|
||||
});
|
||||
grid.appendChild(frag);
|
||||
}
|
||||
|
||||
// ─── Edit modal ───────────────────────────────────────────────────────────
|
||||
@@ -277,48 +365,53 @@
|
||||
window._pfmOpenEdit = async function (fieldId, filename) {
|
||||
const st = getState(fieldId);
|
||||
const overlay = createOverlay(fieldId);
|
||||
overlay.innerHTML = `
|
||||
<div class="pfm-modal">
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
||||
onclick="window._pfmCloseModal('${fieldId}')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body" id="${fieldId}_edit_body">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary"
|
||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-primary" id="${fieldId}_save_btn"
|
||||
onclick="window._pfmSave('${fieldId}','${escHtml(filename)}')">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
// Build modal using DOM methods so filename never enters a JS string literal.
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||
|
||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
||||
if (!data || data.status !== 'success' || !body) {
|
||||
if (body) body.innerHTML = '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>';
|
||||
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.content || data.data || {};
|
||||
st._editData = content;
|
||||
st._editFilename = filename;
|
||||
|
||||
if (isTabular(content)) {
|
||||
// Table path: track cell edits live in _editData
|
||||
st._editData = content;
|
||||
renderEntryTable(fieldId, body, content);
|
||||
} else {
|
||||
// Fallback: JSON textarea
|
||||
body.innerHTML = `
|
||||
<textarea id="${fieldId}_json_ta" rows="20"
|
||||
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||
st._editData = null;
|
||||
safeSetHTML(body, `
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||
<div id="${fieldId}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,19 +428,20 @@
|
||||
function renderEntryTable(fieldId, container, content) {
|
||||
const st = getState(fieldId);
|
||||
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
||||
if (!entries.length) { container.innerHTML = '<div class="pfm-empty">No entries.</div>'; return; }
|
||||
if (!entries.length) { container.textContent = 'No entries.'; return; }
|
||||
|
||||
const cols = Object.keys(entries[0][1]);
|
||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
|
||||
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
|
||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
|
||||
const total = entries.length;
|
||||
const perPage = st.entriesPerPage;
|
||||
|
||||
function buildPage(page) {
|
||||
const start = (page - 1) * perPage;
|
||||
const start = (page - 1) * perPage; // eslint-disable-line no-magic-numbers
|
||||
const pageEntries = entries.slice(start, start + perPage);
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
container.innerHTML = `
|
||||
safeSetHTML(container, `
|
||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
||||
${total} entries total
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
||||
@@ -401,14 +495,23 @@
|
||||
st._tableCols = cols;
|
||||
}
|
||||
|
||||
// Store buildPage in per-instance state so multiple instances don't
|
||||
// clobber each other's pagination via a shared global.
|
||||
st._buildPage = buildPage;
|
||||
buildPage(st._tablePage || 1);
|
||||
window._pfmTablePage = function (fId, p) {
|
||||
const s = getState(fId);
|
||||
const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage);
|
||||
buildPage(Math.max(1, Math.min(p, totalP)));
|
||||
};
|
||||
}
|
||||
|
||||
// Global dispatcher — resolves the per-instance buildPage from state so
|
||||
// multiple plugin-file-manager instances don't clobber each other.
|
||||
window._pfmTablePage = function (fId, p) {
|
||||
const s = getState(fId);
|
||||
if (s._buildPage) {
|
||||
const total = s._tableEntries ? s._tableEntries.length : 0;
|
||||
const totalP = Math.ceil(total / s.entriesPerPage) || 1;
|
||||
s._buildPage(Math.max(1, Math.min(p, totalP)));
|
||||
}
|
||||
};
|
||||
|
||||
window._pfmCellEdit = function (fieldId, day, col, value) {
|
||||
const st = getState(fieldId);
|
||||
if (st._editData && st._editData[day]) st._editData[day][col] = value;
|
||||
@@ -433,13 +536,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving…'; }
|
||||
if (saveBtn) { saveBtn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Saving…'));})(saveBtn); }
|
||||
|
||||
const result = await callAction(st.pluginId, st.actions.save, {
|
||||
filename, content: JSON.stringify(content)
|
||||
}).catch(() => ({ status: 'error', message: 'Network error' }));
|
||||
|
||||
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="fas fa-save mr-1"></i>Save'; }
|
||||
if (saveBtn) { saveBtn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-save mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Save'));})(saveBtn); }
|
||||
|
||||
if (result.status === 'success') {
|
||||
notify('File saved successfully', 'success');
|
||||
@@ -454,30 +557,32 @@
|
||||
|
||||
window._pfmOpenDelete = function (fieldId, filename) {
|
||||
const overlay = createOverlay(fieldId);
|
||||
overlay.innerHTML = `
|
||||
<div class="pfm-modal" style="max-width:28rem;">
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
||||
onclick="window._pfmCloseModal('${fieldId}')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div class="pfm-danger-box">
|
||||
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
|
||||
from the plugin configuration. This cannot be undone.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary"
|
||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-danger"
|
||||
onclick="window._pfmConfirmDelete('${fieldId}','${escHtml(filename)}')">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
</button>
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
modal.style.maxWidth = '28rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div class="pfm-danger-box">
|
||||
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
|
||||
from the plugin configuration. This cannot be undone.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||
};
|
||||
|
||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
||||
@@ -499,35 +604,38 @@
|
||||
const st = getState(fieldId);
|
||||
const fields = st.createFields;
|
||||
const overlay = createOverlay(fieldId);
|
||||
overlay.innerHTML = `
|
||||
<div class="pfm-modal" style="max-width:32rem;">
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
||||
onclick="window._pfmCloseModal('${fieldId}')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div id="${fieldId}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||
${fields.map(f => `
|
||||
<div class="pfm-field">
|
||||
<label for="${fieldId}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||
<input type="text" id="${fieldId}_cf_${escHtml(f.key)}"
|
||||
placeholder="${escHtml(f.placeholder || '')}"
|
||||
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
||||
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary"
|
||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-create" id="${fieldId}_create_btn"
|
||||
onclick="window._pfmConfirmCreate('${fieldId}')">
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
modal.style.maxWidth = '32rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||
${fields.map(f => `
|
||||
<div class="pfm-field">
|
||||
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||
<input type="text" id="${escHtml(fieldId)}_cf_${escHtml(f.key)}"
|
||||
placeholder="${escHtml(f.placeholder || '')}"
|
||||
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
||||
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||
};
|
||||
|
||||
window._pfmConfirmCreate = async function (fieldId) {
|
||||
@@ -540,20 +648,17 @@
|
||||
const inp = document.getElementById(`${fieldId}_cf_${f.key}`);
|
||||
if (!inp) continue;
|
||||
const val = inp.value.trim();
|
||||
if (f.pattern && val && !new RegExp(f.pattern).test(val)) {
|
||||
if (errEl) errEl.textContent = `${f.label || f.key}: invalid format — ${f.hint || ''}`;
|
||||
inp.focus(); return;
|
||||
}
|
||||
// Client-side pattern validation omitted — server-side create-file script validates.
|
||||
params[f.key] = val;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Creating…'; }
|
||||
if (btn) { btn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Creating…'));})(btn); }
|
||||
if (errEl) errEl.textContent = '';
|
||||
|
||||
const result = await callAction(st.pluginId, st.actions.create, params)
|
||||
.catch(() => ({ status: 'error', message: 'Network error' }));
|
||||
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plus mr-1"></i>Create'; }
|
||||
if (btn) { btn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-plus mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Create'));})(btn); }
|
||||
|
||||
if (result.status === 'success') {
|
||||
notify('File created', 'success');
|
||||
@@ -645,7 +750,7 @@
|
||||
directoryLabel: wc.directory_label || ''
|
||||
});
|
||||
|
||||
container.innerHTML = `
|
||||
safeSetHTML(container, `
|
||||
<div class="pfm-root" id="${fieldId}_pfm">
|
||||
<div class="pfm-header">
|
||||
<div>
|
||||
|
||||
@@ -49,6 +49,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
window.LEDMatrixWidgets.register('time-picker', {
|
||||
name: 'Time Picker Widget',
|
||||
version: '1.0.0',
|
||||
@@ -98,7 +106,7 @@
|
||||
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
safeSetHTML(container, html);
|
||||
},
|
||||
|
||||
getValue: function(fieldId) {
|
||||
@@ -148,6 +156,7 @@
|
||||
onClear: function(fieldId) {
|
||||
const widget = window.LEDMatrixWidgets.get('time-picker');
|
||||
widget.setValue(fieldId, '');
|
||||
widget.validate(fieldId); // refresh required/error state
|
||||
triggerChange(fieldId, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,15 +352,14 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Set data-loaded on tab containers after HTMX settles their content,
|
||||
// preventing repeated re-fetches on every tab switch.
|
||||
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
|
||||
// modals and plugin config panels that legitimately reload are unaffected.
|
||||
// Mark tab containers as loaded once their content settles, so switching
|
||||
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
|
||||
// containers only) so modals and plugin config panels can still reload.
|
||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
||||
if (event.detail && event.detail.target) {
|
||||
var target = event.detail.target;
|
||||
var trigger = target.getAttribute('hx-trigger') || '';
|
||||
if (trigger.includes('revealed')) {
|
||||
if (trigger.includes('loadtab')) {
|
||||
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">
|
||||
|
||||
<!-- 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>
|
||||
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
@@ -1030,7 +1029,7 @@
|
||||
<div id="tab-content" class="space-y-6">
|
||||
<!-- Overview tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1098,7 +1097,7 @@
|
||||
|
||||
<!-- General tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<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 id="wifi-content"
|
||||
hx-get="/v3/partials/wifi"
|
||||
hx-trigger="revealed"
|
||||
hx-trigger="loadtab"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::htmx:response-error="loadWifiDirect()">
|
||||
<div class="animate-pulse">
|
||||
@@ -1167,7 +1166,7 @@
|
||||
|
||||
<!-- Schedule tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1183,7 +1182,7 @@
|
||||
|
||||
<!-- Display tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1198,7 +1197,7 @@
|
||||
|
||||
<!-- Backup & Restore tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1210,7 +1209,7 @@
|
||||
|
||||
<!-- Config Editor tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1225,7 +1224,7 @@
|
||||
|
||||
<!-- Plugins tab -->
|
||||
<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()">
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
@@ -1242,7 +1241,7 @@
|
||||
|
||||
<!-- Fonts tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1257,7 +1256,7 @@
|
||||
|
||||
<!-- Logs tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1269,7 +1268,7 @@
|
||||
|
||||
<!-- Cache tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1281,7 +1280,7 @@
|
||||
|
||||
<!-- Operation History tab -->
|
||||
<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="bg-white rounded-lg shadow p-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
@@ -1861,28 +1860,53 @@
|
||||
},
|
||||
|
||||
loadTabContent(tab) {
|
||||
// Try to load content for the active tab
|
||||
if (typeof htmx !== 'undefined') {
|
||||
const contentId = tab + '-content';
|
||||
const contentEl = document.getElementById(contentId);
|
||||
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
|
||||
// Trigger HTMX load
|
||||
htmx.trigger(contentEl, 'revealed');
|
||||
const contentEl = document.getElementById(tab + '-content');
|
||||
// data-loaded: already fetched. data-loading: a fetch is queued or in
|
||||
// flight. Both guard against re-entry so a panel loads exactly once, even
|
||||
// if the tab is reopened before an in-progress (or polling) load settles.
|
||||
if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
|
||||
const url = contentEl.getAttribute('hx-get');
|
||||
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 {
|
||||
// HTMX is still loading asynchronously — retry when it signals ready,
|
||||
// or fall back to direct fetch if it fails to load entirely.
|
||||
const self = this;
|
||||
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
|
||||
function onFailed() {
|
||||
window.removeEventListener('htmx:ready', onReady);
|
||||
return htmx.ajax('GET', url, { target: contentEl, swap: swap })
|
||||
.then(() => contentEl.setAttribute('data-loaded', 'true'))
|
||||
.catch(() => {}) // leave unstamped on failure so it can retry
|
||||
.finally(() => contentEl.removeAttribute('data-loading'));
|
||||
};
|
||||
|
||||
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();
|
||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
||||
}
|
||||
window.addEventListener('htmx:ready', onReady, { once: true });
|
||||
window.addEventListener('htmx-load-failed', onFailed, { once: true });
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
async loadInstalledPlugins() {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
name="chain_length"
|
||||
value="{{ main_config.display.hardware.chain_length or 2 }}"
|
||||
min="1"
|
||||
max="8"
|
||||
max="24"
|
||||
class="form-control">
|
||||
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p>
|
||||
</div>
|
||||
|
||||
@@ -504,9 +504,14 @@
|
||||
{% for col_name in display_columns %}
|
||||
{% set col_def = item_properties.get(col_name, {}) %}
|
||||
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
||||
{% set col_xwidget = col_def.get('x-widget', '') %}
|
||||
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||
{% set col_enum = col_def.get('enum', []) %}
|
||||
{% set col_ctype = col_def.get('type', 'string') %}
|
||||
{% set _raw_ctype = col_def.get('type', 'string') %}
|
||||
{% if _raw_ctype is iterable and _raw_ctype is not string %}
|
||||
{% set col_ctype = (_raw_ctype | reject('equalto','null') | list | first) or 'string' %}
|
||||
{% else %}
|
||||
{% set col_ctype = _raw_ctype or 'string' %}
|
||||
{% endif %}
|
||||
{% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %}
|
||||
{% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %}
|
||||
{% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %}
|
||||
@@ -525,8 +530,14 @@
|
||||
<tr class="array-table-row" data-index="{{ item_index }}">
|
||||
{% for col_name in display_columns %}
|
||||
{% set col_def = item_properties.get(col_name, {}) %}
|
||||
{% set col_type = col_def.get('type', 'string') %}
|
||||
{% set col_xwidget = col_def.get('x-widget', '') %}
|
||||
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
|
||||
{% set _raw_type = col_def.get('type', 'string') %}
|
||||
{% if _raw_type is iterable and _raw_type is not string %}
|
||||
{% set col_type = (_raw_type | reject('equalto','null') | list | first) or 'string' %}
|
||||
{% else %}
|
||||
{% set col_type = _raw_type or 'string' %}
|
||||
{% endif %}
|
||||
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||
{% set col_enum = col_def.get('enum', []) %}
|
||||
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
||||
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
||||
@@ -1033,7 +1044,7 @@
|
||||
or if every action is marked ui_hidden in the manifest. #}
|
||||
{% set has_file_manager_widget = namespace(value=false) %}
|
||||
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
||||
{% if _fp.get('x-widget') in ('json-file-manager', 'plugin-file-manager') %}
|
||||
{% if (_fp.get('x-widget') or _fp.get('x_widget')) in ('json-file-manager', 'plugin-file-manager') %}
|
||||
{% set has_file_manager_widget.value = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user