mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-16 09:38:38 +00:00
Compare commits
10 Commits
122e6d6863
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 | ||
|
|
d22d0a3754 | ||
|
|
5beef0aa01 | ||
|
|
cf28a8c0d5 | ||
|
|
a06682981c | ||
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f |
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
|
||||||
config/config.json.backup
|
config/config.json.backup
|
||||||
config/wifi_config.json
|
config/wifi_config.json
|
||||||
|
config/uninstalled_plugins.json
|
||||||
credentials.json
|
credentials.json
|
||||||
token.pickle
|
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.
|
||||||
@@ -15,8 +15,8 @@ on_error() {
|
|||||||
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
||||||
if [ -n "${LOG_FILE:-}" ]; then
|
if [ -n "${LOG_FILE:-}" ]; then
|
||||||
echo "See the log for details: $LOG_FILE" >&2
|
echo "See the log for details: $LOG_FILE" >&2
|
||||||
echo "-- Last 50 lines from log --" >&2
|
echo "-- Last 100 lines from log --" >&2
|
||||||
tail -n 50 "$LOG_FILE" >&2 || true
|
tail -n 100 "$LOG_FILE" >&2 || true
|
||||||
fi
|
fi
|
||||||
echo "\nCommon fixes:" >&2
|
echo "\nCommon fixes:" >&2
|
||||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||||
@@ -202,8 +202,33 @@ retry() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
apt_update() { retry apt update; }
|
# Wait for another apt/dpkg process (commonly unattended-upgrades running
|
||||||
apt_install() { retry apt install -y "$@"; }
|
# 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; }
|
apt_remove() { apt-get remove -y "$@" || true; }
|
||||||
|
|
||||||
check_network() {
|
check_network() {
|
||||||
@@ -222,6 +247,22 @@ check_network() {
|
|||||||
exit 1
|
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 ""
|
||||||
echo "This script will perform the following steps:"
|
echo "This script will perform the following steps:"
|
||||||
echo "1. Install system dependencies"
|
echo "1. Install system dependencies"
|
||||||
@@ -271,8 +312,9 @@ CURRENT_STEP="Install system dependencies"
|
|||||||
echo "Step 1: Installing system dependencies..."
|
echo "Step 1: Installing system dependencies..."
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
|
|
||||||
# Ensure network is available before APT operations
|
# Pre-flight checks before APT operations
|
||||||
check_network
|
check_network
|
||||||
|
check_disk_space
|
||||||
|
|
||||||
# Update package list
|
# Update package list
|
||||||
apt_update
|
apt_update
|
||||||
@@ -822,14 +864,14 @@ else
|
|||||||
# Try to initialize submodule if .gitmodules exists
|
# Try to initialize submodule if .gitmodules exists
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
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..."
|
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
|
fi
|
||||||
else
|
else
|
||||||
# Fallback: clone directly if submodule not configured
|
# Fallback: clone directly if submodule not configured
|
||||||
echo "Submodule not configured, cloning directly from GitHub..."
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -841,9 +883,9 @@ else
|
|||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
rm -rf rpi-rgb-led-matrix-master
|
rm -rf rpi-rgb-led-matrix-master
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
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
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -851,13 +893,24 @@ else
|
|||||||
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
||||||
echo " Build deps required: python-dev-is-python3 cmake"
|
echo " Build deps required: python-dev-is-python3 cmake"
|
||||||
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
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 "✗ Failed to install rpi-rgb-led-matrix Python package"
|
||||||
echo " Ensure build tools are installed:"
|
echo " Ensure build tools are installed:"
|
||||||
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
|
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
|
popd >/dev/null
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
rm -f "$BUILD_OUTPUT"
|
||||||
popd >/dev/null
|
popd >/dev/null
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
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
|
# Try to install dependencies using the smart installer if available
|
||||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||||
echo "Using smart dependency installer..."
|
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
|
else
|
||||||
echo "Using pip to install dependencies..."
|
echo "Using pip to install dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
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,8 +340,13 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Execute with proper error handling and non-interactive mode
|
# 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
|
set +e
|
||||||
|
trap '' ERR
|
||||||
|
|
||||||
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
||||||
# When running manually, /tmp usually has correct permissions (1777)
|
# When running manually, /tmp usually has correct permissions (1777)
|
||||||
@@ -370,6 +375,7 @@ main() {
|
|||||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
||||||
fi
|
fi
|
||||||
INSTALL_EXIT_CODE=$?
|
INSTALL_EXIT_CODE=$?
|
||||||
|
trap 'on_error $LINENO' ERR # Re-enable ERR trap
|
||||||
set -e # Re-enable errexit
|
set -e # Re-enable errexit
|
||||||
|
|
||||||
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
||||||
|
|||||||
@@ -6,46 +6,67 @@ then falls back to pip with --break-system-packages
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import warnings
|
import warnings
|
||||||
|
from collections import deque
|
||||||
from pathlib import Path
|
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):
|
def install_via_apt(package_name):
|
||||||
"""Try to install a package via apt."""
|
"""Try to install a package via apt. Returns (success, output)."""
|
||||||
try:
|
# Map pip package names to apt package names
|
||||||
# Map pip package names to apt package names
|
apt_package_map = {
|
||||||
apt_package_map = {
|
'flask': 'python3-flask',
|
||||||
'flask': 'python3-flask',
|
'PIL': 'python3-pil',
|
||||||
'PIL': 'python3-pil',
|
'freetype': 'python3-freetype',
|
||||||
'freetype': 'python3-freetype',
|
'psutil': 'python3-psutil',
|
||||||
'psutil': 'python3-psutil',
|
'werkzeug': 'python3-werkzeug',
|
||||||
'werkzeug': 'python3-werkzeug',
|
'numpy': 'python3-numpy',
|
||||||
'numpy': 'python3-numpy',
|
'requests': 'python3-requests',
|
||||||
'requests': 'python3-requests',
|
'python-dateutil': 'python3-dateutil',
|
||||||
'python-dateutil': 'python3-dateutil',
|
'pytz': 'python3-tz',
|
||||||
'pytz': 'python3-tz',
|
'geopy': 'python3-geopy',
|
||||||
'geopy': 'python3-geopy',
|
'unidecode': 'python3-unidecode',
|
||||||
'unidecode': 'python3-unidecode',
|
'websockets': 'python3-websockets',
|
||||||
'websockets': 'python3-websockets',
|
'websocket-client': 'python3-websocket-client'
|
||||||
'websocket-client': 'python3-websocket-client'
|
}
|
||||||
}
|
|
||||||
|
|
||||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
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)
|
|
||||||
|
|
||||||
|
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")
|
print(f"Successfully installed {apt_package} via apt")
|
||||||
return True
|
return True, ""
|
||||||
|
|
||||||
|
print(f"Failed to install {apt_package} via apt, will try pip")
|
||||||
|
return False, output
|
||||||
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
print(f"Failed to install {package_name} via apt, will try pip")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def install_via_pip(package_name):
|
def install_via_pip(package_name):
|
||||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
"""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.
|
Debian/Ubuntu-based systems without a virtual environment.
|
||||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||||
exhausting /tmp space during compilation.
|
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...")
|
||||||
print(f"Installing {package_name} via pip...")
|
success, output = _run([
|
||||||
subprocess.check_call([
|
sys.executable, '-m', 'pip', 'install',
|
||||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
||||||
])
|
])
|
||||||
|
if success:
|
||||||
print(f"Successfully installed {package_name} via pip")
|
print(f"Successfully installed {package_name} via pip")
|
||||||
return True
|
return True, ""
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Failed to install {package_name} via pip: {e}")
|
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
|
||||||
return False
|
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):
|
def check_package_installed(package_name):
|
||||||
"""Check if a package is already installed."""
|
"""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
|
# Suppress deprecation warnings when checking if packages are installed
|
||||||
# (we're just checking, not using them)
|
# (we're just checking, not using them)
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||||
try:
|
try:
|
||||||
__import__(package_name)
|
__import__(import_name)
|
||||||
return True
|
return True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return False
|
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():
|
def main():
|
||||||
"""Main installation function."""
|
"""Main installation function."""
|
||||||
print("Installing dependencies for LED Matrix Web Interface V2...")
|
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
|
# List of required packages
|
||||||
required_packages = [
|
required_packages = [
|
||||||
'flask',
|
'flask',
|
||||||
@@ -100,6 +160,7 @@ def main():
|
|||||||
]
|
]
|
||||||
|
|
||||||
failed_packages = []
|
failed_packages = []
|
||||||
|
failure_details = {}
|
||||||
|
|
||||||
for package in required_packages:
|
for package in required_packages:
|
||||||
if check_package_installed(package):
|
if check_package_installed(package):
|
||||||
@@ -107,9 +168,12 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Try apt first, then pip
|
# Try apt first, then pip
|
||||||
if not install_via_apt(package):
|
ok, apt_output = install_via_apt(package)
|
||||||
if not install_via_pip(package):
|
if not ok:
|
||||||
|
ok, pip_output = install_via_pip(package)
|
||||||
|
if not ok:
|
||||||
failed_packages.append(package)
|
failed_packages.append(package)
|
||||||
|
failure_details[package] = pip_output or apt_output
|
||||||
|
|
||||||
# Install packages that don't have apt equivalents
|
# Install packages that don't have apt equivalents
|
||||||
special_packages = [
|
special_packages = [
|
||||||
@@ -124,8 +188,10 @@ def main():
|
|||||||
]
|
]
|
||||||
|
|
||||||
for package in special_packages:
|
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)
|
failed_packages.append(package)
|
||||||
|
failure_details[package] = pip_output
|
||||||
|
|
||||||
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
||||||
# Check if already installed first
|
# Check if already installed first
|
||||||
@@ -133,36 +199,36 @@ def main():
|
|||||||
print("rgbmatrix module already installed, skipping...")
|
print("rgbmatrix module already installed, skipping...")
|
||||||
else:
|
else:
|
||||||
print("Installing rgbmatrix module from local source...")
|
print("Installing rgbmatrix module from local source...")
|
||||||
try:
|
# Get project root (parent of scripts directory)
|
||||||
# Get project root (parent of scripts directory)
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
if rgbmatrix_path.exists():
|
||||||
if rgbmatrix_path.exists():
|
# Check if the module has been built (look for setup.py)
|
||||||
# Check if the module has been built (look for setup.py)
|
setup_py = rgbmatrix_path / 'setup.py'
|
||||||
setup_py = rgbmatrix_path / 'setup.py'
|
if setup_py.exists():
|
||||||
if setup_py.exists():
|
# Try installing - use regular install, not editable mode
|
||||||
# Try installing - use regular install, not editable mode
|
# This is optional for web interface and should already be installed in Step 6
|
||||||
# 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)])
|
||||||
subprocess.check_call([
|
if ok:
|
||||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
|
|
||||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
print("rgbmatrix module installed successfully")
|
print("rgbmatrix module installed successfully")
|
||||||
else:
|
else:
|
||||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||||
print(" This is normal if Step 6 hasn't completed yet.")
|
# 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:
|
else:
|
||||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||||
except subprocess.CalledProcessError as e:
|
print(" This is normal if Step 6 hasn't completed yet.")
|
||||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
else:
|
||||||
# and should be installed in Step 6 of first_time_install.sh
|
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||||
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
|
|
||||||
|
|
||||||
if failed_packages:
|
if failed_packages:
|
||||||
print(f"\nFailed to install the following packages: {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("You may need to install them manually or check your system configuration.")
|
||||||
|
print_failure_summary(failed_packages, failure_details)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print("\nAll dependencies installed successfully!")
|
print("\nAll dependencies installed successfully!")
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import os
|
|||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Sequence, Union
|
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -28,49 +27,15 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
# Import logger after path setup so src.logging_config is importable
|
# Import logger after path setup so src.logging_config is importable
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
|
find_plugin_dir, load_manifest, load_config_defaults,
|
||||||
|
)
|
||||||
logger = get_logger("[Render Plugin]")
|
logger = get_logger("[Render Plugin]")
|
||||||
|
|
||||||
MIN_DIMENSION = 1
|
MIN_DIMENSION = 1
|
||||||
MAX_DIMENSION = 512
|
MAX_DIMENSION = 512
|
||||||
|
|
||||||
|
|
||||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
|
||||||
"""Find a plugin directory by searching multiple paths."""
|
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
|
||||||
loader = PluginLoader()
|
|
||||||
for search_dir in search_dirs:
|
|
||||||
search_path = Path(search_dir)
|
|
||||||
if not search_path.exists():
|
|
||||||
continue
|
|
||||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
|
||||||
if result:
|
|
||||||
return Path(result)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
|
||||||
"""Load and return manifest.json from plugin directory."""
|
|
||||||
manifest_path = plugin_dir / 'manifest.json'
|
|
||||||
if not manifest_path.exists():
|
|
||||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
|
||||||
with open(manifest_path, 'r') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
|
||||||
"""Extract default values from config_schema.json."""
|
|
||||||
schema_path = plugin_dir / 'config_schema.json'
|
|
||||||
if not schema_path.exists():
|
|
||||||
return {}
|
|
||||||
with open(schema_path, 'r') as f:
|
|
||||||
schema = json.load(f)
|
|
||||||
defaults: Dict[str, Any] = {}
|
|
||||||
for key, prop in schema.get('properties', {}).items():
|
|
||||||
if 'default' in prop:
|
|
||||||
defaults[key] = prop['default']
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||||
|
|||||||
@@ -1505,30 +1505,68 @@ class DisplayController:
|
|||||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||||
self._live_resume_index = None
|
self._live_resume_index = None
|
||||||
|
|
||||||
def _check_live_priority(self):
|
def _collect_live_modes(self):
|
||||||
"""
|
"""Return every currently live-priority mode, in registration order.
|
||||||
Check all plugins for live priority content.
|
|
||||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
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():
|
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 not (hasattr(plugin_instance, 'has_live_priority')
|
||||||
try:
|
and hasattr(plugin_instance, 'has_live_content')):
|
||||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
continue
|
||||||
# Get the specific live mode from the plugin if available
|
try:
|
||||||
if hasattr(plugin_instance, 'get_live_modes'):
|
if not (plugin_instance.has_live_priority()
|
||||||
live_modes = plugin_instance.get_live_modes()
|
and plugin_instance.has_live_content()):
|
||||||
if live_modes and len(live_modes) > 0:
|
continue
|
||||||
# Verify the mode actually exists before returning it
|
resolved = []
|
||||||
for suggested_mode in live_modes:
|
if hasattr(plugin_instance, 'get_live_modes'):
|
||||||
if suggested_mode in self.plugin_modes:
|
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||||
return suggested_mode
|
if suggested_mode in self.plugin_modes:
|
||||||
# If suggested modes don't exist, fall through to check current mode
|
resolved.append(suggested_mode)
|
||||||
# Fallback: if this mode ends with _live, return it
|
if not resolved and mode_name.endswith('_live'):
|
||||||
if mode_name.endswith('_live'):
|
resolved.append(mode_name)
|
||||||
return mode_name
|
for m in resolved:
|
||||||
except Exception as e:
|
if m not in seen:
|
||||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
seen.add(m)
|
||||||
return None
|
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):
|
def run(self):
|
||||||
"""Run the display controller, switching between displays."""
|
"""Run the display controller, switching between displays."""
|
||||||
@@ -1689,9 +1727,11 @@ class DisplayController:
|
|||||||
# Display failed, clear the status and continue normally
|
# Display failed, clear the status and continue normally
|
||||||
wifi_status_data = None
|
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:
|
if not self.on_demand_active and not wifi_status_data:
|
||||||
live_priority_mode = self._check_live_priority()
|
live_priority_mode = self._check_live_priority(advance=True)
|
||||||
self._apply_live_priority(live_priority_mode)
|
self._apply_live_priority(live_priority_mode)
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
@@ -2186,6 +2226,23 @@ class DisplayController:
|
|||||||
loop_completed = True
|
loop_completed = True
|
||||||
break
|
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
|
# Ensure we honour minimum duration when not dynamic and loop ended early
|
||||||
if (
|
if (
|
||||||
not dynamic_enabled
|
not dynamic_enabled
|
||||||
|
|||||||
@@ -322,10 +322,19 @@ class StateReconciliation:
|
|||||||
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||||
and self.store_manager.was_recently_uninstalled(plugin_id)
|
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 = (
|
can_repair = (
|
||||||
self.store_manager is not None
|
self.store_manager is not None
|
||||||
and not previously_unrecoverable
|
and not previously_unrecoverable
|
||||||
and not recently_uninstalled
|
and not recently_uninstalled
|
||||||
|
and not persistently_uninstalled
|
||||||
)
|
)
|
||||||
inconsistencies.append(Inconsistency(
|
inconsistencies.append(Inconsistency(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from both the official registry and custom GitHub repositories.
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -19,7 +20,7 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional, Any, Tuple
|
from typing import List, Dict, Optional, Any, Tuple, Set
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -44,12 +45,23 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
|
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
|
||||||
|
|
||||||
def __init__(self, plugins_dir: str = "plugins"):
|
# 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",
|
||||||
|
uninstalled_registry_path: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the plugin store manager.
|
Initialize the plugin store manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugins_dir: Directory where plugins are installed
|
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.plugins_dir = Path(plugins_dir)
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
@@ -84,6 +96,25 @@ class PluginStoreManager:
|
|||||||
self._uninstall_tombstones: Dict[str, float] = {}
|
self._uninstall_tombstones: Dict[str, float] = {}
|
||||||
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
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)}
|
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
||||||
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||||
# head_contents) so a fast-forward update to the current branch
|
# head_contents) so a fast-forward update to the current branch
|
||||||
@@ -143,6 +174,135 @@ class PluginStoreManager:
|
|||||||
return False
|
return False
|
||||||
return True
|
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]:
|
def _load_github_token(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Load GitHub API token from config_secrets.json if available.
|
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)"
|
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
|
||||||
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
|
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)
|
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
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')
|
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})")
|
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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ Provides base classes and utilities for testing LEDMatrix plugins.
|
|||||||
from .plugin_test_base import PluginTestCase
|
from .plugin_test_base import PluginTestCase
|
||||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||||
from .visual_display_manager import VisualTestDisplayManager
|
from .visual_display_manager import VisualTestDisplayManager
|
||||||
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from .sizes import (
|
||||||
|
DEFAULT_TEST_SIZES, SUPPORTED_SIZES, resolve_test_sizes, size_label,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'PluginTestCase',
|
'PluginTestCase',
|
||||||
'VisualTestDisplayManager',
|
'VisualTestDisplayManager',
|
||||||
|
'BoundsCheckingDisplayManager',
|
||||||
'MockDisplayManager',
|
'MockDisplayManager',
|
||||||
'MockCacheManager',
|
'MockCacheManager',
|
||||||
'MockConfigManager',
|
'MockConfigManager',
|
||||||
'MockPluginManager',
|
'MockPluginManager',
|
||||||
|
'DEFAULT_TEST_SIZES',
|
||||||
|
'SUPPORTED_SIZES',
|
||||||
|
'resolve_test_sizes',
|
||||||
|
'size_label',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
129
src/plugin_system/testing/bounds_display_manager.py
Normal file
129
src/plugin_system/testing/bounds_display_manager.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
Bounds-checking display manager.
|
||||||
|
|
||||||
|
A VisualTestDisplayManager that draws onto an oversized canvas (the declared
|
||||||
|
panel size plus a right/bottom margin) while still reporting the declared size
|
||||||
|
to the plugin. Content that a plugin draws past the right or bottom edge lands
|
||||||
|
in the margin instead of being silently clipped by PIL, so the harness can
|
||||||
|
detect overflow — the classic symptom of hardcoded coordinates or fonts/icons
|
||||||
|
that don't scale down to a smaller panel.
|
||||||
|
|
||||||
|
Limitations (documented on purpose):
|
||||||
|
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
||||||
|
PIL and not detected here. The dominant real-world breakage is content that is
|
||||||
|
too wide/tall for a smaller panel, which this catches.
|
||||||
|
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
||||||
|
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
||||||
|
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
||||||
|
the margin can't be measured and overflow is reported as undetermined (None).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from .sizes import DEFAULT_TEST_SIZES
|
||||||
|
from .visual_display_manager import VisualTestDisplayManager, _MatrixProxy
|
||||||
|
|
||||||
|
# Smallest extra band kept on the right/bottom so a few pixels of overflow are
|
||||||
|
# still visible even on the largest panel in a run.
|
||||||
|
_BASE_MARGIN = 16
|
||||||
|
# Fallback overflow reference when a caller doesn't pass one: the largest shape
|
||||||
|
# in the default sample. We extend every (smaller) canvas out to at least this
|
||||||
|
# size so content drawn at a coordinate meant for a bigger build — e.g. x=200 on
|
||||||
|
# a 64-wide panel — lands in the padded region and is flagged, instead of being
|
||||||
|
# clipped off-canvas and read as a false pass.
|
||||||
|
_DEFAULT_EXTENT_WIDTH = max(w for w, _ in DEFAULT_TEST_SIZES)
|
||||||
|
_DEFAULT_EXTENT_HEIGHT = max(h for _, h in DEFAULT_TEST_SIZES)
|
||||||
|
|
||||||
|
|
||||||
|
class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
||||||
|
"""Detects drawing that overflows the declared panel size."""
|
||||||
|
|
||||||
|
# Kept for backwards compatibility; real padding is computed per-axis below.
|
||||||
|
MARGIN = _BASE_MARGIN
|
||||||
|
|
||||||
|
def __init__(self, width: int = 128, height: int = 32,
|
||||||
|
overflow_extent: Optional[Tuple[int, int]] = None):
|
||||||
|
self._declared_width = int(width)
|
||||||
|
self._declared_height = int(height)
|
||||||
|
# Pad the canvas out to at least `overflow_extent` (the largest panel
|
||||||
|
# this run cares about) plus a base margin, so coordinates meant for a
|
||||||
|
# bigger build are caught — not clipped — when rendering a smaller panel.
|
||||||
|
# Defaults to the largest shape in the sample when no run is known.
|
||||||
|
ext_w, ext_h = overflow_extent or (_DEFAULT_EXTENT_WIDTH, _DEFAULT_EXTENT_HEIGHT)
|
||||||
|
self._canvas_width = max(self._declared_width, int(ext_w)) + _BASE_MARGIN
|
||||||
|
self._canvas_height = max(self._declared_height, int(ext_h)) + _BASE_MARGIN
|
||||||
|
# Parent builds the (oversized) backing canvas + fonts.
|
||||||
|
super().__init__(self._canvas_width, self._canvas_height)
|
||||||
|
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||||
|
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
||||||
|
|
||||||
|
# -- declared dimensions (override parent's image-derived properties) --
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
return self._declared_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
return self._declared_height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_width(self) -> int:
|
||||||
|
return self._declared_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_height(self) -> int:
|
||||||
|
return self._declared_height
|
||||||
|
|
||||||
|
# -- overflow detection --
|
||||||
|
|
||||||
|
def _canvas_is_padded(self) -> bool:
|
||||||
|
return self.image.size == (self._canvas_width, self._canvas_height)
|
||||||
|
|
||||||
|
def check_overflow(self) -> Optional[Tuple[int, int, int, int]]:
|
||||||
|
"""Bounding box (in full-canvas coords) of any drawing beyond the
|
||||||
|
declared panel, or None if nothing overflowed / undetermined."""
|
||||||
|
if not self._canvas_is_padded():
|
||||||
|
return None
|
||||||
|
|
||||||
|
exp_w = self._canvas_width
|
||||||
|
exp_h = self._canvas_height
|
||||||
|
boxes = []
|
||||||
|
|
||||||
|
right = self.image.crop((self._declared_width, 0, exp_w, exp_h)).getbbox()
|
||||||
|
if right:
|
||||||
|
boxes.append((right[0] + self._declared_width, right[1],
|
||||||
|
right[2] + self._declared_width, right[3]))
|
||||||
|
|
||||||
|
bottom = self.image.crop((0, self._declared_height, exp_w, exp_h)).getbbox()
|
||||||
|
if bottom:
|
||||||
|
boxes.append((bottom[0], bottom[1] + self._declared_height,
|
||||||
|
bottom[2], bottom[3] + self._declared_height))
|
||||||
|
|
||||||
|
if not boxes:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
min(b[0] for b in boxes), min(b[1] for b in boxes),
|
||||||
|
max(b[2] for b in boxes), max(b[3] for b in boxes),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- snapshot/image accessors return the cropped, true-panel image --
|
||||||
|
|
||||||
|
def declared_image(self):
|
||||||
|
"""The visible panel: the canvas cropped to the declared size."""
|
||||||
|
if self._canvas_is_padded():
|
||||||
|
return self.image.crop((0, 0, self._declared_width, self._declared_height))
|
||||||
|
return self.image
|
||||||
|
|
||||||
|
def save_snapshot(self, path: str) -> None:
|
||||||
|
self.declared_image().save(path, format='PNG')
|
||||||
|
|
||||||
|
def get_image(self):
|
||||||
|
return self.declared_image()
|
||||||
|
|
||||||
|
def get_image_base64(self) -> str:
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
self.declared_image().save(buffer, format='PNG')
|
||||||
|
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
314
src/plugin_system/testing/harness.py
Normal file
314
src/plugin_system/testing/harness.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
Plugin safety harness.
|
||||||
|
|
||||||
|
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||||
|
size, capturing crashes and overflow. Used by scripts/check_plugin.py and the
|
||||||
|
pytest matrix test to guarantee a plugin change doesn't break a screen at a size
|
||||||
|
the author didn't try.
|
||||||
|
|
||||||
|
The render flow mirrors scripts/render_plugin.py (same PluginLoader call), but
|
||||||
|
this module adds: multi-size iteration, per-mode rendering, overflow detection
|
||||||
|
via BoundsCheckingDisplayManager, and golden-image comparison.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import http.client
|
||||||
|
import inspect
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
from src.logging_config import get_logger
|
||||||
|
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||||
|
from .loading import load_config_defaults, load_manifest
|
||||||
|
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||||
|
|
||||||
|
logger = get_logger("[Plugin Harness]")
|
||||||
|
|
||||||
|
|
||||||
|
def _tolerated_update_errors() -> Tuple[type, ...]:
|
||||||
|
"""Exception types from update() we treat as a tolerated no-connectivity
|
||||||
|
failure (expected in CI / headless dev) rather than a real plugin bug.
|
||||||
|
|
||||||
|
Anything NOT in this set is a genuine regression — a plugin that lets a
|
||||||
|
non-network exception escape update() should fail the harness, not pass
|
||||||
|
green because display() happened to survive.
|
||||||
|
"""
|
||||||
|
types: List[type] = [
|
||||||
|
ConnectionError, TimeoutError, # builtins
|
||||||
|
socket.gaierror, socket.timeout, # DNS / socket timeouts
|
||||||
|
ssl.SSLError,
|
||||||
|
urllib.error.URLError,
|
||||||
|
http.client.HTTPException,
|
||||||
|
]
|
||||||
|
try: # requests is optional; cover its whole error tree when present
|
||||||
|
import requests
|
||||||
|
types.append(requests.exceptions.RequestException)
|
||||||
|
except ImportError: # pragma: no cover - requests not installed
|
||||||
|
logger.debug("requests not installed; its connectivity errors won't be specifically tolerated")
|
||||||
|
return tuple(types)
|
||||||
|
|
||||||
|
|
||||||
|
_TOLERATED_UPDATE_ERRORS = _tolerated_update_errors()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderResult:
|
||||||
|
"""Outcome of rendering one (size, mode) of a plugin."""
|
||||||
|
plugin_id: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
mode: str
|
||||||
|
image: Optional[Image.Image] = None
|
||||||
|
error: Optional[str] = None # fatal: load/display crash, or a non-network update() error
|
||||||
|
update_error: Optional[str] = None # tolerated: connectivity error from update() (no network in CI)
|
||||||
|
overflow: Optional[Tuple[int, int, int, int]] = None # bbox past the panel
|
||||||
|
# golden comparison (populated only when a golden was provided)
|
||||||
|
golden_checked: bool = False
|
||||||
|
golden_ok: Optional[bool] = None
|
||||||
|
golden_diff_pixels: int = 0
|
||||||
|
golden_max_delta: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_label(self) -> str:
|
||||||
|
return size_label(self.width, self.height)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
"""Phase-1 pass: rendered without crashing and without overflow, and if a
|
||||||
|
golden was checked it matched."""
|
||||||
|
if self.error is not None or self.overflow is not None:
|
||||||
|
return False
|
||||||
|
if self.golden_checked and self.golden_ok is False:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
|
||||||
|
"""Enumerate a plugin's screens: instance.modes wins, then manifest
|
||||||
|
display_modes, then the plugin id as a single mode."""
|
||||||
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
|
if modes:
|
||||||
|
return [str(m) for m in modes]
|
||||||
|
declared = manifest.get("display_modes")
|
||||||
|
if declared:
|
||||||
|
return [str(m) for m in declared]
|
||||||
|
return [plugin_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||||
|
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||||
|
display_manager: Any) -> Any:
|
||||||
|
"""Load and construct a plugin instance with mocked managers."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||||
|
|
||||||
|
cache_manager = MockCacheManager()
|
||||||
|
for key, value in (mock_data or {}).items():
|
||||||
|
cache_manager.set(key, value)
|
||||||
|
|
||||||
|
loader = PluginLoader()
|
||||||
|
plugin_instance, _module = loader.load_plugin(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
manifest=manifest,
|
||||||
|
plugin_dir=plugin_dir,
|
||||||
|
config=config,
|
||||||
|
display_manager=display_manager,
|
||||||
|
cache_manager=cache_manager,
|
||||||
|
plugin_manager=MockPluginManager(),
|
||||||
|
install_deps=False,
|
||||||
|
)
|
||||||
|
return plugin_instance
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||||
|
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||||
|
drive the plugin's internal mode state machine (first display() call renders
|
||||||
|
modes[current_mode_index] when current_display_mode is None)."""
|
||||||
|
sig = inspect.signature(plugin_instance.display)
|
||||||
|
if "display_mode" in sig.parameters:
|
||||||
|
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||||
|
return
|
||||||
|
|
||||||
|
modes = getattr(plugin_instance, "modes", None)
|
||||||
|
if modes and mode in modes:
|
||||||
|
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||||
|
if hasattr(plugin_instance, "current_display_mode"):
|
||||||
|
plugin_instance.current_display_mode = None
|
||||||
|
plugin_instance.display(force_clear=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze(freeze_time: Optional[str]):
|
||||||
|
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||||
|
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||||
|
if not freeze_time:
|
||||||
|
return contextlib.nullcontext()
|
||||||
|
try:
|
||||||
|
from freezegun import freeze_time as _ft
|
||||||
|
except ImportError as e: # pragma: no cover - only hit without the dep
|
||||||
|
raise RuntimeError(
|
||||||
|
"freeze_time requires the 'freezegun' package (pip install freezegun)"
|
||||||
|
) from e
|
||||||
|
return _ft(freeze_time)
|
||||||
|
|
||||||
|
|
||||||
|
def render_plugin_matrix(
|
||||||
|
plugin_id: str,
|
||||||
|
plugin_dir: Path,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
mock_data: Optional[Dict[str, Any]] = None,
|
||||||
|
sizes: Optional[List[Tuple[int, int]]] = None,
|
||||||
|
run_update: bool = True,
|
||||||
|
freeze_time: Optional[str] = None,
|
||||||
|
) -> List[RenderResult]:
|
||||||
|
"""Render every (size, mode) combination for a plugin.
|
||||||
|
|
||||||
|
Returns a flat list of RenderResult. A fresh plugin instance is built per
|
||||||
|
(size, mode) so state never leaks between screens. Pass freeze_time (e.g.
|
||||||
|
"2025-08-01 15:25:00") to make time-dependent plugins reproducible.
|
||||||
|
"""
|
||||||
|
plugin_dir = Path(plugin_dir)
|
||||||
|
manifest = load_manifest(plugin_dir)
|
||||||
|
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||||
|
# install; explicit caller config still wins over a schema default.
|
||||||
|
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
||||||
|
sizes = sizes or DEFAULT_TEST_SIZES
|
||||||
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
|
# The largest panel in this run. Every (smaller) canvas is padded out to it
|
||||||
|
# so a coordinate meant for the biggest configuration is still caught when
|
||||||
|
# rendering a smaller one, instead of being clipped into a false pass.
|
||||||
|
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
||||||
|
|
||||||
|
with _freeze(freeze_time):
|
||||||
|
for width, height in sizes:
|
||||||
|
results.extend(_render_size(
|
||||||
|
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||||
|
width, height, run_update, extent,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||||
|
width, height, run_update, extent) -> List[RenderResult]:
|
||||||
|
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||||
|
results: List[RenderResult] = []
|
||||||
|
|
||||||
|
# Discover modes once per size (instance build can depend on config).
|
||||||
|
try:
|
||||||
|
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
|
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
||||||
|
modes = list_modes(probe, manifest, plugin_id)
|
||||||
|
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||||
|
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||||
|
|
||||||
|
for mode in modes:
|
||||||
|
result = RenderResult(plugin_id, width, height, mode)
|
||||||
|
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||||
|
try:
|
||||||
|
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
||||||
|
if run_update:
|
||||||
|
try:
|
||||||
|
inst.update()
|
||||||
|
except _TOLERATED_UPDATE_ERRORS as e:
|
||||||
|
# Expected when CI / headless dev has no network: record it
|
||||||
|
# (surfaced in the report) but don't fail the run.
|
||||||
|
result.update_error = repr(e)
|
||||||
|
logger.debug("update() connectivity error for %s [%s]: %s", plugin_id, mode, e)
|
||||||
|
except Exception as e: # noqa: BLE001 — a non-network update() failure is a real bug
|
||||||
|
# A regression in update() must not pass green just because
|
||||||
|
# display() survives, so treat it as a failure of this render.
|
||||||
|
result.error = repr(e)
|
||||||
|
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||||
|
plugin_id, mode, e)
|
||||||
|
if result.error is None:
|
||||||
|
_render_mode(inst, mode)
|
||||||
|
result.image = dm.get_image()
|
||||||
|
result.overflow = dm.check_overflow()
|
||||||
|
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||||
|
result.error = repr(e)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Golden-image comparison
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compare_images(rendered: Image.Image, golden: Image.Image,
|
||||||
|
max_delta: int = 0, max_diff_pixels: int = 0) -> Tuple[bool, int, int]:
|
||||||
|
"""Compare two images. Returns (ok, diff_pixel_count, max_per_channel_delta).
|
||||||
|
|
||||||
|
Tolerances default to exact match; bump them only to absorb known platform
|
||||||
|
anti-aliasing noise (requires a pinned Pillow + bundled fonts for stability).
|
||||||
|
"""
|
||||||
|
if rendered.size != golden.size:
|
||||||
|
return False, rendered.size[0] * rendered.size[1], 255
|
||||||
|
a = rendered.convert("RGB")
|
||||||
|
b = golden.convert("RGB")
|
||||||
|
diff = ImageChops.difference(a, b)
|
||||||
|
bbox = diff.getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
return True, 0, 0
|
||||||
|
# Count pixels whose largest per-channel delta exceeds the allowed tolerance,
|
||||||
|
# and track the worst delta seen (for reporting).
|
||||||
|
diff_pixels = 0
|
||||||
|
observed_max = 0
|
||||||
|
for px in diff.crop(bbox).getdata():
|
||||||
|
m = max(px) if isinstance(px, tuple) else px
|
||||||
|
if m > observed_max:
|
||||||
|
observed_max = m
|
||||||
|
if m > max_delta:
|
||||||
|
diff_pixels += 1
|
||||||
|
# Pass when the number of out-of-tolerance pixels is within budget.
|
||||||
|
ok = diff_pixels <= max_diff_pixels
|
||||||
|
return ok, diff_pixels, observed_max
|
||||||
|
|
||||||
|
|
||||||
|
def golden_path(golden_dir: Path, width: int, height: int, mode: str) -> Path:
|
||||||
|
"""Location of a golden image: <golden_dir>/<WxH>/<mode>.png.
|
||||||
|
|
||||||
|
The mode is sanitized to a safe basename so a mode name with '/' or '..'
|
||||||
|
can't read or write outside the golden directory.
|
||||||
|
"""
|
||||||
|
return Path(golden_dir) / size_label(width, height) / f"{safe_mode_filename(mode)}.png"
|
||||||
|
|
||||||
|
|
||||||
|
def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
|
||||||
|
max_delta: int = 0, max_diff_pixels: int = 0) -> List[RenderResult]:
|
||||||
|
"""Compare rendered results against committed goldens, mutating each result's
|
||||||
|
golden_* fields. Results with no golden file on disk are left unchecked."""
|
||||||
|
for r in results:
|
||||||
|
if r.image is None:
|
||||||
|
continue
|
||||||
|
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||||
|
if not gp.exists():
|
||||||
|
continue
|
||||||
|
r.golden_checked = True
|
||||||
|
with Image.open(gp) as g:
|
||||||
|
ok, diff_pixels, observed_max = compare_images(
|
||||||
|
r.image, g, max_delta=max_delta, max_diff_pixels=max_diff_pixels)
|
||||||
|
r.golden_ok = ok
|
||||||
|
r.golden_diff_pixels = diff_pixels
|
||||||
|
r.golden_max_delta = observed_max
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
|
||||||
|
"""Write each successfully-rendered result to its golden path. Returns count."""
|
||||||
|
written = 0
|
||||||
|
for r in results:
|
||||||
|
if r.image is None or r.error is not None:
|
||||||
|
continue
|
||||||
|
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||||
|
gp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
r.image.save(gp, format="PNG")
|
||||||
|
written += 1
|
||||||
|
return written
|
||||||
82
src/plugin_system/testing/loading.py
Normal file
82
src/plugin_system/testing/loading.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Shared helpers for loading a plugin headlessly.
|
||||||
|
|
||||||
|
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
|
||||||
|
plugin discovery / manifest / config-default logic lives in exactly one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||||
|
"""Find a plugin directory by searching multiple paths."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
loader = PluginLoader()
|
||||||
|
for search_dir in search_dirs:
|
||||||
|
search_path = Path(search_dir)
|
||||||
|
if not search_path.exists():
|
||||||
|
continue
|
||||||
|
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||||
|
if result:
|
||||||
|
return Path(result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
"""Load and return manifest.json from a plugin directory."""
|
||||||
|
manifest_path = Path(plugin_dir) / 'manifest.json'
|
||||||
|
if not manifest_path.exists():
|
||||||
|
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||||
|
with open(manifest_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||||
|
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||||
|
if not schema_path.exists():
|
||||||
|
return {}
|
||||||
|
with open(schema_path, 'r') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
defaults: Dict[str, Any] = {}
|
||||||
|
for key, prop in schema.get('properties', {}).items():
|
||||||
|
if isinstance(prop, dict) and 'default' in prop:
|
||||||
|
defaults[key] = prop['default']
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||||
|
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
|
||||||
|
|
||||||
|
Lets a plugin opt into golden-image testing by declaring how to render it
|
||||||
|
deterministically. All keys optional:
|
||||||
|
{
|
||||||
|
"config": {...}, # config overrides
|
||||||
|
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"skip_update": false
|
||||||
|
}
|
||||||
|
Returns {} when no harness.json exists.
|
||||||
|
"""
|
||||||
|
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
|
||||||
|
if not spec_path.exists():
|
||||||
|
return {}
|
||||||
|
with open(spec_path, 'r') as f:
|
||||||
|
spec = json.load(f)
|
||||||
|
|
||||||
|
# Resolve mock_data path and inline its contents for convenience.
|
||||||
|
mock_rel = spec.get('mock_data')
|
||||||
|
if mock_rel:
|
||||||
|
mock_path = Path(plugin_dir) / mock_rel
|
||||||
|
if not mock_path.exists():
|
||||||
|
# A declared-but-missing fixture is a harness config error: failing
|
||||||
|
# loudly beats silently rendering the plugin with no mock data.
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"harness.json references mock_data '{mock_rel}' but "
|
||||||
|
f"{mock_path} does not exist"
|
||||||
|
)
|
||||||
|
with open(mock_path, 'r') as mf:
|
||||||
|
spec['mock_data_contents'] = json.load(mf)
|
||||||
|
return spec
|
||||||
@@ -63,11 +63,23 @@ class MockCacheManager:
|
|||||||
"""Mock cache manager for testing."""
|
"""Mock cache manager for testing."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import weakref
|
||||||
self._cache: Dict[str, Any] = {}
|
self._cache: Dict[str, Any] = {}
|
||||||
self._cache_timestamps: Dict[str, float] = {}
|
self._cache_timestamps: Dict[str, float] = {}
|
||||||
self.get_calls = []
|
self.get_calls = []
|
||||||
self.set_calls = []
|
self.set_calls = []
|
||||||
self.delete_calls = []
|
self.delete_calls = []
|
||||||
|
# Real temp dir for plugins that write/read files under cache_dir.
|
||||||
|
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
||||||
|
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
||||||
|
self._finalizer = weakref.finalize(
|
||||||
|
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Remove the temp cache directory created for this instance."""
|
||||||
|
self._finalizer()
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
||||||
"""Get a value from cache."""
|
"""Get a value from cache."""
|
||||||
|
|||||||
120
src/plugin_system/testing/sizes.py
Normal file
120
src/plugin_system/testing/sizes.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
LED matrix sizes the plugin safety harness renders against.
|
||||||
|
|
||||||
|
There is no fixed set of "supported" panel sizes — an RGB matrix build can be
|
||||||
|
any width/height and configuration (square, rectangle, 2x2, 4x4, 8x2, long
|
||||||
|
strips, tall stacks, ...). Plugins are expected to read width/height
|
||||||
|
dynamically and lay themselves out accordingly, so the harness's job is to
|
||||||
|
prove a plugin survives a *spread* of shapes, not a canonical list.
|
||||||
|
|
||||||
|
`DEFAULT_TEST_SIZES` is therefore a representative SAMPLE chosen to span the
|
||||||
|
axes of variation (narrow, wide, square, tall, small, long), not an
|
||||||
|
exhaustive or authoritative list. Callers can override it entirely:
|
||||||
|
|
||||||
|
- CLI: scripts/check_plugin.py --sizes 8x16,64x64,256x32
|
||||||
|
- pytest: LEDMATRIX_TEST_SIZES="8x16,64x64" env var (all plugins), or
|
||||||
|
per-plugin test/harness.json {"sizes": [[8, 16], [64, 64]]}
|
||||||
|
|
||||||
|
so anyone can point the harness at the exact panel(s) their build uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
# A spread of real panel-grid arrangements (each module is 64x32), not a list of
|
||||||
|
# "blessed" sizes. Each entry exercises a different layout assumption a plugin
|
||||||
|
# might accidentally bake in. Annotations are the panel grid (cols x rows).
|
||||||
|
DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
||||||
|
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||||
|
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||||
|
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
||||||
|
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||||
|
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||||
|
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||||
|
(256, 128), # 4x4 — large block, both dimensions big at once
|
||||||
|
]
|
||||||
|
|
||||||
|
# Backwards-compatible alias. Prefer DEFAULT_TEST_SIZES in new code — the old
|
||||||
|
# name implied these were the only valid panel sizes, which they are not.
|
||||||
|
SUPPORTED_SIZES = DEFAULT_TEST_SIZES
|
||||||
|
|
||||||
|
|
||||||
|
def size_label(width: int, height: int) -> str:
|
||||||
|
"""Human/path-friendly label for a size, e.g. '128x32'."""
|
||||||
|
return f"{width}x{height}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_token(token: str) -> Tuple[int, int]:
|
||||||
|
"""Parse a single 'WxH' token into an (int, int) pair.
|
||||||
|
|
||||||
|
Raises ValueError (with a user-friendly message) on malformed input so
|
||||||
|
callers can surface it however they like.
|
||||||
|
"""
|
||||||
|
cleaned = token.strip().lower()
|
||||||
|
if "x" not in cleaned:
|
||||||
|
raise ValueError(f"Invalid size '{token}' (expected WxH, e.g. 128x32)")
|
||||||
|
w, h = cleaned.split("x", 1)
|
||||||
|
try:
|
||||||
|
width, height = int(w), int(h)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid size '{token}' (expected numeric WxH, e.g. 128x32)"
|
||||||
|
) from exc
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid size '{token}' (width and height must be positive, e.g. 128x32)"
|
||||||
|
)
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_sizes(
|
||||||
|
value: Union[str, Iterable[Sequence[int]], None]
|
||||||
|
) -> Optional[List[Tuple[int, int]]]:
|
||||||
|
"""Normalize a size spec into a list of (w, h) tuples, or None if empty.
|
||||||
|
|
||||||
|
Accepts a comma-separated 'WxH,WxH' string (CLI / env var) or an iterable
|
||||||
|
of [w, h] / (w, h) pairs (harness.json). Returns None when value is falsy
|
||||||
|
so callers can fall back to the default sample.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [parse_size_token(tok) for tok in value.split(",") if tok.strip()]
|
||||||
|
sizes: List[Tuple[int, int]] = []
|
||||||
|
for pair in value:
|
||||||
|
w, h = pair # raises if not a 2-element sequence
|
||||||
|
width, height = int(w), int(h)
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError(f"Invalid size pair {pair!r} (width and height must be positive)")
|
||||||
|
sizes.append((width, height))
|
||||||
|
return sizes or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_test_sizes(
|
||||||
|
spec_sizes: Union[str, Iterable[Sequence[int]], None] = None,
|
||||||
|
) -> List[Tuple[int, int]]:
|
||||||
|
"""Decide which sizes to render, by precedence:
|
||||||
|
|
||||||
|
1. LEDMATRIX_TEST_SIZES env var — a global "test on my hardware" override
|
||||||
|
that wins for every plugin.
|
||||||
|
2. spec_sizes — e.g. a per-plugin harness.json "sizes" list.
|
||||||
|
3. DEFAULT_TEST_SIZES — the representative sample.
|
||||||
|
"""
|
||||||
|
env = coerce_sizes(os.environ.get("LEDMATRIX_TEST_SIZES"))
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
spec = coerce_sizes(spec_sizes)
|
||||||
|
if spec:
|
||||||
|
return spec
|
||||||
|
return list(DEFAULT_TEST_SIZES)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_mode_filename(mode: str) -> str:
|
||||||
|
"""A filesystem-safe basename for a plugin mode.
|
||||||
|
|
||||||
|
Mode names come from plugin metadata/render state, so a value containing
|
||||||
|
'/' or '..' could otherwise escape the intended output directory. Collapse
|
||||||
|
anything that isn't alphanumeric / dash / underscore to '_'.
|
||||||
|
"""
|
||||||
|
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in mode)
|
||||||
|
return cleaned or "mode"
|
||||||
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)
|
||||||
@@ -214,6 +214,104 @@ class TestDisplayControllerLivePriority:
|
|||||||
assert controller.current_mode_index == 1
|
assert controller.current_mode_index == 1
|
||||||
assert controller.current_display_mode == "b"
|
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:
|
class TestDisplayControllerDynamicDuration:
|
||||||
"""Test dynamic duration handling."""
|
"""Test dynamic duration handling."""
|
||||||
|
|||||||
@@ -43,6 +43,115 @@ class TestUninstallTombstone(unittest.TestCase):
|
|||||||
self.assertNotIn("foo", self.sm._uninstall_tombstones)
|
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):
|
class TestGitInfoCache(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = TemporaryDirectory()
|
self._tmp = TemporaryDirectory()
|
||||||
|
|||||||
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()
|
||||||
@@ -79,6 +79,21 @@ plugin_manager = PluginManager(
|
|||||||
cache_manager=None # Not needed for web interface
|
cache_manager=None # Not needed for web interface
|
||||||
)
|
)
|
||||||
plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir))
|
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()
|
saved_repositories_manager = SavedRepositoriesManager()
|
||||||
|
|
||||||
# Initialize schema manager
|
# Initialize schema manager
|
||||||
|
|||||||
@@ -1559,6 +1559,20 @@ def execute_system_action():
|
|||||||
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
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:
|
if result.stdout and "Already up to date" not in result.stdout:
|
||||||
pull_message = f"Code updated successfully.{stash_info}"
|
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:
|
else:
|
||||||
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
|
||||||
pull_message = "Update failed; check logs for details"
|
pull_message = "Update failed; check logs for details"
|
||||||
@@ -2933,6 +2947,13 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
|
|||||||
api_v3.schema_manager.invalidate_cache(plugin_id)
|
api_v3.schema_manager.invalidate_cache(plugin_id)
|
||||||
if api_v3.plugin_state_manager:
|
if api_v3.plugin_state_manager:
|
||||||
api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
|
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
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
@@ -3537,21 +3558,29 @@ def _get_schema_property(schema, key_path):
|
|||||||
|
|
||||||
parts = key_path.split('.')
|
parts = key_path.split('.')
|
||||||
current = schema['properties']
|
current = schema['properties']
|
||||||
|
i = 0
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
while i < len(parts):
|
||||||
if part not in current:
|
# Try progressively longer candidates, longest first, so schema keys that
|
||||||
return None
|
# themselves contain dots (e.g. league keys like "fifa.world") are matched
|
||||||
|
# instead of being mistaken for nested "fifa" -> "world" objects.
|
||||||
prop = current[part]
|
matched = False
|
||||||
|
for j in range(len(parts), i, -1):
|
||||||
# If this is the last part, return the property
|
candidate = '.'.join(parts[i:j])
|
||||||
if i == len(parts) - 1:
|
if isinstance(current, dict) and candidate in current:
|
||||||
return prop
|
prop = current[candidate]
|
||||||
|
# Consumed all remaining parts — this is the target property.
|
||||||
# If this is an object with properties, navigate deeper
|
if j == len(parts):
|
||||||
if isinstance(prop, dict) and 'properties' in prop:
|
return prop
|
||||||
current = prop['properties']
|
# Navigate deeper through an object with properties.
|
||||||
else:
|
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
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -3724,10 +3753,45 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
return value
|
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):
|
def _set_nested_value(config, key_path, value):
|
||||||
"""
|
"""
|
||||||
Set a value in a nested dict using dot notation path.
|
Set a value in a nested dict using dot notation path.
|
||||||
Handles existing nested dicts correctly by merging instead of replacing.
|
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:
|
Args:
|
||||||
config: The config dict to modify
|
config: The config dict to modify
|
||||||
@@ -3738,21 +3802,21 @@ def _set_nested_value(config, key_path, value):
|
|||||||
if value is _SKIP_FIELD:
|
if value is _SKIP_FIELD:
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = key_path.split('.')
|
segments = _resolve_key_segments(key_path, config)
|
||||||
current = config
|
current = config
|
||||||
|
|
||||||
# Navigate/create intermediate dicts
|
# Navigate/create intermediate dicts
|
||||||
for i, part in enumerate(parts[:-1]):
|
for seg in segments[:-1]:
|
||||||
if part not in current:
|
if seg not in current:
|
||||||
current[part] = {}
|
current[seg] = {}
|
||||||
elif not isinstance(current[part], dict):
|
elif not isinstance(current[seg], dict):
|
||||||
# If the existing value is not a dict, replace it with a dict
|
# If the existing value is not a dict, replace it with a dict
|
||||||
current[part] = {}
|
current[seg] = {}
|
||||||
current = current[part]
|
current = current[seg]
|
||||||
|
|
||||||
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
# 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:
|
if value is not None or segments[-1] not in current:
|
||||||
current[parts[-1]] = value
|
current[segments[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
name="chain_length"
|
name="chain_length"
|
||||||
value="{{ main_config.display.hardware.chain_length or 2 }}"
|
value="{{ main_config.display.hardware.chain_length or 2 }}"
|
||||||
min="1"
|
min="1"
|
||||||
max="8"
|
max="24"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p>
|
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user