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>
* fix: six bugs found testing main on a real Pi (ledpi)
- Stopping the service now runs cleanup. systemd stops ledmatrix.service
with SIGTERM, whose default action ended Python before run()'s finally
block, so the update worker, Vegas and the panel were never torn down.
main() now turns SIGTERM into KeyboardInterrupt, the Ctrl-C path.
- "Now showing" no longer turns into "unknown". display_current_state was
only written on a mode change and the web UI reads it with max_age=120,
so a live game or a single plugin on screen for longer read as unknown.
It is republished every 30 s while unchanged.
- Switching Vegas on in the web UI works when it was off at startup. The
coordinator was only created at startup; the config watcher now flags it
and the render thread creates it.
- configure_web_sudo.sh finds reboot and poweroff in /usr/sbin. Run as the
web user it could not, silently dropped their rules and still said it
granted them, so the web UI's Reboot/Shutdown stopped working.
- check_system_compatibility.sh reports installed packages as installed.
`dpkg -l | grep -q` under pipefail failed when grep exited early.
- A network failure fetching GitHub repo info logs a WARNING, not ERROR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): fixes found testing on a Pi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: run the Linux-only script tests correctly
The sbin-lookup test set PATH=/nonexistent and then could not find bash
itself; call it by absolute path. The dpkg-query stub read $4, but the
package name is the third (last) argument. Both now pass on a Pi.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): cover the follower, long-render and startup cases
Review follow-ups on the ledpi fixes:
- The pending Vegas start is applied before the sync-follower branch too
(_apply_pending_vegas_init), which skips _is_vegas_mode_active() while a
follower is connected but needs the coordinator for the leader's image.
- _service_pending_changes(), which runs inside Vegas iterations and long
screens, republishes a stale display_current_state as well; the main
loop alone could be away for a 240 s Vegas iteration.
- The SIGTERM handler is installed after DisplayController() is built, so a
stop during parallel plugin loading keeps the default immediate exit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(config): normalize nullable arrays and objects instead of refusing them
`element_style._nullable` widens every `customization.modes.<mode>` override
with 'null' so a blank means "inherit the base", which turns a colour declared
"array" into ["array", "null"]. `normalize_config_values` only knew how to
convert null/integer/number/boolean out of a union, so a valid [0, 249, 0]
matched nothing and logged
Could not normalize field customization.modes.upcoming.odds_text.text_color:
value=[0, 249, 0], type=<class 'list'>, schema_type=['array', 'null']
The warning was the harmless half. It `continue`d past the single-type handling
below, where `prop_type == 'array'` coerces items, so a nullable array never had
its items normalized while a plain one did. A form posts numbers as strings, so
["0", "249", "0"] survived to the validator and was rejected with "Expected type
integer, got str" -- setting a per-mode colour in the web UI failed outright.
Every per-mode override of a structural or string type was exposed, across all
eight scoreboard plugins, not only colours.
Re-enter the single-type handling with the matched member rather than bailing,
accept a string that matches, and warn only on a genuine mismatch so the
diagnostic still reaches the validator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ET8e5weDrb5Ju5QTKLU7zh
* fix(config): convert only integral numbers for integer array items
Review catch on the previous commit. Routing nullable arrays into the shared
item handling made its integer coercion reachable for them, and that coercion
called int(v) on any number: a client sending [2.5, 249, 0] for an RGB array
got 2 stored and a 200 back, so a wrong value was silently corrected into a
valid-looking one rather than refused.
Convert only genuinely integral values, at both the union-item and the plain
'array' item branch so the two cannot drift. A whole float -- 2.0, which is all
JSON can express for an integer -- still converts. This also settles an
inconsistency that predates the change: int('2.5') raises, so the string form
was always preserved and rejected while the numeric form was truncated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ET8e5weDrb5Ju5QTKLU7zh
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
DisplayManager.offscreen() gives a thread its own canvas, so Vegas renders every plugin's ticker content on its prefetch thread instead of pausing the scroll for canvas-bound plugins on the render thread. A render gate (src/common/render_gate.py, vegas_scroll.prefetch_gate, on by default with the GIL-releasing binding) lets the prefetch thread run Python only while the render thread waits in SwapOnVSync: on hdpi, frames 2+ refreshes late fell eightfold and late frames overall from 0.90% to 0.60%. See docs/OFFSCREEN_RENDERING.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 1:N-scan HUB75 panel lights the two rows either side of its middle at opposite ends of each refresh, so a scroll at one pixel per refresh shows a 1px step across the middle of every panel. While something scrolls at one frame per refresh, DisplayManager now shows the half whose seam row lights first one refresh behind the other (src/scan_order.py), which lines the two up again. Only for layouts whose row order is known; display.scan_order_compensation "off" disables it. Confirmed on hdpi before and after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/common/frame_timing.py times every frame the display presents, whoever drew it, and writes cumulative counters to /dev/shm. scripts/frame_soak.py grades a running service (late frames, freezes, where the time goes) and scripts/render_bench.py the hardware and render path alone. A stall watchdog logs the stacks behind any scroll held up for 250 ms or more (LEDMATRIX_STALL_WATCHDOG_MS lowers that). See docs/SCROLL_PERFORMANCE.md, "Soaking a rig".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vegas scrolls a whole number of pixels per panel refresh, locked to SwapOnVSync, against the refresh the panel really holds (measured from swap gaps), instead of blending sub-pixel positions against the refresh cap. The web preview PNG is encoded off the render thread while scrolling, with writes ordered and retried. On hdpi, late frames fell from 6.3% to 0.7%. See docs/SCROLL_PERFORMANCE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): tear down Vegas mode on controller cleanup
DisplayController.cleanup() never called VegasModeCoordinator.cleanup(),
so the Vegas teardown (stop, pipeline/stream reset, adapter cache drop)
was unreachable. Call it before the display manager is cleaned up, and
skip it when Vegas was never created.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(vegas): default max_cycle_duration to the documented 240s
The template, the web UI help, CONFIG_REFERENCE and the controller all
say 240, but the code defaulted to 600 in two places, so a config
without the key ran Vegas iterations 2.5x longer than documented.
from_config now falls back to the dataclass field defaults instead of
repeating each one, so the two copies can no longer drift, and the
controller's follower scroll-speed default reads VegasModeConfig's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): let run.py -d show display_manager's DEBUG output
display_manager pinned its logger to INFO at import, overriding the root
level, so debug mode never showed its DEBUG lines. Use get_logger() from
src.logging_config like the rest of the core and leave the level to the
logging setup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): run each startup validation check once
StartupValidator.validate_all() ran twice at boot, before and after the
plugin manager was created, so every config, cache, display and
systemd-unit warning was logged twice. The second pass now runs only the
plugin checks. Drop the commented-out raise_on_errors line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(vegas): one INFO line per plugin-list refresh
StreamManager logged "=" * 60 banners and a line per plugin (INCLUDED,
SKIPPED, FETCHING CONTENT, SEGMENT CREATED) at INFO on every refresh and
fetch, i.e. at each cycle start and every 30s. Log one INFO summary of
the rotation per refresh and move the per-plugin detail, the weighting
breakdown and "no content this cycle" to DEBUG (the adapter still warns
when every content path fails).
Also drop the check/cross marks from the controller's log messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(vegas): drop the per-iteration static-mode plugin scan
run_iteration() rebuilt _static_mode_plugins on every iteration, asking
every plugin for its display mode and logging the set at INFO, but
nothing ever read it: static pauses are triggered by
_check_static_plugin_trigger() from the next segment. Delete it, the
coordinator's get_ordered_plugins() that only it used, and the
write-only _static_pause_plugin / _static_pause_start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(vegas): remove the staging buffer that was never filled
StreamManager and RenderPipeline carried a double-buffer design that
nothing used: _staging_buffer was only ever cleared or swapped, so
swap_buffers() never did anything and should_recompose()'s
staging_count > 0 branch was dead, and _active_scroll_image,
_staging_scroll_image, _is_rendering, _last_frame_time and
_frame_interval were written but never read. Delete the machinery and
rewrite the docstrings around what actually carries updates:
_pending_updates, consumed by process_updates() in swap mode and
invalidate_pending_updates() in continuous mode.
should_recompose() no longer builds a buffer-status dict every frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(display): tidy the display controller without changing behaviour
- Import VegasModeCoordinator locally instead of through module globals
(there is no circular import to avoid).
- Drop hasattr() checks on attributes PluginManager.__init__ always sets
(plugin_executor, plugin_last_update, get_plugin_lock,
run_scheduled_updates*, stop_update_worker) and the dead "older
manager" fallbacks; keep the health_tracker None checks, now via
_health_tracker().
- Extract _display_once() for the per-frame display call both render
loops copied, _advance_on_demand() for the two on-demand rotations,
_reset_on_demand_fields() for the error and clear paths, and
_timezone() / _in_window() for the two schedule checks.
- Remove always-true conditions and the unreachable non-plugin else
branch in run(), and read _was_display_active / _last_published_mode /
vegas_coordinator directly now that __init__ declares them.
- Declare the follower render state in __init__, name its tuning
constants, add _follower_sign(), and share the 90/s sync send
interval with the render pipeline (SYNC_SEND_INTERVAL).
- Delete history narration and the "Opt #N" labels, fix the comment
that called _scroll_speed constant (hot reload updates it), and drop
a startup timing log that measured nothing.
- render_pipeline / plugin_adapter: read display_manager.width/height
as the properties they are, drop an empty TYPE_CHECKING block, an
aliased threading import and a duplicated `if result and
self.sync_manager:`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(display): trim dead code from display_manager
- Add _new_canvas() for the image/draw/fontmode="1" setup that was
copied six times.
- Call resolve_double_sided() and compose_pixel_mapper_config() directly
instead of through a module alias and a passthrough method, and replace
the comment that said the passthrough read class attributes.
- Delete the unused _initialized flag and _ORIENTATION_ROTATE_DEGREES
alias (no core or monorepo reader; tests stop resetting the flag), the
test pattern's unreachable no-matrix branch (it only runs once the
matrix exists), `del old_image # help GC` (a no-op on a local), a
duplicated early return in process_deferred_updates, and stale
comments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(vegas): remove unread fields and test-only helpers, fix docstrings
- ContentSegment: drop total_width, fetched_at, is_stale, image_count and
is_static, none of which is read.
- StreamManager: drop _current_index (never advanced) and the test-only
get_all_content_for_composition() and has_pending_updates();
VegasModeConfig: drop the test-only is_plugin_included().
- geometry.find_blank_cut() has had no production caller since the crop
moved to item boundaries; delete it and its tests.
- PluginAdapter: the _finalize docstring described separator_width
between every image, and _crop_to_budget's said cuts snap to the
nearest blank column; both now describe what the code does.
- Coordinator: the static-pause interrupt log no longer blames follower
mode for every interrupt, and set_update_callback names the callback
the controller actually wires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(scroll): correct ScrollHelper comments and drop dead branches
- Four comments said the strip always starts with display_width of
blank; it does only when lead_gap is None (Vegas passes its own).
- Delete the "Width calculation mismatch" warning: the image is created
at the calculated width, so the two can never differ.
- Remove the two scroll_delay <= 0 fallbacks (which disagreed with each
other): set_scroll_delay clamps it to at least 0.001 and nothing in
core or the plugin monorepo assigns it directly.
- Trim the scipy history from the blend docstring.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(run): drop a redundant comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): log set_scrolling_state only when it changes
Vegas and scrolling plugins set the scrolling state every frame, so once
display_manager's DEBUG output became visible in debug mode it printed
"Scrolling state set to: True" about 120 times a second. Log only when
the value differs from the previous one; the state, activity timestamp
and frame hold still update on every call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): display-vegas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>