* perf(timing): say which render-thread work a late frame followed
The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.
- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
--patch-every / --patch-where (in-place column writes, as a live element
update does) and --extend-every-screens / --extend-width (append + trim on
a fixed cadence that holds the strip's width).
No runtime behaviour changes: this is the measurement gate for live Vegas
elements.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): note the frame-op attribution and bench modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(scroll): build the strip's PIL image only when something reads it
Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.
cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.
In Vegas the image is now built only by a multi-display sync push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(vegas): live elements -- a plugin API for content that changes while it scrolls
Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.
Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
base_plugin).
Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
background fetch only (under its lock, on its own canvas); every other
path keeps get_vegas_content(). Live elements are pinned (padded with
content_padding, never trimmed), tagged with their key, digest and data
epoch in Image.info so the existing cache and group plumbing carry them
unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
absolute strip columns a trim does not move; the block-start arithmetic
is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
notify_data_changed): told the moment update() completes, not at the
next ~4s Vegas poll. The coordinator uses one to move each plugin's data
epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
live_lead_screens; per-plugin core-owned vegas_live. Live elements are
off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
(src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
is a working example.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(timing): say which render-thread work a late frame followed
The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.
- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
--patch-every / --patch-where (in-place column writes, as a live element
update does) and --extend-every-screens / --extend-width (append + trim on
a fixed cadence that holds the strip's width).
No runtime behaviour changes: this is the measurement gate for live Vegas
elements.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): note the frame-op attribution and bench modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(scroll): build the strip's PIL image only when something reads it
Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.
cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.
In Vegas the image is now built only by a multi-display sync push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(timing): say which render-thread work a late frame followed
The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.
- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
--patch-every / --patch-where (in-place column writes, as a live element
update does) and --extend-every-screens / --extend-width (append + trim on
a fixed cadence that holds the strip's width).
No runtime behaviour changes: this is the measurement gate for live Vegas
elements.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): note the frame-op attribution and bench modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Adds auto_update.channel: stable follows the newest vX.Y.Z release tag
(detached HEAD; pre-releases and other tags ignored), beta follows main as
before. Nothing ever moves a device backwards: a checkout newer than the
newest release keeps following main (or stays put when detached) until a
release contains its commit. Legacy configs migrate to stable when they
reach a release. Update Code, the weekly updater's preflight, and the
verifier's rollback (back to old_ref: branch or detached release) all
honour the channel. General tab Update Channel select, GET/POST
/api/v3/system/update-channel, release-aware Overview banner and Tools git
panel. New installs default to stable.
Rig fix (ledpi): /system/check-update reports update_available: false when
the channel's action is none (a detached HEAD newer than the newest
release), matching Update Code; the Tools panel no longer calls every
detached HEAD "a release".
Merged with main through #687 (heartbeat verifier, #683 login, #688
plugin_catalog, #685 Tailwind build).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If the render loop gets stuck inside a plugin's display(), ledmatrix.service
stays active and the panel stays frozen. This adds a way to detect that.
- src/display_watchdog.py (standard library only) sends sd_notify over
$NOTIFY_SOCKET and writes /run/ledmatrix/display-heartbeat.json. Only the
render thread counts: beats from other threads are ignored.
- ledmatrix.service: WatchdogSec=120, NotifyAccess=main,
RuntimeDirectory=ledmatrix (0755), RestartSteps=4 and
RestartMaxDelaySec=2min. It stays Type=simple. run.py widens the watchdog
to 15 min for start-up, and load_plugin() does the same on the render
thread. The loop arms after its first frame.
- /api/v3/health adds checks.display_loop: running, stalled (no heartbeat
for over 60s, which makes the status degraded) or not_reported. With web
login on, a caller who is not logged in still gets only healthy/degraded,
and a stall degrades that answer.
- The update verifier requires a fresh heartbeat from the restarted display
when the display it replaced was writing one. A frozen panel is rolled
back.
- Existing installs get the systemd watchdog only after install_service.sh
is re-run. The heartbeat works right away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage 2 of the web plugin catalog, after #688.
- The display publishes a plugin runtime snapshot (plugin_runtime.py) to
the shared cache: per plugin loaded, lifecycle state, a short redacted
error summary, the version it loaded and when, plus published_at /
stale_after / running. Written on change (throttled to 10 s; the
RUNNING/ENABLED flip of an ordinary update is not a change) and once a
minute otherwise; cleanup() publishes running: false.
- The web reads it back and restores loaded / state / error_info in
/api/v3/plugins/installed (plus loaded_version, loaded_at and
data.runtime). Only a live snapshot counts; stale, stopped or missing
answers null and says which.
- data/plugin_state.json is retired: every reader and writer moved to
config + disk (desired) or the snapshot (observed). Nothing in it was
non-derivable, so nothing is migrated and an existing file is left
unread. The web-side PluginStateManager (state_manager.py) is removed;
the display's plugin_state.PluginStateManager is the only state machine.
- StateReconciliation compares config + disk with the snapshot, reporting
enabled-but-not-loaded and older-version-loaded as no_action findings.
- Backups list installed manifests with enabled from config.json.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.
- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
directories, display modes, installed version, schema and config reads,
with no way to run a plugin. app.py and both blueprints use it; the
plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
reach the display through ConfigService (on_config_change) and the
enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
resource_monitor. /plugins/installed reports loaded/state/error_info as
null (the display does not publish them) and enabled by the display's
rule.
- Store install, update and uninstall answer restart_required when the
running display will not pick the change up by itself
(display_restart_required). The restart banner follows the flag via
window.noteRestartRequired instead of the /config/main URL heuristic;
/config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
modules, oauth_flow action scripts) goes through
_import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
user's setting or the manifest, with vegas_participation_source; when
only the plugin's code decides it, null with source 'runtime', since the
web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
fails, and asks for a restart when an enabled plugin's first request got
no answer and the re-sent one found it up to date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the hand-written Tailwind subset in app.css with a real, purged
Tailwind build: scripts/build_css.py runs the pinned, SHA-256-checked
standalone Tailwind CLI (no Node), the generated tailwind.css and
plugin-frame.css are committed, and CI fails when they are stale. The Pi
never builds anything. The login page (#683) now links tailwind.css too,
and the load-order test covers every template that links app.css.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store reads three optional registry fields: ledmatrix_min_version
(an incompatible install/update is refused before any download, with a
"Needs LEDMatrix X+" card badge), aliases (update/uninstall/reinstall by
registry id find a plugin installed under its manifest id, with registry
proof only), and commit (shown and linked on the store card). An older
plugins.json behaves as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plugin takes part in Vegas mode in one declared way: 'scroll', 'pause'
or 'exclude', resolved from the user's vegas_participation setting, the
manifest field, then the legacy hooks, so no plugin changes behaviour.
The stream manager decides inclusion and pauses through it; the installed
plugins API and the Vegas plugin-order list report it. Deprecates
get_supported_vegas_modes, get_vegas_segment_width and vegas_panel_count
for removal in 3.9.0, and regenerates docs/DEPRECATIONS_3.8.md to include
them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ESPN sends the break between rounds as STATUS_END_OF_ROUND with
displayClock "-", not "0:00", so the shared game-over rule never
drops a five-round fight at the round 4 break. Verified against
recorded payloads in ChuckBuilds/ledmatrix-plugins#580, which pins it.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
app.py called limiter.limit("200 per minute")(stream_x) after the routes
were registered and discarded the result. flask-limiter 3.x enforces a
decorated limit in the wrapper limit() returns, and marks the original
function so the before_request middleware skips it, so the streams had
no limit at all -- not even the 1000/min default. Register the wrapper
as the view instead.
The new test (skipped without flask-limiter) reconnects to each stream
201 times and expects the last to get a 429; it fails on the old code.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Moves the 35 @deprecated markers from 3.7.0 (already shipped with them in
place) to 3.8.0, and adds scripts/plugin_api_usage.py plus the generated
docs/DEPRECATIONS_3.8.md: who still calls or overrides each deprecated
method across core, the monorepo and third-party plugins. Removes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rewrites the roadmap in docs/SPORTS_UNIFICATION.md for the
reconcile-then-promote decision (stages 0-3 recorded as done), and adds the
sports drift report script with a report-only CI job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The update worker no longer blocks forever on a plugin whose display()
never returns. It waits at most PLUGIN_LOCK_TIMEOUT (5s) for a plugin's
lock, then skips that plugin's update (a report-only "busy skip" in
health) and keeps updating every other plugin. display() frames are timed
(slow calls logged and counted; calls past the executor timeout recorded as
hangs), a hung update() is recorded, and on_config_change() now runs under
the plugin lock or is deferred to the worker. The plugin-facing API is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Optional web login, off by default: a device that sets no password behaves
exactly as before. Set under General > Security; then every page and API
route needs a session login or an API token (Authorization: Bearer).
Loopback, the Wi-Fi setup flow in AP mode, static files, captive-portal
probes and a reduced /api/v3/health stay open. Secrets live in the web_auth
section of config_secrets.json and no API returns them.
scripts/reset_web_password.py turns login off. Stacked on #674.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web interface refuses state-changing requests (POST/PUT/PATCH/DELETE)
whose Origin (or, without one, Referer) is not the host they were sent to,
or is null: 403 CROSS_SITE_REQUEST (web_interface/origin_guard.py). Any
website a LAN user visited could otherwise make their browser POST a plain
form to the Pi. /api/v3/system/action also refuses form-encoded and
text/plain bodies (415) unless sent by HTMX. Clients that send no Origin or
Referer (curl, requests, Home Assistant, the MQTT bridge) are unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): on-demand no longer restarts a running display service
POST /display/on-demand/start treated start_service (default true, sent by
"Preview on display", the on-demand dialog and the MQTT bridge) as
"restart": with the service running it ran systemctl stop, slept 1.5s and
started it again. Every request cold-started the display process -- every
plugin reloaded, panel blank -- to deliver a request the running process
already reads from the cache mailbox every ON_DEMAND_POLL_INTERVAL (0.25s),
including mid-dwell, mid-screen and mid-Vegas. The restart bought nothing:
startup only restores a session the display saved itself
(display_on_demand_config), so the new request arrived through the same
mailbox either way.
start_service now means "start it if it is not running". The stop route
coerces stop_service to a boolean so "false" no longer stops the service.
test_api_v3_on_demand_restart.py pinned the old restart path; it now pins
the replacement. Docs updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): on-demand loads a disabled plugin live instead of failing
The display process only loads enabled plugins, so an on-demand request for
a disabled one -- "Preview on display" offers it on every config page, with a
note that the plugin will be enabled for the preview -- failed with
invalid-mode. Nothing enabled it short of a restart, and the on-demand route
no longer restarts the service.
_activate_on_demand now loads an installed-but-not-running plugin through
the live-enable path (load_plugin + _register_loaded_plugin), with a new
load_plugin(force_enabled=True) so the instance runs enabled while
config.json keeps saying disabled. The plugin is tracked in
_on_demand_loaded_plugins, and the main loop unloads it through
_unregister_plugin once on-demand moves off it (stop, expiry, another
request, or a failed request that ends the session) -- right after its own
poll, where no display() is on the stack. A failed load publishes status
error with load-failed. A plugin enabled during the session stays loaded.
A session restored after a restart uses the same tracking instead of
setting enabled in the config dict config_manager caches, so its plugin is
unloaded when the session ends rather than staying loaded until the next
restart. Ending a session no longer resumes the rotation onto a plugin that
is about to be unloaded, which a restored session did.
Also: a stop sent while on-demand is inactive clears a failed request's
error, instead of /display/on-demand/status reporting status: error until
the state aged out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Any test that imported web_interface.app and sent a request fired the app's
startup reconciliation, which runs against the checkout's real config.json
and plugin-repos/ and reinstalls every configured-but-missing plugin from the
live store. A full Windows run left basketball-scoreboard, calendar,
football-scoreboard, leaderboard and ledmatrix-stocks untracked in
plugin-repos/ (not gitignored) from that daemon thread.
test/conftest.py now installs an import hook that sets the app's run-once
_reconciliation_started latch as the module finishes executing, so lazy
imports, module-level imports and reloads all start disarmed.
StateReconciliation's own tests are unaffected. A regression test pins that
a request to the imported app launches no reconciliation thread.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
POST /display/on-demand/start treated start_service (default true, sent by
"Preview on display", the on-demand dialog and the MQTT bridge) as
"restart": with the service running it ran systemctl stop, slept 1.5s and
started it again. Every request cold-started the display process -- every
plugin reloaded, panel blank -- to deliver a request the running process
already reads from the cache mailbox every ON_DEMAND_POLL_INTERVAL (0.25s),
including mid-dwell, mid-screen and mid-Vegas. The restart bought nothing:
startup only restores a session the display saved itself
(display_on_demand_config), so the new request arrived through the same
mailbox either way.
start_service now means "start it if it is not running". The stop route
coerces stop_service to a boolean so "false" no longer stops the service.
test_api_v3_on_demand_restart.py pinned the old restart path; it now pins
the replacement. Docs updated.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps src.__version__ to 3.7.0 and turns Unreleased (#672: sports_celebration,
sports_fetch and sports_card_wrappers) into ## 3.7.0; src/common/README.md and
docs/SPORTS_UNIFICATION.md say 3.7.0 for the three modules.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Three new hardware-free modules holding code the scoreboard plugins carry
as identical copies (executable AST, docstrings stripped, checked across
every carrying plugin at ledmatrix-plugins 30455671). The bodies are the
plugins'; the changes are type annotations for the mypy ratchet, the
colour helpers losing their leading underscore as public free functions,
and two comments that described the plugins' files.
- src/common/sports_celebration.py: SportsCelebrationMixin, the score/win
takeover drawn by afl, football, hockey, nrl and soccer
(_draw_celebration_layout and the palette, backdrop, scenery, confetti,
crest and _fit_font steps, with their class constants), plus the colour
helpers (logo_palette, lift_color, cap_luminance, mix_color, ...). Only
the drawing: _start_celebration, _check_for_goal/_check_for_score,
_check_for_win and display() differ between the plugins and stay there.
- src/common/sports_fetch.py: SportsFetchMixin, the four SportsCore methods
identical in all nine scoreboards: _fetch_season_directly,
_background_fetches_espn_ranges, _needs_previous_day and
_wants_live_odds, with _LOOKBACK_CUTOFF_HOUR and _LIVE_ODDS_LOOKAHEAD.
_get_timezone, _extract_game_details and _fetch_data are as identical
and stay behind, for the reasons sports_shared gives (a per-plugin
import; the abstract contract); so does SportsUpcoming.__init__, since
no src/common mixin has a constructor.
- src/common/sports_card_wrappers.py: SportsCardWrappersMixin, the
seventeen sports_card delegations the eight game renderers carry (15 in
all eight, 2 in all but football, whose own versions override them).
_schema_font_size/_resolve_font_size look identical but read each
plugin's own _SCHEMA_PATH, so they stay.
Each mixin has no __init__ and creates no attributes (the host contract is
declared as annotations only), defines no name the mixins beside it
define, and documents the attributes it reads; a host-contract test
parses each and fails on an undocumented read. A method kept on a
plugin's class wins over the mixin's.
Tests: behaviour ported from the plugins' celebration, odds, lookback and
date-range tests against stub hosts carrying exactly the contract, with
crests drawn by the test (test_sports_celebration.py, test_sports_fetch.py,
test_sports_card_wrappers.py), and test_sports_stage3_parity.py, which with
LEDMATRIX_PLUGINS set compares every body with every plugin copy that is
left (58 pass against the plugins today; a copy that is gone counts as
adopted). All three modules are on the mypy ratchet, in
src/common/README.md, the CHANGELOG's Unreleased section and
SPORTS_UNIFICATION's module table. Nothing in core uses them yet.
Full suite: the same 67 failing test ids as main (Windows-only), 77 more
passing.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps src.__version__ to 3.6.2 and turns Unreleased (#670, the favourite
check's false "season has finished" for list-calendar competitions between
rounds) into ## 3.6.2.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-09-29 ESPN's uefa.europa scoreboard still showed the 17 September
matchday, so every event was past. Its calendar is a "list" of rounds
(League Phase to 30 Jan 2027, then the knockout rounds to the final), not
a match-day whitelist, and the league's season type is a soccer id rather
than 2/3, so neither 3.6.1 rule applied and the check said the season had
finished.
When every event is past, a round in a list calendar that has not started
yet now draws no conclusion. Only a round's start date counts: end dates
are padded past the last game (AFL's Grand Final round still had a day to
run three days after the Grand Final), and rounds in an offseason phase
(college football's All-Star week) are skipped. Season end dates are still
ignored, so PLL (season to 2027-01-01) stays "finished", as do the World
Cup and AFL. Of 28 live ESPN scoreboards only uefa.europa's message changes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps src.__version__ to 3.6.1 and records #667 (the favourite check's false
"season has finished") under ## 3.6.1; #667 had no CHANGELOG entry. Plugins
that drop their bundled favourite-check copy floor on 3.6.1.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(common): favourite check no longer calls a started postseason a finished season
The day after a regular season ends, ESPN's default scoreboard still
returns that last regular-season day, while leagues[0].season has moved
to Postseason. All events were in the past, so the check logged "the
season has finished" for MLB on 2026-09-29 while the upcoming manager in
the same process was showing TB's wild-card games.
When every event is past and the league is in a later in-season phase
(regular season or postseason) than all of the returned events, draw no
conclusion. The offseason is excluded, so a genuinely finished season is
still reported as finished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(common): favourite check reports the next matchday between soccer rounds
Between matchdays ESPN's soccer scoreboard keeps showing the last one, so
every event is in the past and in the league's current phase, which the
postseason rule does not cover; the check said the Premier League season
had finished on 2026-09-29 (last games 20 September, next 10 October).
When the league calendar is a "day" whitelist, its entries are days with
games, so a future one is used as the next fixture. MLB's day calendar is
a blacklist and is not read that way; PLL's whitelist has no future days
and is still reported as finished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Turns the CHANGELOG's Unreleased section into ## 3.6.0 and bumps
src.__version__, the value plugin ledmatrix_min_version floors compare
against. 3.6.0 ships the two modules from #665 (favorite_team_check,
sports_timezone); nothing else has changed since 3.5.0. src/common/README.md
and docs/SPORTS_UNIFICATION.md say 3.6.0 for them instead of Unreleased.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2)
Two new hardware-free modules, taken from files the scoreboard plugins carry
as copies:
- src/common/favorite_team_check.py: FavoriteTeamCheck(logger, leagues), the
seven byte-identical <sport>_favorite_check.py copies. Same code; the only
additions are two type annotations (for the mypy ratchet).
- src/common/sports_timezone.py: resolve_timezone_name(), resolve_timezone(),
system_timezone_name(), from the ten <sport>_timezone.py copies. They
differed only in the plugin label named in the nothing-resolved warning and
the write-back-bug values, which become keyword-only arguments
(plugin_label, writeback_fixed_in). Same resolution order and log text.
Tests are ported from the plugins' own (test_favorite_check.py,
test_schedule_note_uses_game_dates.py, test_timezone_resolution.py; the
timezone ones run once per plugin's values and pin the exact warning text).
Both modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's
Unreleased section and SPORTS_UNIFICATION's module table. Nothing in core uses
them yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(common): bdf_font and json_body shipped in 3.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(common): annotate the favourite check's deliberate except/pass for Bandit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): fold Unreleased into 3.5.0 for the release
Every Unreleased entry (#605-#663) moves into the 3.5.0 section, grouped
with the existing 3.5.0 areas; new groups for Display and Vegas, Plugin
error reporting, Wi-Fi, Fonts and Removed. "## Unreleased" stays as an
empty heading.
Module list: add src/common/json_body.py (espn_dates imports it with a
fallback) and src/common/bdf_font.py; list the other modules new since
v3.4.0 as core-internal; add the new names in existing modules
(handles_espn_date_ranges, register_plugin_fonts(plugin_dir),
forget_manager_fonts). Record the src.common and plugin_system modules
#608 deleted.
Add entries for merged PRs that had none: #604, #605, #606, #607, #608,
#609, #613, #616, #618, #622, #625, #628, #630, #633. Note that three
scripts named in older 3.5.0 entries were later deleted by #607.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): list the hardware-free test under developer tools, not plugin modules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Plugins import their own files by bare name (`from sports import ...`),
which resolves to the first directory on sys.path that has the file. The
loader added a plugin's directory only if it was missing, so on a reload --
a live re-enable from the web UI -- the plugin's directory stayed behind
every plugin loaded since, and its bare imports found their files first.
Seen on ledpi: re-enabling UFC with hockey running failed with "cannot
import name '_status_is_final' from 'sports'" (it got hockey's sports.py).
A loading plugin's directory is now always moved to the front. Every
scoreboard ships its own sports.py, so any of them was exposed on reload.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* ci: mypy ratchet -- keep type-clean modules clean
mypy-clean.txt lists the 71 modules under src/ that type-check clean;
scripts/check_types.py runs mypy (--follow-imports=silent) on exactly
those files and fails on any error or a missing/unsorted/duplicate entry.
A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and
pinned stubs; the manual pre-commit mypy hook now runs the same script
(a local hook, so mypy sees the installed requirements like CI does).
35 modules were made clean with annotation-only fixes: hints, typing.cast,
TYPE_CHECKING imports, implicit-Optional defaults made explicit, and
annotations widened (never guards removed) where mypy called a defensive
isinstance check unreachable. No runtime behaviour change.
mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also
for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't
parse at python_version 3.10, and orjson is optional, so seeing its stubs
made the result depend on whether it was installed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: annotate check_types.py's list-form mypy subprocess
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A new "Web UI JS tests" job installs jsdom, starts the web interface in
emulator mode and runs test/js/run_all.js with REQUIRE_DOM=1, which makes a
DOM suite that can't run a failure rather than a silent skip. (The unit
suites were already covered through pytest.)
Two suites failed against main when run for real:
- test_tools_sections rendered the Tools partial without LEDEscape, which
base.html's app-early.js defines; it now installs it in beforeParse, and
supplies two sample Starlark apps (one id with a quote) when the server
has none, instead of assuming a device with apps and Pixlet.
- test_store_dom assumed the live registry had at most 48 plugins; it now
checks pagination whichever side of 48 it is.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(plugins): split PluginStoreManager into mixins
src/plugin_system/store_manager.py (2,977 lines) keeps the class, its
shared state, locks, the uninstall registry, directory lookup and
uninstall; its methods are split by area into:
- store_registry.py (_RegistryMixin): registry, GitHub metadata, search,
manifest validation
- store_install.py (_InstallMixin): install paths and dependencies
- store_update.py (_UpdateMixin): updates, rollback, local git state
Pure move: all 56 members are byte-identical (checked with ast) and the
assembled class has exactly the same attributes as before (checked at
runtime). PluginStoreManager is imported from store_manager.py as before.
Tests that patched shared modules (subprocess, requests, tempfile, shutil)
through store_manager now reach them through the module whose code they
exercise; a source-text contract test reads all store_*.py modules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: annotate findings the split moved into new store modules
subprocess imports and a list-form git clone (no shell), and the config
template's placeholder token string -- existing code that Codacy reported
as new because it moved. Annotated with the repo's nosec/nosemgrep style.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: annotate the default-branch git clone the split moved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A game ESPN had no odds for is cached as {"no_odds": True}, so it isn't
re-requested on every update. On the next update get_odds() returned that
marker from the cache as if it were odds: a truthy dict that callers took
to mean the game had some. It's still a cache hit (its ttl decides when to
ask again), but get_odds() now returns None for it -- on the cache hit and
in the stale-cache fallback after a failed fetch -- as the plugins' bundled
copies already did.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): split api_v3/plugins.py by area
web_interface/blueprints/api_v3/plugins.py (3,285 lines) becomes:
- plugins.py: installed list, enable/disable, plugin actions
- plugin_store.py: install, update, uninstall, store, saved repositories
- plugin_config.py: config get/save, schema, reset
- plugin_assets.py: asset uploads and plugin static files
- plugin_health.py: health, metrics, limits
- plugin_operations.py: operation history, state reconciliation
- plugin_calendar.py: calendar credentials and auth
Pure move: all 44 functions and 38 route decorators are byte-identical
(checked with ast), URLs and endpoint names are unchanged (url-map test).
Each module imports only what it uses. Tests and config.py that reached
into plugins.py for moved names now import from the new module.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep exception text out of calendar responses; annotate moved code
The split made scanners report existing findings in the moved code as new:
- CodeQL: the calendar auth and calendar-list routes returned exception
text (redacted, but still derived from the exception). Both now log the
exception and return a fixed message pointing at the log.
- MD5 in the asset upload only makes a filename unique: usedforsecurity=False.
- pickle reads/writes the calendar plugin's own OAuth token (as before):
annotated. Token-status labels and a log line naming the secrets path are
false positives: annotated with the repo's nosec/nosemgrep convention.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): name uploaded assets with SHA-256 instead of MD5
The hash only makes an uploaded image's filename unique. Codacy flags MD5
even with usedforsecurity=False, and SHA-256 does the job as well; existing
files keep their names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep the redacted exception detail in calendar errors
Reverts the calendar part of 5e695b7c. The project's policy
(test_no_api_v3_handler_discards_its_exception) is that an API error
carries the redacted exception detail -- describe_exception runs it
through the credential redactor -- so a failure is diagnosable from the web
UI. Dropping it for CodeQL broke that; CodeQL can't see the redaction, so
its two alerts here are false positives, like the existing ones on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- BackgroundDataService: the session adapter retried connection errors 3x
inside each attempt of the service's own retry loop (up to 16 connection
attempts per request on a dead network). The adapter no longer retries;
ESPN date chunks, which bypass the loop and skip a failed chunk, get a
small connection retry of their own (_ConnectionRetryingSession).
- CI installs web_interface/requirements.txt. The brotli header test now
checks its intent (core never hand-sets br; requests may advertise it when
a decoder is installed) instead of failing whenever brotli is present.
- Every Discord link uses the LEDMatrix server's invite (RdrC37rEag).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety
- Route plugin lookups (installed list, update, recorded version, config
form, web UI pages) through the plugin manager's resolver so plugins in
ledmatrix-<id> directories work.
- Captive-portal detection also sees the nmcli fallback AP (cached).
- WiFi monitor daemon re-reads wifi_config.json when its mtime changes.
- Drop the AP check in disconnect_from_network that could never fire.
- LED status file per WiFiManager; config path falls back to this checkout.
- BDF font preview via src.common.bdf_font.
- Asset uploads validate every file before saving; metadata and calendar
credentials written atomically; no absolute path in the response;
asset delete answers 400 for a missing body.
- Coerce string booleans in plugin toggle, on-demand start and AP force.
- SSE broadcaster clears its thread handle before exiting.
- start.py log filter handles every exc_info form.
- Cleanups: unused plugins/fonts partial work, duplicate backup catch-alls,
raw-config error helper, update-route tidy, redundant imports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): request BDF font previews now that the server renders them
The Fonts tab skipped the preview request for .bdf files because the server
used to refuse them; /fonts/preview now draws BDF with the shared loader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): take the update route's plugin directory from a directory listing
CodeQL flagged the path built from the request's plugin_id (the id was
already validated with safe_path_component, which CodeQL doesn't model; the
same flow on main is alerts 738/739). The directory is now the entry of
plugins_dir matched by name, so nothing built from user input reaches the
filesystem; an id with nothing installed goes to the store manager, which
reports it not found as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): read the blueprint's plugin_manager defensively in _plugin_directory
_get_plugin_version now goes through _plugin_directory, which read
api_v3.plugin_manager directly; the attribute exists only once the app sets
it, so test_path_traversal_guards::test_a_real_manifest_is_read failed
when run on its own (order-dependent in the full suite).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(plugin-system): unload/update race, failed-load module cleanup, limits validation, schema lookup, install rollback, op-queue dedupe
- unload_plugin takes the per-plugin lock (5s bounded) before cleanup(),
and an update() that finishes after its plugin was unloaded no longer
sets the state back to ENABLED.
- A load that fails after import drops plugin_<id> and its submodules
and forgets its manager fonts, so a fixed plugin reloads new code.
- Resource limits are validated as non-negative numbers: 400 at
POST /plugins/limits, bad cached records ignored with one warning.
Route docstrings note health/metrics reset and limits only change the
web process's view.
- SchemaManager.get_schema_path resolves each search dir via
resolve_plugin_dir (manifest id, ledmatrix-<id>) before the literal
paths; plugins/ still before plugin-repos/. Misses cached 30s and
logged once at DEBUG.
- install_from_url sets an existing copy aside and restores it if the
move fails, under the per-plugin reinstall lock.
- Operation queue refuses a second pending op for a plugin and trims
_operations with history.
- get_vegas_render_width reads display_manager.width first.
- get_logger in store/schema/health/resource/saved_repositories;
UTF-8 reads in store_manager and state_manager.
- Docs: update_interval precedence (manifest over config) stated where
users are told to set it in config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): build the limits 400 message from the field name, not an exception
CodeQL flagged str(e) flowing into the response. invalid_limit_field()
returns the offending field without raising, and limits_from_dict uses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Plugin-supplied widgets load as /static/plugin-widgets/...js?v=<plugin
version>, so an update isn't hidden behind the year-long immutable cache.
- Fire-and-forget loadInstalledPlugins() calls catch the rejection it has
already reported, so the global handler no longer adds a second toast.
- Timezone picker renders again when the General partial is re-injected.
- Remove dead code: executePluginAction's six plugin-id fallbacks and
[DEBUG] logging, window.currentPluginConfig and every read of it, the
file-upload JSON delete branch, unused PluginAPI / PluginInstallManager /
PluginStateManager helpers, loadPluginWidgetsFromManifest, the stale
install_manager.js and LEDVisibility fallbacks, error_handler.js's global
escapeHtml, 13 unused CSS rules, and stale comments/no-op returns.
- pytz < 2027, psutil < 7 in requirements-test.txt, pytest-cov < 8.
- Pin anthropics/claude-code-action to the commit v1 resolves to.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Six Starlark fixes are on main via #535 and #537, but a follow-up commit
carrying tests for half of them was pushed to fix/starlark-pixlet-install
six minutes after #535 merged, so those tests never landed. This ports
them onto the api_v3 package split:
- a failed toggle write answers 500, and a loaded app is not flipped in
memory when the manifest write fails
- each manifest writer gets its own temp file; concurrent writes leave
readable JSON; no temp files are left behind
- a failed dynamic import of tronbyte_repository / pixlet_renderer does
not stay cached in sys.modules
- a failed save_config() leaves config and timing untouched and does not
re-render; a successful save still applies
It also logs when the timing update to the manifest is not persisted.
_update_manifest_safe answers False rather than raising, so the existing
except branch never saw that failure.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes
- font_manager: a .zip font URL is served as its extracted font after a
restart (the cached-file check returned the archive first); downloads
use requests with a 30s timeout into a temp file + os.replace.
- api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use
time.monotonic(); last_request_time and the status file's ts stay
wall-clock. set_on_new_cycle docstring no longer claims core uses it.
- logo_helper: the placeholder uses the same scaled box as a real logo.
- permission_utils: one _sudo_bash_candidates() helper (with the sudoers
exact-argv rationale) shared by sudo_remove_directory, which now retries
the next bash path on a sudo refusal, and install_requirements_file.
- dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate
INFO log and contradictory docstring example fixed.
- element_style: scale default looked up through element aliases.
- background_data_service: cache-hit callback runs outside the lock.
- config_arrays: union-aware type check (["array","null"]); stale
dotToNested() reference removed.
- auto_update_setup: non-dict auto_update reads as off; temp result file
unlinked when the write fails.
- exceptions: constructors copy the caller's context dict.
- logging_config: StructuredFormatter json.dumps(default=str).
- error_aggregator: removed unused export_path/export_to_file/_auto_export.
- Docstrings: validate_file_upload max_size_mb, raise_on_errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sync): retry the status-file rename like the other atomic writers
On Windows os.replace can fail with "Access is denied" while a scanner
briefly holds the target open; config_manager_atomic._replace already
retries that (and re-raises at once on other platforms). The sync status
writer called os.replace directly, which made
test_concurrent_writers_each_use_their_own_temp_file flaky on Windows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- DisplayManager.defer_update()/process_deferred_updates(): one lock around
every queue mutation (appends from the update thread were lost to the
render thread's filter/slice reassignments); callables run outside it.
- FontManager and element_style no longer cache BDF freetype.Face objects
process-wide (load_bdf_face caches them per thread); element_style's LRU
is locked against get/move_to_end vs eviction races.
- limit_refresh_rate_hz default is one constant, DEFAULT_REFRESH_LIMIT_HZ =
100 (the template's), for the library options, refresh_hz, the matrix
guard, Vegas and scroll_config. Previously a missing key capped the panel
at 90 while pacing assumed 100.
- Sync follower: the TCP thread queues the leader's scroll image; the render
thread swaps image/array/width in between frames.
- update_display() error log rate-limited (traceback first, then once a
minute with a count); swallowed DisplayController exceptions log at DEBUG.
- Root display_controller.py runs run.py via runpy.
- stream_manager: correct the RLock release comments; merge duplicate if.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The static trigger peeked at the front of StreamManager's segment buffer,
which continuous scrolling (the default) never advances -- it extends the
strip with take_next_group() -- so the same first segment was examined on
every frame. A STATIC plugin paused the scroll only if it was first, once,
at startup; otherwise it scrolled past as ordinary content. Swap mode had
the same problem for any STATIC plugin not first in its cycle.
The render pipeline now records a marker (strip column, plugin id) for
each STATIC plugin where the strip is built -- composition and every
extension -- shifts the markers when the scrolled prefix is trimmed, and
clears them on reset. The coordinator pauses when the scroll reaches the
next marker: a tuple comparison per frame instead of a lock, a plugin
lookup and a get_vegas_display_mode() call. take_next_group() no longer
renders STATIC plugins' content. The pause calls display() under the
plugin lock and is timed with the monotonic clock.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): stop the run loop spinning when no mode has anything to show
A mode whose display() reports nothing rotates to the next at once, with no
dwell. With every enabled mode empty (only a sports plugin in its
off-season, say) the loop went round with no sleep: on ledpi, 169% CPU and
~1,800 "No content" log lines every 10 seconds. After one full rotation of
empty passes it now pauses EMPTY_ROTATION_PAUSE (1s) per pass, servicing
plugin updates and returning early on on-demand or schedule changes; live
priority is still checked at the top of every pass, and the streak resets
as soon as any mode shows something.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): restart the loop if the empty-rotation pause starts on-demand; per-rotation streak
- an on-demand request serviced during the pause returned early into the
on-demand branch, which advanced past the mode just requested; the loop
now restarts when the pause changed the mode, on-demand state or schedule
- the streak is reset when the rotation changes (on-demand start/stop, a
plugin enabled or disabled), so a streak from one rotation can't make
another pause before its own modes are tried
- docstring: live content is picked up within the pause, not "at once"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- mypy.ini parses again (multi-line exclude and inline value comments made
mypy reject the file); the mypy pre-commit hook is manual-only until the
~500 existing errors in src/ are paid down, and CONTRIBUTING says so
- .gitignore: ignore all of config/ except the templates (ytm_auth.json and
others weren't ignored)
- .gitattributes: LF for .sh and .service
- claude-code-review: skip fork PRs, which have no secrets
- check_system_compatibility.sh: 3.13 supported, <3.10 an error
- docs/scripts drift: emulator guide, README API Metrics, route count,
docs index, scripts README; pyflakes nits in dev scripts
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Operation History: the plugin filter lists the installed plugin ids
instead of one option, "plugins" (Object.keys of {plugins: [...]}).
- Ctrl/Cmd+S submits the active tab's first visible form with
requestSubmit() (validation and onsubmit guards run) instead of a bare
Event on the first form in the document; skipped inside a modal dialog
and on tabs without a form.
- Overview "Check Updates" confirms like "Update Code", takes its button
explicitly (no implicit global event) and shows the server's message.
Both, and the Tools tab git pull, raise the restart-pending banner on
restart_required.
- Tools: toolsAction and diagnostics show the server's error message;
only a non-JSON body falls back to HTTP <status>.
- Installed list after uninstall: PluginAPI writes clear the throttler's
GET cache, a forced loadInstalledPlugins clears it too, and the
post-uninstall reload goes through refreshInstalledPlugins().
- Plugin widgets load from /static/plugin-widgets/ only (the other two
paths have no route).
- Raw JSON editor escapes the parse error; slider escapes value/min/max/step.
- Removed the unreferenced array-of-objects and key-value helpers from
plugins_manager.js, the textarea auto-resize and Ctrl+R handlers in
app.js, and a redundant ?v= on the plugins_manager.js script tag.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- load_plugin: an on_enable() that raises unregisters the instance, so the
next load retries instead of returning True "already loaded".
- get_plugin_info: guard plugin.get_info(); one plugin raising no longer
breaks /api/v3/plugins/installed.
- plugin_state.json and the operation history are written with
atomic_write_text under their lock.
- plugin_loader: module-level lock serialises pip installs across the
parallel startup loaders.
- store_manager._install_via_download: extract dir cleanup moved to finally.
- Test doubles: draw_image() warns (DeprecationWarning; the real
DisplayManager has none), MockDisplayManager.draw_text accepts the real
signature's optional params, VisualTestDisplayManager logs draw errors at
WARNING.
- Docs/comments: compatibility.py method name, PluginState.LOADED meaning,
brittle schema count, why _report_skip_once uses setdefault.
- Remove unused PluginOperationQueue.get_active_operations().
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Vegas: a live-priority pause was only lifted from inside run_frame(),
which returns before that check while paused, so the ticker never came
back until a restart. run_iteration() now resumes it (the controller
only calls it when nothing preempts Vegas); start()/stop() clear the
pause state. Iteration length is timed with the monotonic clock.
- Dim schedule: a per-day disabled day now updates the minute-gate cache,
so brightness no longer flips back to dim within each minute.
- On-demand: a second request no longer overwrites the rotation resume
index with the first request's mode.
- Render pipeline: reset() drops the prepared group and deferred queue,
and a prefetch in flight across a reset discards its result.
- Sync: stop() removes the status file (and the controller's cleanup now
calls it), standalone removes a stale one at startup, and writes use a
unique mkstemp temp file.
- render_gate.swap_releases_gil() delegates to frame_timing.
- Stale docstrings/comments corrected.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): refuse unsafe plugin ids, keep secrets private, validate bodies
- install_from_url and the registry install's manifest rename refuse a
plugin id that is not a single safe name (no ../ out of plugins_dir).
- Uninstall and config reset refuse core config sections and ids with
path parts; uninstall of a plugin whose directory is gone still works.
- separate_secrets checks a field's own x-secret marker before recursing,
so object/array secrets no longer land in config.json.
- Backup restore creates missing secrets/wifi/ytm files with mode 640;
export skips non-object manifests and no longer collides on same-second
exports.
- SYSTEM_FONTS includes every bundled font from BUNDLED_FONTS.
- Raw config/secrets saves and validate_request_json require a JSON object.
- A blank max_dynamic_duration_seconds keeps the stored value; other values
are validated to 30-1800 instead of raising a 500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): validate the id before install_plugin moves anything; claim backup names atomically
- install_plugin set aside plugins_dir / plugin_id before any id check, so
"../x" moved a directory outside the plugins dir (the rollback moved it
back, but only if the install path got that far)
- two exports finishing in the same second could both see a free name and
the later os.replace destroyed the first archive; the name is now
claimed with O_EXCL before the archive is swapped in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>