Compare commits

..
Author SHA1 Message Date
Chuck ffbf7b7067 Merge remote-tracking branch 'origin/main' into claude/fix-cache-write-dedup
# Conflicts:
#	CHANGELOG.md
2026-09-29 19:28:21 -04:00
ChuckandClaude Opus 5.5 c3a7a110c4 fix(display): on-demand loads a disabled plugin live instead of failing (#678)
* 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>
2026-09-29 17:59:12 -04:00
ChuckandClaude Opus 5.5 6047eb5e4e test: stop the suite reinstalling plugins into the real plugin-repos/ (#679)
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>
2026-09-29 17:47:32 -04:00
Chuck 8363983f1c Merge remote-tracking branch 'origin/main' into claude/fix-cache-write-dedup
# Conflicts:
#	CHANGELOG.md
2026-09-29 16:56:35 -04:00
ChuckandClaude Opus 5.5 c4c46d3ba7 fix(web): on-demand no longer restarts a running display service (#676)
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>
2026-09-29 14:42:58 -04:00
ChuckandClaude Opus 5.5 0f39e9a2f3 fix(cache): skip rewriting unchanged data saved through CacheManager.set
DiskCache.set skipped a payload identical to the last one written for the
key, but CacheManager.set stamps every record with time.time(), so the
payload always differed and the skip never fired: unchanged API data was
rewritten to the SD card on every plugin update cycle.

Header-first records are now compared without their timestamp (the digest
also carries the content length, since a collision is now a missed write).
A skipped write moves the file's mtime to the skipped record's timestamp,
and a real write sets it to the embedded one, so only a skip moves it
forward. DiskCache.get, including the header fast path, treats such a
record as fresh from the later of the two and returns that time as the
record's timestamp. The skip also checks the file is still the one this
process wrote (inode and size), so a file another process replaced is
rewritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:41:52 -04:00
ChuckandClaude Opus 5.5 da9a999102 chore: prepare the 3.7.0 release (#673)
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>
2026-09-29 12:51:24 -04:00
ChuckandClaude Opus 5.5 1e4c890d59 feat(common): sports_celebration, sports_fetch and sports_card_wrappers, promoted from the scoreboards (sports consolidation stage 3) (#672)
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>
2026-09-29 12:38:40 -04:00
ChuckandClaude Opus 5.5 7f96075076 chore: prepare the 3.6.2 release (#671)
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>
2026-09-29 11:55:48 -04:00
ChuckandClaude Opus 5.5 439013b18c fix(common): favourite check no longer calls the Europa League finished between matchdays (#670)
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>
2026-09-29 11:49:36 -04:00
ChuckandClaude Opus 5.5 e5bbfa2ae3 chore: prepare the 3.6.1 release (#669)
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>
2026-09-29 10:59:20 -04:00
ChuckandClaude Opus 5.5 db49275075 fix(common): favourite check no longer calls a started postseason a finished season (#667)
* 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>
2026-09-29 10:52:38 -04:00
ChuckandClaude Opus 5.5 a11412dabb chore: prepare the 3.6.0 release (#666)
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>
2026-09-29 09:28:21 -04:00
ChuckandClaude Opus 5.5 fe5bed2886 feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2) (#665)
* 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>
2026-09-29 09:05:10 -04:00
ChuckandClaude Opus 5.5 5b30052b59 docs(changelog): fold Unreleased into 3.5.0 for the release (#664)
* 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>
2026-09-28 16:05:22 -04:00
ChuckandClaude Opus 5.5 8557eff88a fix(plugins): put a (re)loading plugin's directory first on sys.path (#663)
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>
2026-09-28 15:02:01 -04:00
ChuckandClaude Opus 5.5 b8c01c69fb ci: mypy ratchet -- keep type-clean modules clean (71 modules, 536 -> 442 errors) (#661)
* 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>
2026-09-28 15:01:35 -04:00
ChuckandClaude Opus 5.5 e6e0a16140 ci: run the web UI DOM test suites; fix two stale suites (#660)
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>
2026-09-28 15:01:08 -04:00
ChuckandClaude Opus 5.5 989eae9405 refactor(plugins): split PluginStoreManager into mixins (#659)
* 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>
2026-09-28 15:00:45 -04:00
ChuckandClaude Opus 5.5 d469fe39d2 fix(odds): don't return the cached no-odds marker as odds (#662)
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>
2026-09-28 14:59:40 -04:00
ChuckandClaude Opus 5.5 09103a8a7d refactor(web): split api_v3/plugins.py by area (#658)
* 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>
2026-09-28 14:58:53 -04:00
ChuckandClaude Opus 5.5 724673ba0b fix: one retry layer for background fetches; CI installs the web requirements; one Discord invite (#657)
- 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>
2026-09-28 11:08:43 -04:00
ChuckandClaude Opus 5.5 6cfcf2e384 fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety, BDF preview (#655)
* 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>
2026-09-28 10:42:07 -04:00
ChuckandClaude Opus 5.5 c00bf5e8e6 fix(plugin-system): unload/update race, failed-load cleanup, limits validation, schema lookup, install rollback (#653)
* 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>
2026-09-28 10:41:40 -04:00
ChuckandClaude Opus 5.5 0e9e2cabba fix(web): widget cache-busting, dead frontend code, and dependency pins (#656)
- 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>
2026-09-28 10:40:52 -04:00
ChuckandClaude Opus 5.5 65d82580bc test(starlark): cover the review fixes #535 shipped without tests (#650)
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>
2026-09-28 10:40:32 -04:00
ChuckandClaude Opus 5.5 f6c0fe55d9 fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes (#654)
* 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>
2026-09-28 10:40:16 -04:00
ChuckandClaude Opus 5.5 6f45ff5e63 fix(display): thread-safety for deferred updates, BDF faces and follower image; one refresh default (#652)
- 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>
2026-09-28 10:39:40 -04:00
ChuckandClaude Opus 5.5 1e62677257 fix(vegas): pause for STATIC plugins where their turn falls in the strip (#651)
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>
2026-09-28 10:39:14 -04:00
ChuckandClaude Opus 5.5 224847cebc fix(display): stop the run loop spinning when no mode has anything to show (#649)
* 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>
2026-09-28 09:07:08 -04:00
ChuckandClaude Opus 5.5 aeaeaa4e94 chore: make contributor tooling work; fix drifted docs (#648)
- 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>
2026-09-28 08:26:55 -04:00
ChuckandClaude Opus 5.5 76f5d8a336 fix(web): seven web UI bugs, and remove dead plugins_manager.js helpers (#647)
- 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>
2026-09-28 08:26:27 -04:00
ChuckandClaude Opus 5.5 7eb7a58d0c fix: web UI and src.common bugs (wifi wrong-password, plugin icon, starlark toggle, API caching, scroll/logo/font helpers) (#646)
- wifi: keep the "wrong_password:" prefix through the restore/AP fallback so
  the UI's incorrect-password prompt fires again.
- /plugins/installed returns the manifest's icon (string only).
- /starlark/apps/<id>/toggle coerces `enabled` and delegates to
  _toggle_starlark_app (disk before memory, no KeyError, "false" is false).
- /api/v3/ JSON GETs are sent Cache-Control: no-store; non-JSON keeps 5s.
- ScrollHelper.set_scrolling_image converts non-RGB input (alpha onto black);
  create/set_scrolling_image reset last_update_time like reset_scroll.
- LogoHelper backs off a failed download per path for
  MISSING_LOGO_RECHECK_SECONDS; cleared on invalidate/clear_cache.
- refresh_placeholder_timestamp saves atomically.
- FontManager.clear_cache / _clear_plugin_font_cache bump cache_generation.
- Odds manager: per-game logs to DEBUG; JSON decode error caught before
  RequestException (same cooldown).
- element_style mangled continuations; startup validator skips null plugin
  blocks and reuses the controller's discovery.
- src/common/README lists frame_timing, json_body, render_gate.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:26:05 -04:00
ChuckandClaude Opus 5.5 b518c51679 fix(plugin-system): load/enable failures, atomic state files, pip lock, test-double parity (#645)
- 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>
2026-09-28 08:25:45 -04:00
ChuckandClaude Opus 5.5 6bc13a8934 fix(display): Vegas resumes after live priority, and six smaller runtime fixes (#644)
- 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>
2026-09-28 08:25:26 -04:00
ChuckandClaude Opus 5.5 bcef1957a9 fix(security): refuse unsafe plugin ids, keep secrets private, validate request bodies (#643)
* 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>
2026-09-28 08:24:43 -04:00
ChuckandClaude Opus 5.5 da5937da3d fix: six bugs found testing main on a real Pi (#641)
* 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>
2026-09-27 18:13:49 -04:00
ChuckandClaude Opus 5 c4927e82a3 fix(config): normalize nullable arrays and objects instead of refusing them (#642)
* 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>
2026-09-26 16:33:23 -04:00
ChuckandClaude Opus 5.5 9964dd2183 feat(vegas): render plugin content off the render thread, and keep it off the GIL when the panel needs it (#630)
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>
2026-09-24 19:57:03 -04:00
ChuckandClaude Opus 5.5 865d62f67b feat(display): compensate for the panel's scan order while scrolling (#634)
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>
2026-09-24 19:47:44 -04:00
ChuckandClaude Opus 5.5 8ad9d191a7 feat(perf): frame timing for every presented frame, a soak tool, a render bench and a stall watchdog (#629)
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>
2026-09-24 19:38:49 -04:00
ChuckandClaude Opus 5.5 7f9c73e9aa fix(vegas): smooth Vegas scroll pacing -- whole pixels per refresh, measured refresh, off-thread preview writes (#628)
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>
2026-09-24 19:38:22 -04:00
ChuckandClaude Opus 5.5 b9416ef803 fix(display): Vegas teardown and 240s default; remove dead Vegas buffer code (#637)
* 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>
2026-09-24 17:37:15 -04:00
ChuckandClaude Opus 5.5 f6afbdbb15 fix(web): Cache/Logs error mix-up, store errors, tab fallbacks; remove ~2.5k lines of dead JS (#639)
* fix(web): keep Cache and Logs helpers out of each other's way

Both partials declared top-level showError and escapeHtml. Their scripts
run at global scope after every HTMX swap, so whichever tab was opened
last owned window.showError, and a Cache failure after visiting Logs
rendered into the Logs panel (and the other way round). Each script is
now an IIFE; Cache still exports deleteCacheFile for its row buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): make the HTMX-failure fallbacks for tab panels actually run

- The "HTMX never loaded" fallback read appElement.__x.$data, which is
  Alpine 2. The page ships Alpine 3, so the check was always false and
  the Overview never loaded without HTMX. It now reads Alpine.$data().
- The Overview and WiFi panels used hx-on::htmx:response-error, which
  htmx expands to "htmx:htmx:response-error", an event that never fires.
- loadTabContent sent requests with <body> as the source, so htmx fired
  its events on <body> and no panel's hx-on handler ran at all. The
  panel is now the source. htmx also resolves its promise on a 4xx/5xx,
  and the panel was stamped data-loaded anyway, leaving a skeleton that
  never retried; it is now stamped only when no responseError fired.

loadPluginsDirect, loadOverviewDirect and loadWifiDirect are merged into
one window.loadPartialDirect(id, url), which also runs the partial's
inline scripts before Alpine sees the markup (as htmx-config.js does on
htmx:afterSwap). The ~10 s "htmx never arrived" path in loadTabContent
uses it for every tab instead of four hard-coded ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): store and registry failures no longer wipe the Plugin Manager

showError replaced the whole #plugins-content with an error message, so
one failed store search, custom-registry install or saved-repository
call took the installed list, the store and every control with it, with
no way back short of reloading the tab. Those failures are now error
notifications. The full-panel message is kept only for a first load of
the installed list that failed (nothing to show yet); a failed refresh
of an already-rendered list is a notification too. showSuccess's
fallback branch, which wrote the message into innerHTML unescaped, is
gone: showNotification always exists.

The "Please try refreshing your browser" hint tested for the text
"Failed to Fetch", which no browser produces (Chrome says "Failed to
fetch", Firefox "NetworkError..."), so it never appeared. It now keys on
the failure itself: a TypeError from fetch(), or PluginAPI's
NETWORK_ERROR wrapper around one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): escape plugin action and install output on every path

executePluginAction escaped data.message and data.output when an action
failed but put data.message straight into innerHTML when it succeeded,
and set the OAuth step-2 button's innerHTML from the manifest's
step2_button_text. Plugin actions run plugin code, so that is plugin- or
server-controlled markup in the page. Both paths now escape, and the
button label is set with textContent.

The same pattern sat in the install-from-GitHub-URL status lines
(plugin_id, the server's message, and error.message, which can echo a
repository URL) and the custom-registry load error; those are escaped
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): file-upload widget owns the image list and schedule editor

plugins_manager.js loads after the widget bundle, so its older copies of
deleteUploadedFile, updateImageList, hideUploadProgress, formatDate,
openImageSchedule, toggleImageScheduleEnabled, updateImageSchedule{Mode,
Time,Day} and updateCheckboxGroupData replaced the widget's. They are
deleted; the widget files are the only definitions.

Before switching over, the two sets were diffed and fixed so nothing
regresses:

- The old copy labelled the schedule/delete buttons for screen readers
  and lazy-loaded thumbnails; the widget now does both.
- The schedule button did nothing on a card rendered by
  plugin_config.html whenever the image id is a UUID (every upload): the
  template turns "-" into "_" in the editor's id, and neither JS copy
  did. Both now use the template's rule.
- The widget's "keep the open editor open" copied the editor's innerHTML
  into the new list. That dropped its event listeners and showed the old
  values, so after the first change the editor looked live but ignored
  input. A schedule edit now saves to the hidden input and updates the
  card's summary in place without re-rendering the list; a list re-render
  (upload, delete) rebuilds an open editor from the data. Editor controls
  are routed by one delegated change listener, so there are no
  per-element listeners to lose.
- The old deleteUploadedFile had a JSON branch that removed a
  #file_<id> element and skipped the re-render. No template or script
  renders such an element, and JSON uploads are listed through
  updateImageList like images, so re-rendering (the widget's behaviour) is
  the consistent one; the branch was not carried over.
- The template always renders the summary line (".image-schedule-summary",
  "Always shown" when unscheduled) so an edit has a line to update.

The inline-handler test evaluated plugins_manager.js's updateImageList;
test_file_upload_widget.js now covers the widget's list and editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): delete the unused handleCredentialsUpload

Its last caller went when plugin_config.html switched credential uploads
to the file-upload widget's handleSingleFileSelect. Nothing in the web
UI, the tests or the plugin monorepo references it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): delete dead and shadowed front-end code

Nothing calls any of these (checked across web_interface/, test/ and the
ledmatrix-plugins monorepo, including hx-*/x-*/onclick attributes):

- app-shell.js: the Alpine methods refreshPlugins (it called a
  nonexistent this.searchPluginStore), loadPluginConfig,
  savePluginConfig, getSchemaPropertyType, escapeCssSelector,
  formatCommitInfo and formatDateInfo, and the top-level copies of
  savePluginConfig, getSchemaPropertyType, escapeCssSelector,
  formatCommitInfo, formatDateInfo and togglePluginFromTab. Plugin config
  forms save through hx-post in plugin_config.html.
- window.reconnectSSE (app-shell.js); window.updateArrayTableAddButtonState
  (array-table.js).
- toggleNestedSection, defined twice (app-shell.js and
  plugins_manager.js) and called from nowhere.
- plugins_manager.js: the window.initializePlugins wrapper around an
  IIFE-local origInit that was always undefined, and __pluginDomReady,
  which was written but never read.
- display.html's fixInvalidNumberInputs fallback: app-shell.js defines it
  before any partial loads.
- base.html's window.loadCodeMirror and the two CodeMirror stylesheet
  preloads, and the .CodeMirror rules in plugins.html. The raw JSON
  editor is a plain textarea.

Also deleted: app-shell.js definitions that a later script always
replaced, so they never ran: executePluginAction (plugins_manager.js
assigns its own), uninstallPlugin and its pollUninstallOperation
(plugins_manager.js), and updateAllPlugins (install_manager.js).

vendor/codemirror stays: test/test_web_smoke.py still requests
codemirror.min.js as a sample static asset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): call showNotification without checking it exists

app-shell.js defines window.showNotification (a stand-in that queues
until the notification widget loads) and base.html runs it, deferred,
before every other script that notifies: app.js, the utilities, the
widget bundle, plugins_manager.js, and all partials, which HTMX loads
after the page. The 81 `typeof showNotification === 'function'` /
`!== 'undefined'` checks, the `window.showNotification || console.log`
and `|| alert` fallbacks, and their else branches (alert(), console
output, and schedule.html's own hand-built toast) could never take the
fallback path. They are removed, as is fonts.html's second copy of the
queueing stand-in.

The stand-in in app-shell.js keeps its guard (it must not replace the
widget's implementation if load order ever changes), and BaseWidget's
public notify()/getNotificationFunction() keep their shape for widgets
that plugins ship.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): one HTML escaper, window.LEDEscape

About 30 files each carried their own escapeHtml / escapeAttr / escHtml /
_esc / escapeJs. They disagreed: several (notification.js, display.html's
escapeHtml, operation_history.html, the app() stub) did not escape
quotes, google-calendar-picker.js and tools.html's escHtml left ' alone,
and some turned 0 into ''. Most were fine only because the quote-safe
widget copies were preferred at runtime.

window.LEDEscape now lives at the top of app-early.js, a blocking script
in <head>, so it exists before any other script runs:

  html(v)          & < > " ' as entities, null/undefined as ''
  attr(v)          the same, for call sites that want to say "attribute"
  jsStringAttr(v)  a JS string literal safe inside an inline handler

Every former copy is now a one-line name for it (kept so call sites do
not change), widgets included, with no fallback. plugins_manager.js
loses its four escapeJs wrappers (callers use jsStringAttr), the
duplicate escapeAttr and escapeHtml inside renderInstalledCards and
renderCustomRegistryPlugins, and the window.escapeHtml /
window.escapeAttribute exports, which nothing read.
addArrayObjectItem's fallback markup (with a sixth hand-written escape
chain) is gone too: window.renderArrayObjectItem is defined earlier in
the same file, so the fallback could not run. The unused escapeHtml
methods on the Alpine app (app-early.js stub and app-shell.js) are
deleted.

test_html_escaping.js now runs LEDEscape and every remaining name for it,
and fails if a hand-rolled escaper reappears anywhere in web_interface/.
Suites that evaluate slices of plugins_manager.js or widget files load
LEDEscape from app-early.js through test/js/led_escape.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): stop htmx re-running partial scripts after every tab load

htmx-config.js runs each swapped-in <script> itself on htmx:afterSwap
and meant to turn htmx's own script handling off with
htmx.config.allowScriptTags = false. It did that once, while setting up,
but base.html loads htmx with a dynamic <script>, so htmx was not defined
yet and the setting never applied. On every tab load htmx then tried to
run each script again in its settle phase, found it already replaced
(no parent node) and threw "Cannot read properties of null (reading
'insertBefore')" into the console, which also skipped the rest of that
swap's settle tasks.

The setting is now applied in the afterSwap handler, which always runs
after htmx exists and before htmx settles the same swap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): show "--" for a system stat the server could not read

The stats stream and /system/status now send null for a metric they
cannot read (cpu_temp off a Pi, for one) instead of 0. updateSystemStats
built the header and Overview text as value + unit, so a null showed as
"null°C". CPU, memory and temperature, in the header and on the
Overview, now render "--" plus the unit for null or a missing field --
the same placeholder the page starts with, and what tools.html already
shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): one Alpine accessor and one plugin-list signal

window.getApp() (app-early.js) returns the root <body x-data="app()">
component through Alpine's public Alpine.$data, or null before Alpine
has initialised it. It replaces the private el._x_dataStack[0] reads in
app.js, app-early.js, app-shell.js, settings-search.js, overview.html and
plugins_manager.js, the three local getAppComponent/appData/getAppData
copies, and the Alpine 2 el.__x.$data fallbacks, which Alpine 3 never
provides.

Publishing the installed-plugin list: one load set window.installedPlugins
and dispatched pluginsUpdated twice (loadInstalledPlugins, then
renderInstalledPlugins), then wrote into the Alpine component through
_x_dataStack[0] and called its updatePluginTabs() directly, and
app-early.js's global listener set window.installedPlugins a third time
and called updatePluginTabs() again. Now renderInstalledPlugins is the
one publisher: it sets window.installedPlugins and dispatches
pluginsUpdated once, and the full app()'s listener (app-shell.js) is the
receiver. The app-early.js listener only builds the tab row while the app
is not the full implementation yet. The "grid not loaded yet" case is a
normal state (Plugin Manager tab not opened), so it logs through
pluginLog instead of console.warn.

updatePluginTabs had a "Debounce" comment and clearTimeout over a timer
nothing ever set, and two identical branches; it now just calls
_doUpdatePluginTabs (app-early.js detects the full implementation by
that name in its source, which the new comment says).

app()'s unused baseComponent lookup is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): reload the plugin list after installs and failed toggles

Several callers refreshed the installed list with

    if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
    else if (typeof window.loadInstalledPlugins === 'function') ...

but loadInstalledPlugins is local to the plugin-manager IIFE and
window.loadInstalledPlugins is never defined, so from outside that IIFE
both tests were false and nothing reloaded:

- A failed plugin toggle left the switch drawn in the new state while
  the data said the old one. It now re-renders from the reverted data.
  The optimistic in-place edit also has to forget the grid's
  last-rendered markup, or setGridHtmlIfChanged sees identical HTML and
  skips the revert. A successful toggle still keeps the switch (and
  focus) as drawn.
- Installing from a GitHub URL (the early handleGitHubPluginInstall),
  installing or uploading a Starlark app, and toggling a Starlark app on
  its config tab never refreshed the list, so the new app had no tab or
  Installed badge until the page was reloaded. They now force a reload
  through window.pluginManager.loadInstalledPlugins(true), and the
  Starlark grid redraws when that finishes instead of after a fixed
  500 ms.
- The Starlark uninstall inside the IIFE reloaded from the 3 s cache,
  which could still hold the app; it now forces a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): route debug output through debugLog

base.html defines window.debugLog, gated on localStorage.pluginDebug.
plugins_manager.js read the same key twice more into its own flags
(_PLUGIN_DEBUG_EARLY, and PLUGIN_DEBUG behind a pluginLog() wrapper), and
api_client.js's RequestThrottler had a separate `debug` property with a
setDebug() that nothing called. All of it now goes through debugLog. The
"functions defined" dumps with their ✓ lines, and two per-plugin
"enabled=" loops that ran on every render, are dropped; "[PLUGINS STUB]"
labels on code that has not been a stub for a long time read
"[PLUGINS]".

Ungated console.log calls that announced normal events on every page
load or action (settings search and tooltips registering, every toast
repeated to the console, the schedule pickers initialising, widget
registry unregister/clear) go through debugLog too. What remains on
console.log is the widget registry's on-demand LEDMatrixWidgets.debug()
dump and BaseWidget.notify's no-notifier fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): drop waits and guards that could never fire

- handlePluginAction polled up to 10 x 50 ms for window.togglePlugin,
  configurePlugin, updatePlugin and uninstallPlugin before calling them.
  All four are defined when the scripts load, before any card can be
  clicked, so the poll always succeeded at once; it now calls them.
  The long thinking-aloud comment over the toggle state is replaced by
  two lines on why the stored state, not the checkbox, decides.
- initializePlugins checked typeof on setupGitHubInstallHandlers and
  applyStoreFiltersAndSort, function declarations in the same IIFE, and
  wrapped window.checkGitHubAuthStatus(), which returns a promise with
  its own .catch, in try/catch.
- searchPluginStore wrapped each "#store-count" update (a getElementById
  and an innerHTML assignment) in try/catch four times; one
  setStoreCount() helper does it. The store's post-render re-attach of
  the GitHub token handler dropped its try/catch and existence checks
  for the same reason.
- The load-time fallback outside the IIFE tested typeof
  initializePluginPageWhenReady, which is IIFE-local and so always
  undefined there; it calls window.initPluginsPage directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): delete two unused plugin-manager helpers

stopOnDemand (IIFE-local; the page's stop button calls window.stopOnDemand
from app-shell.js) and debounce had no callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): document the plugin-config handlers templates call

validatePluginConfigForm, handleConfigSave, handleToggleResponse,
handlePluginUpdate and refreshPluginConfig each get a JSDoc naming the
attribute in partials/plugin_config.html that calls it and what the
return value means (only validatePluginConfigForm's matters: false
cancels the submit).

- The `if (!window.__pluginConfigHandlersInitialized)` wrapper is gone:
  app-shell.js runs once per page, so it was never false. The block is
  dedented one level; `git diff -w` shows the real change.
- The three handlers read xhr.responseJSON first. XMLHttpRequest has no
  such property (it is jQuery's), so that branch never ran; one
  xhrJson(xhr) helper parses responseText for all of them, with the same
  fallbacks as before.
- runPluginOnDemand and stopOnDemand checked that plugins_manager.js's
  openOnDemandModal/requestOnDemandStop exist; plugins_manager.js is on
  every page, so they call them.
- fixInvalidNumberInputs had a stray "Notification helper function"
  comment on top of its own; a leftover "section toggle ... duplicate
  definition removed" note is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): one toast per save, and a failed durations save says so

app.js's global htmx:afterRequest listener showed the server's message
for every htmx request, and every form and button that posts through
htmx (plugin config save/toggle/update, Display, Durations, General,
Schedule, Dim schedule, the Overview actions) also reports its own result
from hx-on after-request. Each save showed two toasts. The global
listener now stays quiet for a request whose element, or its form, has
its own after-request handler.

That exposed the Rotation & Durations form's handler, which read
xhr.responseJSON: XMLHttpRequest has no such property, so it always said
"Durations saved" in green, even when the save failed (the global toast
had been the only place the error showed). display.html already had a
correct version (2xx only counts as saved; the server's message wins;
its status may refine success but never overturn failure). That is now
window.showSaveResult(xhr, savedText, failedText) in app.js, used by the
Display, Durations and General forms; General's inline copy of the same
logic is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(web): file headers and comments that say what the code does now

- plugins_manager.js, app-shell.js, app.js and app-early.js open with a
  header: what the file owns, how base.html loads it and in what order
  relative to the others, and the globals it defines. app-early.js's
  app() stub also says why it exists and that, with app-shell.js now
  loaded before Alpine, it does not run in practice.
- base.html's note on plugins_manager.js said it must load last to win
  over same-named functions in app.js/app-shell.js; there are none left,
  so it now gives the real reason (it uses everything loaded before it).
- Change-narration and "already defined at the top, no need to redefine"
  notes are gone or rewritten as present-tense reasons; comments that
  were wrong are fixed ("Toggle password visibility" over the function
  that opens the token panel, "Insert before the closing </nav>" over an
  appendChild, "(from v2)", the export note that still listed
  escapeHtml). About forty comments that restated the line below them
  are removed, and a second window.currentPluginConfig = null outside the
  IIFE is dropped (the IIFE sets it).
- The file-upload, checkbox-group and custom-feeds widgets' render()
  stubs say plainly that the widget is rendered server-side, instead of
  "for now" / "placeholder for future client-side rendering".

test_plugin_action_delegation.js sliced the source up to one of the
removed notes; it now ends the slice at the next section header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep the escapeHtml/escapeAttribute globals for plugin pages

6da77363 removed window.escapeHtml and window.escapeAttribute because nothing in core or the plugin monorepo read them. Plugin web UIs served through serve_plugin_web_ui and third-party plugin pages may still call them, so they come back as aliases of window.LEDEscape.html and .attr, defined in app-early.js before any other script runs. test_html_escaping.js checks the aliases exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): web-frontend

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): encode the image thumbnail path; match script tags case-insensitively

CodeQL flagged the upload widget building an <img> src from a stored path,
and the escaper test extracting inline scripts with a case-sensitive regex.
Each path segment is now URL-encoded (still a same-origin path, and correct
for names with spaces or

* fix(web): clear Codacy findings in the escaper, app shell and upload widget

- LEDEscape looks entities up in a Map instead of indexing an object.
- showNotification is declared as a global for app-shell.js.
- openImageSchedule checks the index is a non-negative integer and reads
  the image with Array.prototype.at.
- The schedule editor calls escapeHtml directly and documents why its
  innerHTML template is safe: every value is escaped or constrained.
  The remaining rule hits are suppressed on that line with the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): build the image schedule editor with DOM calls

Codacy does not honour inline suppressions, and the editor's innerHTML
template kept tripping its XSS rules even though every value was escaped.
The editor is now built with a small element helper (createElement and
setAttribute), so no value is ever parsed as HTML, and the file's own
escapeHtml goes away.

Also for Codacy:
- LEDEscape.attr is its own function rather than a second name for html.
- The tab loader records a failed load on the panel (data-load-failed)
  from a named handler, instead of a closure over a local flag.

The fake DOM in test_file_upload_widget.js gains append/replaceChildren,
its hostile-id check now asserts the id arrives as attribute data with no
innerHTML anywhere in the editor, and test_html_escaping.js drops the
file-upload.js escaper it no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): schedule editor helpers as plain functions

Codacy's lint flags arrow functions held in local constants and a forEach
callback that returns a value. The editor's pieces are now named function
declarations (displayStyle, scheduleModeOption, scheduleRangeTime,
scheduleDayTime, scheduleDayRow) taking what they need as arguments, and
the element helper loops with for...of. htmx is declared as a global in
app-shell.js. Output is unchanged; test_file_upload_widget.js passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:36:53 -04:00
ChuckandClaude Opus 5.5 3a81f38f09 fix(web): uniqueItems saves, /health count, Vegas order wipe; one list-repair helper (#638)
* fix(web): drop repeats from uniqueItems lists before validating a plugin save

dedup_unique_arrays lost its only caller in #330, so submitting a value a
uniqueItems list already holds (a stock symbol saved once and posted again)
failed the whole save with a validation error. _prepare_plugin_config_for_save
runs it again just before validation, which covers both POST /plugins/config
and plugin sections posted to /config/main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): /health counts the discovered plugins and logs the checks it fails

The plugin check counted plugin_manager.get_available_plugins(), which
PluginManager does not have, behind a hasattr guard that made plugin_count 0
on every device. It now counts the discovered manifests, discovering first
when nothing has been scanned yet.

The config, plugin and hardware checks answered "see logs for details"
without logging anything. Each now logs a warning with the traceback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): store refresh no longer claims a commit-metadata refresh

POST /plugins/store/refresh read fetch_commit_info (or fetch_latest_versions)
only to append "(with refreshed commit metadata from GitHub)" to its message.
It never fetched any: the route re-downloads the registry and nothing else.
search_plugins takes the flag, but it reads commit info through its cache,
so passing it on would not refresh anything either. The flag is ignored now
and the message says what happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): refuse a malformed Vegas plugin order instead of clearing it

A vegas_plugin_order or vegas_excluded_plugins value that was not JSON, or
not a list, was stored as [] and the save answered 200, so a bad value wiped
the saved order or exclusions. Both now answer 400 and save nothing, the way
plugin_rotation_order already did; the three share one parser. A list that
holds anything but plugin-id strings is refused as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): per-plugin health and metrics read the display service's latest

GET /plugins/health/<id> and /plugins/metrics/<id> called get_health_summary
and get_metrics_summary without force_reload, so they answered with whatever
the web process read first and kept in memory, while the display service kept
writing newer state. They now pass force_reload=True, as the list routes do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): plugin config reset saves through the shared atomic save

POST /plugins/config/reset called config_manager.save_config directly, so it
took no backup, and a failed write escaped as an unhandled exception. It then
handed on_config_change the raw stored section, not the prepared config a
loaded plugin runs with. It now saves through _save_config_atomic with a
backup, answers CONFIG_SAVE_FAILED when that fails, and notifies with
_prepared_plugin_config, as POST /plugins/config does.

POST /plugins/toggle carried its own copy of _save_config_atomic's
save_config_atomic-or-save_config fallback; it calls the shared helper now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): one reading and one "unavailable" for each system metric

system_metrics.collect_system_metrics() promised None for a metric it could
not read, but returned cpu_temp as 0 off a Pi, and the whole no-psutil
fallback as zeros. GET /system/status measured the same numbers a second time
with its own code, and answered None there. Now both come from
collect_system_metrics(), and "unavailable" is None everywhere.

/system/status keeps its 0.1s CPU sample and its 10s cache, and gains
nothing it did not already send. Two differences: without psutil it answers
200 with null metrics instead of 503, and a disk it cannot stat is null
instead of a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): /display/current sends the snapshot as-is and logs a failed read

GET /display/current PIL-decoded the preview snapshot and re-encoded it before
base64-ing it, spending CPU on the Pi to send the same picture, and dropped
any failure with `except Exception: pass`. The /stream/display SSE stream
already passed the PNG's bytes straight through.

Both now read through web_interface/display_preview.py and answer with the
same payload. A missing snapshot is still a null image; any other read
failure is logged as a warning. /health reads the snapshot path from the same
module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): one helper puts a submitted plugin config's lists back

The plugin-config save turned position-keyed dicts ({"0": ..., "1": ...})
back into lists in five copies: four in the form path's
fix_array_structures (whose prefix branches never ran, since no caller
passed one), and _fix_json_arrays on the JSON path. It then force-fixed
the news plugin's feeds.custom_feeds by name, in case the generic pass had
missed it. src/web_interface/config_arrays.coerce_array_shapes now does it
for both paths, custom_feeds included. ensure_array_defaults duplicated
_fix_none_arrays and is gone.

In the same function: the union-type re-checks that the null handling
above them made unreachable, the "(temporary)" random_seed debug log, and
a commented-out log line are removed. A failed validation is logged once
as a warning, not four ERROR lines and a WARNING.

Element types are left to normalize_config_values, which already converted
them for both paths. One difference: the form path no longer adds an empty
{} for a nested object the post left out that has no defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): import at module top and log through the module logger

The web_interface.cache imports in config.py and fonts.py were wrapped in
`except ImportError` fallbacks. It is an in-repo module that imports nothing
from the project, so it cannot fail to import; it is imported once at module
top, as system.py now does. cache.py's docstring said blueprints import it
lazily "to avoid circular imports"; it now says why that is unnecessary.

Five logging.error calls in the dim-schedule GET and three logging.warning
calls in plugins.py went to the root logger; they use the module logger.
Function-local re-imports of json, os, shutil, logging and Path, all
already imported by the module, are gone. The `import os` inside two except
blocks of save_plugin_config also made os a local name for the whole function.

execute_plugin_action's step-1 handler gets a comment saying why it stays:
it looks like a copy of the blueprint handler, but without it a
TimeoutExpired from the plugin's script would reach the route's own
`except subprocess.TimeoutExpired` and be answered as a 408.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): app.py loses dead CSRF and reconciliation state, comments fixed

- csrf was always None, so `if csrf: csrf.exempt(...)` never ran, and its
  note that the api_v3 blueprint "is exempted above" named an exemption that
  does not exist. Both are gone; the reason there is no CSRF protection stays,
  shortened.
- The SSE rate-limit comment called the default "tight" at 20 per minute. The
  default is 1000 per minute and the streams' 200 is the tighter one; the
  comment now says so. The limits are unchanged.
- _reconciliation_done was written and never read. The docstring that
  explains why reconciliation runs once keeps its reason, in the present
  tense.
- Removed: a dangling "import cache functions" comment with no import under
  it, a "security check ... within project_root" label on an existence check,
  the "(simplified version)" narration, and the note that no redirect route is
  needed. The preview loop's sleep comment no longer mentions a PIL encode
  that the loop does not do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(web): api_v3 comments name the package __init__, not a _common module

Every route module's docstring said the shared blueprint comes "from
._common", a module the package split never created; they name the
package __init__. The PROJECT_ROOT comment described the path from
_common.py; it now describes this package and keeps the incident it
guards against. The "(corrected) in this commit" note in
resolve_pull_command and the /health comment the split's mechanical
time -> _pkg.time rewrite garbled ("Stamp the start _pkg.time") read
correctly again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): drop hasattr checks for attributes PluginManager always has

PluginManager.__init__ sets health_tracker and resource_monitor (to None
until they are configured), so the seven
hasattr(api_v3.plugin_manager, ...) guards in the health, metrics and limits
routes were always true. The falsy checks that do the work stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): pages_v3 dispatches partials from a dict with one error handler

load_partial chose a loader through a fourteen-branch if/elif, and thirteen
of the loaders then wrapped themselves in the same try/except, logging
"Error loading partial" without saying which. The route now looks the name up
in _PARTIAL_LOADERS and has the one handler, which logs the partial's name.
The loaders just render. _load_tools_partial keeps its own messages. The
search index's _partial_html already catches a loader that raises.

serve_plugin_web_ui repeated _plugin_dir_for inline (containment plus the
ledmatrix- prefix fallback); it calls it now. Also removed: the unused
markupsafe.escape import, function-local json/Path re-imports, and unused
exception bindings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): remove unused imports, locals and a try that cannot fail

- get_error_aggregator was imported by the api_v3 package and used by no
  one; seven names config.py imported, and Path in misc.py and logging in
  plugins.py, likewise.
- branch_info in install_plugin was built and never logged; test_config in
  /health was bound and never read (the load_config call is the check).
- An f-string with no placeholders in the asset upload route.
- _installed_plugin_ids wrapped list(manifests.keys()) in try/except;
  _discovered_plugin_manifests always returns a dict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): start.py logs its startup lines and drops unreachable branches

The startup banner went to stdout with print(); it goes through a logger
now, which the app import has already configured, so it reaches the journal
with a level and timestamp like every other line. The "no addresses" branch
is gone: get_local_ips() always returns at least "localhost".

The except around app.run re-raised "only if it's not a client
disconnection error" from inside the branch that had just established it
was one, so that raise could not run. It is one check now, on a named
tuple of the errnos, which the werkzeug log filter uses too. The comment
on threaded=True counts three SSE endpoints, which is how many there are.
Trailing whitespace is stripped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): save_main_config names its General fields once

The General tab's field names were listed twice, once to detect a General
form post and again, with four more, to keep the remaining-keys merge from
storing them as top-level keys. GENERAL_FIELDS and _MAPPED_TOP_LEVEL_FIELDS
hold them now, and the four per-section skip checks are one set.

The comment on that merge said plugin configs are handled "here too", and
"(including plugin keys)". Plugin sections are handled and removed from the
body before it runs; the comment says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): plugin directories come from the plugin manager only

Six lookups fell back to PROJECT_ROOT/plugins/<id> when there was no plugin
manager: GET /plugins/config's of-the-day data, POST /plugins/action, the
plugin static-file route, the calendar credentials upload and the calendar
OAuth routes. The loader never scans plugins/ (PluginManager.discover_plugins
reads only the configured directory, plugin-repos by default), so what they
found there was a plugin that never runs. _plugin_directory() asks the
manager and answers None without one, which each route already reports as
"not found".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): web-backend

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:35:49 -04:00
ChuckandClaude Opus 5.5 7b90759252 fix: /errors stack traces, Wi-Fi disconnect and save, plugin fonts, API cache TTL (#636)
* fix(errors): record the exception's own stack trace

record_error() called traceback.format_exc(), which only sees an
exception while its except block is running. plugin_executor records
exceptions caught on a worker thread after that block has ended, so
every trace on /errors read "NoneType: None". The trace is now built
from the exception's __traceback__. The executor's log call had the
same problem with exc_info=True and now passes the exception.

record_error() also merged LEDMatrixError context into the caller's
dict in place; it now works on a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(wifi): point at configure_wifi_permissions.sh instead of a sudoers list

The module docstring told users to grant NOPASSWD sudo on iptables and
ip. configure_wifi_permissions.sh refuses those grants on purpose: a
wildcard rule for either runs an arbitrary program as root. Point at
the script and say why it leaves them out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(wifi): disconnect finds the saved profile by SSID

disconnect_from_network() asked `nmcli -f NAME,802-11-wireless.ssid
connection show` for the profile to take down, but nmcli rejects that
column for `connection show`, so the lookup always failed and only the
device was disconnected. The per-profile lookup _connect_nmcli() already
used is now _find_profile_for_ssid(), and both callers share it. It
also splits terse output on the last colon and unescapes "\:", so a
profile name containing a colon is found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(wifi): write wifi_config.json atomically and report a failed save

_save_config() opened the file for writing in place and swallowed any
error, so a wifi_config.json left owned by root made the web toggle for
auto-enabling AP mode report success while nothing was saved, and a
crash mid-write could truncate the file. It now uses atomic_write_json,
which also keeps the file's owner and shared group when root saves it,
and returns False on failure. POST /wifi/ap/auto-enable answers 500 in
that case.

The file is now written with indent=4, like the other config files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fonts): resolve plugin:// fonts in the plugin's own directory

FontManager looked for a plugin's bundled fonts under Path("plugins") /
plugin_id: relative to the process cwd, and not the default install
directory (plugin-repos/), so a manifest's plugin:// fonts never loaded.

register_plugin_fonts() takes an optional plugin_dir, and PluginManager
passes the directory it loaded the plugin from. Callers that omit it get
a lookup in the configured plugin_system.plugins_directory, then plugins/,
resolved against the install root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api-helper): cache responses for the requested cache_ttl

APIHelper.get(cache_ttl=...) and set_cache(ttl=...) dropped the ttl on
the claim that CacheManager does not support one, but CacheManager.set()
takes a ttl, stores it with the entry, and both cache tiers honour it
over a reader's max_age. Without it every response expired after the
300-second default read age, whatever the plugin asked for. The ttl is
now passed through, and the cache read passes cache_ttl as max_age for
entries written without one. The class docstring describes what the
helper actually does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(style): one scale range for the schema, element_scale and LogoHelper

The generated Scale field allowed 0.1 to 10, element_style's reader
capped at 10 with no floor, and LogoHelper accepted 0.05 to 8 and reset
anything else to 1.0. A logo scale of 9, which the form accepts, drew at
the shipped size.

MIN_ELEMENT_SCALE / MAX_ELEMENT_SCALE (0.1, 10.0) in src.element_style
are now the schema bounds and the clamp every reader applies through
coerce_scale(): a positive number outside the range is clamped, and
anything that is not a finite positive number means the default. That
also stops element_scale() passing NaN through, since min(nan, 10.0)
is nan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(logos): placeholder lands at the requested path; empty logos list

download_missing_logo() wrote its fallback placeholder to
<normalize_abbreviation(abbr)>.png in the logo directory rather than to
the logo_path the caller passed, so it could return True while nothing
existed where the plugin looks (e.g. "TA&M.png" vs "TAANDM.png").
create_placeholder_logo() takes an optional filepath, and
download_missing_logo passes the requested one.

download_missing_logo_for_team() only caught KeyError, so a team whose
"logos" list is empty raised IndexError; it now treats KeyError,
IndexError and TypeError as "no logo URL".

The placeholder is drawn with PLACEHOLDER_SIZE / PLACEHOLDER_BG, the
constants is_placeholder_logo() recognises it by, instead of repeated
literals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fonts): resolve bundled font paths against the install root

TextHelper's default font_dir, the logo placeholder's font and
FontManager's font_overrides.json were all relative to the process cwd,
so a process started anywhere but the install root (the plugin safety
harness, a manual run, a unit without WorkingDirectory) drew with PIL's
default face and read no overrides. They now go through
font_layout.resolve_asset_path; the overrides file sits in the install
root's config/.

The resolver docstrings described an order the code does not follow:
resolve_asset_path never consults the cwd, and sports_shared's
_resolve_font_path tries the cwd first. Both docstrings now say what
the code does, and _resolve_font_path calls resolve_asset_path instead
of probing FontManager for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): the web UI reads the sync status file the display writes

sync_manager writes its status to tempfile.gettempdir(), but
GET /api/v3/sync/status read a hardcoded /tmp/led_matrix_sync_status.json
and defaulted the port to a literal 5765. Wherever TMPDIR is set (or on
any non-/tmp host) the page only ever showed "starting". The endpoint now
uses sync_manager.STATUS_FILE and SYNC_PORT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(http): the rankings resolver sends the project's User-Agent

DynamicTeamResolver fetched ESPN rankings with a bare requests.get, so
it sent python-requests' default User-Agent, which ESPN rejects; the
AP_TOP_N favourites then resolved to nothing. It now sends
DEFAULT_HTTP_HEADERS. BaseOddsManager carried its own copy of the
User-Agent string and now uses the same shared headers (which also adds
Accept-Language).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(backup): record the core release and read the configured plugin dir

The manifest's ledmatrix_version came from a VERSION file that does not
exist, then from .git/HEAD: a 12-character sha, or "ref: refs/he" when
the branch's ref was packed. It is now src.__version__.

list_installed_plugins() scanned a hardcoded plugin-repos/, so on an
install whose plugin_system.plugins_directory points elsewhere, plugins
missing from plugin_state.json were left out of the backup. It now reads
the configured directory from config/config.json, defaulting to
plugin-repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(startup): report a missing display section once

A config without a display section produced three errors for the one
problem ("Missing required configuration key: display", "Display
configuration is missing or empty" and "Display configuration is
missing"), and an empty one produced two. _validate_config now reports
it once, as a missing key or an empty section, and
_validate_display_config leaves it to that.

The module docstring said the validator fails fast; nothing in the
display service calls raise_on_errors(), so it now says the errors are
reported and startup continues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(wifi): share the copied blocks and name the AP constants

- _parse_nmcli_wifi_list() is the one parser behind _scan_nmcli and
  _scan_nmcli_cached.
- _verify_connected(), _wait_for_device_idle(), _failsafe_ap() and
  _mark_forced() replace blocks that were pasted two or three times in
  the connect and enable-AP paths. The device-idle wait now checks
  before its first one-second sleep instead of after it.
- _check_command() calls _find_command_path() instead of repeating it.
- AP_IP, PORTAL_PORT, AP_PROFILE_NAME and AP_PROFILE_NAMES name values
  that were spelled out 14, 12, 8 and 2 times; the two deletion loops
  now walk the same tuple. The iwconfig status path compares the AP
  address exactly: startswith() also skipped 192.168.4.10-19.
- Dropped a second WIFI.SIGNAL query that repeated the first, a no-op
  "if ssid: continue", the try/except around _connect_wpa_supplicant's
  constant return, and a second save of a scan scan_networks already
  saves.
- _ensure_wifi_radio_enabled's docstring says it returns True when the
  radio state cannot be read at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(config): drop dead branches and history comments in ConfigManager

- The module docstring pointed plugin authors at update_plugin_config(),
  which does not exist; it now names save_config_atomic() and
  save_raw_file_content().
- load_config's FileNotFoundError handler tested the message for
  "config_secrets.json", but a missing secrets file is handled where it
  is read, so only config.json reaches it; the check is gone.
- save_raw_file_content's `file_type == "main" or "secrets"` guard was
  always true (anything else raised earlier).
- get_raw_file_content('secrets') already returns {} for a missing file,
  so the os.path.exists() in front of two calls to it is gone.
- Comments that narrated earlier behaviour are rewritten as what the
  code does now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(background-data): present-tense comments, drop unused API

- Comments that told the history of each fix (what "used to" happen,
  "the old per-delivery release") now state the invariant the code keeps.
- get_statistics() no longer reports a constant 'queue_size': 0, and the
  uncalled clear_completed_requests() is gone (_cleanup_completed_requests
  does that job on every completion). Neither is referenced in core, the
  web UI or the plugin monorepo.

shutdown_background_service() has no production caller either, but it
is the only way to tear down the get_background_service() singleton,
which the tests rely on, so it stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(odds): drop the unread cache_ttl and merge the odds_data branches

BaseOddsManager loaded base_odds_manager.cache_ttl from config and never
used it: cached odds live for the update interval (get_odds' ttl=interval).
No core or monorepo code reads the attribute, so it is gone along with
its log line. The two consecutive `if odds_data:` blocks are one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(backup): one table for the single-file sections

config, secrets, wifi and ytm_auth were each spelled out in create,
preview, validate and restore. _SINGLE_FILE_SECTIONS lists them once,
with the RestoreOptions flag that restores each, and all four walk it.
Restore error messages keep their wording ("Failed to restore
<file name>").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(fonts): drop FontManager's write-only state and duplicate logs

- fonts_config, font_metadata and font_dependencies were written and
  never read; the performance_stats keys font_load_times, render_times,
  total_renders and the per-call "resolve" timings
  (_record_performance_metric) likewise. get_performance_stats() reads
  only the counters that remain. Nothing in core or the plugin monorepo
  references any of them.
- A failed BDF load was logged twice, by _load_bdf_font and again by
  get_font; get_font's line is the one kept.
- Removed "NEW:" and commented-out cozette entries, the "Copy font to
  assets/fonts" comment on code that copies nothing, and local imports
  of names the module already imports. The deprecated add_font() now
  resolves assets/fonts against the install root.

The @deprecated methods stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(text-helper): cache loaded fonts; drop the pre-textlength fallback

TextHelper declared _font_cache, cleared it and reported its size, but
never stored anything in it. load_fonts() now keeps each (file, size)
it loads there, so clear_font_cache() and get_font_cache_stats() mean
what they say and repeated load_fonts() calls reuse the fonts.

get_text_width() no longer catches AttributeError for Pillow releases
without ImageDraw.textlength; requirements.txt pins Pillow>=12.2.
The class docstring describes what the helper does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(common): fix wrong docstrings in api_helper, permission_utils, snapshot_policy

- permission_utils called 0o2775 "sticky bit"; the 2 is setgid, which is
  what makes new files take the directory's group.
- snapshot_policy pointed at web_interface/blueprints/api_v3.py, which
  is a package now; the health check is in api_v3/misc.py.
- APIHelper.clear_cache() lost a history note and a fallback to a
  clear() method that neither CacheManager nor the testing
  MockCacheManager has. The session headers are built from
  DEFAULT_HTTP_HEADERS instead of a copy of them, and the module
  docstring says what the module offers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sports): present-tense comments in the shared scoreboard renderers

- sports_scroll and sports_game_renderer comments that referred to "this
  PR", "the old flat 128px card" or what the renderer "previously" did
  now describe the current behaviour and its reason.
- The block explaining why non-finite settings are rejected sat above
  _score_reserve_width; it describes _center_gap_width and now lives in
  it.
- unshare_element_fonts wrapped its import of font_layout.load_truetype
  in an `except ImportError` that cannot fire inside core; the import
  stays at call time so tests can spy on the pinned loader.
- sports_card docstrings that told the history of a fix say what the
  code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(sports-shared): drop dead code, name the ESPN limit

- _get_weeks_data asked for limit=1000, which fetch_espn_scoreboard
  clamps to ESPN_MAX_LIMIT anyway; it now names that constant. Its
  unused `immediate_events = []` is gone.
- _get_season_schedule_dates() returned ("", "") and has no caller in
  core or the plugin monorepo.
- _should_log keeps its warning_type parameter (part of the inherited
  signature, though nothing in core or the monorepo calls it) and its
  docstring says the cooldown is shared across types.
- An unused ImageFont import is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(sync): one follower-mode switch, shared panel defaults

- The class docstring said the leader sends PNG frames. Frames go over
  UDP as raw RGB; PNG is only the Vegas scroll image sent over TCP. It
  now describes both paths.
- _enter_follower_mode() replaces the two copies of "note the leader,
  switch from standalone to follower, log, write status" in the frame
  and scroll-position handlers.
- The rows/cols fallbacks use DEFAULT_ROWS / DEFAULT_COLS from
  src.display_geometry, as chain_length already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(style): drop _layout_axis, name the layout group title

- ElementStyleResolver._layout_axis() had no caller in core or the
  plugin monorepo.
- _element_block_from_spec checked spec['size'] was a dict again after
  size_spec already had; it reads size_spec.
- The "Layout Offsets" title written into three generated schema blocks
  is _LAYOUT_TITLE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(logo-helper): say what the placeholder draws; name the 1.5 box factor

- _create_placeholder_logo's docstring said it draws the team
  abbreviation; it draws an outlined grey box and nothing else. The
  docstring says so, and the "in a real implementation you'd want text"
  comments are gone.
- The 1.5 x panel default logo box, written out six times, is
  DEFAULT_LOGO_BOX_FACTOR.
- ImageDraw is imported with Image at the top of the module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(logos): drop dead code and a duplicate regex in logo_downloader

- _SAFE_LEAGUE_CODE_RE was the same pattern as _SAFE_LEAGUE_RE; both
  checks use the one.
- get_logo_filename_variations reassigned the TA&M case to the list it
  already had; the function returns the two names directly.
- _get_team_name_variations() had no caller in core or the plugin
  monorepo.
- fetch_single_team's docstring was copied from fetch_teams_data; a log
  message read "for{team_id}".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: drop the Pillow<9.1 resample shim and a catch-and-reraise

- adaptive_images fell back to Image.LANCZOS/NEAREST for Pillow < 9.1;
  requirements.txt pins Pillow>=12.2. RESAMPLE_LANCZOS and
  RESAMPLE_NEAREST keep their names (src.common re-exports them).
- CacheManager.save_cache caught CacheError only to re-raise it; the
  disk write is now called directly, with the same result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(api-helper): stop the real CacheManager's cleanup thread

The cache-lifetime tests built a CacheManager and left its cleanup
thread's class-wide claim on the directory in place, which broke
test_cache_cleanup_thread_ownership when it ran later in the session.
The fixture now stops the thread on teardown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): core-common

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:32:29 -04:00
ChuckandClaude Opus 5.5 b11bcfa204 fix(plugins): store and plugin-manager bugs; tidy src/plugin_system (#635)
* fix(store): don't read a ZIP-installed plugin's remote from the LEDMatrix repo

update_plugin looked up remote.origin.url with `git -C <plugin> config
--local` for plugins that are not git checkouts. Under plugin-repos/ git
walks up to the enclosing LEDMatrix repository, so the lookup returned
LEDMatrix's own URL and a plugin missing from the registry was
"reinstalled" from the LEDMatrix repo. Only ask git when the plugin
directory has its own .git, the test _get_local_git_info already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schema): report each missing required field once, by name

validate_config_against_schema ran its own required-fields loop after
Draft7Validator.iter_errors, which already yields one `required` error
per missing field, so every missing top-level field was listed twice.
The validator's copy also printed the schema's whole `required` list
("Missing required property '['api_key', 'city']'") instead of the field.
Drop the loop and take the field name from the error itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(store): stop mangling repository URLs that contain ".git"

install_from_url and fetch_registry_from_url cleaned URLs with
`rstrip('/').replace('.git', '')`, which removes ".git" anywhere:
https://github.com/user/my.github.io became .../myhub.io, so installing
or browsing that repository asked GitHub for one that does not exist.

Add src/plugin_system/repo_urls.py with one anchored normalize_repo_url(),
same_repo() for comparisons, github_owner_repo() and github_api_headers(),
and use them for the five copies of the owner/repo parsing and GitHub
headers in the store and for saved repositories. GitHub URLs are now
recognised by urlparse().hostname everywhere: _get_latest_commit_info
used a substring test, and _install_from_monorepo_api parsed any host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(store): install a repository whose only branch is not main/master

_install_via_git returned None both when every clone failed and when the
last-resort clone of the repository's default branch succeeded.
_install_plugin_impl papered over it with `and not plugin_path.exists()`;
install_from_url did not, so a repository whose only branch is e.g.
`develop` was cloned, then treated as a failure, then "downloaded" from
main/master archives that do not exist.

After a default-branch clone, return the branch the clone checked out
(read from .git/HEAD), so None means failure and nothing else, and give
both callers the same `branch_used is None` fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): judge the memory limit on each call's own growth

monitor_call stores `metrics.memory_mb = max(previous, growth)`, and
_check_limits compared that high-water mark with max_memory_mb. It never
decreases, so once one update() grew the process past the limit every
later call raised ResourceLimitExceeded and the circuit breaker kept
reopening. Pass the call's own RSS growth to _check_limits; keep the
high-water mark for reporting and document what it measures.

Remove ResourceMetrics.update_average_execution_time: nothing called it,
and it overwrote the running total with the average.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): reload_plugin re-reads the manifest from the discovered directory

reload_plugin read `plugins_dir / plugin_id / "manifest.json"`, ignoring
the discovery map and the plugin_dirs rules. For a plugin whose
directory name differs from its manifest id the path did not exist, the
re-read was skipped without a word, and the reload kept the stale
manifest. Resolve the directory with find_plugin_directory, as
load_plugin does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): drop the always-null last_display from plugin state info

PluginStateManager reported `last_display` from `_last_display`, which
nothing ever wrote, so it was null for every plugin. Recording it in
PluginExecutor.execute_display would not help: get_state_info's only
reader is the web process, whose PluginManager never calls display().
Remove the field, its dict and get_last_display() (no caller in core,
the web UI or the plugin monorepo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(store): share the rollback and requirements helpers, drop dead code

- install_plugin and _reinstall_with_rollback set aside, discard and
  restore the old copy through _set_aside/_discard_backup/_restore_backup
  instead of two copies of the same blocks.
- The loader and the store run the same pre-pip checks through
  contained_plugin_dir() and requirements_to_install() in plugin_loader.
  They still invoke pip differently (sys.executable -m pip vs. the sudo
  wrapper). `except (BrokenPipeError, OSError)` + `isinstance(e, OSError)`
  becomes `except OSError` checking errno.EPIPE.
- load_module never returns None, so load_plugin's check is gone and the
  docstring says what it raises.
- Remove the always-true JSONSCHEMA_AVAILABLE, the inline re-imports of
  re and permission_utils, the fake status_result object nobody reads,
  hasattr(git_error, 'cmd'), a redundant "merge conflict" test and
  `import traceback` (exc_info=True does it).
- Correct comments: install_from_url names the directory for the
  caller's id when given (not always the manifest id), _get_local_git_info
  saves one git subprocess (not four), _enrich calls two helpers,
  search_plugins documents all its arguments, _find_plugin_path states
  its behaviour instead of a TODO, and history narration is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): tidy base_plugin, correct plugin_manager/state comments

- base_plugin: drop the unused `import logging`; get_display_duration
  runs the instance value and the config value through one
  _positive_seconds() helper instead of two copies of the coercion; the
  'static'/'none'/fallback branches of get_vegas_display_mode, which all
  returned FIXED_SEGMENT, are one; fix the mis-indented validate_config
  example; say that get_supported_vegas_modes/get_vegas_segment_width
  are not consulted by core (kept, plugins override them).
- schema_manager: import expand_style_elements normally rather than
  swallowing an ImportError of a core module.
- plugin_manager: the plugins directory is the configured one
  (plugin-repos/ by default), not plugins/; get_config() returns the live
  dict, not a copy, so the interval cache comments say what it saves.
- state_manager: config_version and the file version are not used to
  detect corruption; say what they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): stop writing data/plugin_operations.json

PluginOperationQueue wrote its finished-operation history to
data/plugin_operations.json after every operation, and read it back only
into its own in-memory list, which only get_operation_history() exposes
-- and nothing calls that. The operation-history endpoint reads
OperationHistory (data/operation_history.json). No code in src/,
web_interface/, scripts/ or test/ reads the file.

Drop the history_file/lazy_load parameters and the load/save code; the
bounded in-memory history stays. web_interface/app.py and the
integration test stop passing the removed arguments. An existing
data/plugin_operations.json is left in place (data/* is gitignored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): plugin-system

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:32:02 -04:00
ChuckandClaude Opus 5.5 3967a6cffc fix(security): re-harden root sudo helpers; installer fixes; ARCHITECTURE and PERMISSIONS docs (#640)
* docs: add ARCHITECTURE and PERMISSIONS guides

ARCHITECTURE.md maps the processes, the state the display and web
services share through the cache, the display loop, the plugin system,
the web UI and the update path, with links into the code and a
where-to-start table.

PERMISSIONS.md lists who owns what after install, both sudoers files
(and why iptables is not granted), the polkit rule, and which
scripts/fix_perms script to run as which user.

Both are linked from the docs index, along with the MQTT bridge README
and src/common/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: correct stale setup, service and troubleshooting claims

- README: quick actions run systemctl on ledmatrix.service (run.py), not
  display_controller.py; use_short_date_format has no effect; the
  installer uses system pip with --break-system-packages, not a venv.
- CONFIG_DEBUGGING: LEDMATRIX_DEBUG must be "true"; logs are in journald.
- GETTING_STARTED, WEB_INTERFACE_GUIDE, TROUBLESHOOTING: enabling a
  plugin, plugin settings, brightness and Vegas settings apply without a
  restart; matrix hardware settings still need one.
- TROUBLESHOOTING: install dependencies with sudo so the root service
  sees them; point permission problems at PERMISSIONS.md instead of a
  project-wide chown.
- ADVANCED_FEATURES: real BackgroundDataService stats keys; Vegas hooks
  return VegasDisplayMode and None falls back to capture; cache files
  are 0660; fix_web_permissions.sh runs as the web user and does not
  touch sudoers.
- STARLARK_APPS_GUIDE: only the linux-arm64 pixlet binary is downloaded.
- HOW_TO_RUN_TESTS: test class examples that exist.
- CLAUDE.md: PluginStoreManager, plugin_dirs.py, monorepo installs via
  the Trees API with ZIP fallback, requirements.txt is optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: mark deprecated plugin APIs and state manifest fields once

Methods @deprecated("3.7.0") (the set pinned in test_deprecation.py)
were shown as current API in the quick reference, API reference,
advanced guide, development guide and FONT_MANAGER. Each is now marked
deprecated with its replacement. FONT_MANAGER is rewritten around the
current API; the override editor is gone and override methods are
deprecated.

Required manifest fields were stated three different ways. The API
reference now has one section: the 7 schema-required fields, the 4 the
store refuses without, class_name for the loader, and the 8 to set.
The other guides link to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: document every src/common module and every widget

- src/common/README.md covered 7 of 17 modules. It now has a table of
  all of them (purpose, whether plugins import it, release to floor
  on), a short entry each, and logging advice that matches the code.
- SPORTS_UNIFICATION listed two shared modules and called
  sports_helpers the first; it now lists all six.
- The widgets README lists all 28 registered widgets plus the support
  files, and absorbs the parts that only docs/widget-guide.md had
  (x-options.labels, x-advanced, x-display hidden, plugin-file-manager).
  docs/widget-guide.md is now a pointer to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): fix_web_permissions.sh re-hardens the root sudo helpers

The script chowns the whole project to the web user. That included
scripts/fix_perms/safe_plugin_rm.sh and safe_pip_install.sh -- the two
helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root -- so
running it turned both into a root shell for whoever can edit them. It
also re-grouped config_secrets.json away from ledmatrix.

After the chown it now does what first_time_install.sh's Steps 11 and
11.1 do: helpers back to root:root 755, and config_secrets.json back to
the web unit's User=:ledmatrix 640. Each step is non-fatal and prints the
manual command if it fails.

Also fixes what the script and its docs claimed: it never configured
sudoers, its closing hint pointed at ./configure_web_sudo.sh (wrong
path), and the README and ADVANCED_FEATURES.md said to run it with sudo,
which it refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): validate and harden every sudoers drop-in the scripts write

configure_wifi_permissions.sh copied its rules into
/etc/sudoers.d/ledmatrix_wifi without `visudo -c`. A malformed drop-in
makes sudo refuse every command for every user, which on a headless Pi
leaves no way back in. It now checks first and leaves the installed file
alone when the rules do not parse, as the other two writers do. (It
already used mktemp, so that part of the review did not apply.)

It also grants the two literal commands wifi_manager.py runs for
NetworkManager's shared-mode dnsmasq drop-in -- `cp
/tmp/ledmatrix-nm-dnsmasq.conf .../dnsmasq-shared.d/ledmatrix-captive.conf`
and `rm -f` of that file. The directory's mkdir was granted, the file was
not. Both are pinned in test_sudo_allowlist_covers_calls.py.

configure_web_sudo.sh wrote its rules to /tmp/ledmatrix_web_sudoers_$$,
a predictable name in a world-writable directory; it now uses mktemp with
an EXIT trap, as first_time_install.sh does. It sets mode 440 on the
installed file instead of leaving the temp file's mode, and finds visudo
in /usr/sbin when that is not on the user's PATH, which skipped the
check silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(install): escape the project path in the DNS-fix and MQTT unit renderers

install_dns_fix.sh and install_mqtt_bridge.sh substituted
__PROJECT_ROOT_DIR__ with the raw path, while the other three renderers
go through sed_escape_replacement from lib_systemd_render.sh. A checkout
under a path containing `&`, `\` or `|` rendered a corrupted unit from
these two only. Both now source the helper and use it, and a test checks
that every placeholder substitution in scripts/install uses an escaped
value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(install): stop the installer scripts reporting things that are not true

- first_time_install.sh printed "Password: ledmatrix123" for the setup
  access point. wifi_manager creates it as an open network ("No
  password" on the panel), so it now says so.
- Step 10.1 printed "✓ WiFi management permissions configured" straight
  after its own failure message; install_wifi_monitor.sh printed
  "✓ Package installation completed" after a failed apt install. The
  tick now only follows success.
- Step 7 printed "Web dependencies already installed ... in Step 5" in
  the one branch that runs because Step 5 did not install them, then
  created .web_deps_installed on that basis. It now warns and leaves the
  marker off so the next run retries, as the comment below it intends.
- check_system_compatibility.sh called Debian 12 Bookworm "full
  compatibility confirmed" while first_time_install.sh refuses anything
  but Debian 13. Bookworm, older Debian and non-Debian systems are now
  errors. Its counters used ((X++)), which under `set -e` exits the
  script at the first warning or error (the expression is 0), so the
  check never reached its summary on any system with one.
- configure_web_sudo.sh and configure_wifi_permissions.sh finished by
  testing `sudo -n test -f ...` and `sudo -n nmcli device status`,
  neither of which is granted, so they always reported a failure. They
  now ask `sudo -n -l` about commands the new rules do grant, which
  checks the rule without running anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(install): print the completion summary before rebooting

With -y -- and so for every one-shot `curl | bash` install, which always
passes -y -- first_time_install.sh ran `reboot` about 180 lines before
its "Installation Complete / Web UI Access" summary. reboot returns at
once, so the summary printed while the Pi was going down and the SSH
session usually dropped before the web UI address could be read.

The reboot block moves, unchanged, to the very end of the script. The
interactive prompt now also follows the summary. Because the summary now
runs before the -y reboot, its one command that could fail under
`set -Eeuo pipefail` (the SSID lookup, when nmcli reports a connected
device but no active network line) gets `|| true`; a missing SSID was
already handled as "SSID unknown".

one-shot-install.sh prints its "Next steps" after the installer returns,
by which time the reboot is under way, so it now says so, and README's
Quick Install mentions the automatic reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(scripts): correct wrong comments and messages, drop dead code

No behaviour change except the output text noted below.

- 2775 is setgid, not the sticky bit (first_time_install.sh Step 3.1,
  fix_plugin_permissions.sh), and root needs no "PWM hardware access"
  to plugin files.
- The 777 comments in first_time_install.sh Step 3's fallback and
  fix_assets_permissions.sh said root needs it to write. Root ignores
  mode bits; the comments now say what 777 actually opens. The 777
  itself is unchanged.
- apt_remove ends in `|| true`, so Step 12's "Some packages could not be
  removed" branch could never run; it is gone and the helper stays
  non-fatal.
- detect_web_service_user's comment named Step 8 for the web unit
  (install_service.sh installs it in Step 7.5) and now says which
  branch actually runs.
- Step 5 described an "already installed" check that does not exist;
  the ACTUAL_USER comment described the re-exec backwards.
- on_error printed a literal "\n" before "Common fixes:".
- Dead code: one-shot-install.sh's uncalled fix_tmp_permissions,
  LEDMATRIX_ELEVATED=1 (never read) on the sudo re-exec, and
  configure_web_sudo.sh's unused PYTHON_PATH, which also made a missing
  python3 fatal for rules that never mention it.
- start_display.sh / stop_display.sh said "for user: <you>"; the
  service runs as root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(fix_perms): fix_cache_permissions.sh uses setup_cache.sh's model

There were two models for /var/cache/ledmatrix. setup_cache.sh (the
installer's Step 2) and install_web_service.sh share it through the
ledmatrix group: root:ledmatrix, 2775, files 660, which is also what
DiskCache relies on to give files the directory's group.
fix_cache_permissions.sh instead made it 777 and re-grouped it to the
invoking user's group, undoing that.

It now runs setup_cache.sh for /var/cache/ledmatrix and keeps its own
handling of ~/.ledmatrix_cache. Dropped: /var/cache/ledmatrix/
placeholder_logos (nothing reads it) and the checks against the
`daemon` user (no service runs as daemon).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: pin actions/checkout in the Claude workflows, drop template comments

claude.yml and claude-code-review.yml used actions/checkout@v4 while
test.yml and release-version-check.yml pin the v4.2.2 commit SHA; they
now pin the same SHA. The commented-out starter-template settings
(prompt, claude_args, paths, author filter) are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(scripts): index every script and list removal candidates

New scripts/README.md gives one line per top-level script and scripts
directory, marked keep, dev-only or diagnostic, and lists the eight
scripts nothing in the repo refers to as candidates for removal (kept
for now). The install, utils and dev READMEs now list the files they
were missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: tighten two checks that mutation testing showed were too loose

- The wifi sudoers check matched `visudo -c -f "$TEMP_SUDOERS"` in the
  error report too, so replacing the check with `if false` still passed.
  It now requires the command as the condition.
- The summary test never had the setup access point up, so reinstating
  the bogus "Password: ledmatrix123" line went unnoticed. A case with
  hostapd active now checks the AP is described as open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(permissions): describe the repaired fix_perms scripts and new WiFi grants

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): docs-scripts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:31:41 -04:00
ChuckandClaude Opus 5.5 4e61d7248a refactor(web): one error-response path for api_v3 (#624)
* refactor(web): answer unhandled api_v3 errors from one blueprint handler

Fifty-three api_v3 routes ended in a copy of the same catch-all: log the
traceback, return {status, "An error occurred; see logs for details",
details: describe_exception(e)} with a 500. They are replaced by one
errorhandler on the api_v3 blueprint that returns exactly that body.

It lives on the blueprint rather than falling through to app.py's global
handler because the two answers differ: the global one adds
error_code: UNKNOWN_ERROR, and api_client.js sends a body with an
error_code to the error modal and one without to a plain toast. A
blueprint handler also gives tests that mount api_v3 on a bare Flask app
the same answer the real app gives.

Only handlers that were byte-for-byte that shape were removed (matched on
the AST, and each rewritten function re-parsed and compared). Handlers
with their own message, extra keys, operation-history records or cleanup
stay, as does execute_plugin_action's step-1 handler, which sits inside
an `except subprocess.TimeoutExpired` arm that would otherwise turn a
plugin's timeout into a 408.

HTTPExceptions raised inside a route go back as themselves in the global
handler's 4xx shape. Where a removed catch-all used to swallow one (only
delete_plugin_asset's non-silent get_json() is reachable), a malformed
request now gets its 415/400 instead of a 500.

Most of the diff is re-indentation from unwrapping the try blocks;
`git diff -w` shows the real change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): plugin action errors name the real failure, not UnboundLocalError

execute_plugin_action bound a local `logger` in its JSON-parsing arm,
which made `logger` local to the whole function. Every other
`logger.error` in it then raised UnboundLocalError, so a failing OAuth
step-1 script was reported as "UnboundLocalError: cannot access local
variable 'logger'" -- from the step-1 handler, and before the previous
commit from the route's outer catch-all too. Use the module logger.

Found by comparing every api_v3 route's forced-failure response before
and after the catch-all consolidation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): drop the error category and exception-name code guessing

WebInterfaceError derived an ErrorCategory from every error code and put
it in each structured error body as `error_category`. Nothing reads it:
not the web UI (static/ and templates/), not the tests beyond the ones
pinning the mapping itself, and not any plugin in ledmatrix-plugins. The
enum, the inference table and the JSON key go.

from_exception() could also guess an error code from the exception's
class name ("Config" -> CONFIG_LOAD_FAILED, and so on). Every caller
passes a code, so the guess never ran; error_code is now required.

suggested_fixes stays: the error dialog in static/v3/js/utils/
error_handler.js lists them.

The REST reference loses error_category and says what an unanticipated
exception in an /api/v3 route answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): one call for the from_exception error responses

Nine plugin routes built a structured error by hand:

    from src.web_interface.errors import WebInterfaceError
    error = WebInterfaceError.from_exception(e, ErrorCode.X)
    return error_response(error.error_code, error.message,
                          details=error.details, context=error.context,
                          status_code=500)

That is now exception_error_response(e, ErrorCode.X) in api_helpers, so
error_response() is the only structured-error entry point the routes
use. The three operation-history routes never passed the context, and
with_context=False keeps their bodies exactly as they were; a test
compares the helper against the hand-written pair for both forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): one api_v3 error-response path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:53:19 -04:00
ChuckandClaude Opus 5.5 ece416c4e5 refactor(plugins): one plugin-directory resolver (#623)
* refactor(plugins): one resolver for plugin id -> directory

Five places mapped a plugin id to its directory, each with its own rules
and each re-reading manifests per lookup: PluginManager discovery and
get_plugin_directory, PluginLoader.find_plugin_directory,
PluginStoreManager._find_plugin_path / list_installed_plugins, and
state_reconciliation.disk_plugin_ids. They disagreed on backup dirs,
on whether the manifest id or the directory name is the id, on duplicate
ids and on path safety.

src/plugin_system/plugin_dirs.py now holds the rules once:
PluginDirectoryIndex scans one directory and reads each manifest once;
resolve_plugin_dir() searches directories in order. What legitimately
differs per caller is an explicit argument: search dirs (discovery and
the loader: configured dir only; the store: configured then sibling
plugins/), ledmatrix- prefix (not for the store), case folding (loader
only), manifest pass (not for get_plugin_directory, whose discovery map
already holds it).

Behaviour changes, all for layouts installs do not produce:
- a directory whose manifest declares the id beats one merely named for
  it (discovery already worked this way; the loader and store now agree)
- the store searches the configured dir completely before plugins/
- backup and hidden dirs are skipped everywhere (the loader's case and
  manifest scans and list_installed_plugins used to return them)
- duplicate ids resolve deterministically (exact name, then
  ledmatrix-<id>, then by name) with a one-time warning; discovery no
  longer lists the id twice
- disk_plugin_ids / list_installed_plugins report manifest ids, falling
  back to the directory name; auto-update looks the directory up
- ids that are not one plain path segment resolve to nothing in every
  caller (the loader used to truncate them, the store to join them)

The .standalone-backup- marker is one constant, BACKUP_MARKER, used by
store_manager's rename-aside names and every lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): one plugin-directory resolver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:52:52 -04:00
ChuckandClaude Opus 5.5 13bbb537f3 refactor(web): one logging setup and one TTL cache for the web process (#621)
* refactor(web): use src.logging_config in the web process; routine requests to DEBUG

The web interface had its own logging setup (web_interface/logging_config.py)
that replaced the root handlers with a plain stdout formatter. The web
service's journal lines therefore never carried a syslog priority, so
`journalctl -p err -u ledmatrix-web` returned nothing while errors were
logged, and the line shape differed from the display's (the log viewer's
prefix stripping only matched the display format). It also ran after the
module-level managers were built, so their INFO lines at import (including
"Re-removed N uninstalled plugin(s)") were dropped.

app.py now calls src.logging_config.setup_logging() first thing, the same as
run.py: journald priorities under systemd, LEDMATRIX_DEBUG honoured,
LEDMATRIX_JSON_LOGGING still selects JSON.

Per-request logging moves to web_interface/request_logging.py. Every request
used to be logged at INFO, so the UI's polling filled the journal
("GET /api/v3/errors/summary - 200" every minute per tab). Now a successful
GET/HEAD/OPTIONS is DEBUG, a successful write is INFO, 4xx WARNING, 5xx
ERROR. Durations use perf_counter and print to 0.1ms.

The duplicate module is deleted; nothing else imported it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): one thread-safe TTL cache for the web process

web_interface/cache.py becomes a small TTLCache class (lock-guarded,
monotonic clock) with the existing get_cached/set_cached/delete_cached/
invalidate_cache helpers kept on top of a shared instance, so the api_v3
callers are unchanged.

Bugs fixed:
- set_cached(ttl_seconds=...) ignored its TTL; only the reader's value
  counted and get_cached defaulted to 60s. An entry now expires after the TTL
  it was stored with; a reader's ttl_seconds can only shorten that. Both
  current callers pass the same value on both sides (fonts_catalog 300s,
  system_status 10s), so their observable TTLs are unchanged.
- get_cached deleted expired keys without a lock; two threads reading the
  same expired key could raise KeyError (reproduced), which the endpoints
  turned into a 500.

app.py's two hand-rolled systemctl caches (_ap_mode_cache, 30s, and
_ledmatrix_service_cache, 15s) now share one helper over a private
TTLCache, with the same TTLs. The AP-mode check used to retry on every
request after a failure (and log an ERROR each time); a failure now keeps the
last known answer for the TTL, as the display-service check already did. With
no systemctl at all (a dev machine) it answers False without forking.

Left alone as not TTL memoisation: the gzip cache (size-bounded, keyed by URL
and version), the settings search index (keyed by installed-plugin set), the
widget bundle (keyed by file fingerprint) and CacheManager (cross-process).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): web logging and TTL cache

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): only ask systemctl about known units

Codacy flagged the systemctl argv built from a variable. The unit now has
to be one of two literals, and anything else raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): response_time_ms reads the same clock request_logging stamps

request_logging now stamps request.start_time from perf_counter, but
success_response still subtracted it from time.time(), so metadata
reported ~1.8e12 ms. Found testing on ledpi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:52:17 -04:00
ChuckandClaude Opus 5.5 afe9001aed refactor(fonts): one BDF loader and one BDF rasterizer (#627)
* refactor(fonts): one BDF loader and one BDF rasterizer

BDF faces were loaded three ways (FontManager._load_bdf_font,
element_style._load_bdf, DisplayManager._load_fonts) and drawn by two
copies of the same per-pixel loop (DisplayManager._draw_bdf_text and the
plugin test harness's "replicated" copy), which golden images and
check_plugin/dev_server previews rely on matching the panel.

src/common/bdf_font.py now owns both:
- load_bdf_face(path, size) -> (face, realised_px): native-strike fallback
  for sizes the file lacks, one bounded LRU cache keyed on path, size and
  mtime. FontManager, element_style and DisplayManager delegate to it;
  read_bdf_native_size moves here (the old names delegate).
- draw_bdf_text(draw, text, x, y, face, color, clip): builds each glyph as
  a 1-bit mask and fills it with ImageDraw.bitmap instead of a draw.point
  per pixel. A blending Draw (RGB image, "RGBA" mode) keeps the point path
  so translucent colours still blend.

Pixel-identical: 220,032 renders (every bundled BDF at native and
off-strike sizes, 14 strings, 4 colours, clipped on every edge, through
each old loader x rasterizer) match origin/main byte for byte.
test/test_bdf_font.py keeps a lightweight version against a frozen copy of
the old loop. DisplayManager._draw_bdf_text goes from 1.4-23 ms to about
0.1 ms per string (the old loop re-read FreeType's buffer as a Python list
for every pixel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(testing): harness calendar_font is sized like the panel's

VisualTestDisplayManager built its 5x7 calendar_font / bdf_5x7_font as a
bare freetype.Face. With no size set its ascender reads 0, so BDF text
drawn with it landed 6px above where DisplayManager draws it -- entirely
off the canvas at y=0 -- and get_font_height() returned 0. Golden images
and check_plugin / dev_server previews showed text the panel does not.

Load it through load_bdf_face at the panel's 7px, so it is the very face
DisplayManager uses. Across the differential run this changes only the
cases drawn with the harness's own calendar_font (968 of 220,032), which
now match the panel's output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fonts): one BDF face per thread

The shared face cache now hands every loader (FontManager, element_style,
DisplayManager, the harness) the same freetype.Face. FreeType does not allow
two threads to use one face at once, since load_char rewrites its glyph
slot, so key the cache by thread as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:51:53 -04:00
ChuckandClaude Opus 5.5 abedc46104 refactor(sports): merge the sports_shared/sports_card twins that behave identically (#626)
* refactor(sports): wrap the sports_card twins that behave identically

SportsCoreSharedMixin (switch mode, via each scoreboard's sports.py) and
sports_card (scroll/Vegas mode, via game_renderer.py) carried the same
helpers twice. test/test_sports_twins.py now calls every pair with the
same inputs -- the eight scoreboards' harness fixture games in flat,
flat+nested and nested-only shapes, plus edge cases (favourites by id and
abbreviation, NRL's colliding abbreviations, missing and non-numeric
scores, bad zones, out-of-range dates, shared font faces).

Identical pairs become thin wrappers over the sports_card function:
_card_option, _vs_text, _format_game_time, _coerce_rgb, _crisp_size (with
the class's own tables), _unshare_element_fonts (with the class's own
element map, via a new optional argument), and the colour/month/weekday/
font-grid tables (dicts copied, not aliased). _format_game_date shares the
card's formatting body but keeps its own setting, weekday zone and month
table; _schema_font_size shares the parser but keeps its per-class cache,
because a reloaded plugin gets new classes and a shared path cache would
stop it seeing an edited schema. _resolve_font_size agrees but keeps its
body so it still dispatches through the overridable hooks.

No behaviour change: old and new mixin/card agree on all 22,994
comparisons over the test corpus, and the pairs that do differ
(favourite-result colours on nested payloads and by favourites source,
the weekday's timezone, the element-name map, per-mode colours) are left
alone and pinned in TestPinnedDivergence for an owner decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sports): pin that an ambiguous NRL abbreviation tints in both modes

NRL's resolver passes a shared abbreviation ("NEW") through with an error
and its _is_favorite_game matches ids only, but both favourite-colour
helpers match on abbreviation as well, so both display modes tint a
Knights or Warriors result for a user who typed "NEW". The twins agree;
neither consults the _favorite_key seam. Pinned so a fix is deliberate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:51:35 -04:00
ChuckandClaude Opus 5.5 1fe7237799 refactor(install): generate the web sudoers rules in one place (#622)
* refactor(install): generate the web sudoers rules in one place

/etc/sudoers.d/ledmatrix_web was written by two copies of the same
allow-list: a heredoc in first_time_install.sh Step 10 and a block of
echo lines in scripts/install/configure_web_sudo.sh. They drifted before
(safe_pip_install.sh was granted by one only), and a test existed just
to catch that.

Both now call web_sudoers_rules() from the new
scripts/install/lib_sudoers.sh and keep their own validate (visudo -c),
install and confirm flows.

- first_time_install.sh output is byte-for-byte unchanged, so a device
  re-running the installer gets "already up to date". If the library is
  missing, Step 10 keeps the installed file and carries on, the same way
  it handles rules that fail visudo (an empty file would pass visudo).
- configure_web_sudo.sh now writes the installer's layout: same 18 rules,
  different comments and order. It still leaves out reboot, poweroff and
  journalctl when they are missing; the library does that for both.

The drift test now pins the generator's grants, checks that neither
installer writes rules of its own, and runs each installer's call line
to check the argument order. Tests that read the rule text now read the
library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(install): detect the web service user in one function

first_time_install.sh pasted the same WEB_SERVICE_USER detection block
three times (Step 3.1's fallback, the plugin-repos setup and Step 11).
The copies were identical apart from comments; they now call
detect_web_service_user(), whose body is that block unchanged.

Behaviour is the same: the function sets the same global and always
returns 0, as the inline if-chain did. Checked on Linux against all
three original copies across 13 layouts (installed unit with and without
User=, the repo as shipped, each grep branch, template placeholders).

The comment notes that the install_web_service.sh / install_service.sh
greps no longer match anything, so until Step 8 installs the unit the
result is "root". That behaviour is left as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:51:14 -04:00
ChuckandClaude Opus 5.5 ddf5f085a5 perf(cache): tell a stale record from its header instead of parsing it (#633)
The sports plugins cache whole season schedules: 53MB for MLB, 18MB for
NHL, 17MB for NCAA baseball. On a Pi 4, orjson.loads of the MLB file
takes ~1.8s with the GIL held, and every thread in the display service
waits -- the stall watchdog caught the render thread frozen 0.5-1.3s with
the interpreter itself blocked, right on these reads. When a season record
expired, DiskCache.get paid that whole parse only to find the timestamp
too old and throw the result away.

CacheManager.set now writes timestamp and ttl ahead of the data, and
DiskCache.get reads them from the first 256 bytes of the file, applying
the same rule as before (a per-entry ttl wins over max_age; no limit
means never stale). A record that is stale is refused without being
parsed. Files in the old layout, and records from other writers, don't
match the header and are parsed in full as before.

Also: ESPN responses in the background data service and espn_dates are
parsed with orjson when it is installed (src/common/json_body.py). The
stdlib parser behind response.json() takes 3.1s on the MLB season
against orjson's 1.8s, both with the GIL held. espn_dates imports it with
a fallback, since plugins bundle copies of that module for older cores.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:50:50 -04:00
407 changed files with 35597 additions and 20079 deletions
-1
View File
@@ -4,4 +4,3 @@ exclude_paths:
- "plugins/**" - "plugins/**"
- "assets/**" - "assets/**"
- "test/**" - "test/**"
- "scripts/debug/**"
+6
View File
@@ -1,2 +1,8 @@
# Auto detect text files and perform LF normalization # Auto detect text files and perform LF normalization
* text=auto * text=auto
# Files the Pi executes must stay LF even in a Windows checkout with
# core.autocrlf=true: a CRLF shebang line fails with "bad interpreter",
# and systemd rejects CRLF unit files.
*.sh text eol=lf
*.service text eol=lf
+6 -16
View File
@@ -3,21 +3,13 @@ name: Claude Code Review
on: on:
pull_request: pull_request:
types: [opened, synchronize, ready_for_review, reopened] types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs: jobs:
claude-review: claude-review:
# Optional: Filter by PR author # Pull requests from forks get no repository secrets, so without this
# if: | # guard every outside contributor's PR showed this check red for a reason
# github.event.pull_request.user.login == 'external-contributor' || # they can't fix. Skipped checks don't block merging.
# github.event.pull_request.user.login == 'new-developer' || if: github.event.pull_request.head.repo.full_name == github.repository
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -27,13 +19,13 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Run Claude Code Review - name: Run Claude Code Review
id: claude-review id: claude-review
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
with: with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Review PRs opened by the Claude GitHub App. Without this the action # Review PRs opened by the Claude GitHub App. Without this the action
@@ -45,6 +37,4 @@ jobs:
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins' plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+2 -10
View File
@@ -26,13 +26,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs actions: read # Required for Claude to read CI results on PRs
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Run Claude Code - name: Run Claude Code
id: claude id: claude
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
with: with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -40,11 +40,3 @@ jobs:
additional_permissions: | additional_permissions: |
actions: read actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+70 -3
View File
@@ -8,7 +8,7 @@ on:
# needs a re-run or didn't get created. # needs a re-run or didn't get created.
workflow_dispatch: workflow_dispatch:
# Both jobs only check out the repo and run pytest. # The jobs only check out the repo and run the tests.
permissions: permissions:
contents: read contents: read
@@ -35,7 +35,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt pip install -r requirements.txt -r web_interface/requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator pip install RGBMatrixEmulator
- name: Run plugin safety harness - name: Run plugin safety harness
@@ -58,7 +58,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt pip install -r requirements.txt -r web_interface/requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator pip install RGBMatrixEmulator
# Run the ENTIRE test tree (except test/plugins, which the # Run the ENTIRE test tree (except test/plugins, which the
@@ -73,3 +73,70 @@ jobs:
--cov=src --cov=web_interface \ --cov=src --cov=web_interface \
--cov-report=term \ --cov-report=term \
--cov-fail-under=52 --cov-fail-under=52
js-tests:
name: Web UI JS tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r web_interface/requirements.txt
npm install --no-audit --no-fund --prefix test/js
# The DOM suites test the real server-rendered pages and API, so they
# need the web interface running. REQUIRE_DOM turns "couldn't reach it"
# into a failure instead of a silent skip.
- name: Start the web interface
run: |
EMULATOR=true python -c "from web_interface.app import app; app.run(host='127.0.0.1', port=5000, threaded=True)" > web.log 2>&1 &
for i in $(seq 60); do curl -sf -o /dev/null http://127.0.0.1:5000/ && exit 0; sleep 1; done
cat web.log
exit 1
- name: Run JS suites
env:
BASE: http://127.0.0.1:5000
REQUIRE_DOM: "1"
run: node test/js/run_all.js
type-check:
name: Type check (mypy ratchet)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
# The runtime requirements are installed so mypy sees the real types of
# PIL, requests, psutil and friends -- missing, they'd be Any and the
# result would differ from a developer's machine. mypy and the stubs are
# pinned so a new release can't turn this red without a code change.
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r web_interface/requirements.txt
pip install mypy==1.20.2 types-requests==2.33.0.20260906 types-pytz==2026.4.0.20260926
# mypy on exactly the modules in mypy-clean.txt; fails on any error in
# them, or if a listed file is missing. See CONTRIBUTING.md.
- name: Run mypy on the ratchet list
run: python scripts/check_types.py
+11 -13
View File
@@ -3,15 +3,13 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# Secrets # Secrets and per-device state. Everything the software writes into config/
config/config_secrets.json # is local to one device -- config.json, config_secrets.json, wifi_config.json,
# Atomic writes leave these behind when a save or a test is interrupted; # ytm_auth.json (a login session), saved_repositories.json, font_overrides.json,
# the suite drops several per run. # and the temp files atomic writes leave behind when interrupted -- so only
config/.config_secrets.json.tmp.* # the templates are tracked. Listing files one by one missed several.
config/config.json config/*
config/config.json.backup !config/*.template.json
config/wifi_config.json
config/uninstalled_plugins.json
credentials.json credentials.json
token.pickle token.pickle
@@ -81,10 +79,10 @@ assets/stocks/crypto_icons/
# Plugin operation state written at runtime. # Plugin operation state written at runtime.
# #
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json # web_interface/app.py writes data/plugin_state.json and data/operation_history.json
# and data/operation_history.json as the web interface runs, into a directory that # (older releases also data/plugin_operations.json) as the web interface runs, into
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever # a directory that ships tracked (data/.gitkeep). Unignored, every rig that ever
# opened the web UI -- and every test run that constructs the app -- left three # opened the web UI -- and every test run that constructs the app -- would leave
# untracked files behind and a permanently dirty `git status`. Same reasoning as # untracked files behind and a permanently dirty `git status`. Same reasoning as
# the logo rule above: a checkout that is always dirty is a checkout nobody reads. # the logo rule above: a checkout that is always dirty is a checkout nobody reads.
data/* data/*
+13 -5
View File
@@ -37,14 +37,22 @@ repos:
types: [python] types: [python]
pass_filenames: false pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-mypy # The mypy ratchet -- the same check as CI's "Type check (mypy ratchet)"
rev: v1.8.0 # job: mypy on exactly the modules listed in mypy-clean.txt. Run it with
# pre-commit run mypy --hook-stage manual
# A local hook rather than mirrors-mypy so mypy sees the packages installed
# from requirements.txt, as CI does; an isolated hook env without them types
# PIL, requests and friends as Any and reports different errors. Needs
# mypy==1.20.2 (the version CI pins) in the environment you commit from.
- repo: local
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: [types-requests, types-pytz] name: mypy (ratchet, mypy-clean.txt)
args: [--ignore-missing-imports, --no-error-summary] entry: python scripts/check_types.py
language: system
pass_filenames: false pass_filenames: false
files: ^src/ always_run: true
stages: [manual]
- repo: https://github.com/PyCQA/bandit - repo: https://github.com/PyCQA/bandit
rev: 1.8.3 rev: 1.8.3
+600 -114
View File
@@ -19,129 +19,122 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
- `FontManager.get_font()` returns a BDF font at its native size when asked for ### Fixes
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
return PIL's default font, a different typeface, so a plugin that relied on
that will now render the font it asked for.
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
the display are written (`config/wifi_status.json`).
- `src.device_location` — a blank `Location` field on a Starlark (Tidbyt) app
now renders at the device's City / State / Country (geocoded once via
Open-Meteo and cached) instead of the app author's hard-coded default,
usually San Francisco. A location saved on the app still wins. With no
device city set, or when the lookup fails or finds no match, the app keeps
its own default (a failed lookup is retried after 30 minutes). Clearing an
app's location in the web UI now actually clears it; the save used to drop
the blank field, so the old value stayed.
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that - On-demand no longer restarts a running display. `POST
registered each font with `FontManager.register_manager_font()`, published /display/on-demand/start` treated `start_service` (on by default, and what
by the display service to the shared cache (`src/font_usage.py`) and merged "Preview on display", the on-demand dialog and the MQTT bridge all send) as
into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin "restart": it stopped the service, waited 1.5s and started it again, so
uses now names those plugins in the confirmation (it is not blocked). every request reloaded every plugin and left the panel blank for seconds.
`FontManager.forget_manager_fonts()` is new; unloading a plugin calls it. The running display already reads the request within a quarter of a second,
mid-screen and mid-Vegas included, so the route now only starts the service
when it is not running. `POST /display/on-demand/stop` reads
`stop_service` as a boolean, so `"false"` no longer stops the service.
- On-demand works for a disabled plugin. The display only loads enabled
plugins, so "Preview on display" on a disabled plugin's config page (which
says the plugin will be enabled for the preview) failed with
`invalid-mode`. The display now loads the plugin live for the session,
without writing `enabled` to `config.json`, and unloads it when on-demand
is stopped, expires or moves to another plugin. A plugin that fails to
load reports on-demand status `error` with `load-failed`. A session
restored after a restart unloads its disabled plugin the same way; it used
to stay loaded until the next restart.
- A stop request now clears an on-demand error. After a failed request,
`/display/on-demand/status` kept reporting `status: error` for up to two
minutes even after a stop.
- Re-saving unchanged data through `CacheManager.set` no longer rewrites its
cache file. The disk cache already skipped a payload identical to the last
one written, but `set()` stamps every record with the current time, so the
skip never fired and every plugin rewrote its unchanged API data to the SD
card on every update cycle. Records are now compared without that
timestamp, and a skipped write moves the file's mtime to it instead; reads
treat a record as fresh from the later of the two, so it expires exactly
when the rewrite would have. Changed data or a changed `ttl` still writes,
and so does a file another process has replaced since.
Deprecated, removed in 3.7.0 (each logs a warning on first use; see ## 3.7.0
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
core, the monorepo or the registry's third-party plugins calls them:
- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`, Sports consolidation stage 3 (#672). No behaviour change: nothing in core
`get_sport_live_interval`, `get_sport_key_from_cache_key`, uses these yet, and the scoreboards adopt them when they floor on 3.7.0.
`get_background_cached_data`, `is_background_data_available`,
`record_cache_hit`, `record_cache_miss`, `record_fetch_time`,
`get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`.
- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`,
`draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`.
- `FontManager`: `set_override`, `remove_override`, `get_overrides`,
`add_font`, `remove_font`, `validate_font`, `get_font_catalog`,
`get_available_fonts`, `get_size_tokens`, `get_performance_stats`,
`get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`,
`unregister_plugin_fonts`.
- `PluginManager.get_enabled_plugins`.
### Config writes ### New modules
- A power cut or crash mid-save can no longer leave `config/config.json` A plugin may import these via `src.*` (floor on 3.7.0). All three hold code
truncated. `ConfigManager.save_config()` wrote the file in place; it, the scoreboard plugins carry as identical copies, moved without behaviour
`save_config_atomic()`, `save_raw_file_content()` and backup rollback now change under the plugins' own method names; each docstring lists what the
share one writer (`atomic_write_text` in `src/config_manager_atomic.py`) host class must provide. The plugins delete their copies when they floor on
that fsyncs a temp file, renames it into place and fsyncs the directory. 3.7.0.
- `save_config_atomic()` no longer rewrites `config_secrets.json` on every
save, only when its content changes, and rotating backups no longer re-reads
every backup. The backups themselves are unchanged:
`config/backups/config.json.backup.<version>` plus its paired secrets
backup, five newest kept.
- A save by the root-run display service keeps the file's previous owner
instead of handing `config.json` to root, and an install path with
"secrets" in a directory name no longer makes `config.json` mode 0640.
New names in existing modules (no new modules; a plugin importing these must - `src/common/sports_celebration.py` — `SportsCelebrationMixin`, the
floor on the release that ships them): score/win celebration takeover drawn by afl, football, hockey, nrl and
soccer (`_draw_celebration_layout` and the palette, backdrop, scenery,
confetti and crest steps behind it), plus its colour helpers as free
functions: `logo_palette`, `lift_color`, `cap_luminance`, `mix_color`,
`scale_color`, `dim_rgba`, `rgb_luminance`, `rgb_saturation`,
`color_distance`. Only the drawing: when to celebrate, the phrase and the
scenery stay in each plugin.
- `src/common/sports_fetch.py` — `SportsFetchMixin`, 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`).
- `src/common/sports_card_wrappers.py` — `SportsCardWrappersMixin`, the
seventeen `sports_card` delegations the eight scoreboard game renderers
carry (`_vs_text`, `_element_color`, `_format_game_date`, ...): the methods
`SportsGameRendererMixin` expects its host to provide.
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only). ## 3.6.2
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
`shared_downloader`.
### Logo downloads A fix to `src.common.favorite_team_check` (#670).
- `download_missing_logo` / `LogoDownloader.download_logo` (the path the ### Fixes
scoreboard plugins use) now stream the logo with a 10 MB cap, accept only an
`image/*` response that Pillow can decode, and move the finished RGBA PNG
into place atomically. A failed, oversized or non-image download no longer
leaves a partial file behind, and no longer replaces a logo already on disk.
`LogoHelper._download_logo` goes through the same code. Signatures and return
values are unchanged; saved files are pixel-identical to before.
- `download_missing_logo` reuses one downloader (one `requests.Session`) per
thread instead of building a new one for every logo.
- Placeholder logos are written atomically, without the `test_write.tmp`
probe file.
### HTTP headers - The favourite-team check no longer says the Europa League season has
finished between matchdays. Its scoreboard keeps showing the last matchday,
and its calendar is a "list" of rounds rather than match days, so neither
3.6.1 rule applied. When every event is past, a round in a list calendar
that has not started yet (outside an offseason phase) now draws no
conclusion. PLL, the World Cup and AFL, whose seasons are over, are still
reported as finished: no round of theirs is still to start. (#670)
- The logo downloader and the background data service send the real ## 3.6.1
`LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)` User-Agent
instead of a `yourusername` / `contact@example.com` placeholder, and no
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
`br` response could not be decoded); requests picks the encodings.
### Plugin error reporting A fix to `src.common.favorite_team_check` (#667). Plugins that drop their
bundled copy of it should floor on 3.6.1, not 3.6.0.
- `/api/v3/errors/summary` and `/api/v3/errors/plugin/<id>` report the errors ### Fixes
the display service recorded. They used to read the web process's own error
aggregator, which never records anything, so they always answered "no
errors". The display service now publishes a bounded snapshot to the shared
cache (`plugin_error_snapshot`, at most every 10 seconds and only on change;
`src/error_aggregator.py`, started from `DisplayController.__init__`).
Responses keep their shape and add `snapshot_available`, `generated_at` and
`clear_pending`; exception text has credentials redacted.
- `POST /api/v3/errors/clear` records a request (`plugin_error_clear_request`)
the display service applies within about 5 seconds; reads hide the cleared
errors at once. It accepts `"all": true`, and `cleared_count` can be `null`
when the count is only known to the display service.
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
errors and a Clear button.
- Credential redaction in exception text (`src/redaction.py`) takes time
proportional to the text, not its square. Two patterns were quadratic: URL
`user:password@`, on a long unbroken run of letters or digits (a hex digest,
an ID), and `Authorization:` followed by a long run of whitespace. Either
used to stall every thread of the display service for up to seconds each
time the snapshot was published: about 0.5s for 20k characters of hex, 8s
for 20k spaces. What gets redacted is unchanged.
### Removed - The favourite-team check no longer logs "the season has finished" for a
league that is still playing. ESPN's default scoreboard keeps showing the
last slate after it: MLB's regular-season games two days into the
postseason, a soccer league's previous matchday between rounds. When every
event is in the past, the check now looks first at the league's phase (a
regular season or postseason that has moved past the events shown draws no
conclusion) and at a match-day calendar (`calendarType` "day" with
`calendarIsWhitelist`, as soccer, the NHL and the NBA use), whose next date
becomes "nothing on until <date>". An offseason, or a payload without these
fields, is reported as before.
- **The skin system.** Skins never rendered with the current scoreboard ## 3.6.0
plugins, so they are gone rather than "not supported yet": `src/skin_system/`,
`skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's New modules a plugin may import via `src.*` (floor on 3.6.0). Both are
`"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`. promoted from files the scoreboard plugins carry as copies; the plugins keep
A `skin` or `skin_options` key left in a plugin's saved config still loads their copies as a fallback until they floor on 3.6.0. No other change since
and saves without a validation error; it is ignored, and the next save of 3.5.0.
that plugin's settings removes it (unless the plugin's own schema declares
the key). - `src/common/favorite_team_check.py` — `FavoriteTeamCheck(logger, leagues)`:
- **`src/base_classes/`** (`SportsCore`, the sport and mode classes, checks configured favourite team codes against ESPN once per league, on a
`CelebrationMixin`, the rotation strategies, `data_sources`, daemon thread, and logs why a league shows nothing (a wrong code, with the
`api_extractors`). No known plugin imports it. A plugin that does must use nearest real one, or a season that has not started). The seven copies
`src.common` or its own copy of the code. (`<sport>_favorite_check.py`) were byte-identical; this is the same code,
with type annotations added.
- `src/common/sports_timezone.py` — `resolve_timezone_name()` /
`resolve_timezone()` (plus `system_timezone_name()`): the timezone a
scoreboard draws start times in. The ten copies (`<sport>_timezone.py`)
differed only in two values, which are keyword-only arguments here:
`plugin_label` (named in the warning logged when nothing resolves) and
`writeback_fixed_in` (for a plugin that once wrote `"UTC"` back into the
saved config; `None` otherwise). Same resolution order and log messages.
## 3.5.0 ## 3.5.0
@@ -160,13 +153,69 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
deletes a copy and leans on an older module having grown the method fails at deletes a copy and leans on an older module having grown the method fails at
runtime with `AttributeError`, which no load-time check sees, while a missing runtime with `AttributeError`, which no load-time check sees, while a missing
module fails at load. Nothing in core uses it yet. module fails at load. Nothing in core uses it yet.
- `test/test_common_is_hardware_free.py` — `src/common` must import without
`rgbmatrix` and never import `src.base_classes`, `src.display_manager` or
`src.plugin_system` at module level.
- `src/common/espn_dates.py` — `fetch_espn_scoreboard`, - `src/common/espn_dates.py` — `fetch_espn_scoreboard`,
`fetch_espn_date_chunks`, `espn_date_chunks`, `clamp_espn_limit`, `fetch_espn_date_chunks`, `espn_date_chunks`, `clamp_espn_limit`,
`ESPN_MAX_LIMIT`: fetch an ESPN scoreboard date range now that ESPN rejects `ESPN_MAX_LIMIT`: fetch an ESPN scoreboard date range now that ESPN rejects
ranges (see Sports data below). Plugins bundle a copy of it. ranges (see Sports data below). Plugins bundle a copy of it.
- `src/common/json_body.py` — `response_json(response)`: `response.json()`,
parsed by orjson when it is installed (an optional dependency) and by the
stdlib otherwise; an orjson parse error falls back to `response.json()` so
requests raises its usual error. Same Python objects either way; a season
schedule parses about 1.7x faster on a Pi 4, and the parse holds the GIL (so
freezes the display) for that much less time. `espn_dates` and
`BackgroundDataService` use it; `espn_dates` falls back to `response.json()`
when it is missing, so the plugins' bundled copies of `espn_dates` still load
on an older core.
- `src/common/bdf_font.py` — `load_bdf_face(path, size)` (a cached
`freetype.Face` plus the pixel size it really renders at, falling back to
the file's native strike) and `draw_bdf_text(draw, text, x, y, face, color)`.
`DisplayManager`, `FontManager`, `element_style` and the plugin test harness
now all load and draw BDF text through it; the panel's pixels are unchanged
and BDF text draws 10-250x faster. The plugin test harness's
`calendar_font` / `bdf_5x7_font` now has the panel's 7px size set: it used
to be an unsized face, so in golden images and `check_plugin` /
`dev_server` previews its text sat 6px above where the panel draws it (off
the canvas entirely near the top) and `get_font_height()` returned 0.
Also new under `src/` since 3.4.0, but internal to core rather than for plugins:
`src/common/frame_timing.py` and `src/common/render_gate.py` (see Scrolling),
`src/core_config_keys.py`, `src/deprecation.py`, `src/device_location.py`,
`src/font_usage.py`, `src/matrix_support.py`, `src/pi5_matrix_support.py`,
`src/redaction.py`, `src/scan_order.py`, `src/web_interface/config_arrays.py`,
and in `src/plugin_system/`: `plugin_dirs.py`, `repo_urls.py`,
`store_install.py`, `store_registry.py` and `store_update.py`.
New names in existing modules (a plugin using these must floor on 3.5.0):
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
`shared_downloader`.
- `src.common.sports_card.unshare_element_fonts` takes an optional third
argument, `element_for_font` (default: the module's `ELEMENT_FOR_FONT`, so
existing calls are unchanged).
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
the display are written (`config/wifi_status.json`).
- `BackgroundDataService.handles_espn_date_ranges` (see Sports data).
- `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`, and
`FontManager.forget_manager_fonts()` is new (see Fonts).
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
core, the monorepo or the registry's third-party plugins calls them:
- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`,
`get_sport_live_interval`, `get_sport_key_from_cache_key`,
`get_background_cached_data`, `is_background_data_available`,
`record_cache_hit`, `record_cache_miss`, `record_fetch_time`,
`get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`.
- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`,
`draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`.
- `FontManager`: `set_override`, `remove_override`, `get_overrides`,
`add_font`, `remove_font`, `validate_font`, `get_font_catalog`,
`get_available_fonts`, `get_size_tokens`, `get_performance_stats`,
`get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`,
`unregister_plugin_fonts`.
- `PluginManager.get_enabled_plugins`.
### Config saves and plugin config preparation ### Config saves and plugin config preparation
@@ -213,8 +262,23 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
"enabled but not found in plugins directory", and plugin ids that collide "enabled but not found in plugins directory", and plugin ids that collide
with any core config section are flagged: the last private copies of the with any core config section are flagged: the last private copies of the
core-key list now use `src/core_config_keys.py`. core-key list now use `src/core_config_keys.py`.
- Plugin config saves recombine position-keyed inputs for nullable array fields (`"type": ["array", "null"]`).
- A blank Max Dynamic Duration keeps the stored value instead of failing the Display save with a 500; other values must be whole seconds from 30 to 1800.
- A power cut or crash mid-save can no longer leave `config/config.json`
truncated. `ConfigManager.save_config()` wrote the file in place; it,
`save_config_atomic()`, `save_raw_file_content()` and backup rollback now
share one writer (`atomic_write_text` in `src/config_manager_atomic.py`)
that fsyncs a temp file, renames it into place and fsyncs the directory.
- `save_config_atomic()` no longer rewrites `config_secrets.json` on every
save, only when its content changes, and rotating backups no longer re-reads
every backup. The backups themselves are unchanged:
`config/backups/config.json.backup.<version>` plus its paired secrets
backup, five newest kept.
- A save by the root-run display service keeps the file's previous owner
instead of handing `config.json` to root, and an install path with
"secrets" in a directory name no longer makes `config.json` mode 0640.
### Sports data ### Sports data, logos and odds
- Since 2026-09-15 ESPN answers `dates=YYYYMMDD-YYYYMMDD` scoreboard queries - Since 2026-09-15 ESPN answers `dates=YYYYMMDD-YYYYMMDD` scoreboard queries
with `400 Bad Request` for every sport, so season schedules, the weeks window with `400 Bad Request` for every sport, so season schedules, the weeks window
@@ -262,6 +326,44 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
longer swallowed as a missing poll. This is the implementation the football, longer swallowed as a missing poll. This is the implementation the football,
baseball and hockey boards already ship; core was the last copy on the old baseball and hockey boards already ship; core was the last copy on the old
one. one.
- `BaseOddsManager.get_odds()` no longer returns the cached "no odds" marker (`{"no_odds": True}`) as if it were odds. A game ESPN had no odds for is cached that way so it isn't re-requested every update; on the next update the cache hit handed the marker back, and callers saw a truthy dict. It 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.
- Background data fetches retry at one level instead of two. The session adapter retried a connection error three times inside every attempt of the service's own retry loop, so a dead network cost up to 16 connection attempts per request and held one of the few worker threads throughout; now it is the loop's `max_retries + 1` attempts. ESPN date-range chunks, which don't go through that loop and skip a chunk that fails, keep a small connection retry of their own so a brief blip doesn't drop a month from a cached season.
- `LogoHelper.load_logo_with_download()` sizes its placeholder to the scaled logo box, like a real logo (only differs when `scale` isn't 1).
- The AP Top 25 resolver remembers a failed or empty rankings fetch for 5 minutes, so an ESPN outage no longer costs every scoreboard update a 30s timeout. Its duplicate INFO log line is gone.
- `BackgroundDataService` runs a cache-hit callback outside its lock, as the fetch path does.
- `LogoHelper.load_logo_with_download()` waits an hour before retrying a download that failed for a missing logo, instead of retrying (with a 30 s timeout) on every call.
- Restamping a placeholder logo writes the file atomically.
- The odds manager logs cache hits, misses and fetches at DEBUG, and a bad JSON body is logged as a parse error rather than a failed fetch.
- `APIHelper` keeps cached responses for the `cache_ttl` it was given, instead of always 300 s.
- Logo scales from 0.1 to 10 are honoured everywhere; values outside that range are clamped.
- `LogoHelper` and `logo_downloader`: an empty ESPN logo list counts as a failed download, and the placeholder is written at the requested path.
- The `SportsCoreSharedMixin` helpers that behave identically to their
`sports_card` twins (`_card_option`, `_vs_text`, `_format_game_time`,
`_coerce_rgb`, `_crisp_size`, `_unshare_element_fonts`, the colour/month/
weekday/font-grid tables) are now thin wrappers over the `sports_card`
functions, and `_format_game_date` / `_schema_font_size` share its
formatting body and schema parser. No method was removed or renamed and
nothing renders differently: `test/test_sports_twins.py` checks each pair
against the same inputs, and the old and new mixin agree on every input
there. The pairs that do differ -- favourite-result colours on nested
payloads, the weekday's timezone, the element-name map, per-mode colours --
are left as they are and pinned in that test.
- `download_missing_logo` / `LogoDownloader.download_logo` (the path the
scoreboard plugins use) now stream the logo with a 10 MB cap, accept only an
`image/*` response that Pillow can decode, and move the finished RGBA PNG
into place atomically. A failed, oversized or non-image download no longer
leaves a partial file behind, and no longer replaces a logo already on disk.
`LogoHelper._download_logo` goes through the same code. Signatures and return
values are unchanged; saved files are pixel-identical to before.
- `download_missing_logo` reuses one downloader (one `requests.Session`) per
thread instead of building a new one for every logo.
- Placeholder logos are written atomically, without the `test_write.tmp`
probe file.
- The logo downloader and the background data service send the real
`LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)` User-Agent
instead of a `yourusername` / `contact@example.com` placeholder, and no
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
`br` response could not be decoded); requests picks the encodings.
### Scrolling ### Scrolling
@@ -292,6 +394,92 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
held 20 ms frame as missed refreshes, and Vegas `frame_based_scrolling` / held 20 ms frame as missed refreshes, and Vegas `frame_based_scrolling` /
`scroll_delay` are described as the speed clamp they are rather than frame `scroll_delay` are described as the speed clamp they are rather than frame
stepping. Scoreboard `scroll_delay` is documented as ignored for pacing. stepping. Scoreboard `scroll_delay` is documented as ignored for pacing.
- `ScrollHelper.set_scrolling_image()` accepts RGBA, L and palette images (transparent pixels become black), and a new scrolling image no longer jumps ahead by the time the helper sat idle.
- `src.common.frame_timing` -- times every frame the display presents, whoever
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
`scripts/frame_soak.py` judges a running service (late frames, freezes,
where the time goes), and `scripts/render_bench.py` judges the hardware and
render path alone on a synthetic strip. Both fail a run above 0.1% late
frames, and both call a loop that never waited for the panel NOT LOCKED. A
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
- `display.scan_order_compensation` (`"auto"` by default): while something
scrolls at one pixel per refresh, one half of each panel is shown a refresh
behind the other, which removes the 1px step a 1:N-scan panel shows across
its middle. Only for layouts whose row order is known; `"off"` disables it.
See `docs/SCROLL_PERFORMANCE.md`, "A tear across the middle on fast scrolls".
### Display and Vegas
- Vegas scrolls in step with the panel's refresh (#628). With `smooth_scroll`
(on by default) the strip moves a whole number of pixels per presented
frame, each held for `frame_hold` refreshes and timed by `SwapOnVSync`,
the same pacing as the plugin tickers; it used to advance by elapsed time
and sleep to `target_fps`, missing a vsync every few frames. The speed is
solved against the panel's measured refresh when that is below its
`limit_refresh_rate_hz` cap. The old sub-pixel blend, which the panel shows
as shimmer, is kept as `vegas_scroll.sub_pixel_blend` (default off). While
scrolling, the web preview's PNG is encoded on a background writer instead
of the render thread. On a Pi 4 driving 512x64, late frames went from about
6.4% to 0.7%.
- Vegas prepares plugin content off the render thread (#630). A plugin that
needed the shared canvas used to be fetched on the render thread, stalling
the scroll for as long as it took (320 ms for news, 660 ms for a hockey
scoreboard, measured). `DisplayManager.offscreen(width, height)` gives the
calling thread a canvas of its own: `image`, `draw` and `matrix` are now
properties that resolve to it inside the block, where `update_display()`,
the hardware half of `clear()` and `set_scrolling_state()` /
`set_frame_hold()` do nothing. Background fetches take the plugin's lock,
waiting up to 2 s for a running `update()` and otherwise skipping the plugin
that round. A GIL gate (`src/common/render_gate.py`) pauses the prefetch
thread outside a window around each vsync swap, so the render thread finds
the GIL free; it needs the rebuilt binding that releases the GIL in
`SwapOnVSync` and stays off (one INFO line per Vegas run) on a stock one.
New `vegas_scroll` keys: `offscreen_prefetch` and `prefetch_gate` (both on by
default) and `switch_interval_ms` (experimental, default 0, off). Design in
`docs/OFFSCREEN_RENDERING.md`.
- On-demand requests, the display on/off schedule and brightness take effect
within about a quarter of a second instead of at the next screen (#618). A
screen can stay up for a minute and a Vegas iteration for 240 s, so an
on-demand request during Vegas waited for the iteration to end and a
brightness save mid-screen could be lost. Vegas now stops for an on-demand
request and for the display being scheduled off, and a brightness change
re-sends the current frame. Plugin enable/disable, screen durations and Vegas
settings still apply at the next screen.
- The Rotation & Durations page takes effect (#605). A saved
`display.display_durations` value now wins over the plugin's own duration;
the plugin was asked first, and every plugin inherits
`get_display_duration()`, so saved values did nothing. The page shows an
unsaved screen blank with the plugin's own duration as the placeholder (it
showed 30 where the real default is 15), and saving a blank removes the
override. Durations saved before this now apply.
- Vegas settings reach a running scroll (#605): they are queued when
`display.vegas_scroll` changes (unrelated saves don't rebuild the strip) and
also applied while Vegas is stopped. The sync follower's scroll-speed
default (75) now matches `VegasModeConfig`'s (50). The Vegas live-priority
scan is throttled to 4 Hz.
- `DisplayManager.defer_update()` from a plugin's update thread no longer loses queued updates while the render thread processes the queue; the queue is locked, and the queued callables still run outside the lock.
- **Behaviour change:** when `display.hardware.limit_refresh_rate_hz` is missing from config, the panel is now capped at 100 Hz (the config template's value) instead of 90 Hz. Scroll pacing already assumed 100 Hz in that case, so it now matches what the panel does. Configs that set the key (every config migrated from the template) are unaffected.
- A sync follower adopts the leader's scroll image between frames on the render thread, instead of the TCP thread swapping the image, array and width while a frame is being drawn.
- `update_display()` errors are logged once with a traceback, then at most once a minute with a count, instead of an untraced line every frame. Several swallowed exceptions in `DisplayController` now log at DEBUG.
- The repo-root `display_controller.py` now runs `run.py` (the real entry point), so it gets run.py's `-e`/`-d` flags, logging setup and `sys.dont_write_bytecode`.
- Vegas: a plugin set to `vegas_mode: "static"` pauses the scroll for its turn again. The pause was triggered by peeking at the front of a segment buffer that continuous scrolling (the default) never advances, so a static plugin paused only if it happened to be first, once, at startup, and otherwise scrolled past as ordinary content; swap mode had the same problem for any static plugin not first in its cycle. The render pipeline now marks where each static plugin's turn falls in the strip and the scroll pauses when it gets there. The pause runs the plugin's `display()` under its plugin lock, and a static plugin's content is no longer rendered for the strip.
- The display loop no longer spins at 100% CPU when no enabled mode has anything to show (for example, only a sports plugin enabled in its off-season). After one full rotation of empty modes it checks one mode per second until something shows; live content still takes over at once.
- Stopping `ledmatrix.service` runs the controller's cleanup (SIGTERM now takes the Ctrl-C path).
- Turning Vegas on in the web UI works without a restart when it was off at startup.
- Vegas comes back after live content interrupts it. It stayed paused, and the display fell back to normal rotation until a restart.
- A day with dimming turned off in a per-day dim schedule stays at normal brightness. Before, brightness went back to dim for most of each minute.
- Stopping on-demand after a second request resumes rotation where it was first interrupted, not at the first request's screen.
- Turning Vegas off and on no longer shows content prepared for the previous run, including plugins disabled in between.
- How long a Vegas iteration runs is timed with the monotonic clock, so an NTP clock step on a Pi without an RTC doesn't cut it short or stretch it.
- The sync status file is removed when the display service stops, and at startup in standalone mode, so the web UI no longer reports a peer from an earlier run. Concurrent writes each use their own temp file.
- `render_gate.swap_releases_gil()` delegates to `frame_timing.binding_releases_gil()` instead of duplicating it.
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
- The display controller stops Vegas mode on shutdown.
- Startup validation warnings are logged once, not twice.
- Vegas logs one INFO line per plugin-list refresh.
- `run.py -d` shows `display_manager` debug output.
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
### Web interface ### Web interface
@@ -356,8 +544,142 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
discover when nothing has been discovered yet, and rescan once when a discover when nothing has been discovered yet, and rescan once when a
specific plugin id (or, for on-demand by mode, a mode) is not found, so a specific plugin id (or, for on-demand by mode, a mode) is not found, so a
plugin installed since the last scan is found too. plugin installed since the last scan is found too.
- `web_interface/blueprints/api_v3/plugins.py` (3,285 lines) is split by area into `plugins.py` (installed list, enable/disable, plugin actions), `plugin_store.py`, `plugin_config.py`, `plugin_assets.py`, `plugin_health.py`, `plugin_operations.py` and `plugin_calendar.py`. Pure move: every function body and route decorator is byte-identical, and URLs and endpoint names are unchanged.
- Plugins installed as `ledmatrix-<id>` (or in a directory not named after their id) work in the installed list, the update button, recorded versions, the plugin config form and plugin web UI pages. Those routes built `plugins_dir/<id>` themselves instead of asking the plugin manager.
- Uploading several plugin images checks every file before saving any, so a rejected file no longer leaves the others saved; the images' `.metadata.json` and the calendar plugin's `credentials.json` are written atomically, and the credentials upload no longer returns the server's absolute path.
- `"false"` sent as a string no longer counts as true when toggling a plugin (including Starlark apps) or starting on-demand mode (`pinned`, `start_service`); `force` on the AP-enable route is parsed like every other WiFi boolean (`"yes"` and `1` now force).
- The live-preview stream starts a new broadcast thread for a client that connects while the previous one is shutting down; that client got no updates.
- The web server's log filter no longer raises when werkzeug logs with `exc_info=True`.
- The raw secrets editor's save errors carry `error_code` like the main config's; the asset delete route answers 400 for a missing body instead of 415/500. Dead code removed: an unused manifest scan on each Plugins-tab load, backup routes' duplicate catch-alls, redundant imports.
- A plugin's own config widget (`/static/plugin-widgets/<id>/<widget>.js`) is requested with `?v=<plugin version>`, so an updated plugin's widget reaches browsers instead of the copy cached as immutable for a year.
- A failed installed-plugins reload after a toggle, install or uninstall shows one error, not a second generic "unexpected error" toast.
- The timezone picker on the General tab renders again when the tab is reloaded in the same page session.
- Removed dead code: the plugin-action button's six plugin-id fallbacks (the button always passes its id) and its `[DEBUG]` logging, `window.currentPluginConfig` (never set to anything but `null`), the file-upload widget's JSON delete branch (its endpoint never existed), unused `PluginAPI` / `PluginInstallManager` / `PluginStateManager` helpers, `loadPluginWidgetsFromManifest`, no-longer-reachable fallbacks for a stale `install_manager.js` and a missing `LEDVisibility`, and 13 unused CSS utility rules.
- The Logs tab's "Now showing" no longer reads "unknown" when one screen stays up longer than 2 minutes.
- A network failure fetching GitHub repo info logs a warning, not an error.
- The Operation History plugin filter lists installed plugins (it showed one option, "plugins").
- Ctrl/Cmd+S submits the active tab's visible form (with its validation) instead of the first form in the page; it does nothing inside a dialog or on a tab without a form. The Ctrl/Cmd+R override (the browser's own reload) and the textarea auto-resize (no textarea exists at load) are removed.
- Tools tab actions and diagnostics show the server's error message; only a non-JSON error falls back to `HTTP <status>`.
- An uninstalled plugin no longer reappears in the installed list: writes through `PluginAPI` clear its 5s GET cache, and Refresh and the post-uninstall reload bypass both list caches.
- Plugin widgets load from `/static/plugin-widgets/` only; the two other paths it tried have no route.
- The raw JSON editor escapes the parse error, and the slider widget escapes its value, min, max and step.
- Removed unused array-of-objects and key-value helpers from `plugins_manager.js` (about 640 lines, no callers) and a redundant `?v=` on its script tag.
- Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it.
- `POST /api/v3/starlark/apps/<id>/toggle` goes through the same code as `/plugins/toggle`: `"false"` disables, a failed save no longer leaves the running app out of step with disk, and a loaded app with no manifest entry no longer answers 500.
- `/api/v3/` JSON responses are sent `Cache-Control: no-store`, so a reload right after an install, toggle or Wi-Fi connect shows the new state. Non-JSON files served through the API keep the 5 s cache.
- Startup plugin validation no longer gives up on a `null` plugin block, and plugins are discovered once at startup instead of twice.
- A plugin save drops repeated entries in lists whose schema says `uniqueItems`, instead of failing validation.
- `/api/v3/health` reports the real plugin count.
- A malformed `vegas_plugin_order` or `vegas_excluded_plugins` is refused with a 400 and nothing is saved. It used to wipe the saved list.
- The per-plugin health and metrics routes return the display service's latest state.
- Resetting a plugin's config takes a backup first and reports a failed save.
- System metrics that can't be read are `null` everywhere: `cpu_temp` off a Pi, and every metric without psutil, where `/system/status` now answers 200 instead of 503.
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
- Cache tab errors no longer show up in the Logs tab.
- A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
- Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
- The image schedule button works on uploaded images, and the editor stays open while you edit.
- A failed plugin toggle moves the switch back.
- Each save shows one notification; a failed Durations save says it failed.
- Stats the server can't read show `--`.
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
INFO; 4xx at WARNING, 5xx at ERROR. `LEDMATRIX_DEBUG=true` shows them again.
`web_interface/logging_config.py` is removed. The web cache
(`web_interface/cache.py`) now honours the TTL a value was stored with and is
thread-safe.
- `/api/v3` routes answer an exception they don't handle themselves from one
blueprint error handler, with the same `{status, message, details}` body the
53 removed per-route catch-alls returned. `ErrorCategory` and the
`error_category` key are removed from `src.web_interface.errors` (nothing read
them); `exception_error_response()` replaces the `from_exception` +
`error_response` pairs. A failing plugin action script's error now names the
real failure instead of `UnboundLocalError`.
- Installing a Starlark app works on a fresh install (#604). `starlark-apps/`
is created by whichever service reaches it first, and on a fresh install
that was usually the root display service, so the web interface could not
write to it and every install path answered "Failed to install from
repository". The display service now hands the directory and its contents
to the checkout's owner on every start (a no-op when not root or when the
checkout belongs to root), which also repairs devices already affected; a
permission error from the install routes names the directory and the fix.
- Clicks on plugin cards reach `handlePluginAction` (#605). Every click took
a copied fallback that asked twice before uninstalling and sent Starlark app
uninstalls to `POST /plugins/uninstall` instead of
`DELETE /starlark/apps/<id>`. A failed plugin toggle no longer always says
"A plugin operation is already in progress".
- The web interface starts with an absolute `plugin_system.plugins_directory`
(#616); it crashed at import with `NameError: project_root`.
- Stopping a Pixlet editor that ignores SIGTERM restarts the display instead
of answering 500 and leaving the panel dark (#625).
- Removed dead routes and files (#609): `POST /plugins/authenticate/spotify`
and `/ytm` (the music plugin runs its auth scripts through `web_ui_actions`),
`POST /plugins/of-the-day/json/upload` and `/json/delete` (they used the
wrong plugin id), `js/plugins/store_manager.js`, `js/config/diff_viewer.js`
and `js/htmx-sse.js`, and `web_interface/run.sh`. `htmx-config.js` no longer
replaces `console.error` / `console.warn`, which hid some real errors.
### Security (request paths and inline handlers, siblings of #561) ### Plugin error reporting
- `/api/v3/errors/summary` and `/api/v3/errors/plugin/<id>` report the errors
the display service recorded. They used to read the web process's own error
aggregator, which never records anything, so they always answered "no
errors". The display service now publishes a bounded snapshot to the shared
cache (`plugin_error_snapshot`, at most every 10 seconds and only on change;
`src/error_aggregator.py`, started from `DisplayController.__init__`).
Responses keep their shape and add `snapshot_available`, `generated_at` and
`clear_pending`; exception text has credentials redacted.
- `POST /api/v3/errors/clear` records a request (`plugin_error_clear_request`)
the display service applies within about 5 seconds; reads hide the cleared
errors at once. It accepts `"all": true`, and `cleared_count` can be `null`
when the count is only known to the display service.
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
errors and a Clear button.
- Credential redaction in exception text (`src/redaction.py`) takes time
proportional to the text, not its square. Two patterns were quadratic: URL
`user:password@`, on a long unbroken run of letters or digits (a hex digest,
an ID), and `Authorization:` followed by a long run of whitespace. Either
used to stall every thread of the display service for up to seconds each
time the snapshot was published: about 0.5s for 20k characters of hex, 8s
for 20k spaces. What gets redacted is unchanged.
### Wi-Fi
- WiFi status messages reach the panel (#605). The display controller looked
for `wifi_status.json` one directory above the repo; both sides now use
`wifi_manager.get_wifi_status_path()`, the file is written atomically, and
the plugin that resumes afterwards redraws the whole panel.
- The captive-portal checks (`/generate_204` and friends) also detect an access point brought up through NetworkManager, the fallback `enable_ap_mode` uses without hostapd; only hostapd was checked, so phones on that AP were told the internet worked.
- The WiFi monitor daemon re-reads `wifi_config.json` when it changes, so the "auto-enable AP mode" toggle takes effect without restarting the daemon.
- Disconnecting from WiFi in the web UI no longer runs an AP-mode check that could never enable the AP; it only added seconds of waiting. The daemon still enables the AP after its grace period.
- The WiFi status message file follows each WiFi manager's own config directory, and the config path falls back to this checkout rather than `/home/ledpi/LEDMatrix`.
- A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal.
- Wi-Fi disconnect takes the saved connection profile down.
- `wifi_config.json` is written atomically, and a save that fails now gets a 500.
### Fonts
- Fonts tab: the preview endpoint renders BDF fonts with the panel's own rasterizer instead of refusing them. (The Fonts page still skips the request for `.bdf`; enabling it there is a separate template change.)
- A plugin font declared as a `.zip` URL is served as the font extracted from it after a restart, instead of registering the archive itself. Font downloads time out after 30s and land in the cache only once complete, so an interrupted download is retried rather than served forever.
- BDF fonts: `FontManager.get_font()` and `element_style.load_font()` no longer hand one `freetype.Face` to every thread. BDF faces come from `load_bdf_face`, which already caches them per thread; TrueType fonts are cached as before. `element_style`'s font cache is locked (a concurrent eviction could raise `KeyError`).
- `FontManager.clear_cache()` and unregistering a plugin's fonts bump `cache_generation`, so cached layouts are rebuilt.
- `plugin://` fonts load from the plugin's own install directory. `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`.
- Bundled font paths no longer depend on the directory the process was started from.
- `FontManager.get_font()` returns a BDF font at its native size when asked for
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
return PIL's default font, a different typeface, so a plugin that relied on
that will now render the font it asked for.
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
registered each font with `FontManager.register_manager_font()`, published
by the display service to the shared cache (`src/font_usage.py`) and merged
into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin
uses now names those plugins in the confirmation (it is not blocked).
`FontManager.forget_manager_fonts()` is new; unloading a plugin calls it.
### Security
- `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and - `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and
`POST .../assets/delete` validate `plugin_id` with `src/common/path_safety` `POST .../assets/delete` validate `plugin_id` with `src/common/path_safety`
@@ -376,6 +698,24 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
and add its own script. The store's View button opens only `http(s)` links. and add its own script. The store's View button opens only `http(s)` links.
- The uploaded-images list escapes each file's original name, path and ids; a - The uploaded-images list escapes each file's original name, path and ids; a
name like `<img src=x onerror=...>.png` was inserted as markup. name like `<img src=x onerror=...>.png` was inserted as markup.
- Installing from a URL (and a registry install whose manifest renames the plugin) refuses a plugin id that isn't a single safe name, so `../x` can no longer delete and replace a directory outside the plugins directory.
- Plugin uninstall and config reset refuse core config sections (`display`, `schedule`, ...) and ids with path parts. Uninstall still cleans the config of a plugin whose directory is already gone.
- A config field marked `x-secret` whose value is an object or array is saved to `config_secrets.json`, not to `config.json` in plain text.
- Restoring a backup onto a device without `config_secrets.json`, `wifi_config.json` or `ytm_auth.json` creates them with mode 640 instead of world-readable 644.
- Backup export skips a plugin `manifest.json` that isn't a JSON object instead of failing, and two exports in the same second no longer share a temp file or overwrite each other (the second gets a `-2` suffix).
- Every font that ships in `assets/fonts/` is protected from deletion; `MatrixChunky8X`, `MatrixLight6X`, `MatrixLight8X` and `ic8x8u` could be deleted from the Fonts tab.
- The raw config and secrets editors, and endpoints using `validate_request_json`, answer 400 for a JSON body that isn't an object.
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
- Wi-Fi passwords are no longer stored in `config/wifi_config.json` (#608).
`WiFiManager` appended every joined network's SSID and password, in plain
text, to `saved_networks`, and nothing read them back (NetworkManager keeps
its own credentials). Loading the config now drops a `saved_networks` key and
rewrites the file, so passwords already on disk are removed.
- The installers no longer grant the web user passwordless root on
`display_controller.py`, `start_display.sh` and `stop_display.sh` (#606).
Those files are owned by the user, so the web user could rewrite them and
run them as root; nothing ran them through sudo. Existing devices keep the
old rules until the installer or `configure_web_sudo.sh` is run again.
### Display hardware settings the library refuses ### Display hardware settings the library refuses
@@ -418,6 +758,38 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
(`legacy_bool_as_object` in `src/plugin_system/schema_manager.py`). Nothing (`legacy_bool_as_object` in `src/plugin_system/schema_manager.py`). Nothing
is written at load; the next save of that plugin's settings stores the object. is written at load; the next save of that plugin's settings stores the object.
Other type mismatches still warn. Other type mismatches still warn.
- A plugin that is reloaded (switched off and on again from the web UI) imports its own modules again, not another plugin's. Plugins import their own files by bare name (`from sports import ...`), which resolves to the first plugin directory on `sys.path` that has the file; the loader only added a directory that was missing, so a reloaded plugin's directory stayed behind any loaded since. On a Pi, re-enabling UFC with hockey running failed with "cannot import name '_status_is_final' from 'sports'". A loading plugin's directory is now always moved to the front.
- `src/plugin_system/store_manager.py` (2,977 lines) is split into mixins: `store_registry.py` (registry, GitHub metadata, search, manifest validation), `store_install.py` (install paths and dependencies) and `store_update.py` (updates, rollback, local git state). `PluginStoreManager` is still imported from `store_manager.py` and has exactly the same methods and attributes; every method body is byte-identical.
- Unloading a plugin waits (up to 5s) for an in-flight `update()` before running `cleanup()`/`on_disable()`, and an update that finishes after the unload no longer puts the plugin back to ENABLED.
- A plugin whose load fails after its module was imported (constructor, `validate_config()` or `on_enable()` raising) no longer leaves that module cached: fixing the plugin and reloading it runs the new code without a restart. Its font registrations are dropped too.
- `POST /api/v3/plugins/limits/<id>` answers 400 for a limit that isn't a non-negative number (a string limit used to make every later update of that plugin raise). A bad cached limits record is ignored with a warning instead of raising.
- The config schema is found for a plugin installed as `ledmatrix-<id>` or in a directory named differently from its manifest id, resolved the way the loader resolves it (plugins/ is still searched before plugin-repos/). A plugin with no schema is logged once at DEBUG instead of a warning on every lookup.
- Installing from a URL over an existing install sets the old copy aside and restores it if the move fails, under the same per-plugin lock as a registry install.
- The operation queue refuses a second operation for a plugin whose first is still waiting (a double-clicked Install ran twice), and no longer keeps every finished operation in memory.
- `get_vegas_render_width()` reads `display_manager.width` first, as plugins are told to.
- Store and state files are read as UTF-8 regardless of the system locale.
- Docs: `update_interval` in `config.json` sets the scheduler's cadence only for a plugin whose manifest has none (TROUBLESHOOTING, PLUGIN_CONFIGURATION_GUIDE). The health/metrics reset and limits routes note that they only change the web process's view.
- A plugin whose `on_enable()` raises is no longer left registered: the next load retries it instead of reporting "already loaded" for a plugin that never ran.
- One plugin's `get_info()` raising no longer breaks the installed-plugins list; it is logged and shown with empty runtime info.
- `plugin_state.json` and the operation history are written atomically (temp file + rename) under their lock, so concurrent saves or a failed save can't leave a truncated file.
- Plugin dependency installs run one `pip` at a time during parallel startup loading.
- A failed store download no longer leaves its extraction directory in the temp dir.
- Test doubles: `draw_image()` on `MockDisplayManager`, `VisualTestDisplayManager` and `BoundsCheckingDisplayManager` now emits a `DeprecationWarning` — the real `DisplayManager` has no such method; use `display_manager.image.paste(img, (x, y))`. `MockDisplayManager.draw_text` accepts the real signature's `small_font`/`centered` and default `x`/`y`, and `VisualTestDisplayManager` logs draw errors at WARNING.
- Removed the unused `PluginOperationQueue.get_active_operations()`.
- Updating a plugin that was installed from a ZIP no longer tries to reinstall it from the LEDMatrix repository's own URL.
- Repository URLs with `.git` in the middle are no longer mangled. The URL helpers now live in `src/plugin_system/repo_urls.py`.
- Installing from a URL works when the repository's only branch isn't `main` or `master`.
- A missing required config field is reported once, by name.
- A plugin that went over `max_memory_mb` once is no longer refused on every call after that.
- `reload_plugin` reads the manifest from the plugin's discovered directory.
- Removed: `last_display` from plugin state info and `get_last_display()` (nothing recorded them); `PluginOperationQueue`'s `history_file` and `lazy_load` arguments; and `data/plugin_operations.json`, which nothing read.
- One plugin-directory resolver, `src/plugin_system/plugin_dirs.py`, behind
discovery, `PluginManager.get_plugin_directory`, `PluginLoader`, the store and
state reconciliation. A manifest's `id` wins over a directory merely named for
the id; hidden and `.standalone-backup-` directories are never treated as
plugins (auto-update could previously try to update a backup); ids like
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
named for its manifest id, the installer's layout, behave as before.
### Core ### Core
@@ -436,6 +808,39 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
handling, so the restore stopped at `config.json` with nothing restored. The handling, so the restore stopped at `config.json` with nothing restored. The
ownership step is now skipped where `os.chown` is missing. No behaviour ownership step is now skipped where `os.chown` is missing. No behaviour
change on the Pi. change on the Pi.
- `APIHelper`'s rate limit and the display-sync heartbeat/leader timeouts measure elapsed time with `time.monotonic()`. A wall-clock step (NTP correcting a Pi with no RTC) could stall API requests for as long as the step or fake a sync timeout. `get_request_stats()['last_request_time']` is still wall-clock time.
- `sudo_remove_directory()` tries each bash path the sudoers rule might name, as `install_requirements_file()` already did.
- An element's saved layout `scale` equal to its schema default is no longer treated as a user choice when the default is declared under an alias (`score` for `score_text`).
- `CacheError`/`ConfigError`/`PluginError`/`DisplayError` no longer write their key into the caller's `context` dict; the JSON log formatter stringifies values it can't encode instead of dropping the record.
- Removed `ErrorAggregator`'s unused JSON export (`export_path`, `export_to_file()`); nothing called it. Docstring fixes in `validate_file_upload`, `StartupValidator.raise_on_errors`, `DisplaySyncManager.set_on_new_cycle`, `dynamic_team_resolver` and `config_arrays`.
- `/api/v3/errors` shows each exception's real stack trace instead of `NoneType: None`.
- Backups record `src.__version__`.
- Removed: `BackgroundDataService`'s `queue_size` stat and `clear_completed_requests()`.
- `src.device_location` — a blank `Location` field on a Starlark (Tidbyt) app
now renders at the device's City / State / Country (geocoded once via
Open-Meteo and cached) instead of the app author's hard-coded default,
usually San Francisco. A location saved on the app still wins. With no
device city set, or when the lookup fails or finds no match, the app keeps
its own default (a failed lookup is retried after 30 minutes). Clearing an
app's location in the web UI now actually clears it; the save used to drop
the blank field, so the old value stayed.
- Fixed a memory leak in the display service (#605): `ErrorAggregator`
appended every plugin in the time window to a pattern's `affected_plugins`
on each repeat (3,000 errors from three plugins reached 2.5 million
entries).
- An expired cache record is refused without being parsed (#633).
`CacheManager.set` writes `timestamp` and `ttl` ahead of `data`, and
`DiskCache.get` reads the first 256 bytes to decide staleness, with the same
rules as before. A 53 MB MLB season file used to be parsed in full (about
1.8 s holding the GIL on a Pi 4, freezing the display) only to be thrown
away. Files in the old layout are parsed as before and convert when
rewritten.
- Cache internals (#613): `CacheManager` delegates memory-tier cleanup and
stats to `MemoryCache`; `list_cache_files` no longer holds the memory lock
during directory I/O; `BackgroundDataService.get_sport_cache_key()` formats
the key instead of building a whole `CacheManager` (and probing the cache
directory) on every call; the unused request queue is gone, and `priority=`
is accepted and documented as ignored.
### Cache permissions ### Cache permissions
@@ -490,6 +895,8 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
timeouts with a second bash path, and all reinstalls share a 10-minute timeouts with a second bash path, and all reinstalls share a 10-minute
budget, so a rollback finishes inside the unit's 30-minute limit instead of budget, so a rollback finishes inside the unit's 30-minute limit instead of
being killed mid-way. being killed mid-way.
- A hand-edited non-object `auto_update` value reads as off instead of raising at startup, and a failed result write no longer leaves a temp file behind.
- Overview "Check Updates" asks for the same confirmation as "Update Code" and shows the server's message. Both, and the Tools tab's git pull, show the restart-pending banner when the update needs a restart.
### Installers ### Installers
@@ -503,6 +910,22 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
`configure_web_sudo.sh` does the same before offering the rules for `configure_web_sudo.sh` does the same before offering the rules for
confirmation. `first_time_install.sh` also built that file at a fixed `/tmp` confirmation. `first_time_install.sh` also built that file at a fixed `/tmp`
path as root; `mktemp` now picks the name. path as root; `mktemp` now picks the name.
- `check_system_compatibility.sh` treats Python 3.13 (what Trixie ships) as supported and anything below 3.10 as an error.
- `configure_web_sudo.sh` run as the web user keeps the reboot/poweroff rules.
- `check_system_compatibility.sh` no longer reports installed packages as missing.
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
- `configure_web_sudo.sh` uses a random temp file and installs its rules with mode 440.
- The installer prints its completion summary before the `-y` reboot, and describes the setup access point as an open network (it was shown with a password it doesn't have).
- `fix_cache_permissions.sh` applies `setup_cache.sh`'s `ledmatrix`-group model instead of setting 777.
- `check_system_compatibility.sh` reports anything but Debian 13 (Trixie) as unsupported, and reaches its summary.
- `one-shot-install.sh`'s `retry()` retries (#606). It read `$?` after `!`,
which is always 0, so a failed command ran once and was reported as a
success. It now tries three times and returns the command's status; both
apt steps still warn and continue after their retries, and a clone that
keeps failing stops the install sooner, with its own message.
- One generator for the web sudoers rules, `scripts/install/lib_sudoers.sh`,
used by `first_time_install.sh` and `configure_web_sudo.sh` (#622); the two
copies had drifted.
### Small fixes (update-all, plugin system settings, scripts) ### Small fixes (update-all, plugin system settings, scripts)
@@ -540,7 +963,9 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
(only an explicit `web_display_autostart: false` keeps the web interface (only an explicit `web_display_autostart: false` keeps the web interface
down), so a missing key no longer shows as disabled. The shell scripts also down), so a missing key no longer shows as disabled. The shell scripts also
check `web_interface/blueprints/api_v3/`, which became a package, instead of check `web_interface/blueprints/api_v3/`, which became a package, instead of
reporting `api_v3.py` as missing. reporting `api_v3.py` as missing. (`scripts/verify_web_ui.sh`,
`scripts/diagnose_web_ui.sh` and `scripts/debug/debug_web_manual.py` were
later deleted as unreferenced; see Docs and developer tools.)
### Docs and developer tools ### Docs and developer tools
@@ -569,6 +994,67 @@ New modules a plugin may import via `src.*` (floor on 3.5.0):
`app.py` line numbers, `api_v3.py` paths, StreamManager method names, `app.py` line numbers, `api_v3.py` paths, StreamManager method names,
nonexistent version-bump scripts and `ledmatrix` service user references nonexistent version-bump scripts and `ledmatrix` service user references
removed. removed.
- A mypy ratchet in CI. `mypy-clean.txt` lists the 71 modules under `src/` that type-check clean, and the new "Type check (mypy ratchet)" job runs `python scripts/check_types.py` (mypy 1.20.2 on exactly those files) so they stay clean; add a module when you make it clean (see CONTRIBUTING.md). The manual pre-commit `mypy` hook runs the same script. 35 modules were made clean for it with annotation-only fixes, no behaviour change. Their public signatures only widened (`declared_min_version()` now says it returns the manifest's value as-is, `Any`); `DynamicTeamResolver._rankings_cache` is annotated as the abbreviation-to-rank dict it holds. `mypy.ini` treats numpy and orjson as `Any`, so it parses with `python_version = 3.10` against numpy 2.3+ stubs and gives the same result whether orjson is installed or not.
- CI runs the web UI's DOM test suites (jsdom against the real server-rendered pages and API) in a new **Web UI JS tests** job, with the web interface started in emulator mode; `REQUIRE_DOM=1` makes a suite that can't run fail instead of being skipped. Two suites that had gone stale were fixed: the Tools suite now installs `LEDEscape` the way `base.html` does and supplies sample Starlark apps when the server has none, and the Store suite no longer assumes the registry has 48 plugins or fewer.
- CI installs `web_interface/requirements.txt` too, so flask-limiter, flask-compress and the web floors are tested. `test_api_helper_does_not_hand_set_brotli` now checks what it meant: core doesn't add `br` itself, and `requests` may advertise it when a brotli decoder is installed.
- All Discord links point to the LEDMatrix server's invite.
- `pytz` may be any release before 2027, so current timezone data installs; `requirements-test.txt` caps `psutil` below 7 like the runtime requirements and allows `pytest-cov` up to 7.x (checked against pytest 9 with the CI coverage run).
- The Claude GitHub Actions workflows pin `anthropics/claude-code-action` to a commit SHA like the other actions.
- `mypy.ini` parses again. A multi-line `exclude` and trailing comments on values made mypy refuse the whole file, so none of its settings applied and the pre-commit hook failed with "Missing target". The mypy hook is now manual (`pre-commit run mypy --hook-stage manual`) while the ~500 existing type errors in `src/` are paid down.
- `.gitignore` ignores everything in `config/` except the templates; `ytm_auth.json`, `saved_repositories.json`, `wifi_status.json` and `font_overrides.json` weren't ignored.
- `.sh` and `.service` files are always checked out with LF line endings.
- The Claude code-review check is skipped on pull requests from forks, which get no secrets and always failed it.
- Doc fixes: emulator guide (Python 3.10+, `emulator_config.json` isn't in the repo), README's nonexistent "API Metrics" feature, a stale route count, and missing index entries for the scroll-performance and offscreen-rendering docs and the frame-soak and render-bench scripts.
- `src/common/README.md` lists `frame_timing`, `json_body` and `render_gate`.
- New `scripts/README.md` lists every script.
- New `docs/ARCHITECTURE.md` (processes, shared state, display loop, plugin system, web UI) and `docs/PERMISSIONS.md` (owners, modes, both sudoers files, repair scripts).
- Deprecated plugin APIs are marked in the plugin docs.
- `src/common/README.md` covers every module.
- Stale setup, service and troubleshooting claims are corrected.
- Deleted 13 scripts nothing referenced (#607): `utils/cleanup_venv.sh`,
`utils/clear_python_cache.sh`, `install/migrate_config.sh`,
`install/debug_install.sh`, `debug/debug_web_manual.py`,
`diagnose_web_ui.sh`, `verify_web_ui.sh`, `fix_internet_connectivity.sh`,
`diagnose_plugin_permissions.sh`, `dev/validate_python.py`,
`download_nba_logos.py` (with `README_NBA_LOGOS.md`) and
`setup_plugin_repos.py`, all under `scripts/`; also `docs/archive/` and
`PLUGIN_IMPLEMENTATION_SUMMARY.md`. `config.template.json` no longer carries
`plugin_system.auto_discover`, `auto_load_enabled` or `development_mode`,
which nothing reads (existing configs keep them). About 20 docs had stale
claims corrected against the code.
- `test/test_js_unit_suites.py` runs every `test/js/unit/*.js` suite under
pytest; CI used to run one of the eight (#605).
- New test `test/test_common_is_hardware_free.py`: `src/common` must import
without `rgbmatrix`, and never import `src.base_classes`,
`src.display_manager` or `src.plugin_system` at module level, so plugins can
use it on machines with no panel library.
### Removed
- **The skin system.** Skins never rendered with the current scoreboard
plugins, so they are gone rather than "not supported yet": `src/skin_system/`,
`skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's
`"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`.
A `skin` or `skin_options` key left in a plugin's saved config still loads
and saves without a validation error; it is ignored, and the next save of
that plugin's settings removes it (unless the plugin's own schema declares
the key).
- **`src/base_classes/`** (`SportsCore`, the sport and mode classes,
`CelebrationMixin`, the rotation strategies, `data_sources`,
`api_extractors`). No known plugin imports it. A plugin that does must use
`src.common` or its own copy of the code.
- **Unused `src.common` modules and plugin-system helpers** (#608):
`src/common/config_helper.py`, `display_helper.py`, `game_helper.py`,
`utils.py` and `error_handler.py` (its re-exports leave `src.common`'s
`__all__`), `src/plugin_system/health_monitor.py` (`PluginHealthMonitor`,
whose loop did nothing; `PluginHealthTracker` is unchanged), and
`src.plugin_system.get_store_manager` / `__api_version__`. Nothing in core,
the scripts or the plugin monorepo imported them. `APIHelper`, `TextHelper`,
`ScrollHelper`, `LogoHelper` and the adaptive-layout exports of `src.common`
are unchanged. The same change removed unused methods from `ConfigService`,
`PluginStateManager`, `PluginManager`, `PluginExecutor`, `PluginLoader`,
`PluginStoreManager`, `VegasModeConfig` and `DisplayController`; none had
callers in core, the scripts or the monorepo.
## 3.4.0 ## 3.4.0
+11 -7
View File
@@ -13,14 +13,17 @@
loader does NOT fall back to it — `PluginManager.discover_plugins()` loader does NOT fall back to it — `PluginManager.discover_plugins()`
(`src/plugin_system/plugin_manager.py`) scans only the configured (`src/plugin_system/plugin_manager.py`) scans only the configured
directory. Fallbacks exist in two narrower places: store operations directory. Fallbacks exist in two narrower places: store operations
(`StoreManager._find_plugin_path()` in `store_manager.py`) and schema (`PluginStoreManager._find_plugin_path()` in `store_manager.py`, which
lookup (`SchemaManager.get_schema_path()` in `schema_manager.py`, searches `store_search_dirs()` from `plugin_dirs.py`) and schema lookup
which probes `plugins/` *before* `plugin-repos/`). (`SchemaManager.get_schema_path()` in `schema_manager.py`, which probes
`plugins/` *before* `plugin-repos/`).
- `src/plugin_system/plugin_dirs.py` — the one resolver for "which directory
holds plugin X" (manifest `id` first, then `<id>` / `ledmatrix-<id>`)
## Plugin System ## Plugin System
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py` - Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
- Required abstract methods: `update()`, `display(force_clear=False)` - Required abstract methods: `update()`, `display(force_clear=False)`
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt` - Each plugin needs: `manifest.json`, `config_schema.json`, and the entry point (`manager.py` by default); `requirements.txt` if it has dependencies. Required manifest fields: `docs/PLUGIN_API_REFERENCE.md#manifest-required-fields`
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager` - Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
- Config schemas use JSON Schema Draft-7 - Config schemas use JSON Schema Draft-7
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size) - Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
@@ -34,19 +37,20 @@
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001 - Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`) - Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>` - Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
- Soak a rig for frame timing (on the Pi, service running): `python3 scripts/frame_soak.py --preview` — late-frame rate across every scroller; see `docs/SCROLL_PERFORMANCE.md`
## Plugin Store Architecture ## Plugin Store Architecture
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos) - Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`) - Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json` - `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall - Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
- Monorepo plugins are installed via ZIP extraction (no `.git` directory) - Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version) - Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls - Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
- Third-party plugins can use their own repo URL with empty `plugin_path` - Third-party plugins can use their own repo URL with empty `plugin_path`
## Common Pitfalls ## Common Pitfalls
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat - paho-mqtt 2.x requires a `CallbackAPIVersion` argument: `VERSION1` for code written against v1 callback signatures (the MQTT bridge uses `VERSION2`)
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()` - BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly: - `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
`self.display_manager.image.paste(img, (x, y))` then `update_display()` `self.display_manager.image.paste(img, (x, y))` then `update_display()`
+1 -1
View File
@@ -63,7 +63,7 @@ ChuckBuilds, and any other forums hosted by or affiliated with the project.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement on the reported to the community leaders responsible for enforcement on the
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT) (DM a moderator or [LEDMatrix Discord](https://discord.gg/RdrC37rEag) (DM a moderator or
ChuckBuilds directly) or by opening a private GitHub Security Advisory if ChuckBuilds directly) or by opening a private GitHub Security Advisory if
the issue involves account safety. All complaints will be reviewed and the issue involves account safety. All complaints will be reviewed and
investigated promptly and fairly. investigated promptly and fairly.
+12 -4
View File
@@ -9,7 +9,7 @@ improvements, and code changes.
- **Bugs / feature requests**: open an issue using one of the templates - **Bugs / feature requests**: open an issue using one of the templates
in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/). in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
- **Real-time discussion**: the - **Real-time discussion**: the
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT). [LEDMatrix Discord](https://discord.gg/RdrC37rEag).
- **Plugin development**: - **Plugin development**:
[`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md) [`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md)
and the [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins) and the [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
@@ -58,10 +58,18 @@ integration tests.
3. **Keep PRs focused.** One conceptual change per PR. If you find 3. **Keep PRs focused.** One conceptual change per PR. If you find
adjacent bugs while working, fix them in a separate PR. adjacent bugs while working, fix them in a separate PR.
4. **Follow the existing code style.** The pre-commit hooks run 4. **Follow the existing code style.** The pre-commit hooks run
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `mypy` on `flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `bandit`,
`src/`, `bandit`, and `gitleaks` — install the CLI with and `gitleaks` — install the CLI with
`python -m pip install pre-commit`, then run `python -m pip install pre-commit`, then run
`pre-commit install` so they run on every commit; HTML/JS in `pre-commit install` so they run on every commit. Type checking
is a ratchet while the existing mypy errors in `src/` are paid
down: `mypy-clean.txt` lists the modules that type-check clean, and
CI runs `python scripts/check_types.py` (also the manual hook
`pre-commit run mypy --hook-stage manual`) to keep every listed
module clean. When you make another module clean, add it to the
list (sorted); don't take one off to get CI green. Keep type fixes
annotation-only where you can -- widen a hint rather than delete a
defensive runtime check mypy calls unreachable. HTML/JS in
`web_interface/` follows the patterns already in `templates/v3/` `web_interface/` follows the patterns already in `templates/v3/`
and `static/v3/`. and `static/v3/`.
5. **Update documentation** alongside code changes. If you add a 5. **Update documentation** alongside code changes. If you add a
+21 -12
View File
@@ -33,7 +33,7 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
- Show support on Youtube: https://www.youtube.com/@ChuckBuilds - Show support on Youtube: https://www.youtube.com/@ChuckBuilds
- Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/ - Check out the write-up on my website: https://www.chuck-builds.com/led-matrix/
- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/ - Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/
- Want to chat? Reach out on the LEDMatrix Discord: [https://discord.com/invite/uW36dVAtcT](https://discord.gg/dfFwsasa6W) - Want to chat? Reach out on the LEDMatrix Discord: [https://discord.gg/RdrC37rEag](https://discord.gg/RdrC37rEag)
- Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!) - Feeling Generous? Consider sponsoring this project or sending a donation (these AI credits aren't cheap!)
----------------------------------------------------------------------------------- -----------------------------------------------------------------------------------
@@ -328,6 +328,7 @@ This one-shot installer will automatically:
- Install required system packages (git, python3, build tools, etc.) - Install required system packages (git, python3, build tools, etc.)
- Clone or update the LEDMatrix repository - Clone or update the LEDMatrix repository
- Run the complete first-time installation script - Run the complete first-time installation script
- Print the web interface address, then **reboot the Pi automatically** (your SSH session will disconnect; give it a few minutes to come back)
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes. The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
@@ -689,9 +690,10 @@ Controls how long each installed plugin stays visible in seconds before switchin
### Display Format Settings ### Display Format Settings
- **`use_short_date_format`** (boolean, default: true) - **`use_short_date_format`** (boolean, default: true)
- Use short date format (e.g., "Jan 15") instead of long format (e.g., "January 15th") - Currently has no effect. The web UI still saves it, but no core code
- Set to `false` for longer, more readable dates reads it. Scoreboard plugins that offer a short date format read the
- Set to `true` to save space and show more information setting from their own plugin config instead. See
[CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md#display--other-keys).
### Dynamic Duration Settings (`display.dynamic_duration`) ### Dynamic Duration Settings (`display.dynamic_duration`)
@@ -779,15 +781,21 @@ Controls how long each installed plugin stays visible in seconds before switchin
<details> <details>
<summary>Manual SSH Commands (for reference)</summary> <summary>Manual SSH Commands (for reference)</summary>
The quick actions essentially just execute the following commands on the Pi. The web interface's quick actions (Start/Stop/Restart Display) call
`sudo systemctl start|stop|restart ledmatrix.service` — see
`execute_system_action()` in
[`web_interface/blueprints/api_v3/system.py`](web_interface/blueprints/api_v3/system.py).
The service runs [`run.py`](run.py) as root.
From the project root directory (ex: /home/ledpi/LEDMatrix): To run the display in the foreground instead (for debugging), stop the service
first, then from the project root (e.g. `/home/ledpi/LEDMatrix`):
```bash ```bash
sudo python3 display_controller.py sudo systemctl stop ledmatrix.service
sudo python3 run.py # add -d for debug logging
``` ```
This will start the display cycle but only stays active as long as your ssh session is active. This only runs as long as your SSH session stays open.
### Convenience Scripts ### Convenience Scripts
@@ -940,7 +948,7 @@ sudo systemctl enable ledmatrix-web.service
- **On-Demand Controls**: Start specific displays (weather, stocks, sports) on demand - **On-Demand Controls**: Start specific displays (weather, stocks, sports) on demand
- **Service Management**: Start/stop the main display service - **Service Management**: Start/stop the main display service
- **System Controls**: Restart, update code, and manage the system - **System Controls**: Restart, update code, and manage the system
- **API Metrics**: Monitor API usage and system performance - **System Stats**: CPU, memory and temperature on the Overview tab
- **Logs**: View system logs in real-time - **Logs**: View system logs in real-time
### Troubleshooting Web Interface ### Troubleshooting Web Interface
@@ -957,9 +965,10 @@ sudo systemctl enable ledmatrix-web.service
3. Check if another service is using port 5000 3. Check if another service is using port 5000
**Service Fails to Start:** **Service Fails to Start:**
1. Check Python dependencies are installed 1. Check Python dependencies are installed. The installer puts them in the
2. Verify the virtual environment is set up correctly system Python with `pip install --break-system-packages` (there is no
3. Check file permissions and ownership virtual environment), so `python3 -c "import flask"` should succeed.
2. Check file permissions and ownership
</details> </details>
+1 -1
View File
@@ -16,7 +16,7 @@ Use one of these channels, in order of preference:
maintainer. maintainer.
- Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new> - Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new>
2. **Discord DM**. Send a direct message to a moderator on the 2. **Discord DM**. Send a direct message to a moderator on the
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT). Don't post in [LEDMatrix Discord](https://discord.gg/RdrC37rEag). Don't post in
public channels. public channels.
Please include: Please include:
+15 -7
View File
@@ -1,12 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Legacy entry point: runs ``run.py``, which is the one to use.
``python3 run.py`` (``-e`` for the emulator, ``-d`` for debug logging) is how
the display service and the docs start LEDMatrix. This file used to import
``src.display_controller.main`` directly, which skipped what run.py sets up
first -- ``sys.dont_write_bytecode`` (root-owned ``__pycache__`` in plugin
directories blocks the web service from updating them), the ``-e``/``-d``
flags, and the logging configuration. It now runs run.py exactly as
``python3 run.py`` would, with the same arguments.
"""
import os import os
import sys import runpy
# Add the project root directory to Python path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from src.display_controller import main
if __name__ == "__main__": if __name__ == "__main__":
main() runpy.run_path(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "run.py"),
run_name="__main__",
)
+55 -74
View File
@@ -185,63 +185,53 @@ their config section to control how oversized content is handled (see
### Plugin Integration (Developer Guide) ### Plugin Integration (Developer Guide)
All of these have defaults in
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you
need.
**1. Implement Content Method:** **1. Implement Content Method:**
```python ```python
def get_vegas_content(self): def get_vegas_content(self):
""" # Return a PIL Image, a list of Images, or None.
Return PIL Image or list of Images for Vegas mode. # A single image is one block; a list becomes one item per image.
return [self._render_game(game) for game in self.games]
Returns:
PIL.Image or list[PIL.Image]: Content to display
- Single image: fixed-width content
- List of images: multiple segments
- None: skip this cycle
"""
# Example: Return single wide image
img = Image.new('RGB', (256, 32))
# ... render your content ...
return img
# Example: Return multiple segments
return [image1, image2, image3]
``` ```
If it returns `None` (the default), Vegas falls back to the plugin's
`scroll_helper` image, then to capturing `display()` output
(`PluginAdapter.get_content()` in
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
**2. Specify Content Type:** **2. Specify Content Type:**
```python ```python
def get_vegas_content_type(self): def get_vegas_content_type(self):
""" # 'multi' | 'static' | 'none' -- default is 'static'
Specify how content should be handled. return 'multi'
Returns:
str: 'multi' | 'static' | 'none'
"""
return 'multi' # Default for most plugins
``` ```
`'none'` excludes the plugin from Vegas mode.
**3. Optionally Specify Display Mode:** **3. Optionally Specify Display Mode:**
```python These return `VegasDisplayMode` members, not strings:
def get_vegas_display_mode(self):
"""
Preferred display mode for this plugin.
Returns: ```python
str: 'scroll' | 'fixed' | 'static' from src.plugin_system.base_plugin import VegasDisplayMode
"""
return 'scroll' def get_vegas_display_mode(self):
return VegasDisplayMode.SCROLL
def get_supported_vegas_modes(self): def get_supported_vegas_modes(self):
""" return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
List of supported modes.
Returns:
list: ['scroll', 'fixed', 'static']
"""
return ['scroll', 'static']
``` ```
`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and
`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the
plugin's `vegas_mode` config value if set, otherwise maps the content type
(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`).
### Content Rendering Guidelines ### Content Rendering Guidelines
**Image Dimensions:** **Image Dimensions:**
@@ -966,11 +956,16 @@ from src.cache_manager import CacheManager
service = get_background_service(CacheManager()) service = get_background_service(CacheManager())
stats = service.get_statistics() stats = service.get_statistics()
print(f"Active tasks: {stats['active_tasks']}") print(f"Active: {stats['active_requests']}")
print(f"Completed: {stats['completed']}") print(f"Completed: {stats['completed_requests']}")
print(f"Failed: {stats['failed']}") print(f"Failed: {stats['failed_requests']}")
``` ```
Other keys: `total_requests`, `cached_hits`, `cache_misses`,
`average_fetch_time`, `completed_requests_count` (results currently held in
memory) — see `BackgroundDataService.get_statistics()` in
[`src/background_data_service.py`](../src/background_data_service.py).
**Enable Debug Logging:** **Enable Debug Logging:**
```python ```python
import logging import logging
@@ -981,6 +976,10 @@ logging.getLogger('src.background_data_service').setLevel(logging.DEBUG)
## 5. Permission Management ## 5. Permission Management
Ownership, modes, sudo rules and the repair scripts are listed in
[PERMISSIONS.md](PERMISSIONS.md). This section covers the helpers code uses
to keep files shareable.
### Overview ### Overview
LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files. LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files.
@@ -1044,7 +1043,7 @@ ensure_file_permissions(config_path, get_config_file_mode(config_path))
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read | | Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read | | Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
| Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read | | Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read |
| Cache files | `rw-rw-r--` | `0o664` | Owner/group write, all read | | Cache files | `rw-rw----` | `0o660` | Owner/group write, no world access (`_CACHE_FILE_MODE` in `src/cache/disk_cache.py`) |
**Directory Permissions:** **Directory Permissions:**
@@ -1115,40 +1114,22 @@ These core utilities **already handle permissions** - you don't need to call per
### Manual Fixes ### Manual Fixes
If you encounter permission issues: [PERMISSIONS.md](PERMISSIONS.md) lists who owns what on an installed system,
the expected modes, and which `scripts/fix_perms/` script to run as which
user. In short:
```bash - `fix_assets_permissions.sh`, `fix_cache_permissions.sh` and
# Targeted permission fixes (see scripts/fix_perms/README.md) `fix_plugin_permissions.sh` are run with `sudo`.
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts) - `fix_web_permissions.sh` is run as the web interface user, without
sudo ./scripts/fix_perms/fix_cache_permissions.sh # all cache directories `sudo` (it refuses to run as root and calls `sudo` itself where needed).
sudo ./scripts/fix_perms/fix_plugin_permissions.sh # plugin directories It resets project file ownership for that user, then makes the two
sudo ./scripts/fix_perms/fix_web_permissions.sh # web interface files helper scripts the web user may run as root (`safe_plugin_rm.sh`,
`safe_pip_install.sh`) root-owned again and restores `config_secrets.json`
to its owner, the `ledmatrix` group and mode `640`. It does not write
sudoers rules; `scripts/install/configure_web_sudo.sh` does that.
# Fix specific directory Do not `chmod` the whole `config/` directory: `config_secrets.json` must stay
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config `640`.
sudo chmod -R 2775 /home/ledpi/LEDMatrix/config
sudo find /home/ledpi/LEDMatrix/config -type f -exec chmod 664 {} \;
# Verify permissions
ls -la config/
ls -la assets/
```
### Verification
```bash
# Check directory has setgid bit
ls -ld assets/
# Should show: drwxrwsr-x (note the 's')
# Check file has correct group
ls -l assets/logo.png
# Should show group 'ledpi'
# Check file permissions
stat -c "%a %n" config/config.json
# Should show: 644 config/config.json
```
--- ---
+14 -76
View File
@@ -13,7 +13,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
- [Using Weather Icons](#using-weather-icons) - [Using Weather Icons](#using-weather-icons)
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates) - [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
- [Cache Strategy Patterns](#cache-strategy-patterns) - [Cache Strategy Patterns](#cache-strategy-patterns)
- [Font Management and Overrides](#font-management-and-overrides) - [Font Management](#font-management)
- [Error Handling Best Practices](#error-handling-best-practices) - [Error Handling Best Practices](#error-handling-best-practices)
- [Performance Optimization](#performance-optimization) - [Performance Optimization](#performance-optimization)
- [Testing Plugins with Mocks](#testing-plugins-with-mocks) - [Testing Plugins with Mocks](#testing-plugins-with-mocks)
@@ -25,69 +25,12 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
## Using Weather Icons ## Using Weather Icons
The Display Manager provides built-in weather icon drawing methods for easy visual representation of weather conditions. The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
### Basic Weather Icon Usage are deprecated, removed in 3.7.0. Draw your own icons instead: render them
onto a PIL image and paste it onto `self.display_manager.image`, or ship
```python icon images with the plugin. The weather plugin's `WeatherIcons` class is an
def display(self, force_clear=False): example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
if force_clear:
self.display_manager.clear()
# Draw weather icon based on condition
condition = self.data.get('condition', 'clear')
self.display_manager.draw_weather_icon(condition, x=5, y=5, size=16)
# Draw temperature next to icon
temp = self.data.get('temp', 72)
self.display_manager.draw_text(
f"{temp}°F",
x=25, y=10,
color=(255, 255, 255)
)
self.display_manager.update_display()
```
### Supported Weather Conditions
The `draw_weather_icon()` method automatically maps condition strings to appropriate icons:
- `"clear"`, `"sunny"` → Sun icon
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
- `"thunderstorm"`, `"storm"` → Storm icon
### Custom Weather Icons
For more control, use individual icon methods:
```python
# Draw specific icons
self.display_manager.draw_sun(x=10, y=10, size=16)
self.display_manager.draw_cloud(x=10, y=10, size=16, color=(150, 150, 150))
self.display_manager.draw_rain(x=10, y=10, size=16)
self.display_manager.draw_snow(x=10, y=10, size=16)
```
### Text with Weather Icons
Use `draw_text_with_icons()` to combine text and icons:
```python
icons = [
("sun", 5, 5), # Sun icon at (5, 5)
("cloud", 100, 5) # Cloud icon at (100, 5)
]
self.display_manager.draw_text_with_icons(
"Weather: Sunny, Cloudy",
icons=icons,
x=10, y=20,
color=(255, 255, 255)
)
```
--- ---
@@ -251,11 +194,8 @@ def update(self):
sport_key = "nhl" sport_key = "nhl"
cache_key = f"{self.plugin_id}_{sport_key}_games" cache_key = f"{self.plugin_id}_{sport_key}_games"
# Uses sport-specific live_update_interval from config # get_background_cached_data() is deprecated, removed in 3.7.0 — use get()
cached = self.cache_manager.get_background_cached_data( cached = self.cache_manager.get(cache_key, max_age=60)
cache_key,
sport_key=sport_key
)
if cached: if cached:
self.games = cached self.games = cached
@@ -282,9 +222,9 @@ def on_config_change(self, new_config):
--- ---
## Font Management and Overrides ## Font Management
Use the Font Manager for advanced font handling and user customization. The display manager's built-in fonts and text measurement. For fonts shipped with a plugin, see [FONT_MANAGER.md](FONT_MANAGER.md).
### Using Different Fonts ### Using Different Fonts
@@ -656,12 +596,10 @@ def update(self):
```python ```python
def update(self): def update(self):
# Check if another plugin is enabled # get_enabled_plugins() is deprecated, removed in 3.7.0 — check the
enabled_plugins = self.plugin_manager.get_enabled_plugins() # instance's `enabled` flag instead
if "weather" in enabled_plugins:
# Weather plugin is available
weather_plugin = self.plugin_manager.get_plugin("weather") weather_plugin = self.plugin_manager.get_plugin("weather")
if weather_plugin: if weather_plugin is not None and weather_plugin.enabled:
# Use weather data # Use weather data
pass pass
``` ```
+226
View File
@@ -0,0 +1,226 @@
# Architecture
A map of the codebase for a new contributor: which process does what, how
they talk to each other, and where to start reading for common changes.
## Processes
| systemd unit | Runs as | Runs | Installed by |
|---|---|---|---|
| `ledmatrix.service` | root | [`run.py`](../run.py) → `DisplayController` | [`install_service.sh`](../scripts/install/install_service.sh) |
| `ledmatrix-web.service` | the installing user | [`start_web_conditionally.py`](../scripts/utils/start_web_conditionally.py) → [`web_interface/start.py`](../web_interface/start.py) (Flask, port 5000) | `install_service.sh`, [`install_web_service.sh`](../scripts/install/install_web_service.sh) |
| `ledmatrix-update-verify.path` / `.service` | the web user | Health check after an automatic update | the same installers, or [`src/auto_update_setup.py`](../src/auto_update_setup.py) at runtime |
| `ledmatrix-wifi-monitor.service` | root | [`wifi_monitor_daemon.py`](../scripts/utils/wifi_monitor_daemon.py) | [`install_wifi_monitor.sh`](../scripts/install/install_wifi_monitor.sh) |
| `ledmatrix-mqtt-bridge.service` | root | [MQTT bridge](../integrations/mqtt_bridge/README.md) (optional) | [`install_mqtt_bridge.sh`](../scripts/install/install_mqtt_bridge.sh) |
| `ledmatrix-dns-fix.service` | root | DNS workaround (optional) | [`install_dns_fix.sh`](../scripts/install/install_dns_fix.sh) |
Unit templates are in [`systemd/`](../systemd/README.md). The display runs as
root because the LED matrix library needs direct GPIO access. The web
interface runs unprivileged and uses a fixed list of `sudo` rules for the
few privileged things it does; see [PERMISSIONS.md](PERMISSIONS.md).
`start_web_conditionally.py` exits without starting Flask when
`web_display_autostart` is explicitly false in `config.json`.
## How the two main processes share state
The display and the web interface are separate processes that never call
each other. They share three things:
1. **`config/config.json` and `config/config_secrets.json`.** The web
interface writes them through `ConfigManager`
([`src/config_manager.py`](../src/config_manager.py)); the display
notices through `ConfigService` (below).
2. **The disk cache**, `/var/cache/ledmatrix` (owned `root:ledmatrix`,
setgid, files `0660`), read and written through `CacheManager`
([`src/cache_manager.py`](../src/cache_manager.py),
[`src/cache/disk_cache.py`](../src/cache/disk_cache.py)). Readers in the
other process pass `memory_ttl=0` so they do not serve a stale in-memory
copy.
3. **A few files in `/tmp`.**
| State | Where | Written by | Read by |
|---|---|---|---|
| On-demand request | cache `display_on_demand_request` | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py) | display: `_poll_on_demand_requests()` |
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
| Error clear | cache `plugin_error_clear_request` | web | display |
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
The on-demand start route starts `ledmatrix.service` when it is not running
(`start_service`, on by default) but never restarts a running one: the display
reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell
sleep, its render loops and Vegas's interrupt check as well as the main loop.
## Display loop
[`src/display_controller.py`](../src/display_controller.py), class
`DisplayController`. `__init__` loads config, starts the cache and the
error-snapshot publisher, runs the startup validator, creates the
`DisplayManager` ([`src/display_manager.py`](../src/display_manager.py)),
`FontManager` and `PluginManager`, loads the enabled plugins in parallel,
runs an initial `update()` pass within a 20-second budget
(`_INITIAL_UPDATE_BUDGET_SECONDS`; a plugin that misses it is deferred to
the scheduler), and sets up Vegas mode.
`run()` is the main loop. Each pass, in order: apply a pending plugin
enable/disable, poll on-demand requests, run scheduled plugin updates, check
the on/off schedule and brightness, then show one screen. Priority is
on-demand, then WiFi status messages, then live priority, then Vegas mode,
then normal rotation.
- **Rotation.** `available_modes` is the ordered list of display modes;
`current_mode_index` advances after each screen.
`_apply_plugin_rotation_order()` applies `display.plugin_rotation_order`.
- **Durations.** `_get_display_duration()`: `display.display_durations[mode]`,
else the plugin's `get_display_duration()`, else 30 s. Plugins that
support dynamic duration run until `is_cycle_complete()`, capped by
`display.dynamic_duration.max_duration_seconds` (default 180 s).
- **On-demand.** A request from the web interface pins one plugin (or mode)
for a duration. `_activate_on_demand()` / `_clear_on_demand()`; the
session is saved under `display_on_demand_config` so it survives a
restart. It also keeps the display on during scheduled off hours. A
request for a plugin that is disabled in config loads it live
(`_load_plugin_for_on_demand()`, `load_plugin(force_enabled=True)`)
without writing `config.json`; the main loop unloads it once on-demand
moves off it (`_release_on_demand_plugins()`).
- **Live priority.** `_check_live_priority()` looks for a plugin whose
`has_live_priority()` and `has_live_content()` are both true and switches
to it, rotating between several live games.
- **Schedule and dim schedule.** `_check_schedule()` reads `schedule`;
`_check_dim_schedule()` reads `dim_schedule` and
`display.hardware.brightness`. Both are re-evaluated once a minute.
- **Long screens.** While a screen is showing (a dwell, a scroll, a Vegas
iteration), `_service_pending_changes()` repeats the on-demand, schedule
and brightness checks every 0.25 s, so a change does not wait for the
screen to end.
- **Config hot reload.** `ConfigService`
([`src/config_service.py`](../src/config_service.py)) polls the config and
secrets files' mtimes every 2 s and notifies subscribers when the content
changes. The controller refreshes its cached settings; enabling or
disabling a plugin queues `_reconcile_enabled_plugins()`, which loads or
unloads it on the display thread; each plugin gets `on_config_change()`
for its own section. Set `LEDMATRIX_HOT_RELOAD=false` to turn this off.
Matrix hardware settings are only read at start-up.
- **Vegas mode.** [`src/vegas_mode/`](../src/vegas_mode/): the display loop
calls `VegasModeCoordinator.run_iteration()`
([`coordinator.py`](../src/vegas_mode/coordinator.py)) when
`display.vegas_scroll.enabled` is set. `PluginAdapter` gets each plugin's
content (`get_vegas_content()`, else its `scroll_helper` image, else a
capture of `display()`), `StreamManager` orders it and `RenderPipeline`
scrolls it. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md).
- **Multi-display sync.** `DisplaySyncManager`
([`src/common/sync_manager.py`](../src/common/sync_manager.py)), enabled by
`sync.role`: a leader sends a follower its share of each frame over UDP
(port 5765).
## Plugin system
[`src/plugin_system/`](../src/plugin_system/):
| Area | Where |
|---|---|
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
| Discovery, load, unload, scheduled updates | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
| Resource metrics | [`resource_monitor.py`](../src/plugin_system/resource_monitor.py) |
| Config schemas and defaults | [`schema_manager.py`](../src/plugin_system/schema_manager.py) |
| Install, update, uninstall | [`store_manager.py`](../src/plugin_system/store_manager.py) (`PluginStoreManager`), with its methods split across [`store_registry.py`](../src/plugin_system/store_registry.py) (registry, GitHub), [`store_install.py`](../src/plugin_system/store_install.py) and [`store_update.py`](../src/plugin_system/store_update.py) |
| Core-version gate | [`compatibility.py`](../src/plugin_system/compatibility.py) |
Discovery scans only `plugin_system.plugins_directory` (default
`plugin-repos/`). Scheduled `update()` calls run on one background worker
thread; a per-plugin lock keeps `display()` from running during an update.
**Store flow.** `install_plugin()` renames any existing copy aside
(`<id>.standalone-backup-preinstall`), installs the new one, and puts the old
copy back if the install fails. Monorepo plugins come from the GitHub Trees
API, falling back to the repository ZIP; other plugins by `git clone` or
download. The manifest is checked (see
[required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)), the core
version gate runs, then dependencies are installed as root through
`scripts/fix_perms/safe_pip_install.sh`. `update_plugin()` pulls git
installs, undoing a pull whose new version is incompatible, and reinstalls
everything else through `_reinstall_with_rollback()`.
## Web interface
- **App.** [`web_interface/app.py`](../web_interface/app.py) builds the
Flask `app` at import time, creates the managers, and registers two
blueprints. `web_interface/start.py` runs it on port 5000.
- **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py)
serves the shell `templates/v3/base.html` at `/` and each tab as a
partial at `/partials/<name>` (templates in
`web_interface/templates/v3/partials/`). Plugin configuration tabs are
rendered from the plugin's schema by `plugin_config.html`.
- **API.** [`blueprints/api_v3/`](../web_interface/blueprints/api_v3/) is one
blueprint at `/api/v3`, split by area: `backup.py`, `config.py`,
`display.py`, `fonts.py`, `misc.py` (health, logs, errors, cache, sync),
`starlark.py`, `system.py` (service actions, updates, git), `wifi.py`, and
the plugin routes: `plugins.py` (installed list, enable/disable, plugin
actions), `plugin_store.py` (install, update, uninstall, store),
`plugin_config.py` (config, schema, reset), `plugin_assets.py` (uploads,
plugin static files), `plugin_health.py` (health, metrics, limits),
`plugin_operations.py` (operation history, state reconciliation) and
`plugin_calendar.py`. `__init__.py` defines the blueprint and shared helpers and
imports the modules so their routes register. Endpoints are listed in
[REST_API_REFERENCE.md](REST_API_REFERENCE.md).
- **Front end.** HTMX loads each tab's partial on first open
(`hx-trigger="loadtab"`); Alpine.js holds page state. Scripts are in
`web_interface/static/v3/js/`; form widgets are bundled from
[`js/widgets/`](../web_interface/static/v3/js/widgets/README.md).
- **Server-sent events** (`app.py`): `/api/v3/stream/stats` (CPU, memory,
temperature, service state, every 10 s), `/api/v3/stream/display` (preview
frames when the PNG changes) and `/api/v3/stream/logs` (journal of both
services). One generator thread per stream is shared by all clients.
## Updates
- **Update Code** on the Overview tab and the automatic updater both call
`perform_core_update()` in
[`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py):
`git pull --rebase`, reinstall changed requirement files, report whether a
restart is needed.
- **Automatic updates** (`auto_update.enabled`, off by default):
`AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py)
runs in the web process, checks every 30 minutes, and updates at most
weekly between 02:00 and 05:00. Before pulling it copies
[`scripts/utils/auto_update_verify.py`](../scripts/utils/auto_update_verify.py)
to `data/auto_update_verifier.py`, then writes
`data/auto_update_verify.request`. That file triggers
`ledmatrix-update-verify.path`, which runs the verifier as a separate unit
(so restarting the web service does not kill it). The verifier restarts
both services, waits for the web API to answer and the display service to
stay up, and on failure resets to the previous commit and restarts again.
Plugin updates run only after a verified core update. State is in
`data/auto_update_state.json` and `data/auto_update_pending.json`.
- **Startup validator.** `StartupValidator`
([`src/startup_validator.py`](../src/startup_validator.py)) runs twice in
`DisplayController.__init__`: config and cache directory first, then
enabled plugins once the plugin manager exists. It also warns when an
installed systemd unit differs from its template in `systemd/`. Results
are logged; startup continues either way.
## Where to start reading
| Task | Start with |
|---|---|
| Change rotation, durations or priorities | `DisplayController.run()` and `_get_display_duration()` in [`display_controller.py`](../src/display_controller.py) |
| Add a config key | [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md), [`config/config.template.json`](../config/config.template.json), the tab's partial and `api_v3/config.py` |
| Change drawing or fonts | [`display_manager.py`](../src/display_manager.py), [`font_manager.py`](../src/font_manager.py), [`src/common/bdf_font.py`](../src/common/bdf_font.py) |
| Add a plugin-facing API | [`base_plugin.py`](../src/plugin_system/base_plugin.py) or [`src/common/`](../src/common/README.md); document it in [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) |
| Plugin install/update bugs | `PluginStoreManager` in [`store_manager.py`](../src/plugin_system/store_manager.py) |
| A plugin that won't load | `PluginManager.load_plugin()` and `PluginLoader.load_plugin()`; `python3 scripts/check_plugin.py --plugin <id>` |
| Add an API endpoint | the matching module in [`api_v3/`](../web_interface/blueprints/api_v3/) |
| Add a web UI tab or control | `templates/v3/base.html`, the tab's partial, `pages_v3.py` |
| Vegas scroll | [`src/vegas_mode/coordinator.py`](../src/vegas_mode/coordinator.py) |
| Installer or permissions | [`first_time_install.sh`](../first_time_install.sh), [`scripts/install/`](../scripts/install/), [PERMISSIONS.md](PERMISSIONS.md) |
| Work without a Pi | [DEV_PREVIEW.md](DEV_PREVIEW.md), [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md), [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) |
+11 -5
View File
@@ -172,10 +172,14 @@ ERROR - Plugin football-scoreboard configuration validation failed: 'api_key' is
### Enable Debug Logging ### Enable Debug Logging
Set environment variable: Run the display in the foreground with `-d`, or set `LEDMATRIX_DEBUG=true`
(the value must be `true`; `1` is ignored — see `setup_logging()` in
[`src/logging_config.py`](../src/logging_config.py)):
```bash ```bash
export LEDMATRIX_DEBUG=1 sudo systemctl stop ledmatrix.service
python run.py sudo python3 run.py -d
# or
sudo LEDMATRIX_DEBUG=true python3 run.py
``` ```
### Check Merged Configuration ### Check Merged Configuration
@@ -321,8 +325,10 @@ cp config/backups/config.json.backup.20240115_120000_000000 config/config.json
## Getting Help ## Getting Help
1. Check logs: `tail -f logs/ledmatrix.log` 1. Check logs. Both services log to journald, not to a file:
2. Enable debug: `LEDMATRIX_DEBUG=1` `sudo journalctl -u ledmatrix.service -f` (display) and
`sudo journalctl -u ledmatrix-web.service -f` (web interface)
2. Enable debug: `LEDMATRIX_DEBUG=true` or `python3 run.py -d`
3. Check error dashboard: `/api/v3/errors/summary` 3. Check error dashboard: `/api/v3/errors/summary`
4. Validate JSON: https://jsonlint.com/ 4. Validate JSON: https://jsonlint.com/
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues 5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
+7 -2
View File
@@ -105,6 +105,7 @@ logical image to multiple chained physical panels.
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) | | `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) | | `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config | | `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config |
| `scan_order_compensation` | string, `"auto"` | `"auto"` shows one half of each panel a refresh behind while something scrolls at one frame per refresh, which removes the 1px step a 1:N-scan panel shows across its middle; `"off"` disables it. Applies only to layouts whose row order is known: plain or parallel chains, 0 or 180 degree orientation, `multiplexing` 0, `scan_mode` 0, and not in the emulator | `DisplayManager._setup_scan_order_compensation()` (`src/display_manager.py`, `src/scan_order.py`) |
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) | | `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
## `display.vegas_scroll` — continuous scroll mode ## `display.vegas_scroll` — continuous scroll mode
@@ -127,7 +128,11 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `min_content_separation` | int, `24` | | `min_content_separation` | int, `24` |
| `min_cut_gap` | int, `6` | | `min_cut_gap` | int, `6` |
| `continuous_scroll` | bool, `true` | | `continuous_scroll` | bool, `true` |
| `smooth_scroll` | bool, `true` | | `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
| `extend_threshold_screens` | float, `2.0` | | `extend_threshold_screens` | float, `2.0` |
| `auto_trim` | bool, `true` | | `auto_trim` | bool, `true` |
| `trim_threshold` | int, `10` | | `trim_threshold` | int, `10` |
@@ -175,5 +180,5 @@ See [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md).
| Key | Meaning | | Key | Meaning |
|---|---| |---|---|
| `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_manager.py`) | | `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_registry.py`) |
| `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time | | `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time |
+9 -6
View File
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
self.draw_fit("12:34", rows[0]) # largest crisp font that fits self.draw_fit("12:34", rows[0]) # largest crisp font that fits
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True) self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
# Weather icons # Weather icons: draw_weather_icon() is deprecated, removed in 3.7.0 —
display_manager.draw_weather_icon("rain", x=10, y=10, size=16) # draw your own icons (the weather plugin ships WeatherIcons)
# Scrolling state # Scrolling state
display_manager.set_scrolling_state(True) display_manager.set_scrolling_state(True)
@@ -72,20 +72,23 @@ cache_manager.delete("key") # alias for clear_cache(key)
# Advanced caching # Advanced caching
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather") data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
data = cache_manager.get_background_cached_data("key", sport_key="nhl")
# Strategy # Strategy
strategy = cache_manager.get_cache_strategy("weather") strategy = cache_manager.get_cache_strategy("weather")
interval = cache_manager.get_sport_live_interval("nhl")
``` ```
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
are deprecated, removed in 3.7.0. See
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
## Plugin Manager Quick Methods ## Plugin Manager Quick Methods
```python ```python
# Get plugins # Get plugins
plugin = plugin_manager.get_plugin("plugin-id") plugin = plugin_manager.get_plugin("plugin-id")
all_plugins = plugin_manager.get_all_plugins() all_plugins = plugin_manager.get_all_plugins()
enabled = plugin_manager.get_enabled_plugins() # get_enabled_plugins() is deprecated, removed in 3.7.0 — check `enabled`
# on the entries in plugin_manager.plugins
# Get info # Get info
info = plugin_manager.get_plugin_info("plugin-id") info = plugin_manager.get_plugin_info("plugin-id")
@@ -168,7 +171,7 @@ def display(self, force_clear=False):
- [ ] Plugin inherits from `BasePlugin` - [ ] Plugin inherits from `BasePlugin`
- [ ] Implements `update()` and `display()` methods - [ ] Implements `update()` and `display()` methods
- [ ] `manifest.json` with required fields - [ ] `manifest.json` with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
- [ ] `config_schema.json` for web UI (recommended) - [ ] `config_schema.json` for web UI (recommended)
- [ ] `README.md` with documentation - [ ] `README.md` with documentation
- [ ] Error handling implemented - [ ] Error handling implemented
+6 -6
View File
@@ -17,13 +17,13 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com
## Prerequisites ## Prerequisites
### System Requirements ### System Requirements
- Python 3.7 or higher - Python 3.10 or higher
- Windows, macOS, or Linux - Windows, macOS, or Linux
- At least 2GB RAM (4GB recommended) - At least 2GB RAM (4GB recommended)
- Internet connection for plugin downloads - Internet connection for plugin downloads
### Required Software ### Required Software
- Python 3.7+ - Python 3.10+
- pip (Python package manager) - pip (Python package manager)
- Git (for plugin management) - Git (for plugin management)
@@ -50,8 +50,7 @@ pip install -r requirements-emulator.txt
``` ```
This installs: This installs:
- `RGBMatrixEmulator` - The core emulation library - `RGBMatrixEmulator` - the emulation library (and whatever it depends on)
- Additional dependencies for display adapters
### 3. Install Standard Dependencies ### 3. Install Standard Dependencies
@@ -63,8 +62,9 @@ pip install -r requirements.txt
### 1. Emulator Configuration File ### 1. Emulator Configuration File
The emulator uses `emulator_config.json` for configuration. Here's the The emulator uses `emulator_config.json` for configuration. It isn't in
default configuration as it ships in the repo: the repo (it's gitignored): RGBMatrixEmulator writes it on first run.
A typical file looks like this:
```json ```json
{ {
+120 -323
View File
@@ -9,12 +9,14 @@
## Overview ## Overview
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for: [`src/font_manager.py`](../src/font_manager.py) loads and caches the TTF and
- Manager font registration and detection BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
- Plugin font management which plugin uses which font so the web UI can show it.
- Programmatic per-element font overrides
- Performance monitoring and caching Several methods are deprecated and will be removed in LEDMatrix 3.7.0; they
- Dynamic font discovery log a warning on first call. They are listed in
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
[`test/test_deprecation.py`](../test/test_deprecation.py).
## Getting the FontManager ## Getting the FontManager
@@ -34,157 +36,60 @@ standalone FontManager when none is available (test harnesses, mocks).
`DisplayManager` has **no** `font_manager` attribute — `DisplayManager` has **no** `font_manager` attribute —
`display_manager.font_manager` raises `AttributeError`. `display_manager.font_manager` raises `AttributeError`.
## Architecture ## Resolving a font
### Manager-Centric Design
Managers define their own fonts, but the FontManager:
1. **Loads and caches fonts** for performance
2. **Detects font usage** for visibility
3. **Allows manual overrides** when needed
4. **Supports plugin fonts** with namespacing
### Font Resolution Flow
```
Manager requests font → Check manual overrides → Apply manager choice → Cache & return
```
## For Manager Developers
### Basic Font Usage
```python ```python
from src.font_manager import FontManager element_key = f"{self.plugin_id}.title"
class MyManager: # Register the choice so the web UI's Fonts tab can list it.
def __init__(self, config, display_manager, cache_manager, plugin_manager): self.font_manager.register_manager_font(
self.display_manager = display_manager manager_id=self.plugin_id,
self.font_manager = plugin_manager.font_manager # Shared FontManager
self.manager_id = "my_manager"
def display(self):
# Define your font choices
element_key = "my_manager.title"
font_family = "press_start"
font_size_px = 10
color = (255, 255, 255) # RGB white
# Register your font choice (for detection and future overrides)
self.font_manager.register_manager_font(
manager_id=self.manager_id,
element_key=element_key, element_key=element_key,
family=font_family,
size_px=font_size_px,
color=color
)
# Get the font (checks for manual overrides automatically)
font = self.font_manager.resolve_font(
element_key=element_key,
family=font_family,
size_px=font_size_px
)
# Use the font for rendering
self.display_manager.draw_text(
"Hello World",
x=10, y=10,
color=color,
font=font
)
```
### Advanced Font Usage
```python
class AdvancedManager:
def __init__(self, config, display_manager, cache_manager, plugin_manager):
self.display_manager = display_manager
self.font_manager = plugin_manager.font_manager
self.manager_id = "advanced_manager"
# Define your font specifications
self.font_specs = {
"title": {"family": "press_start", "size_px": 12, "color": (255, 255, 0)},
"body": {"family": "four_by_six", "size_px": 8, "color": (255, 255, 255)},
"footer": {"family": "five_by_seven", "size_px": 7, "color": (128, 128, 128)}
}
# Register all font specs
for element_type, spec in self.font_specs.items():
element_key = f"{self.manager_id}.{element_type}"
self.font_manager.register_manager_font(
manager_id=self.manager_id,
element_key=element_key,
family=spec["family"],
size_px=spec["size_px"],
color=spec["color"]
)
def get_font(self, element_type: str):
"""Helper method to get fonts with override support."""
spec = self.font_specs[element_type]
element_key = f"{self.manager_id}.{element_type}"
return self.font_manager.resolve_font(
element_key=element_key,
family=spec["family"],
size_px=spec["size_px"]
)
def display(self):
# Get fonts (automatically checks for overrides)
title_font = self.get_font("title")
body_font = self.get_font("body")
footer_font = self.get_font("footer")
# Render with fonts
self.display_manager.draw_text("Title", font=title_font, color=self.font_specs["title"]["color"])
self.display_manager.draw_text("Body Text", font=body_font, color=self.font_specs["body"]["color"])
self.display_manager.draw_text("Footer", font=footer_font, color=self.font_specs["footer"]["color"])
```
### Using Size Tokens
```python
# Get available size tokens
tokens = self.font_manager.get_size_tokens()
# Returns: {'xs': 6, 'sm': 8, 'md': 10, 'lg': 12, 'xl': 14, 'xxl': 16}
# Use token to get size
size_px = tokens.get('md', 10) # 10px
# Then use in font resolution
font = self.font_manager.resolve_font(
element_key="my_manager.text",
family="press_start", family="press_start",
size_px=size_px size_px=10,
color=(255, 255, 255),
) )
font = self.font_manager.resolve_font(
element_key=element_key,
family="press_start",
size_px=10,
)
self.display_manager.draw_text("Hello", x=10, y=10, font=font)
``` ```
## For Plugin Developers `resolve_font()` applies any entry for `element_key` in
`config/font_overrides.json`, maps a plugin-local family to its namespaced
name when `plugin_id` is passed, and then calls `get_font(family, size_px)`.
On error it returns a fallback font rather than raising.
> **Note**: plugins that ship their own fonts via a `"fonts"` block `get_font(family, size_px)` looks the family up in `font_catalog` and loads
> in `manifest.json` are registered automatically during plugin load it (cached per family and size).
> (`src/plugin_system/plugin_manager.py` calls
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
> URIs documented below are resolved relative to the plugin's
> install directory.
>
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
> font files in `assets/fonts/`. Its **Used by** column shows which
> loaded plugins registered each file through `register_manager_font()`
> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it
> warns before deleting one of them. It has no override editor (the
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
> The programmatic override workflow in
> [Manual Font Overrides](#manual-font-overrides) below still works.
> Let users pick fonts through your plugin's own config schema.
### Plugin Font Registration ## Font families
In your plugin's `manifest.json`: At start-up the FontManager scans `assets/fonts/` for `.ttf` and `.bdf`
files. Each becomes a family named after the file, lower-cased and without
the extension (`PressStart2P-Regular.ttf` → `pressstart2p-regular`). Four
aliases are added on top:
| Alias | File |
|---|---|
| `press_start` | `assets/fonts/PressStart2P-Regular.ttf` |
| `four_by_six` | `assets/fonts/4x6-font.ttf` |
| `five_by_seven` | `assets/fonts/5x7.bdf` |
| `tom_thumb` | `assets/fonts/tom-thumb.bdf` |
Read the catalog directly: `font_manager.font_catalog` is a dict of family
name to file path. Files added later are picked up on the next start of the
display service.
## Plugin fonts
Plugins that ship their own fonts declare them in a `"fonts"` block in
`manifest.json`. The plugin manager calls
`FontManager.register_plugin_fonts()` during plugin load. `plugin://…`
sources are resolved relative to the plugin's install directory.
```json ```json
{ {
@@ -195,231 +100,123 @@ In your plugin's `manifest.json`:
{ {
"family": "custom_font", "family": "custom_font",
"source": "plugin://fonts/custom.ttf", "source": "plugin://fonts/custom.ttf",
"metadata": { "metadata": {"description": "Custom plugin font", "license": "MIT"}
"description": "Custom plugin font",
"license": "MIT"
}
}, },
{ {
"family": "web_font", "family": "web_font",
"source": "https://example.com/fonts/font.ttf", "source": "https://example.com/fonts/font.ttf",
"metadata": { "metadata": {"checksum": "sha256:abc123..."}
"description": "Downloaded font",
"checksum": "sha256:abc123..."
}
} }
] ]
} }
} }
``` ```
### Using Plugin Fonts Registered families are namespaced as `<plugin_id>::<family>`. Pass
`plugin_id` to `resolve_font()` to use the short name:
```python ```python
class MyPlugin(BasePlugin): font = self.font_manager.resolve_font(
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.font_manager = self._get_font_manager()
def display(self):
# Use plugin font (automatically namespaced)
font = self.font_manager.resolve_font(
element_key=f"{self.plugin_id}.text", element_key=f"{self.plugin_id}.text",
family="custom_font", # Will be resolved as "my-plugin::custom_font" family="custom_font", # resolved as "my-plugin::custom_font"
size_px=10, size_px=10,
plugin_id=self.plugin_id plugin_id=self.plugin_id,
)
self.display_manager.draw_text("Plugin Text", font=font)
```
## Manual Font Overrides
Overrides are set in code (there is no web UI or REST endpoint for them).
They are stored in `config/font_overrides.json` and persist across restarts.
### Programmatic Overrides
```python
# Set override
font_manager.set_override(
element_key="nfl.live.score",
family="four_by_six",
size_px=8
) )
# Remove override
font_manager.remove_override("nfl.live.score")
# Get all overrides
overrides = font_manager.get_overrides()
``` ```
## Font Discovery ## Overrides
### Available Fonts `resolve_font()` still honours `config/font_overrides.json` (a map of
element key to `family` and/or `size_px`), which is read once at start-up.
The FontManager automatically scans `assets/fonts/` for TTF and BDF fonts: The methods that edit it — `set_override()`, `remove_override()`,
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
```python for overrides (the override editor and `/api/v3/fonts/overrides` were
# Get all available fonts removed). To let users choose a font, add a field to your plugin's config
fonts = font_manager.get_available_fonts() schema.
# Returns: {'press_start': 'assets/fonts/PressStart2P-Regular.ttf', ...}
# Check if font exists
if "my_font" in fonts:
font = font_manager.get_font("my_font", 10)
```
### Adding Custom Fonts
Place font files in `assets/fonts/` directory:
- Supported formats: `.ttf`, `.bdf`
- Font family name is derived from filename (without extension)
- Will be automatically discovered on next initialization
## Font usage in the web UI ## Font usage in the web UI
The web interface runs in its own process and has no FontManager, so the The web UI's **Fonts** tab lists, uploads, previews and deletes the font
display service publishes which plugin uses which font files in `assets/fonts/`. The web interface runs in its own process and has
(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it: no FontManager, so the display service publishes which plugin uses which
font ([`src/font_usage.py`](../src/font_usage.py)), and the tab's **Used by**
column reads it:
- **Source**: `register_manager_font()` registrations of the loaded - **Source**: `register_manager_font()` registrations of the loaded
plugins. `get_font()` and `resolve_font()` do not know the calling plugin plugins. `get_font()` and `resolve_font()` do not know the calling plugin
and are not counted, and neither is a plugin that opens a font file and are not counted, and neither is a plugin that opens a font file
directly with PIL — register the fonts your plugin draws with if you want directly with PIL — register the fonts your plugin draws with if you want
them listed. them listed.
- **Names**: a family, alias (`press_start`, `four_by_six`, - **Names**: a family, alias or path is resolved through `font_catalog` to
`five_by_seven`, `tom_thumb`) or path is resolved through the file it loads and reported under that file's name without extension
`font_catalog` to the file it loads and reported under that file's name (`PressStart2P-Regular`, `4x6-font`, `5x7`, `tom-thumb`), which is how the
without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`, Fonts tab keys its rows. Fonts outside `assets/fonts/` (a plugin's own
`tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside `plugin_id::family` fonts) and families that resolve to nothing are left
`assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families out.
that resolve to nothing are left out.
- **When**: a daemon thread started once plugins have loaded checks every - **When**: a daemon thread started once plugins have loaded checks every
10 seconds and writes the `font_usage_snapshot` cache key only when the 10 seconds and writes the `font_usage_snapshot` cache key only when the
usage changed (and once a day, so the cache's cleanup never expires it). usage changed (and once a day, so the cache's cleanup never expires it).
Unloading a plugin drops its registrations (`forget_manager_fonts`). Unloading a plugin drops its registrations (`forget_manager_fonts`).
- **Unknown**: until the display service has published, the column reads - **Unknown**: until the display service has published, the column reads
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`. "unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
- The tab warns before deleting a font that a loaded plugin registered.
## Performance Monitoring ## Text measurement
```python ```python
# Get performance stats
stats = font_manager.get_performance_stats()
print(f"Cache hit rate: {stats['cache_hit_rate']*100:.1f}%")
print(f"Total fonts cached: {stats['total_fonts_cached']}")
print(f"Failed loads: {stats['failed_loads']}")
print(f"Manager fonts: {stats['manager_fonts']}")
print(f"Plugin fonts: {stats['plugin_fonts']}")
```
## Text Measurement
```python
# Measure text dimensions
width, height, baseline = font_manager.measure_text("Hello", font) width, height, baseline = font_manager.measure_text("Hello", font)
# Get font height
font_height = font_manager.get_font_height(font) font_height = font_manager.get_font_height(font)
``` ```
## Best Practices ## Tips
### For Managers - BDF fonts usually look better than TTF at small sizes on LED panels.
- Use `{plugin_id}.{element}` element keys.
1. **Register all fonts** you use for visibility - Register the fonts you draw with, so the Fonts tab can warn before one is
2. **Use consistent element keys** (e.g., `{manager_id}.{element_type}`) deleted.
3. **Cache font references** if using same font multiple times - Replace direct `ImageFont.truetype("assets/fonts/...", 8)` calls with
4. **Use `resolve_font()`** not `get_font()` directly to support overrides `resolve_font()`: it caches, resolves paths against the install directory,
5. **Define sensible defaults** that work well on LED matrix and handles BDF files.
### For Plugins
1. **Use plugin-relative paths** (`plugin://fonts/...`)
2. **Include font metadata** (license, description)
3. **Provide fallback** fonts if custom fonts fail to load
4. **Test with different display sizes**
### General
1. **BDF fonts** are often better for small sizes on LED matrices
2. **TTF fonts** work well for larger sizes
3. **Monospace fonts** are easier to align
4. **Test on actual hardware** - what looks good on screen may not work on LED matrix
## Migration from Old System
### Old Way (Direct Font Loading)
```python
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
```
### New Way (FontManager)
```python
element_key = f"{self.manager_id}.text"
self.font_manager.register_manager_font(
manager_id=self.manager_id,
element_key=element_key,
family="pressstart2p-regular",
size_px=8
)
self.font = self.font_manager.resolve_font(
element_key=element_key,
family="pressstart2p-regular",
size_px=8
)
```
## Troubleshooting ## Troubleshooting
### Font Not Found **Font not found**
- Check font file exists in `assets/fonts/` - Check the file exists in `assets/fonts/`.
- Verify font family name matches filename (without extension, lowercase) - The family name is the filename without extension, lower-cased.
- Check logs for font discovery errors - Check the display service log for font discovery errors.
### Override Not Working **Plugin fonts not loading**
- Verify element key matches exactly what manager registered - Check the manifest's `"fonts"` block.
- Check `config/font_overrides.json` for correct syntax - Check the log for download or registration errors, and that font URLs are
- Restart application to ensure overrides are loaded reachable.
### Performance Issues ## API reference
- Check cache hit rate in performance stats
- Reduce number of unique font/size combinations
- Clear cache if it grows too large: `font_manager.clear_cache()`
### Plugin Fonts Not Loading Current methods:
- Verify plugin manifest syntax
- Check plugin directory structure
- Review logs for download/registration errors
- Ensure font URLs are accessible
## API Reference | Method | Purpose |
|---|---|
| `register_manager_font(manager_id, element_key, family, size_px, color=None)` | Record a font choice (feeds the Fonts tab) |
| `forget_manager_fonts(manager_id)` | Drop a manager's registrations (core calls it when a plugin unloads) |
| `resolve_font(element_key, family, size_px, plugin_id=None)` | Get a font, applying overrides and plugin namespacing |
| `get_font(family, size_px)` | Get a font directly |
| `get_native_bdf_size(family)` | Native pixel size of a BDF family, or `None` |
| `measure_text(text, font)` | `(width, height, baseline)` |
| `get_font_height(font)` | Line height |
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
| `clear_cache()` | Drop cached fonts and metrics |
| `font_catalog` (attribute) | Family name → file path |
### FontManager Methods ### Deprecated methods
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage Removed in 3.7.0. Each logs a warning on first call.
- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads)
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
- `measure_text(text, font)` - Measure text dimensions
- `get_font_height(font)` - Get font height
- `set_override(element_key, family=None, size_px=None)` - Set manual override
- `remove_override(element_key)` - Remove override
- `get_overrides()` - Get all overrides
- `get_detected_fonts()` - Get all detected font usage
- `get_manager_fonts(manager_id=None)` - Get fonts by manager
- `get_available_fonts()` - Get font catalog
- `get_size_tokens()` - Get size token definitions
- `get_performance_stats()` - Get performance metrics
- `clear_cache()` - Clear font cache
- `register_plugin_fonts(plugin_id, font_manifest)` - Register plugin fonts
- `unregister_plugin_fonts(plugin_id)` - Unregister plugin fonts
## Example: Complete Manager Implementation
For a working example of the font manager API in use, see
`src/font_manager.py` itself.
| Method | Use instead |
|---|---|
| `get_available_fonts()`, `get_font_catalog()` | read `font_catalog` |
| `get_size_tokens()` | pass a pixel size |
| `get_performance_stats()` | — |
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
| `get_manager_fonts()`, `get_detected_fonts()` | — |
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
+11 -10
View File
@@ -116,8 +116,8 @@ weather and other location-aware plugins.
4. Wait for installation to finish — installed plugins appear in the 4. Wait for installation to finish — installed plugins appear in the
**Installed Plugins** section above and get their own tab in the second **Installed Plugins** section above and get their own tab in the second
nav row nav row
5. Toggle the plugin to enabled 5. Toggle the plugin to enabled. The running display loads it within a
6. From **Overview**, click **Restart Display Service** few seconds; no restart is needed
You can also install community plugins straight from a GitHub URL using the You can also install community plugins straight from a GitHub URL using the
**Install from GitHub** section further down the same tab — see **Install from GitHub** section further down the same tab — see
@@ -128,9 +128,9 @@ You can also install community plugins straight from a GitHub URL using the
1. Each installed plugin gets its own tab in the second navigation row 1. Each installed plugin gets its own tab in the second navigation row
2. Open that plugin's tab to edit its settings (favorite teams, API keys, 2. Open that plugin's tab to edit its settings (favorite teams, API keys,
update intervals, etc.) update intervals, etc.)
3. Click **Save** 3. Click **Save**. The display service watches `config.json` and hands the
4. Restart the display service from **Overview** so the new settings take new settings to the running plugin, so no restart is needed. If a plugin
effect still shows old settings, restart the display service from **Overview**
**Note:** how long each plugin stays on screen is not set in the **Note:** how long each plugin stays on screen is not set in the
plugin's own tab — use the **Rotation** tab's **Screen Durations** plugin's own tab — use the **Rotation** tab's **Screen Durations**
@@ -197,14 +197,15 @@ The fastest way to verify a plugin works without waiting for the rotation:
**Check:** **Check:**
1. Plugin is enabled (toggle on the **Plugin Manager** tab) 1. Plugin is enabled (toggle on the **Plugin Manager** tab)
2. Display service was restarted after enabling 2. Plugin's display duration is non-zero
3. Plugin's display duration is non-zero 3. No errors in the **Logs** tab for that plugin. A plugin whose
4. No errors in the **Logs** tab for that plugin `validate_config()` fails is not loaded until its settings are fixed
**Fix:** **Fix:**
1. Enable the plugin from **Plugin Manager** 1. Enable the plugin from **Plugin Manager**
2. Click **Restart Display Service** on **Overview** 2. Check the **Logs** tab for plugin-specific errors
3. Check the **Logs** tab for plugin-specific errors 3. If it still does not appear, click **Restart Display Service** on
**Overview**
### Weather Plugin Shows "No Data" ### Weather Plugin Shows "No Data"
+5 -5
View File
@@ -52,10 +52,10 @@ pytest test/test_display_controller.py test/test_plugin_system.py
```bash ```bash
# Run a specific test class # Run a specific test class
pytest test/test_display_controller.py::TestDisplayControllerModeRotation pytest test/test_display_controller.py::TestDisplayControllerLivePriority
# Run a specific test function # Run a specific test function
pytest test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation pytest test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
``` ```
### Run Tests by Marker ### Run Tests by Marker
@@ -98,7 +98,7 @@ When you run `pytest`, you'll see:
``` ```
test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED
test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation PASSED test/test_display_controller.py::TestDisplayControllerOnDemand::test_activate_on_demand PASSED
... ...
``` ```
@@ -174,10 +174,10 @@ pytest
```bash ```bash
# Run with maximum verbosity and show print statements # Run with maximum verbosity and show print statements
pytest -vv -s test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation pytest -vv -s test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
# Run with Python debugger (pdb) # Run with Python debugger (pdb)
pytest --pdb test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation pytest --pdb test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
``` ```
### Run Tests in Parallel (Faster) ### Run Tests in Parallel (Faster)
+388
View File
@@ -0,0 +1,388 @@
# Offscreen Rendering
**Status (2026-09-24):** step 1, offscreen rendering, is implemented
(`DisplayManager.offscreen()`, the adapter on the prefetch thread, the plugin
lock). Steps 2 and 3 are proposed. When all three land, this file becomes the
reference for how plugin content is rendered off the render thread.
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
runs, A/B/B/A):
| build | late | by 1 | 2 | 3–5 | 6+ | freezes | render-thread fetches |
|---|---|---|---|---|---|---|---|
| #628 | 0.53% | 82 | 2 | 3 | 2 | 3 | 6 |
| step 1 | 0.63% | 78 | 63 | 17 | 2 | 1 | 0 |
| step 1 | 0.42% | 77 | 23 | 10 | 0 | 0 | 0 |
| #628 | 0.37% | 84 | 5 | 3 | 3 | 2 | 14 |
It does what it was built to: no plugin is fetched on the render thread, and
freezes fell from 5 to 1. But frames 2–5 refreshes late rose. The rendering
moved to the prefetch thread still needs the GIL, and the render thread waits
for it (risk 5 below). The late rate did not improve overall. The 1–2 s
freezes appear in both builds and have a separate, not yet identified cause.
The GIL fix, measured on hdpi (90 px/s, `pwm_bits` 8, preview open, 8-minute
runs after a 2-minute warm-up, order A B C C B A, 2026-09-24). Each arm pools
two runs, about 81,000 frames:
| arm | late | by 1 | 2 | 3–5 | 6+ | 2+ late per 10k frames | freezes |
|---|---|---|---|---|---|---|---|
| A: step 1 as is | 0.90% | 575 | 64 | 91 | 9 | 20.1 | 0 |
| B: `switch_interval_ms` 1 | 0.78% | 510 | 105 | 23 | 2 | 15.8 | 0 |
| C: `prefetch_gate` | **0.60%** | 471 | 11 | 7 | 2 | **2.5** | 0 |
The gate removes the frames the render thread spent waiting for the GIL, and
it costs the prefetch nothing that shows: it parked the thread for 3–6 s per
run, and the next group was ready at every strip extension in every arm.
`prefetch_gate` is therefore on by default; `switch_interval_ms` stays an
off-by-default experiment. What is left is almost all one refresh late, which
is the per-frame budget (a 6.75 ms p50 blit in a refresh the panel holds at
83–85 Hz while rendering), not contention.
The runs restart the service, so the hourly sports refresh never fell inside
one. That refresh is its own case: about twenty ESPN chunk-fetch threads at
once, which the gate does not cover (it gates only the prefetch thread).
## The problem
Vegas mode builds its ticker from every plugin's content. Most of that work
already happens on a background prefetch thread
(`RenderPipeline.start_prefetch`). But any plugin whose content needs the
**shared display canvas** is deferred to the render thread
(`RenderPipeline.drain_deferred`), one plugin every two seconds. The code's
own comments put each of those at 40–600 ms, and the render thread presents no
frames while one runs.
On hdpi (Pi 4, 512×64) most plugins take that path: geochron, tide-display,
news, hockey-scoreboard, ledmatrix-stocks, incoming-packages, clock-simple,
countdown, birdnet-go, ledmatrix-music and odds-ticker. They arrive in bursts
("Whole group deferred; strip will extend as it drains") every minute or so,
12 fetches in five minutes. That is the "occasional pause" a viewer sees.
An 8-minute soak (`scripts/frame_soak.py --preview`) of the #628 build on
hdpi:
| late by | frames |
|---|---|
| 1 refresh | 238 |
| 2 | 32 |
| 3–5 | 30 |
| 6+ | 5 |
| freezes ≥ 250 ms | 2 (0.97 s total) |
The 3+ rows and the freezes are the pauses. The single-refresh row is a
separate problem: the blit is 6 ms of a 10 ms refresh, so there is little
slack. It is covered under *What this does not fix*.
## Why a plugin is canvas-bound
The plugin-facing canvas is a set of shared attributes on `DisplayManager`:
`image`, `draw`, `matrix`, and the `width`/`height` properties that read from
`matrix`. Three adapter paths (`src/vegas_mode/plugin_adapter.py`) need them,
and each returns `None` under `offscreen_only=True` so the plugin is queued for
the render thread:
1. **Display capture** (`_capture_display_content`): clear the canvas, call
`plugin.display()`, copy `display_manager.image`. Used by any plugin
without `get_vegas_content()` or a populated `scroll_helper`.
2. **Scroll-content generation** (`_trigger_scroll_content_generation`): a
ticker plugin whose `scroll_helper.cached_image` is empty is made to build
it by calling `display(force_clear=True)` or `_create_scrolling_display()`.
Both draw on the canvas.
3. **Narrowed rendering** (`DisplayManager.render_size`): swaps the shared
`matrix`, `image` and `draw` for a narrower set so the plugin lays out for
`render_width_pct`. The render thread would see the swap mid-frame.
The render thread keeps the canvas coherent only because nothing else touches
it at the same time. A background thread can't use it.
## The design: a per-thread render target
`capture_mode()` is already per-thread (#423 made its state a
`threading.local`, so a background capture no longer suppresses the render
loop's pushes). The same move applies to the canvas itself:
```python
with display_manager.offscreen(width=None, height=None) as surface:
plugin.display(force_clear=True)
content = surface.image.copy()
```
For the **calling thread only**, inside the block:
| accessor | resolves to |
|---|---|
| `display_manager.image`, `.draw` | the surface's own image and draw: a fresh black canvas, `fontmode = "1"` |
| `display_manager.matrix` | a logical proxy reporting the surface size, so `width`/`height` and plugins that read `matrix.width` follow it. Hardware calls through it (`SetImage`, `SwapOnVSync`, `Clear`, brightness writes) are inert. |
| `update_display()`, `clear()` | canvas-only: the block implies capture mode, which is already per-thread |
| `set_scrolling_state()`, `set_frame_hold()` | no-ops, so a plugin's `display()` cannot re-pace the live scroll. Today it can, when it is captured on the render thread. |
Every other thread sees the real canvas, unchanged. The render loop in
particular keeps presenting while a plugin draws elsewhere.
### Implementation sketch
- `image`, `draw` and `matrix` become properties over `_image`, `_draw` and
`_matrix`, plus a thread-local current surface. The getter returns the
surface's value when the calling thread has one, else the shared one; setters
mirror that. That costs about 0.1 µs per access, and `update_display()` reads
each a handful of times per frame. Every existing `self.image = ...` in
`DisplayManager` (`clear()`, setup, fallback) keeps working and becomes
thread-correct for free.
- `render_size()` is rebuilt on `offscreen()`: it creates or narrows the
calling thread's surface instead of swapping shared state.
- `offscreen()` nests and always restores on exit, including when the plugin
raises.
- `VisualDisplayManager` (the plugin test harness) gets the same method, for
parity.
### Adapter changes
- `get_content(offscreen_only=True)` stops returning `None` for the three
paths above. Each runs inside `display_manager.offscreen(render_width)`.
- `_capture_display_content` and `_trigger_scroll_content_generation` drop
their "copy the shared image, restore it afterwards" bookkeeping, since the
shared image is never touched.
- **Take the plugin's lock.** `PluginManager.get_plugin_lock()` keeps
`update()` and `display()` mutually exclusive in normal rotation, but Vegas
never takes it, so today's render-thread captures already race
`update()`. Off the render thread the adapter can afford to wait: blocking
acquire with a timeout (proposed 2 s). On timeout it keeps the cached segment
and tries again next group.
- `drain_deferred()` and the deferred queue are deleted. The only render-thread
fetch left is the inline fallback when no prepared group is ready, which in
practice is the first extension. Prefetching at start removes that too.
## Keeping live content fresh
Offscreen rendering is also what makes fresh sports scores possible. Today a
plugin's segment is drawn when its group is prefetched, and the strip carries
7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
70–100 seconds later. When a plugin reports new data, Vegas only drops its
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
*next* turn, several minutes later. A segment already in the strip scrolls by
with the data it was drawn with.
That was the right trade while every redraw of a canvas-bound plugin stalled
the scroll. Off the render thread a redraw costs the scroll nothing, so the
strip can afford three things.
### 1. Refresh at the gate
Before a segment enters the viewport, check whether its plugin has updated
since the segment was drawn. If it has, redraw it offscreen and replace it
while it is still out of sight. Width changes are fine here, because
everything from that segment onward is still invisible.
The gate sits `lead` pixels ahead of the viewport's right edge:
`lead = max(one screen, speed × (render time + margin))`. The render time is
the plugin's own, measured on each render (sports cards take the longest,
hundreds of ms up to seconds per the prefetch notes). A plugin whose render
does not finish before its segment reaches the viewport keeps the old segment.
The scroll never waits for it.
Content is then at most `lead / speed` seconds old when it appears, a few
seconds instead of minutes, without changing how far ahead the rotation
fetches.
### 2. Replace ahead of the screen
When a plugin reports new data (the Vegas update tick already names them), any
of its segments that are **anywhere ahead of the viewport** are redrawn and
replaced straight away, not only at the gate. That covers the long stretch of
strip between prefetch and the gate.
### 3. Update on screen
A segment that is already **visible** is patched in place when the redrawn
version has the same geometry: the same total width, and the same width for
each card (a sports plugin returns one image per game, joined with
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
patches in and the digits update as the card scrolls past. The patch is a
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
between frames, so a frame never shows half of a patch.
When the geometry differs (a game added or dropped, a card that grew), the
visible part cannot change without a jump. Only the cards not yet on screen
are replaced, and only if the geometry up to that point is unchanged. Otherwise
the segment keeps its snapshot until it has scrolled off.
### Avoiding wasted work
- **Change detection.** `run_scheduled_updates_with_changes()` names a plugin
whenever its `update()` ran, not when its data changed. On hdpi
`clock-simple` and `ledmatrix-music` are named on every 4-second tick. A
redraw whose pixels hash the same as the segment's is discarded without a
swap.
- **Redraw on real updates only.** Vegas makes no API calls. Each plugin
fetches on its own schedule, and a redraw is triggered only when the
plugin's `update()` has run since its segment was drawn. On hdpi live
football, baseball and hockey poll every 30 s (live odds every 60 s,
everything else hourly), so a live sports card is redrawn once per poll.
- **Floor.** A plugin is redrawn at most once per
`vegas_scroll.refresh_min_interval` (proposed 10 s), and never while its
previous redraw is still running. The floor never holds back a sports card
polling every 30 s. It exists for chatty plugins: `clock-simple` updates
every second and `ledmatrix-music` polls every 2 s.
- **One worker.** Redraws go through the same background worker as prefetch,
one plugin at a time at `nice 10`, under the plugin's lock.
Data freshness is still bounded by each plugin's own fetch interval (how often
it polls live scores). Drawing faster cannot beat the data source.
### The strip becomes a list of segments
All three need the strip to be replaceable by segment. Today it is one
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
`append_content()` rebuilds the whole thing on the render thread for every
appended block. That is also a pause source.
Proposed `SegmentStrip`, used by Vegas in place of the single image:
- an ordered list of segments: plugin id, card boundaries, a pixel array, the
render time, and the plugin data version it was drawn from, plus its
x-offset in the strip;
- `visible(x, width)` assembles the viewport by slicing across at most a few
segments: the same ~100 KB copy per frame that slicing the single image
costs today;
- append and trim become O(block) list operations, not a copy of the strip;
- replace swaps one list entry and shifts the offsets of the segments after it
(dozens at most). A same-geometry patch copies pixels into the existing array.
Every mutation is prepared off the render thread and applied by the render
thread at a frame boundary, so the strip the render loop reads is never
half-changed.
### Multi-display sync
The follower renders from its own copy of the strip, offset from the leader's
scroll position. Today the leader sends that copy whole, and only in
`start_new_cycle()` (`send_scroll_image`), plus the scroll position every
frame. Continuous scroll, the default, extends and trims the strip without
starting a new cycle, and nothing sends those changes. From reading the code,
the follower therefore probably falls out of step after the first extension
already, before any of this design. That is untested; it needs a two-Pi rig.
With a segment strip, keeping the follower identical becomes **replaying the
leader's operations**:
- Every strip mutation (append, trim, replace, patch) is one operation in
strip coordinates. The leader applies it and sends the same operation to the
follower over the existing TCP channel. Segments are small: a card is ~29 KB
raw and compresses well.
- Operations on off-screen segments apply on arrival. A patch to a segment
that is on either panel carries an *apply at scroll position X* stamp a
couple of hundred milliseconds ahead. Both sides apply it when their scroll
position passes X, so both panels change on the same frame, within the
existing position-sync jitter.
- Each operation carries a sequence number. A follower that sees a gap (a
reconnect, a dropped message) asks for a full snapshot, which is today's
`send_scroll_image` path.
That also fixes the probable continuous-mode gap as a side effect, since
appends and trims become operations too. Until it is in place, fresh-content
updates are disabled while sync is active.
## Risks, and what was checked
1. **Plugins holding their own reference to the shared `draw` or `image`.**
They would keep drawing into the shared canvas, and routing by thread can't
redirect them. A grep of the 49 plugins installed on hdpi found none storing
`display_manager.draw` or `.image` in an attribute (a pattern search, so
indirect aliasing would slip past it). A plugin that did would
draw into an image nobody displays, which trims to a blank segment. That is
not corruption, and it is no worse than today.
2. **Plugins calling the matrix directly.** None in the audit. Inside
`offscreen()` the proxy makes it inert anyway.
3. **Font thread-safety.** `FontManager` shares font objects across plugins.
Measured on Pillow 12.3, two threads rendering text take 1.94× as long as
one, so text rendering holds the GIL and FreeType is never entered
concurrently. Re-check if Pillow changes that.
4. **Plugin thread-safety.** `display()` moves to the prefetch thread. The
plugin lock makes it exclusive with `update()`, which is more protection
than it has today. Threads a plugin starts itself are not covered, as today.
5. **The GIL.** Moving 40–600 ms of plugin rendering off the render thread
removes the pauses, but the work still needs the GIL. Pillow drawing holds
it, and a waiting thread only gets it back after the switch interval
(default 5 ms). Expect some single-refresh late frames while a prefetch
runs. Measure with the soak. A render process separate from plugin work
is the structural answer (the "native presenter" step). Two experiments
get most of the way first (results under Status, above):
- `vegas_scroll.switch_interval_ms` lowers the switch interval for a Vegas
run (1 ms is the obvious try), so the render thread waits at most that
long behind bytecode. It does nothing for a C call that keeps the GIL.
- `vegas_scroll.prefetch_gate` (`src/common/render_gate.py`) lets the
prefetch thread run Python only while the render thread is blocked in
`SwapOnVSync`, up to just before the refresh the swap returns on, and
parks it the rest of the time. That covers C calls too, since the gate is
checked before each one starts. It never parks the thread while it holds
a lock the render thread takes, and never for more than 50 ms. It needs
the rebuilt binding, which releases the GIL during the swap. On by
default.
## What this does not fix
- **The blit.** Copying a 512×64 frame into the matrix (`SetImage`) is ~6 ms at
8 PWM bits on a Pi 4, leaving ~4 ms of slack per refresh. That is the main
source of the single-refresh late frames. Holding frames for two refreshes
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
step.
- **Live refreshes pushed from `update()`.** Some sports plugins call
`display()` and `update_display()` from inside `update()`, which runs on the
update worker and can push to the panel mid-Vegas. That is a separate
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
while Vegas owns the panel), but it is out of scope here.
## Test plan
- **Unit, `DisplayManager`:** one thread inside `offscreen()` draws while
another reads `image`/`draw`/`matrix`/`width`/`height` and sees the real
canvas. Also: `update_display()` and `set_scrolling_state()` are inert inside;
`render_size()` narrows only the calling thread; nesting and exceptions
restore state.
- **Unit, adapter:** a stub display-capture plugin and a stub scroll-helper
plugin both return content with `offscreen_only=True`, and nothing is queued
for the render thread. The plugin lock is taken, and a timeout keeps the cached
segment.
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
300 ms. The Vegas render loop never goes a frame without presenting (frame
timing recorder: zero freezes).
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
matches slicing one concatenated image, pixel for pixel. Append, trim,
replace-ahead and same-geometry patch each leave every other column
unchanged. A geometry-changing patch of a visible segment is refused.
- **Freshness:** a stub sports plugin whose score changes every second. The
score on screen is never older than `lead / speed` plus the plugin's fetch
interval. A visible card's digits change without the frame-timing recorder
seeing a late frame. An unchanged redraw is discarded.
- **Hardware:** an hdpi soak, A/B against the #628 build, alternating order.
Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late
rate below 0.66%. Plus, for freshness: log each segment's age when it enters
the viewport, and compare the median and max before and after.
## Rollout
Three changes, each soaked on hdpi before the next:
1. **Offscreen rendering:** `offscreen()`, the adapter on the prefetch thread,
and the plugin lock. Removes the render-thread pauses.
2. **`SegmentStrip`:** Vegas's strip becomes a list of segments. Removes the
whole-strip copy on append. No visible behaviour change.
3. **Fresh content:** refresh at the gate, replace ahead, patch on screen,
with change detection and the rate limit.
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores today's
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
`true`) turns off step 3. Keep both for one release, then delete the old paths.
## Open questions
1. Keep the kill switch, or ship without one?
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
or wait longer?
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
live sports are redrawn once per 30 s poll regardless.
4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is
proposed as part of the segment strip (step 2), with fresh content
disabled under sync until it has been verified on real hardware.
+154
View File
@@ -0,0 +1,154 @@
# Permissions
Who owns what on an installed system, which privileged commands the web
interface may run, and how to repair ownership when it goes wrong. The
installer, [`first_time_install.sh`](../first_time_install.sh), sets all of
this up; this page describes the result.
## Users and groups
| Account | Used by | Why |
|---|---|---|
| `root` | `ledmatrix.service` (the display) | The LED matrix library needs direct GPIO access |
| The installing user (e.g. `ledpi`) | `ledmatrix-web.service`, `ledmatrix-update-verify.service` | A web server should not run as root |
| `ledmatrix` group | shared files | Members: the installing user, `root`, and `daemon` if it exists. Created by [`setup_cache.sh`](../scripts/install/setup_cache.sh) and the installer |
The installer also adds the web user to `systemd-journal` and `adm` so the
**Logs** tab can read the journal. Group changes apply after the user logs
in again (services pick them up on restart).
## Files and directories
| Path | Owner | Mode | Notes |
|---|---|---|---|
| Project directory | web user | dirs `755`, files `644`, `*.sh` `755` | Set in the installer's "Normalize project file permissions" step |
| `config/` | web user | `2775` | |
| `config/config.json` | web user | `644` | Written by the web interface |
| `config/config_secrets.json` | web user : `ledmatrix` | `640` | Owned by the web user because the web interface writes it; root reads it regardless of mode |
| `plugin-repos/`, `plugins/` | web user | dirs `2775`, files `664` | The web interface installs and removes plugins |
| `assets/` | web user | dirs `755`, files `644` | Root writes downloaded logos regardless |
| `/var/cache/ledmatrix/` | `root:ledmatrix` | `2775` (setgid) | Shared cache: see below |
| Cache files | creator : `ledmatrix` | `660` | |
| `scripts/fix_perms/safe_plugin_rm.sh`, `safe_pip_install.sh` | `root:root` | `755` | Run as root through sudo, so the web user must not be able to edit them |
| `/etc/sudoers.d/ledmatrix_web`, `ledmatrix_wifi` | `root` | `440` | |
What keeps it that way at runtime:
- **Config files.** Saves go through
[`src/config_manager_atomic.py`](../src/config_manager_atomic.py), which
applies `get_config_file_mode()` (`640` for secrets, `644` otherwise) and,
when running as root, moves the file's group to the project directory's
group (`ensure_shared_group_ownership()` in
[`src/common/permission_utils.py`](../src/common/permission_utils.py)).
- **Cache files.** [`src/cache/disk_cache.py`](../src/cache/disk_cache.py)
sets every file it writes to `0660` and gives it the cache directory's
group, without relying on the setgid bit. So a file root writes stays
readable by the web user.
- **Plugin directories.** [`run.py`](../run.py) sets
`sys.dont_write_bytecode`, because root-owned `__pycache__` directories
inside a plugin stop the web user updating or removing it.
`ledmatrix-web.service` deliberately has no `CacheDirectory=`: systemd would
re-own `/var/cache/ledmatrix` to the web user and its primary group, and the
web interface could no longer read what the display writes (see the comment
in [`systemd/ledmatrix-web.service`](../systemd/ledmatrix-web.service)).
If `/var/cache/ledmatrix` is not usable, `CacheManager` falls back to
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache` or a temp directory
([`src/cache_manager.py`](../src/cache_manager.py)). The two services then
may not share a cache, and the web UI shows stale or empty display status,
on-demand state and plugin health. Fix the directory rather than living
with the fallback.
## sudo rules
### `/etc/sudoers.d/ledmatrix_web`
Generated by `web_sudoers_rules()` in
[`scripts/install/lib_sudoers.sh`](../scripts/install/lib_sudoers.sh), the
only place these rules are defined. Installed by the installer and by
[`configure_web_sudo.sh`](../scripts/install/configure_web_sudo.sh), both of
which check them with `visudo -c` first. The web user may run, without a
password:
- `reboot`, `poweroff`
- `systemctl start|stop|restart|enable|disable|status ledmatrix.service`,
`systemctl is-active ledmatrix[.service]`
- `systemctl start|stop|restart ledmatrix-web.service`
- `bash <project>/scripts/fix_perms/safe_plugin_rm.sh *` — removes a
directory only if it resolves to a child of `plugin-repos/` or `plugins/`
- `bash <project>/scripts/fix_perms/safe_pip_install.sh *` — installs a
`requirements.txt` only if it is the project's own or one under
`plugin-repos/` or `plugins/`, so the root display service can import the
packages
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
escape from that pager would be a root shell
### `/etc/sudoers.d/ledmatrix_wifi`
Written by
[`scripts/install/configure_wifi_permissions.sh`](../scripts/install/configure_wifi_permissions.sh)
(run as the web user; the installer calls it). It refuses to grant a binary
that is not root-owned or is group/world-writable. The rules cover:
- `nmcli device wifi connect|disconnect *`, `nmcli device connect|disconnect *`,
`nmcli radio wifi on|off`
- `systemctl start|stop|restart hostapd`, `... dnsmasq`,
`systemctl restart NetworkManager`
- `sysctl -w net.ipv4.ip_forward=0|1`
- `nft add|delete table ip ledmatrix`
- `rfkill unblock wifi`
- `mkdir -p /etc/NetworkManager/dnsmasq-shared.d`
- `cp` of `/tmp/hostapd.conf` and `/tmp/dnsmasq.conf` to their fixed
destinations, and `rm -f /etc/dnsmasq.d/ledmatrix-captive.conf`
- `cp /tmp/ledmatrix-nm-dnsmasq.conf` to
`/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf`, and
`rm -f` of that file
**`iptables` is deliberately not granted.** The captive portal's rules are
built from the interface name and port, so a rule covering them would need a
trailing wildcard, and `iptables --modprobe=<path>` runs `<path>` as root: a
wildcard grant is a root shell for the web user. Doing it safely needs a
wrapper script that builds the rules itself, like `safe_plugin_rm.sh`. On a
stock Raspberry Pi OS image the default user's blanket `NOPASSWD` rule
(`/etc/sudoers.d/010_pi-nopasswd`) hides this gap.
### polkit
The same script installs `/etc/polkit-1/rules.d/10-ledmatrix-wifi.rules`,
which lets the web user perform any `org.freedesktop.NetworkManager.*`
action without authentication.
## Repair scripts
In [`scripts/fix_perms/`](../scripts/fix_perms/). Run them from the project
directory.
| Script | Run as | What it does | Notes |
|---|---|---|---|
| `fix_plugin_permissions.sh` | `sudo` | `plugins/` and `plugin-repos/` to `root:<user>`, dirs `2775`, files `664`; makes a `700` home directory `755` so root can traverse it | Safe. Group-writable, so the web user keeps write access |
| `fix_assets_permissions.sh` | `sudo` | `assets/` to `<user>:<group>`, mode `777` recursively | Works, but looser than the installer's `755`/`644` |
| `fix_cache_permissions.sh` | `sudo` | Runs [`setup_cache.sh`](../scripts/install/setup_cache.sh) for `/var/cache/ledmatrix` (`root:ledmatrix`, `2775`, files `660`), then makes `~/.ledmatrix_cache` (the fallback cache) `<user>:<group>` mode `777` | Safe. The `~/.ledmatrix_cache` mode is still `777` |
| `fix_web_permissions.sh` | the web user, **without** `sudo` | Resets project file ownership for the web user (it calls `sudo` itself), then makes `safe_plugin_rm.sh` and `safe_pip_install.sh` `root:root` `755` again and restores `config_secrets.json` to its owner, group `ledmatrix`, mode `640` | Refuses to run as root. It does not write sudoers rules |
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
To reinstall the sudoers rules, run
`./scripts/install/configure_web_sudo.sh` (web rules) or
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
the web user, not with `sudo`.
After any of these, restart both services:
```bash
sudo systemctl restart ledmatrix.service ledmatrix-web.service
```
## Checking
```bash
ls -ld /var/cache/ledmatrix # drwxrwsr-x root ledmatrix
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
id # web user should list ledmatrix
sudo -l # lists the NOPASSWD rules
```
+71 -78
View File
@@ -9,6 +9,7 @@ Complete API reference for plugin developers. This document describes all method
## Table of Contents ## Table of Contents
- [Manifest Required Fields](#manifest-required-fields)
- [BasePlugin](#baseplugin) - [BasePlugin](#baseplugin)
- [Display Manager](#display-manager) - [Display Manager](#display-manager)
- [Cache Manager](#cache-manager) - [Cache Manager](#cache-manager)
@@ -17,6 +18,50 @@ Complete API reference for plugin developers. This document describes all method
--- ---
## Manifest Required Fields
Three parts of core check `manifest.json`, each for a different set of
fields:
| Check | Fields | What happens when one is missing |
|---|---|---|
| JSON schema, [`schema/manifest_schema.json`](../schema/manifest_schema.json) | `id`, `name`, `version`, `author`, `entry_point`, `class_name`, `compatible_versions` | Install from URL logs a warning (`PluginStoreManager._validate_manifest_schema()`); nothing is refused |
| Plugin Store install, [`src/plugin_system/store_install.py`](../src/plugin_system/store_install.py) | `id`, `name`, `class_name`, `display_modes` | Install is refused. A registry install first tries to detect a missing `class_name` from the entry-point file |
| Plugin loader, [`src/plugin_system/plugin_loader.py`](../src/plugin_system/plugin_loader.py) | `class_name` | The plugin fails to load |
Defaults and other uses:
- `entry_point` defaults to `manager.py`; the store writes the default back
into the manifest on install.
- `compatible_versions` (a list of semver ranges such as `">=2.0.0"`) is how
the store decides whether a plugin can run on this core. An install is
refused only when the field excludes the running version
(`compatibility.check()` in
[`src/plugin_system/compatibility.py`](../src/plugin_system/compatibility.py)).
- `version` is compared with the registry's `latest_version` to decide
whether an update is available.
- If `display_modes` is empty at load time, the display controller uses the
plugin id as the only mode.
**Set all eight:** `id`, `name`, `version`, `author`, `entry_point`,
`class_name`, `display_modes`, `compatible_versions`. That satisfies every
check. The schema lists the optional fields.
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "YourName",
"entry_point": "manager.py",
"class_name": "MyPlugin",
"display_modes": ["my-plugin"],
"compatible_versions": [">=2.0.0"]
}
```
---
## BasePlugin ## BasePlugin
All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality. All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality.
@@ -432,82 +477,17 @@ self.display_manager.update_display()
This is the canonical way to render arbitrary images. This is the canonical way to render arbitrary images.
### Weather Icons ### Weather Icons (deprecated)
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None` > Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
Draw a weather icon based on the condition string. - `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
**Parameters**: - `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
- `condition` (str): Weather condition (e.g., "clear", "cloudy", "rain", "snow", "storm") `draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
- `x` (int): X position - `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
- `y` (int): Y position — text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
- `size` (int): Icon size in pixels (default: 16)
**Supported Conditions**:
- `"clear"`, `"sunny"` → Sun icon
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
- `"thunderstorm"`, `"storm"` → Storm icon
**Example**:
```python
self.display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
```
#### `draw_sun(x: int, y: int, size: int = 16) -> None`
Draw a sun icon with rays.
**Parameters**:
- `x` (int): X position
- `y` (int): Y position
- `size` (int): Icon size (default: 16)
#### `draw_cloud(x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)) -> None`
Draw a cloud icon.
**Parameters**:
- `x` (int): X position
- `y` (int): Y position
- `size` (int): Icon size (default: 16)
- `color` (tuple): RGB color (default: light gray)
#### `draw_rain(x: int, y: int, size: int = 16) -> None`
Draw rain icon with cloud and droplets.
#### `draw_snow(x: int, y: int, size: int = 16) -> None`
Draw snow icon with cloud and snowflakes.
#### `draw_text_with_icons(text: str, icons: List[tuple] = None, x: int = None, y: int = None, color: tuple = (255, 255, 255)) -> None`
Draw text with weather icons at specified positions.
**Parameters**:
- `text` (str): Text to display
- `icons` (List[tuple], optional): List of (icon_type, x, y) tuples
- `x` (int, optional): X position for text
- `y` (int, optional): Y position for text
- `color` (tuple): Text color
**Note**: Automatically calls `update_display()` after drawing.
**Example**:
```python
icons = [
("sun", 5, 5),
("cloud", 100, 5)
]
self.display_manager.draw_text_with_icons(
"Weather: Sunny, Cloudy",
icons=icons,
x=10, y=20
)
```
### Scrolling State Management ### Scrolling State Management
@@ -601,6 +581,8 @@ Process any deferred updates if not currently scrolling. Called automatically by
#### `get_scrolling_stats() -> dict` #### `get_scrolling_stats() -> dict`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get current scrolling statistics for debugging. Get current scrolling statistics for debugging.
**Returns**: Dictionary with scrolling state information **Returns**: Dictionary with scrolling state information
@@ -742,6 +724,8 @@ data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]` #### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
> Deprecated, removed in 3.7.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
Get background service cached data with sport-specific intervals. Get background service cached data with sport-specific intervals.
**Parameters**: **Parameters**:
@@ -779,6 +763,8 @@ max_age = strategy['max_age'] # Get configured max age
#### `get_sport_live_interval(sport_key: str) -> int` #### `get_sport_live_interval(sport_key: str) -> int`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get the live_update_interval for a specific sport from config. Get the live_update_interval for a specific sport from config.
**Parameters**: **Parameters**:
@@ -803,6 +789,8 @@ Extract data type from cache key to determine appropriate cache strategy.
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]` #### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Extract sport key from cache key for sport-specific strategies. Extract sport key from cache key for sport-specific strategies.
**Parameters**: **Parameters**:
@@ -847,10 +835,12 @@ for file_info in files:
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}") self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
``` ```
### Metrics Methods ### Metrics Methods (deprecated)
#### `get_cache_metrics() -> Dict[str, Any]` #### `get_cache_metrics() -> Dict[str, Any]`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get cache performance metrics. Get cache performance metrics.
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.) **Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
@@ -863,6 +853,8 @@ self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
#### `get_memory_cache_stats() -> Dict[str, Any]` #### `get_memory_cache_stats() -> Dict[str, Any]`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get memory cache statistics. Get memory cache statistics.
**Returns**: Dictionary with memory cache stats (size, max_size, etc.) **Returns**: Dictionary with memory cache stats (size, max_size, etc.)
@@ -907,6 +899,8 @@ for plugin_id, plugin in all_plugins.items():
#### `get_enabled_plugins() -> List[str]` #### `get_enabled_plugins() -> List[str]`
> Deprecated, removed in 3.7.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
Get list of enabled plugin IDs. Get list of enabled plugin IDs.
**Returns**: List of plugin identifier strings **Returns**: List of plugin identifier strings
@@ -985,9 +979,8 @@ def update(self):
**Example - Checking if another plugin is enabled**: **Example - Checking if another plugin is enabled**:
```python ```python
enabled_plugins = self.plugin_manager.get_enabled_plugins() weather = self.plugin_manager.plugins.get("weather")
if "weather" in enabled_plugins: if weather is not None and weather.enabled:
# Weather plugin is enabled
pass pass
``` ```
+9 -1
View File
@@ -124,6 +124,14 @@ Plugins are configured by adding their plugin ID as a top-level key in the confi
} }
``` ```
How often the core calls a plugin's `update()`: the plugin's
`get_update_interval()` if it returns a number, else `update_interval` in the
plugin's `manifest.json`, else `update_interval` in its `config.json` section
as above, else 60 seconds. A config `update_interval` therefore only sets the
scheduler's cadence for a plugin whose manifest does not; plugins that expose
it in their config schema typically also honour it themselves inside
`update()`. See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#get_update_interval---optionalfloat).
### Plugin Display Durations ### Plugin Display Durations
Add plugin display modes to the `display_durations` section: Add plugin display modes to the `display_durations` section:
@@ -194,7 +202,7 @@ plugin-repos/
``` ```
The Plugin Store refuses a manifest that lacks any of `id`, `name`, The Plugin Store refuses a manifest that lacks any of `id`, `name`,
`class_name` or `display_modes` (`store_manager.py`); the loader itself `class_name` or `display_modes` (`store_install.py`); the loader itself
needs `class_name`. `version` is not required, but the store compares it needs `class_name`. `version` is not required, but the store compares it
with the registry's `latest_version` to offer updates, so set it. with the registry's `latest_version` to offer updates, so set it.
`entry_point` defaults to `manager.py` if omitted. The config schema is not `entry_point` defaults to `manager.py` if omitted. The config schema is not
+1 -1
View File
@@ -203,7 +203,7 @@ Forms are rendered on the server, not generated in the browser:
from the schema (widgets named by `x-widget` are rendered by the scripts in from the schema (widgets named by `x-widget` are rendered by the scripts in
`web_interface/static/v3/js/widgets/`) `web_interface/static/v3/js/widgets/`)
4. **Save Configuration** posts the form to `/api/v3/plugins/config` 4. **Save Configuration** posts the form to `/api/v3/plugins/config`
(`web_interface/blueprints/api_v3/plugins.py`), which validates it against (`web_interface/blueprints/api_v3/plugin_config.py`), which validates it against
the schema, writes `config.json` (secret fields go to the schema, writes `config.json` (secret fields go to
`config_secrets.json`) and shows a notification `config_secrets.json`) and shows a notification
+6 -6
View File
@@ -32,7 +32,7 @@
│ • masks x-secret fields │ │ • masks x-secret fields │
│ • renders partials/plugin_config.html (render_field macros) │ │ • renders partials/plugin_config.html (render_field macros) │
│ │ │ │
│ api_v3 blueprint (blueprints/api_v3/plugins.py) │ │ api_v3 blueprint (blueprints/api_v3/plugin_config.py) │
│ save_plugin_config() POST /api/v3/plugins/config │ │ save_plugin_config() POST /api/v3/plugins/config │
│ get_plugin_config() GET /api/v3/plugins/config │ │ get_plugin_config() GET /api/v3/plugins/config │
│ get_plugin_schema() GET /api/v3/plugins/schema │ │ get_plugin_schema() GET /api/v3/plugins/schema │
@@ -91,7 +91,7 @@ validatePluginConfigForm() (client-side checks)
POST /api/v3/plugins/config?plugin_id=<id> (form data, all fields of the form) POST /api/v3/plugins/config?plugin_id=<id> (form data, all fields of the form)
│ │
▼ ▼
save_plugin_config() (api_v3/plugins.py) save_plugin_config() (api_v3/plugin_config.py)
├─→ Start from the stored config.json[<id>] ├─→ Start from the stored config.json[<id>]
├─→ Apply form fields: dotted names → nested keys, "[]" checkbox ├─→ Apply form fields: dotted names → nested keys, "[]" checkbox
│ groups → lists, values coerced to the schema's types │ groups → lists, values coerced to the schema's types
@@ -155,9 +155,9 @@ deep-merged back into the plugin's config at load time
### Custom input widgets ### Custom input widgets
Set `"x-widget": "<name>"` on a property. Core widgets are in Set `"x-widget": "<name>"` on a property. Core widgets are in
`web_interface/static/v3/js/widgets/` (see its README); a plugin can ship its `web_interface/static/v3/js/widgets/`; a plugin can ship its own widget
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`. script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`. See the
See [widget-guide.md](widget-guide.md). [widget guide](../web_interface/static/v3/js/widgets/README.md).
### Custom actions ### Custom actions
@@ -175,7 +175,7 @@ Implement `on_config_change(new_config)` in the plugin (see
|---------|------| |---------|------|
| Tab partial loader | `web_interface/blueprints/pages_v3.py` (`_load_plugin_config_partial`) | | Tab partial loader | `web_interface/blueprints/pages_v3.py` (`_load_plugin_config_partial`) |
| Form template and field macros | `web_interface/templates/v3/partials/plugin_config.html` | | Form template and field macros | `web_interface/templates/v3/partials/plugin_config.html` |
| Save / get / schema / reset handlers | `web_interface/blueprints/api_v3/plugins.py` | | Save / get / schema / reset handlers | `web_interface/blueprints/api_v3/plugin_config.py` |
| Schema loading, defaults, validation | `src/plugin_system/schema_manager.py` | | Schema loading, defaults, validation | `src/plugin_system/schema_manager.py` |
| Secret masking and splitting | `src/web_interface/secret_helpers.py` | | Secret masking and splitting | `src/web_interface/secret_helpers.py` |
| Widgets | `web_interface/static/v3/js/widgets/` | | Widgets | `web_interface/static/v3/js/widgets/` |
+5 -7
View File
@@ -5,11 +5,9 @@
A plugin can name an icon for its tab in the web interface's second nav row A plugin can name an icon for its tab in the web interface's second nav row
(next to **Plugin Manager**) with the `icon` field in `manifest.json`. (next to **Plugin Manager**) with the `icon` field in `manifest.json`.
> **Status:** the tab code honors `icon`, but `GET /api/v3/plugins/installed` `GET /api/v3/plugins/installed` passes the manifest's `icon` through (a
> (`web_interface/blueprints/api_v3/plugins.py`) does not currently include non-string value comes back as `null`), and a plugin without one gets the
> the manifest's `icon` in its response, so every tab shows the default default puzzle piece.
> puzzle piece. Setting `icon` is harmless and will take effect once the API
> passes it through again.
## Font Awesome classes only ## Font Awesome classes only
@@ -55,8 +53,8 @@ With no `icon` (or an empty one) the tab shows `fas fa-puzzle-piece`.
or misspelled class renders as a blank space. or misspelled class renders as a blank space.
2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon 2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon
class. class.
3. See the status note above: the icon is currently not passed through by 3. The manifest is re-read on each plugin list load; reload the page after
the API. editing `icon`.
## Related Documentation ## Related Documentation
+4 -3
View File
@@ -25,7 +25,7 @@ which runs as root.** Anything installed only into another user's
The web interface is not root, so it installs through a narrow sudo helper: The web interface is not root, so it installs through a narrow sudo helper:
1. `PluginStoreManager._install_dependencies()` 1. `PluginStoreManager._install_dependencies()`
(`src/plugin_system/store_manager.py`) calls (`src/plugin_system/store_install.py`) calls
`install_requirements_file()` (`src/common/permission_utils.py`). `install_requirements_file()` (`src/common/permission_utils.py`).
2. That runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <plugin>/requirements.txt`. 2. That runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <plugin>/requirements.txt`.
The helper checks the path is the project's own `requirements.txt` or a The helper checks the path is the project's own `requirements.txt` or a
@@ -154,8 +154,9 @@ For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TR
## Files to Reference ## Files to Reference
- Service units: `systemd/ledmatrix.service`, `systemd/ledmatrix-web.service` - Service units: `systemd/ledmatrix.service`, `systemd/ledmatrix-web.service`
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`) - Store installs: `src/plugin_system/store_install.py` (`_install_dependencies`)
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh` - Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`) - Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
- Sudo rules: `scripts/install/configure_web_sudo.sh` - Sudo rules: `scripts/install/lib_sudoers.sh` (written by `first_time_install.sh`
and `scripts/install/configure_web_sudo.sh`)
- Manual installer: `scripts/install_plugin_dependencies.sh` - Manual installer: `scripts/install_plugin_dependencies.sh`
+10 -5
View File
@@ -520,19 +520,22 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
`display_manager.image` (a PIL Image) and call `update_display()`; `display_manager.image` (a PIL Image) and call `update_display()`;
there is no `draw_image()` helper method. there is no `draw_image()` helper method.
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons - `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
(deprecated, removed in 3.7.0 — draw your own icons)
- `get_text_width()`, `get_font_height()` - Text utilities - `get_text_width()`, `get_font_height()` - Text utilities
- `set_scrolling_state()`, `defer_update()` - Scrolling state management - `set_scrolling_state()`, `defer_update()` - Scrolling state management
**Cache Manager** (`self.cache_manager`): **Cache Manager** (`self.cache_manager`):
- `get()`, `set()`, `delete()` - Basic caching - `get()`, `set()`, `delete()` - Basic caching
- `get_cached_data_with_strategy()` - Advanced caching with strategies - `get_cached_data_with_strategy()` - Advanced caching with strategies
- `get_background_cached_data()` - Background service caching - `get_background_cached_data()` - deprecated, removed in 3.7.0 — use `get()`
**Plugin Manager** (`self.plugin_manager`): **Plugin Manager** (`self.plugin_manager`):
- `get_plugin()`, `get_all_plugins()` - Access other plugins - `get_plugin()`, `get_all_plugins()` - Access other plugins
- `get_plugin_info()` - Get plugin information - `get_plugin_info()` - Get plugin information
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete documentation. See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete
documentation, and its [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis)
table for everything removed in 3.7.0.
## 3rd Party Plugin Development ## 3rd Party Plugin Development
@@ -577,12 +580,14 @@ Your plugin must:
pass pass
``` ```
2. **Include manifest.json** with required fields: 2. **Include manifest.json** with the required fields listed in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields):
```json ```json
{ {
"id": "my-plugin", "id": "my-plugin",
"name": "My Plugin", "name": "My Plugin",
"version": "1.0.0", "version": "1.0.0",
"author": "YourName",
"class_name": "MyPlugin", "class_name": "MyPlugin",
"entry_point": "manager.py", "entry_point": "manager.py",
"display_modes": ["my_plugin"], "display_modes": ["my_plugin"],
@@ -640,7 +645,7 @@ To have your plugin added to the official plugin store:
3. **Contact maintainers** (own-repository plugins): 3. **Contact maintainers** (own-repository plugins):
- Open a GitHub issue in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository - Open a GitHub issue in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository
- Or reach out on Discord: https://discord.gg/uW36dVAtcT - Or reach out on Discord: https://discord.gg/RdrC37rEag
- Include: Repository URL, plugin description, why it's useful - Include: Repository URL, plugin description, why it's useful
4. **Review process**: 4. **Review process**:
@@ -658,7 +663,7 @@ For your plugin to work well in the plugin store:
with the registry's `latest_version`; releases and tags are not read with the registry's `latest_version`; releases and tags are not read
- **README.md**: Clear installation and configuration instructions - **README.md**: Clear installation and configuration instructions
- **config_schema.json**: Recommended for web UI configuration - **config_schema.json**: Recommended for web UI configuration
- **manifest.json**: Required with all required fields - **manifest.json**: Required, with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
- **requirements.txt**: If your plugin has Python dependencies - **requirements.txt**: If your plugin has Python dependencies
### Distribution Options ### Distribution Options
+4 -1
View File
@@ -45,7 +45,8 @@ LEDMatrix/
### 1. Minimal Plugin Structure ### 1. Minimal Plugin Structure
**manifest.json**: **manifest.json** (the required fields are explained in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields)):
```json ```json
{ {
"id": "my-plugin", "id": "my-plugin",
@@ -54,6 +55,8 @@ LEDMatrix/
"author": "YourName", "author": "YourName",
"entry_point": "manager.py", "entry_point": "manager.py",
"class_name": "MyPlugin", "class_name": "MyPlugin",
"display_modes": ["my-plugin"],
"compatible_versions": [">=2.0.0"],
"category": "custom" "category": "custom"
} }
``` ```
+3 -3
View File
@@ -67,9 +67,9 @@ Don't edit `latest_version` or `last_updated` by hand for monorepo plugins:
## Adding or changing an official plugin ## Adding or changing an official plugin
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo. The store refuses 1. Add or edit `plugins/<your-plugin-id>/` in the monorepo, with the
a manifest without `id`, `name`, `class_name` and `display_modes`; also manifest fields listed in
set `version`. [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields).
2. Bump `version` in the plugin's `manifest.json` for every change, or users 2. Bump `version` in the plugin's `manifest.json` for every change, or users
won't be offered the update. won't be offered the update.
3. Run `python update_registry.py` in ledmatrix-plugins and commit the 3. Run `python update_registry.py` in ledmatrix-plugins and commit the
+9 -3
View File
@@ -11,7 +11,7 @@ the one-shot installer. The pages here go deeper.
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) — using the web UI 2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) — using the web UI
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) — installing and managing plugins 3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) — installing and managing plugins
4. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) — WiFi and AP-mode setup 4. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) — WiFi and AP-mode setup
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes 5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes ([PERMISSIONS.md](PERMISSIONS.md) for "Permission denied")
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install 6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems 7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits 8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
@@ -37,7 +37,7 @@ Going deeper:
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) - [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json)) - [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps - [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
- [widget-guide.md](widget-guide.md) — widget development - [Widget guide](../web_interface/static/v3/js/widgets/README.md) — built-in `x-widget`s and custom widgets
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling) - [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size - [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
@@ -56,21 +56,27 @@ Going deeper:
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display, - [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
cache management, background services, permissions cache management, background services, permissions
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system - [FONT_MANAGER.md](FONT_MANAGER.md) — font system
- [SCROLL_PERFORMANCE.md](SCROLL_PERFORMANCE.md) — how scrolling is paced, and how to make a plugin's marquee smooth
- [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) — rendering plugin content off the render thread
- [PERMISSIONS.md](PERMISSIONS.md) — file ownership, sudo rules, repair scripts
- [MQTT bridge](../integrations/mqtt_bridge/README.md) — control the display from Home Assistant over MQTT
## Reference ## Reference
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json - [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints - [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins - [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
- [src/common/README.md](../src/common/README.md) — shared helper modules plugins can import
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks - [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
## Contributing to LEDMatrix itself ## Contributing to LEDMatrix itself
- [ARCHITECTURE.md](ARCHITECTURE.md) — processes, display loop, plugin system, web UI; where to start reading
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup - [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite - [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace - [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases - [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how the sports scoreboard base classes are organized - [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how shared sports scoreboard code moves into `src/common`
## Audits ## Audits
+19 -7
View File
@@ -40,13 +40,14 @@ the entry below says so.
> The API blueprint is the `api_v3` package in > The API blueprint is the `api_v3` package in
> `web_interface/blueprints/api_v3/` (one module per area: `config.py`, > `web_interface/blueprints/api_v3/` (one module per area: `config.py`,
> `display.py`, `plugins.py`, `system.py`, `backup.py`, `fonts.py`, > `display.py`, `system.py`, `backup.py`, `fonts.py`, `misc.py`, `wifi.py`,
> `misc.py`, `wifi.py`, `starlark.py`). `web_interface/app.py` registers it > `starlark.py`, and `plugins.py` plus the `plugin_*.py` modules for the
> plugin routes). `web_interface/app.py` registers it
> at `/api/v3` (`app.register_blueprint(api_v3, url_prefix='/api/v3')`). > at `/api/v3` (`app.register_blueprint(api_v3, url_prefix='/api/v3')`).
> The three SSE endpoints (`/api/v3/stream/*`) are defined directly on the > The three SSE endpoints (`/api/v3/stream/*`) are defined directly on the
> Flask app in `app.py` (`stream_stats`, `stream_display`, `stream_logs`). > Flask app in `app.py` (`stream_stats`, `stream_display`, `stream_logs`).
> `test/fixtures/api_v3_url_map.json` is the canonical list of blueprint > `test/fixtures/api_v3_url_map.json` is the canonical list of blueprint
> routes (116 URL rules); a test fails if the code and that fixture differ. > routes; a test fails if the code and that fixture differ.
--- ---
@@ -389,7 +390,7 @@ Request a specific plugin to display on-demand.
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided) - `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
- `duration` (number, optional): Duration in seconds (0 = until stopped) - `duration` (number, optional): Duration in seconds (0 = until stopped)
- `pinned` (boolean, optional): Pin display (pause rotation) - `pinned` (boolean, optional): Pin display (pause rotation)
- `start_service` (boolean, optional): (Re)start the display service so it picks the request up (default: true) - `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
**Response**: **Response**:
```json ```json
@@ -869,7 +870,13 @@ Get a plugin's resource limits. `data` is `null` when none are configured.
**POST** `/api/v3/plugins/limits/<plugin_id>` **POST** `/api/v3/plugins/limits/<plugin_id>`
Set a plugin's resource limits. The body replaces all four limits: a key you Set a plugin's resource limits. The body replaces all four limits: a key you
omit is stored as no limit (`warning_threshold` defaults to `0.8`). omit is stored as no limit (`warning_threshold` defaults to `0.8`). Each value
must be a non-negative number or `null`; anything else is a 400.
The limits are stored in the shared cache. A display service that has already
read limits for the plugin keeps using those until it restarts; likewise the
health and metrics reset routes clear the stored record and the web process's
copy, not the display service's in-memory state.
**Request Body**: **Request Body**:
```json ```json
@@ -2111,13 +2118,18 @@ Errors use one of two shapes. Most endpoints answer:
} }
``` ```
Endpoints built on the structured error helper add a code and category: An exception no route anticipated gets this shape too, with a 500, the
message `An error occurred; see logs for details`, and `details` naming the
exception type and text (credentials redacted). The api_v3 blueprint's
error handler produces it, so it is the same for every `/api/v3` route.
Endpoints built on the structured error helper add a code, and usually
suggested fixes (the web UI's error dialog lists them):
```json ```json
{ {
"status": "error", "status": "error",
"error_code": "CONFIG_SAVE_FAILED", "error_code": "CONFIG_SAVE_FAILED",
"error_category": "configuration",
"message": "Error description", "message": "Error description",
"details": "optional", "details": "optional",
"context": { }, "context": { },
+206 -7
View File
@@ -207,7 +207,10 @@ Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
### 3. Sub-pixel blending was wrong for this display ### 3. Sub-pixel blending was wrong for this display
Enabling it made things worse, not better — see the rule at the top. It is off Enabling it made things worse, not better — see the rule at the top. It is off
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`. by default everywhere. Vegas mode used to opt in; it now scrolls in whole
pixels locked to the refresh like the plugin tickers, and keeps the blend only
behind `display.vegas_scroll.sub_pixel_blend` (default `false`). The blend is
also why text looked anti-aliased in the web preview while the panel shimmered.
### 4. Frame-based stepping raced the vsync clock ### 4. Frame-based stepping raced the vsync clock
@@ -231,6 +234,9 @@ advances by elapsed time at `scroll_speed / scroll_delay` px/s.
## Diagnosing a juddery scroller ## Diagnosing a juddery scroller
To check a whole rig rather than one scroller, soak it -- see *Soaking a rig*
below.
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait **An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
healthy 100 fps. The stats line reports the tail for that reason — read the healthy 100 fps. The stats line reports the tail for that reason — read the
@@ -303,6 +309,170 @@ journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
If a plugin logs its scroll config **twice** with different modes, the second If a plugin logs its scroll config **twice** with different modes, the second
line is what is running. line is what is running.
## Soaking a rig
The per-scroller lines above tell you *which* scroller misbehaves. The soak
answers the question a release has to answer for each rig: **over a long run,
how often did a moving frame reach the panel late?**
Every frame reaches the panel through `DisplayManager.update_display`, so it is
timed there once, whoever drew it -- Vegas, a ticker plugin, anything. The
render thread only appends a tuple; a worker thread aggregates and rewrites
`/dev/shm/ledmatrix_frame_stats.json` every 10 seconds (RAM, so no SD-card
wear). `src/common/frame_timing.py` has the details.
```bash
python3 scripts/frame_soak.py # 10 minutes, as the display is now
python3 scripts/frame_soak.py --preview # with the web preview open
python3 scripts/frame_soak.py --show # totals since the service started
python3 scripts/frame_soak.py --json a.json # keep the report to compare later
```
It runs as any user next to the display service and stops nothing. It needs
something to *scroll* during the run: a live game holding a static scoreboard
on screen gives no verdict. `--preview` keeps the web preview's viewer marker
fresh, which puts the preview's PNG encoding at full rate -- run it as the web
service's user.
| line | what it tells you |
|---|---|
| **Late frames** | Frames presented one or more refreshes after they were due: the panel showed the previous frame again, a visible hitch. **The pass/fail number**, 0.1% by default (`--max-late-pct`). Only intervals between two scrolling frames count, and a frame held for `frame_hold` refreshes is due `frame_hold` refreshes after the last. |
| **Freezes** | Gaps of 250 ms or more inside a scroll: recomposes, plugin handovers, blocking calls on the render thread. Reported but not failed on, because some are handovers between plugins rather than faults. A gap still counts when the display's scroll state went missing for one frame across it, as long as scrolling resumes within 1 s: both of that frame's intervals count. Two static frames in a row end the scroll. (The state expires after 2 s without scroll activity, and plugins can clear it from their own `display()`.) The late and early rates are over frames judged against a known refresh period, which the recorder adopts once two windows in a row agree on it. |
| **blit** | Copying the frame into the matrix canvas (`SetImage`). It grows with width × height × `pwm_bits`: ~5.5 ms at 512×64 with 8 bits on a Pi 4. It is the biggest fixed cost, and it sets the refresh rates a rig can hold one pixel per refresh at. |
| **wait** | Time blocked in `SwapOnVSync`, i.e. the slack left in each refresh. A p50 near zero means the rig has no headroom and anything extra lands a frame late. |
| **work** | Everything else between two frames: drawing, scrolling, and waiting for the GIL. A wide gap between its p50 and p99 is another thread getting in the way. |
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
The refresh rate is estimated from the frames themselves (swaps that block on
vsync can only land on refresh boundaries). Cross-check it with
`scroll_speeds.py --measure` if it looks wrong. It can read high on a rig where
nothing ever presented at the full refresh rate.
A soak is only meaningful against a fixed workload. Compare runs with the same
content and `--preview` setting, and alternate which build goes first when you
A/B two of them. A live-API workload drifts over time.
The soak says how often; the service's log says why. A scroll that presents no
frame for 250 ms logs `Render stall:` with the stack of the render thread and
the top of every other thread's, and whether the whole interpreter was blocked
(C code holding the GIL) rather than one thread. To see what is behind the
shorter hitches, run the service with `LEDMATRIX_STALL_WATCHDOG_MS=30`, which
dumps at three refreshes late instead: its extra polling costs a little GIL
time of its own, so do that on a diagnostic run, not a soak you are grading.
`LEDMATRIX_STALL_WATCHDOG=0` turns it off.
### Results: hdpi, 2026-09-24
Pi 4, 4×128×64 on one chain (512×64), `gpio_slowdown` 3, cap 120 Hz, the
GIL-releasing binding. Vegas mode with live content, 8-minute soaks with
`--preview`, run in the order shown so each build went both first and last.
| run | build | pacing | pwm_bits | refresh | late | 1 | 2 | 3–5 | 6+ | freezes |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.33% | 2,542 | 74 | 19 | 4 | 0 |
| 2 | #628 | 1 px / refresh | 8 | 100.2 Hz | 0.66% | 238 | 32 | 30 | 5 | 2 |
| 3 | #628 | 1 px / refresh | 8 | 100.3 Hz | 0.70% | 252 | 38 | 26 | 6 | 2 |
| 4 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.46% | 2,659 | 90 | 10 | 4 | 0 |
| 5 | #628 | 1 px / 2 refreshes (53 px/s) | **7** | 107.2 Hz | 0.32% | 68 | 7 | 4 | 2 | 1 |
- Blending cost the panel refresh rate as well as frames: 94.5 Hz against
~100 Hz for the same hardware under whole-pixel pacing.
- The freezes and the 3+ rows in the #628 runs line up with canvas-bound
plugins fetched on the render thread (`drain_deferred`): `news` took ~320 ms
and `hockey-scoreboard` ~660 ms there. Moving those
fetches off the render thread is proposed separately (offscreen rendering).
- Run 5 changed two things at once: the speed, and `pwm_bits` (changed on the
rig between runs). Its lower late rate cannot be credited to either alone.
- These soaks were taken before the recorder counted 1–2 s stalls as freezes,
so a stall of that length would be missing from these rows.
### Without the service: `render_bench.py`
The soak measures the service as it really runs: live content, plugin
updates, the web preview. `scripts/render_bench.py` answers the narrower
question underneath: *with nothing else in the way, can this hardware present
every frame on time?* It scrolls a synthetic strip through the production path
-- a real `DisplayManager`, a real `ScrollHelper`, the same `scroll_config`
resolver every ticker uses -- on content that is identical every run, which
makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT against
another) and for A/B testing a change to the render path.
```bash
sudo systemctl stop ledmatrix # the service owns the GPIO
sudo python3 scripts/render_bench.py # 60s at one pixel per refresh
sudo python3 scripts/render_bench.py --seconds 600 # the shipping gate
sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) speed
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
sudo systemctl start ledmatrix
```
It never starts or stops the service itself, so a crash in it cannot leave
the panel dark. It grades with the same recorder as the soak and prints the
same report, with the same exit status, except that **2** also means the run
could not be set up at all (no root, no panel, a fallback display), so a rig
that was never measured cannot pass by accident.
Two differences from the soak matter:
- **It measures the panel first.** Before scrolling it times bare swaps for a
few seconds to get the idle refresh rate, and seeds the recorder with it.
That is what catches a loop that never locked to the panel at all. The first
version of the bench announced its scrolling state once instead of every
frame; the state expired, the dirty-tracking skip fired mid-scroll, and the
loop free-ran at 827 fps. Graded against its own frames that looks perfectly
steady; graded against the panel's measured rate every frame is early, and
the run fails as NOT LOCKED. (The soak has no idle measurement, so it checks
the rate against `limit_refresh_rate_hz` instead: a "refresh" faster than
the cap cannot have been waiting for the panel.)
- **The stall watchdog prints to the terminal.** A frame held up for more than
250 ms prints the stack of what held it up, in the middle of the run.
Measured with the first version of the bench on hdpi (Pi 4, 512x64,
`pwm_bits` 8), two-minute runs at one pixel per refresh: 8 of 11,449 frames
late (0.070%), and with `--busy 2` 3 of 11,445 (0.026%). The render path and
the hardware pass on their own. Compare the soak results above, from the same
rig with the service running, for how much of the late rate comes from
everything else.
### The panel is slower while you are rendering into it
The bench prints two refresh rates, and they differ:
| | Pi 4, 512x64, `pwm_bits` 8 |
|---|---|
| idle, timing bare swaps | 100.4 Hz |
| while scrolling | 96.3 Hz |
Both are real. Driving an LED matrix is bit-banging on the same machine, so
`SetImage` over a 512x64 chain contends with the refresh itself and slows it.
The recorder therefore reads the rendering rate back from the frames: swaps
that block on vsync can only return on a refresh boundary, so the low end of
`interval / frame_hold` is the period. The idle figure is still printed,
because the gap between the two is itself a measure of how expensive a frame
is: **a rise in that gap is a render-cost regression even when nothing is
late.**
The practical consequence for config: set `limit_refresh_rate_hz` near the rate
the panel holds *while rendering*, not the idle rate and certainly not a cap it
can never reach. A cap well above the real rate makes `scroll_config` solve
speeds against a refresh that does not exist, which is where "3px every 4
refreshes" comes from.
### Bench-only counters
| line | meaning |
|---|---|
| `duplicate` | frames that advanced no pixels. A crisp fixed-step scroll should show none; any at all means the loop is presenting faster than the strip is moving. |
| `blank` | frames with no visible slice to draw: the helper had no content. Should be zero. |
| `restarts` | how many times the strip was scrolled through end to end. Informational: the bench restarts the strip where a plugin would hand over to the next one. |
`--json` writes the full report plus the panel geometry, the solved speed and
these counters, so two rigs (or one rig before and after a change) can be
compared without re-reading a terminal.
--- ---
## A tear across the middle on fast scrolls ## A tear across the middle on fast scrolls
@@ -325,10 +495,9 @@ of roughly
offset ≈ scroll speed × refresh period offset ≈ scroll speed × refresh period
``` ```
Each frame already reaches the panel whole (`SwapOnVSync` swaps complete frames Each frame reaches the panel whole (`SwapOnVSync` swaps complete frames between
between refreshes), so there is nothing to fix in the render path; the shift is refreshes); the shift is created inside a single refresh. Other panel heights
created inside a single refresh. Other panel heights show it too, at the point show it too, at the point where their two scan halves meet.
where their two scan halves meet.
On the 2×128×64 chain above, which refreshes at about 130 Hz flat out On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
(7.7 ms per pass): (7.7 ms per pass):
@@ -339,7 +508,37 @@ On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
| 100 px/s | ~0.8 px | | 100 px/s | ~0.8 px |
| 150 px/s | ~1.2 px, plainly visible | | 150 px/s | ~1.2 px, plainly visible |
### What changes it ### What the display does about it
At one pixel per refresh, the fastest crisp speed, the step is exactly one
refresh's worth of motion, so it can be cancelled: show one half of the panel
a refresh behind the other -- the half whose row at the seam lights at the
start of each refresh. The two rows either side of the seam then show the same
moment again. What is left is a
lean of one pixel per half from top to bottom, continuous across the panel,
which reads as nothing where the step read as a tear. `DisplayManager` does
this while something scrolls at one frame per refresh
(`display.scan_order_compensation`, `"auto"` by default, `"off"` to disable;
the geometry is in `src/scan_order.py`). The lagging rows come from the
previous frame the display presented, so it works for Vegas and every plugin
ticker without knowing how they scroll.
Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was
written: `scan_mode: 1` (interlaced) made the step vanish but turned moving
edges grainy, and halving the speed halved it, so it is the scan and not a torn
frame. With the compensation the step is gone at 90 px/s.
It is left off where the row order is unknown or the maths does not hold:
- **Slower speeds**, where each frame is held for two or more refreshes. The
offset there is half a pixel or less, and cancelling it would need a lag of
a fraction of a frame.
- **Other layouts:** pixel mappers other than a 0 or 180 degree rotation
(U-mapper, 90/270), non-zero `multiplexing`, interlaced `scan_mode`, and a
canvas remapped to another height (double-sided mode).
- **The emulator,** which has no scan order.
### When it cannot apply
Only a shorter scan period (a faster refresh) or a slower scroll. Measure what Only a shorter scan period (a faster refresh) or a slower scroll. Measure what
the panel actually achieves first. The library prints the rate with a carriage the panel actually achieves first. The library prints the rate with a carriage
@@ -370,7 +569,7 @@ on its own output and set `parallel` to the number of outputs used and
should roughly double the refresh rate and halve the offset. That is a cable should roughly double the refresh rate and halve the offset. That is a cable
change, so measure again afterwards. change, so measure again afterwards.
Short of rewiring, keep fast scrolls moderate: at the default 50 px/s the Short of rewiring, keep fast scrolls moderate on those layouts: at 50 px/s the
offset is under half a pixel. offset is under half a pixel.
## Rebuilding the binding ## Rebuilding the binding
+25 -9
View File
@@ -73,14 +73,21 @@ B2 below promoted code into it (`SportsCore`, the mode classes,
capabilities sections record that design, but none of it ships in core any capabilities sections record that design, but none of it ships in core any
more. Shared sports code lives in `src/common`: more. Shared sports code lives in `src/common`:
``` | Module | Since | Holds |
src/common/ |---|---|---|
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration | `sports_scroll.py` | 3.2.0 | `SportsScrollDisplay` / `SportsScrollDisplayManager` — scroll orchestration (content building stays in the plugins) |
(content building stays in the plugins) | `sports_card.py` | 3.3.0 | Free functions for card settings, colours, favourite-team rules, dates and font sizes |
sports_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin | `sports_game_renderer.py` | 3.3.0 | `SportsGameRendererMixin` — scroll/Vegas card geometry |
(3.5.0) — the helpers byte-identical in the | `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
plugins' sports.py, and the _favorite_key seam | `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
``` | `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
| `favorite_team_check.py` | 3.6.0 | `FavoriteTeamCheck` — logs why a favourite team code shows nothing |
| `sports_timezone.py` | 3.6.0 | Which timezone start times are drawn in (`resolve_timezone_name`) |
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
Each is described in [src/common/README.md](../src/common/README.md).
### Converging on `src/common` ### Converging on `src/common`
@@ -90,7 +97,7 @@ modules taken from the plugin copies, each a **new module** rather than growth
on an existing one: a plugin that deletes a method copy and relies on an older on an existing one: a plugin that deletes a method copy and relies on an older
module having gained it fails at runtime with an `AttributeError`, while a module having gained it fails at runtime with an `AttributeError`, while a
missing module fails at load, where the version checks can see it. missing module fails at load, where the version checks can see it.
`sports_helpers.py` is the first (it holds `_favorite_key`, the override point `sports_helpers.py` is the newest (it holds `_favorite_key`, the override point
listed below, for later phases); its parity test compares every body against listed below, for later phases); its parity test compares every body against
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
`test/test_common_is_hardware_free.py` keeps `src/common` free of `test/test_common_is_hardware_free.py` keeps `src/common` free of
@@ -164,6 +171,15 @@ Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
SportsLive)` — so the celebration `display()` runs first and falls through to SportsLive)` — so the celebration `display()` runs first and falls through to
the scorebug via `super()`. the scorebug via `super()`.
What shipped is narrower. `src/common/sports_celebration.py`
(`SportsCelebrationMixin`) holds only the drawing, which is identical in the
five scoreboards that celebrate (afl, football, hockey, nrl, soccer — hockey
grew celebrations after this was written). Arming a celebration stays in each
plugin: the trigger bodies differ (nrl matches favourites by team id, football
folds a touchdown's extra point into one celebration and picks scenery by
points), and so does `display()`. The seams above were not needed to move the
drawing, so none was added.
**Rotation strategies.** The three "dialects" turned out to be one algorithm **Rotation strategies.** The three "dialects" turned out to be one algorithm
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state (Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
across calls (afl/nrl/soccer) and a precomputed per-cycle list across calls (afl/nrl/soccer) and a precomputed per-cycle list
+11 -6
View File
@@ -102,9 +102,16 @@ cd /path/to/LEDMatrix
bash scripts/download_pixlet.sh bash scripts/download_pixlet.sh
``` ```
The script downloads only the Linux ARM64 build (Raspberry Pi OS 64-bit),
from the `tronbyt/pixlet` releases, to `bin/pixlet/pixlet-linux-arm64`. On
any other platform (32-bit Pi OS, x86_64, macOS), put a `pixlet` binary on
your `PATH`, or set the plugin's `pixlet_path`, or place it in `bin/pixlet/`
under the name `_find_pixlet_binary()` looks for
([`web_interface/blueprints/api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py)).
Verify installation: Verify installation:
```bash ```bash
./bin/pixlet/pixlet-linux-amd64 version ./bin/pixlet/pixlet-linux-arm64 version
# Pixlet 0.50.2 (or later) # Pixlet 0.50.2 (or later)
``` ```
@@ -276,10 +283,8 @@ LEDMatrix/
│ ├── hour_hand.png │ ├── hour_hand.png
│ └── minute_hand.png │ └── minute_hand.png
│ │
├── bin/pixlet/ # Pixlet binaries ├── bin/pixlet/ # Pixlet binary
│ ├── pixlet-linux-amd64 │ └── pixlet-linux-arm64 # the only one download_pixlet.sh fetches
│ ├── pixlet-linux-arm64
│ └── pixlet-darwin-arm64
│ │
└── scripts/ └── scripts/
└── download_pixlet.sh # Pixlet installer └── download_pixlet.sh # Pixlet installer
@@ -324,7 +329,7 @@ Many apps require API keys for external services:
**Solutions**: **Solutions**:
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet` 1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
2. Verify config: Ensure all required fields are filled 2. Verify config: Ensure all required fields are filled
3. Test manually: `./bin/pixlet/pixlet-linux-amd64 render starlark-apps/{app-id}/{app-id}.star` 3. Test manually: `./bin/pixlet/pixlet-linux-arm64 render starlark-apps/{app-id}/{app-id}.star`
4. Missing assets: Some apps need images/fonts that may fail to download 4. Missing assets: Some apps need images/fonts that may fail to download
5. API issues: Check API keys and rate limits 5. API issues: Check API keys and rate limits
+41 -27
View File
@@ -201,10 +201,11 @@ sudo systemctl restart ledmatrix-web
**Solutions:** **Solutions:**
1. **Install dependencies:** 1. **Install dependencies** as root, so the root display service can import
them:
```bash ```bash
pip3 install --break-system-packages -r requirements.txt sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
pip3 install --break-system-packages -r web_interface/requirements.txt sudo python3 -m pip install --break-system-packages --no-cache-dir -r web_interface/requirements.txt
``` ```
2. **Test imports step-by-step:** 2. **Test imports step-by-step:**
@@ -250,15 +251,18 @@ sudo systemctl restart ledmatrix-web
**Solutions:** **Solutions:**
```bash [PERMISSIONS.md](PERMISSIONS.md) lists the expected owner and mode of every
# Fix ownership of LEDMatrix directory file and directory, and which `scripts/fix_perms/` script to run as which
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix user. Don't `chown -R` the whole project: the two sudo helper scripts in
`scripts/fix_perms/` must stay owned by root.
# Fix config file permissions ```bash
# Config files: web user owns both; secrets must stay 640
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
sudo chmod 644 config/config.json sudo chmod 644 config/config.json
sudo chmod 640 config/config_secrets.json sudo chmod 640 config/config_secrets.json
# Verify service runs as correct user # Which user the web interface runs as
sudo systemctl cat ledmatrix-web | grep User sudo systemctl cat ledmatrix-web | grep User
``` ```
@@ -462,15 +466,17 @@ sudo systemctl cat ledmatrix-web | grep User
} }
``` ```
2. **Restart display:** Or toggle the plugin on in the **Plugin Manager** tab, which writes the
```bash same flag.
sudo systemctl restart ledmatrix
```
3. **Verify in web interface:** 2. **Wait a few seconds.** The display service watches `config.json` and
- Open the **Plugin Manager** tab loads a newly enabled plugin without a restart
- Toggle the plugin switch to enable (`DisplayController._reconcile_enabled_plugins()` in
- From **Overview**, click **Restart Display Service** [`src/display_controller.py`](../src/display_controller.py)). This
needs hot reload, which is on unless `LEDMATRIX_HOT_RELOAD=false` is set.
3. **If it still does not appear**, check the logs for a config validation
error, then restart: `sudo systemctl restart ledmatrix`
#### Plugin Not Loading #### Plugin Not Loading
@@ -491,10 +497,12 @@ sudo systemctl cat ledmatrix-web | grep User
# Verify all required fields present # Verify all required fields present
``` ```
3. **Check dependencies installed:** 3. **Check dependencies installed.** Install them with `sudo`: the display
service runs as root and does not see packages pip put in your user's
`~/.local` (see [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md)):
```bash ```bash
if [ -f plugin-repos/plugin-id/requirements.txt ]; then if [ -f plugin-repos/plugin-id/requirements.txt ]; then
pip3 install --break-system-packages -r plugin-repos/plugin-id/requirements.txt sudo python3 -m pip install --break-system-packages --no-cache-dir -r plugin-repos/plugin-id/requirements.txt
fi fi
``` ```
@@ -503,14 +511,9 @@ sudo systemctl cat ledmatrix-web | grep User
sudo journalctl -u ledmatrix -f | grep plugin-id sudo journalctl -u ledmatrix -f | grep plugin-id
``` ```
5. **Test plugin import:** 5. **Load and render the plugin headlessly:**
```bash ```bash
python3 -c " python3 scripts/check_plugin.py --plugin plugin-id
import sys
sys.path.insert(0, 'plugin-repos/plugin-id')
from manager import PluginClass
print('Plugin imports successfully')
"
``` ```
#### Stale Cache Data #### Stale Cache Data
@@ -540,10 +543,12 @@ sudo systemctl cat ledmatrix-web | grep User
sudo systemctl restart ledmatrix sudo systemctl restart ledmatrix
``` ```
2. **Check cache permissions:** 2. **Check cache permissions.** Expected: `root:ledmatrix`, `drwxrwsr-x`.
`setup_cache.sh` restores that layout (see
[PERMISSIONS.md](PERMISSIONS.md#repair-scripts)):
```bash ```bash
ls -ld /var/cache/ledmatrix ls -ld /var/cache/ledmatrix
sudo ./scripts/fix_perms/fix_cache_permissions.sh sudo bash scripts/install/setup_cache.sh
``` ```
--- ---
@@ -611,6 +616,15 @@ sudo systemctl cat ledmatrix-web | grep User
``` ```
**Note:** Minimum recommended: 300 seconds (5 minutes) **Note:** Minimum recommended: 300 seconds (5 minutes)
How often the core calls the plugin's `update()` comes from the plugin
itself first: its `get_update_interval()` if it has one, then
`update_interval` in its `manifest.json`. The `update_interval` in
`config.json` is used by the scheduler only when the manifest sets none.
Many plugins also read their own config `update_interval` and skip the
API call inside `update()` until it has elapsed, which is what makes the
setting above effective; check the plugin's settings form or
`config_schema.json` for the option it actually honours.
2. **Check current rate limit usage:** 2. **Check current rate limit usage:**
- OpenWeatherMap free tier: 1,000 calls/day, 60 calls/minute - OpenWeatherMap free tier: 1,000 calls/day, 60 calls/minute
- With 300s interval: 288 calls/day (well within limits) - With 300s interval: 288 calls/day (well within limits)
+9 -5
View File
@@ -161,7 +161,11 @@ duration, and related settings — so you can configure Vegas mode
entirely from the web UI without hand-editing JSON. See entirely from the web UI without hand-editing JSON. See
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do. [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
Changes require **Restart Display Service** from the Overview tab. Brightness and the Vegas Scroll settings apply to the running display
within a few seconds. Matrix hardware settings (rows, columns, chain length,
mapping, GPIO slowdown, PWM and refresh settings) are only read when the
display starts, so those need **Restart Display Service** from the Overview
tab.
### Plugin Manager Tab ### Plugin Manager Tab
@@ -248,16 +252,16 @@ View real-time system logs:
1. Open the **Display** tab 1. Open the **Display** tab
2. Adjust the **Brightness** slider (1–100) 2. Adjust the **Brightness** slider (1–100)
3. Click **Save** 3. Click **Save**. The panel picks up the new brightness within a few
4. Click **Restart Display Service** on the **Overview** tab seconds; no restart is needed
### Installing a New Plugin ### Installing a New Plugin
1. Open the **Plugin Manager** tab 1. Open the **Plugin Manager** tab
2. Scroll to the **Plugin Store** section and browse or search 2. Scroll to the **Plugin Store** section and browse or search
3. Click **Install** next to the plugin 3. Click **Install** next to the plugin
4. Toggle the plugin on in **Installed Plugins** 4. Toggle the plugin on in **Installed Plugins**. The running display
5. Click **Restart Display Service** on **Overview** loads it within a few seconds; no restart is needed
### Configuring a Plugin ### Configuring a Plugin
+5 -588
View File
@@ -1,590 +1,7 @@
# Widget Development Guide # Widget Development Guide
## Overview The widget guide lives next to the widgets, in
[web_interface/static/v3/js/widgets/README.md](../web_interface/static/v3/js/widgets/README.md).
The LEDMatrix Widget Registry system allows plugins to use reusable UI components for configuration forms. This enables: It lists every built-in `x-widget`, the schema keywords the config form
understands (`x-options.labels`, `x-advanced`, `x-display: "hidden"`), and how
- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code to ship a custom widget with a plugin.
- **Custom Widgets**: Create plugin-specific widgets without modifying the LEDMatrix codebase
- **Backwards Compatibility**: Existing plugins continue to work without changes
## Available Core Widgets
### Plugin File Manager Widget (`plugin-file-manager`)
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
**Schema Configuration:**
```json
{
"file_manager": {
"type": "null",
"title": "Data Files",
"x-widget": "plugin-file-manager",
"x-widget-config": {
"actions": {
"list": "list-files",
"get": "get-file",
"save": "save-file",
"upload": "upload-file",
"delete": "delete-file",
"create": "create-file",
"toggle": "toggle-category"
},
"upload_hint": "JSON files with day numbers 1–365 as keys",
"directory_label": "my_data/",
"create_fields": [
{ "key": "category_name", "label": "Category Name",
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
"hint": "Lowercase letters, numbers, underscores" },
{ "key": "display_name", "label": "Display Name",
"placeholder": "e.g., My Words", "hint": "Optional" }
]
}
}
}
```
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
**Used by:** of-the-day
---
### Time Picker Widget (`time-picker`)
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
**Schema Configuration:**
```json
{
"target_time": {
"type": "string",
"x-widget": "time-picker",
"default": "00:00",
"x-options": {
"placeholder": "Select time",
"clearable": true
}
}
}
```
**Used by:** countdown
---
### File Upload Single Widget (`file-upload-single`)
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
**Schema Configuration:**
```json
{
"image_path": {
"type": "string",
"x-widget": "file-upload-single",
"x-upload-config": {
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
"max_size_mb": 5
}
}
}
```
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
**Used by:** countdown
---
### File Upload Widget (`file-upload`)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 10,
"max_size_mb": 5,
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
}
}
```
**Used by:** static-image, news plugins
### Checkbox Group Widget (`checkbox-group`)
Multi-select checkboxes for array fields with enum items.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["option1", "option2", "option3"]
},
"x-options": {
"labels": {
"option1": "Option 1 Label",
"option2": "Option 2 Label"
}
}
}
```
**Used by:** odds-ticker, news plugins
### Custom Feeds Widget (`custom-feeds`)
Table-based RSS feed editor with logo uploads.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"enabled": { "type": "boolean" },
"logo": { "type": "object" }
}
},
"maxItems": 50
}
```
**Used by:** news plugin (for custom RSS feeds)
## Using Existing Widgets
To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property:
```json
{
"properties": {
"my_images": {
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 5
}
},
"enabled_leagues": {
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["nfl", "nba", "mlb"]
},
"x-options": {
"labels": {
"nfl": "NFL",
"nba": "NBA",
"mlb": "MLB"
}
}
}
}
}
```
The widget will be automatically rendered when the plugin configuration form is loaded.
## Labelling Enum Options (`x-options.labels`)
A plain `enum` renders as a dropdown whose option text is the value with
underscores replaced and title case applied — `day_first` becomes "Day First".
That is fine for values that read as their own label, and wrong for values that
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
actually produces.
Supply `x-options.labels` to set the visible text. This is the same convention
the `checkbox-group` widget uses:
```json
{
"date_format": {
"type": "string",
"enum": ["abbrev", "numeric", "day_first"],
"default": "abbrev",
"x-options": {
"labels": {
"abbrev": "Sep 19",
"numeric": "9/19",
"day_first": "19 Sep"
}
}
}
}
```
Labels are **display only** — the stored value is still the enum value, so
adding them never changes a saved config. The map may be partial: any value
without a label keeps the humanised fallback. Older cores that predate this
support ignore `x-options` and render the fallback for every option, so a
plugin can ship labels without requiring a core upgrade.
Array-table columns (`x-widget: array-table`) accept the same
`x-options.labels` on a column definition, but their fallback is the **raw
value** rather than the humanised one, because those columns hold values such
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
browser use the labels too (`array-table.js`), so a column reads the same
before and after a page reload.
## Marking Fields as Advanced (`x-advanced`)
Add `"x-advanced": true` to any top-level, non-object property to move it out
of the main form and into a single collapsed **Advanced Settings** section at
the bottom of the plugin's configuration page:
```json
{
"properties": {
"city": {
"type": "string",
"title": "City"
},
"request_timeout": {
"type": "integer",
"default": 10,
"description": "HTTP timeout in seconds",
"x-advanced": true
}
}
}
```
Guidelines:
- Use it for fine-tuning knobs most users never touch (timeouts, retry
behavior, cache TTLs, styling overrides). Anything a first-time user must
set to get the plugin working should stay basic.
- Nothing is hidden permanently — the section expands on click, and the
settings search finds and auto-expands advanced fields like any others.
- The flag is ignored on `object`-type properties (they already render as
their own collapsible sections) and is safely ignored by older cores, so
adding it never breaks compatibility.
## Hiding Fields From the Form (`x-display: "hidden"`)
Add `"x-display": "hidden"` to a property that must stay in the schema but
should not appear as a control: a deprecated key kept so existing configs keep
validating, or an internal value such as an auto-generated row id.
```json
{
"properties": {
"radar_zoom": {
"type": "integer",
"default": 6,
"title": "Radar Zoom Level (deprecated)",
"x-display": "hidden"
}
}
}
```
What the core does with it:
- **Not rendered** at any depth: top-level fields, children of an object
section, and properties of array-of-object items (never a table column, even
if `x-columns` names it, and never in the row editor). A hidden field flagged
`x-advanced` is not listed or counted in Advanced Settings, and an object
whose children are all hidden draws no empty section. Hidden fields don't
show up in the settings search either, since it indexes the rendered form.
- **Stored value preserved on save.** Saving the form never changes a hidden
value. The unchecked-checkbox rule ignores a hidden boolean. Array rows carry
a hidden property's stored value through the form, so the value survives the
row being posted back; a new row gets no value (the plugin fills it in).
- **The API is unaffected.** A JSON save to `POST /api/v3/plugins/config` can
still set a hidden field.
Older cores ignore the flag and render the field as a normal control.
## Creating Custom Widgets
### Step 1: Create Widget File
Create a JavaScript file in your plugin's `widgets/` directory, named
`widgets/[widget-name].js`. The directory is not optional: it is the only
place the core will serve a widget from.
```javascript
// Ensure LEDMatrixWidgets registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('LEDMatrixWidgets registry not found');
return;
}
// Register your widget
window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget',
version: '1.0.0',
/**
* Render the widget HTML
* @param {HTMLElement} container - Container element to render into
* @param {Object} config - Widget configuration from schema
* @param {*} value - Current value
* @param {Object} options - Additional options (fieldId, pluginId, etc.)
*/
render: function(container, config, value, options) {
const fieldId = options.fieldId || container.id;
// Always escape HTML to prevent XSS
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
container.innerHTML = `
<div class="my-custom-widget">
<input type="text"
id="${fieldId}_input"
value="${escapeHtml(value || '')}"
class="w-full px-3 py-2 border border-gray-300 rounded">
</div>
`;
// Attach event listeners
const input = container.querySelector('input');
input.addEventListener('change', (e) => {
this.handlers.onChange(fieldId, e.target.value);
});
},
/**
* Get current value from widget
*/
getValue: function(fieldId) {
const input = document.querySelector(`#${fieldId}_input`);
return input ? input.value : null;
},
/**
* Set value programmatically
*/
setValue: function(fieldId, value) {
const input = document.querySelector(`#${fieldId}_input`);
if (input) {
input.value = value || '';
}
},
/**
* Event handlers
*/
handlers: {
onChange: function(fieldId, value) {
// Trigger form change event
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
```
### Step 2: Declare the Widget in `manifest.json`
The manifest is the allowlist. A widget is served only if the plugin declares
it, so shipping a file under `widgets/` does not by itself publish it:
```json
{
"widgets": [
{
"name": "my-custom-widget",
"script": "my-custom-widget.js",
"description": "What this widget is for"
}
]
}
```
`name` is what you use in `x-widget` and in the URL. `script` is optional and
defaults to `[name].js`; it must be a plain filename directly inside
`widgets/` (no paths). Both are validated against
`schema/manifest_schema.json`.
### Step 3: Reference Widget in Schema
In your plugin's `config_schema.json`:
```json
{
"properties": {
"my_field": {
"type": "string",
"description": "My custom field",
"x-widget": "my-custom-widget",
"default": ""
}
}
}
```
### Step 4: Widget Loading
The widget is loaded on demand when the plugin's configuration form renders a
field that references it. The system will:
1. Check whether the widget is already registered in the core registry.
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
That route serves the declared `script` from your plugin's `widgets/`
directory, as `text/javascript`.
3. Render it by calling the `render` function your script registered.
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
**If the widget fails to load** (not declared, file missing, script throws, or
it never calls `register`), the field falls back to a plain text input holding
the current value. This is deliberate: a broken widget costs the user an
editor, not their configured value.
**Limitation:** the on-demand path applies to `string`-typed fields (the
default branch of the config-form renderer). Fields typed `object`, `array`,
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
dispatched by the server-side template to its own built-in renderers, so a
plugin-supplied `x-widget` on one of those is ignored today.
## Widget API Reference
### Widget Definition Object
```javascript
{
name: string, // Human-readable widget name
version: string, // Widget version
render: function, // Required: Render function
getValue: function, // Optional: Get current value
setValue: function, // Optional: Set value programmatically
handlers: object // Optional: Event handlers
}
```
### Render Function
```javascript
render(container, config, value, options)
```
**Parameters:**
- `container` (HTMLElement): Container element to render into
- `config` (Object): Widget configuration from schema
- `value` (*): Current field value
- `options` (Object): Additional options
- `fieldId` (string): Field ID
- `pluginId` (string): Plugin ID
- `fullKey` (string): Full field key path
### Get Value Function
```javascript
getValue(fieldId)
```
**Returns:** Current widget value
### Set Value Function
```javascript
setValue(fieldId, value)
```
**Parameters:**
- `fieldId` (string): Field ID
- `value` (*): Value to set
## Examples
See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interface/static/v3/js/widgets/example-color-picker.js) for a complete example of a custom color picker widget.
## Best Practices
### Security
1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS
2. **Validate inputs**: Validate user input before processing
3. **Sanitize values**: Clean values before storing
### Performance
1. **Lazy loading**: Load widget scripts only when needed
2. **Event delegation**: Use event delegation for dynamic content
3. **Debounce**: Debounce frequent events (e.g., input changes)
### Accessibility
1. **Labels**: Always associate labels with inputs
2. **ARIA attributes**: Use appropriate ARIA attributes
3. **Keyboard navigation**: Ensure keyboard accessibility
## Troubleshooting
### Widget Not Loading
1. Check browser console for errors
2. Verify widget file path is correct
3. Ensure `LEDMatrixWidgets.register()` is called
4. Check that widget name matches schema `x-widget` value
### Widget Not Rendering
1. Verify `render` function is defined
2. Check container element exists
3. Ensure widget is registered before form loads
4. Check for JavaScript errors in console
### Value Not Saving
1. Ensure widget triggers `widget-change` event
2. Verify form submission includes widget value
3. Check `getValue` function returns correct type
4. Verify field name matches schema property
## Current Implementation Status
**Phase 1 Complete:**
- ✅ Widget registry system created
- ✅ Core widgets extracted to separate files
- ✅ Widget handlers available globally (backwards compatible)
- ✅ Plugin widget loading system implemented
**Current Behavior:**
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
- Widget handlers are registered and available globally
- Custom widgets can be created, declared in `manifest.json`, and are served
and rendered on demand for `string`-typed fields
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
**Backwards Compatibility:**
- All existing plugins using widgets continue to work without changes
- Server-side rendering remains the primary method
- Widget registry provides foundation for future enhancements
## See Also
- [Widget README](../web_interface/static/v3/js/widgets/README.md) - Complete widget development guide with examples
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - General plugin development
- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
+108 -147
View File
@@ -18,7 +18,7 @@ on_error() {
echo "-- Last 100 lines from log --" >&2 echo "-- Last 100 lines from log --" >&2
tail -n 100 "$LOG_FILE" >&2 || true tail -n 100 "$LOG_FILE" >&2 || true
fi fi
echo "\nCommon fixes:" >&2 printf '\nCommon fixes:\n' >&2
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2 echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
echo "- If you saw an APT lock error: wait a minute, close other installers, then run: sudo dpkg --configure -a" >&2 echo "- If you saw an APT lock error: wait a minute, close other installers, then run: sudo dpkg --configure -a" >&2
echo "- Re-run this script. It is safe to run multiple times." >&2 echo "- Re-run this script. It is safe to run multiple times." >&2
@@ -115,7 +115,8 @@ fi
echo "✓ OS requirements met" echo "✓ OS requirements met"
echo "" echo ""
# Get the actual user who invoked sudo (set after we ensure sudo below) # The user who ran the installer: SUDO_USER once we are running under sudo
# (the re-exec below guarantees that), otherwise whoever we are now.
if [ -n "${SUDO_USER:-}" ]; then if [ -n "${SUDO_USER:-}" ]; then
ACTUAL_USER="$SUDO_USER" ACTUAL_USER="$SUDO_USER"
else else
@@ -202,7 +203,7 @@ echo ""
# Check if running as root; if not, try to elevate automatically for novices # Check if running as root; if not, try to elevate automatically for novices
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo "This script needs administrator privileges. Attempting to re-run with sudo..." echo "This script needs administrator privileges. Attempting to re-run with sudo..."
exec sudo -E env LEDMATRIX_ELEVATED=1 bash "$0" "$@" exec sudo -E bash "$0" "$@"
fi fi
echo "✓ Running as root (required for installation)" echo "✓ Running as root (required for installation)"
@@ -502,6 +503,44 @@ print_rgbmatrix_build_failure() {
fi fi
} }
# Set WEB_SERVICE_USER to the account ledmatrix-web.service runs as, or "root"
# when it cannot tell. Steps 3.1 and 11 choose plugin-directory ownership from
# it. The logic was pasted three times, identically, and is kept verbatim here.
# Note: install_web_service.sh and install_service.sh no longer contain the
# "User=root" / "User=${ACTUAL_USER}" strings grepped for below (the units come
# from systemd/*.service templates with User=__USER__). So once the unit is
# installed (Step 7.5, by install_service.sh) the first branch reads its real
# User=; before that the second branch is taken whenever
# install_web_service.sh exists, matches neither string, and yields "root" --
# the later branches are reached only if that script is missing.
detect_web_service_user() {
WEB_SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
# Check template file (may have placeholder)
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
# If template has placeholder, check install script
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
# Check install_service.sh to see what user it uses
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
# Web service will be installed by install_service.sh as ACTUAL_USER
WEB_SERVICE_USER="$ACTUAL_USER"
fi
}
echo "" echo ""
echo "This script will perform the following steps:" echo "This script will perform the following steps:"
echo "1. Check prerequisites (network, disk, memory) and install system dependencies" echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
@@ -634,8 +673,9 @@ else
echo "Setting ownership of assets directory..." echo "Setting ownership of assets directory..."
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets" chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets"
# Set permissions to allow read/write for owner, group, and others (for root service user) # 777: read/write for owner, group and every other account. Root (the
# Note: 777 allows root (service user) to write, which is necessary when service runs as root # display service) does not need it -- root ignores mode bits -- so the
# "other" bits only matter to accounts that are neither the owner nor root.
echo "Setting permissions for assets directory..." echo "Setting permissions for assets directory..."
chmod -R 777 "$PROJECT_ROOT_DIR/assets" chmod -R 777 "$PROJECT_ROOT_DIR/assets"
@@ -699,32 +739,7 @@ else
fi fi
# Determine ownership based on web service user # Determine ownership based on web service user
# Check if web service file exists and what user it runs as detect_web_service_user
WEB_SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
# Check template file (may have placeholder)
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
# If template has placeholder, check install script
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
# Check install_service.sh to see what user it uses
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
# Web service will be installed by install_service.sh as ACTUAL_USER
WEB_SERVICE_USER="$ACTUAL_USER"
fi
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER # If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
# so the web service can change permissions. Root service can still access via group (775). # so the web service can change permissions. Root service can still access via group (775).
@@ -758,32 +773,7 @@ if [ ! -d "$PLUGIN_REPOS_DIR" ]; then
fi fi
# Determine ownership based on web service user # Determine ownership based on web service user
# Check if web service file exists and what user it runs as detect_web_service_user
WEB_SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
# Check template file (may have placeholder)
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
# If template has placeholder, check install script
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
# Check install_service.sh to see what user it uses
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
# Web service will be installed by install_service.sh as ACTUAL_USER
WEB_SERVICE_USER="$ACTUAL_USER"
fi
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER # If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
# so the web service can change permissions. Root service can still access via group (775). # so the web service can change permissions. Root service can still access via group (775).
@@ -797,8 +787,8 @@ else
chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR" chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
fi fi
# Set directory permissions (775: rwxrwxr-x) # Set directory permissions (2775: rwxrwsr-x, setgid so new entries inherit the group)
echo "Setting plugin-repos directory permissions to 2775 (sticky bit)..." echo "Setting plugin-repos directory permissions to 2775 (setgid)..."
find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \; find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \;
# Set file permissions (664: rw-rw-r--) # Set file permissions (664: rw-rw-r--)
@@ -1005,9 +995,9 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
PACKAGE_NUM=$((PACKAGE_NUM + 1)) PACKAGE_NUM=$((PACKAGE_NUM + 1))
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line" echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
# Check if package is already installed (basic check - may not catch all cases) # Install with a timeout where available. --verbose output goes to
# Try installing with verbose output and timeout (if available) # $INSTALL_OUTPUT (filtered below, full copy in the log); --no-cache-dir
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics # avoids pip cache issues.
INSTALL_OUTPUT=$(mktemp) INSTALL_OUTPUT=$(mktemp)
INSTALL_SUCCESS=false INSTALL_SUCCESS=false
@@ -1312,7 +1302,11 @@ else
WEB_DEPS_OK=false WEB_DEPS_OK=false
fi fi
else else
echo "Web dependencies already installed from web_interface/requirements.txt in Step 5" # No marker means Step 5 did not install web_interface/requirements.txt,
# and without the smart installer there is nothing else to try here.
echo "⚠ scripts/install_dependencies_apt.py not found, and Step 5 did not install"
echo " web_interface/requirements.txt, so web interface dependencies may be missing."
WEB_DEPS_OK=false
fi fi
# Create the marker only when installation actually succeeded, so a # Create the marker only when installation actually succeeded, so a
@@ -1516,51 +1510,31 @@ POWEROFF_PATH=$(which poweroff)
BASH_PATH=$(which bash) BASH_PATH=$(which bash)
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true) JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
# Create sudoers content # The rules themselves live in scripts/install/lib_sudoers.sh, shared with
cat > "$SUDOERS_TMP" << EOF # scripts/install/configure_web_sudo.sh so the two cannot drift apart again.
# LED Matrix Web Interface passwordless sudo configuration # If it is missing (a damaged checkout), keep whatever is already installed
# This allows the web interface user to run specific commands without a password # rather than failing the whole install; the gate below skips the install.
SUDOERS_VALID=1
# Allow $ACTUAL_USER to run specific commands without a password for the LED Matrix web interface SUDOERS_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_sudoers.sh"
$ACTUAL_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH if [ -f "$SUDOERS_LIB" ]; then
$ACTUAL_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH # shellcheck source=scripts/install/lib_sudoers.sh
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service . "$SUDOERS_LIB"
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service web_sudoers_rules "$ACTUAL_USER" "$PROJECT_ROOT_DIR" "$SYSTEMCTL_PATH" "$BASH_PATH" \
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service "$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$SUDOERS_TMP"
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service else
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service SUDOERS_VALID=0
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service echo "⚠ $SUDOERS_LIB not found; cannot generate the sudoers rules." >&2
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service echo " the display service until this is fixed." >&2
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
# Install a requirements.txt as root via vetted helper, so packages are visible
# to root-run ledmatrix.service (not just the web interface's own user).
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat >> "$SUDOERS_TMP" << EOF
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
# when its output is a terminal. From that pager (less) a "!sh" is a root
# shell -- the standard journalctl escalation. The web interface always passes
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
# sits in the middle of the command line. NOEXEC stops the command executing
# another program at all, which closes the hole without depending on wildcard
# matching subtleties.
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
EOF
fi fi
# Never install rules we have not parsed. A malformed drop-in in # Never install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user, which on a # /etc/sudoers.d makes sudo refuse every command for every user, which on a
# headless Pi leaves no way in at all. If the rules do not parse, say so and # headless Pi leaves no way in at all. If the rules do not parse, say so and
# keep whatever is already installed. # keep whatever is already installed.
SUDOERS_VALID=1 if [ "$SUDOERS_VALID" = "0" ]; then
if command -v visudo >/dev/null 2>&1; then : # nothing was generated; already reported above
elif command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
SUDOERS_VALID=0 SUDOERS_VALID=0
echo "⚠ The generated sudoers rules did not parse:" >&2 echo "⚠ The generated sudoers rules did not parse:" >&2
@@ -1597,11 +1571,12 @@ echo "-----------------------------------------------------"
if [ -f "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" ]; then if [ -f "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" ]; then
echo "Configuring WiFi management permissions..." echo "Configuring WiFi management permissions..."
# Run as the actual user (not root) since the script checks for that # Run as the actual user (not root) since the script checks for that
sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" || { if sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh"; then
echo "✓ WiFi management permissions configured"
else
echo "⚠ WiFi permissions configuration failed, but continuing installation" echo "⚠ WiFi permissions configuration failed, but continuing installation"
echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh" echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh"
} fi
echo "✓ WiFi management permissions configured"
else else
echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration" echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration"
echo " You can configure WiFi permissions later by running:" echo " You can configure WiFi permissions later by running:"
@@ -1690,28 +1665,8 @@ fi
# Re-apply plugin directory permissions based on web service user # Re-apply plugin directory permissions based on web service user
echo "Re-applying plugin directory permissions..." echo "Re-applying plugin directory permissions..."
# Determine web service user (check installed service, install scripts, or template) # Determine ownership based on web service user
WEB_SERVICE_USER="root" detect_web_service_user
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
# Set ownership based on web service user # Set ownership based on web service user
if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then
@@ -1791,10 +1746,11 @@ echo "-------------------------------------"
echo "Removing potential conflicting services (bluetooth and others)..." echo "Removing potential conflicting services (bluetooth and others)..."
if [ "$SKIP_SOUND" = "1" ]; then if [ "$SKIP_SOUND" = "1" ]; then
echo "Skipping sound module configuration as requested (--skip-sound)." echo "Skipping sound module configuration as requested (--skip-sound)."
elif apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio; then
echo "✓ Unnecessary services removed (or not present)"
else else
echo "⚠ Some packages could not be removed; continuing" # apt_remove never fails (it ends in `|| true`); apt itself reports any
# package it could not remove.
apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio
echo "✓ Unnecessary services removed (or not present)"
fi fi
# Blacklist onboard sound module (idempotent) # Blacklist onboard sound module (idempotent)
@@ -2009,20 +1965,6 @@ if systemctl list-unit-files | grep -q "ledmatrix-wifi-monitor.service"; then
fi fi
echo "" echo ""
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
elif [ "$ASSUME_YES" = "1" ]; then
echo "Non-interactive mode: rebooting now to apply changes..."
reboot
else
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Rebooting now..."
reboot
fi
fi
echo "==========================================" echo "=========================================="
echo "Installation Complete!" echo "Installation Complete!"
echo "==========================================" echo "=========================================="
@@ -2068,7 +2010,7 @@ if command -v nmcli >/dev/null 2>&1; then
if [ -n "$WIFI_STATUS" ]; then if [ -n "$WIFI_STATUS" ]; then
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
if [ "$state" = "connected" ]; then if [ "$state" = "connected" ]; then
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1) SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1 || true)
if [ -n "$SSID" ]; then if [ -n "$SSID" ]; then
echo " ✓ Connected to: $SSID" echo " ✓ Connected to: $SSID"
else else
@@ -2092,7 +2034,7 @@ echo "AP Mode Status:"
if systemctl is-active --quiet hostapd 2>/dev/null; then if systemctl is-active --quiet hostapd 2>/dev/null; then
echo " ✓ AP Mode is ACTIVE" echo " ✓ AP Mode is ACTIVE"
echo " → Connect to WiFi network: LEDMatrix-Setup" echo " → Connect to WiFi network: LEDMatrix-Setup"
echo " → Password: ledmatrix123" echo " → Open network, no password"
echo " → Access web UI at: http://192.168.4.1:5000" echo " → Access web UI at: http://192.168.4.1:5000"
AP_MODE_ACTIVE=true AP_MODE_ACTIVE=true
else else
@@ -2100,7 +2042,7 @@ else
if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
echo " ✓ AP Mode is ACTIVE (IP detected)" echo " ✓ AP Mode is ACTIVE (IP detected)"
echo " → Connect to WiFi network: LEDMatrix-Setup" echo " → Connect to WiFi network: LEDMatrix-Setup"
echo " → Password: ledmatrix123" echo " → Open network, no password"
echo " → Access web UI at: http://192.168.4.1:5000" echo " → Access web UI at: http://192.168.4.1:5000"
AP_MODE_ACTIVE=true AP_MODE_ACTIVE=true
else else
@@ -2202,3 +2144,22 @@ echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json" echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
echo "" echo ""
echo "Enjoy your LED Matrix display!" echo "Enjoy your LED Matrix display!"
# Reboot last. It used to come before the summary above, so with -y (and
# the one-shot installer, which always passes -y) the reboot was already
# under way while the summary printed, and the SSH session usually dropped
# before any of it -- the web UI address included -- could be read.
echo ""
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
elif [ "$ASSUME_YES" = "1" ]; then
echo "Non-interactive mode: rebooting now to apply changes..."
reboot
else
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Rebooting now..."
reboot
fi
fi
+86
View File
@@ -0,0 +1,86 @@
# The mypy ratchet: modules that type-check clean, one path per line, sorted.
#
# CI ("Type check (mypy ratchet)") runs `python scripts/check_types.py`, which
# runs mypy on exactly these files (imports followed silently, so errors in an
# unlisted module they import don't count) and fails on any error, so a listed
# module stays clean. Most of src/ isn't clean yet. When you make a module
# clean, add it here. Don't take a module off to get CI green -- fix the error
# (annotation-only where you can: hints, typing.cast, TYPE_CHECKING imports;
# widen an annotation rather than delete a defensive runtime check).
src/__init__.py
src/adaptive_images.py
src/auto_update_setup.py
src/backup_manager.py
src/base_odds_manager.py
src/cache/__init__.py
src/cache/cache_metrics.py
src/cache/cache_strategy.py
src/cache/memory_cache.py
src/common/__init__.py
src/common/api_helper.py
src/common/bdf_font.py
src/common/espn_dates.py
src/common/favorite_team_check.py
src/common/font_layout.py
src/common/frame_timing.py
src/common/json_body.py
src/common/logo_helper.py
src/common/path_safety.py
src/common/permission_utils.py
src/common/render_gate.py
src/common/scroll_config.py
src/common/snapshot_policy.py
src/common/sports_card.py
src/common/sports_card_wrappers.py
src/common/sports_celebration.py
src/common/sports_fetch.py
src/common/sports_scroll.py
src/common/sports_timezone.py
src/config_service.py
src/core_config_keys.py
src/deprecation.py
src/device_location.py
src/display_geometry.py
src/dynamic_team_resolver.py
src/exceptions.py
src/font_usage.py
src/logging_config.py
src/logo_downloader.py
src/matrix_support.py
src/pi5_matrix_support.py
src/plugin_system/__init__.py
src/plugin_system/compatibility.py
src/plugin_system/operation_history.py
src/plugin_system/operation_queue.py
src/plugin_system/operation_types.py
src/plugin_system/plugin_dirs.py
src/plugin_system/plugin_executor.py
src/plugin_system/plugin_health.py
src/plugin_system/plugin_loader.py
src/plugin_system/plugin_state.py
src/plugin_system/repo_urls.py
src/plugin_system/resource_monitor.py
src/plugin_system/saved_repositories.py
src/plugin_system/schema_manager.py
src/plugin_system/state_reconciliation.py
src/plugin_system/testing/__init__.py
src/plugin_system/testing/bounds_display_manager.py
src/plugin_system/testing/loading.py
src/plugin_system/testing/mocks.py
src/plugin_system/testing/plugin_test_base.py
src/plugin_system/testing/sizes.py
src/redaction.py
src/scan_order.py
src/startup_validator.py
src/vegas_mode/__init__.py
src/vegas_mode/config.py
src/vegas_mode/coordinator.py
src/vegas_mode/geometry.py
src/vegas_mode/stream_manager.py
src/web_interface/api_helpers.py
src/web_interface/config_arrays.py
src/web_interface/error_handler.py
src/web_interface/errors.py
src/web_interface/secret_helpers.py
src/web_interface/validators.py
+25 -10
View File
@@ -1,6 +1,11 @@
[mypy] [mypy]
# Mypy configuration for LEDMatrix # Mypy configuration for LEDMatrix
# What a bare `mypy` checks. ini values can't span lines or carry trailing
# comments -- this file used to have both, so mypy refused to read it at all.
files = src
exclude = (^|/)(test|__pycache__)/
# Python version # Python version
python_version = 3.10 python_version = 3.10
@@ -25,11 +30,11 @@ warn_unreachable = True
# Strict optional checking # Strict optional checking
strict_optional = True strict_optional = True
# Disallow untyped definitions # Disallow untyped definitions (set to True once all code is typed)
disallow_untyped_defs = False # Set to True once all code is typed disallow_untyped_defs = False
# Disallow untyped calls # Disallow untyped calls (set to True once all code is typed)
disallow_untyped_calls = False # Set to True once all code is typed disallow_untyped_calls = False
# Check untyped definitions # Check untyped definitions
check_untyped_defs = True check_untyped_defs = True
@@ -96,10 +101,20 @@ ignore_missing_imports = True
[mypy-spotipy.*] [mypy-spotipy.*]
ignore_missing_imports = True ignore_missing_imports = True
# Exclude test files and generated files
exclude = (?x)(
^test/.*|
^.*/__pycache__/.*|
^.*\.pyc$
)
# numpy's own stubs (numpy>=2.3) use Python 3.12 `type` statements, which mypy
# refuses to parse under python_version = 3.10 -- and 3.10 is the floor this
# code has to run on, so it stays. Treat numpy as Any instead: skip it, and
# follow_imports_for_stubs makes the skip apply to its .pyi files too.
[mypy-numpy.*]
follow_imports = skip
follow_imports_for_stubs = True
# orjson is optional (see requirements.txt): the modules that use it fall back
# to the stdlib when `import orjson` fails. Whether mypy sees its stubs would
# otherwise depend on whether it happens to be installed -- installed, the
# `orjson = None` fallback is a type error and the stdlib branch "unreachable";
# not installed, silencing either is an unused ignore. Treat it as Any always.
[mypy-orjson.*]
follow_imports = skip
follow_imports_for_stubs = True
+2 -2
View File
@@ -1,10 +1,10 @@
# Test/dev-only dependencies (not needed on a running display). # Test/dev-only dependencies (not needed on a running display).
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt # Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
pytest>=9.0.3,<10.0.0 pytest>=9.0.3,<10.0.0
pytest-cov>=4.1.0,<5.0.0 pytest-cov>=4.1.0,<8.0.0
pytest-mock>=3.11.0,<4.0.0 pytest-mock>=3.11.0,<4.0.0
freezegun>=1.2,<2 # deterministic time for golden-image tests freezegun>=1.2,<2 # deterministic time for golden-image tests
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the
# /system/status endpoint's real path is exercised # /system/status endpoint's real path is exercised
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml) mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
+1 -1
View File
@@ -7,7 +7,7 @@ Pillow>=12.2.0,<13.0.0
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x) numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
# Timezone handling # Timezone handling
pytz>=2024.2,<2025.0 # Updated for latest timezone data pytz>=2024.2,<2027.0 # Updated for latest timezone data
# HTTP requests # HTTP requests
requests>=2.33.0,<3.0.0 requests>=2.33.0,<3.0.0
-2
View File
@@ -31,8 +31,6 @@ if args.emulator:
print("Using pygame/RGBMatrixEmulator for display") print("Using pygame/RGBMatrixEmulator for display")
print("Press ESC to exit\n") print("Press ESC to exit\n")
# Project directory already added above
# Debug output (only in debug mode or emulator mode) # Debug output (only in debug mode or emulator mode)
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true' debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
if debug_mode: if debug_mode:
+61
View File
@@ -0,0 +1,61 @@
# Scripts
Helper scripts for installing, repairing, diagnosing and developing
LEDMatrix. Most users only ever run the one-shot installer (see the project
README); everything else here is for troubleshooting or development.
Status key: **keep** — part of install/runtime or referenced by docs, CI,
tests or code; **dev-only** — for plugin/core development, not needed on a
display; **diagnostic** — run by hand on a Pi when something is wrong.
## Directories
| Directory | Status | What it holds |
|---|---|---|
| [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources |
| [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) |
| [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) |
| [`dev/`](dev/README.md) | dev-only | Plugin linking, emulator runner, Vegas density audit, Pillow smoke test |
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
## Top-level scripts
| Script | Status | What it does |
|---|---|---|
| `build_rgbmatrix_nogil.sh` | keep | Rebuilds the rgbmatrix Python binding so `SwapOnVSync` releases the GIL (docs/SCROLL_PERFORMANCE.md) |
| `check_plugin.py` | dev-only | Renders a plugin across every mode and matrix size and fails on crashes, overflow or golden-image drift |
| `check_release_version.py` | keep | Checks a release tag, CHANGELOG and `src.__version__` agree (release-version-check workflow) |
| `check_system_compatibility.sh` | diagnostic | Pre-install check of hardware, OS (Trixie only), kernel, Python, packages, disk and network |
| `dev_server.py` | dev-only | Browser preview server for plugins without the display loop (http://localhost:5001) |
| `diagnose_dependencies.sh` | diagnostic | Investigates pip installs stuck on "Preparing metadata" |
| `diagnose_web_interface.sh` | diagnostic | Checks why the web interface is not reachable |
| `download_pixlet.sh` | keep | Downloads the bundled Pixlet binaries for Starlark apps (also run from the web UI) |
| `emergency_reconnect.sh` | diagnostic | Reconnects to your WiFi network if captive-portal testing leaves the Pi offline |
| `frame_soak.py` | diagnostic | Soaks a running display and reports how often frames reached the panel late (docs/SCROLL_PERFORMANCE.md) |
| `install_dependencies_apt.py` | keep | Dependency installer that tries apt packages first, then pip (installer Step 7, plugin loader) |
| `install_plugin_dependencies.sh` | diagnostic | Installs plugin requirements by hand when the automatic install fails |
| `prove_security.py` | keep | Security property checks run by pre-commit |
| `render_bench.py` | diagnostic | Benchmarks the render loop against the panel's real refresh rate on a synthetic strip |
| `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
| `run_plugin_tests.py` | dev-only | Discovers and runs plugin test suites |
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo |
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
## Candidates for removal
Nothing in the repo (docs, CI, tests, other scripts or code) refers to these.
They are kept for now; each one needs an owner decision before it goes.
| Script | What it does |
|---|---|
| `add_defaults_to_schemas.py` | One-off: adds missing `default` values to plugin config schemas |
| `analyze_plugin_schemas.py` | One-off: reports duplicate/inconsistent fields across plugin schemas |
| `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it |
| `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` |
| `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing |
| `test_captive_portal.sh` | Tests the captive portal from a device connected to the AP |
| `verify_wifi_before_testing.sh` | Pre-flight check before unplugging Ethernet to test WiFi |
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
+1 -1
View File
@@ -194,7 +194,7 @@ def process_schema_file(schema_path: Path) -> bool:
print(f" ✓ Modified {len(modified_fields)} fields") print(f" ✓ Modified {len(modified_fields)} fields")
return True return True
else: else:
print(f" ✓ No changes needed") print(" ✓ No changes needed")
return False return False
+2 -3
View File
@@ -87,7 +87,6 @@ def find_duplicate_fields(schema: Dict[str, Any], path: str = "") -> List[str]:
def validate_schema_syntax(schema_path: Path) -> tuple[bool, List[str]]: def validate_schema_syntax(schema_path: Path) -> tuple[bool, List[str]]:
"""Validate JSON Schema syntax.""" """Validate JSON Schema syntax."""
errors = []
try: try:
with open(schema_path, 'r', encoding='utf-8') as f: with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f) schema = json.load(f)
@@ -164,7 +163,7 @@ def analyze_schema(schema_path: Path) -> Dict[str, Any]:
if "update_interval_seconds" in properties: if "update_interval_seconds" in properties:
analysis["update_interval_variant"] = "update_interval_seconds" analysis["update_interval_variant"] = "update_interval_seconds"
analysis["naming_issues"].append( analysis["naming_issues"].append(
f"Uses 'update_interval_seconds' instead of 'update_interval'" "Uses 'update_interval_seconds' instead of 'update_interval'"
) )
else: else:
analysis["missing_common_fields"].append(field_name) analysis["missing_common_fields"].append(field_name)
@@ -239,7 +238,7 @@ def main():
print(f" Missing common fields: {', '.join(result['missing_common_fields'])}") print(f" Missing common fields: {', '.join(result['missing_common_fields'])}")
if result['naming_issues']: if result['naming_issues']:
print(f" Naming issues:") print(" Naming issues:")
for issue in result['naming_issues']: for issue in result['naming_issues']:
print(f" - {issue}") print(f" - {issue}")
+19 -20
View File
@@ -28,12 +28,12 @@ print_success() {
print_warning() { print_warning() {
echo -e "${YELLOW}⚠${NC} $1" echo -e "${YELLOW}⚠${NC} $1"
((WARNINGS++)) WARNINGS=$((WARNINGS + 1))
} }
print_error() { print_error() {
echo -e "${RED}✗${NC} $1" echo -e "${RED}✗${NC} $1"
((COMPATIBILITY_ISSUES++)) COMPATIBILITY_ISSUES=$((COMPATIBILITY_ISSUES + 1))
} }
# Check if running on Raspberry Pi # Check if running on Raspberry Pi
@@ -61,20 +61,18 @@ if [ -f /etc/os-release ]; then
echo "OS: $PRETTY_NAME" echo "OS: $PRETTY_NAME"
echo "Version ID: ${VERSION_ID:-unknown}" echo "Version ID: ${VERSION_ID:-unknown}"
# first_time_install.sh refuses anything but Raspberry Pi OS / Debian 13
# (Trixie), so anything else is an error here too, not a warning.
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
if [ "${VERSION_ID:-0}" -ge "12" ]; then if [ "${VERSION_ID:-0}" = "13" ]; then
print_success "Running compatible Debian/Raspbian version (${VERSION_ID})" print_success "Detected Debian 13 Trixie - supported"
elif [ "${VERSION_ID:-0}" = "12" ]; then
if [ "${VERSION_ID:-0}" -eq "13" ]; then print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
print_success "Detected Debian 13 Trixie - full compatibility expected" else
elif [ "${VERSION_ID:-0}" -eq "12" ]; then print_error "Debian/Raspbian ${VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
print_success "Detected Debian 12 Bookworm - full compatibility confirmed"
fi fi
else else
print_warning "Old Debian/Raspbian version (${VERSION_ID}) - upgrade recommended" print_error "${ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
fi
else
print_warning "Not running Debian/Raspbian - compatibility not guaranteed"
fi fi
else else
print_error "Could not detect OS version" print_error "Could not detect OS version"
@@ -114,15 +112,13 @@ if command -v python3 >/dev/null 2>&1; then
echo "Python: $PYTHON_VERSION" echo "Python: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -eq "3" ]; then if [ "$PYTHON_MAJOR" -eq "3" ]; then
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "12" ]; then if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "13" ]; then
print_success "Python version is fully supported (3.10-3.12)" print_success "Python version is supported (3.10-3.13)"
elif [ "$PYTHON_MINOR" -eq "13" ]; then
print_warning "Python 3.13 detected - most packages compatible, but some may have limited testing"
print_warning "Please report any compatibility issues you encounter"
elif [ "$PYTHON_MINOR" -ge "14" ]; then elif [ "$PYTHON_MINOR" -ge "14" ]; then
print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet" print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet"
else else
print_warning "Python 3.${PYTHON_MINOR} is outdated - upgrade to 3.10+ recommended" # Pillow 12 and the pinned test tools need 3.10+, so this won't install.
print_error "Python 3.${PYTHON_MINOR} is too old - Python 3.10+ is required"
fi fi
else else
print_error "Python 2.x detected - Python 3.10+ is required" print_error "Python 2.x detected - Python 3.10+ is required"
@@ -157,7 +153,10 @@ ESSENTIAL_PACKAGES=(
for pkg_info in "${ESSENTIAL_PACKAGES[@]}"; do for pkg_info in "${ESSENTIAL_PACKAGES[@]}"; do
IFS=':' read -r pkg desc <<< "$pkg_info" IFS=':' read -r pkg desc <<< "$pkg_info"
if dpkg -l | grep -q "^ii $pkg "; then # dpkg-query rather than `dpkg -l | grep -q`: under pipefail, grep -q
# exiting on its first match kills dpkg with SIGPIPE and fails the pipeline,
# which reported installed packages as missing.
if [ "$(dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null)" = "install ok installed" ]; then
print_success "$desc ($pkg) is installed" print_success "$desc ($pkg) is installed"
else else
print_warning "$desc ($pkg) not installed - will be installed during setup" print_warning "$desc ($pkg) not installed - will be installed during setup"
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Type-check the modules listed in mypy-clean.txt (the mypy ratchet).
Most of src/ still has mypy errors, so CI can't require a clean `mypy src`.
Instead mypy-clean.txt lists the modules that *are* clean, and this script
fails if any of them regresses. When you make another module clean, add it to
the list; nothing ever comes off it.
Imports are followed silently: a listed module is checked against the types of
everything it imports, but errors inside those imported modules are not
reported, so a clean file isn't failed by an unlisted neighbour.
Usage:
python scripts/check_types.py # check the listed modules
python scripts/check_types.py --list # print the list and exit
Extra arguments after ``--`` are passed to mypy.
Exit status: 0 clean, 1 mypy errors, 2 a bad list (missing file, duplicate,
unsorted, or empty).
"""
import argparse
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
LIST_FILE = REPO_ROOT / "mypy-clean.txt"
def read_list(path: Path = LIST_FILE) -> list:
"""The listed paths, in file order, with comments and blank lines dropped."""
entries = []
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.split("#", 1)[0].strip()
if line:
entries.append(line)
return entries
def list_problems(entries: list, root: Path = REPO_ROOT) -> list:
"""Why the list can't be used as-is; empty when it is fine."""
problems = []
if not entries:
problems.append(f"{LIST_FILE.name} lists no modules")
seen = set()
for entry in entries:
if entry in seen:
problems.append(f"listed twice: {entry}")
seen.add(entry)
if "\\" in entry:
problems.append(f"use forward slashes: {entry}")
elif not (root / entry).is_file():
problems.append(f"listed but not found (renamed or deleted? update the list): {entry}")
if entries != sorted(entries):
problems.append(f"{LIST_FILE.name} is not sorted")
return problems
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--list", action="store_true", help="print the listed modules and exit")
parser.add_argument("mypy_args", nargs="*", help="extra mypy arguments (after --)")
args = parser.parse_args(argv)
entries = read_list()
problems = list_problems(entries)
if problems:
for problem in problems:
print(f"check_types: {problem}", file=sys.stderr)
return 2
if args.list:
print("\n".join(entries))
return 0
cmd = [
sys.executable, "-m", "mypy",
"--config-file", str(REPO_ROOT / "mypy.ini"),
"--follow-imports=silent",
*args.mypy_args,
*entries,
]
print(f"check_types: mypy on {len(entries)} modules from {LIST_FILE.name}", flush=True)
# This interpreter's mypy, fixed flags, and paths from the checked-in list.
result = subprocess.run(cmd, cwd=REPO_ROOT) # nosec B603 - list-form argv, no shell # nosemgrep
if result.returncode > 1: # mypy itself failed (bad config, crash)
return result.returncode
if result.returncode != 0:
print(
"check_types: a module on the mypy ratchet has type errors. Fix them "
f"(annotation-only where possible) rather than taking it off {LIST_FILE.name}.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+2
View File
@@ -6,6 +6,8 @@ This directory contains scripts and utilities for development and testing.
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories - **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware) - **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
- **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio)
- **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`)
## Usage ## Usage
+1 -1
View File
@@ -423,7 +423,7 @@ def main():
global _extra_dirs global _extra_dirs
_extra_dirs = args.extra_dir _extra_dirs = args.extra_dir
print(f"LEDMatrix Dev Preview Server") print("LEDMatrix Dev Preview Server")
print(f"Open http://{args.host}:{args.port} in your browser") print(f"Open http://{args.host}:{args.port} in your browser")
print(f"Plugin search dirs: {[str(d) for d in get_search_dirs()]}") print(f"Plugin search dirs: {[str(d) for d in get_search_dirs()]}")
print() print()
+17 -10
View File
@@ -18,21 +18,26 @@ system user.
permissions on the `assets/` tree so plugins can download and cache permissions on the `assets/` tree so plugins can download and cache
team logos, fonts, and other static content. team logos, fonts, and other static content.
- **`fix_cache_permissions.sh`** — Creates (if missing) and fixes - **`fix_cache_permissions.sh`** — Restores `/var/cache/ledmatrix/` to the
permissions on `/var/cache/ledmatrix/` and `~/.ledmatrix_cache/` of the shared `ledmatrix`-group setup by running
user running `sudo`, and creates `scripts/install/setup_cache.sh` (the same script the installer uses),
`/var/cache/ledmatrix/placeholder_logos/` for the sports plugins. It does and creates/fixes `~/.ledmatrix_cache/` of the user running `sudo`. It
not touch the cache manager's other fallbacks (`/opt/ledmatrix/cache`, does not touch the cache manager's other fallbacks
`$TMPDIR/ledmatrix_cache`). (`/opt/ledmatrix/cache`, `$TMPDIR/ledmatrix_cache`).
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins - **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
directory so both the root display service and the web service user directory so both the root display service and the web service user
can read and write plugin files (manifests, configs, requirements can read and write plugin files (manifests, configs, requirements
installs). installs).
- **`fix_web_permissions.sh`** — Fixes permissions on log files, - **`fix_web_permissions.sh`** — Adds you to the `systemd-journal` and
systemd journal access, and the sudoers entries the web interface `adm` groups so the web UI can read logs, and makes the project
needs to control the display service. directory yours again, keeping the root-owned sudo helpers
(`safe_plugin_rm.sh`, `safe_pip_install.sh`) and `config_secrets.json`
the way the installer leaves them. Run it as the web interface's user,
**without** `sudo` (it refuses to run as root and calls `sudo` itself).
It does not write sudoers rules; that is
`scripts/install/configure_web_sudo.sh`.
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root - **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
after checking it is the project's own or one under `plugin-repos/` or after checking it is the project's own or one under `plugin-repos/` or
@@ -67,7 +72,9 @@ Run these scripts only when:
sudo ./scripts/fix_perms/fix_cache_permissions.sh sudo ./scripts/fix_perms/fix_cache_permissions.sh
sudo ./scripts/fix_perms/fix_assets_permissions.sh sudo ./scripts/fix_perms/fix_assets_permissions.sh
sudo ./scripts/fix_perms/fix_plugin_permissions.sh sudo ./scripts/fix_perms/fix_plugin_permissions.sh
sudo ./scripts/fix_perms/fix_web_permissions.sh
# Run as the web interface's user, without sudo (it asks for sudo itself)
./scripts/fix_perms/fix_web_permissions.sh
``` ```
If you're not sure which one you need, run `fix_cache_permissions.sh` If you're not sure which one you need, run `fix_cache_permissions.sh`
+5 -5
View File
@@ -36,11 +36,12 @@ else
exit 1 exit 1
fi fi
# Set permissions to allow read/write for owner, group, and others (for root service user) # 777: read/write for owner, group and every other account. Root (the display
# Note: 777 allows root (service user) to write, which is necessary when service runs as root # service) does not need it -- root ignores mode bits -- so the "other" bits
# only matter to accounts that are neither $REAL_USER nor root.
echo "Setting permissions for assets directory..." echo "Setting permissions for assets directory..."
if sudo chmod -R 777 "$ASSETS_DIR"; then if sudo chmod -R 777 "$ASSETS_DIR"; then
echo "✓ Set assets directory permissions to 777 (writable by root service user)" echo "✓ Set assets directory permissions to 777"
else else
echo "✗ Failed to set assets directory permissions" echo "✗ Failed to set assets directory permissions"
exit 1 exit 1
@@ -70,8 +71,7 @@ for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
echo " - Current permissions:" echo " - Current permissions:"
ls -ld "$FULL_PATH" ls -ld "$FULL_PATH"
# Ensure the directory is writable by both the real user and root (service user) # Owned by the real user; 777 as above (root can write here regardless)
# Use 777 permissions to allow root (service) to write, or set group ownership
sudo chmod 777 "$FULL_PATH" sudo chmod 777 "$FULL_PATH"
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH" sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
+35 -59
View File
@@ -1,11 +1,23 @@
#!/bin/bash #!/bin/bash
# LEDMatrix Cache Permissions Fix Script # LEDMatrix Cache Permissions Fix Script
# This script fixes permissions on all known cache directories so they're writable by the daemon or current user #
# Also sets up placeholder logo directories for sports managers # /var/cache/ledmatrix is shared by the display service (root) and the web
# interface (your user) through the ledmatrix group: root:ledmatrix, 2775,
# files 660. scripts/install/setup_cache.sh is what sets that up (the
# installer's Step 2 runs it, and install_web_service.sh keeps the group), so
# this script runs it rather than applying a model of its own. It used to set
# the directory 777 and re-group it to your own group, replacing the ledmatrix
# group everything else relies on.
#
# It also repairs ~/.ledmatrix_cache, the cache manager's fallback when
# /var/cache/ledmatrix is unusable.
echo "Fixing LEDMatrix cache directory permissions..." echo "Fixing LEDMatrix cache directory permissions..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SETUP_CACHE="$SCRIPT_DIR/../install/setup_cache.sh"
# Get the real user (not root when running with sudo) # Get the real user (not root when running with sudo)
REAL_USER=${SUDO_USER:-$USER} REAL_USER=${SUDO_USER:-$USER}
# Resolve the home directory of the real user robustly # Resolve the home directory of the real user robustly
@@ -16,72 +28,36 @@ else
fi fi
REAL_GROUP=$(id -gn "$REAL_USER") REAL_GROUP=$(id -gn "$REAL_USER")
# Known cache directories for LEDMatrix. Use the actual user's home instead of a hard-coded path. echo ""
CACHE_DIRS=( echo "Checking cache directory: /var/cache/ledmatrix"
"/var/cache/ledmatrix" if [ -f "$SETUP_CACHE" ]; then
"$REAL_HOME/.ledmatrix_cache" bash "$SETUP_CACHE"
) else
echo " ✗ $SETUP_CACHE not found; /var/cache/ledmatrix left unchanged."
fi
for CACHE_DIR in "${CACHE_DIRS[@]}"; do CACHE_DIR="$REAL_HOME/.ledmatrix_cache"
echo "" echo ""
echo "Checking cache directory: $CACHE_DIR" echo "Checking cache directory: $CACHE_DIR"
if [ ! -d "$CACHE_DIR" ]; then if [ ! -d "$CACHE_DIR" ]; then
echo " - Directory does not exist. Creating it..." echo " - Directory does not exist. Creating it..."
sudo mkdir -p "$CACHE_DIR" sudo mkdir -p "$CACHE_DIR"
fi
echo " - Current permissions:"
ls -ld "$CACHE_DIR"
echo " - Fixing permissions..."
# Make directory writable by services regardless of user context
sudo chmod 777 "$CACHE_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
echo " - Updated permissions:"
ls -ld "$CACHE_DIR"
echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
else
echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
fi
echo " - Permissions fix complete for $CACHE_DIR."
done
# Set up placeholder logos directory for sports managers
echo ""
echo "Setting up placeholder logos directory for sports managers..."
PLACEHOLDER_DIR="/var/cache/ledmatrix/placeholder_logos"
if [ ! -d "$PLACEHOLDER_DIR" ]; then
echo "Creating placeholder logos directory: $PLACEHOLDER_DIR"
sudo mkdir -p "$PLACEHOLDER_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
sudo chmod 777 "$PLACEHOLDER_DIR"
else
echo "Placeholder logos directory already exists: $PLACEHOLDER_DIR"
sudo chmod 777 "$PLACEHOLDER_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
fi fi
echo " - Current permissions:" echo " - Current permissions:"
ls -ld "$PLACEHOLDER_DIR" ls -ld "$CACHE_DIR"
echo " - Fixing permissions..."
sudo chmod 777 "$CACHE_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
echo " - Updated permissions:"
ls -ld "$CACHE_DIR"
echo " - Testing write access as $REAL_USER..." echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$PLACEHOLDER_DIR"; then if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
echo " ✓ Placeholder logos directory is writable by $REAL_USER" echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
else else
echo " ✗ Placeholder logos directory is not writable by $REAL_USER" echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
fi
# Test with daemon user (which the system might run as)
if sudo -u daemon test -w "$PLACEHOLDER_DIR" 2>/dev/null; then
echo " ✓ Placeholder logos directory is writable by daemon user"
else
echo " ✗ Placeholder logos directory is not writable by daemon user"
fi fi
echo " - Permissions fix complete for $CACHE_DIR."
echo "" echo ""
echo "All cache directory permission fixes attempted." echo "All cache directory permission fixes attempted."
echo "If you still see errors, check which user is running the LEDMatrix service and ensure it matches the owner above." echo "If you still see errors, check which user is running the LEDMatrix service and ensure it matches the owner above."
echo ""
echo "The system will now create placeholder logos in:"
echo " $PLACEHOLDER_DIR"
echo "This should eliminate the permission denied warnings for sports logos."
+6 -5
View File
@@ -51,9 +51,10 @@ fi
echo "Setting ownership to root:$ACTUAL_USER..." echo "Setting ownership to root:$ACTUAL_USER..."
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR" sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
# Set directory permissions (775: rwxrwxr-x) # Set directory permissions (2775: rwxrwsr-x)
# Root: read/write/execute, Group (ACTUAL_USER): read/write/execute, Others: read/execute # Owner (root) and group (ACTUAL_USER): read/write/execute, others: read/execute.
echo "Setting directory permissions to 2775 (rwxrwxr-x + sticky bit)..." # The setgid bit makes new entries inherit the ACTUAL_USER group.
echo "Setting directory permissions to 2775 (rwxrwsr-x, setgid)..."
find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \; find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \;
# Set file permissions (664: rw-rw-r--) # Set file permissions (664: rw-rw-r--)
@@ -71,7 +72,7 @@ fi
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..." echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR" sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
echo "Setting plugin-repos directory permissions to 2775 (rwxrwxr-x + sticky bit)..." echo "Setting plugin-repos directory permissions to 2775 (rwxrwsr-x, setgid)..."
find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \; find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \;
echo "Setting plugin-repos file permissions to 664..." echo "Setting plugin-repos file permissions to 664..."
@@ -87,7 +88,7 @@ echo "plugin-repos/:"
ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)" ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)"
echo "" echo ""
echo "Permissions summary:" echo "Permissions summary:"
echo "- Root service: Can read/write plugins (for PWM hardware access)" echo "- Root service: Can read/write plugins (as root it needs no permission bits)"
echo "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)" echo "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)"
echo "- Others: Can read plugins" echo "- Others: Can read plugins"
+51 -5
View File
@@ -25,8 +25,9 @@ echo ""
echo "This script will:" echo "This script will:"
echo "1. Add the web user to the 'systemd-journal' group for log access" echo "1. Add the web user to the 'systemd-journal' group for log access"
echo "2. Add the web user to the 'adm' group for additional system access" echo "2. Add the web user to the 'adm' group for additional system access"
echo "3. Configure sudoers for passwordless access to system commands" echo "3. Make the project directory yours again, keeping the root-owned sudo"
echo "4. Set proper file permissions" echo " helpers and config_secrets.json as the installer leaves them"
echo " (sudoers rules are configure_web_sudo.sh's job, not this script's)"
echo "" echo ""
# Ask for confirmation # Ask for confirmation
@@ -62,6 +63,51 @@ else
echo "✗ Failed to set project ownership" echo "✗ Failed to set project ownership"
fi fi
# The chown above also takes back two kinds of file that first_time_install.sh
# deliberately keeps from the web user. Put them back the way the installer
# leaves them (its Steps 11 and 11.1), whether or not the chown succeeded.
#
# 1. The helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
# (scripts/install/lib_sudoers.sh). A copy the web user owns is a root shell
# for whoever can edit it, so they stay root-owned and writable by root only.
# Keep this list in step with the installer's Step 11.1 loop;
# test/test_web_sudoers_installers_agree.py checks both against the grants.
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
HELPER_PATH="$PROJECT_DIR/scripts/fix_perms/$helper"
if [ -f "$HELPER_PATH" ]; then
if sudo chown root:root "$HELPER_PATH" && sudo chmod 755 "$HELPER_PATH"; then
echo "✓ $helper is root-owned again (sudo runs it as root)"
else
echo "⚠ Could not make $HELPER_PATH root-owned, mode 755."
echo " Fix it by hand: sudo chown root:root $HELPER_PATH && sudo chmod 755 $HELPER_PATH"
fi
fi
done
# 2. config_secrets.json: owned by the account ledmatrix-web.service runs as,
# group ledmatrix, mode 640 -- the same owner, group and mode as the
# installer's Step 11 gives it.
SECRETS_FILE="$PROJECT_DIR/config/config_secrets.json"
if [ -f "$SECRETS_FILE" ]; then
SECRETS_OWNER=""
if [ -f /etc/systemd/system/ledmatrix-web.service ]; then
SECRETS_OWNER=$(grep -m1 "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || true)
fi
SECRETS_OWNER="${SECRETS_OWNER:-$WEB_USER}"
if getent group ledmatrix >/dev/null 2>&1; then
SECRETS_OWNERSHIP="$SECRETS_OWNER:ledmatrix"
else
# No ledmatrix group means the installer never ran; keep the chown's group.
SECRETS_OWNERSHIP="$SECRETS_OWNER"
fi
if sudo chown "$SECRETS_OWNERSHIP" "$SECRETS_FILE" && sudo chmod 640 "$SECRETS_FILE"; then
echo "✓ config_secrets.json restored to $SECRETS_OWNERSHIP, mode 640"
else
echo "⚠ Could not restore $SECRETS_FILE to $SECRETS_OWNERSHIP, mode 640."
echo " Fix it by hand: sudo chown $SECRETS_OWNERSHIP $SECRETS_FILE && sudo chmod 640 $SECRETS_FILE"
fi
fi
# Set proper permissions for config files # Set proper permissions for config files
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
echo "✓ Set config file permissions" echo "✓ Set config file permissions"
@@ -86,7 +132,7 @@ echo "Step 5: Testing sudo access..."
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
echo "✓ Sudo access test passed" echo "✓ Sudo access test passed"
else else
echo "⚠ Sudo access test failed - you may need to run configure_web_sudo.sh" echo "⚠ Sudo access test failed - you may need to run scripts/install/configure_web_sudo.sh"
fi fi
echo "" echo ""
@@ -101,5 +147,5 @@ echo ""
echo "After logging back in, test journal access with:" echo "After logging back in, test journal access with:"
echo " journalctl --no-pager --lines=5" echo " journalctl --no-pager --lines=5"
echo "" echo ""
echo "If you still have sudo issues, run:" echo "If you still have sudo issues, run (as this user, without sudo):"
echo " ./configure_web_sudo.sh" echo " $PROJECT_DIR/scripts/install/configure_web_sudo.sh"
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Soak a running display and report how often moving frames reached the panel late.
Runs NEXT TO the display service, as any user: it only reads the stats file the
service writes (src/common/frame_timing.py) at the start and end of the run and
reports the difference. Nothing is stopped, restarted or drawn.
# 10 minutes, as the display is now
python3 scripts/frame_soak.py
# the same with the web preview open (the preview's PNG encodes are one of
# the things that used to make the render loop miss refreshes)
python3 scripts/frame_soak.py --preview
# quick look at the totals since the service started
python3 scripts/frame_soak.py --show
# keep the report for a before/after comparison
python3 scripts/frame_soak.py --duration 600 --json soak-before.json
Exit status: 0 when the late-frame rate is within ``--max-late-pct``, 1 when it
is not, 2 when there was nothing to measure (no stats file, the service
restarted mid-run, or nothing scrolled).
What the numbers mean
---------------------
late frames frames that reached the panel one or more refreshes after they
were due -- the panel showed the previous frame again, which on
a moving strip is a visible hitch. This is the pass/fail number.
freezes gaps of 250ms+ inside a scroll: recomposes, plugin handovers,
blocking calls on the render thread. Reported, not failed on,
since some are handovers between plugins rather than faults.
blit copying the frame into the matrix canvas (rgbmatrix SetImage).
Grows with width x height x pwm_bits.
wait blocked in SwapOnVSync, i.e. slack before the refresh.
work everything else between two frames: drawing, scrolling, and
waiting for the GIL.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.frame_timing import ( # noqa: E402
BUCKET_COUNT,
SCHEMA_VERSION,
default_stats_path,
)
#: Touched by the web UI while someone has the preview open; a fresh marker
#: puts the display service's snapshot writer at full rate. Same path as
#: DisplayManager._viewer_marker_path.
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
#: A stats file not rewritten for this long means nothing is being presented.
STALE_SECONDS = 30.0
def load(path: str) -> Optional[Dict[str, Any]]:
try:
with open(path, encoding="utf-8") as handle:
stats = json.load(handle)
except (OSError, ValueError):
return None
if not isinstance(stats, dict) or stats.get("version") != SCHEMA_VERSION:
return None
return stats
def _histogram(stats: Dict[str, Any], name: str) -> Dict[int, int]:
raw = (stats.get("histograms") or {}).get(name) or {}
return {int(k): int(v) for k, v in raw.items()}
def diff(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
"""What happened between two snapshots of the same process."""
tb, ta = before["totals"], after["totals"]
totals = {}
for key, value in ta.items():
if isinstance(value, dict):
totals[key] = {k: v - tb.get(key, {}).get(k, 0)
for k, v in value.items()}
elif key == "worst_interval_ms":
# A running maximum can't be differenced; it is reported as the
# worst since the service started.
totals[key] = value
else:
totals[key] = value - tb.get(key, 0)
histograms = {}
for name in (after.get("histograms") or {}):
hb, ha = _histogram(before, name), _histogram(after, name)
histograms[name] = {k: v - hb.get(k, 0) for k, v in ha.items()
if v - hb.get(k, 0) > 0}
return {"totals": totals, "histograms": histograms,
"seconds": after["updated"] - before["updated"]}
def percentiles(histogram: Dict[int, int], bucket_ms: float) -> Dict[str, Any]:
"""p50/p95/p99/max from a sparse histogram, as each bucket's upper edge."""
count = sum(histogram.values())
if not count:
return {}
out = {}
targets = {"p50": 0.50, "p95": 0.95, "p99": 0.99}
running = 0
for index in sorted(histogram):
running += histogram[index]
for name, fraction in list(targets.items()):
if running >= fraction * count:
out[name] = _edge(index, bucket_ms)
del targets[name]
out["max"] = _edge(max(histogram), bucket_ms)
return out
def _edge(index: int, bucket_ms: float):
if index >= BUCKET_COUNT - 1:
return f">={index * bucket_ms:g}"
return round((index + 1) * bucket_ms, 2)
def build_report(before, after, preview: bool) -> Dict[str, Any]:
delta = diff(before, after)
totals = delta["totals"]
frames = totals["scroll_frames"]
# The rates are over frames judged against a known refresh period. Stats
# from a recorder that predates the count fall back to every frame.
timed = totals.get("timed_frames", frames) if "timed_frames" in totals else frames
hours = delta["seconds"] / 3600.0 if delta["seconds"] > 0 else 0.0
bucket_ms = after.get("bucket_ms", 0.25)
report = {
"seconds": round(delta["seconds"], 1),
"preview": preview,
"info": after.get("info"),
"binding_releases_gil": after.get("binding_releases_gil"),
"measured_refresh_hz": after.get("measured_refresh_hz"),
"scroll_frames": frames,
"static_frames": totals["static_frames"],
"late_frames": totals["late_frames"],
"timed_frames": timed,
"late_pct": round(100.0 * totals["late_frames"] / timed, 3) if timed else None,
"missed_refreshes": totals["missed_refreshes"],
"late_by": totals["late_by"],
"early_frames": totals.get("early_frames", 0),
"early_pct": (round(100.0 * totals.get("early_frames", 0) / timed, 3)
if timed else None),
"freeze_by": totals.get("freeze_by", {}),
"freezes": totals["freezes"],
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
"freeze_seconds": round(totals["freeze_seconds"], 2),
"worst_interval_ms": (round(totals["worst_interval_ms"], 1)
if totals["worst_interval_ms"] else None),
"timing_ms": {name: percentiles(h, bucket_ms)
for name, h in delta["histograms"].items()},
}
# The rate the panel held while rendering: the typical frame's interval
# per refresh held. A few percent under the idle rate is normal (the Pi is
# bit-banging the panel and pushing frames at once); a widening gap between
# the two is a render-cost regression even when nothing is late.
typical = (report["timing_ms"].get("interval_per_hold") or {}).get("p50")
# percentiles() reports a bucket's upper edge; the midpoint is the better
# estimate, and half a 0.25ms bucket is already ~1% at 100Hz -- the size
# of the idle-vs-held gap this number exists to show.
if isinstance(typical, (int, float)) and typical > bucket_ms / 2:
report["held_refresh_hz"] = round(1000.0 / (typical - bucket_ms / 2), 1)
else:
report["held_refresh_hz"] = None
return report
def print_report(report: Dict[str, Any], limit: float) -> None:
info = report.get("info") or {}
size = "{}x{}".format(
(info.get("cols") or 0) * (info.get("chain_length") or 1),
(info.get("rows") or 0) * (info.get("parallel") or 1))
gil = {True: "releases the GIL", False: "STOCK (holds the GIL in SwapOnVSync)",
None: "unknown"}[report.get("binding_releases_gil")]
print(f"Rig {info.get('pi_model') or 'unknown'}")
print(f"Panel {size} chain {info.get('chain_length')} x parallel "
f"{info.get('parallel')} pwm_bits {info.get('pwm_bits')} "
f"slowdown {info.get('gpio_slowdown')} mapping {info.get('hardware_mapping')}")
print(f"Refresh {report.get('measured_refresh_hz') or '?'} Hz measured, "
f"cap {info.get('limit_refresh_rate_hz')}")
print(f"Binding {gil}")
print(f"Run {report['seconds']:.0f}s, preview "
f"{'open (simulated)' if report['preview'] else 'as-is'}")
print()
frames = report["scroll_frames"]
print(f"Scrolling frames {frames}")
if frames:
late_by = report["late_by"]
print(f"Late frames {report['late_frames']} ({report['late_pct']}%)"
f" missed refreshes {report['missed_refreshes']}"
f" [by 1: {late_by['1']}, 2: {late_by['2']}, "
f"3-5: {late_by['3-5']}, 6+: {late_by['6+']}]")
if report["early_frames"]:
print(f"Early frames {report['early_frames']} "
f"({report['early_pct']}%) swaps returned a refresh early")
print(f"Freezes >=250ms {report['freezes']}"
f" ({report['freezes_per_hour']}/h, {report['freeze_seconds']}s total)"
f" worst gap since start {report['worst_interval_ms'] or '-'} ms")
if report["freezes"]:
print(" by length: " + ", ".join(
f"{k}: {v}" for k, v in report["freeze_by"].items()))
print()
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
for name in ("blit", "wait", "work", "interval_per_hold"):
row = report["timing_ms"].get(name) or {}
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
for k in ("p50", "p95", "p99", "max")))
print()
if report["late_pct"] is None:
print("RESULT nothing scrolled - no verdict")
elif not locked(report, limit):
ceiling = refresh_ceiling(report)
if (report.get("early_pct") or 0.0) > limit:
why = (f"{report['early_pct']}% of frames came a refresh early, so the "
"swaps were not waiting for the panel")
else:
why = (f"frames arrived at {report['measured_refresh_hz']}Hz, faster than "
f"the panel can refresh ({ceiling:g}Hz)")
print(f"RESULT FAIL NOT LOCKED: {why}, and the late count means nothing")
elif report["late_pct"] <= limit:
print(f"RESULT PASS {report['late_pct']}% late <= {limit}%")
else:
print(f"RESULT FAIL {report['late_pct']}% late > {limit}%")
#: How far over the panel's rate frames may arrive before the loop cannot have
#: been waiting for it. The margin covers the refresh wandering a little.
CEILING_MARGIN = 1.05
def refresh_ceiling(report: Dict[str, Any]) -> Optional[float]:
"""The fastest the panel can refresh, as far as this run knows.
The benchmark measures it (``idle_refresh_hz``); the service only knows its
cap. With neither, there is no ceiling to check against.
"""
idle = report.get("idle_refresh_hz")
if idle:
return float(idle)
cap = (report.get("info") or {}).get("limit_refresh_rate_hz")
try:
cap = float(cap)
except (TypeError, ValueError):
return None
return cap if cap > 0 else None
def locked(report: Dict[str, Any], limit: float) -> bool:
"""Whether the loop was paced by the panel at all.
Two ways it is not. Frames a whole refresh early mean some swaps did not
wait. And a loop that never waited at all -- the dirty-tracking skip firing
mid-scroll let one free-run at 827fps -- looks self-consistent to a refresh
estimate taken from its own frames, so nothing registers as early; what
gives it away is a "refresh" faster than the panel can physically do.
"""
if (report.get("early_pct") or 0.0) > limit:
return False
ceiling = refresh_ceiling(report)
measured = report.get("measured_refresh_hz")
return not (ceiling and measured and measured > ceiling * CEILING_MARGIN)
def passed(report: Dict[str, Any], limit: float) -> bool:
return (report["late_pct"] is not None and locked(report, limit)
and report["late_pct"] <= limit)
def touch_marker() -> bool:
try:
with open(VIEWER_MARKER, "a"):
pass
os.utime(VIEWER_MARKER, None)
return True
except OSError:
return False
def wait_for_fresh(path: str, timeout: float) -> Optional[Dict[str, Any]]:
"""The first snapshot written after now, so both ends of the run are exact."""
first = load(path)
deadline = time.time() + timeout
while time.time() < deadline:
current = load(path)
if current and (first is None or current["updated"] != first["updated"]):
return current
time.sleep(0.5)
return None
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--duration", type=float, default=600.0,
help="seconds to soak (default 600)")
parser.add_argument("--preview", action="store_true",
help="keep the web-preview viewer marker fresh, as an "
"open preview tab does")
parser.add_argument("--max-late-pct", type=float, default=0.1,
help="fail above this percentage of late frames (default 0.1)")
parser.add_argument("--stats", default=default_stats_path(),
help="stats file written by the display service")
parser.add_argument("--json", metavar="PATH",
help="also write the report as JSON")
parser.add_argument("--show", action="store_true",
help="print totals since the service started and exit")
args = parser.parse_args(argv)
current = load(args.stats)
if current is None:
print(f"No frame stats at {args.stats}. Is the display service running a "
"build with frame timing, and has anything scrolled for ~10s?",
file=sys.stderr)
return 2
if time.time() - current["updated"] > STALE_SECONDS:
print(f"Frame stats are {time.time() - current['updated']:.0f}s old: nothing "
"has been presented recently (static screen, or the service stopped).",
file=sys.stderr)
if not args.show:
return 2
if args.show:
empty = json.loads(json.dumps(current))
for key, value in empty["totals"].items():
empty["totals"][key] = ({k: 0 for k in value} if isinstance(value, dict)
else 0)
empty["histograms"] = {}
empty["updated"] = current["started"]
report = build_report(empty, current, preview=False)
print_report(report, args.max_late_pct)
return 0
if args.preview and not touch_marker():
print(f"Cannot touch {VIEWER_MARKER}; run as the web service's user to "
"simulate an open preview.", file=sys.stderr)
return 2
print(f"Waiting for a fresh baseline from {args.stats} ...", flush=True)
before = wait_for_fresh(args.stats, timeout=60.0)
if before is None:
print("The stats file stopped updating.", file=sys.stderr)
return 2
end = time.time() + args.duration
next_progress = time.time() + 60.0
while time.time() < end:
if args.preview:
touch_marker()
time.sleep(1.0)
if time.time() >= next_progress:
now = load(args.stats)
if now and now.get("pid") == before["pid"]:
done = now["totals"]["scroll_frames"] - before["totals"]["scroll_frames"]
late = now["totals"]["late_frames"] - before["totals"]["late_frames"]
print(f" {int(end - time.time())}s left: {done} scrolling frames, "
f"{late} late", flush=True)
next_progress += 60.0
after = wait_for_fresh(args.stats, timeout=60.0)
if after is None:
print("The stats file stopped updating during the run.", file=sys.stderr)
return 2
if after.get("pid") != before.get("pid"):
print("The display service restarted during the run; results discarded.",
file=sys.stderr)
return 2
report = build_report(before, after, preview=args.preview)
print()
print_report(report, args.max_late_pct)
if args.json:
with open(args.json, "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2)
if report["late_pct"] is None:
return 2
return 0 if passed(report, args.max_late_pct) else 1
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -19,6 +19,21 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
(the user who runs the script, i.e. the one you installed LEDMatrix as; (the user who runs the script, i.e. the one you installed LEDMatrix as;
there is no `ledmatrix` system user) the passwordless `nmcli` and related there is no `ledmatrix` system user) the passwordless `nmcli` and related
WiFi permissions the web interface needs WiFi permissions the web interface needs
- **`install_dns_fix.sh`** - Optional. Installs `ledmatrix-dns-fix.service`,
which adds `options single-request` to the resolver when API calls time
out (see `systemd/README.md`)
- **`install_mqtt_bridge.sh`** - Optional. Installs the Home Assistant MQTT
bridge service (see `integrations/mqtt_bridge/README.md`)
Libraries (sourced, not run):
- **`lib_sudoers.sh`** - The web interface's sudo allow-list
(`/etc/sudoers.d/ledmatrix_web`), shared by `first_time_install.sh` and
`configure_web_sudo.sh`
- **`lib_systemd_render.sh`** - `sed_escape_replacement`, used by every
script that renders a unit from `systemd/*.service`
- **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++
build on low-memory Pis (`first_time_install.sh` Step 6)
## Usage ## Usage
+63 -68
View File
@@ -26,17 +26,31 @@ fi
# Get the full paths to commands and validate each one # Get the full paths to commands and validate each one
MISSING_CMDS=() MISSING_CMDS=()
PYTHON_PATH=$(command -v python3) || true # Full path of a command, also looking in the sbin directories. This script runs
SYSTEMCTL_PATH=$(command -v systemctl) || true # as the web user, whose PATH usually lacks /usr/sbin and /sbin -- where reboot
REBOOT_PATH=$(command -v reboot) || true # and poweroff live -- so `command -v` alone silently dropped their rules.
POWEROFF_PATH=$(command -v poweroff) || true find_command() {
BASH_PATH=$(command -v bash) || true local found
JOURNALCTL_PATH=$(command -v journalctl) || true found=$(command -v "$1" 2>/dev/null) && { printf '%s\n' "$found"; return 0; }
for dir in /usr/sbin /sbin /usr/bin /bin; do
if [ -x "$dir/$1" ]; then
printf '%s\n' "$dir/$1"
return 0
fi
done
return 1
}
SYSTEMCTL_PATH=$(find_command systemctl) || true
REBOOT_PATH=$(find_command reboot) || true
POWEROFF_PATH=$(find_command poweroff) || true
BASH_PATH=$(find_command bash) || true
JOURNALCTL_PATH=$(find_command journalctl) || true
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh" SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh" SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
# Validate required commands (systemctl, bash, python3 are essential) # Validate required commands (systemctl and bash are essential)
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do for CMD_NAME in SYSTEMCTL_PATH BASH_PATH; do
CMD_VAL="${!CMD_NAME}" CMD_VAL="${!CMD_NAME}"
if [ -z "$CMD_VAL" ]; then if [ -z "$CMD_VAL" ]; then
MISSING_CMDS+=("$CMD_NAME") MISSING_CMDS+=("$CMD_NAME")
@@ -59,8 +73,17 @@ if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
exit 1 exit 1
fi fi
# The rules are shared with first_time_install.sh (Step 10) so the two cannot
# drift apart; add or remove a grant in lib_sudoers.sh, not here.
SUDOERS_LIB="$PROJECT_DIR/lib_sudoers.sh"
if [ ! -f "$SUDOERS_LIB" ]; then
echo "Error: Sudoers rules library not found: $SUDOERS_LIB" >&2
exit 1
fi
# shellcheck source=scripts/install/lib_sudoers.sh
. "$SUDOERS_LIB"
echo "Command paths:" echo "Command paths:"
echo " Python: $PYTHON_PATH"
echo " Systemctl: $SYSTEMCTL_PATH" echo " Systemctl: $SYSTEMCTL_PATH"
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}" echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}" echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
@@ -69,62 +92,24 @@ echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
echo " Safe plugin rm: $SAFE_RM_PATH" echo " Safe plugin rm: $SAFE_RM_PATH"
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH" echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
# Create a temporary sudoers file # Create a temporary sudoers file. A predictable name in a world-writable
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$" # directory is a symlink target, and these rules end up in /etc/sudoers.d, so
# let mktemp pick the name; the trap removes it however the script ends.
TEMP_SUDOERS=$(mktemp "${TMPDIR:-/tmp}/ledmatrix_web_sudoers.XXXXXX") || {
echo "Error: could not create a temporary file" >&2
exit 1
}
trap 'rm -f "$TEMP_SUDOERS"' EXIT
{ web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \
echo "# LED Matrix Web Interface passwordless sudo configuration" "$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS"
echo "# This allows the web interface user to run specific commands without a password"
echo ""
echo "# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface"
# Optional: reboot/poweroff (non-critical — skip if not found)
if [ -n "$REBOOT_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
fi
if [ -n "$POWEROFF_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
fi
# Required: systemctl
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
# Optional: journalctl (non-critical — skip if not found)
#
# NOEXEC, matching first_time_install.sh. These rules end in a wildcard and
# journalctl starts a pager, so without it the caller can reach a shell:
# less runs "!command" as the user the pager belongs to, which here is
# root. NOEXEC stops the granted command executing anything of its own.
if [ -n "$JOURNALCTL_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *"
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *"
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *"
fi
echo ""
echo "# Allow web user to remove plugin directories via vetted helper script"
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
echo ""
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
echo "# helper script, so packages are visible to root-run ledmatrix.service"
echo "# (not just the web interface's own user). The helper validates the target"
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
} > "$TEMP_SUDOERS"
# Never offer to install rules we have not parsed. A malformed drop-in in # Never offer to install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user. # /etc/sudoers.d makes sudo refuse every command for every user.
# visudo lives in /usr/sbin, which is not on every user's PATH.
if ! command -v visudo >/dev/null 2>&1 && [ -x /usr/sbin/visudo ]; then
PATH="$PATH:/usr/sbin"
fi
if command -v visudo >/dev/null 2>&1; then if command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
echo "" echo ""
@@ -134,6 +119,8 @@ if command -v visudo >/dev/null 2>&1; then
rm -f "$TEMP_SUDOERS" rm -f "$TEMP_SUDOERS"
exit 1 exit 1
fi fi
else
echo "⚠ visudo not found; the rules below have not been validated"
fi fi
echo "" echo ""
@@ -162,7 +149,7 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 0 exit 0
fi fi
# Apply the configuration using visudo # Apply the configuration
echo "Applying sudoers configuration..." echo "Applying sudoers configuration..."
# Harden the helper script: root-owned, not writable by web user # Harden the helper script: root-owned, not writable by web user
echo "Hardening safe_plugin_rm.sh ownership..." echo "Hardening safe_plugin_rm.sh ownership..."
@@ -181,21 +168,29 @@ if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
fi fi
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
# sudo reads /etc/sudoers.d files that are root-owned and not writable by
# group or other; 440 is the mode visudo and first_time_install.sh use.
if ! sudo chmod 440 /etc/sudoers.d/ledmatrix_web; then
echo "Warning: could not set mode 440 on /etc/sudoers.d/ledmatrix_web"
fi
echo "Configuration applied successfully!" echo "Configuration applied successfully!"
echo "" echo ""
echo "Testing sudo access..." echo "Testing sudo access..."
# Test a few commands # Ask sudo whether two of the new rules let this user in without a
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then # password. `sudo -l CMD` answers from the rules without running CMD, so
# this does not depend on whether ledmatrix.service is running, and it
# tests commands the rules actually grant.
if sudo -n -l "$SYSTEMCTL_PATH" status ledmatrix.service > /dev/null 2>&1; then
echo "✓ systemctl status ledmatrix.service - OK" echo "✓ systemctl status ledmatrix.service - OK"
else else
echo "✗ systemctl status ledmatrix.service - Failed" echo "✗ systemctl status ledmatrix.service - not allowed without a password"
fi fi
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then if sudo -n -l "$BASH_PATH" "$SAFE_RM_PATH" "$PROJECT_ROOT/plugin-repos/example" > /dev/null 2>&1; then
echo "✓ File access test - OK" echo "✓ safe_plugin_rm.sh helper - OK"
else else
echo "✗ File access test - Failed" echo "✗ safe_plugin_rm.sh helper - not allowed without a password"
fi fi
echo "" echo ""
+27 -4
View File
@@ -144,6 +144,11 @@ $WEB_USER ALL=(ALL) NOPASSWD: $MKDIR_PATH -p /etc/NetworkManager/dnsmasq-shared.
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf $WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf $WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf $WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
# The same captive-portal DNS drop-in for NetworkManager's shared-mode dnsmasq
# (wifi_manager._write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf),
# exact paths.
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/ledmatrix-nm-dnsmasq.conf /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
EOF EOF
echo "Generated sudoers configuration:" echo "Generated sudoers configuration:"
@@ -151,6 +156,21 @@ echo "--------------------------------"
cat "$TEMP_SUDOERS" cat "$TEMP_SUDOERS"
echo "--------------------------------" echo "--------------------------------"
# Never install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
# headless Pi leaves no way in at all. first_time_install.sh and
# configure_web_sudo.sh check their rules the same way.
if command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
echo "✗ The generated sudoers rules did not parse:" >&2
visudo -c -f "$TEMP_SUDOERS" >&2 || true
echo " Leaving $SUDOERS_FILE unchanged." >&2
exit 1
fi
else
echo "⚠ visudo not found; installing the sudoers rules unvalidated"
fi
# Apply the sudoers configuration # Apply the sudoers configuration
echo "" echo ""
echo "Applying sudoers configuration..." echo "Applying sudoers configuration..."
@@ -213,11 +233,14 @@ rm -f "$TEMP_POLKIT"
echo "" echo ""
echo "Step 3: Testing permissions..." echo "Step 3: Testing permissions..."
# Test sudo access # Ask sudo whether one of the new rules lets this user in without a password.
if sudo -n "$NMCLI_PATH" device status > /dev/null 2>&1; then # `sudo -l CMD` answers from the rules without running CMD, so the radio is
echo "✓ nmcli device status - OK" # left alone. (This used to run `nmcli device status`, which is not granted,
# so it could only ever report a failure.)
if sudo -n -l "$NMCLI_PATH" radio wifi on > /dev/null 2>&1; then
echo "✓ nmcli radio wifi on - OK"
else else
echo "✗ nmcli device status - Failed (this is expected if not connected)" echo "✗ nmcli radio wifi on - not allowed without a password"
fi fi
echo "" echo ""
+6 -1
View File
@@ -10,6 +10,10 @@
set -e set -e
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd) PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
# shellcheck source=scripts/install/lib_systemd_render.sh
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
SERVICE_NAME="ledmatrix-dns-fix" SERVICE_NAME="ledmatrix-dns-fix"
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service" UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service" UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
@@ -34,7 +38,8 @@ fi
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh" chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
echo "Installing $UNIT_DEST..." echo "Installing $UNIT_DEST..."
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \ ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
| $SUDO tee "$UNIT_DEST" > /dev/null | $SUDO tee "$UNIT_DEST" > /dev/null
# Order ledmatrix.service after the fix. `Before=` in the unit itself only # Order ledmatrix.service after the fix. `Before=` in the unit itself only
+6 -1
View File
@@ -9,6 +9,10 @@
set -e set -e
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd) PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
# shellcheck source=scripts/install/lib_systemd_render.sh
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge" BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
SERVICE_NAME="ledmatrix-mqtt-bridge" SERVICE_NAME="ledmatrix-mqtt-bridge"
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service" UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
@@ -40,7 +44,8 @@ python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt" || python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
echo "Installing $UNIT_DEST..." echo "Installing $UNIT_DEST..."
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \ ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
| $SUDO tee "$UNIT_DEST" > /dev/null | $SUDO tee "$UNIT_DEST" > /dev/null
$SYSTEMCTL_CMD daemon-reload $SYSTEMCTL_CMD daemon-reload
+5
View File
@@ -51,20 +51,25 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
# Install packages automatically (no prompt) # Install packages automatically (no prompt)
# Use apt directly if running as root, otherwise use sudo # Use apt directly if running as root, otherwise use sudo
PACKAGES_OK=true
if [ "$EUID" -eq 0 ]; then if [ "$EUID" -eq 0 ]; then
apt update || echo "⚠ apt update failed, continuing anyway..." apt update || echo "⚠ apt update failed, continuing anyway..."
apt install -y "${MISSING_PACKAGES[@]}" || { apt install -y "${MISSING_PACKAGES[@]}" || {
PACKAGES_OK=false
echo "⚠ Package installation failed, but continuing with WiFi monitor setup" echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}" echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}"
} }
else else
sudo apt update || echo "⚠ apt update failed, continuing anyway..." sudo apt update || echo "⚠ apt update failed, continuing anyway..."
sudo apt install -y "${MISSING_PACKAGES[@]}" || { sudo apt install -y "${MISSING_PACKAGES[@]}" || {
PACKAGES_OK=false
echo "⚠ Package installation failed, but continuing with WiFi monitor setup" echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}" echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}"
} }
fi fi
if [ "$PACKAGES_OK" = true ]; then
echo "✓ Package installation completed" echo "✓ Package installation completed"
fi
fi fi
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than # Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
#
# The web interface's passwordless-sudo allow-list, /etc/sudoers.d/ledmatrix_web.
#
# Sourced by first_time_install.sh (Step 10) and
# scripts/install/configure_web_sudo.sh. Both used to carry their own copy of
# these rules, and the copies drifted: one granted safe_pip_install.sh and the
# other did not. Each caller still owns its own validate (visudo -c) / install /
# confirm flow; this file only prints the rules.
#
# Add or remove a grant here and nowhere else.
# web_sudoers_rules WEB_USER PROJECT_ROOT SYSTEMCTL_PATH BASH_PATH REBOOT_PATH POWEROFF_PATH JOURNALCTL_PATH
#
# Print the ledmatrix_web sudoers rules to stdout.
#
# SYSTEMCTL_PATH and BASH_PATH are required, and the caller must make sure they
# are not empty: `visudo -c` does not catch every such rule (with an empty
# BASH_PATH the helper rules still parse, granting the script itself).
# first_time_install.sh stops on a failed `which`; configure_web_sudo.sh checks
# them before calling this.
# REBOOT_PATH, POWEROFF_PATH and JOURNALCTL_PATH are optional: pass "" and
# their rules are left out.
web_sudoers_rules() {
local WEB_USER="${1:-}"
local PROJECT_ROOT="${2:-}"
local SYSTEMCTL_PATH="${3:-}"
local BASH_PATH="${4:-}"
local REBOOT_PATH="${5:-}"
local POWEROFF_PATH="${6:-}"
local JOURNALCTL_PATH="${7:-}"
cat << EOF
# LED Matrix Web Interface passwordless sudo configuration
# This allows the web interface user to run specific commands without a password
# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface
EOF
if [ -n "$REBOOT_PATH" ]; then
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
fi
if [ -n "$POWEROFF_PATH" ]; then
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
fi
cat << EOF
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh *
# Install a requirements.txt as root via vetted helper, so packages are visible
# to root-run ledmatrix.service (not just the web interface's own user).
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *
EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat << EOF
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
# when its output is a terminal. From that pager (less) a "!sh" is a root
# shell -- the standard journalctl escalation. The web interface always passes
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
# sits in the middle of the command line. NOEXEC stops the command executing
# another program at all, which closes the hole without depending on wildcard
# matching subtleties.
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
EOF
fi
}
+4 -3
View File
@@ -2,9 +2,10 @@
# #
# Shared helper for rendering systemd unit templates via sed. # Shared helper for rendering systemd unit templates via sed.
# #
# Sourced by install_service.sh, install_web_service.sh and # Sourced by install_service.sh, install_web_service.sh,
# install_wifi_monitor.sh so all three escape sed replacement text the same # install_wifi_monitor.sh, install_dns_fix.sh and install_mqtt_bridge.sh so
# way instead of carrying three copies of the same fix. # every unit renderer escapes sed replacement text the same way instead of
# carrying its own copy of the fix.
# sed_escape_replacement VALUE # sed_escape_replacement VALUE
# #
+8 -22
View File
@@ -205,27 +205,6 @@ check_sudo() {
print_success "Sudo access confirmed" print_success "Sudo access confirmed"
} }
# Fix /tmp permissions if needed (common issue when running via curl | bash)
# Note: /tmp permission fixing is now done inline before running first_time_install.sh
# This function is kept for backward compatibility but not actively used
fix_tmp_permissions() {
CURRENT_STEP="TMP directory check"
# Only fix if /tmp is actually not writable (don't preemptively fix)
if [ ! -w /tmp ]; then
print_warning "/tmp is not writable, attempting to fix..."
if [ "$EUID" -eq 0 ]; then
chmod 1777 /tmp 2>/dev/null || true
else
sudo chmod 1777 /tmp 2>/dev/null || true
fi
fi
# Ensure TMPDIR is set correctly
if [ -z "${TMPDIR:-}" ] || [ ! -w "${TMPDIR:-/tmp}" ]; then
export TMPDIR=/tmp
fi
}
# Main installation function # Main installation function
main() { main() {
print_step "LED Matrix One-Shot Installation" print_step "LED Matrix One-Shot Installation"
@@ -429,6 +408,13 @@ main() {
print_step "Installation Complete!" print_step "Installation Complete!"
print_success "LED Matrix has been successfully installed!" print_success "LED Matrix has been successfully installed!"
echo "" echo ""
# first_time_install.sh -y reboots as its last action, so by now the
# reboot is under way (unless LEDMATRIX_SKIP_REBOOT_PROMPT=1 was set).
if [ "${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}" != "1" ]; then
echo "The installer has just started a reboot to finish setup, so this"
echo "session may disconnect now. Give the Pi a few minutes to come back, then:"
echo ""
fi
echo "Next steps:" echo "Next steps:"
echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json" echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json"
if command -v hostname >/dev/null 2>&1; then if command -v hostname >/dev/null 2>&1; then
@@ -449,7 +435,7 @@ main() {
else else
echo " 2. Or use the web interface: http://<your-pi-ip>:5000" echo " 2. Or use the web interface: http://<your-pi-ip>:5000"
fi fi
echo " 3. Start the service: sudo systemctl start ledmatrix.service" echo " 3. The display service starts on boot; to start it by hand: sudo systemctl start ledmatrix.service"
echo "" echo ""
else else
print_error "Main installation script exited with code $INSTALL_EXIT_CODE" print_error "Main installation script exited with code $INSTALL_EXIT_CODE"
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""Benchmark the render loop against the panel's real refresh rate.
The question this answers is the one that decides whether a rig ships: *does
every frame present on the refresh it was meant to?* It drives the production
path -- a real ``DisplayManager`` and ``ScrollHelper``, the same crisp speed
resolver every ticker uses -- scrolls a synthetic strip for a while, and grades
it with the same frame-timing recorder the display service uses
(``src.common.frame_timing``), printing the same report as
``scripts/frame_soak.py``. A run passes when the loop was genuinely locked to
the panel and no more than ``--max-late-pct`` percent of frames were late.
Where frame_soak.py measures the service as it runs -- live content, plugin
updates, the web preview -- this measures the hardware and the render path
with nothing else in the way, on content that is identical every run. That is
what makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT
against another) and for A/B testing a change to the render path.
# stop the service first; it owns the GPIO
sudo systemctl stop ledmatrix
sudo python3 scripts/render_bench.py # 60s, default speed
sudo python3 scripts/render_bench.py --seconds 600 # the 10-minute gate
sudo python3 scripts/render_bench.py --speed 50 # a slower, held speed
sudo python3 scripts/render_bench.py --busy 2 # with background load
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
sudo systemctl start ledmatrix
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
so a crash here can never leave the panel dark.
Exit status is 0 when the run clears the gate, 1 when it does not, and 2 when
the run could not be set up (no hardware, no root, unusable config) -- so a rig
that cannot be measured is never mistaken for a rig that passed.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import threading
import time
import zlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import frame_timing, scroll_config # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_soak # noqa: E402 (same report, same verdict as the soak)
REPO = Path(__file__).resolve().parent.parent
CONFIG = REPO / "config" / "config.json"
#: Long enough to average out a scheduler hiccup, short enough that nobody
#: skips running it. The shipping gate is --seconds 600.
DEFAULT_SECONDS = 60.0
#: Seconds spent timing bare swaps before the scroll starts. The measurement
#: has to settle, but every second here is a second not scrolling.
MEASURE_SECONDS = 4.0
#: Scrolling discarded before the graded run starts: the first frames carry
#: first-touch costs and the scrolling state settling.
WARMUP_SECONDS = 2.0
def load_config() -> dict:
"""The config the display service would run with."""
try:
from src.config_manager import ConfigManager
config = ConfigManager().config
if isinstance(config, dict) and config:
return config
except Exception as exc: # noqa: BLE001 - any failure means use the plain read
print(f"ConfigManager unavailable ({exc}); reading {CONFIG} directly",
file=sys.stderr)
# ConfigManager pulls in a lot; a plain read is enough to drive the panel
# and keeps the benchmark usable on a half-installed machine.
try:
with open(CONFIG, encoding="utf-8") as handle:
config = json.load(handle)
except (OSError, ValueError) as exc:
sys.exit(f"could not read {CONFIG}: {exc}")
if not isinstance(config, dict):
sys.exit(f"{CONFIG} is not a config object")
return config
def build_strip(width: int, height: int, label: str):
"""A marquee strip a few screens wide, with text and colour.
Deliberately not plain white text on black: how long ``SetImage`` takes
depends on how many subpixels are lit, so a strip that is mostly dark
flatters the panel and hides exactly the regression this benchmark exists
to catch.
"""
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
font = None
for path, size in (
(str(REPO / "assets/fonts/PressStart2P-Regular.ttf"), max(8, height // 4)),
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", max(10, height // 2)),
):
try:
font = load_truetype(path, size)
break
except OSError:
continue
if font is None:
font = ImageFont.load_default()
text = f" {label} *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG *** "
probe = ImageDraw.Draw(Image.new("RGB", (8, 8)))
box = probe.textbbox((0, 0), text, font=font)
text_width = max(1, box[2] - box[0])
text_height = box[3] - box[1]
reps = max(2, (width * 4) // text_width + 1)
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
draw = ImageDraw.Draw(strip)
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
palette = [(255, 210, 60), (80, 200, 255), (255, 90, 90), (140, 255, 140)]
for i in range(reps):
left = i * text_width
# A filled block per repeat, so a meaningful share of the strip is lit.
draw.rectangle(
[left + 4, height - 4, left + text_width - 4, height - 2],
fill=palette[i % len(palette)],
)
draw.text((left, (height - text_height) // 2 - box[1]), text,
font=font, fill=palette[(i + 1) % len(palette)])
return strip
class BackgroundLoad:
"""Threads that imitate plugins updating while the panel scrolls.
Not a simulation of any particular plugin -- it is the shape of the work
that competes with the render loop for the GIL: decoding JSON, resizing an
image, compressing bytes. A render loop that only holds its pacing on an
idle machine is not shippable, and this is how that shows up.
"""
def __init__(self, workers: int) -> None:
self.workers = max(0, workers)
self._stop = threading.Event()
self._threads: list = []
def __enter__(self) -> "BackgroundLoad":
for index in range(self.workers):
thread = threading.Thread(
target=self._run, args=(index,), name=f"bench-load-{index}", daemon=True)
thread.start()
self._threads.append(thread)
return self
def __exit__(self, *exc_info) -> None:
self._stop.set()
for thread in self._threads:
thread.join(timeout=2.0)
def _run(self, index: int) -> None:
from PIL import Image
payload = json.dumps({"games": [{"id": n, "score": [n, n + 1],
"name": f"team {n}"} for n in range(200)]})
image = Image.new("RGB", (256, 64), (12, 34, 56))
while not self._stop.wait(0.25 + 0.05 * index):
json.loads(payload)
image.resize((128, 32), Image.LANCZOS)
zlib.compress(image.tobytes(), 1)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--seconds", type=float, default=DEFAULT_SECONDS,
help=f"how long to scroll for (default {DEFAULT_SECONDS:.0f}; "
"the shipping gate is 600)")
parser.add_argument("--speed", type=float, default=None,
help="requested px/s; snapped to the nearest speed the "
"panel can show in whole pixels (default: one pixel "
"per refresh)")
parser.add_argument("--hz", type=float, default=None,
help="skip the idle measurement and take this as the "
"panel's rate (for reproducing a rig's numbers)")
parser.add_argument("--busy", type=int, default=0, metavar="N",
help="run N background workers imitating plugin updates")
parser.add_argument("--max-late-pct", "--max-missed", dest="max_late_pct",
type=float, default=0.1, metavar="PCT",
help="fail above this percentage of late frames (default 0.1)")
parser.add_argument("--json", dest="json_path", default=None, metavar="PATH",
help="also write the report as JSON, for comparing rigs")
parser.add_argument("--label", default=None,
help="name for this run in the JSON report (default: hostname)")
args = parser.parse_args(argv)
# Everything the display service logs would otherwise land in the middle of
# the report; the benchmark's own output is the point. The stall watchdog
# is the exception: a stack dump naming what held a frame up belongs here.
logging.basicConfig(level=logging.ERROR, stream=sys.stderr)
logging.getLogger("src.common.frame_timing").setLevel(logging.WARNING)
if hasattr(os, "geteuid") and os.geteuid() != 0:
print("this needs root for GPIO access - rerun with sudo", file=sys.stderr)
return 2
config = load_config()
from src.common.scroll_helper import ScrollHelper
from src.display_manager import DisplayManager
try:
display = DisplayManager(config, suppress_test_pattern=True)
except Exception as exc:
print(f"could not open the display ({exc}).\n"
"If the display service is running it owns the GPIO - stop it "
"first:\n sudo systemctl stop ledmatrix", file=sys.stderr)
return 2
if getattr(display, "matrix", None) is None:
print("the display came up in fallback mode - there is no panel here to "
"measure, and a software loop's frame times say nothing about "
"vsync. Run this on a rig.", file=sys.stderr)
return 2
width, height = display.width, display.height
if args.hz is not None:
idle_hz = float(args.hz)
print(f"taking the panel's rate as {idle_hz:.1f}Hz (given, not measured)")
else:
print(f"measuring the panel for {MEASURE_SECONDS:.0f}s...", flush=True)
idle_hz = frame_timing.measure_refresh_hz(display.matrix, MEASURE_SECONDS)
if idle_hz <= 0:
print("the panel did not answer a swap; cannot measure it",
file=sys.stderr)
return 2
cap = scroll_config.refresh_hz_from_config(config)
note = (f" (cap is {cap:.0f}Hz)" if idle_hz < cap * 0.98
else " (at its configured cap)")
print(f"panel refreshes at {idle_hz:.1f}Hz{note}")
requested = args.speed if args.speed else idle_hz
# Configured through the shared resolver rather than by setting the helper
# up by hand, so the benchmark measures the engine every ticker runs on. A
# speed the bench reached some other way would be measuring something no
# plugin does.
helper = ScrollHelper(width, height)
settings = scroll_config.configure(
helper,
plugin_config={"scroll_pixels_per_second": requested},
global_config=config,
refresh_hz=idle_hz,
display_manager=display,
)
choice = settings.crisp
if choice is None:
print("the resolver did not snap to a whole-pixel speed; nothing to "
"grade against", file=sys.stderr)
return 2
print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
helper.set_sub_pixel_scrolling(False)
helper.set_scrolling_image(
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s"))
# The display service's own recorder, owned outright here: never flushed to
# the service's stats file, drained exactly at the start and end of the
# graded run, and seeded with the idle rate so a loop that never locked
# (free-running, or stuck at a fraction of the refresh) shows as early or
# late frames instead of looking self-consistent.
recorder = frame_timing.FrameTimingRecorder(
flush_interval=float("inf"),
info=display._frame_timing_info(), # pylint: disable=protected-access
refresh_hz=idle_hz,
)
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
display.frame_timing = recorder
print(f"scrolling {width}x{height} for {args.seconds:.0f}s"
+ (f" with {args.busy} background worker(s)" if args.busy else "")
+ " ...", flush=True)
frames = 0
duplicates = 0
blanks = 0
restarts = 0
last_column = None
before = None
started = time.perf_counter()
run_started = None
try:
with BackgroundLoad(args.busy):
while True:
now = time.perf_counter()
if run_started is None and now - started >= WARMUP_SECONDS:
recorder.drain()
before = recorder.snapshot()
run_started = now
frames = duplicates = blanks = restarts = 0
if run_started is not None and now - run_started >= args.seconds:
break
helper.update_scroll_position()
if helper.is_scroll_complete():
# The helper parks at the end of the strip and stops
# advancing, exactly as it does under a plugin -- which
# then hands over to the next one. Here there is nothing
# to hand over to, so start the strip again. Without this
# the benchmark measures a still image for the rest of the
# run and reports a smoothness it never demonstrated.
helper.reset_scroll()
restarts += 1
visible = helper.get_visible_portion()
column = int(helper.scroll_position)
if column == last_column:
duplicates += 1
last_column = column
if visible is None:
blanks += 1
else:
display.image.paste(visible, (0, 0))
# Every frame, not once before the loop. The scrolling state
# expires on its own inactivity threshold and takes the frame
# hold with it, so a scroll that announces itself once is
# presented at the wrong rate for all but its first moments --
# and its unchanged frames start taking the dirty-tracking
# skip, which returns without waiting for the panel at all.
# Every ticker re-announces per frame; so does this.
display.set_scrolling_state(True, frame_hold=choice.frame_hold)
display.update_display()
frames += 1
except KeyboardInterrupt:
print("\ninterrupted - reporting what was measured so far")
finally:
display.set_scrolling_state(False)
try:
display.clear()
except Exception as exc: # noqa: BLE001 - a lit panel is harmless; say so and go on
print(f"could not blank the panel: {exc}", file=sys.stderr)
if before is None:
print("interrupted during warm-up; nothing was graded", file=sys.stderr)
return 2
recorder.drain()
report = frame_soak.build_report(before, recorder.snapshot(), preview=False)
report["idle_refresh_hz"] = round(idle_hz, 2)
print()
frame_soak.print_report(report, args.max_late_pct)
held = report.get("held_refresh_hz")
if held:
drop = 100.0 * (idle_hz - held) / idle_hz
print(f"\npanel held ~{held:.1f}Hz while rendering, {drop:.1f}% below its "
f"{idle_hz:.1f}Hz idle rate (a widening gap is a render-cost "
"regression even with nothing late)")
if duplicates:
# A frame that shows the same columns as the one before it is work the
# panel did not need. It is not a miss -- the frame arrived on time --
# but it means the loop is presenting faster than the strip is moving.
print(f"duplicate {duplicates} frames advanced no pixels "
f"({100.0 * duplicates / max(1, frames):.2f}%)")
if blanks:
print(f"blank {blanks} frames had no visible slice to draw")
if restarts:
print(f"restarts {restarts} (the strip was scrolled through "
f"{restarts} time{'s' if restarts != 1 else ''})")
if args.json_path:
report.update({
"label": args.label or os.uname().nodename,
"bench": True,
"requested_pixels_per_second": requested,
"pixels_per_second": choice.pixels_per_second,
"pixels_per_frame": choice.pixels_per_frame,
"frame_hold": choice.frame_hold,
"busy_workers": args.busy,
"duplicate_frames": duplicates,
"blank_frames": blanks,
"strip_restarts": restarts,
"max_late_pct": args.max_late_pct,
"passed": frame_soak.passed(report, args.max_late_pct),
})
Path(args.json_path).write_text(json.dumps(report, indent=2) + "\n",
encoding="utf-8")
print(f"\nwrote {args.json_path}")
if report["late_pct"] is None:
return 2
return 0 if frame_soak.passed(report, args.max_late_pct) else 1
if __name__ == "__main__":
sys.exit(main())
-1
View File
@@ -299,6 +299,5 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
import importlib.util import importlib.util
from typing import Optional
sys.exit(main()) sys.exit(main())
+6 -13
View File
@@ -42,7 +42,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import scroll_config # noqa: E402 from src.common import frame_timing, scroll_config # noqa: E402
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json" CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
@@ -99,20 +99,13 @@ def open_matrix(config, refresh_override=None):
def measure_refresh(config, seconds=6.0): def measure_refresh(config, seconds=6.0):
"""Actual refresh rate, by running uncapped and timing the swaps. """Actual refresh rate, by running uncapped and timing the swaps.
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop What an older Pi or a longer chain will really give you, as opposed to
runs at exactly the panel's rate. This is what an older Pi or a longer whatever limit_refresh_rate_hz optimistically asks for. The timing loop
chain will really give you, as opposed to whatever limit_refresh_rate_hz itself lives in src.common.frame_timing so the benchmark grades against
optimistically asks for. the same measurement this ladder is built from.
""" """
matrix = open_matrix(config, refresh_override=0) matrix = open_matrix(config, refresh_override=0)
canvas = matrix.CreateFrameCanvas() measured = frame_timing.measure_refresh_hz(matrix, seconds)
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
frames = 0
started = time.perf_counter()
while time.perf_counter() - started < seconds:
canvas = matrix.SwapOnVSync(canvas)
frames += 1
measured = frames / (time.perf_counter() - started)
matrix.Clear() matrix.Clear()
return measured return measured
+1
View File
@@ -9,6 +9,7 @@ This directory contains utility scripts for maintenance and system operations.
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode - **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app - **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`) - **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
- **`auto_update_verify.py`** - Health check after an automatic update, rolling back if it fails (the updater copies it to `data/` before pulling and `ledmatrix-update-verify.service` runs that copy)
## Usage ## Usage
+30 -1
View File
@@ -42,6 +42,8 @@ class WiFiMonitorDaemon:
""" """
self.check_interval = check_interval self.check_interval = check_interval
self.wifi_manager = WiFiManager() self.wifi_manager = WiFiManager()
# mtime of wifi_config.json as last loaded; see _reload_config_if_changed.
self._config_mtime = self._config_file_mtime()
self.running = True self.running = True
self.last_state = None self.last_state = None
# Counts consecutive checks where nmcli says "connected" but internet is unreachable. # Counts consecutive checks where nmcli says "connected" but internet is unreachable.
@@ -58,6 +60,31 @@ class WiFiMonitorDaemon:
logger.info(f"Received signal {signum}, shutting down...") logger.info(f"Received signal {signum}, shutting down...")
self.running = False self.running = False
def _config_file_mtime(self):
try:
return self.wifi_manager.config_path.stat().st_mtime_ns
except OSError:
return None
def _reload_config_if_changed(self):
"""Re-read wifi_config.json when it has changed on disk.
The web UI's auto-enable toggle (POST /api/v3/wifi/ap/auto-enable)
only writes the file; this process read it once at startup, so the
toggle did nothing until the daemon restarted. One stat per check.
"""
mtime = self._config_file_mtime()
if mtime is None or mtime == self._config_mtime:
return
before = self.wifi_manager.config.get("auto_enable_ap_mode", True)
self.wifi_manager._load_config()
# _load_config can itself save (it fills in missing keys), so take
# the mtime after it, or that save would trigger another reload.
self._config_mtime = self._config_file_mtime()
after = self.wifi_manager.config.get("auto_enable_ap_mode", True)
if after != before:
logger.info(f"wifi_config.json changed: auto_enable_ap_mode={after}")
def run(self): def run(self):
"""Main daemon loop""" """Main daemon loop"""
logger.info("WiFi Monitor Daemon started") logger.info("WiFi Monitor Daemon started")
@@ -78,6 +105,8 @@ class WiFiMonitorDaemon:
while self.running: while self.running:
try: try:
self._reload_config_if_changed()
# One combined check that also returns the state it observed — # One combined check that also returns the state it observed —
# the previous flow fetched status before AND after the check # the previous flow fetched status before AND after the check
# on top of the check's own internal fetch, each one several # on top of the check's own internal fetch, each one several
@@ -219,7 +248,7 @@ def main():
parser.add_argument( parser.add_argument(
'--foreground', '--foreground',
action='store_true', action='store_true',
help='Run in foreground (for debugging)' help='Accepted for compatibility; the daemon always runs in the foreground'
) )
args = parser.parse_args() args = parser.parse_args()
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project. Core source package for the LED Matrix Display project.
""" """
__version__ = "3.5.0" __version__ = "3.7.0"
+3 -7
View File
@@ -29,13 +29,9 @@ from typing import Any, Optional, Tuple
from PIL import Image from PIL import Image
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies). # Re-exported by src.common for plugins, which import them from there.
try: RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS RESAMPLE_NEAREST = Image.Resampling.NEAREST
RESAMPLE_NEAREST = Image.Resampling.NEAREST
except AttributeError: # Pillow < 9.1
RESAMPLE_LANCZOS = Image.LANCZOS
RESAMPLE_NEAREST = Image.NEAREST
FIT_MODES = ("contain", "cover", "fill_height", "stretch") FIT_MODES = ("contain", "cover", "fill_height", "stretch")
+17 -1
View File
@@ -70,7 +70,14 @@ def _read(path):
def is_enabled(config): def is_enabled(config):
return bool((config.get('auto_update') or {}).get('enabled', False)) # Only the {"enabled": true} object turns this on. A hand-edited
# non-dict (e.g. "auto_update": true) raised AttributeError here and
# aborted startup setup; the web UI's save replaces such a value with {}
# (disabled), so read it the same way.
section = config.get('auto_update')
if not isinstance(section, dict):
return False
return bool(section.get('enabled', False))
class UpdateHelperSetup: class UpdateHelperSetup:
@@ -201,6 +208,7 @@ class UpdateHelperSetup:
try: try:
self.result_file.parent.mkdir(parents=True, exist_ok=True) self.result_file.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_') fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f: with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2) json.dump(result, f, indent=2)
os.chmod(tmp, 0o644) os.chmod(tmp, 0o644)
@@ -212,6 +220,14 @@ class UpdateHelperSetup:
except OSError: except OSError:
pass pass
os.replace(tmp, self.result_file) os.replace(tmp, self.result_file)
except BaseException:
# Don't leave a .auto_update_setup_* file behind in the
# project dir every time the write fails.
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError as e: except OSError as e:
logger.warning("Could not record automatic update setup result: %s", e) logger.warning("Could not record automatic update setup result: %s", e)
return result return result
+61 -64
View File
@@ -103,6 +103,32 @@ class FetchResult:
# FAILED, which turns "you cancelled this" into "this errored". # FAILED, which turns "you cancelled this" into "this errored".
final_status: Optional[FetchStatus] = None final_status: Optional[FetchStatus] = None
class _ConnectionRetryingSession:
"""``session.get`` that retries a connection error a few times.
For ESPN date chunks, which bypass _make_request_with_retry: a failed
chunk is logged and skipped, so a brief network blip would otherwise drop
a month from a cached season. That protection used to come from the
session adapter's own retries, which every other request stacked with the
retry loop.
"""
ATTEMPTS = 3
DELAY = 0.5
def __init__(self, session):
self._session = session
def get(self, *args, **kwargs):
for attempt in range(self.ATTEMPTS):
try:
return self._session.get(*args, **kwargs)
except requests.ConnectionError:
if attempt == self.ATTEMPTS - 1:
raise
time.sleep(self.DELAY * (attempt + 1))
class BackgroundDataService: class BackgroundDataService:
""" """
Background data service for fetching season data without blocking the main thread. Background data service for fetching season data without blocking the main thread.
@@ -131,19 +157,14 @@ class BackgroundDataService:
# Thread management # Thread management
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData") self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
# cache_key -> request_id for fetches currently in flight. Submitting # cache_key -> request_id for fetches currently in flight, so a second
# the same key twice used to start two identical fetches: request_id # submit for the same key joins the running fetch instead of starting
# carries a millisecond timestamp, so every submit looked new, and # another. It is the normal case: a sport's Recent and Upcoming
# active_requests is keyed by it rather than by what is being fetched. # managers miss the cache for the same season schedule together.
# On a real board the season-schedule key is requested by both the
# Recent and the Upcoming manager, which miss the cache in the same
# millisecond and each download and parse the same payload.
self._inflight_by_cache_key: Dict[str, str] = {} self._inflight_by_cache_key: Dict[str, str] = {}
# request_id was sport_year_milliseconds, which is not unique: two # Makes every request_id unique. The id also carries a millisecond
# submits inside the same millisecond produced the SAME id, so one # timestamp, but two submits can share a millisecond, and a joiner
# silently replaced the other in active_requests and completed_requests. # uses the id as its handle for get_result().
# Rare before, but dedupe hands this id back to every joiner as their
# handle for get_result(), so it has to be unique. A counter is enough.
self._request_seq = itertools.count() self._request_seq = itertools.count()
self.active_requests: Dict[str, FetchRequest] = {} self.active_requests: Dict[str, FetchRequest] = {}
self.completed_requests: Dict[str, FetchResult] = {} self.completed_requests: Dict[str, FetchResult] = {}
@@ -168,10 +189,16 @@ class BackgroundDataService:
'average_fetch_time': 0.0 'average_fetch_time': 0.0
} }
# Session for HTTP requests # Session for HTTP requests. No retries at the adapter: a fetch goes
# through _make_request_with_retry (max_retries + 1 attempts with
# exponential backoff, logged), and date-range chunks through
# _ConnectionRetryingSession. With the adapter also retrying
# connection errors three times, a dead network cost up to 16
# connection attempts per request and held one of the few worker
# threads for all of them.
self.session = requests.Session() self.session = requests.Session()
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=3)) self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=0))
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=3)) self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=0))
# Default headers: core's shared set (real User-Agent, no hand-set # Default headers: core's shared set (real User-Agent, no hand-set
# Accept-Encoding) -- see src/common/api_helper.py. # Accept-Encoding) -- see src/common/api_helper.py.
@@ -186,9 +213,9 @@ class BackgroundDataService:
This ensures Recent/Upcoming managers and background service This ensures Recent/Upcoming managers and background service
use the same cache keys. use the same cache keys.
""" """
# Same format as CacheManager.generate_sport_cache_key(). This used to # Same format as CacheManager.generate_sport_cache_key(), built here
# build a whole CacheManager to call it -- config load, cache-dir # rather than by constructing a CacheManager (config load, cache-dir
# probing with test writes -- on every submit without a cache_key. # probing) on every submit without a cache_key.
if date_str is None: if date_str is None:
date_str = datetime.now(pytz.utc).strftime('%Y%m%d') date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
return f"{sport}_{date_str}" return f"{sport}_{date_str}"
@@ -252,6 +279,10 @@ class BackgroundDataService:
# same object the dict holds. # same object the dict holds.
self.completed_requests[request_id] = result self.completed_requests[request_id] = result
# The callback runs outside the lock, as on the worker path: it is
# plugin code, and holding the service lock through it blocked
# every worker's result bookkeeping (and any other thread's
# submit) for as long as the callback took.
if callback: if callback:
try: try:
callback(result) callback(result)
@@ -331,10 +362,8 @@ class BackgroundDataService:
try: try:
with self._lock: with self._lock:
# A request cancelled while it sat in the executor queue must # A request cancelled while it sat in the executor queue stays
# stay cancelled. Overwriting the status here undid the cancel # cancelled: no download, no cache write, no callback.
# outright: the worker went on to download, cache and call back
# for work the caller had already withdrawn.
if request.status == FetchStatus.CANCELLED: if request.status == FetchStatus.CANCELLED:
cancelled_before_start = True cancelled_before_start = True
else: else:
@@ -463,10 +492,9 @@ class BackgroundDataService:
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}") logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
with self._lock: with self._lock:
# Don't relabel a cancelled request. The callback gate in the # A cancelled request stays CANCELLED even when its fetch
# finally block only suppresses CANCELLED, so promoting it to # failed: the finally block skips callbacks only for
# FAILED here delivered an error callback for a fetch nobody # CANCELLED, and nobody is waiting on this fetch any more.
# was waiting on any more.
if request.status != FetchStatus.CANCELLED: if request.status != FetchStatus.CANCELLED:
request.status = FetchStatus.FAILED request.status = FetchStatus.FAILED
request.error = error_msg request.error = error_msg
@@ -526,20 +554,13 @@ class BackgroundDataService:
except Exception as e: except Exception as e:
logger.error(f"Error in callback for request {request.id}: {e}") logger.error(f"Error in callback for request {request.id}: {e}")
# Released AFTER the loop, not inside it. Every callback here holds # Released after the loop, never inside it: every callback holds
# the same FetchResult, so releasing per-delivery handed the first # the same FetchResult (a sport's recent, upcoming and live
# one the data and every joiner `result.data is None` -- which is # managers usually share one fetch), so a release between
# not a quiet degradation: they read `result.data.get('events')` and # deliveries would hand the later ones `result.data is None`.
# raise AttributeError, which this very loop catches and logs, so
# the symptom was one ERROR line and a manager that silently never
# got its schedule. Deduplication is the normal case, not a corner:
# a sport's recent, upcoming and live managers all ride one season
# fetch.
# #
# Guarded on `callbacks`, because a request submitted without one # Only when there were callbacks: a request submitted without one
# has no other way to collect its payload than polling get_result(). # collects its payload by polling get_result().
# The old per-delivery release got that right by accident: an empty
# list never entered the loop body.
if callbacks: if callbacks:
self._release_payload(result) self._release_payload(result)
request.result = None request.result = None
@@ -575,7 +596,7 @@ class BackgroundDataService:
""" """
logger.info("Recovering %s %s from a rejected date range", request.sport, request.year) logger.info("Recovering %s %s from a rejected date range", request.sport, request.year)
return fetch_espn_date_chunks( return fetch_espn_date_chunks(
self.session, _ConnectionRetryingSession(self.session),
request.url, request.url,
params=request.params, params=request.params,
headers=request.headers, headers=request.headers,
@@ -721,9 +742,6 @@ class BackgroundDataService:
'completed_requests_count': len(self.completed_requests), 'completed_requests_count': len(self.completed_requests),
'max_completed_requests': self._max_completed_requests, 'max_completed_requests': self._max_completed_requests,
'completed_requests_usage_percent': (len(self.completed_requests) / self._max_completed_requests * 100) if self._max_completed_requests > 0 else 0, 'completed_requests_usage_percent': (len(self.completed_requests) / self._max_completed_requests * 100) if self._max_completed_requests > 0 else 0,
# Nothing is queued outside the executor; kept for callers
# that read the key.
'queue_size': 0,
'last_cleanup': self._last_completed_requests_cleanup, 'last_cleanup': self._last_completed_requests_cleanup,
'cleanup_interval': self._completed_requests_cleanup_interval 'cleanup_interval': self._completed_requests_cleanup_interval
} }
@@ -793,27 +811,6 @@ class BackgroundDataService:
return removed_count return removed_count
def clear_completed_requests(self, older_than_hours: int = 24):
"""
Clear completed requests older than specified time.
Args:
older_than_hours: Clear requests older than this many hours
"""
cutoff_time = time.time() - (older_than_hours * 3600)
with self._lock:
to_remove = []
for request_id, result in self.completed_requests.items():
if result.completed_at < cutoff_time:
to_remove.append(request_id)
for request_id in to_remove:
del self.completed_requests[request_id]
if to_remove:
logger.info(f"Cleared {len(to_remove)} old completed requests")
def shutdown(self, wait: bool = True): def shutdown(self, wait: bool = True):
""" """
Shutdown the background data service. Shutdown the background data service.
+110 -109
View File
@@ -83,14 +83,31 @@ BUNDLED_FONTS: frozenset[str] = frozenset({
_CONFIG_REL = Path("config/config.json") _CONFIG_REL = Path("config/config.json")
_SECRETS_REL = Path("config/config_secrets.json") _SECRETS_REL = Path("config/config_secrets.json")
_WIFI_REL = Path("config/wifi_config.json") _WIFI_REL = Path("config/wifi_config.json")
# Sits in config/ next to the three above and is pure user state — a # A YouTube Music session: pure user state that has to be re-authenticated by
# YouTube Music session that has to be re-authenticated by hand if lost. # hand if lost, so a restore must bring it back.
# It was omitted from backups, so a restore silently signed the user out.
_YTM_REL = Path("config/ytm_auth.json") _YTM_REL = Path("config/ytm_auth.json")
_FONTS_REL = Path("assets/fonts") _FONTS_REL = Path("assets/fonts")
_PLUGIN_UPLOADS_REL = Path("assets/plugins") _PLUGIN_UPLOADS_REL = Path("assets/plugins")
_STATE_REL = Path("data/plugin_state.json") _STATE_REL = Path("data/plugin_state.json")
#: The sections that are one file each: (section name, path, the
#: RestoreOptions flag that restores it). create, preview, validate and
#: restore all walk this table. ytm_auth follows restore_wifi: it is
#: device-local auth like the Wi-Fi settings, and a toggle of its own for one
#: file would be noise in the restore dialog.
_SINGLE_FILE_SECTIONS: Tuple[Tuple[str, Path, str], ...] = (
("config", _CONFIG_REL, "restore_config"),
("secrets", _SECRETS_REL, "restore_secrets"),
("wifi", _WIFI_REL, "restore_wifi"),
("ytm_auth", _YTM_REL, "restore_wifi"),
)
#: Sections holding credentials. Restored onto a device that has no copy yet,
#: they would otherwise take the extracted temp file's umask mode (0o644,
#: world-readable); 0o640 matches what config_manager_atomic gives secrets.
_PRIVATE_SECTION_RELS = frozenset({_SECRETS_REL, _WIFI_REL, _YTM_REL})
_PRIVATE_FILE_MODE = 0o640
MANIFEST_NAME = "manifest.json" MANIFEST_NAME = "manifest.json"
PLUGINS_MANIFEST_NAME = "plugins.json" PLUGINS_MANIFEST_NAME = "plugins.json"
@@ -140,34 +157,18 @@ class RestoreResult:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _ledmatrix_version(project_root: Path) -> str: def _ledmatrix_version() -> str:
"""Best-effort version string for the current install.""" """The release of the running core (``src.__version__``), recorded in the
version_file = project_root / "VERSION" manifest so a restore can tell which release wrote the backup."""
if version_file.exists(): from src import __version__
try: return __version__
return version_file.read_text(encoding="utf-8").strip() or "unknown"
except OSError:
pass
head_file = project_root / ".git" / "HEAD"
if head_file.exists():
try:
head = head_file.read_text(encoding="utf-8").strip()
if head.startswith("ref: "):
ref = head[5:]
ref_path = project_root / ".git" / ref
if ref_path.exists():
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
return head[:12] or "unknown"
except OSError:
pass
return "unknown"
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]: def _build_manifest(contents: List[str]) -> Dict[str, Any]:
return { return {
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"ledmatrix_version": _ledmatrix_version(project_root), "ledmatrix_version": _ledmatrix_version(),
"hostname": socket.gethostname(), "hostname": socket.gethostname(),
"contents": contents, "contents": contents,
} }
@@ -178,13 +179,34 @@ def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _plugins_directory(project_root: Path) -> Path:
"""The plugin install directory: ``plugin_system.plugins_directory`` from
config/config.json (relative to ``project_root`` unless absolute), or
``plugin-repos`` when the config does not say or cannot be read."""
configured: Any = None
try:
with (project_root / _CONFIG_REL).open("r", encoding="utf-8") as f:
config = json.load(f)
if isinstance(config, dict):
plugin_system = config.get("plugin_system")
if isinstance(plugin_system, dict):
configured = plugin_system.get("plugins_directory")
except (OSError, json.JSONDecodeError):
pass
if not isinstance(configured, str) or not configured.strip():
configured = "plugin-repos"
path = Path(configured)
return path if path.is_absolute() else project_root / path
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]: def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
""" """
Return a list of currently-installed plugins suitable for the backup Return a list of currently-installed plugins suitable for the backup
manifest. Each entry has ``plugin_id`` and ``version``. manifest. Each entry has ``plugin_id`` and ``version``.
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin Reads ``data/plugin_state.json`` if present, then adds any plugin it
directory and reads each ``manifest.json``. does not list from the ``manifest.json`` files in the configured plugin
directory (see :func:`_plugins_directory`).
""" """
plugins: Dict[str, Dict[str, Any]] = {} plugins: Dict[str, Dict[str, Any]] = {}
@@ -206,8 +228,7 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
except (OSError, json.JSONDecodeError) as e: except (OSError, json.JSONDecodeError) as e:
logger.warning("Could not read plugin_state.json: %s", e) logger.warning("Could not read plugin_state.json: %s", e)
# Fall back to scanning plugin-repos/ for manifests. plugins_root = _plugins_directory(project_root)
plugins_root = project_root / "plugin-repos"
if plugins_root.exists(): if plugins_root.exists():
for entry in sorted(plugins_root.iterdir()): for entry in sorted(plugins_root.iterdir()):
if not entry.is_dir(): if not entry.is_dir():
@@ -220,6 +241,10 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
data = json.load(f) data = json.load(f)
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
continue continue
# Valid JSON that is not an object (a list, a bare string) would
# raise AttributeError on .get() and abort the whole export.
if not isinstance(data, dict):
continue
plugin_id = data.get("id") or entry.name plugin_id = data.get("id") or entry.name
if plugin_id not in plugins: if plugin_id not in plugins:
plugins[plugin_id] = { plugins[plugin_id] = {
@@ -295,22 +320,17 @@ def create_backup(
contents: List[str] = [] contents: List[str] = []
# Stream directly to a temp file so we never hold the whole ZIP in memory. # Stream directly to a temp file so we never hold the whole ZIP in memory.
tmp_path = zip_path.with_suffix(".zip.tmp") # The name is unique per call: a fixed "<zip>.tmp" was shared by two
# exports started in the same second, which then wrote the same file.
fd, tmp_name = tempfile.mkstemp(dir=str(output_dir), prefix=f".{zip_name}.", suffix=".tmp")
os.close(fd)
tmp_path = Path(tmp_name)
try: try:
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# Config files. for section, rel, _flag in _SINGLE_FILE_SECTIONS:
if (project_root / _CONFIG_REL).exists(): if (project_root / rel).exists():
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix()) zf.write(project_root / rel, rel.as_posix())
contents.append("config") contents.append(section)
if (project_root / _SECRETS_REL).exists():
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
contents.append("secrets")
if (project_root / _WIFI_REL).exists():
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
contents.append("wifi")
if (project_root / _YTM_REL).exists():
zf.write(project_root / _YTM_REL, _YTM_REL.as_posix())
contents.append("ytm_auth")
# User-uploaded fonts. # User-uploaded fonts.
user_fonts = iter_user_fonts(project_root) user_fonts = iter_user_fonts(project_root)
@@ -338,10 +358,27 @@ def create_backup(
contents.append("plugins") contents.append("plugins")
# Manifest goes last so that `contents` reflects what we actually wrote. # Manifest goes last so that `contents` reflects what we actually wrote.
manifest = _build_manifest(contents, project_root) manifest = _build_manifest(contents)
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2)) zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
# Same-second exports share a timestamp; number the later one rather
# than replacing the backup the first one just returned. The name is
# claimed with an exclusive create (O_EXCL fails if it exists), so two
# exports finishing together can't both pick the same free name; the
# replace then swaps the finished archive in over our own placeholder.
suffix = 2
while True:
try:
os.close(os.open(zip_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
break
except FileExistsError:
zip_path = output_dir / f"{Path(zip_name).stem}-{suffix}.zip"
suffix += 1
try:
os.replace(tmp_path, zip_path) os.replace(tmp_path, zip_path)
except BaseException:
zip_path.unlink(missing_ok=True)
raise
except Exception: except Exception:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
raise raise
@@ -352,15 +389,16 @@ def create_backup(
def preview_backup_contents(project_root: Path) -> Dict[str, Any]: def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
"""Return a summary of what ``create_backup`` would include.""" """Return a summary of what ``create_backup`` would include."""
project_root = Path(project_root).resolve() project_root = Path(project_root).resolve()
return { preview: Dict[str, Any] = {
"has_config": (project_root / _CONFIG_REL).exists(), f"has_{section}": (project_root / rel).exists()
"has_secrets": (project_root / _SECRETS_REL).exists(), for section, rel, _flag in _SINGLE_FILE_SECTIONS
"has_wifi": (project_root / _WIFI_REL).exists(), }
"has_ytm_auth": (project_root / _YTM_REL).exists(), preview.update({
"user_fonts": [p.name for p in iter_user_fonts(project_root)], "user_fonts": [p.name for p in iter_user_fonts(project_root)],
"plugin_uploads": len(iter_plugin_uploads(project_root)), "plugin_uploads": len(iter_plugin_uploads(project_root)),
"plugins": list_installed_plugins(project_root), "plugins": list_installed_plugins(project_root),
} })
return preview
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -431,15 +469,10 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
{}, {},
) )
detected: List[str] = [] detected: List[str] = [
if _CONFIG_REL.as_posix() in names: section for section, rel, _flag in _SINGLE_FILE_SECTIONS
detected.append("config") if rel.as_posix() in names
if _SECRETS_REL.as_posix() in names: ]
detected.append("secrets")
if _WIFI_REL.as_posix() in names:
detected.append("wifi")
if _YTM_REL.as_posix() in names:
detected.append("ytm_auth")
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names): if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
detected.append("fonts") detected.append("fonts")
if any( if any(
@@ -448,7 +481,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
): ):
detected.append("plugin_uploads") detected.append("plugin_uploads")
plugins: List[Dict[str, Any]] = [] # Whatever the archive's manifest holds; checked below.
plugins: Any = []
if PLUGINS_MANIFEST_NAME in names: if PLUGINS_MANIFEST_NAME in names:
try: try:
plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8")) plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8"))
@@ -491,7 +525,7 @@ def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
shutil.copyfileobj(src, dst, length=64 * 1024) shutil.copyfileobj(src, dst, length=64 * 1024)
def _copy_file(src: Path, dst: Path) -> None: def _copy_file(src: Path, dst: Path, new_mode: Optional[int] = None) -> None:
"""Replace ``dst`` with ``src``, atomically, without needing to own ``dst``. """Replace ``dst`` with ``src``, atomically, without needing to own ``dst``.
``shutil.copy2`` opens the destination for writing, so it needs write ``shutil.copy2`` opens the destination for writing, so it needs write
@@ -507,6 +541,7 @@ def _copy_file(src: Path, dst: Path) -> None:
The destination's existing mode is preserved when there is one, so The destination's existing mode is preserved when there is one, so
restoring secrets does not silently widen them to the umask default. restoring secrets does not silently widen them to the umask default.
When there is none, ``new_mode`` (if given) is used instead of ``src``'s.
""" """
dst.parent.mkdir(parents=True, exist_ok=True) dst.parent.mkdir(parents=True, exist_ok=True)
@@ -528,6 +563,8 @@ def _copy_file(src: Path, dst: Path) -> None:
shutil.copyfile(src, tmp_path) shutil.copyfile(src, tmp_path)
if existing_mode is not None: if existing_mode is not None:
os.chmod(tmp_path, existing_mode) os.chmod(tmp_path, existing_mode)
elif new_mode is not None:
os.chmod(tmp_path, new_mode)
else: else:
shutil.copymode(src, tmp_path) shutil.copymode(src, tmp_path)
if existing_owner is not None and hasattr(os, 'chown'): if existing_owner is not None and hasattr(os, 'chown'):
@@ -584,55 +621,19 @@ def restore_backup(
result.errors.append("Failed to extract backup") result.errors.append("Failed to extract backup")
return result return result
# Main config. for section, rel, flag in _SINGLE_FILE_SECTIONS:
if options.restore_config and (tmp_dir / _CONFIG_REL).exists(): if not (tmp_dir / rel).exists():
continue
if not getattr(options, flag):
result.skipped.append(section)
continue
try: try:
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL) _copy_file(tmp_dir / rel, project_root / rel,
result.restored.append("config") new_mode=_PRIVATE_FILE_MODE if rel in _PRIVATE_SECTION_RELS else None)
result.restored.append(section)
except OSError as e: except OSError as e:
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True) logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
result.errors.append("Failed to restore config.json") result.errors.append(f"Failed to restore {rel.name}")
elif (tmp_dir / _CONFIG_REL).exists():
result.skipped.append("config")
# Secrets.
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
try:
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
result.restored.append("secrets")
except OSError as e:
logger.error(
"[Backup] Failed to restore config_secrets.json: %s", e, exc_info=True
)
result.errors.append("Failed to restore config_secrets.json")
elif (tmp_dir / _SECRETS_REL).exists():
result.skipped.append("secrets")
# WiFi.
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
try:
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
result.restored.append("wifi")
except OSError as e:
logger.error(
"[Backup] Failed to restore wifi_config.json: %s", e, exc_info=True
)
result.errors.append("Failed to restore wifi_config.json")
elif (tmp_dir / _WIFI_REL).exists():
result.skipped.append("wifi")
# YouTube Music session. Follows restore_wifi rather than getting its
# own flag: it is device-local auth in the same sense, and a separate
# toggle for one file would be noise in the restore dialog.
if options.restore_wifi and (tmp_dir / _YTM_REL).exists():
try:
_copy_file(tmp_dir / _YTM_REL, project_root / _YTM_REL)
result.restored.append("ytm_auth")
except OSError as e:
logger.error("[Backup] Failed to restore ytm_auth.json: %s", e, exc_info=True)
result.errors.append("Failed to restore ytm_auth.json")
elif (tmp_dir / _YTM_REL).exists():
result.skipped.append("ytm_auth")
# User fonts — skip anything that collides with a bundled font. # User fonts — skip anything that collides with a bundled font.
tmp_fonts = tmp_dir / _FONTS_REL tmp_fonts = tmp_dir / _FONTS_REL
+41 -31
View File
@@ -16,8 +16,15 @@ import time
import requests import requests
import json import json
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List, cast
from src.common.api_helper import DEFAULT_HTTP_HEADERS
def _is_no_odds_marker(data: Any) -> bool:
"""Whether a cached odds entry is the "ESPN had none" marker, not odds."""
return isinstance(data, dict) and bool(data.get("no_odds"))
class BaseOddsManager: class BaseOddsManager:
""" """
@@ -45,22 +52,15 @@ class BaseOddsManager:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.base_url = "https://sports.core.api.espn.com/v2/sports" self.base_url = "https://sports.core.api.espn.com/v2/sports"
# This path used a bare requests.get, so it identified itself as # Core's shared headers: ESPN rejects requests' default User-Agent
# python-requests/x.y -- the one thing ESPN is known to reject. Around # (see api_helper.USER_AGENT), and a rejected odds request costs the
# 2026-08-04 it began 403ing browser strings and bare custom tokens # calling plugin its update budget.
# alike; what it accepts is a token with a URL that says who is
# calling. Every other ESPN caller in the tree already sends this
# (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost
# the caller its whole update budget.
# #
# Deliberately no retry adapter, unlike api_helper: retries multiply # Deliberately no retry adapter, unlike api_helper: retries multiply
# request_timeout, which is set to 5s precisely to stay inside that # request_timeout, which is set to 5s precisely to stay inside that
# budget. One try, then the cooldown below. # budget. One try, then the cooldown below.
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update(DEFAULT_HTTP_HEADERS)
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'Accept': 'application/json',
})
# Configuration with defaults # Configuration with defaults
self.update_interval = 3600 # 1 hour default self.update_interval = 3600 # 1 hour default
@@ -72,7 +72,6 @@ class BaseOddsManager:
self.request_timeout = 5 self.request_timeout = 5
# Set when a request fails; until then, skip the network entirely. # Set when a request fails; until then, skip the network entirely.
self._skip_network_until = 0.0 self._skip_network_until = 0.0
self.cache_ttl = 1800 # 30 minutes default
# Load configuration if available # Load configuration if available
if config_manager: if config_manager:
@@ -89,12 +88,10 @@ class BaseOddsManager:
self.update_interval = odds_config.get('update_interval', self.update_interval) self.update_interval = odds_config.get('update_interval', self.update_interval)
self.request_timeout = odds_config.get('timeout', self.request_timeout) self.request_timeout = odds_config.get('timeout', self.request_timeout)
self.cache_ttl = odds_config.get('cache_ttl', self.cache_ttl)
self.logger.debug(f"BaseOddsManager configuration loaded: " self.logger.debug(f"BaseOddsManager configuration loaded: "
f"update_interval={self.update_interval}s, " f"update_interval={self.update_interval}s, "
f"timeout={self.request_timeout}s, " f"timeout={self.request_timeout}s")
f"cache_ttl={self.cache_ttl}s")
except Exception as e: except Exception as e:
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}") self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
@@ -108,7 +105,7 @@ class BaseOddsManager:
_FAILURE_COOLDOWN = 60.0 _FAILURE_COOLDOWN = 60.0
def get_odds(self, sport: str | None, league: str | None, event_id: str, def get_odds(self, sport: str | None, league: str | None, event_id: str,
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]: update_interval_seconds: Optional[int] = None) -> Optional[Dict[str, Any]]:
""" """
Fetch odds data for a specific game. Fetch odds data for a specific game.
@@ -129,10 +126,20 @@ class BaseOddsManager:
cache_key = f"odds_espn_{sport}_{league}_{event_id}" cache_key = f"odds_espn_{sport}_{league}_{event_id}"
# Check cache first # Check cache first
cached_data = self.cache_manager.get_with_auto_strategy(cache_key) cached_data: Optional[Dict[str, Any]] = self.cache_manager.get_with_auto_strategy(cache_key)
# Per-game chatter, logged on every update of every game on the
# slate: debug, not the journal.
if cached_data: if cached_data:
self.logger.info(f"Using cached odds from ESPN for {cache_key}") # A game ESPN had no odds for is cached as {"no_odds": True} so it
# isn't re-requested every update. That marker is a cache hit --
# its ttl decides when to ask again -- but it is not odds: returned
# as-is, a caller saw a truthy dict and treated the game as having
# odds. The plugins' bundled copies already did this.
if _is_no_odds_marker(cached_data):
self.logger.debug("Cached no-odds marker for %s", cache_key)
return None
self.logger.debug(f"Using cached odds from ESPN for {cache_key}")
return cached_data return cached_data
if time.monotonic() < self._skip_network_until: if time.monotonic() < self._skip_network_until:
@@ -145,7 +152,7 @@ class BaseOddsManager:
self._skip_network_until - time.monotonic()) self._skip_network_until - time.monotonic())
return None return None
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}") self.logger.debug(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
try: try:
# Map league names to ESPN API format # Map league names to ESPN API format
@@ -159,7 +166,7 @@ class BaseOddsManager:
espn_league = league_mapping.get(league, league) espn_league = league_mapping.get(league, league)
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds" url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
self.logger.info(f"Requesting odds from URL: {url}") self.logger.debug(f"Requesting odds from URL: {url}")
response = self.session.get(url, timeout=self.request_timeout) response = self.session.get(url, timeout=self.request_timeout)
response.raise_for_status() response.raise_for_status()
@@ -171,30 +178,33 @@ class BaseOddsManager:
odds_data = self._extract_espn_data(raw_data) odds_data = self._extract_espn_data(raw_data)
if odds_data: if odds_data:
self.logger.info(f"Successfully extracted odds data: {odds_data}") self.logger.debug(f"Successfully extracted odds data: {odds_data}")
else:
self.logger.debug("No odds data available for this game")
if odds_data:
self.cache_manager.set(cache_key, odds_data, ttl=interval) self.cache_manager.set(cache_key, odds_data, ttl=interval)
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s") self.logger.debug(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
else: else:
self.logger.debug(f"No odds data available for {cache_key}") self.logger.debug(f"No odds data available for {cache_key}")
# Cache the fact that no odds are available to avoid repeated API calls # Cache the absence too, so the game is not re-requested
# on every update until the interval passes.
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
return odds_data return odds_data
# Before RequestException: requests' JSONDecodeError subclasses it, so
# listed second this branch never ran and a bad body was reported as a
# failed fetch. It holds off like a failed fetch did, so only the
# message changes.
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
self.logger.error( self.logger.error(
"Error fetching odds from ESPN API for %s: %s. Holding off on odds " "Error fetching odds from ESPN API for %s: %s. Holding off on odds "
"for %.0fs so a slate of games does not pay this timeout each.", "for %.0fs so a slate of games does not pay this timeout each.",
cache_key, e, self._FAILURE_COOLDOWN) cache_key, e, self._FAILURE_COOLDOWN)
except json.JSONDecodeError:
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
return self.cache_manager.get_with_auto_strategy(cache_key) cached = self.cache_manager.get_with_auto_strategy(cache_key)
return None if _is_no_odds_marker(cached) else cast(Optional[Dict[str, Any]], cached)
def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
""" """
+111 -18
View File
@@ -14,7 +14,7 @@ import tempfile
import logging import logging
import threading import threading
import zlib import zlib
from typing import Dict, Any, Optional, Protocol from typing import Dict, Any, Optional, Protocol, Tuple
from datetime import datetime from datetime import datetime
from src.common.path_safety import safe_path_component from src.common.path_safety import safe_path_component
@@ -111,18 +111,66 @@ _HEAD_RE = re.compile(
) )
def _stale_from_head(head: bytes, max_age: Optional[int], now: float) -> bool: def _head_timestamp(head: bytes) -> Optional[Tuple[float, int]]:
"""A header-first record's timestamp and the offset just past it.
None when the record does not start with a finite numeric timestamp.
"""
match = _HEAD_RE.match(head)
if not match:
return None
try:
timestamp = float(match.group(1))
except ValueError:
return None
if not math.isfinite(timestamp):
return None
return timestamp, match.end(1)
# FRESHNESS OF A SKIPPED WRITE
# ----------------------------
# CacheManager.set stamps every record with time.time(), so re-saving
# unchanged data produced a different payload every time and DiskCache.set's
# identical-payload skip never fired: every plugin rewrote its unchanged API
# data to the SD card every update cycle. set() now compares header-first
# records without their timestamp, and on a skip moves the file's mtime to
# the timestamp the skipped record carried instead of rewriting it. So the
# file's mtime is when its content was last saved, and a header-first
# record is as fresh as the later of its embedded timestamp and its mtime.
#
# A real write sets the mtime to the embedded timestamp too, so mtime is
# never later than the timestamp for a record written with an old one on
# purpose -- only a skip can move it forward.
def _refreshed_at(timestamp: float, mtime: float) -> float:
"""When a header-first record was last saved, embedded time or mtime.
An mtime within a second of the timestamp is the write that carried it
(float rounding, or an older file whose mtime was not set to match),
not a skipped rewrite, and leaves the record as written.
"""
return mtime if mtime > timestamp + 1.0 else timestamp
def _stale_from_head(head: bytes, max_age: Optional[int], now: float,
refreshed: Optional[float] = None) -> bool:
"""True when a record's header alone shows it has expired. """True when a record's header alone shows it has expired.
Mirrors the expiry rule in DiskCache.get: a per-entry ttl wins over the Mirrors the expiry rule in DiskCache.get: a per-entry ttl wins over the
caller's max_age, and no limit at all means never stale. False whenever the caller's max_age, and no limit at all means never stale. False whenever the
header cannot be read, so the full parse decides as it always did. header cannot be read, so the full parse decides as it always did.
``refreshed`` is the file's mtime: a skipped rewrite advances it rather
than the embedded timestamp (see "FRESHNESS OF A SKIPPED WRITE").
""" """
match = _HEAD_RE.match(head) match = _HEAD_RE.match(head)
if not match: if not match:
return False return False
try: try:
timestamp = float(match.group(1)) timestamp = float(match.group(1))
if refreshed is not None:
timestamp = max(timestamp, refreshed)
limit = max_age limit = max_age
if match.group(2) is not None: if match.group(2) is not None:
ttl = float(match.group(2)) ttl = float(match.group(2))
@@ -248,11 +296,13 @@ class DiskCache:
self.cache_dir = cache_dir self.cache_dir = cache_dir
self.logger = logger or logging.getLogger(__name__) self.logger = logger or logging.getLogger(__name__)
self._lock = threading.Lock() self._lock = threading.Lock()
# key -> adler32 of the last payload successfully written to the # key -> ((length, adler32) of the last content written to the
# primary cache path; lets set() skip rewriting identical data # primary cache path, (st_ino, st_size) of the file it left); lets
# (per-process only — worst case another process rewrites, never # set() skip rewriting identical data. The file identity catches
# a missed write). Guarded by _lock. # another process -- the web interface writes and clears keys too --
self._write_digests: Dict[str, int] = {} # having replaced the file since, which would otherwise make the skip
# a missed write. Per-process only. Guarded by _lock.
self._write_digests: Dict[str, Tuple[Tuple[int, int], Tuple[int, int]]] = {}
def get_cache_path(self, key: str) -> Optional[str]: def get_cache_path(self, key: str) -> Optional[str]:
""" """
@@ -306,7 +356,12 @@ class DiskCache:
# records (a season schedule is re-fetched when its cache # records (a season schedule is re-fetched when its cache
# expires), and parsing 53MB to throw it away held the GIL # expires), and parsing 53MB to throw it away held the GIL
# for ~1.8s -- a visible freeze on the panel. # for ~1.8s -- a visible freeze on the panel.
if _stale_from_head(f.read(_HEAD_BYTES), max_age, time.time()): head = f.read(_HEAD_BYTES)
stamp = _head_timestamp(head)
fresh_at = None
if stamp is not None:
fresh_at = _refreshed_at(stamp[0], os.fstat(f.fileno()).st_mtime)
if _stale_from_head(head, max_age, time.time(), fresh_at):
return None return None
f.seek(0) f.seek(0)
record = _loads(f.read()) record = _loads(f.read())
@@ -315,6 +370,11 @@ class DiskCache:
record_ts = None record_ts = None
if isinstance(record, dict): if isinstance(record, dict):
record_ts = record.get('timestamp') record_ts = record.get('timestamp')
if fresh_at is not None and fresh_at > stamp[0]:
# A skipped rewrite refreshed this record (see "FRESHNESS
# OF A SKIPPED WRITE"); hand callers the time it was last
# saved, as the rewrite would have.
record['timestamp'] = record_ts = fresh_at
if record_ts is None: if record_ts is None:
try: try:
record_ts = os.path.getmtime(cache_path) record_ts = os.path.getmtime(cache_path)
@@ -403,23 +463,36 @@ class DiskCache:
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e) self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
return return
digest = zlib.adler32(payload) # A header-first record is compared without its timestamp, which
# CacheManager.set changes on every call (see "FRESHNESS OF A SKIPPED
# WRITE"). The length rides along with adler32, which is weak on its
# own for short payloads, and a collision here is a missed write.
stamp = _head_timestamp(payload[:_HEAD_BYTES])
stamped_at = stamp[0] if stamp is not None else None
content = memoryview(payload)[stamp[1]:] if stamp is not None else payload
digest = (len(content), zlib.adler32(content))
try: try:
# Atomic write to avoid partial/corrupt files # Atomic write to avoid partial/corrupt files
with self._lock: with self._lock:
# Skip the disk entirely when this exact payload was already # Skip the disk entirely when this content was already
# written for this key (plugins re-save unchanged API data # written for this key (plugins re-save unchanged API data
# every update cycle — each write is real SD-card wear). # every update cycle — each write is real SD-card wear).
# Refresh the file mtime so records that rely on it for TTL # Move the file mtime instead, so the record stays as fresh as
# (no embedded 'timestamp') don't expire early; a metadata # the rewrite would have left it; a metadata touch is
# touch is journal-cheap compared to rewriting the data. # journal-cheap compared to rewriting the data.
if self._write_digests.get(key) == digest: known = self._write_digests.get(key)
if known is not None and known[0] == digest:
try: try:
os.utime(cache_path, None) st = os.stat(cache_path)
if (st.st_ino, st.st_size) == known[1]:
os.utime(cache_path, None if stamped_at is None
else (stamped_at, stamped_at))
return return
except OSError: except OSError:
# File vanished or perms changed — fall through and write pass
# File vanished, was replaced by another process, or its
# times cannot be set — fall through and write
self._write_digests.pop(key, None) self._write_digests.pop(key, None)
tmp_dir = os.path.dirname(cache_path) tmp_dir = os.path.dirname(cache_path)
@@ -458,7 +531,7 @@ class DiskCache:
# opened it in between was refused. # opened it in between was refused.
_share_open_file(tmp_file.fileno(), _shared_group(tmp_dir)) _share_open_file(tmp_file.fileno(), _shared_group(tmp_dir))
os.replace(tmp_path, cache_path) os.replace(tmp_path, cache_path)
self._write_digests[key] = digest self._remember_write(key, cache_path, digest, stamped_at)
finally: finally:
if os.path.exists(tmp_path): if os.path.exists(tmp_path):
try: try:
@@ -471,7 +544,7 @@ class DiskCache:
with open(cache_path, 'wb') as cache_file: with open(cache_path, 'wb') as cache_file:
cache_file.write(payload) cache_file.write(payload)
_share_open_file(cache_file.fileno(), _shared_group(tmp_dir)) _share_open_file(cache_file.fileno(), _shared_group(tmp_dir))
self._write_digests[key] = digest self._remember_write(key, cache_path, digest, stamped_at)
self.logger.debug("Wrote cache for %s directly (non-atomic)", key) self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
except (IOError, OSError, PermissionError) as write_error: except (IOError, OSError, PermissionError) as write_error:
# If direct write also fails, try fallback location # If direct write also fails, try fallback location
@@ -520,6 +593,26 @@ class DiskCache:
) )
return # Exit gracefully without raising exception return # Exit gracefully without raising exception
def _remember_write(self, key: str, cache_path: str,
digest: Tuple[int, int], stamped_at: Optional[float]) -> None:
"""Record a completed write so an identical set() can skip the disk.
Caller holds _lock. A header-first record's mtime is set to its
timestamp, so only a skipped rewrite ever moves it later (see
"FRESHNESS OF A SKIPPED WRITE").
"""
try:
if stamped_at is not None:
os.utime(cache_path, (stamped_at, stamped_at))
st = os.stat(cache_path)
except OSError:
# Written but not stamped (another user's file, on the direct
# write path): mtime is the write time, which _refreshed_at reads
# as the write itself. Remember nothing; the next set() writes.
self._write_digests.pop(key, None)
return
self._write_digests[key] = (digest, (st.st_ino, st.st_size))
def clear(self, key: Optional[str] = None) -> None: def clear(self, key: Optional[str] = None) -> None:
""" """
Clear cache entry or all entries. Clear cache entry or all entries.
+6 -3
View File
@@ -8,7 +8,7 @@ import os
import time import time
import threading import threading
import logging import logging
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, Union
# Historical fixed ceiling, kept as the fallback when RAM cannot be read. # Historical fixed ceiling, kept as the fallback when RAM cannot be read.
DEFAULT_MAX_SIZE = 1000 DEFAULT_MAX_SIZE = 1000
@@ -70,13 +70,15 @@ class MemoryCache:
""" """
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self._cache: Dict[str, Dict[str, Any]] = {} self._cache: Dict[str, Dict[str, Any]] = {}
self._timestamps: Dict[str, float] = {} # Values are time.time() floats; get()/cleanup also accept a numeric
# string, as a timestamp may have been restored from serialized data.
self._timestamps: Dict[str, Union[float, str]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._max_size = max_size self._max_size = max_size
self._cleanup_interval = cleanup_interval self._cleanup_interval = cleanup_interval
self._last_cleanup = time.time() self._last_cleanup = time.time()
def get(self, key: str, max_age: Optional[int] = None) -> Optional[Dict[str, Any]]: def get(self, key: str, max_age: Optional[float] = None) -> Optional[Dict[str, Any]]:
""" """
Get value from memory cache. Get value from memory cache.
@@ -200,6 +202,7 @@ class MemoryCache:
max_age_for_cleanup = 3600 # 1 hour max_age_for_cleanup = 3600 # 1 hour
expired_keys = [] expired_keys = []
timestamp: Optional[Union[float, str]]
for key, timestamp in list(self._timestamps.items()): for key, timestamp in list(self._timestamps.items()):
if isinstance(timestamp, str): if isinstance(timestamp, str):
try: try:
+2 -6
View File
@@ -32,7 +32,6 @@ from typing import Any, Dict, List, Optional
import logging import logging
import threading import threading
import tempfile import tempfile
from src.exceptions import CacheError
from src.cache.memory_cache import MemoryCache, default_max_size from src.cache.memory_cache import MemoryCache, default_max_size
from src.cache.disk_cache import DiskCache from src.cache.disk_cache import DiskCache
from src.cache.cache_strategy import CacheStrategy from src.cache.cache_strategy import CacheStrategy
@@ -272,12 +271,9 @@ class CacheManager:
# Update memory cache first # Update memory cache first
self._memory_cache_component.set(key, data) self._memory_cache_component.set(key, data)
# Save to disk cache # DiskCache logs a failed write and raises CacheError, which the
try: # caller gets as is.
self._disk_cache_component.set(key, data) self._disk_cache_component.set(key, data)
except CacheError:
# Disk cache errors are already logged and raised by DiskCache
raise
def load_cache(self, key: str) -> Optional[Dict[str, Any]]: def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
"""Load data from cache with memory caching.""" """Load data from cache with memory caching."""
+303 -30
View File
@@ -1,53 +1,326 @@
# Common Utilities # src/common
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules. Helpers shared by core and plugins. This page lists every module, what it is
for, and whether plugins are expected to import it.
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`) Rules for the package:
The recommended way to lay out plugins that render legibly on **any** panel - Every module must import without display hardware: nothing here may import
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported `src.display_manager` or `src.plugin_system` at module level
from `src.common` for convenience; canonical import paths are ([`test/test_common_is_hardware_free.py`](../../test/test_common_is_hardware_free.py)).
`src.adaptive_layout` / `src.adaptive_images`. That keeps plugins that use it loadable by the web preview,
`scripts/check_plugin.py` and tests on a laptop.
- A plugin that imports a module added in a given core release must declare
that release as its minimum (`ledmatrix_min_version` in the manifest's
`versions` entry). The "Since" column gives the release; "—" means it
predates 3.1.0, "n/a" that plugins should not import it.
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
`configure_scroll`, `resolve_scroll_settings`, `refresh_hz_from_config`) and
the adaptive layout names below ([`__init__.py`](__init__.py)).
## Summary
| Module | For | Plugins import it? | Since |
|---|---|---|---|
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
| [`render_gate`](#render_gate) | Keep background Python off the GIL while the panel swaps | No, core-internal | n/a |
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
The `sports_*` mixin and card modules hold code the scoreboard plugins
used to carry as identical copies. Each module docstring lists what a host
class must provide. The plan behind them is in
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
## Adaptive layout and images
`src/adaptive_layout.py` and `src/adaptive_images.py` live outside this
package but are re-exported from `src.common`. They are the recommended way
to lay out a plugin that renders legibly on any panel size. Every
`BasePlugin` already has `self.layout`, `self.draw_fit()` and
`self.draw_image()`:
```python ```python
# Every BasePlugin already has self.layout and the draw helpers:
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout) regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
self.draw_image(away_logo, regs.away_slot, mode="fill_height", self.draw_image(away_logo, regs.away_slot, mode="fill_height",
crop_to_ink=True, cache_key=f"logo:{abbr}") crop_to_ink=True, cache_key=f"logo:{abbr}")
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
self.draw_fit(status, regs.status_band)
``` ```
Key pieces: `Region` (rect algebra: bands/columns/splits/offset), Key pieces: `Region`, the font ladders `LADDER_GRID` / `LADDER_ARCADE`,
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never `LayoutContext` (`fit_text`, `fit_image`, `by_tier`, `px`), and
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`, `scoreboard_regions()` / `media_row()`. Guide:
`px`), and composite carvers `scoreboard_regions()` / `media_row()`. [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
## API Helpers (`api_helper.py`) ## Modules
Utilities for making HTTP requests and handling API responses. ### api_helper
## Logo Helpers (`logo_helper.py`) [`api_helper.py`](api_helper.py). `APIHelper(cache_manager=None, ...)`:
`get()` and `post()` with retries, optional caching through the cache
manager, and a minimum interval between requests (`set_rate_limit()`). Also has
`fetch_espn_scoreboard()`, `fetch_espn_standings()` and
`fetch_espn_rankings()`.
Utilities for loading and managing team logos. ### bdf_font
## Text Helpers (`text_helper.py`) [`bdf_font.py`](bdf_font.py). The one BDF loader and rasterizer.
`load_bdf_face(path, size)` returns `(face, realised_px)`, falling back to
the file's native strike when it has none at `size`;
`draw_bdf_text(draw, text, x, y, face, color)` draws top-left anchored onto a
PIL `ImageDraw` the same way the panel does. `read_bdf_native_size(path)`
and `clear_face_cache()` round it out. Faces are cached per thread (FreeType
faces are not thread-safe). `DisplayManager`, `FontManager`, `element_style`
and the plugin test harness all use it. Most plugins get BDF text through
`display_manager.draw_text()` or `FontManager` and never import this.
Utilities for text processing and formatting. ### espn_dates
## Scroll Helpers (`scroll_helper.py`) [`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
splits a range into month and day requests ESPN accepts and merges the
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
Scoreboard plugins also bundle a copy for older cores.
Utilities for scrolling text on the display. ### favorite_team_check
## Permission Utilities (`permission_utils.py`) [`favorite_team_check.py`](favorite_team_check.py).
`FavoriteTeamCheck(logger, leagues)`, where `leagues` maps a league key to
`(display name, ESPN sport/league path)`. `schedule(league_key, favorites)`
checks the configured favourite team codes against ESPN's team list once per
league, on a daemon thread, and logs a bad code with the nearest real one, or
says the league has nothing on yet; `reset()` re-arms it after a config edit.
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
a copy for older cores.
Helpers for ensuring directory permissions and ownership are correct ### font_layout
when running as a service (used by `CacheManager` to set up its
persistent cache directory).
## Best Practices [`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
`ImageFont.truetype` with PIL's Basic layout engine pinned, so text lays out
the same whether or not the host Pillow has libraqm; use it for anything
drawn to the panel or compared against a golden image. `crisp_size()` gives
the size a bundled face renders on whole pixels at. `resolve_asset_path()`
resolves `assets/fonts/...` against the install root rather than the
working directory.
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly ### frame_timing
2. **Reuse utilities**: Check existing utilities before creating new ones
3. **Document additions**: Add documentation when adding new utilities [`frame_timing.py`](frame_timing.py). Core-internal. `DisplayManager`
records every presented frame in a `FrameTimingRecorder`, which writes
cumulative late-frame counters and histograms to `/dev/shm` for
`scripts/frame_soak.py` and `scripts/render_bench.py`. `StallWatchdog` logs
the stack of whatever holds up a scroll. See
[docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
### json_body
[`json_body.py`](json_body.py). `response_json(response)` is
`response.json()` parsed by orjson when it is installed, falling back to the
stdlib parser (and requests' own error) otherwise. For multi-MB payloads such
as a season schedule, where the parse holds the GIL and freezes the display.
A plugin that also runs on older cores should guard the import, as
`espn_dates` does.
### logo_helper
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
display_height, ...)`: `load_logo()`, `load_logo_with_download()`,
`get_logo_variations()`, `normalize_abbreviation()`, with an in-memory cache.
### path_safety
[`path_safety.py`](path_safety.py). Core-internal, used by web handlers that
open files named in a request. `safe_path_component(value)` returns the
value if it is one harmless path segment, else `None`;
`resolve_under(base, *parts)` returns the resolved path, or `None` if a part
is unsafe or the result would leave `base`; `safe_relative_parts()` splits a
relative path the same way. Both return the sanitised value rather than a
boolean, so a caller cannot check one string and open another.
### permission_utils
[`permission_utils.py`](permission_utils.py). The modes and ownership that
let the root display service and the web user share files:
`ensure_directory_permissions()`, `ensure_file_permissions()`, the
`get_*_mode()` functions, `ensure_shared_group_ownership()`,
`sudo_remove_directory()` and `install_requirements_file()` (the sudo
`safe_pip_install.sh` path). `ConfigManager`, `CacheManager` and the store
already call these; a plugin needs them only when it creates its own files
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
### render_gate
[`render_gate.py`](render_gate.py). Core-internal. `RenderGate` is opened by
the render thread around each vsync swap; a background thread inside
`gate.yielding()` (Vegas's prefetch) parks while the gate is closed, so the
render thread finds the GIL free when its refresh arrives. It never parks a
thread holding a guarded lock or inside logging, threading or import code.
### scroll_config
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
plugin_config=, global_config=, display_manager=, plugin_logger=)` reads a
plugin's scroll settings, snaps the speed to a whole number of pixels per
panel refresh, puts the helper in fixed-step mode and returns
`ScrollSettings`. Pass `settings.frame_hold` to
`display_manager.set_scrolling_state(True, frame_hold=...)` or the scroll
runs too fast. `resolve()` does the calculation without touching a helper.
See [docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
### scroll_helper
[`scroll_helper.py`](scroll_helper.py). `ScrollHelper(display_width,
display_height, logger=None)`: build a wide image once
(`create_scrolling_image()` or `set_scrolling_image()`), then per frame
`update_scroll_position()` and `get_visible_portion()`;
`is_scroll_complete()`, `calculate_dynamic_duration()` and
`get_dynamic_duration()` for timing. Configure it with `scroll_config`
rather than the `set_*` methods. Vegas mode reads a plugin's
`scroll_helper` image when the plugin has no `get_vegas_content()`.
### snapshot_policy
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
touch its mtime, or skip, based on whether a browser is watching the preview.
The web health check reads the file's age.
### sports_card
[`sports_card.py`](sports_card.py). Free functions taking `config`, `fonts`
and `logger` explicitly: card options (`scroll_card_option()`,
`vs_text()`, `upcoming_center_mode()`), colours (`element_color()`,
`font_color()`, `score_color_for()`, `recent_score_color()`), favourite-team
rules (`favorite_teams_for()`, `side_is_favorite()`, `favorite_result()`),
dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
method and delegates the body.
### sports_card_wrappers
[`sports_card_wrappers.py`](sports_card_wrappers.py).
`SportsCardWrappersMixin`: the one-line methods a scoreboard's game renderer
uses to call `sports_card` with its own `config` and `logger`
(`_vs_text()`, `_element_color()`, `_format_game_date()`, ... seventeen in
all), under their existing names. They are what `sports_game_renderer`'s
mixin expects its host to provide. No `__init__` and no state.
### sports_celebration
[`sports_celebration.py`](sports_celebration.py). `SportsCelebrationMixin`
draws the full-screen takeover a scoreboard shows when a team scores or wins
(`_draw_celebration_layout(celebration)`): a backdrop in the scoring team's
colours read off its crest, scenery, confetti, the headline and the score.
The colour helpers are free functions (`logo_palette()`, `lift_color()`,
`mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which
builds the celebration dict the docstring describes.
### sports_fetch
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
methods that decide which requests a scoreboard makes --
`_fetch_season_directly()` (a season, in chunks ESPN accepts),
`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live
lookback) and `_wants_live_odds()` (odds only for games near the screen).
### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py).
`SportsGameRendererMixin`: the scroll/Vegas card geometry (centre gap, logo
slot, layout offsets, upcoming-card date and time). No `__init__` and no
state; add it as a base class of the plugin's game renderer and override
what differs.
### sports_helpers
[`sports_helpers.py`](sports_helpers.py). Free functions `clamp_window()`,
`clamp_seconds()`, `logo_needs_refresh()`, `spread_weighted_order()`, and
`SportsHelpersMixin` with the scoreboards' `_mode_customization`,
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
Nothing in core uses it.
### sports_scroll
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
`SportsScrollDisplayManager`: the scroll-display orchestration the
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
`SCROLL_LEAGUE_KEYS`; see the module docstring for an example.
### sports_shared
[`sports_shared.py`](sports_shared.py). `SportsCoreSharedMixin`,
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` methods
that were identical in every scoreboard (game selection and rotation,
fonts, colours, dates, the switch-mode upcoming card). The docstring lists
the attributes the host class must have and the three methods deliberately
left out.
### sports_timezone
[`sports_timezone.py`](sports_timezone.py).
`resolve_timezone_name(config, plugin_manager, cache_manager, log, *,
plugin_label, writeback_fixed_in=None)` and `resolve_timezone(...)` (the same
as a pytz zone): the plugin's own `timezone`, then the global one via either
manager's `config_manager`, then the host's zone (`system_timezone_name()`),
then UTC. `plugin_label` names the plugin in the warning logged when nothing
resolves; `writeback_fixed_in` is for a plugin that once wrote `"UTC"` into
the saved config (a bare plugin-level `"UTC"` is then ignored when another
source disagrees). Scoreboard plugins also bundle a copy for older cores.
### sync_manager
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
links two displays as leader and follower (`sync.role` in config) over UDP
port 5765, plus TCP on the next port for scroll images. The leader drives the
scroll and sends the follower its part of each frame; a follower falls back
to its own plugins when the leader goes quiet. Rows and columns must match.
Created by `DisplayController`; works with any plugin.
### text_helper
[`text_helper.py`](text_helper.py). `TextHelper(font_dir=None, ...)`:
`load_fonts()`, `draw_text_with_outline()`, `get_text_width()`,
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
`draw_multiline_text()`, `create_text_image()`.
## Logging
Modules here create their logger with `logging.getLogger(__name__)`, which is
the same logger `src.logging_config.get_logger(__name__)` returns. The helper
classes (`APIHelper`, `LogoHelper`, `ScrollHelper`, `TextHelper`) and
`espn_dates` take an optional `logger`. In a plugin, pass `self.logger`: it is
created by `get_logger(..., plugin_id=...)` in `BasePlugin`, so messages carry
the plugin id.
## Adding a module
- Keep it importable without hardware (see the test above).
- Give it a module docstring that says what it is for and, if it is a mixin,
what the host class must provide.
- Add it to the table on this page and, if plugins may import it, to the
CHANGELOG with the release to floor on.
+59 -47
View File
@@ -1,8 +1,9 @@
""" """
API Helper API Helper
Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins. HTTP requests, response caching and ESPN fetch helpers for plugins
Extracted from LEDMatrix core to provide reusable functionality for plugins. (``from src.common import APIHelper``), plus the headers every core request
sends (:data:`USER_AGENT`, :data:`DEFAULT_HTTP_HEADERS`).
""" """
import logging import logging
@@ -10,12 +11,16 @@ import time
from datetime import datetime from datetime import datetime
from types import MappingProxyType from types import MappingProxyType
from src.common.espn_dates import ESPN_MAX_LIMIT from src.common.espn_dates import ESPN_MAX_LIMIT
from typing import Any, Dict, Mapping, Optional from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
if TYPE_CHECKING:
# What Session() puts in .headers; the stubs only promise a MutableMapping.
from requests.structures import CaseInsensitiveDict
#: The User-Agent core sends to ESPN and other data APIs. It names the client #: The User-Agent core sends to ESPN and other data APIs. It names the client
#: and links to it: around 2026-08-04 ESPN began 403ing bare custom tokens #: and links to it: around 2026-08-04 ESPN began 403ing bare custom tokens
@@ -36,13 +41,20 @@ DEFAULT_HTTP_HEADERS: Mapping[str, str] = MappingProxyType({
class APIHelper: class APIHelper:
""" """
Helper class for HTTP requests, caching, and ESPN API integration. HTTP requests with retries, response caching and ESPN helpers.
Provides functionality for: - Requests go through one ``requests.Session`` that retries GET, HEAD
- HTTP requests with retry logic and timeouts and OPTIONS on 429 and 5xx with exponential backoff, and sends
- Response caching with TTL support :data:`DEFAULT_HTTP_HEADERS`.
- ESPN API integration for sports data - Consecutive requests from one helper are spaced at least
- Request rate limiting and throttling ``set_rate_limit()`` seconds apart (1 second by default). A cache hit
does not count.
- With a ``cache_manager``, :meth:`get` caches the parsed JSON under
``cache_key`` for ``cache_ttl`` seconds. The lifetime is stored with
the entry, so CacheManager honours it on every later read, whatever
max_age that read asks for.
- Failed requests are logged and return None; nothing here raises for a
network or HTTP error.
""" """
def __init__(self, cache_manager=None, default_timeout: int = 30, def __init__(self, cache_manager=None, default_timeout: int = 30,
@@ -73,16 +85,14 @@ class APIHelper:
self.session.mount("https://", adapter) self.session.mount("https://", adapter)
self.session.mount("http://", adapter) self.session.mount("http://", adapter)
# Default headers self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
self.session.headers.update({
'User-Agent': USER_AGENT,
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Connection': 'keep-alive'
})
# Rate limiting # Rate limiting
self._last_request_time = 0 self._last_request_time: float = 0 # wall clock, reported by get_request_stats()
# The interval is measured on time.monotonic(): a wall-clock step
# back (NTP correcting a Pi with no RTC) made time_since_last
# negative and the "remaining interval" sleep as long as the step.
self._last_request_monotonic: Optional[float] = None
self._min_request_interval = 1.0 # Minimum seconds between requests self._min_request_interval = 1.0 # Minimum seconds between requests
def get(self, url: str, params: Optional[Dict] = None, def get(self, url: str, params: Optional[Dict] = None,
@@ -102,19 +112,18 @@ class APIHelper:
Returns: Returns:
Response data as dictionary or None if request fails Response data as dictionary or None if request fails
""" """
# Check cache first
if cache_key and self.cache_manager: if cache_key and self.cache_manager:
cached = self._get_from_cache(cache_key) cached = self._get_from_cache(cache_key, cache_ttl)
if cached is not None: if cached is not None:
self.logger.debug(f"Using cached response for {cache_key}") self.logger.debug(f"Using cached response for {cache_key}")
return cached return cast(Dict[Any, Any], cached)
# Rate limiting # Rate limiting
self._enforce_rate_limit() self._enforce_rate_limit()
try: try:
# Prepare request # Prepare request
request_headers = self.session.headers.copy() request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers: if headers:
request_headers.update(headers) request_headers.update(headers)
@@ -128,7 +137,7 @@ class APIHelper:
response.raise_for_status() response.raise_for_status()
# Parse JSON response # Parse JSON response
data = response.json() data: Dict[Any, Any] = response.json()
# Cache response if cache key provided # Cache response if cache key provided
if cache_key and self.cache_manager: if cache_key and self.cache_manager:
@@ -242,7 +251,7 @@ class APIHelper:
self._enforce_rate_limit() self._enforce_rate_limit()
try: try:
request_headers = self.session.headers.copy() request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers: if headers:
request_headers.update(headers) request_headers.update(headers)
@@ -255,7 +264,7 @@ class APIHelper:
) )
response.raise_for_status() response.raise_for_status()
return response.json() return cast(Optional[Dict[Any, Any]], response.json())
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self.logger.error(f"POST request failed for {url}: {e}") self.logger.error(f"POST request failed for {url}: {e}")
@@ -268,10 +277,10 @@ class APIHelper:
Args: Args:
key: Cache key key: Cache key
data: Data to cache data: Data to cache
ttl: Time-to-live in seconds (ignored - CacheManager doesn't support TTL) ttl: Seconds the entry stays valid. Stored with the entry, so
it applies to every later read of ``key``.
""" """
if self.cache_manager: self._set_cache(key, data, ttl)
self.cache_manager.set(key, data)
def get_cache(self, key: str) -> Optional[Any]: def get_cache(self, key: str) -> Optional[Any]:
""" """
@@ -281,20 +290,18 @@ class APIHelper:
key: Cache key key: Cache key
Returns: Returns:
Cached data or None if not found Cached data, or None if there is none or it has expired. An
entry written with a ttl (set_cache, get) expires after that ttl;
one written without expires after CacheManager's default max_age.
""" """
if self.cache_manager: return self._get_from_cache(key)
return self.cache_manager.get(key)
return None
def clear_cache(self, pattern: Optional[str] = None) -> None: def clear_cache(self, pattern: Optional[str] = None) -> None:
""" """
Clear cache data. Clear cache data.
Uses CacheManager's real surface (clear_cache / delete / Uses CacheManager's clear_cache(), or list_cache_files() and delete()
list_cache_files); safely no-ops on managers without it. The old for a pattern. A cache manager without those methods is left alone.
implementation guarded on a nonexistent ``clear`` method, so it
silently never cleared anything.
Args: Args:
pattern: Optional substring to match cache keys; only matching pattern: Optional substring to match cache keys; only matching
@@ -315,31 +322,33 @@ class APIHelper:
"cannot clear by pattern") "cannot clear by pattern")
elif hasattr(self.cache_manager, 'clear_cache'): elif hasattr(self.cache_manager, 'clear_cache'):
self.cache_manager.clear_cache() self.cache_manager.clear_cache()
elif hasattr(self.cache_manager, 'clear'):
self.cache_manager.clear()
else: else:
self.logger.debug("Cache manager exposes no clear method; no-op") self.logger.debug("Cache manager exposes no clear method; no-op")
def _get_from_cache(self, key: str) -> Optional[Any]: def _get_from_cache(self, key: str, max_age: Optional[int] = None) -> Optional[Any]:
"""Get data from cache.""" """Cached data for ``key``, or None. ``max_age`` only matters for an
if self.cache_manager: entry stored without a ttl; one stored with a ttl uses that."""
return self.cache_manager.get(key) if not self.cache_manager:
return None return None
if max_age is None:
return self.cache_manager.get(key)
return self.cache_manager.get(key, max_age=max_age)
def _set_cache(self, key: str, data: Any, ttl: int) -> None: def _set_cache(self, key: str, data: Any, ttl: Optional[int]) -> None:
"""Set data in cache.""" """Store ``data`` under ``key`` for ``ttl`` seconds."""
if self.cache_manager: if self.cache_manager:
self.cache_manager.set(key, data) self.cache_manager.set(key, data, ttl=ttl)
def _enforce_rate_limit(self) -> None: def _enforce_rate_limit(self) -> None:
"""Enforce rate limiting between requests.""" """Enforce rate limiting between requests."""
current_time = time.time() if self._last_request_monotonic is not None:
time_since_last = current_time - self._last_request_time time_since_last = time.monotonic() - self._last_request_monotonic
if time_since_last < self._min_request_interval: if time_since_last < self._min_request_interval:
sleep_time = self._min_request_interval - time_since_last sleep_time = self._min_request_interval - time_since_last
time.sleep(sleep_time) time.sleep(sleep_time)
self._last_request_monotonic = time.monotonic()
self._last_request_time = time.time() self._last_request_time = time.time()
def set_rate_limit(self, min_interval: float) -> None: def set_rate_limit(self, min_interval: float) -> None:
@@ -362,5 +371,8 @@ class APIHelper:
return { return {
'min_request_interval': self._min_request_interval, 'min_request_interval': self._min_request_interval,
'last_request_time': self._last_request_time, 'last_request_time': self._last_request_time,
'time_since_last_request': time.time() - self._last_request_time 'time_since_last_request': (
time.monotonic() - self._last_request_monotonic
if self._last_request_monotonic is not None
else time.time() - self._last_request_time),
} }
+261
View File
@@ -0,0 +1,261 @@
"""Loading and drawing BDF bitmap fonts: one loader, one rasterizer.
BDF fonts are fixed-size bitmap strikes. FreeType renders them at the size
baked into the file and rejects any other size, and PIL cannot draw a
``freetype.Face`` at all, so the core draws BDF text itself, glyph by glyph.
This used to be done in several places that drifted apart:
``FontManager``, ``element_style`` and ``DisplayManager`` each loaded faces
their own way, and ``DisplayManager`` and the plugin test harness
(``VisualTestDisplayManager``) each had a copy of the glyph drawing loop. The
harness renders plugin golden images and ``check_plugin`` / ``dev_server``
previews, so a copy that differs from the panel's shows something the panel
never draws. Everything now goes through the two functions here:
* :func:`load_bdf_face` -- a ``freetype.Face`` at the requested pixel size,
or at the file's native strike when the file has no strike at that size.
* :func:`draw_bdf_text` -- draw a string in a ``freetype.Face`` onto a PIL
``ImageDraw``, top-left anchored like ``ImageDraw.text``.
Only PIL and freetype-py are imported, so the module is as cheap to import
from the test harness as from core.
"""
from __future__ import annotations
import ctypes
import logging
import os
import threading
from collections import OrderedDict
from typing import Any, Optional, Sequence, Tuple
from PIL import Image
try:
import freetype
except ImportError: # pragma: no cover - freetype-py is a core requirement
freetype = None
logger = logging.getLogger(__name__)
__all__ = ["read_bdf_native_size", "load_bdf_face", "draw_bdf_text"]
# --------------------------------------------------------------------------
# Loading
# --------------------------------------------------------------------------
def read_bdf_native_size(bdf_path: str) -> Optional[int]:
"""A BDF file's one true pixel size, read from its header, or None.
Prefers the PIXEL_SIZE property, which states the real pixel height
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE is
absent, since point-size only equals pixel height at exactly 100dpi --
several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined at 75dpi, where
the two values genuinely differ. Stops at the first STARTCHAR.
"""
size_line_value = None
try:
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
for line in f:
if line.startswith("PIXEL_SIZE"):
parts = line.split()
if len(parts) >= 2:
return int(float(parts[1]))
elif line.startswith("SIZE") and size_line_value is None:
# Format: "SIZE <point_size> <xres> <yres>"
parts = line.split()
if len(parts) >= 2:
size_line_value = int(float(parts[1]))
elif line.startswith("STARTCHAR"):
break
except (OSError, ValueError):
return None
return size_line_value
#: Loaded faces, keyed on (absolute path, requested size, mtime_ns, file size)
#: so a font file replaced on disk under the same name is loaded afresh.
#: Bounded LRU: the display process runs for weeks and every config save can
#: introduce a new (font, size) pair, but a panel draws from a handful.
_FACE_CACHE_MAX = 256
_face_cache: "OrderedDict[tuple, Tuple[Any, int]]" = OrderedDict()
_face_cache_lock = threading.Lock()
def _face_at(path: str, size_px: int) -> Any:
face = freetype.Face(path)
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
return face
def load_bdf_face(path: str, size_px: int) -> Tuple[Any, int]:
"""``(face, realised_px)`` for the BDF file at ``path``.
``realised_px`` is ``size_px`` when the file has a strike at that size,
otherwise the file's native size: FreeType refuses any other size for a
bitmap font, and answering that with some other typeface (which both
``FontManager`` and ``element_style`` once did) is worse than drawing the
font that was asked for at the size it can do. Callers that lay out by
size need ``realised_px``, not the size they asked for.
Faces are cached per thread. A ``freetype.Face`` holds per-glyph state
(``load_char`` rewrites its glyph slot), and FreeType does not allow two
threads to use one face at once, so the display thread and a plugin's
update thread must never be handed the same object. Within a thread the
face is shared by every caller. Raises if the file can't be loaded at
either size.
"""
if freetype is None:
raise RuntimeError("freetype-py is not installed; BDF fonts need it")
size_px = int(size_px)
abs_path = os.path.abspath(path)
try:
st = os.stat(abs_path)
key = (threading.get_ident(), abs_path, size_px,
st.st_mtime_ns, st.st_size)
except OSError:
key = None # let freetype raise its own error below
if key is not None:
with _face_cache_lock:
cached = _face_cache.get(key)
if cached is not None:
_face_cache.move_to_end(key)
return cached
try:
entry = (_face_at(abs_path, size_px), size_px)
except Exception:
native = read_bdf_native_size(abs_path)
if not native or native == size_px:
raise
# A fresh Face: the first one already took a failed set_char_size.
entry = (_face_at(abs_path, native), native)
logger.debug(
"BDF font %s requested at %spx renders at its native %spx "
"(the file has no strike at the requested size)",
abs_path, size_px, native,
)
if key is not None:
with _face_cache_lock:
_face_cache[key] = entry
_face_cache.move_to_end(key)
while len(_face_cache) > _FACE_CACHE_MAX:
_face_cache.popitem(last=False)
return entry
def clear_face_cache() -> None:
"""Drop every cached face (tests; a font directory swapped wholesale)."""
with _face_cache_lock:
_face_cache.clear()
# --------------------------------------------------------------------------
# Drawing
# --------------------------------------------------------------------------
def _bitmap_bytes(bitmap: Any, nbytes: int) -> bytes:
"""The first ``nbytes`` of a glyph bitmap's buffer, zero-padded.
``bitmap.buffer`` builds a Python list one byte at a time; reading the
underlying FT_Bitmap directly is the same bytes without that cost.
"""
raw = getattr(bitmap, "_FT_Bitmap", None)
if raw is not None and raw.buffer:
return ctypes.string_at(raw.buffer, nbytes)
buf = bytes(bitmap.buffer[:nbytes])
if len(buf) < nbytes:
buf += bytes(nbytes - len(buf))
return buf
def _glyph_points(bitmap: Any, left: int, top: int,
clip_w: int, clip_h: int) -> list:
"""Every lit pixel of a glyph, clipped, as ``(x, y)`` pairs.
The reference definition of which pixels a glyph lights: the MSB-first
bit ``j`` of byte ``i * pitch + j // 8``. Used only where the fast path
below can't express exactly the same thing.
"""
buffer = bitmap.buffer
pitch = bitmap.pitch
points = []
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * pitch + (j // 8)
if byte_index < len(buffer) and buffer[byte_index] & (1 << (7 - (j % 8))):
px = left + j
py = top + i
if 0 <= px < clip_w and 0 <= py < clip_h:
points.append((px, py))
return points
def draw_bdf_text(draw: Any, text: str, x: int, y: int, face: Any,
color: Any = (255, 255, 255),
clip: Optional[Sequence[int]] = None) -> int:
"""Draw ``text`` in a ``freetype.Face`` with ``draw``; return the pen x.
``(x, y)`` is the top-left of the line, as for ``ImageDraw.text``: the
baseline is ``y`` plus the face's ascender. Each glyph's lit bits are set
to ``color`` exactly -- no blending, no anti-aliasing -- and pixels
outside ``[0, clip_w) x [0, clip_h)`` are skipped (``clip`` defaults to
the image size). The pen advances by each glyph's advance width.
Glyphs are drawn as 1-bit masks with ``ImageDraw.bitmap`` rather than a
point at a time, which is pixel-identical and far faster. A ``draw`` that
blends (``ImageDraw.Draw(rgb_image, "RGBA")``) is drawn point by point, so
a translucent colour still blends exactly as it always has.
Errors (a non-BDF ``face``, a bad colour) propagate after any glyphs
before the failing one are drawn; callers decide whether to log them.
"""
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
if clip is None:
clip_w, clip_h = draw.im.size
else:
clip_w, clip_h = int(clip[0]), int(clip[1])
blending = draw.mode != draw.im.mode
for char in text:
face.load_char(char)
glyph = face.glyph
bitmap = glyph.bitmap
rows, width, pitch = bitmap.rows, bitmap.width, bitmap.pitch
left = x + glyph.bitmap_left
top = baseline_y - glyph.bitmap_top
if rows > 0 and width > 0:
if blending or pitch <= 0:
points = _glyph_points(bitmap, left, top, clip_w, clip_h)
if points:
draw.point(points, fill=color)
else:
# The visible part of the glyph box, in glyph coordinates.
x0, y0 = max(0, -left), max(0, -top)
x1, y1 = min(width, clip_w - left), min(rows, clip_h - top)
if x0 < x1 and y0 < y1:
# Raw mode "1" with stride=pitch reads exactly the bits
# _glyph_points does, whatever the glyph's pixel mode.
mask = Image.frombytes(
"1", (width, rows), _bitmap_bytes(bitmap, rows * pitch),
"raw", "1", pitch)
if (x0, y0, x1, y1) != (0, 0, width, rows):
mask = mask.crop((x0, y0, x1, y1))
# An all-blank glyph draws nothing -- and, as before,
# never touches the colour.
if mask.getbbox() is not None:
draw.bitmap((left + x0, top + y0), mask, fill=color)
x += glyph.advance.x >> 6
return x
+4 -4
View File
@@ -37,7 +37,7 @@ import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import date, timedelta from datetime import date, timedelta
from functools import partial from functools import partial
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple, cast
try: try:
from src.common.json_body import response_json from src.common.json_body import response_json
@@ -159,7 +159,7 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
return chunks return chunks
def merge_scoreboard_payloads(payloads: List[Dict[str, Any]]) -> Dict[str, Any]: def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
"""Fold chunk responses into one scoreboard payload. """Fold chunk responses into one scoreboard payload.
Events are de-duplicated by id and keep first-seen order. Non-event keys Events are de-duplicated by id and keep first-seen order. Non-event keys
@@ -202,7 +202,7 @@ def _fetch_one_chunk(
timeout=timeout, timeout=timeout,
) )
response.raise_for_status() response.raise_for_status()
return response_json(response) return cast(Optional[Dict[str, Any]], response_json(response))
except Exception as exc: # noqa: BLE001 - see docstring except Exception as exc: # noqa: BLE001 - see docstring
if logger: if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc) logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
@@ -379,4 +379,4 @@ def fetch_espn_scoreboard(
if data is not None: if data is not None:
return data return data
response.raise_for_status() response.raise_for_status()
return response_json(response) return cast(Dict[str, Any], response_json(response))
+409
View File
@@ -0,0 +1,409 @@
"""
Explain an empty screen: a wrong team code, or a season that has not started.
Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking
code silently matches nothing and the plugin shows an empty screen with no hint
that the code is at fault. The codes are not always guessable — ESPN calls
Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than
``GSW``. Between seasons a perfectly correct code produces the same empty
screen for a completely different reason, and the two were indistinguishable
from the logs.
This module is diagnostics only. It runs on a daemon thread, once per league per
process, and every failure is swallowed: it must never delay a frame or change
what is displayed.
"""
import difflib
import logging
import re
import threading
from datetime import datetime, timezone
from typing import Dict, Iterable, List, Optional, Set, Tuple
TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000"
SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard"
REQUEST_TIMEOUT = 15
class FavoriteTeamCheck:
"""
Validates configured favourite team codes against ESPN, and says so in the log.
``leagues`` maps the plugin's own league key to a
``(human readable name, ESPN sport/league path)`` pair, e.g.
``{'nhl': ('NHL', 'hockey/nhl')}``.
"""
# How far out the next fixture has to be before it is worth mentioning.
# An off day or two is normal mid-season and saying so would just be noise.
GAP_DAYS = 3
def __init__(self, logger: Optional[logging.Logger],
leagues: Dict[str, Tuple[str, str]]) -> None:
self.logger = logger or logging.getLogger(__name__)
self.leagues = leagues
self._checked: Set[str] = set()
self._lock = threading.Lock()
def reset(self) -> None:
"""Re-check on the next call, e.g. after the user edits the config."""
with self._lock:
self._checked.clear()
def schedule(self, league_key: str, favorites: Iterable[str]) -> None:
"""Check one league in the background, at most once per process."""
try:
favorites = [str(f) for f in (favorites or []) if str(f).strip()]
if not favorites or league_key not in self.leagues:
return
with self._lock:
if league_key in self._checked:
return
self._checked.add(league_key)
threading.Thread(
target=self._run, args=(league_key, favorites),
name="favorite-team-check", daemon=True,
).start()
except Exception:
pass # nosec B110 - a diagnostic must never be the reason an update fails # nosemgrep
def _run(self, league_key: str, favorites) -> None:
try:
self._check(league_key, favorites)
except Exception as exc:
self.logger.debug("Favorite team check failed for %s: %s",
league_key, exc)
def _check(self, league_key: str, favorites) -> None:
name, path = self.leagues[league_key]
try:
teams = self._fetch_teams(path)
except Exception as exc:
self.logger.debug("Could not verify %s favorite teams: %s", name, exc)
return
if not teams:
# Some ESPN endpoints (college lacrosse) return no teams at all.
# Nothing can be concluded, so say nothing.
return
# Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not
# team codes and must not be reported as bad ones.
codes = [f for f in favorites if not self._is_dynamic(f)]
recognised = [f for f in codes if f in teams]
unknown = [f for f in codes if f not in teams]
for code in unknown:
self.logger.warning(
"%s favorite team %r is not a %s team code.%s "
"Every code this league accepts is listed at %s.",
name, code, name, self._suggest(code, teams),
TEAMS_URL.format(path=path),
)
if codes and not recognised:
self.logger.warning(
"%s has no recognised favorite teams, so nothing will be shown "
"for it. Codes must be ESPN abbreviations, e.g. %s.",
name, ", ".join("{} ({})".format(a, n)
for a, n in list(sorted(teams.items()))[:3]),
)
return
if not recognised:
return
# Codes are fine, so check the other cause of an empty screen.
try:
note = self._schedule_note(path)
except Exception as exc:
self.logger.debug("Could not check the %s schedule: %s", name, exc)
return
if note:
self.logger.info(
"%s favorite teams %s look correct, but %s. An empty display "
"until then is expected, not a configuration problem.",
name, ", ".join(recognised), note,
)
else:
self.logger.info("%s favorite teams recognised: %s",
name, ", ".join(recognised))
@staticmethod
def _is_dynamic(code: str) -> bool:
upper = (code or "").strip().upper()
return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper
@staticmethod
def _fetch_teams(path: str) -> Dict[str, str]:
"""ESPN's {abbreviation: display name} for a league.
``limit=1000`` is required: the default page size truncates the NCAA
responses to roughly half their teams, which makes valid codes look wrong.
"""
import requests
payload = requests.get(TEAMS_URL.format(path=path),
timeout=REQUEST_TIMEOUT).json()
entries = payload['sports'][0]['leagues'][0]['teams']
return {
t['team']['abbreviation']: t['team']['displayName']
for t in entries if t.get('team', {}).get('abbreviation')
}
@classmethod
def _schedule_note(cls, path: str) -> Optional[str]:
"""
Why the league has nothing to show, as a clause, or ``None`` if it does.
Two things make this harder than reading ``events``:
* An out-of-season league does not come back empty. ESPN rolls the
scoreboard forward to the next day that has fixtures, so in July the
NHL endpoint returns seven September games. Emptiness cannot be the
signal; the date of those games is, and it is more useful anyway.
* A *finished* season rolls nowhere and returns its last game instead,
months in the past — so dates have to be filtered to the future
before the soonest one means anything.
"""
import requests
payload = requests.get(SCOREBOARD_URL.format(path=path),
timeout=REQUEST_TIMEOUT).json()
event_dates = [cls._parse_date(e.get('date'))
for e in payload.get('events') or []]
calendar_dates = []
for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []:
calendar_dates.append(cls._parse_date(
entry if isinstance(entry, str) else entry.get('startDate')))
# Count the last day as current, rather than filtering on "later than
# right now": a game that began a few hours ago still means the league
# has something on, and dropping it would report a live slate as a
# finished season. A day's grace also keeps this correct whatever the
# user's timezone, since these timestamps are UTC.
now = datetime.now(timezone.utc)
def future(candidates):
return sorted(d for d in candidates if d and (now - d).days < 1)
# Events are fixtures; the calendar is week and phase boundaries,
# which routinely open days before their first game (an NFL week 1
# calendar entry starts the weekend before the Thursday opener).
# Reading the two together reported the earliest boundary as a game
# date -- "nothing on until 06 September" for a league whose first
# snap is the 10th. The calendar only gets a say when the scoreboard
# has no events at all to roll forward to: events that exist but are
# all in the past mean the season is over, and an offseason calendar
# phase must not be dressed up as its next game.
#
# The exception is a calendar of match days. With calendarType "day"
# and calendarIsWhitelist true, every entry is a day that has games,
# so a future entry is a real next fixture. Soccer needs it: between
# matchdays the scoreboard keeps showing the last one, so on
# 2026-09-29 every Premier League event was from 20 September and the
# next games (10 October) were only in the calendar. A day calendar
# that is not a whitelist (MLB's) lists days *without* games.
if any(event_dates):
upcoming = future(event_dates)
if not upcoming and cls._calendar_is_match_days(payload):
upcoming = future(calendar_dates)
else:
upcoming = future(calendar_dates)
if not upcoming:
if not any(event_dates) and not any(calendar_dates):
return None # Nothing published either way; draw no conclusion.
if cls._moved_to_later_phase(payload):
return None # e.g. postseason under way; see the method.
if cls._later_round_scheduled(payload, now):
return None # e.g. Europa League between matchdays.
return ("the season has finished and the next one's fixtures are "
"not published yet")
# A day or two out is just an off day, and saying so would be noise.
if (upcoming[0] - now).days < cls.GAP_DAYS:
return None
return "the league has nothing on until {}".format(
upcoming[0].strftime('%d %B %Y'))
@staticmethod
def _calendar_is_match_days(payload) -> bool:
"""Whether the league calendar lists the days that have games."""
league = (payload.get('leagues') or [{}])[0] or {}
return (league.get('calendarType') == 'day'
and league.get('calendarIsWhitelist') is True)
@staticmethod
def _moved_to_later_phase(payload) -> bool:
"""
Whether the league is in a later in-season phase than its events.
ESPN does not roll the scoreboard forward into a postseason. The day
after MLB's regular season ended, the default scoreboard still returned
that last regular-season day, while ``leagues[0].season`` already said
Postseason and the wild-card games were two days out. Past events alone
then read as a finished season while the same process's upcoming
manager was showing the favourite's playoff games.
Only regular season (2) and postseason (3) count as "later". The
offseason (4) follows the postseason too, and there past events really
do mean the season is over.
"""
season = ((payload.get('leagues') or [{}])[0] or {}).get('season') or {}
league_type = (season.get('type') or {}).get('type')
if league_type not in (2, 3):
return False
event_types = [(e.get('season') or {}).get('type')
for e in payload.get('events') or []]
known = [t for t in event_types if isinstance(t, int)]
return bool(known) and all(t < league_type for t in known)
@classmethod
def _later_round_scheduled(cls, payload, now: datetime) -> bool:
"""
Whether a "list" calendar has a round that has not started yet.
Competitions with a list calendar (the UEFA club competitions, the
World Cup, AFL, NFL) give each phase its rounds as ``entries`` with
start and end dates. Between matchdays the Europa League scoreboard
keeps showing the last one: on 2026-09-29 every event was from 17
September, the next matchday was only days away, and the rounds from
the knockout play-offs to the final were all still to come. A round
that starts later means the season is not over, even though the
date of the next fixture is not known.
Only a round's *start* counts. End dates are padded well past the
last game -- the World Cup's final round ran to 1 August for a 19 July
final -- so a future end date is also true of a finished season.
Rounds in an offseason phase (the college football All-Star week)
are not games for the favourites and do not count either.
"""
league = (payload.get('leagues') or [{}])[0] or {}
for phase in league.get('calendar') or []:
if not isinstance(phase, dict) or cls._is_offseason(phase.get('label')):
continue
for entry in phase.get('entries') or []:
if not isinstance(entry, dict) or cls._is_offseason(entry.get('label')):
continue
start = cls._parse_date(entry.get('startDate'))
if start and start > now:
return True
return False
@staticmethod
def _is_offseason(label) -> bool:
"""'Off Season', 'Offseason', 'Off-season' ..."""
return isinstance(label, str) and 'offseason' in re.sub(
r'[^a-z]', '', label.lower())
@staticmethod
def _parse_date(raw) -> Optional[datetime]:
if not raw or not isinstance(raw, str):
return None
try:
return datetime.fromisoformat(raw.replace('Z', '+00:00'))
except ValueError:
return None
@classmethod
def _suggest(cls, code: str, teams: Dict[str, str]) -> str:
"""Nearest matching code for a typo, as a ready-to-log clause."""
upper = (code or "").strip().upper()
if not upper or code in teams:
return ""
# Right code, wrong case — matching is case-sensitive. Guard on the case
# actually differing, so a valid code never draws this message.
for abbr in teams:
if abbr.upper() == upper:
return " Codes are case-sensitive; use {!r} ({}).".format(
abbr, teams[abbr])
ranked = cls._rank(upper, (a for a, n in teams.items()
if cls._abbreviates(upper, n)), teams)
if not ranked:
# Nicknames are often a fragment of a word rather than its initials:
# 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require
# three characters, since shorter fragments match far too much.
if len(upper) >= 3:
ranked = cls._rank(
upper,
(a for a, n in teams.items()
if any(upper in w for w in cls._words(n))),
teams)
if len(ranked) == 1:
return " Closest match is {!r} ({}).".format(
ranked[0], teams[ranked[0]])
if ranked:
return " Did you mean {}?".format(", ".join(
"{!r} ({})".format(a, teams[a]) for a in ranked[:3]))
# Otherwise fall back to similarity, against names before codes: a name
# gives more characters to compare and so produces fewer ties.
names = {n.upper(): a for a, n in teams.items()}
hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6)
if hits:
abbr = names[hits[0]]
return " Closest match is {!r} ({}).".format(abbr, teams[abbr])
code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6)
if code_hits:
return " Closest match is {!r} ({}).".format(
code_hits[0], teams[code_hits[0]])
return ""
@staticmethod
def _words(name: str):
return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
@classmethod
def _rank(cls, code: str, candidates, teams: Dict[str, str]):
"""
Order candidate codes best-first.
A code that picks up the *first* word of the name wins, because that is
how people shorten team names: 'SCAR' for South Carolina starts at
'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without
this the tie is broken alphabetically and the obvious answer can land
third in the list.
"""
def key(abbr):
words = cls._words(teams.get(abbr, ''))
first_word_hit = bool(words) and words[0].startswith(code[:1])
return (not first_word_hit, len(abbr), abbr)
return sorted(set(candidates), key=key)
@staticmethod
def _abbreviates(code: str, name: str) -> bool:
"""
Whether ``code`` reads as an abbreviation of ``name``.
Each part of the code must be a prefix of one of the name's words, taken
in order — which is how people actually shorten team names. Plain string
similarity is no use for three-letter codes: 'MUN' scores identically
against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the
suggestion is a coin flip. This rule separates them, because 'MUN'
splits as M-anchester UN-ited while Sunderland has no word starting M.
"""
words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
def consume(rest: str, remaining: List[str]) -> bool:
if not rest:
return True
if not remaining:
return False
head, tail = remaining[0], remaining[1:]
# Skip this word entirely, as in "Manchester United" -> "UTD".
if consume(rest, tail):
return True
for size in range(1, min(len(rest), len(head)) + 1):
if head.startswith(rest[:size]) and consume(rest[size:], tail):
return True
return False
return consume((code or "").strip().upper(), words)
+5 -4
View File
@@ -67,10 +67,11 @@ _INSTALL_ROOT = Path(__file__).resolve().parents[2]
def resolve_asset_path(relative_path: str) -> str: def resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd. """Resolve a repo-relative asset path independently of the process cwd.
Prefers the path as given — so an absolute path is returned untouched and In order: an absolute path that exists is returned untouched; otherwise
behaviour is unchanged wherever the cwd already happened to be the install ``relative_path`` under the install root derived above, if that exists;
root — then the install root derived above, then the original string so a otherwise ``relative_path`` unchanged, so a caller that wants to raise
caller that wants to raise and fall back still can. and fall back still can. The cwd is never consulted, so a relative path
means the same file whichever directory the process started in.
Without the fallback, any process started outside the install root (the Without the fallback, any process started outside the install root (the
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
+615
View File
@@ -0,0 +1,615 @@
"""System-wide frame timing: one set of numbers for every presented frame.
Each scroller already logs its own stats line (ScrollHelper.log_frame_rate,
the Vegas coordinator's "Vegas FPS"), but in different formats, per source,
and Vegas only logs a healthy window at DEBUG. None of that answers the
question a release has to answer on each rig: *over a long run, how often did
a moving frame reach the panel late?*
Every frame reaches the panel through ``DisplayManager.update_display``, so it
is recorded there, once, whoever drew it. The render thread only appends a
tuple; a worker thread aggregates, and every ``flush_interval`` seconds writes
cumulative counters and histograms to a small JSON file -- in ``/dev/shm`` where
it exists, so a stats file refreshed all day costs no SD-card writes.
``scripts/frame_soak.py`` reads it twice and reports the difference.
What is counted
---------------
Only intervals between two consecutive *scrolling* frames count: a static
screen that changes once a second has no timing to get wrong, and the first
frame of a scroll has no predecessor worth measuring against.
"Scrolling" is DisplayManager's scroll state when the frame is presented, and
that state can go missing in the middle of a scroll. It expires after 2s
without scroll activity, which a long enough stall outlasts, and any thread can
clear it: plugins call ``set_scrolling_state(False)`` from their own
``display()``, and Vegas captures some of those on the render thread between
two of its frames. The frame after that is recorded as static, and the interval
it ends -- the stall, or the capture -- would vanish from the report. So a
single static frame between two scrolling ones, with the scroll picking up
again within ``RESUME_SECONDS``, is treated as a frame of the scroll: both of
its intervals count. A second static frame in a row means the scroll really
ended. (On hdpi on 2026-09-24 the watchdog logged a 1.9s stall that the soak
report did not have; this is how.)
A frame held for ``hold`` refreshes should arrive ``hold`` refresh periods
after the one before it. One that arrives a whole refresh or more after that is
**late**: the panel showed the previous frame again, which on a moving strip is
a visible hitch. ``missed_refreshes`` sums how many refreshes late.
An interval of ``FREEZE_SECONDS`` or more is a **freeze** instead -- a
recompose, a plugin handover, a blocking call on the render thread. Those are
counted separately, both because they are a different fault and because
folding a single 400ms handover into the late count as "40 missed refreshes"
would drown the jitter the late count exists to measure. ``freeze_by`` splits
them by length. Intervals of ``GAP_SECONDS`` or more are ignored as not being
frames of one scroll at all.
A frame that arrives a whole refresh or more *early* means the swap did not
wait for the panel: the emulator, the fallback display, or a hold that was not
the one in effect. Those are counted as **early**, and a run with more than a
trace of them was not locked to the panel, so its late count means nothing.
The refresh period is estimated from the frames themselves: swaps that block
on vsync can only land on refresh boundaries, so the low end of
interval / hold is the period. It is the smallest per-window 10th percentile
seen so far, over windows with enough frames to trust -- except that a window
cutting it by more than ``MAX_REFRESH_DROP`` is ignored. A panel's refresh does
not jump like that; swaps that stopped blocking do, and adopting their period
would make every early frame look on time.
A caller that has measured the panel independently -- ``scripts/render_bench.py``
times bare swaps first with :func:`measure_refresh_hz` -- passes that rate in
as ``refresh_hz``. The estimate then starts from it instead of from the frames,
which is what catches a loop that never locked at all: one that free-runs
faster than the panel (every frame early) or sits at half its rate (every
frame late), both of which look self-consistent to an estimate taken from
their own intervals.
Stall watchdog
--------------
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
same frames from its own thread and, when a scroll's last frame is more than
``STALL_SECONDS`` old, logs the stack of the thread that presented it and the
top of every other thread's, so the log names what the render thread was
waiting on. It also measures how late its own wake-up was: if the watchdog was
held up as long as the render thread, the whole interpreter was blocked (C
code holding the GIL, or the process not scheduled), not one thread on a lock.
Set ``LEDMATRIX_STALL_WATCHDOG=0`` to turn it off, or
``LEDMATRIX_STALL_WATCHDOG_MS`` to dump at a lower threshold -- 30 catches
frames three refreshes late, which is where GIL contention shows. It polls
three times per threshold, so keep it to diagnostic runs, not soaks.
"""
from __future__ import annotations
import copy
import json
import logging
import os
import queue
import sys
import tempfile
import threading
import time
import traceback
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
logger = logging.getLogger(__name__)
#: Bumped when a field changes meaning, so a reader can refuse stale files.
SCHEMA_VERSION = 1
#: Histogram resolution. 64ms of range covers any frame worth drawing a
#: distribution of; everything beyond lands in the last bucket.
BUCKET_MS = 0.25
BUCKET_COUNT = 256
#: See the module docstring.
FREEZE_SECONDS = 0.25
#: Intervals this long are not frames of one scroll. This used to be 1s,
#: which silently dropped every 1-2s stall inside a scroll. It is now only a
#: sanity bound.
GAP_SECONDS = 5.0
#: A frame recorded as static between two scrolling frames is a frame of the
#: scroll whose state went missing, if the scroll resumes within this long.
#: See "What is counted".
RESUME_SECONDS = 1.0
#: Buckets for freeze length, as cumulative counters a soak can difference.
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
(float("inf"), "2s+"))
#: A window may lower the refresh-period estimate by at most this fraction.
MAX_REFRESH_DROP = 0.2
#: A window needs this many scrolling frames before its refresh estimate is
#: trusted -- about a second of scrolling.
MIN_FRAMES_FOR_REFRESH = 90
FLUSH_INTERVAL = 10.0
#: A scroll's last frame older than this is a stall worth a stack dump.
STALL_SECONDS = 0.25
#: How often the watchdog looks. Also the resolution of its starvation check.
WATCHDOG_POLL_SECONDS = 0.05
#: At most one stack dump per this many seconds: a stall that repeats every
#: extension would otherwise write the same stacks to the SD card all day.
STALL_LOG_INTERVAL = 30.0
#: Written by the display service, read by scripts/frame_soak.py and anything
#: else that wants the numbers. The web UI's viewer marker lives in /tmp; this
#: goes to RAM where there is some, since it is rewritten all day.
STATS_FILENAME = "ledmatrix_frame_stats.json"
def default_stats_path() -> str:
# A fixed name in a shared directory is safe here: write() creates its
# temp file with mkstemp and os.replace()s it over this path, which swaps
# out whatever is there -- a planted symlink included -- without following it.
base = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() # nosec B108
return os.path.join(base, STATS_FILENAME)
def _bucket(seconds: float) -> int:
index = int(seconds * 1000.0 / BUCKET_MS)
return min(max(index, 0), BUCKET_COUNT - 1)
def binding_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
The stock binding blocks in SwapOnVSync holding the GIL, which starves
every other thread for most of each frame (docs/SCROLL_PERFORMANCE.md).
scripts/build_rgbmatrix_nogil.sh rebuilds it, and the rebuilt module links
PyEval_SaveThread where the stock one never does -- a crude test, but the
only one that needs neither a probe on the panel nor the source tree the
module was built from. None when no hardware binding is loaded.
"""
module = sys.modules.get("rgbmatrix.core")
path = getattr(module, "__file__", None)
if not path:
return None
try:
with open(path, "rb") as handle:
return b"PyEval_SaveThread" in handle.read()
except OSError:
return None
def _pi_model() -> Optional[str]:
try:
with open("/proc/device-tree/model", "rb") as handle:
return handle.read().rstrip(b"\0").decode("ascii", "replace").strip()
except OSError:
return None
def measure_refresh_hz(matrix: Any, seconds: float = 4.0) -> float:
"""The panel's refresh rate with nothing else running, by timing bare swaps.
``SwapOnVSync`` blocks until the panel's next refresh, so a loop that does
nothing else runs at exactly the panel's rate. ``limit_refresh_rate_hz`` is
a *cap*, and a long chain, a high ``pwm_bits`` or an older Pi will sit well
under it. Solving scroll speeds against a cap the panel cannot reach is
what produces "3px every 4 refreshes" and the judder that comes with it.
This is the idle rate. The panel refreshes a few percent slower while the
Pi is also pushing frames into it (100.4Hz idle against 96.3Hz scrolling on
a Pi 4 driving 512x64), which is why the recorder reads the rendering rate
back from the frames rather than trusting this.
Pass the matrix the display is already running on rather than opening a
second one: the GPIO has a single owner, and the options in force change
the answer.
:returns: measured Hz, or 0.0 if the matrix cannot be swapped (no
hardware, a stub, a mock).
"""
try:
canvas = matrix.CreateFrameCanvas()
# Discard the first swap: it carries construction and first-touch costs
# that have nothing to do with the steady-state refresh.
canvas = matrix.SwapOnVSync(canvas)
except Exception: # pylint: disable=broad-except
return 0.0
frames = 0
started = time.perf_counter()
while time.perf_counter() - started < seconds:
canvas = matrix.SwapOnVSync(canvas)
frames += 1
elapsed = time.perf_counter() - started
if elapsed <= 0 or frames <= 0:
return 0.0
return frames / elapsed
class FrameTimingRecorder:
"""Collects per-frame timings on the render thread; aggregates elsewhere.
``record`` is the only method the render thread calls, and it does no more
than compare two floats and append a tuple.
"""
def __init__(
self,
path: Optional[str] = None,
flush_interval: float = FLUSH_INTERVAL,
info: Optional[Dict[str, Any]] = None,
refresh_hz: Optional[float] = None,
):
"""
:param refresh_hz: the panel's rate, measured independently (see the
module docstring). Omit it to estimate from the frames alone, as
the display service does.
"""
self.path = path or default_stats_path()
self.flush_interval = flush_interval
self.info = dict(info or {})
# Render-thread state.
self._pending: List[Tuple[float, float, float, int]] = []
self._static_frames = 0
self._previous: Optional[Tuple[float, bool, int]] = None
# The interval ended by a static frame that followed a scrolling one,
# until the next frame shows whether the scroll went on.
self._unsure: Optional[Tuple[float, float, float, int]] = None
self._last_flush: Optional[float] = None
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
self._worker: Optional[threading.Thread] = None
# Worker-thread state. Nothing on the render thread reads these.
self.started = time.time()
self.refresh_period: Optional[float] = (
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
# The first estimate, until a second window agrees with it.
self._refresh_candidate: Optional[float] = None
self.totals: Dict[str, Any] = {
"static_frames": 0,
"scroll_frames": 0,
"late_frames": 0,
"missed_refreshes": 0,
"late_by": {"1": 0, "2": 0, "3-5": 0, "6+": 0},
"early_frames": 0,
# Frames judged against a known refresh period: the denominator
# for the late and early rates. Frames before the period is known
# are neither, and must not dilute them.
"timed_frames": 0,
"freezes": 0,
"freeze_seconds": 0.0,
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
"worst_interval_ms": 0.0,
}
self.histograms: Dict[str, Dict[int, int]] = {
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
}
self._binding_gil: Optional[bool] = None
self._binding_checked = False
# Read by the stall watchdog from its own thread: one tuple assignment,
# so it always sees a consistent (time, scrolling, thread) triple.
self.last_frame: Optional[Tuple[float, bool, int]] = None
#: Whether a scroll is running *now*, supplied by the display manager.
#: The last frame's flag alone would call the end of every scroll a
#: stall.
self.scrolling_now: Optional[Callable[[], bool]] = None
self.watchdog: Optional["StallWatchdog"] = None
def close(self) -> None:
"""Stop the stall watchdog, if one was started."""
watchdog, self.watchdog = self.watchdog, None
if watchdog is not None:
watchdog.stop()
# -- render thread ------------------------------------------------------
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
presented_at: float) -> None:
"""One frame reached the panel.
:param blit: seconds spent copying the frame into the canvas.
:param wait: seconds SwapOnVSync blocked.
:param hold: the refreshes this frame was held for.
:param scrolling: whether a scroll was running when it was presented.
:param presented_at: ``time.perf_counter()`` when the swap returned.
"""
previous = self._previous
self._previous = (presented_at, scrolling, hold)
self.last_frame = (presented_at, scrolling, threading.get_ident())
if not scrolling:
self._static_frames += 1
# The scroll ended, or its state went missing for this frame: the
# next frame says which. Its hold may have been dropped with the
# state, so the interval is due at the scroll's own.
self._unsure = None
if previous is not None and previous[1]:
self._unsure = (presented_at - previous[0], blit, wait, previous[2])
elif self.watchdog is None and self.scrolling_now is not None \
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
self.watchdog = StallWatchdog(self, **watchdog_settings())
self.watchdog.start()
elif previous is not None:
interval = presented_at - previous[0]
unsure, self._unsure = self._unsure, None
if previous[1]:
if interval < GAP_SECONDS:
self._pending.append((interval, blit, wait, hold))
elif unsure is not None and interval < RESUME_SECONDS:
# One static frame between two scrolling ones: the scroll never
# stopped, only its state did. Both intervals were motion.
self._static_frames -= 1
if unsure[0] < GAP_SECONDS:
self._pending.append(unsure)
self._pending.append((interval, blit, wait, hold))
if self._last_flush is None:
self._last_flush = presented_at
elif presented_at - self._last_flush >= self.flush_interval:
self._hand_off()
self._last_flush = presented_at
def _hand_off(self) -> None:
batch, self._pending = self._pending, []
static, self._static_frames = self._static_frames, 0
self._queue.put((batch, static))
if self._worker is None or not self._worker.is_alive():
self._worker = threading.Thread(
target=self._run, daemon=True, name="frame-timing")
self._worker.start()
def drain(self) -> None:
"""Aggregate everything recorded so far, on the calling thread.
For a caller that owns the recorder outright and wants exact numbers at
a moment of its choosing -- the benchmark, between warm-up and run and
at the end. Construct it with ``flush_interval=float('inf')`` so the
worker never runs; the two must not aggregate at once.
"""
batch, self._pending = self._pending, []
static, self._static_frames = self._static_frames, 0
self.aggregate(batch, static)
# -- worker thread ------------------------------------------------------
def _run(self) -> None:
while True:
batch, static = self._queue.get()
try:
self.aggregate(batch, static)
self.write()
except Exception: # never let telemetry take anything down
logger.debug("Frame timing flush failed", exc_info=True)
def aggregate(self, batch: List[Tuple[float, float, float, int]],
static: int) -> None:
"""Fold one window of frames into the running totals."""
totals = self.totals
totals["static_frames"] += static
per_hold = sorted(interval / max(1, hold)
for interval, _, _, hold in batch
if interval < FREEZE_SECONDS)
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
estimate = per_hold[len(per_hold) // 10]
current = self.refresh_period
if estimate <= 0:
pass
elif current is None:
# Adopt the first period only once two windows in a row agree:
# one loaded window at startup, most of its frames a refresh
# late, would otherwise fix a period twice the real one for
# the life of the process, since later windows may only lower
# it by MAX_REFRESH_DROP.
candidate = self._refresh_candidate
if candidate and abs(estimate - candidate) <= candidate * MAX_REFRESH_DROP:
self.refresh_period = min(candidate, estimate)
else:
self._refresh_candidate = estimate
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
self.refresh_period = estimate
period = self.refresh_period
histograms = self.histograms
for interval, blit, wait, hold in batch:
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
interval * 1000.0)
if interval >= FREEZE_SECONDS:
totals["freezes"] += 1
totals["freeze_seconds"] += interval
label = next(name for limit, name in FREEZE_BUCKETS
if interval < limit)
totals["freeze_by"][label] += 1
continue
totals["scroll_frames"] += 1
for name, value in (("blit", blit), ("wait", wait),
("work", max(0.0, interval - blit - wait)),
("interval_per_hold", interval / max(1, hold))):
bucket = _bucket(value)
histogram = histograms[name]
histogram[bucket] = histogram.get(bucket, 0) + 1
if period:
totals["timed_frames"] += 1
missed = round(interval / period) - hold
if missed >= 1:
totals["late_frames"] += 1
totals["missed_refreshes"] += missed
key = ("1" if missed == 1 else "2" if missed == 2
else "3-5" if missed <= 5 else "6+")
totals["late_by"][key] += 1
elif missed <= -1:
totals["early_frames"] += 1
def snapshot(self) -> Dict[str, Any]:
"""The JSON document: cumulative since this process started."""
if not self._binding_checked:
self._binding_gil = binding_releases_gil()
self._binding_checked = True
info = dict(self.info)
info.setdefault("pi_model", _pi_model())
period = self.refresh_period
return {
"version": SCHEMA_VERSION,
"pid": os.getpid(),
"started": self.started,
"updated": time.time(),
"bucket_ms": BUCKET_MS,
"freeze_seconds": FREEZE_SECONDS,
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
"binding_releases_gil": self._binding_gil,
"info": info,
"totals": copy.deepcopy(self.totals),
# JSON keys are strings; readers convert back.
"histograms": {name: {str(k): v for k, v in sorted(h.items())}
for name, h in self.histograms.items()},
}
def write(self) -> None:
"""Replace the stats file atomically with the current snapshot."""
directory = os.path.dirname(self.path) or "."
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".frame_stats.",
suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(self.snapshot(), handle)
os.chmod(tmp, 0o644)
os.replace(tmp, self.path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
class _WatchdogSettings(TypedDict, total=False):
"""The StallWatchdog keyword arguments watchdog_settings() may set."""
threshold: float
poll: float
def watchdog_settings() -> _WatchdogSettings:
"""StallWatchdog arguments from ``LEDMATRIX_STALL_WATCHDOG_MS``, if set.
The poll comes down with the threshold, or a stall shorter than one poll
would go unseen.
"""
try:
ms = float(os.environ.get("LEDMATRIX_STALL_WATCHDOG_MS") or 0)
except ValueError:
ms = 0.0
if ms <= 0:
return {}
threshold = ms / 1000.0
return {"threshold": threshold,
"poll": min(WATCHDOG_POLL_SECONDS, threshold / 3)}
class StallWatchdog:
"""Log what the render thread is doing when a scroll stops presenting.
See the module docstring. Polls; never touches the render thread.
"""
def __init__(
self,
recorder: FrameTimingRecorder,
threshold: float = STALL_SECONDS,
poll: float = WATCHDOG_POLL_SECONDS,
log_interval: float = STALL_LOG_INTERVAL,
clock: Callable[[], float] = time.perf_counter,
):
self.recorder = recorder
self.threshold = threshold
self.poll = poll
self.log_interval = log_interval
self.clock = clock
self.stalls = 0
self._last_dump: Optional[float] = None
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, daemon=True, name="stall-watchdog")
self._thread.start()
def stop(self, timeout: float = 1.0) -> None:
"""End the polling thread (DisplayManager.cleanup calls this)."""
self._stop.set()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout)
def _run(self) -> None:
last_wake = self.clock()
stall_from: Optional[float] = None # presented_at of the stalled frame
dumped = False
while not self._stop.wait(self.poll):
now = self.clock()
late = max(0.0, now - last_wake - self.poll)
last_wake = now
try:
stall_from, dumped = self.check(now, late, stall_from, dumped)
except Exception: # never let a diagnostic take anything down
logger.debug("Stall watchdog check failed", exc_info=True)
def check(self, now: float, late: float, stall_from: Optional[float],
dumped: bool) -> Tuple[Optional[float], bool]:
"""One look. Returns the updated (stall_from, dumped) state."""
frame = self.recorder.last_frame
if frame is None:
return None, False
presented_at, scrolling, ident = frame
if stall_from is not None and presented_at != stall_from:
# A frame arrived: the stall is over.
if dumped:
logger.warning(
"Render stall over: no frame for %.0fms",
(presented_at - stall_from) * 1000.0)
return None, False
scrolling_now = self.recorder.scrolling_now
if (stall_from is not None and now - stall_from >= GAP_SECONDS
and (scrolling_now is None or not scrolling_now())):
# The scroll ended without another frame: nothing more to time.
# Only past GAP_SECONDS: the scroll state expires after 2s without
# activity, which a stall outlasts, and its end still wants saying.
return None, False
age = now - presented_at
if (stall_from is None and scrolling and age >= self.threshold
and scrolling_now is not None and scrolling_now()):
self.stalls += 1
if self._last_dump is None or now - self._last_dump >= self.log_interval:
self._last_dump = now
logger.warning(self.describe(ident, age, late))
return presented_at, True
return presented_at, False
return stall_from, dumped
def describe(self, ident: int, age: float, late: float) -> str:
"""The stack dump: the stalled thread in full, the rest in brief."""
names = {t.ident: t.name for t in threading.enumerate()}
frames = sys._current_frames()
lines = [
f"Render stall: no frame for {age * 1000.0:.0f}ms mid-scroll "
f"(watchdog woke {late * 1000.0:.0f}ms late"
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
+ ")",
f"-- {names.get(ident, ident)} (presents frames):",
]
stalled = frames.get(ident)
if stalled is not None:
lines.extend(line.rstrip() for line in
traceback.format_stack(stalled, limit=12))
for other, frame in frames.items():
if other in (ident, threading.get_ident()):
continue
top = traceback.extract_stack(frame, limit=3)
where = " <- ".join(
f"{os.path.basename(f.filename)}:{f.lineno} {f.name}"
for f in reversed(top))
lines.append(f"-- {names.get(other, other)}: {where}")
return "\n".join(lines)
+67 -61
View File
@@ -8,10 +8,10 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
import logging import logging
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Tuple, Union
import requests import requests
from PIL import Image from PIL import Image, ImageDraw
from src.common.api_helper import USER_AGENT from src.common.api_helper import USER_AGENT
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
@@ -32,35 +32,14 @@ from src.common.permission_utils import (
# trade for not re-warning about a file nobody is going to add. # trade for not re-warning about a file nobody is going to add.
MISSING_LOGO_RECHECK_SECONDS = 3600.0 MISSING_LOGO_RECHECK_SECONDS = 3600.0
#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed
#: enough that a typo cannot ask for a 4000px image on a 64px panel.
MIN_LOGO_SCALE = 0.05
MAX_LOGO_SCALE = 8.0
def _usable_scale(scale) -> float:
"""A scale that can be applied, or 1.0.
Anything unusable -- None, a string, zero, a negative, NaN, infinity --
means "as shipped", because the alternative is a blank panel from a
mistyped number.
"""
try:
value = float(scale)
except (TypeError, ValueError):
return 1.0
if value != value or value in (float('inf'), float('-inf')):
return 1.0
if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE:
return 1.0
return value
# Well above any real team logo; bounds what a remote URL can write to disk. # Well above any real team logo; bounds what a remote URL can write to disk.
# The cap for every logo download: src.logo_downloader.fetch_logo uses it too. # The cap for every logo download: src.logo_downloader.fetch_logo uses it too.
MAX_LOGO_BYTES = 10 * 1024 * 1024 MAX_LOGO_BYTES = 10 * 1024 * 1024
#: A logo's default bounding box, as a multiple of the panel's width and
#: height, when the caller gives no max_width / max_height.
DEFAULT_LOGO_BOX_FACTOR = 1.5
class LogoHelper: class LogoHelper:
""" """
@@ -102,6 +81,11 @@ class LogoHelper:
# downloader writes them at runtime) is still picked up. # downloader writes them at runtime) is still picked up.
self._missing_logos: Dict[str, float] = {} self._missing_logos: Dict[str, float] = {}
# Failed downloads by logo path. A logo that is absent (not a stale
# placeholder) has no on-disk timestamp to back off on, so without this
# every call retried the download -- up to a 30s timeout each time.
self._download_failures: Dict[str, float] = {}
# Session for HTTP requests # Session for HTTP requests
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update({
@@ -119,12 +103,16 @@ class LogoHelper:
Args: Args:
team_abbr: Team abbreviation for caching team_abbr: Team abbreviation for caching
logo_path: Path to the logo file logo_path: Path to the logo file
max_width: Maximum width (defaults to display_width * 1.5) max_width: Maximum width (default display_width *
max_height: Maximum height (defaults to display_height * 1.5) DEFAULT_LOGO_BOX_FACTOR)
max_height: Maximum height (default display_height *
DEFAULT_LOGO_BOX_FACTOR)
scale: User's size multiplier for this image, from scale: User's size multiplier for this image, from
``customization.layout.<element>.scale``. 1.0 is untouched and ``customization.layout.<element>.scale``; 1.0 leaves the box
takes exactly the path it always did. Callers hold the config, as is. Callers hold the config, so they resolve the element
so they resolve the element name; this only applies the number. name; this only applies the number, clamped to
src.element_style's MIN_ELEMENT_SCALE..MAX_ELEMENT_SCALE.
A value that is not a finite positive number means 1.0.
Returns: Returns:
PIL Image object or None if loading fails PIL Image object or None if loading fails
@@ -137,14 +125,7 @@ class LogoHelper:
# Resolve the effective target size BEFORE the cache lookup so the # Resolve the effective target size BEFORE the cache lookup so the
# key is size-qualified — a panel-size change must not return a # key is size-qualified — a panel-size change must not return a
# logo resized for the old dimensions. # logo resized for the old dimensions.
if max_width is None: max_width, max_height, scale = self._scaled_box(max_width, max_height, scale)
max_width = int(self.display_width * 1.5)
if max_height is None:
max_height = int(self.display_height * 1.5)
scale = _usable_scale(scale)
if scale != 1.0:
max_width = max(1, int(round(max_width * scale)))
max_height = max(1, int(round(max_height * scale)))
# The key carries the scaled box, so two elements scaled differently # The key carries the scaled box, so two elements scaled differently
# cannot be served each other's image. # cannot be served each other's image.
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}" cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
@@ -173,7 +154,7 @@ class LogoHelper:
return None return None
# Load image # Load image
logo = Image.open(logo_path) logo: Image.Image = Image.open(logo_path)
if logo.mode != 'RGBA': if logo.mode != 'RGBA':
logo = logo.convert('RGBA') logo = logo.convert('RGBA')
@@ -218,7 +199,12 @@ class LogoHelper:
return self.load_logo(team_abbr, logo_path, max_width, max_height, return self.load_logo(team_abbr, logo_path, max_width, max_height,
scale) scale)
# Download if URL provided and file doesn't exist # Download if URL provided and file doesn't exist, unless the last
# attempt for this path failed recently.
failed_at = self._download_failures.get(str(logo_path))
if (logo_url and failed_at is not None
and time.time() - failed_at < MISSING_LOGO_RECHECK_SECONDS):
logo_url = None
if logo_url: if logo_url:
try: try:
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}") self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
@@ -232,6 +218,7 @@ class LogoHelper:
scale) scale)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to download logo for {team_abbr}: {e}") self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
self._download_failures[str(logo_path)] = time.time()
# The retry failed, so restart the back-off. The stale # The retry failed, so restart the back-off. The stale
# placeholder is still on disk with its old timestamp, and # placeholder is still on disk with its old timestamp, and
# leaving it there means the next call retries immediately -- # leaving it there means the next call retries immediately --
@@ -239,8 +226,30 @@ class LogoHelper:
# exists to prevent. # exists to prevent.
self._refresh_stale_placeholder(logo_path) self._refresh_stale_placeholder(logo_path)
# Create placeholder if all else fails # Create placeholder if all else fails. Sized to the same scaled box
return self._create_placeholder_logo(team_abbr, max_width, max_height) # a real logo gets, so a scaled element doesn't jump in size while
# its logo is missing.
box_width, box_height, _ = self._scaled_box(max_width, max_height, scale)
return self._create_placeholder_logo(team_abbr, box_width, box_height)
def _scaled_box(self, max_width: Optional[int], max_height: Optional[int],
scale: float) -> Tuple[int, int, float]:
"""The logo box after defaults and the user's scale are applied.
Returns ``(width, height, coerced_scale)``.
"""
if max_width is None:
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None:
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Imported here: src.element_style imports src.common (for bdf_font),
# whose __init__ imports this module.
from src.element_style import coerce_scale
scale = coerce_scale(scale, 1.0)
if scale != 1.0:
max_width = max(1, int(round(max_width * scale)))
max_height = max(1, int(round(max_height * scale)))
return max_width, max_height, scale
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None: def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
"""Drop every cached size of one logo after its file changed on disk.""" """Drop every cached size of one logo after its file changed on disk."""
@@ -254,6 +263,7 @@ class LogoHelper:
# leaving it would hide a logo we just downloaded. # leaving it would hide a logo we just downloaded.
for key in [k for k in self._missing_logos if k.startswith(prefix)]: for key in [k for k in self._missing_logos if k.startswith(prefix)]:
del self._missing_logos[key] del self._missing_logos[key]
self._download_failures.pop(str(logo_path), None)
@staticmethod @staticmethod
def _refresh_stale_placeholder(logo_path: Path) -> None: def _refresh_stale_placeholder(logo_path: Path) -> None:
@@ -346,9 +356,10 @@ class LogoHelper:
self._logo_cache.clear() self._logo_cache.clear()
self._cache_order.clear() self._cache_order.clear()
self._missing_logos.clear() self._missing_logos.clear()
self._download_failures.clear()
self.logger.debug("Logo cache cleared") self.logger.debug("Logo cache cleared")
def get_cache_stats(self) -> Dict[str, int]: def get_cache_stats(self) -> Dict[str, float]:
""" """
Get cache statistics. Get cache statistics.
@@ -374,9 +385,9 @@ class LogoHelper:
nobody asked to grow would change every existing render. nobody asked to grow would change every existing render.
""" """
if max_width is None: if max_width is None:
max_width = int(self.display_width * 1.5) max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None: if max_height is None:
max_height = int(self.display_height * 1.5) max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Only resize if necessary # Only resize if necessary
if logo.width <= max_width and logo.height <= max_height: if logo.width <= max_width and logo.height <= max_height:
@@ -429,31 +440,26 @@ class LogoHelper:
max_width: Optional[int] = None, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Optional[Image.Image]: max_height: Optional[int] = None) -> Optional[Image.Image]:
""" """
Create a placeholder logo with team abbreviation. A stand-in for a logo that could not be loaded or downloaded: a
translucent grey box with a light outline, filling the logo box.
No text is drawn; ``team_abbr`` is only used in log messages.
Args: Args:
team_abbr: Team abbreviation to display team_abbr: Team the placeholder stands in for
max_width: Maximum width max_width: Width (default display_width * DEFAULT_LOGO_BOX_FACTOR)
max_height: Maximum height max_height: Height (default display_height * DEFAULT_LOGO_BOX_FACTOR)
Returns: Returns:
PIL Image with placeholder logo The RGBA placeholder, or None if it could not be created
""" """
try: try:
if max_width is None: if max_width is None:
max_width = int(self.display_width * 1.5) max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None: if max_height is None:
max_height = int(self.display_height * 1.5) max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Create placeholder image
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0)) placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
# This would require a font, so we'll create a simple colored rectangle
# In a real implementation, you'd want to add text rendering here
from PIL import ImageDraw
draw = ImageDraw.Draw(placeholder) draw = ImageDraw.Draw(placeholder)
# Draw a simple rectangle with team abbreviation
draw.rectangle([0, 0, max_width-1, max_height-1], draw.rectangle([0, 0, max_width-1, max_height-1],
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255)) fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
+38 -17
View File
@@ -241,7 +241,8 @@ def get_assets_dir_mode() -> int:
Return permission mode for asset directories. Return permission mode for asset directories.
Returns: Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
""" """
return 0o2775 # rwxrwsr-x (setgid + group writable) return 0o2775 # rwxrwsr-x (setgid + group writable)
@@ -251,7 +252,8 @@ def get_config_dir_mode() -> int:
Return permission mode for config directory. Return permission mode for config directory.
Returns: Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
""" """
return 0o2775 # rwxrwsr-x (setgid + group writable) return 0o2775 # rwxrwsr-x (setgid + group writable)
@@ -271,7 +273,8 @@ def get_plugin_dir_mode() -> int:
Return permission mode for plugin directories. Return permission mode for plugin directories.
Returns: Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
""" """
return 0o2775 # rwxrwsr-x (setgid + group writable) return 0o2775 # rwxrwsr-x (setgid + group writable)
@@ -281,11 +284,32 @@ def get_cache_dir_mode() -> int:
Return permission mode for cache directories. Return permission mode for cache directories.
Returns: Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
""" """
return 0o2775 # rwxrwsr-x (setgid + group writable) return 0o2775 # rwxrwsr-x (setgid + group writable)
def _sudo_bash_candidates() -> list:
"""Bash paths to try, in order, when running a vetted helper via sudo.
sudoers matches the exact argv, so ``sudo -n <bash> <helper> ...`` only
works if <bash> is the same path configure_web_sudo.sh wrote into the
rule -- whatever ``command -v bash`` said on the machine that ran it.
On merged-/usr systems /usr/bin/bash and /bin/bash are the same file but
different strings to sudo, and the web user's PATH can differ from the
installer's, so no single guess is reliable. Callers try each in turn and
move on only when sudo refused the command line (SUDO_REFUSAL_PHRASES).
The helper is invoked through bash rather than its shebang for the same
reason: the rule names bash, not the script.
"""
candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in candidates:
candidates.append(candidate)
return candidates
def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool: def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool:
""" """
Remove a directory using sudo as a last resort. Remove a directory using sudo as a last resort.
@@ -346,9 +370,8 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
logger.error(f"Safe removal helper not found: {helper_script}") logger.error(f"Safe removal helper not found: {helper_script}")
return False return False
bash_path = _shutil.which('bash') or '/bin/bash'
try: try:
for bash_path in _sudo_bash_candidates():
result = subprocess.run( result = subprocess.run(
['sudo', '-n', bash_path, str(helper_script), str(resolved)], ['sudo', '-n', bash_path, str(helper_script), str(resolved)],
capture_output=True, capture_output=True,
@@ -358,8 +381,12 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
if result.returncode == 0 and not resolved.exists(): if result.returncode == 0 and not resolved.exists():
logger.info(f"Successfully removed {path} via sudo helper") logger.info(f"Successfully removed {path} via sudo helper")
return True return True
else: # Only a refused command line is worth another bash path; if the
stderr = result.stderr.strip() # helper itself ran and failed, a retry would just repeat it.
if result.returncode == 0 or not any(
phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
break
stderr = (result.stderr or '').strip()
logger.error(f"sudo helper failed for {path}: {stderr}") logger.error(f"sudo helper failed for {path}: {stderr}")
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -413,16 +440,10 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh" wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
if wrapper.exists(): if wrapper.exists():
# See sudo_remove_directory / configure_web_sudo.sh for why bash must # See _sudo_bash_candidates for why bash is invoked by explicit path
# be invoked with an explicit, known path rather than relying on the # and why there is more than one to try.
# wrapper's shebang: sudoers matches the exact command line.
bash_candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in bash_candidates:
bash_candidates.append(candidate)
result = None result = None
for bash_path in bash_candidates: for bash_path in _sudo_bash_candidates():
# bash_path and wrapper are fixed, known-good paths, and # bash_path and wrapper are fixed, known-good paths, and
# safe_pip_install.sh independently re-validates req_file is an # safe_pip_install.sh independently re-validates req_file is an
# allowed requirements.txt before installing anything as root. # allowed requirements.txt before installing anything as root.
+238
View File
@@ -0,0 +1,238 @@
"""Let a background thread run Python only while the render thread waits on vsync.
With plugin rendering moved to Vegas's prefetch thread (DisplayManager.offscreen,
#630) the render thread no longer stops for it, but it still shares the GIL
with it. The render thread spends most of each refresh inside SwapOnVSync,
which releases the GIL, and needs it back the moment the swap returns. If the
prefetch thread is running Python right then, the render thread waits: up to
the switch interval (5ms) behind bytecode, and for as long as a C call that
keeps the GIL takes. On hdpi that showed up as frames 2-5 refreshes late while
a group was being prepared.
The gate turns that around. The display manager opens it just before each swap,
with a deadline shortly ahead of the refresh the swap will return on, and
closes it when the swap returns. A thread inside ``gate.yielding()`` checks it on
every Python and C call through a profile hook, and once the window has closed
it parks -- blocked on a condition, GIL released -- until the next swap opens
it. The render thread then finds the GIL free when its refresh arrives, and the
background work runs in time the render thread was only spending waiting.
Parking a thread is only safe if nothing the render thread needs is stuck
behind it, so it is never parked:
* while it holds a lock registered with ``guard()`` (the Vegas buffers and
caches the render thread also takes);
* inside logging, threading, importlib or the cache, all of which take locks the
render thread can take too;
* when there is no render loop to protect -- no swap for ``STALE_SECONDS``, as
on a static screen or a stalled frame.
And a parked thread is never held more than ``MAX_WAIT_SECONDS`` at a time, so
whatever the gate gets wrong costs a frame, not a freeze. The render thread
itself is never gated, whatever it calls.
It gates the prefetch thread only. Gating the ESPN fetch threads as well was
tried for the hourly sports refresh, twenty-odd of them at once, and measured
worse on hdpi (0.85% late frames without it, 1.14% with it, across a burst every
five minutes): each parked thread has to take the GIL again just to park at the
end of every window, and the fetches ran two to three times as long.
"""
from __future__ import annotations
import math
import sys
import threading
import time
from collections import deque
from typing import Any, Callable, Deque, List, Optional, cast
from src.common.frame_timing import binding_releases_gil
#: Park background threads this long before the refresh a swap will return on,
#: so a short C call already under way has finished by then.
MARGIN_SECONDS = 0.002
#: The longest a background thread is parked in one go.
MAX_WAIT_SECONDS = 0.05
#: No swap for this long means there is no render loop running to protect.
STALE_SECONDS = 0.05
#: Swaps needed before the refresh period is trusted enough to open a window.
MIN_SAMPLES = 8
#: Parking inside any of these modules could hold a lock the render thread
#: takes: logging handler locks, Condition and Event internals, the module
#: import locks, and the disk and memory cache locks. Matched by module name,
#: not file path: a path can say "cache" or "logging" for reasons of its own --
#: a virtualenv under ~/.cache, or GitHub's /opt/hostedtoolcache, where every
#: stdlib frame would otherwise count and the gate would never park anything.
_UNSAFE_MODULES = frozenset({
"logging", "threading", "importlib", "src.cache_manager", "src.cache",
})
_UNSAFE_PREFIXES = ("logging.", "importlib.", "_frozen_importlib", "src.cache.")
def _unsafe(frame: Any, base: Any) -> bool:
"""True if a frame above ``base`` comes from somewhere parking could deadlock.
``base`` is the frame that entered ``yielding()``; what lies below it (the
thread's own bootstrap in threading.py) holds nothing.
"""
while frame is not None and frame is not base:
name = frame.f_globals.get("__name__") or ""
if name in _UNSAFE_MODULES or name.startswith(_UNSAFE_PREFIXES):
return True
frame = frame.f_back
return False
def swap_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
A thin delegate to src.common.frame_timing.binding_releases_gil (#629),
which this used to duplicate line for line. The name stays because the
coordinator calls it here and tests replace it here.
"""
return binding_releases_gil()
def _held(lock: Any) -> bool:
"""Is ``lock`` held? RLocks report this thread's ownership; plain locks, anyone's."""
is_owned = getattr(lock, "_is_owned", None)
if is_owned is not None:
return cast(bool, is_owned())
return cast(bool, lock.locked())
class RenderGate:
"""Opened by the render thread around each swap; honoured by background threads."""
def __init__(self, clock: Callable[[], float] = time.monotonic):
self.clock = clock
self._cond = threading.Condition()
self._generation = 0
self._open_until = 0.0
self._last_return: Optional[float] = None
self._periods: Deque[float] = deque(maxlen=64)
self._period: Optional[float] = None
self._guarded: List[Any] = []
self._local = threading.local()
self._render_ident: Optional[int] = None
#: How often, and for how long in all, background threads were parked.
self.parks = 0
self.parked_seconds = 0.0
def guard(self, *locks: Any) -> None:
"""Never park a thread while it holds (or, for a plain Lock, anyone holds) these."""
self._guarded.extend(lock for lock in locks if lock is not None)
# -- render thread -----------------------------------------------------
def refresh_period(self) -> Optional[float]:
"""The panel's refresh period from recent swaps, or None until known.
The 10th percentile of the gaps between swap returns, each divided by
the hold: a late frame only ever lengthens a gap, so the low end is
the panel's own period.
"""
return self._period
def before_swap(self, hold: int) -> None:
"""The render thread is about to block in SwapOnVSync: open the window."""
hold = max(1, int(hold))
period = self._period
now = self.clock()
last = self._last_return
if period and last is not None and now - last < STALE_SECONDS:
# The swap returns on the first refresh boundary after both the
# current frame's hold is up and this frame has been handed over;
# boundaries fall a whole period apart from the last return.
refreshes = max(hold, math.ceil((now - last) / period))
open_until = last + refreshes * period - MARGIN_SECONDS
else:
open_until = 0.0 # no rhythm to predict from: leave threads be
with self._cond:
self._open_until = open_until
self._generation += 1
self._cond.notify_all()
def after_swap(self, hold: int) -> None:
"""The swap returned and the render thread needs the GIL: close the window."""
now = self.clock()
self._open_until = 0.0
if self._render_ident is None:
# The first thread to swap is the render loop. A plugin pushing a
# live refresh from its update thread swaps too, but must not take
# over its exemption.
self._render_ident = threading.get_ident()
last = self._last_return
if last is not None and now - last < STALE_SECONDS:
self._periods.append((now - last) / max(1, int(hold)))
if len(self._periods) >= MIN_SAMPLES:
ordered = sorted(self._periods)
self._period = ordered[len(ordered) // 10]
self._last_return = now
# -- background threads ------------------------------------------------
def _should_park(self, frame: Any, now: float) -> bool:
if now < self._open_until:
return False # inside the window
last = self._last_return
if last is None or now - last > STALE_SECONDS or self._period is None:
return False # no render loop to protect
for lock in self._guarded:
if _held(lock):
return False
return not _unsafe(frame, getattr(self._local, "base", None))
def _hook(self, frame: Any, _event: str, _arg: Any) -> None:
now = self.clock()
if not self._should_park(frame, now):
return
generation = self._generation
with self._cond:
self._cond.wait_for(lambda: self._generation != generation,
timeout=MAX_WAIT_SECONDS)
self.parks += 1
self.parked_seconds += self.clock() - now
def yielding(self) -> "_Yielding":
"""``with gate.yielding():`` runs the block giving way to the render thread."""
return _Yielding(self)
class _Yielding:
"""Installs a gate's profile hook on the thread for the length of a block."""
def __init__(self, gate: RenderGate):
self.gate = gate
self._previous: Any = None
self._previous_base: Any = None
self._skipped = False
def __enter__(self) -> RenderGate:
gate = self.gate
# pylint: disable=protected-access
if threading.get_ident() == gate._render_ident:
self._skipped = True # parking the render thread parks the display
return gate
local = gate._local
self._previous_base = getattr(local, "base", None)
if self._previous_base is None:
# Nested blocks keep the outermost frame, so everything the thread
# entered since it first gave way is still checked for locks.
local.base = sys._getframe(1)
self._previous = sys.getprofile()
sys.setprofile(gate._hook)
return gate
def __exit__(self, *_exc: Any) -> None:
if self._skipped:
return
sys.setprofile(self._previous)
self.gate._local.base = self._previous_base # pylint: disable=protected-access
+10 -6
View File
@@ -49,7 +49,9 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional, cast
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -63,8 +65,9 @@ MIN_PIXELS_PER_SECOND = 1.0
MAX_PIXELS_PER_SECOND = 500.0 MAX_PIXELS_PER_SECOND = 500.0
#: Assumed refresh when the caller does not say. Matches the usual #: Assumed refresh when the caller does not say. Matches the usual
#: ``display.hardware.limit_refresh_rate_hz``. #: ``display.hardware.limit_refresh_rate_hz``, and is the cap DisplayManager
DEFAULT_REFRESH_HZ = 100.0 #: applies when that key is missing.
DEFAULT_REFRESH_HZ = float(DEFAULT_REFRESH_LIMIT_HZ)
#: How far px/s may sit from a whole number of pixels per refresh before it is #: How far px/s may sit from a whole number of pixels per refresh before it is
#: worth warning about. 0.05px per frame is invisible; a third of a pixel is not. #: worth warning about. 0.05px per frame is invisible; a third of a pixel is not.
@@ -129,14 +132,14 @@ def crisp_ladder(
refresh_hz: float = DEFAULT_REFRESH_HZ, refresh_hz: float = DEFAULT_REFRESH_HZ,
max_frame_hold: int = MAX_FRAME_HOLD, max_frame_hold: int = MAX_FRAME_HOLD,
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME, max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
): ) -> List[CrispSpeed]:
"""Every whole-pixel speed this panel can show, slowest first. """Every whole-pixel speed this panel can show, slowest first.
Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable
as 1px every refresh or 2px every 2nd refresh, and the former moves in as 1px every refresh or 2px every 2nd refresh, and the former moves in
smaller increments, so that is the one worth offering. smaller increments, so that is the one worth offering.
""" """
best = {} best: Dict[float, CrispSpeed] = {}
for hold in range(1, max_frame_hold + 1): for hold in range(1, max_frame_hold + 1):
for ppf in range(1, max_pixels_per_frame + 1): for ppf in range(1, max_pixels_per_frame + 1):
pps = refresh_hz / hold * ppf pps = refresh_hz / hold * ppf
@@ -431,7 +434,8 @@ def configure(
# they start scrolling. configure() only reports what is needed. # they start scrolling. configure() only reports what is needed.
if choice: if choice:
requested = settings.requested_pixels_per_second # Set whenever there is a crisp choice (see the replace() above).
requested = cast(float, settings.requested_pixels_per_second)
if abs(requested - applied) > 0.05: if abs(requested - applied) > 0.05:
log.info( log.info(
"Scroll configured: %s (asked for %.1f px/s from %s; " "Scroll configured: %s (asked for %.1f px/s from %s; "
+31 -36
View File
@@ -238,20 +238,9 @@ class ScrollHelper:
self.cached_image = full_image self.cached_image = full_image
# Convert to numpy array for fast operations # Convert to numpy array for fast operations
self.cached_array = np.array(full_image) self.cached_array = np.array(full_image)
# Use actual image width instead of calculated width to ensure accuracy
# This fixes cases where width calculation doesn't match actual positioning
actual_image_width = full_image.width actual_image_width = full_image.width
self.total_scroll_width = actual_image_width self.total_scroll_width = actual_image_width
# Log if there's a mismatch (indicating a bug in width calculation)
if actual_image_width != total_width:
self.logger.warning(
"Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). "
"Using actual width for scroll calculations.",
total_width, actual_image_width, abs(actual_image_width - total_width)
)
self.scroll_position = 0.0 self.scroll_position = 0.0
self.total_distance_scrolled = 0.0 self.total_distance_scrolled = 0.0
self.scroll_complete = False self.scroll_complete = False
@@ -265,6 +254,9 @@ class ScrollHelper:
now = time.time() now = time.time()
self.scroll_start_time = now self.scroll_start_time = now
self.last_progress_log_time = now self.last_progress_log_time = now
# The position just went back to 0; the first update must not advance
# it by however long the helper sat idle (off-screen) before this.
self.last_update_time = now
self.logger.info( self.logger.info(
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)", "Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
self.calculated_duration, self.calculated_duration,
@@ -339,10 +331,8 @@ class ScrollHelper:
# gained. This is what the one visibly smooth scroller on the # gained. This is what the one visibly smooth scroller on the
# hardware (the stock ticker) was already doing by virtue of never # hardware (the stock ticker) was already doing by virtue of never
# enabling frame-based mode. # enabling frame-based mode.
if self.scroll_delay > 0: # set_scroll_delay clamps scroll_delay to at least 0.001.
pixels_per_second = self.scroll_speed / self.scroll_delay pixels_per_second = self.scroll_speed / self.scroll_delay
else:
pixels_per_second = self.scroll_speed * 100.0
pixels_to_move = pixels_per_second * delta_time pixels_to_move = pixels_per_second * delta_time
self.last_step_time = current_time self.last_step_time = current_time
else: else:
@@ -353,11 +343,11 @@ class ScrollHelper:
self.scroll_position += pixels_to_move self.scroll_position += pixels_to_move
self.total_distance_scrolled += pixels_to_move self.total_distance_scrolled += pixels_to_move
# Calculate required total distance: total_scroll_width only. # One pass is total_scroll_width. With the default lead_gap the strip
# The image already includes display_width pixels of blank padding at the start # starts with display_width of blank, so by then the last item has
# (added by create_scrolling_image), so once scroll_position reaches # fully left the panel; a caller passing a smaller lead_gap (Vegas)
# total_scroll_width the last card has fully scrolled off the left edge. # decides for itself where its cycle ends. Adding display_width here
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains. # caused 1-2 extra wrap-arounds on wide chains.
required_total_distance = self.total_scroll_width required_total_distance = self.total_scroll_width
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip # Guard: zero-width content has nothing to scroll — keep position at 0 and skip
@@ -414,7 +404,6 @@ class ScrollHelper:
and current_time - self.last_progress_log_time >= self.progress_log_interval and current_time - self.last_progress_log_time >= self.progress_log_interval
): ):
elapsed_time = current_time - (self.scroll_start_time or current_time) elapsed_time = current_time - (self.scroll_start_time or current_time)
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width required_total_distance = self.total_scroll_width
# Progress telemetry, emitted every few seconds for the whole of # Progress telemetry, emitted every few seconds for the whole of
# every scroll. It says how far along a marquee is, which is what # every scroll. It says how far along a marquee is, which is what
@@ -461,10 +450,8 @@ class ScrollHelper:
""" """
Linear blend between the frames at ``start_x`` and ``start_x + 1``. Linear blend between the frames at ``start_x`` and ``start_x + 1``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not Implemented with numpy rather than scipy.ndimage.shift, which is not
installed on the target devices, and the old scipy-based sub-pixel path installed on the target devices.
was dead code -- get_visible_portion never consulted the flag. The scipy
import was removed with it; installing scipy has no effect.
Args: Args:
start_x: Left column of the earlier of the two frames start_x: Left column of the earlier of the two frames
@@ -571,22 +558,16 @@ class ScrollHelper:
return self.min_duration return self.min_duration
try: try:
# Calculate total scroll distance needed # The strip's width plus one more screen, so the duration covers
# The image already includes display_width padding at the start, so we need # the last item leaving the panel even when the strip has less
# to scroll total_scroll_width pixels to show all content, plus display_width # than display_width of lead-in blank (lead_gap).
# more pixels to ensure the last content scrolls completely off the screen
total_scroll_distance = self.total_scroll_width + self.display_width total_scroll_distance = self.total_scroll_width + self.display_width
# Calculate effective pixels per second based on scrolling mode # Calculate effective pixels per second based on scrolling mode
if self.frame_based_scrolling: if self.frame_based_scrolling:
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame # Frame-based mode: scroll_speed is pixels per scroll_delay
# Effective pixels per second = pixels per frame / seconds per frame # seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
if self.scroll_delay > 0:
pixels_per_second = self.scroll_speed / self.scroll_delay pixels_per_second = self.scroll_speed / self.scroll_delay
else:
# Fallback if scroll_delay is invalid
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
scroll_mode_str = "frame-based" scroll_mode_str = "frame-based"
else: else:
# Time-based mode: scroll_speed is already pixels per second # Time-based mode: scroll_speed is already pixels per second
@@ -798,6 +779,18 @@ class ScrollHelper:
self.clear_cache() self.clear_cache()
return return
# Every frame is cut from cached_array with Image.frombytes('RGB', ...),
# which reads a 4-channel (RGBA) array as garbage and raises on a
# 1-channel (L) one. Transparent pixels go to black, the panel's
# background, rather than to whatever colour hides under the alpha.
if image.mode != 'RGB':
if 'A' in image.mode or 'transparency' in image.info:
rgba = image.convert('RGBA')
image = Image.new('RGB', rgba.size, (0, 0, 0))
image.paste(rgba, (0, 0), rgba)
else:
image = image.convert('RGB')
# Set the cached image # Set the cached image
self.cached_image = image self.cached_image = image
@@ -824,6 +817,9 @@ class ScrollHelper:
self.scroll_start_time = now self.scroll_start_time = now
self.last_progress_log_time = now self.last_progress_log_time = now
self.last_step_time = now # Initialize step timer for frame-based scrolling self.last_step_time = now # Initialize step timer for frame-based scrolling
# The position just went back to 0; the first update must not advance
# it by however long the helper sat idle before this image arrived.
self.last_update_time = now
self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d", self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d",
image.width, image.height, self.total_scroll_width) image.width, image.height, self.total_scroll_width)
@@ -1072,7 +1068,6 @@ class ScrollHelper:
Returns: Returns:
Dictionary with scroll state information Dictionary with scroll state information
""" """
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0 required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
return { return {
'scroll_position': self.scroll_position, 'scroll_position': self.scroll_position,
+3 -3
View File
@@ -5,7 +5,7 @@ serves two consumers with different needs:
- The web UI's live preview (SSE reader in web_interface/app.py) wants - The web UI's live preview (SSE reader in web_interface/app.py) wants
fresh frames — but only while a browser is actually watching. fresh frames — but only while a browser is actually watching.
- The health check (web_interface/blueprints/api_v3.py, hardware status) - The health check (web_interface/blueprints/api_v3/misc.py, hardware status)
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded. uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
PNG-encoding every frame at 5 fps forever — identical frames, no viewers — PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
@@ -27,7 +27,7 @@ Policy:
TOUCH_INTERVAL so the health check (60s threshold) never degrades. TOUCH_INTERVAL so the health check (60s threshold) never degrades.
If any constant here changes, re-check the health threshold in If any constant here changes, re-check the health threshold in
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it. api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
""" """
from enum import Enum from enum import Enum
@@ -37,7 +37,7 @@ VIEWER_INTERVAL = 0.2
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds). # Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
IDLE_INTERVAL = 30.0 IDLE_INTERVAL = 30.0
# Max age of the last write/touch before bumping mtime for the health # Max age of the last write/touch before bumping mtime for the health
# check. MUST stay well under api_v3's 60s degraded threshold. # check. MUST stay well under get_hardware_status's 60s degraded threshold.
TOUCH_INTERVAL = 20.0 TOUCH_INTERVAL = 20.0
# A viewer marker older than this no longer counts as a live viewer. # A viewer marker older than this no longer counts as a live viewer.
VIEWER_MARKER_FRESH_SEC = 5.0 VIEWER_MARKER_FRESH_SEC = 5.0
+62 -32
View File
@@ -101,11 +101,10 @@ def element_color(config: Optional[Dict[str, Any]], element: str,
mode: Optional[str] = None): mode: Optional[str] = None):
"""Per-element text colour from customization.<element>.text_color. """Per-element text colour from customization.<element>.text_color.
Delegated rather than reimplemented: there were two copies of this Delegates to src.element_style.element_color, which also resolves the
read and three of the offset read, and the shared one also resolves element under the names plugins actually use (the layout block says
the element under the names plugins actually use (the layout block `score` where the style block says `score_text`) and honours a per-mode
says `score` where the style block says `score_text`) and honours a override. Hex strings are accepted.
per-mode override. Hex strings are still accepted.
""" """
from src.element_style import element_color as _shared from src.element_style import element_color as _shared
return _shared(config, element, default, mode) return _shared(config, element, default, mode)
@@ -125,12 +124,11 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
One object can legitimately belong to several elements -- a size resolver One object can legitimately belong to several elements -- a size resolver
can land two of them on the same face, and a BDF face cannot be un-shared can land two of them on the same face, and a BDF face cannot be un-shared
at all because ``freetype.Face`` objects cannot be rebuilt from a path. at all because ``freetype.Face`` objects cannot be rebuilt from a path.
Those draws used to go out white, which is how an element rendered in any Ambiguity is therefore narrowed before it is given up on: among the
of the 32 shipped bitmap fonts could silently lose a colour the user had
set. So ambiguity is now narrowed before it is given up on: among the
elements sharing a face, a single configured colour is the only thing the elements sharing a face, a single configured colour is the only thing the
user can have meant, and several that agree mean the same thing. Only a user can have meant, and several that agree mean the same thing. Only a
genuine disagreement falls back to *default*. genuine disagreement falls back to *default* -- otherwise an element
drawn in any of the shipped bitmap fonts could lose a colour the user set.
The element vocabulary is a parameter because the two callers disagree The element vocabulary is a parameter because the two callers disagree
about it -- the mixin's map says ``team_text`` where this module's says about it -- the mixin's map says ``team_text`` where this module's says
@@ -146,7 +144,8 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
if len(matches) > 1: if len(matches) > 1:
configured = [] configured = []
for element in matches: for element in matches:
colour = element_color(config, element, None, mode) # None as the default makes it come back when unconfigured.
colour = element_color(config, element, None, mode) # type: ignore[arg-type]
if colour is not None and colour not in configured: if colour is not None and colour not in configured:
configured.append(colour) configured.append(colour)
if len(configured) == 1: if len(configured) == 1:
@@ -339,6 +338,18 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
if not raw: if not raw:
return "" return ""
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev") fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
The body both date formatters share. They differ in which setting names the
style and in which zone the weekday is taken from (see
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
*weekday* is a zero-argument callable, only called for the "weekday" style.
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
"""
if fmt == "numeric": if fmt == "numeric":
return raw return raw
parts = raw.replace("-", "/").split("/") parts = raw.replace("-", "/").split("/")
@@ -347,14 +358,14 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
month, day = int(parts[0]), int(parts[1]) month, day = int(parts[0]), int(parts[1])
if not 1 <= month <= 12: if not 1 <= month <= 12:
return raw return raw
name = MONTH_ABBR[month - 1] name = months[month - 1]
if fmt == "numeric_day_first": if fmt == "numeric_day_first":
return f"{day}/{month}" return f"{day}/{month}"
if fmt == "day_first": if fmt == "day_first":
return f"{day} {name}" return f"{day} {name}"
if fmt == "weekday": if fmt == "weekday":
weekday = weekday_for(config, logger, game) day_name = weekday()
return f"{weekday} {name} {day}" if weekday else f"{name} {day}" return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
return f"{name} {day}" return f"{name} {day}"
@@ -388,6 +399,29 @@ def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {} _SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]:
"""``{element: font_size default}`` from a config_schema.json. Raises.
The parse both schema-default lookups share. Each keeps its own cache --
this function per schema path, ``SportsCoreSharedMixin._schema_font_size``
per class -- because the lifetimes differ: a class is rebuilt when the
display service reloads a plugin, a module-level path cache is not. One
cache would change when a reloaded plugin sees an edited schema.
"""
import json
with open(schema_path) as fh:
schema = json.load(fh)
props = (schema.get('properties', {})
.get('customization', {})
.get('properties', {}))
sizes: Dict[str, int] = {}
for key, spec in props.items():
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
sizes[key] = int(size)
return sizes
def schema_font_size(schema_path: str, element_key) -> Optional[int]: def schema_font_size(schema_path: str, element_key) -> Optional[int]:
"""The font_size this plugin's config_schema.json declares, or None. """The font_size this plugin's config_schema.json declares, or None.
@@ -399,18 +433,8 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
return None return None
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path) cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
if cache is None: if cache is None:
cache = {}
try: try:
import json cache = _read_schema_font_sizes(schema_path)
with open(schema_path) as fh:
schema = json.load(fh)
props = (schema.get('properties', {})
.get('customization', {})
.get('properties', {}))
for key, spec in props.items():
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
except Exception as exc: except Exception as exc:
# See sports_shared._schema_font_size: an unreadable schema # See sports_shared._schema_font_size: an unreadable schema
# silently disables the pixel-grid snap for every element. # silently disables the pixel-grid snap for every element.
@@ -444,7 +468,7 @@ def resolve_font_size(schema_path: str, element_config, element_key,
return crisp_size(font_name, default_size, aliases, grid_table) return crisp_size(font_name, default_size, aliases, grid_table)
def unshare_element_fonts(logger, fonts): def unshare_element_fonts(logger, fonts, element_for_font=None):
"""Give each colourable element its own face object. """Give each colourable element its own face object.
The colour a draw gets is resolved from the face it was handed, and The colour a draw gets is resolved from the face it was handed, and
@@ -458,14 +482,20 @@ def unshare_element_fonts(logger, fonts):
with identical metrics, so nothing about the rendering changes; only with identical metrics, so nothing about the rendering changes; only
the ability to tell two elements apart does. Faces that cannot be the ability to tell two elements apart does. Faces that cannot be
rebuilt (a BDF loaded through freetype.Face, anything without a usable rebuilt (a BDF loaded through freetype.Face, anything without a usable
path) are left shared, and their draws stay white as before. path) are left shared; resolve_font_color then picks their colour.
*element_for_font* names the font keys to consider, in order (the first
holder of a face keeps it); it defaults to this module's
:data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map,
which names different keys -- see ``resolve_font_color`` for why the two
vocabularies are kept apart.
""" """
try: # Looked up at call time so tests can spy on the pinned loader.
from src.common.font_layout import load_truetype as _load from src.common.font_layout import load_truetype
except ImportError: # pragma: no cover if element_for_font is None:
return fonts element_for_font = ELEMENT_FOR_FONT
seen = {} seen = {}
for key in ELEMENT_FOR_FONT: for key in element_for_font:
font = fonts.get(key) font = fonts.get(key)
if font is None: if font is None:
continue continue
@@ -476,7 +506,7 @@ def unshare_element_fonts(logger, fonts):
if not path or not size: if not path or not size:
continue continue
try: try:
fonts[key] = _load(path, size) fonts[key] = load_truetype(path, size)
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
logger.debug( logger.debug(
"Could not un-share the %s face; it keeps the default colour", key) "Could not un-share the %s face; it keeps the default colour", key)
+136
View File
@@ -0,0 +1,136 @@
"""The ``sports_card`` delegations every scoreboard's game renderer carries.
After the card helpers moved to ``sports_card`` (3.3.0), each of the eight
scoreboards with a ``game_renderer.py`` -- afl, baseball, basketball,
football, hockey, lacrosse, nrl and soccer -- kept one-line methods that
forward to them with its own ``config`` and ``logger``. Seventeen are
identical in all eight (executable AST, docstrings stripped) or in all but
football, and were copied here from ledmatrix-plugins ``30455671``
(origin/main, 2026-09-29) under their existing names. Football's own
``_format_game_date`` and ``_upcoming_center_mode`` (they follow the
switch-mode settings when it draws the full-screen scorebug) stay in football
and override these.
``_schema_font_size`` and ``_resolve_font_size`` look the same in every copy
but are not moved: they read ``_SCHEMA_PATH``, a module global that is each
plugin's own ``config_schema.json``.
These are the methods ``SportsGameRendererMixin`` (``sports_game_renderer``)
lists among what its host must provide, so a renderer that inherits both no
longer has to write them. Like that mixin this has no ``__init__`` and no
state. It is a separate module rather than more methods there for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-render.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_card_wrappers.py`` fails if a read is added
without being listed here.
- ``config`` and ``logger``.
- ``fonts``, read with ``getattr`` -- ``_font_color``.
- ``_FONT_NAME_ALIASES`` and ``_FONT_PIXEL_GRID`` class attributes --
``_crisp_size``, which passes them to ``sports_card.crisp_size`` so a
renderer that declares extra faces keeps them.
Add it as a base of the plugin's renderer, e.g.
``class GameRenderer(SportsCardWrappersMixin, SportsGameRendererMixin)``.
The two define no name in common; a method on the plugin's own class still
wins over either.
"""
import logging
from typing import Any, ClassVar, Dict, Optional, Tuple
from src.common import sports_card as _card
class SportsCardWrappersMixin:
"""The game renderer's ``sports_card`` delegations. See module docstring."""
# The host contract, declared for type checking only: these create no
# attributes, so the host's own values are what the methods read.
config: Dict[str, Any]
logger: logging.Logger
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]]
_FONT_PIXEL_GRID: ClassVar[Dict[str, Any]]
# ---- fonts ---------------------------------------------------------
@classmethod
def _crisp_size(cls, font_file, desired):
"""``sports_card.crisp_size`` with this renderer's font tables."""
return _card.crisp_size(font_file, desired,
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
def _unshare_element_fonts(self, fonts):
"""``sports_card.unshare_element_fonts``."""
return _card.unshare_element_fonts(self.logger, fonts)
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
"""``sports_card.font_color`` for one of ``self.fonts``."""
return _card.font_color(self.config, getattr(self, "fonts", None), font, default)
# ---- colours and favourites ---------------------------------------
@staticmethod
def _coerce_rgb(value, fallback):
"""``sports_card.coerce_rgb``."""
return _card.coerce_rgb(value, fallback)
@staticmethod
def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
"""``sports_card.side_is_favorite``."""
return _card.side_is_favorite(game, side, favorites)
@staticmethod
def _side_score(game: Dict[str, Any], side: str) -> Optional[int]:
"""``sports_card.side_score``."""
return _card.side_score(game, side)
def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]:
"""``sports_card.favorite_result``."""
return _card.favorite_result(self.config, game)
def _score_color_for(self, game: Dict[str, Any], game_type: str, default=None):
"""``sports_card.score_color_for``."""
return _card.score_color_for(self.config, self.logger, game, game_type, default)
def _recent_score_color(self, game: Dict[str, Any], default):
"""``sports_card.recent_score_color``."""
return _card.recent_score_color(self.config, self.logger, game, default)
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
"""``sports_card.element_color``."""
return _card.element_color(self.config, element, default)
# ---- card options, dates and times --------------------------------
def _scroll_card_option(self, key: str, default: Any = None) -> Any:
"""``sports_card.scroll_card_option``."""
return _card.scroll_card_option(self.config, key, default)
def _upcoming_center_mode(self) -> str:
"""``sports_card.upcoming_center_mode``."""
return _card.upcoming_center_mode(self.config)
def _vs_text(self) -> str:
"""``sports_card.vs_text``."""
return _card.vs_text(self.config)
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
"""``sports_card.format_game_date``."""
return _card.format_game_date(self.config, self.logger, date_text, game)
def _weekday_for(self, game: Optional[Dict]) -> str:
"""``sports_card.weekday_for``."""
return _card.weekday_for(self.config, self.logger, game)
def _card_tzinfo(self):
"""``sports_card.card_tzinfo``."""
return _card.card_tzinfo(self.config, self.logger)
def _format_game_time(self, time_text: str) -> str:
"""``sports_card.format_game_time``."""
return _card.format_game_time(self.config, time_text)

Some files were not shown because too many files have changed in this diff Show More