* feat(display): add double-sided mode to mirror one screen across the panel chain
Renders a plugin once at a logical (per-screen) size, then tiles the
rendered frame across the full physical chain so two (or more) panels show
identical content. A 128x32 chain configured with 2 copies drives two 64x32
screens; vertical axis splits parallel outputs instead of the chain.
Plugins size themselves from matrix.width/height, so a thin _LogicalMatrix
proxy reports the logical size while delegating every real operation
(CreateFrameCanvas, SwapOnVSync, brightness, Clear) to the physical matrix —
no plugin changes required. Duplication is a single PIL paste per copy in
update_display(), so render cost is unchanged.
Config: display.double_sided { enabled, copies, axis }. Invalid config
(non-divisible dimension, bad axis/copies) logs a warning and falls back to
single-screen rather than failing to light up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(web): expose double-sided display config in the settings UI
Adds a Double-Sided Display section to the Display settings page (enabled
checkbox, copies, horizontal/vertical axis) and wires the save handler to
persist it under display.double_sided. Validates copies (2-8) and axis,
returning 400 on bad input; an omitted checkbox is saved as disabled.
Like the other hardware fields, changes take effect after a display restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(display): add type hints and docstrings to double-sided proxy
Addresses CodeRabbit nits: sort _LogicalMatrix.__slots__ (Ruff RUF023),
annotate the proxy's __init__/properties/dunders and _resolve_double_sided's
return type, and add docstrings to the property/dunder methods.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
_check_live_priority() was stateless first-match-wins: it returned the
first plugin in registration order with live content, and the post-dwell
hold pinned the carousel to it, so when two games were live at once (e.g.
a baseball game and a soccer match) the second never showed until the
first ended.
Add _collect_live_modes() (all currently-live modes, deduped, in
registration order) and give _check_live_priority an 'advance' flag. The
main rotation calls it with advance=True, which returns the live mode
after the one currently shown -- using current_display_mode as the cursor
-- so each dwell advances to the next live game and they take turns. The
Vegas coordinator and the vegas-active check keep the default
non-advancing peek (advance=False), so they only report whether any game
is live without spinning the cursor. should_rotate and _apply_live_priority
are unchanged; a single live game still holds as before.
Adds regression tests to TestDisplayControllerLivePriority.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(install): avoid apt-package uninstall failure on web deps
On a fresh Pi install, requests is installed via apt (python3-requests),
which ships no pip RECORD file. When pip later installs
google-api-python-client, its dependency tree pulls a newer requests and
attempts to uninstall the apt copy, failing with "uninstall-no-record-file"
and aborting the whole install at step 7 (web interface dependencies).
Add --ignore-installed to install_via_pip so pip lays the new version down
in /usr/local (shadowing the apt copy) instead of trying to remove an
apt-managed package. This resolves the failure for any transitive
dependency pip needs to upgrade over an apt-installed package, not just
requests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(install): also pass --ignore-installed for local rgbmatrix install
Keeps the rgbmatrix pip install consistent with install_via_pip so it
won't fail trying to uninstall an apt-managed dependency either.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The plugin config form posts form-data with dot-notation paths
(e.g. "leagues.fifa.world.enabled"). _get_schema_property and
_set_nested_value split those paths on every dot, so a schema key that
itself contains a dot (soccer league keys like "fifa.world", "eng.1")
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 saves silently dropped the change and produced a
byte-identical config.
Both helpers now greedily match the longest path segment that exists in
the schema (_get_schema_property) or the config being updated
(_set_nested_value), mirroring the frontend's dotted-key handling.
Adds regression tests covering schema lookup, value typing, and writes
under dotted league keys, plus a guard that plain nested paths still work.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugins): stop core updates from resurrecting uninstalled built-in plugins
Built-in plugins (e.g. web-ui-info, starlark-apps) are committed into the
repo under plugin-repos/. When a user uninstalls one, a subsequent core
`git pull` update restores the committed files, so the plugin reappears on
every update. The update endpoint stashes the deletion and never pops it,
and `git pull` faithfully restores any committed file whose deletion was
never committed — so excluding plugin-repos/ from the stash can't fix this
(it would only make `git pull --rebase` fail on a dirty tree).
Add a persistent uninstall registry (config/uninstalled_plugins.json,
gitignored) that survives restarts, unlike the existing in-memory tombstone:
- Uninstall records the plugin id; install clears it.
- purge_uninstalled_plugins() re-removes any recorded plugin whose directory
reappears on disk; called after a successful git-pull update and at web
startup (covers manual `git pull` on the Pi too).
- The state reconciler also refuses to auto-repair a persistently
uninstalled plugin.
Wires up mark_recently_uninstalled in the uninstall flow (previously only
referenced by tests) via the new persistent record.
Adds regression tests covering record/forget/purge lifecycle, persistence
across manager instances, and corrupt-registry tolerance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugins): validate uninstall-registry ids and lock registry writes
Address review feedback on the persistent uninstall registry:
- Critical: validate plugin ids on read/record and add a containment guard
in purge_uninstalled_plugins. A corrupt or hand-edited registry entry of
"" resolves to the plugins root, so purge could have deleted every plugin;
traversal ids ("..", "../x") could target paths outside the root. Invalid
ids are now dropped on read, refused on record, and never removed unless
the path is a direct child of the plugins directory.
- Major: guard record/forget read-modify-write with a lock so concurrent
install/uninstall requests can't lose updates.
- Minor: narrow the startup and post-update purge exception handlers from
bare Exception to (OSError, RuntimeError).
Adds regression tests for empty-id, traversal-id, and invalid-record cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
check_one() always compares the render against committed golden images, but
the CLI never loaded the plugin's test/harness.json — so the deterministic
settings the goldens were generated with (config, mock data, frozen time,
sizes) weren't applied. For any time/data-dependent plugin this means the CLI
(and the plugins-repo CI workflow that calls it) renders live data and the
golden drifts on every run, even with no real regression. The pytest matrix
path already reads harness.json via load_harness_spec; the CLI now does too.
- check_one loads load_harness_spec(plugin_dir) and layers it under explicit
CLI flags: config = schema defaults < harness.json < --config; sizes =
--sizes > LEDMATRIX_TEST_SIZES env > harness.json > default sample;
mock_data/freeze_time/skip_update fall back to harness.json when not given
on the CLI.
- parse_sizes returns None (not DEFAULT_TEST_SIZES) when --sizes is omitted,
so the env/harness.json/default fallback chain in resolve_test_sizes applies.
- Regression tests: harness.json supplies render settings, and CLI flags
override it. Use a temp fixture plugin so they run in core CI (no plugins).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
requirements.txt already pins pytest>=9.0.3,<10 (from #331), but
requirements-test.txt re-pinned pytest>=7.4,<9. The two ranges are
disjoint, so `pip install -r requirements.txt -r requirements-test.txt`
fails with ResolutionImpossible — breaking the core test workflow and the
plugins-repo safety workflow that installs both files.
pytest, pytest-cov, pytest-mock, and jsonschema are all already pinned
with major-version caps in requirements.txt, so drop them from
requirements-test.txt and keep only freezegun (the one test dep
requirements.txt doesn't provide).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(testing): add cross-size/cross-screen plugin safety harness
Render every plugin across all supported matrix sizes (64x32, 128x32,
128x64, 256x32) and every declared screen, failing on crashes, content
drawn past the panel edge, or visual drift vs committed golden images.
- BoundsCheckingDisplayManager: oversized-canvas overflow detection
- harness.py: multi-size/multi-screen render engine + golden compare
- scripts/check_plugin.py: CLI (functional+bounds, --out-dir, --update-golden,
--freeze-time); render_plugin.py refactored onto shared loading helpers
- test/plugins/test_harness.py + test_plugin_matrix.py (parametrized,
honors per-plugin test/harness.json; skips when no plugins present)
- MockCacheManager.cache_dir so cache-dir-using plugins load headlessly
- .github/workflows/test.yml + docs/plugin-safety-harness.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(testing): address PR review feedback on plugin safety harness
- check_plugin: friendly error for non-numeric --sizes; reject non-object
--config / --mock-data JSON; sanitize plugin mode before using as a
filename; stop --update-golden from masking crash/overflow failures
- bounds_display_manager: pad the canvas out to the largest supported panel
(not a fixed 16px) so far-overshoot coordinates are caught, not clipped
- harness: merge config_schema defaults inside render_plugin_matrix; surface
update() failures as a non-fatal warning + result field instead of a debug
log; sanitize mode in golden_path
- loading: fail fast when harness.json references a missing mock_data fixture
- mocks: clean up the per-instance temp cache dir via weakref.finalize
- test_plugin_matrix: add a discovery guard that fails when
LEDMATRIX_REQUIRE_PLUGINS=1 but none found (still skips locally); type hints
- bound test deps with upper version pins for deterministic CI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(testing): render plugins across arbitrary panel sizes, not a fixed list
Addresses maintainer feedback that there is no canonical set of supported
panel sizes — a build can be any size/configuration (square, 2x2, 4x4, 8x2,
long strips, tall stacks).
- sizes.py: SUPPORTED_SIZES -> DEFAULT_TEST_SIZES (back-compat alias kept),
reframed as a representative SAMPLE of real panel-grid arrangements rather
than an authoritative list; add parse_size_token / coerce_sizes /
resolve_test_sizes helpers
- sizes are now fully overridable: LEDMATRIX_TEST_SIZES env (global, e.g. test
on your exact hardware) > per-plugin harness.json "sizes" > default sample;
CLI --sizes unchanged
- bounds_display_manager: pad the canvas to the largest panel IN THE CURRENT
RUN (via overflow_extent) instead of a hardcoded max, so cross-size overflow
detection scales to whatever sizes a run uses
- harness: compute per-run extent and thread it into the bounds manager
- tests: arbitrary-shape + size-parsing/precedence coverage
- docs: rewrite "Supported sizes" -> "Sizes: a sample, not a fixed list"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(testing): fail the harness on non-connectivity update() errors
Addresses the remaining review thread: recording every update() exception as a
non-fatal warning still let a real update() regression pass green as long as
display() survived. Now update() failures are classified — a tolerated set of
connectivity errors (ConnectionError/TimeoutError/socket/ssl/urllib/http/
requests) is recorded non-fatally (expected with no network in CI), while any
other exception is treated as a genuine bug and fails that render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci(security): pin actions to SHAs and disable checkout credential persistence
Addresses the CodeRabbit/zizmor workflow-hardening finding: pin
actions/checkout and actions/setup-python to full commit SHAs and set
persist-credentials: false on checkout to reduce supply-chain and
token-exposure risk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(testing): validate positive sizes; narrow requests import except
Two review findings:
- sizes.py: parse_size_token / coerce_sizes now reject non-positive
dimensions (0x32, -64x32) with a clear message instead of passing invalid
sizes downstream (CodeRabbit).
- harness.py: the optional `requests` import now catches ImportError
specifically and logs instead of `except Exception: pass`, clearing the
Codacy medium "Try, Except, Pass" (harness.py L52) and Ruff S110/BLE001.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The web interface runs headless, so every privileged systemctl call must be
covered by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web. The sudo command
matches the command line exactly, but the code called 'systemctl start
ledmatrix' while configure_web_sudo.sh grants 'systemctl start
ledmatrix.service'. The rule never matched, so start/stop/enable/disable/
restart fell back to a password prompt and failed with 'a terminal is
required to read the password'.
Align all privileged systemctl calls on the fully-qualified unit names the
sudoers grants use. Add a regression test that cross-checks api_v3.py calls
against the grants in configure_web_sudo.sh.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
When a live-priority plugin (e.g. live sports, flights overhead) preempted
the rotation, the controller overwrote current_mode_index with the live
plugin's index. Once live priority ended, rotation continued from after the
live plugin's mode, skipping every mode between the interrupted position and
the live plugin. With a live plugin late in the order, modes just before it
were starved indefinitely.
Save the rotation position on the initial live-priority switch and restore it
when live priority ends, in a new _apply_live_priority() helper. Add
regression tests covering resume, no-double-save during the hold, and the
idle no-op.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>