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
244 changed files with 17774 additions and 7872 deletions
-1
View File
@@ -4,4 +4,3 @@ exclude_paths:
- "plugins/**"
- "assets/**"
- "test/**"
- "scripts/debug/**"
+6
View File
@@ -1,2 +1,8 @@
# Auto detect text files and perform LF normalization
* 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
+5 -1
View File
@@ -6,6 +6,10 @@ on:
jobs:
claude-review:
# Pull requests from forks get no repository secrets, so without this
# guard every outside contributor's PR showed this check red for a reason
# they can't fix. Skipped checks don't block merging.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
@@ -21,7 +25,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Review PRs opened by the Claude GitHub App. Without this the action
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+70 -3
View File
@@ -8,7 +8,7 @@ on:
# needs a re-run or didn't get created.
workflow_dispatch:
# Both jobs only check out the repo and run pytest.
# The jobs only check out the repo and run the tests.
permissions:
contents: read
@@ -35,7 +35,7 @@ jobs:
- name: Install dependencies
run: |
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
- name: Run plugin safety harness
@@ -58,7 +58,7 @@ jobs:
- name: Install dependencies
run: |
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
# Run the ENTIRE test tree (except test/plugins, which the
@@ -73,3 +73,70 @@ jobs:
--cov=src --cov=web_interface \
--cov-report=term \
--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
+7 -9
View File
@@ -3,15 +3,13 @@ __pycache__/
*.py[cod]
*$py.class
# Secrets
config/config_secrets.json
# Atomic writes leave these behind when a save or a test is interrupted;
# the suite drops several per run.
config/.config_secrets.json.tmp.*
config/config.json
config/config.json.backup
config/wifi_config.json
config/uninstalled_plugins.json
# Secrets and per-device state. Everything the software writes into config/
# is local to one device -- config.json, config_secrets.json, wifi_config.json,
# ytm_auth.json (a login session), saved_repositories.json, font_overrides.json,
# and the temp files atomic writes leave behind when interrupted -- so only
# the templates are tracked. Listing files one by one missed several.
config/*
!config/*.template.json
credentials.json
token.pickle
+13 -5
View File
@@ -37,14 +37,22 @@ repos:
types: [python]
pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
# The mypy ratchet -- the same check as CI's "Type check (mypy ratchet)"
# 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:
- id: mypy
additional_dependencies: [types-requests, types-pytz]
args: [--ignore-missing-imports, --no-error-summary]
name: mypy (ratchet, mypy-clean.txt)
entry: python scripts/check_types.py
language: system
pass_filenames: false
files: ^src/
always_run: true
stages: [manual]
- repo: https://github.com/PyCQA/bandit
rev: 1.8.3
+598 -249
View File
@@ -19,266 +19,122 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
- Fixes found testing on a Pi:
- Stopping `ledmatrix.service` runs the controller's cleanup (SIGTERM now takes the Ctrl-C path).
- The Logs tab's "Now showing" no longer reads "unknown" when one screen stays up longer than 2 minutes.
- Turning Vegas on in the web UI works without a restart when it was off at startup.
- `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.
- A network failure fetching GitHub repo info logs a warning, not an error.
### Fixes
- Scripts and installer:
- `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.
- `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.
- New `scripts/README.md` lists every script.
- Docs:
- 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.
- On-demand no longer restarts a running display. `POST
/display/on-demand/start` treated `start_service` (on by default, and what
"Preview on display", the on-demand dialog and the MQTT bridge all send) as
"restart": it stopped the service, waited 1.5s and started it again, so
every request reloaded every plugin and left the panel blank for seconds.
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.
- Plugin store and plugin manager fixes:
- 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.
## 3.7.0
- Core service fixes:
- `/api/v3/errors` shows each exception's real stack trace instead of `NoneType: None`.
- Wi-Fi disconnect takes the saved connection profile down.
- `wifi_config.json` is written atomically, and a save that fails now gets a 500.
- `plugin://` fonts load from the plugin's own install directory. `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`.
- `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.
- Bundled font paths no longer depend on the directory the process was started from.
- Backups record `src.__version__`.
- Removed: `BackgroundDataService`'s `queue_size` stat and `clear_completed_requests()`.
Sports consolidation stage 3 (#672). No behaviour change: nothing in core
uses these yet, and the scoreboards adopt them when they floor on 3.7.0.
- Web API fixes:
- 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`.
### New modules
- Web UI:
- 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.
A plugin may import these via `src.*` (floor on 3.7.0). All three hold code
the scoreboard plugins carry as identical copies, moved without behaviour
change under the plugins' own method names; each docstring lists what the
host class must provide. The plugins delete their copies when they floor on
3.7.0.
- Display and Vegas:
- 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()`.
- `src/common/sports_celebration.py` — `SportsCelebrationMixin`, the
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.
- 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.
## 3.6.2
- 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.
A fix to `src.common.favorite_team_check` (#670).
- `/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`.
### Fixes
- `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.
- `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.
- `src.common.bdf_font` — `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.
- 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 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.
## 3.6.1
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:
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.
- `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`.
### Fixes
### Config writes
- 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.
- 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.
## 3.6.0
New names in existing modules (no new modules; a plugin importing these must
floor on the release that ships them):
New modules a plugin may import via `src.*` (floor on 3.6.0). Both are
promoted from files the scoreboard plugins carry as copies; the plugins keep
their copies as a fallback until they floor on 3.6.0. No other change since
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).
### Sports twins
- 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.
### Logo downloads
- `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.
### HTTP headers
- 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.
### 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.
### 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.
- `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".
- `src/common/favorite_team_check.py` — `FavoriteTeamCheck(logger, leagues)`:
checks configured favourite team codes against ESPN once per league, on a
daemon thread, and logs why a league shows nothing (a wrong code, with the
nearest real one, or a season that has not started). The seven copies
(`<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
@@ -297,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
runtime with `AttributeError`, which no load-time check sees, while a missing
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`,
`fetch_espn_date_chunks`, `espn_date_chunks`, `clamp_espn_limit`,
`ESPN_MAX_LIMIT`: fetch an ESPN scoreboard date range now that ESPN rejects
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
@@ -350,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
with any core config section are flagged: the last private copies of the
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
with `400 Bad Request` for every sport, so season schedules, the weeks window
@@ -399,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,
baseball and hockey boards already ship; core was the last copy on the old
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
@@ -429,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` /
`scroll_delay` are described as the speed clamp they are rather than frame
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
@@ -493,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
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.
- `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 .../assets/delete` validate `plugin_id` with `src/common/path_safety`
@@ -513,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.
- 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.
- 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
@@ -555,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
is written at load; the next save of that plugin's settings stores the object.
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
@@ -573,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
ownership step is now skipped where `os.chown` is missing. No behaviour
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
@@ -627,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
budget, so a rollback finishes inside the unit's 30-minute limit instead of
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
@@ -640,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
confirmation. `first_time_install.sh` also built that file at a fixed `/tmp`
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)
@@ -677,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
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
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
@@ -706,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,
nonexistent version-bump scripts and `ledmatrix` service user references
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
+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
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
the issue involves account safety. All complaints will be reviewed and
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
in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
- **Real-time discussion**: the
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT).
[LEDMatrix Discord](https://discord.gg/RdrC37rEag).
- **Plugin development**:
[`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md)
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
adjacent bugs while working, fix them in a separate PR.
4. **Follow the existing code style.** The pre-commit hooks run
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `mypy` on
`src/`, `bandit`, and `gitleaks` — install the CLI with
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `bandit`,
and `gitleaks` — install the CLI with
`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/`
and `static/v3/`.
5. **Update documentation** alongside code changes. If you add a
+2 -2
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
- 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/
- 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!)
-----------------------------------------------------------------------------------
@@ -948,7 +948,7 @@ sudo systemctl enable ledmatrix-web.service
- **On-Demand Controls**: Start specific displays (weather, stocks, sports) on demand
- **Service Management**: Start/stop the main display service
- **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
### Troubleshooting Web Interface
+1 -1
View File
@@ -16,7 +16,7 @@ Use one of these channels, in order of preference:
maintainer.
- Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new>
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.
Please include:
+15 -7
View File
@@ -1,12 +1,20 @@
#!/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 sys
# Add the project root directory to Python path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from src.display_controller import main
import runpy
if __name__ == "__main__":
main()
runpy.run_path(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "run.py"),
run_name="__main__",
)
+17 -6
View File
@@ -52,8 +52,10 @@ each other. They share three things:
| 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 also restarts `ledmatrix.service` by default so the
request takes effect straight away.
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
@@ -82,7 +84,11 @@ then normal rotation.
- **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.
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.
@@ -127,7 +133,7 @@ then normal rotation.
| 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`) |
| 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
@@ -158,8 +164,13 @@ everything else through `_reinstall_with_rollback()`.
- **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),
`plugins.py`, `starlark.py`, `system.py` (service actions, updates, git),
`wifi.py`. `__init__.py` defines the blueprint and shared helpers and
`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
+1 -1
View File
@@ -180,5 +180,5 @@ See [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md).
| 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 |
+6 -6
View File
@@ -17,13 +17,13 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com
## Prerequisites
### System Requirements
- Python 3.7 or higher
- Python 3.10 or higher
- Windows, macOS, or Linux
- At least 2GB RAM (4GB recommended)
- Internet connection for plugin downloads
### Required Software
- Python 3.7+
- Python 3.10+
- pip (Python package manager)
- Git (for plugin management)
@@ -50,8 +50,7 @@ pip install -r requirements-emulator.txt
```
This installs:
- `RGBMatrixEmulator` - The core emulation library
- Additional dependencies for display adapters
- `RGBMatrixEmulator` - the emulation library (and whatever it depends on)
### 3. Install Standard Dependencies
@@ -63,8 +62,9 @@ pip install -r requirements.txt
### 1. Emulator Configuration File
The emulator uses `emulator_config.json` for configuration. Here's the
default configuration as it ships in the repo:
The emulator uses `emulator_config.json` for configuration. It isn't in
the repo (it's gitignored): RGBMatrixEmulator writes it on first run.
A typical file looks like this:
```json
{
+1 -1
View File
@@ -26,7 +26,7 @@ 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_manager.py`](../src/plugin_system/store_manager.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 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:
+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
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`,
`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
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
+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
`web_interface/static/v3/js/widgets/`)
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
`config_secrets.json`) and shows a notification
+3 -3
View File
@@ -32,7 +32,7 @@
│ • masks x-secret fields │
│ • 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 │
│ get_plugin_config() GET /api/v3/plugins/config │
│ 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)
│
▼
save_plugin_config() (api_v3/plugins.py)
save_plugin_config() (api_v3/plugin_config.py)
├─→ Start from the stored config.json[<id>]
├─→ Apply form fields: dotted names → nested keys, "[]" checkbox
│ groups → lists, values coerced to the schema's types
@@ -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`) |
| 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` |
| Secret masking and splitting | `src/web_interface/secret_helpers.py` |
| 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
(next to **Plugin Manager**) with the `icon` field in `manifest.json`.
> **Status:** the tab code honors `icon`, but `GET /api/v3/plugins/installed`
> (`web_interface/blueprints/api_v3/plugins.py`) does not currently include
> the manifest's `icon` in its response, so every tab shows the default
> puzzle piece. Setting `icon` is harmless and will take effect once the API
> passes it through again.
`GET /api/v3/plugins/installed` passes the manifest's `icon` through (a
non-string value comes back as `null`), and a plugin without one gets the
default puzzle piece.
## 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.
2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon
class.
3. See the status note above: the icon is currently not passed through by
the API.
3. The manifest is re-read on each plugin list load; reload the page after
editing `icon`.
## Related Documentation
+2 -2
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:
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`).
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
@@ -154,7 +154,7 @@ For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TR
## Files to Reference
- 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`
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
- Sudo rules: `scripts/install/lib_sudoers.sh` (written by `first_time_install.sh`
+1 -1
View File
@@ -645,7 +645,7 @@ To have your plugin added to the official plugin store:
3. **Contact maintainers** (own-repository plugins):
- 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
4. **Review process**:
+2
View File
@@ -56,6 +56,8 @@ Going deeper:
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
cache management, background services, permissions
- [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
+12 -5
View File
@@ -40,13 +40,14 @@ the entry below says so.
> The API blueprint is the `api_v3` package in
> `web_interface/blueprints/api_v3/` (one module per area: `config.py`,
> `display.py`, `plugins.py`, `system.py`, `backup.py`, `fonts.py`,
> `misc.py`, `wifi.py`, `starlark.py`). `web_interface/app.py` registers it
> `display.py`, `system.py`, `backup.py`, `fonts.py`, `misc.py`, `wifi.py`,
> `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')`).
> The three SSE endpoints (`/api/v3/stream/*`) are defined directly on the
> Flask app in `app.py` (`stream_stats`, `stream_display`, `stream_logs`).
> `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)
- `duration` (number, optional): Duration in seconds (0 = until stopped)
- `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**:
```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>`
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**:
```json
+14
View File
@@ -81,6 +81,11 @@ more. Shared sports code lives in `src/common`:
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
| `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).
@@ -166,6 +171,15 @@ Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
SportsLive)` — so the celebration `display()` runs first and falls through to
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
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
across calls (afl/nrl/soccer) and a precomputed per-cycle list
+9
View File
@@ -616,6 +616,15 @@ sudo systemctl cat ledmatrix-web | grep User
```
**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:**
- OpenWeatherMap free tier: 1,000 calls/day, 60 calls/minute
- With 300s interval: 288 calls/day (well within limits)
+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 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 = 3.10
@@ -25,11 +30,11 @@ warn_unreachable = True
# Strict optional checking
strict_optional = True
# Disallow untyped definitions
disallow_untyped_defs = False # Set to True once all code is typed
# Disallow untyped definitions (set to True once all code is typed)
disallow_untyped_defs = False
# Disallow untyped calls
disallow_untyped_calls = False # Set to True once all code is typed
# Disallow untyped calls (set to True once all code is typed)
disallow_untyped_calls = False
# Check untyped definitions
check_untyped_defs = True
@@ -96,10 +101,20 @@ ignore_missing_imports = True
[mypy-spotipy.*]
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).
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
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
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
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
+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)
# Timezone handling
pytz>=2024.2,<2025.0 # Updated for latest timezone data
pytz>=2024.2,<2027.0 # Updated for latest timezone data
# HTTP requests
requests>=2.33.0,<3.0.0
+2
View File
@@ -31,9 +31,11 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
| `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 |
+1 -1
View File
@@ -194,7 +194,7 @@ def process_schema_file(schema_path: Path) -> bool:
print(f" ✓ Modified {len(modified_fields)} fields")
return True
else:
print(f" ✓ No changes needed")
print(" ✓ No changes needed")
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]]:
"""Validate JSON Schema syntax."""
errors = []
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
@@ -164,7 +163,7 @@ def analyze_schema(schema_path: Path) -> Dict[str, Any]:
if "update_interval_seconds" in properties:
analysis["update_interval_variant"] = "update_interval_seconds"
analysis["naming_issues"].append(
f"Uses 'update_interval_seconds' instead of 'update_interval'"
"Uses 'update_interval_seconds' instead of 'update_interval'"
)
else:
analysis["missing_common_fields"].append(field_name)
@@ -239,7 +238,7 @@ def main():
print(f" Missing common fields: {', '.join(result['missing_common_fields'])}")
if result['naming_issues']:
print(f" Naming issues:")
print(" Naming issues:")
for issue in result['naming_issues']:
print(f" - {issue}")
+4 -6
View File
@@ -112,15 +112,13 @@ if command -v python3 >/dev/null 2>&1; then
echo "Python: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -eq "3" ]; then
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "12" ]; then
print_success "Python version is fully supported (3.10-3.12)"
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"
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "13" ]; then
print_success "Python version is supported (3.10-3.13)"
elif [ "$PYTHON_MINOR" -ge "14" ]; then
print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet"
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
else
print_error "Python 2.x detected - Python 3.10+ is required"
+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())
+1 -1
View File
@@ -423,7 +423,7 @@ def main():
global _extra_dirs
_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"Plugin search dirs: {[str(d) for d in get_search_dirs()]}")
print()
-1
View File
@@ -299,6 +299,5 @@ def main():
if __name__ == '__main__':
import importlib.util
from typing import Optional
sys.exit(main())
+30 -1
View File
@@ -42,6 +42,8 @@ class WiFiMonitorDaemon:
"""
self.check_interval = check_interval
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.last_state = None
# 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...")
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):
"""Main daemon loop"""
logger.info("WiFi Monitor Daemon started")
@@ -78,6 +105,8 @@ class WiFiMonitorDaemon:
while self.running:
try:
self._reload_config_if_changed()
# One combined check that also returns the state it observed —
# the previous flow fetched status before AND after the check
# on top of the check's own internal fetch, each one several
@@ -219,7 +248,7 @@ def main():
parser.add_argument(
'--foreground',
action='store_true',
help='Run in foreground (for debugging)'
help='Accepted for compatibility; the daemon always runs in the foreground'
)
args = parser.parse_args()
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.5.0"
__version__ = "3.7.0"
+25 -9
View File
@@ -70,7 +70,14 @@ def _read(path):
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:
@@ -201,17 +208,26 @@ class UpdateHelperSetup:
try:
self.result_file.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2)
os.chmod(tmp, 0o644)
if self._web_ids and hasattr(os, 'chown'):
# Only root can give the file away; the result is readable
# (0644) either way, so a failed chown must not lose it.
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2)
os.chmod(tmp, 0o644)
if self._web_ids and hasattr(os, 'chown'):
# Only root can give the file away; the result is readable
# (0644) either way, so a failed chown must not lose it.
try:
os.chown(tmp, *self._web_ids)
except OSError:
pass
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.chown(tmp, *self._web_ids)
os.unlink(tmp)
except OSError:
pass
os.replace(tmp, self.result_file)
raise
except OSError as e:
logger.warning("Could not record automatic update setup result: %s", e)
return result
+48 -12
View File
@@ -103,6 +103,32 @@ class FetchResult:
# FAILED, which turns "you cancelled this" into "this errored".
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:
"""
Background data service for fetching season data without blocking the main thread.
@@ -163,10 +189,16 @@ class BackgroundDataService:
'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.mount('http://', requests.adapters.HTTPAdapter(max_retries=3))
self.session.mount('https://', 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=0))
# Default headers: core's shared set (real User-Agent, no hand-set
# Accept-Encoding) -- see src/common/api_helper.py.
@@ -247,15 +279,19 @@ class BackgroundDataService:
# same object the dict holds.
self.completed_requests[request_id] = result
if callback:
try:
callback(result)
except Exception as e:
logger.error(f"Error in callback for request {request_id}: {e}")
self._release_payload(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:
try:
callback(result)
except Exception as e:
logger.error(f"Error in callback for request {request_id}: {e}")
self._release_payload(result)
logger.debug(f"Cache hit for {sport} {year} data")
return request_id
logger.debug(f"Cache hit for {sport} {year} data")
return request_id
# limit above 500 makes an ESPN *scoreboard* return a truncated list
# (src/common/espn_dates.py). Other endpoints need more: /teams has 762
@@ -560,7 +596,7 @@ class BackgroundDataService:
"""
logger.info("Recovering %s %s from a rejected date range", request.sport, request.year)
return fetch_espn_date_chunks(
self.session,
_ConnectionRetryingSession(self.session),
request.url,
params=request.params,
headers=request.headers,
+41 -5
View File
@@ -102,6 +102,12 @@ _SINGLE_FILE_SECTIONS: Tuple[Tuple[str, Path, str], ...] = (
("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"
PLUGINS_MANIFEST_NAME = "plugins.json"
@@ -235,6 +241,10 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
data = json.load(f)
except (OSError, json.JSONDecodeError):
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
if plugin_id not in plugins:
plugins[plugin_id] = {
@@ -310,7 +320,11 @@ def create_backup(
contents: List[str] = []
# 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:
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for section, rel, _flag in _SINGLE_FILE_SECTIONS:
@@ -347,7 +361,24 @@ def create_backup(
manifest = _build_manifest(contents)
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
os.replace(tmp_path, zip_path)
# 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)
except BaseException:
zip_path.unlink(missing_ok=True)
raise
except Exception:
tmp_path.unlink(missing_ok=True)
raise
@@ -450,7 +481,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
):
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:
try:
plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8"))
@@ -493,7 +525,7 @@ def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
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``.
``shutil.copy2`` opens the destination for writing, so it needs write
@@ -509,6 +541,7 @@ def _copy_file(src: Path, dst: Path) -> None:
The destination's existing mode is preserved when there is one, so
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)
@@ -530,6 +563,8 @@ def _copy_file(src: Path, dst: Path) -> None:
shutil.copyfile(src, tmp_path)
if existing_mode is not None:
os.chmod(tmp_path, existing_mode)
elif new_mode is not None:
os.chmod(tmp_path, new_mode)
else:
shutil.copymode(src, tmp_path)
if existing_owner is not None and hasattr(os, 'chown'):
@@ -593,7 +628,8 @@ def restore_backup(
result.skipped.append(section)
continue
try:
_copy_file(tmp_dir / rel, project_root / rel)
_copy_file(tmp_dir / rel, project_root / rel,
new_mode=_PRIVATE_FILE_MODE if rel in _PRIVATE_SECTION_RELS else None)
result.restored.append(section)
except OSError as e:
logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
+32 -11
View File
@@ -16,11 +16,16 @@ import time
import requests
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:
"""
Base class for odds data fetching and management.
@@ -100,7 +105,7 @@ class BaseOddsManager:
_FAILURE_COOLDOWN = 60.0
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.
@@ -121,10 +126,20 @@ class BaseOddsManager:
cache_key = f"odds_espn_{sport}_{league}_{event_id}"
# 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:
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
if time.monotonic() < self._skip_network_until:
@@ -137,7 +152,7 @@ class BaseOddsManager:
self._skip_network_until - time.monotonic())
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:
# Map league names to ESPN API format
@@ -151,7 +166,7 @@ class BaseOddsManager:
espn_league = league_mapping.get(league, league)
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.raise_for_status()
@@ -163,9 +178,9 @@ class BaseOddsManager:
odds_data = self._extract_espn_data(raw_data)
if odds_data:
self.logger.info(f"Successfully extracted odds data: {odds_data}")
self.logger.debug(f"Successfully extracted odds data: {odds_data}")
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:
self.logger.debug(f"No odds data available for {cache_key}")
# Cache the absence too, so the game is not re-requested
@@ -174,16 +189,22 @@ class BaseOddsManager:
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:
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
self.logger.error(
"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.",
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]]:
"""
+113 -20
View File
@@ -14,7 +14,7 @@ import tempfile
import logging
import threading
import zlib
from typing import Dict, Any, Optional, Protocol
from typing import Dict, Any, Optional, Protocol, Tuple
from datetime import datetime
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.
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
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)
if not match:
return False
try:
timestamp = float(match.group(1))
if refreshed is not None:
timestamp = max(timestamp, refreshed)
limit = max_age
if match.group(2) is not None:
ttl = float(match.group(2))
@@ -248,11 +296,13 @@ class DiskCache:
self.cache_dir = cache_dir
self.logger = logger or logging.getLogger(__name__)
self._lock = threading.Lock()
# key -> adler32 of the last payload successfully written to the
# primary cache path; lets set() skip rewriting identical data
# (per-process only — worst case another process rewrites, never
# a missed write). Guarded by _lock.
self._write_digests: Dict[str, int] = {}
# key -> ((length, adler32) of the last content written to the
# primary cache path, (st_ino, st_size) of the file it left); lets
# set() skip rewriting identical data. The file identity catches
# another process -- the web interface writes and clears keys too --
# 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]:
"""
@@ -306,7 +356,12 @@ class DiskCache:
# records (a season schedule is re-fetched when its cache
# expires), and parsing 53MB to throw it away held the GIL
# 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
f.seek(0)
record = _loads(f.read())
@@ -315,6 +370,11 @@ class DiskCache:
record_ts = None
if isinstance(record, dict):
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:
try:
record_ts = os.path.getmtime(cache_path)
@@ -403,24 +463,37 @@ class DiskCache:
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
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:
# Atomic write to avoid partial/corrupt files
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
# every update cycle — each write is real SD-card wear).
# Refresh the file mtime so records that rely on it for TTL
# (no embedded 'timestamp') don't expire early; a metadata
# touch is journal-cheap compared to rewriting the data.
if self._write_digests.get(key) == digest:
# Move the file mtime instead, so the record stays as fresh as
# the rewrite would have left it; a metadata touch is
# journal-cheap compared to rewriting the data.
known = self._write_digests.get(key)
if known is not None and known[0] == digest:
try:
os.utime(cache_path, None)
return
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
except OSError:
# File vanished or perms changed — fall through and write
self._write_digests.pop(key, None)
pass
# File vanished, was replaced by another process, or its
# times cannot be set — fall through and write
self._write_digests.pop(key, None)
tmp_dir = os.path.dirname(cache_path)
# Try to create temp file in cache directory first
@@ -458,7 +531,7 @@ class DiskCache:
# opened it in between was refused.
_share_open_file(tmp_file.fileno(), _shared_group(tmp_dir))
os.replace(tmp_path, cache_path)
self._write_digests[key] = digest
self._remember_write(key, cache_path, digest, stamped_at)
finally:
if os.path.exists(tmp_path):
try:
@@ -471,7 +544,7 @@ class DiskCache:
with open(cache_path, 'wb') as cache_file:
cache_file.write(payload)
_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)
except (IOError, OSError, PermissionError) as write_error:
# If direct write also fails, try fallback location
@@ -520,6 +593,26 @@ class DiskCache:
)
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:
"""
Clear cache entry or all entries.
+6 -3
View File
@@ -8,7 +8,7 @@ import os
import time
import threading
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.
DEFAULT_MAX_SIZE = 1000
@@ -70,13 +70,15 @@ class MemoryCache:
"""
self.logger = logging.getLogger(__name__)
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._max_size = max_size
self._cleanup_interval = cleanup_interval
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.
@@ -200,6 +202,7 @@ class MemoryCache:
max_age_for_cleanup = 3600 # 1 hour
expired_keys = []
timestamp: Optional[Union[float, str]]
for key, timestamp in list(self._timestamps.items()):
if isinstance(timestamp, str):
try:
+86 -2
View File
@@ -24,24 +24,32 @@ Rules for the package:
| 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 | Unreleased |
| [`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 four `sports_*` mixin and card modules hold code the scoreboard plugins
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).
@@ -97,6 +105,17 @@ 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.
### favorite_team_check
[`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.
### font_layout
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
@@ -107,6 +126,24 @@ 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.
### frame_timing
[`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,
@@ -134,6 +171,14 @@ let the root display service and the web user share files:
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,
@@ -174,6 +219,33 @@ 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).
@@ -208,6 +280,18 @@ 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`
+25 -13
View File
@@ -11,12 +11,16 @@ import time
from datetime import datetime
from types import MappingProxyType
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
from requests.adapters import HTTPAdapter
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
#: and links to it: around 2026-08-04 ESPN began 403ing bare custom tokens
@@ -84,7 +88,11 @@ class APIHelper:
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
# 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
def get(self, url: str, params: Optional[Dict] = None,
@@ -108,14 +116,14 @@ class APIHelper:
cached = self._get_from_cache(cache_key, cache_ttl)
if cached is not None:
self.logger.debug(f"Using cached response for {cache_key}")
return cached
return cast(Dict[Any, Any], cached)
# Rate limiting
self._enforce_rate_limit()
try:
# Prepare request
request_headers = self.session.headers.copy()
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers:
request_headers.update(headers)
@@ -129,7 +137,7 @@ class APIHelper:
response.raise_for_status()
# Parse JSON response
data = response.json()
data: Dict[Any, Any] = response.json()
# Cache response if cache key provided
if cache_key and self.cache_manager:
@@ -243,7 +251,7 @@ class APIHelper:
self._enforce_rate_limit()
try:
request_headers = self.session.headers.copy()
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers:
request_headers.update(headers)
@@ -256,7 +264,7 @@ class APIHelper:
)
response.raise_for_status()
return response.json()
return cast(Optional[Dict[Any, Any]], response.json())
except requests.exceptions.RequestException as e:
self.logger.error(f"POST request failed for {url}: {e}")
@@ -333,13 +341,14 @@ class APIHelper:
def _enforce_rate_limit(self) -> None:
"""Enforce rate limiting between requests."""
current_time = time.time()
time_since_last = current_time - self._last_request_time
if self._last_request_monotonic is not None:
time_since_last = time.monotonic() - self._last_request_monotonic
if time_since_last < self._min_request_interval:
sleep_time = self._min_request_interval - time_since_last
time.sleep(sleep_time)
if time_since_last < self._min_request_interval:
sleep_time = self._min_request_interval - time_since_last
time.sleep(sleep_time)
self._last_request_monotonic = time.monotonic()
self._last_request_time = time.time()
def set_rate_limit(self, min_interval: float) -> None:
@@ -362,5 +371,8 @@ class APIHelper:
return {
'min_request_interval': self._min_request_interval,
'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),
}
+4 -4
View File
@@ -37,7 +37,7 @@ import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date, timedelta
from functools import partial
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, cast
try:
from src.common.json_body import response_json
@@ -159,7 +159,7 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
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.
Events are de-duplicated by id and keep first-seen order. Non-event keys
@@ -202,7 +202,7 @@ def _fetch_one_chunk(
timeout=timeout,
)
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
if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
@@ -379,4 +379,4 @@ def fetch_espn_scoreboard(
if data is not None:
return data
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)
+8 -2
View File
@@ -93,7 +93,7 @@ import tempfile
import threading
import time
import traceback
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
logger = logging.getLogger(__name__)
@@ -483,7 +483,13 @@ class FrameTimingRecorder:
raise
def watchdog_settings() -> Dict[str, float]:
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
+42 -17
View File
@@ -8,7 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
import logging
import time
from pathlib import Path
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional, Tuple, Union
import requests
from PIL import Image, ImageDraw
@@ -81,6 +81,11 @@ class LogoHelper:
# downloader writes them at runtime) is still picked up.
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
self.session = requests.Session()
self.session.headers.update({
@@ -120,17 +125,7 @@ class LogoHelper:
# Resolve the effective target size BEFORE the cache lookup so the
# key is size-qualified — a panel-size change must not return a
# logo resized for the old dimensions.
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)))
max_width, max_height, scale = self._scaled_box(max_width, max_height, scale)
# The key carries the scaled box, so two elements scaled differently
# cannot be served each other's image.
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
@@ -159,7 +154,7 @@ class LogoHelper:
return None
# Load image
logo = Image.open(logo_path)
logo: Image.Image = Image.open(logo_path)
if logo.mode != 'RGBA':
logo = logo.convert('RGBA')
@@ -204,7 +199,12 @@ class LogoHelper:
return self.load_logo(team_abbr, logo_path, max_width, max_height,
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:
try:
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
@@ -218,6 +218,7 @@ class LogoHelper:
scale)
except Exception as 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
# placeholder is still on disk with its old timestamp, and
# leaving it there means the next call retries immediately --
@@ -225,8 +226,30 @@ class LogoHelper:
# exists to prevent.
self._refresh_stale_placeholder(logo_path)
# Create placeholder if all else fails
return self._create_placeholder_logo(team_abbr, max_width, max_height)
# Create placeholder if all else fails. Sized to the same scaled box
# 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:
"""Drop every cached size of one logo after its file changed on disk."""
@@ -240,6 +263,7 @@ class LogoHelper:
# leaving it would hide a logo we just downloaded.
for key in [k for k in self._missing_logos if k.startswith(prefix)]:
del self._missing_logos[key]
self._download_failures.pop(str(logo_path), None)
@staticmethod
def _refresh_stale_placeholder(logo_path: Path) -> None:
@@ -332,9 +356,10 @@ class LogoHelper:
self._logo_cache.clear()
self._cache_order.clear()
self._missing_logos.clear()
self._download_failures.clear()
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.
+41 -24
View File
@@ -290,6 +290,26 @@ def get_cache_dir_mode() -> int:
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:
"""
Remove a directory using sudo as a last resort.
@@ -350,22 +370,25 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
logger.error(f"Safe removal helper not found: {helper_script}")
return False
bash_path = _shutil.which('bash') or '/bin/bash'
try:
result = subprocess.run(
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and not resolved.exists():
logger.info(f"Successfully removed {path} via sudo helper")
return True
else:
stderr = result.stderr.strip()
logger.error(f"sudo helper failed for {path}: {stderr}")
return False
for bash_path in _sudo_bash_candidates():
result = subprocess.run(
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and not resolved.exists():
logger.info(f"Successfully removed {path} via sudo helper")
return True
# Only a refused command line is worth another bash path; if the
# 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}")
return False
except subprocess.TimeoutExpired:
logger.error(f"sudo helper timed out for {path}")
return False
@@ -417,16 +440,10 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
if wrapper.exists():
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
# be invoked with an explicit, known path rather than relying on the
# 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)
# See _sudo_bash_candidates for why bash is invoked by explicit path
# and why there is more than one to try.
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
# safe_pip_install.sh independently re-validates req_file is an
# allowed requirements.txt before installing anything as root.
+9 -15
View File
@@ -46,7 +46,9 @@ import sys
import threading
import time
from collections import deque
from typing import Any, Callable, Deque, List, Optional
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.
@@ -90,27 +92,19 @@ def _unsafe(frame: Any, base: Any) -> bool:
def swap_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
The rebuilt binding links PyEval_SaveThread and the stock one never does.
The same test as src.common.frame_timing.binding_releases_gil (#629); one
of the two goes once both have landed.
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.
"""
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
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 is_owned()
return lock.locked()
return cast(bool, is_owned())
return cast(bool, lock.locked())
class RenderGate:
+10 -6
View File
@@ -49,7 +49,9 @@ from __future__ import annotations
import logging
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__)
@@ -63,8 +65,9 @@ MIN_PIXELS_PER_SECOND = 1.0
MAX_PIXELS_PER_SECOND = 500.0
#: Assumed refresh when the caller does not say. Matches the usual
#: ``display.hardware.limit_refresh_rate_hz``.
DEFAULT_REFRESH_HZ = 100.0
#: ``display.hardware.limit_refresh_rate_hz``, and is the cap DisplayManager
#: 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
#: 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,
max_frame_hold: int = MAX_FRAME_HOLD,
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
):
) -> List[CrispSpeed]:
"""Every whole-pixel speed this panel can show, slowest first.
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
smaller increments, so that is the one worth offering.
"""
best = {}
best: Dict[float, CrispSpeed] = {}
for hold in range(1, max_frame_hold + 1):
for ppf in range(1, max_pixels_per_frame + 1):
pps = refresh_hz / hold * ppf
@@ -431,7 +434,8 @@ def configure(
# they start scrolling. configure() only reports what is needed.
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:
log.info(
"Scroll configured: %s (asked for %.1f px/s from %s; "
+18
View File
@@ -254,6 +254,9 @@ class ScrollHelper:
now = time.time()
self.scroll_start_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(
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
self.calculated_duration,
@@ -776,6 +779,18 @@ class ScrollHelper:
self.clear_cache()
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
self.cached_image = image
@@ -802,6 +817,9 @@ class ScrollHelper:
self.scroll_start_time = now
self.last_progress_log_time = now
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",
image.width, image.height, self.total_scroll_width)
+2 -1
View File
@@ -144,7 +144,8 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
if len(matches) > 1:
configured = []
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:
configured.append(colour)
if len(configured) == 1:
+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)
+780
View File
@@ -0,0 +1,780 @@
"""How the scoreboards draw a score or win celebration.
Five scoreboards -- afl, football, hockey, nrl and soccer -- take over the
panel when a team scores or wins: a backdrop in the scoring team's colours
read off its crest, scenery for the kind of score, confetti, the headline and
the score with the scoring side's digits breathing. The drawing is identical
in all five ``sports.py`` copies (executable AST, docstrings stripped), and
so are the colour helpers it uses; they were copied here from
ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29).
Only the drawing moved. What *arms* a celebration stays in each plugin,
because it differs: which scores count (``_check_for_goal`` /
``_check_for_score``, and nrl matches favourites by team id), the phrase and
the scenery (``_start_celebration``), and when a win fires
(``_check_for_win``). So does ``display()``, which decides whether the
takeover or the scorebug is on screen. A plugin hands this mixin a
celebration dict and it draws it.
The colour helpers are public free functions here (``logo_palette``,
``lift_color``, ``mix_color``, ...); in the plugins they were the same
functions with a leading underscore.
THE CELEBRATION DICT
--------------------
Built by the plugin's ``_start_celebration``. Read here: ``game`` (a
view-model dict; ``<side>_id``, ``<side>_abbr``, ``<side>_logo_path`` and
``<side>_logo_url`` for the crests, ``id`` for the confetti seed),
``scored_side`` (``"away"`` or ``"home"``), ``away_score``, ``home_score``,
``phrase``, ``started_at`` (a ``time.time()`` value) and ``motif``
(``"score"``, ``"kick"``, ``"touchdown"``, ``"net"`` or ``"win"``; anything
else draws the ``"score"`` diagonals). The drawing caches what it derives in
the same dict, under ``_palette``, ``_backdrop``, ``_confetti`` and
``_crests``, so each is worked out once per celebration.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_celebration.py`` fails if a read is added without
being listed here. All five scoreboards' ``SportsLive`` provide them.
- ``display_manager`` -- ``image`` is replaced with the frame, then
``update_display()``; ``clear()`` on ``force_clear``. Its ``matrix``
width and height are used when it has a matrix, else ``display_width`` /
``display_height``.
- ``fonts`` -- ``"time"`` and ``"status"`` for the headline (the first that
fits), ``"score"`` for the score.
- ``logger``.
- ``_load_and_resize_logo(team_id, abbr, logo_path, logo_url)`` -- a crest
as an RGBA image, or ``None``.
- ``_draw_text_with_outline(draw, text, position, font, fill=...)`` -- on
``SportsCoreSharedMixin``.
- ``celebration_duration``, ``celebration_team_colors`` and
``celebration_confetti``, read with ``getattr`` (defaults 8, on, on).
Mix it in ahead of the mode classes, e.g.
``class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin,
SportsCore)``. It defines nothing any of them define, so the order only
matters for a plugin that keeps its own copy of one of these methods: a
method on the plugin's class always wins over the mixin's.
"""
import colorsys
import logging
import math
import random
import time
from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple
from PIL import Image, ImageDraw
#: A colour as the helpers return it: three 0-255 channels.
Color = Tuple[int, ...]
#: ``deep``, ``glow``, ``headline`` and ``accent``; see ``logo_palette``.
Palette = Dict[str, Color]
#: One confetti flake: column, start height, fall speed, sway phase, size
#: in pixels, colour.
Flake = Tuple[float, float, float, float, int, Color]
# ----------------------------------------------------------------------
# Colour helpers for the score/win celebration
#
# Module level rather than methods: they are pure, which is what makes the
# palette testable without standing up a live manager, and they are shared by
# the takeover's backdrop, confetti and text.
# ----------------------------------------------------------------------
#: The crest is sampled at this resolution. Big enough that a secondary
#: colour survives (a helmet stripe, a trim), small enough that the whole
#: sample is ~1600 pixels of pure-Python work, once per team.
_PALETTE_SAMPLE_PX = 40
#: Above this, a colour carries team identity; below it, it is a grey.
_PALETTE_VIVID_SATURATION = 0.22
#: Ignore pixels this dark -- crest outlines, drop shadows, anti-aliasing.
_PALETTE_MIN_CHANNEL = 24
#: How far apart two bins must be to count as a second, different colour.
_PALETTE_DISTINCT_DISTANCE = 90.0
#: Never bleed a lifted colour below this saturation; past it a hue stops
#: being the team's colour and starts being a pastel.
_PALETTE_MIN_SATURATION = 0.42
#: Lift a headline colour until it is at least this luminous. Chosen so
#: midnight navy reaches a blue that reads at 6px on a panel without
#: becoming a different colour.
_PALETTE_HEADLINE_LUMINANCE = 112.0
#: A crest colour this luminous already reads on a panel, so it is preferred
#: over a darker one that would have to be lifted to get there. Lifting is a
#: compromise -- Green Bay's dark green only reaches legibility as a teal --
#: and most teams whose primary is dark carry a bright second colour that is
#: just as much theirs. This is what picks the Packers' gold over that teal.
_PALETTE_LEGIBLE_LUMINANCE = 90.0
#: ...but only from a colour the crest actually means. The pixels where a
#: bright edge is anti-aliased into a dark fill are luminous too, and there is
#: always a band of them: Kansas City's white-on-red outline leaves a pink at
#: luminance 90 that would otherwise be preferred over the red itself. A blend
#: is a mix, so it is markedly less saturated than either colour it sits
#: between -- that pink is 0.48 where the red is 0.96 and the Packers' gold,
#: which this must keep, is 0.89.
_PALETTE_LEGIBLE_SATURATION = 0.65
#: And it has to be a band of the crest, not a speck of one.
_PALETTE_LEGIBLE_AREA = 0.02
#: Cap the backdrop's luminance so the headline stays legible over it,
#: and the scenery's so it stays behind the headline. Both are luminance and
#: not HSV value on purpose: a silver crest -- the Raiders, or the grey
#: placeholder a failed logo download leaves behind -- has a value of ~0.95,
#: and capping that at 0.34 still yields a light grey card that white text
#: then vanishes into. Scaling the channels down is also hue-exact, which is
#: what lets this be the plain arithmetic that lifting a colour cannot be.
_PALETTE_BACKDROP_LUMINANCE = 34.0
_PALETTE_SCENERY_LUMINANCE = 70.0
def rgb_luminance(color: Sequence[float]) -> float:
"""Rec. 709 relative luminance, 0-255."""
return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]
def rgb_saturation(color: Sequence[float]) -> float:
"""HSV saturation, 0-1."""
high = max(color)
return (high - min(color)) / high if high else 0.0
def color_distance(a: Sequence[float], b: Sequence[float]) -> float:
"""Euclidean distance between two colours in RGB."""
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
def mix_color(a: Sequence[float], b: Sequence[float], t: float) -> Color:
"""Blend ``a`` towards ``b``; t=0 is all a, t=1 is all b."""
t = min(max(t, 0.0), 1.0)
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
def scale_color(color: Sequence[float], factor: float) -> Color:
"""Scale a colour's brightness, clamped to the panel's range."""
return tuple(min(255, max(0, int(round(c * factor)))) for c in color)
def lift_color(color: Sequence[float], min_luminance: float = _PALETTE_HEADLINE_LUMINANCE,
cap_saturation: float = 0.92) -> Color:
"""Raise a colour's brightness until it reads on a panel, keeping its hue.
Scaling the channels directly is what the obvious version of this does,
and it shifts hue badly on exactly the colours that need lifting: it turns
Baltimore's navy-purple into magenta. Working in HSV and raising only the
value leaves the hue where the team put it.
"""
if rgb_luminance(color) >= min_luminance:
return tuple(int(c) for c in color)
hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255.0 for c in color])
if saturation < 0.12:
# A grey or a silver has no hue to preserve; just make it bright.
lifted = colorsys.hsv_to_rgb(hue, saturation, max(value, 0.85))
return tuple(int(round(c * 255)) for c in lifted)
saturation = min(saturation, cap_saturation)
def _rgb(s: float, v: float) -> Color:
return tuple(int(round(c * 255)) for c in colorsys.hsv_to_rgb(hue, s, v))
out = _rgb(saturation, value)
while value < 1.0 and rgb_luminance(out) < min_luminance:
value = min(1.0, value + 0.05)
out = _rgb(saturation, value)
# Blue carries almost no luminance -- pure blue sits at 18 of 255 -- so a
# navy or a deep purple runs out of value long before it is legible.
# Bleeding saturation out of it is the only way up, and it keeps the hue
# (Baltimore stays purple, just a lighter one) where giving up would
# leave the headline unreadable. Floored so it never washes out to white.
while saturation > _PALETTE_MIN_SATURATION and rgb_luminance(out) < min_luminance:
saturation = max(_PALETTE_MIN_SATURATION, saturation - 0.05)
out = _rgb(saturation, value)
return out
def cap_luminance(color: Sequence[float], max_luminance: float) -> Color:
"""Darken a colour until it is no brighter than ``max_luminance``.
A straight channel scale, which is exactly hue-preserving on the way down
-- unlike lifting, where clamping at 255 is what bends the hue.
"""
luminance = rgb_luminance(color)
if luminance <= max_luminance or luminance <= 0:
return tuple(int(c) for c in color)
return scale_color(color, max_luminance / luminance)
def dim_rgba(image: Image.Image, factor: float) -> Image.Image:
"""Scale an RGBA image's colour channels, leaving its alpha alone.
ImageEnhance.Brightness would scale the alpha band too, which fades the
crest out instead of dimming it and leaves its anti-aliased edge looking
chewed against the backdrop.
"""
red, green, blue, alpha = image.split()
lut = [min(255, int(i * factor)) for i in range(256)]
return Image.merge(
"RGBA", (red.point(lut), green.point(lut), blue.point(lut), alpha)
)
_Buckets = Dict[Tuple[int, int, int], List[int]]
def _palette_buckets(logo: Image.Image) -> Tuple[_Buckets, _Buckets]:
"""Bucket a crest's opaque pixels into coarse colour bins.
Returns ``(vivid, neutral)``; each maps a 3-bit-per-channel key to
``[r_sum, g_sum, b_sum, count]``. Neutral holds the greys, silvers and
whites that carry no identity on their own but are all a monochrome crest
-- the Raiders' silver on black -- has to offer.
"""
sample = logo.convert("RGBA")
sample.thumbnail((_PALETTE_SAMPLE_PX, _PALETTE_SAMPLE_PX), Image.Resampling.BOX)
vivid: _Buckets = {}
neutral: _Buckets = {}
# tobytes() rather than getdata(): same pixels, no per-pixel Python
# object, and getdata() is deprecated from Pillow 14.
raw = sample.tobytes()
for i in range(0, len(raw) - 3, 4):
red, green, blue, alpha = raw[i], raw[i + 1], raw[i + 2], raw[i + 3]
if alpha < 160:
continue
high, low = max(red, green, blue), min(red, green, blue)
if high < _PALETTE_MIN_CHANNEL:
continue
target = vivid if (high - low) / high >= _PALETTE_VIVID_SATURATION else neutral
acc = target.setdefault((red >> 5, green >> 5, blue >> 5), [0, 0, 0, 0])
acc[0] += red
acc[1] += green
acc[2] += blue
acc[3] += 1
return vivid, neutral
def _bucket_mean(acc: List[int]) -> Color:
count = acc[3]
return (acc[0] // count, acc[1] // count, acc[2] // count)
def _bucket_headline_score(acc: List[int]) -> float:
"""How well a colour bin would serve as 6px of text on a panel.
Area alone picks the biggest block of colour, which on a lot of crests is
a dark navy fill -- correct as a backdrop, invisible as text. Weighting
area by saturation and by luminance picks the colour the team is loud in:
Chicago's orange over its navy, Baltimore's gold over its purple.
"""
color = _bucket_mean(acc)
return (
acc[3]
* (0.30 + 0.70 * rgb_saturation(color))
* (0.20 + 0.80 * min(1.0, rgb_luminance(color) / 120.0))
)
def logo_palette(logo: Image.Image) -> Optional[Palette]:
"""Pick a celebration palette out of a team crest, or None.
Two rankings, because a crest's largest colour and its most legible one
are usually not the same and the takeover needs both:
* ``deep`` -- the largest vivid area, darkened into the background wash.
This is what the team reads as at a glance: Chicago navy, Dallas navy,
Baltimore purple.
* ``headline`` -- the vivid area that best survives being shrunk to text,
then lifted until it is legible: Chicago orange, Baltimore gold.
* ``accent`` -- the next vivid colour far enough away from the headline to
be told apart, for confetti. Falls back to the headline.
A crest with no vivid pixels at all falls back to its brightest neutral,
which for the Raiders' silver-on-black is exactly the right answer.
"""
try:
vivid, neutral = _palette_buckets(logo)
except Exception: # noqa: BLE001 - a crest is never worth the takeover
return None
pool = list(vivid.values())
if not pool and neutral:
pool = [
max(
neutral.values(),
key=lambda acc: acc[3]
* (0.2 + 0.8 * min(1.0, rgb_luminance(_bucket_mean(acc)) / 160.0)),
)
]
if not pool:
return None
deep_base = _bucket_mean(max(pool, key=lambda acc: acc[3]))
ranked = sorted(pool, key=_bucket_headline_score, reverse=True)
headline_base = _bucket_mean(ranked[0])
vivid_pixels = sum(acc[3] for acc in pool)
for acc in ranked:
candidate = _bucket_mean(acc)
if (
rgb_luminance(candidate) >= _PALETTE_LEGIBLE_LUMINANCE
and rgb_saturation(candidate) >= _PALETTE_LEGIBLE_SATURATION
and acc[3] >= max(3, vivid_pixels * _PALETTE_LEGIBLE_AREA)
):
headline_base = candidate
break
headline = lift_color(headline_base)
accent = headline
for acc in ranked[1:]:
candidate = _bucket_mean(acc)
if color_distance(candidate, headline_base) > _PALETTE_DISTINCT_DISTANCE:
accent = lift_color(candidate)
break
deep = cap_luminance(deep_base, _PALETTE_BACKDROP_LUMINANCE)
return {
"deep": deep,
# Scenery is the backdrop carried a little way towards the headline:
# tied to the team's colours, and guaranteed to be visible even when
# the backdrop is nearly black.
"glow": cap_luminance(
mix_color(deep, headline, 0.22), _PALETTE_SCENERY_LUMINANCE
),
"headline": headline,
"accent": accent,
}
class SportsCelebrationMixin:
"""Draws a score/win celebration takeover. See the 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.
display_manager: Any
display_width: int
display_height: int
fonts: Dict[str, Any]
logger: logging.Logger
_load_and_resize_logo: Callable[..., Optional[Image.Image]]
_draw_text_with_outline: Callable[..., None]
def _fit_font(self, draw, text: str, max_width: int, fonts: list):
"""Return the first font whose rendered ``text`` fits ``max_width``,
falling back to the last (smallest) font."""
for font in fonts:
if draw.textlength(text, font=font) <= max_width - 2:
return font
return fonts[-1]
# ------------------------------------------------------------------
# Celebration palette
#
# The takeover is drawn in the scoring team's own colours, taken from the
# pixels of its crest.
#
# ESPN does serve team.color / team.alternateColor, but only inside
# _extract_game_details_common -- a function each scoreboard lineage
# keeps its own copy of -- so reading it there would drag every one of
# them into a celebration change. The crest is already downloaded,
# decoded and sitting in the logo cache by the time a celebration draws,
# so the colours come from it instead: no extra request, no per-league
# colour table to maintain, and it works for any team ESPN can name --
# including the FCS opponents no table would list.
#
# Where a crest's colour differs from the club's published one it
# tends to differ usefully: a published primary is often a near-black
# navy, or an actual #000000, where what the crest carries is the colour
# that reads on an LED panel. Measured across all 32 clubs in
# football-scoreboard.
# ------------------------------------------------------------------
#: Used when the crest yields nothing (no logo on disk yet, or the grey
#: placeholder a failed download leaves) or team colours are switched
#: off -- the navy and amber the celebration wore before it had a palette.
_DEFAULT_CELEBRATION_PALETTE: ClassVar[Palette] = {
"deep": (10, 10, 40),
"glow": (30, 30, 86),
"headline": (255, 208, 56),
"accent": (255, 255, 255),
}
def _celebration_palette(self, celebration: Dict) -> Palette:
"""The scoring team's colours, derived once per celebration."""
cached: Optional[Palette] = celebration.get("_palette")
if cached is not None:
return cached
palette = dict(self._DEFAULT_CELEBRATION_PALETTE)
if getattr(self, "celebration_team_colors", True):
try:
game = celebration["game"]
side = celebration.get("scored_side") or "home"
logo = self._load_and_resize_logo(
game.get("%s_id" % side),
game.get("%s_abbr" % side),
game.get("%s_logo_path" % side),
game.get("%s_logo_url" % side),
)
derived = logo_palette(logo) if logo is not None else None
if derived:
palette = derived
except Exception as e: # noqa: BLE001 - never lose a takeover to a crest
self.logger.debug(f"Celebration palette fell back to the default: {e}")
celebration["_palette"] = palette
return palette
# ------------------------------------------------------------------
# Celebration choreography
#
# Every frame is a finished card. The beats below shift the emphasis --
# an opening colour hit, confetti, a breathing score -- but none of them
# leaves the panel mid-wipe, because on a switch-mode board the core
# drives this plugin at 1 FPS (display_controller reserves its high-FPS
# loop for plugins that scroll or declare needs_high_fps), so any single
# frame may be the only one a viewer ever sees of it.
# ------------------------------------------------------------------
#: Fraction of the celebration spent on the opening colour hit.
_CELEBRATION_IMPACT: ClassVar[float] = 0.11
#: Fraction of it after which the takeover eases back down.
_CELEBRATION_SETTLE: ClassVar[float] = 0.80
#: Seconds per breath of the scoring side's digits. Deliberately a
#: continuous sine rather than an on/off toggle: the 4 Hz flash this
#: replaced was sampled once a second on a switch-mode board, which
#: aliases into a colour that changes at random. A ramp degrades into a
#: slow glow instead, and still reads as a pulse at 125 FPS.
_CELEBRATION_BREATH_SECONDS: ClassVar[float] = 1.7
def _celebration_backdrop(
self,
celebration: Dict,
width: int,
height: int,
palette: Palette,
) -> Image.Image:
"""The static half of the takeover: a team-colour gradient with the
scenery for this kind of score painted into it.
Built once per celebration per panel size and copied per frame, so the
per-pixel work never lands on the render path.
"""
cached: Optional[Tuple[Tuple[int, int], Image.Image]] = celebration.get("_backdrop")
if cached is not None and cached[0] == (width, height):
return cached[1]
# One column, then stretched: filling the panel pixel by pixel would
# be `width` times the work for the same image.
column = Image.new("RGB", (1, max(height, 1)))
pixels: Any = column.load()
for y in range(height):
k = y / max(height - 1, 1)
pixels[0, y] = mix_color(palette["deep"], (0, 0, 0), 0.18 + 0.82 * k)
backdrop = column.resize((width, height)).convert("RGBA")
try:
self._draw_celebration_motif(
ImageDraw.Draw(backdrop),
celebration.get("motif") or "score",
width,
height,
palette,
)
except Exception as e: # noqa: BLE001 - scenery is never worth a blank panel
self.logger.debug(f"Celebration motif skipped: {e}")
celebration["_backdrop"] = ((width, height), backdrop)
return backdrop
def _draw_celebration_motif(
self,
draw,
motif: str,
width: int,
height: int,
palette: Palette,
) -> None:
"""Paint the scenery for one kind of score, dim enough to stay behind
the headline and the score instead of competing with them."""
glow = palette["glow"]
if motif == "kick":
# The uprights a field goal or an extra point went through,
# spread wide enough to frame the score rather than sit beside it.
half = max(8, min(width // 3, height))
mid = width // 2
crossbar = int(height * 0.60)
draw.line([(mid - half, int(height * 0.08)), (mid - half, crossbar)], fill=glow)
draw.line([(mid + half, int(height * 0.08)), (mid + half, crossbar)], fill=glow)
draw.line([(mid - half, crossbar), (mid + half, crossbar)], fill=glow)
draw.line([(mid, crossbar), (mid, height - 1)], fill=glow)
elif motif == "touchdown":
# The goal line, with its hash marks.
line_y = int(height * 0.36)
draw.line([(0, line_y), (width, line_y)], fill=glow)
for x in range(3, width, 9):
draw.line([(x, line_y - 2), (x, line_y + 2)], fill=glow)
elif motif == "net":
# The goal a puck just went into: frame, posts and mesh, sized to
# frame the score the way the uprights do.
half = max(7, min(width // 4, height))
mid = width // 2
top = int(height * 0.34)
draw.rectangle([(mid - half, top), (mid + half, height - 1)], outline=glow)
step = max(3, (half * 2) // 6)
for x in range(mid - half + step, mid + half, step):
draw.line([(x, top + 1), (x, height - 2)], fill=glow)
for y in range(top + step, height - 1, step):
draw.line([(mid - half + 1, y), (mid + half - 1, y)], fill=glow)
elif motif == "win":
# A sunburst behind the winner.
cx, cy = width // 2, height // 2
reach = max(width, height)
for i in range(10):
angle = (math.pi * 2 * i / 10) + math.pi / 20
draw.line(
[
(cx, cy),
(cx + math.cos(angle) * reach, cy + math.sin(angle) * reach),
],
fill=glow,
)
else:
for x in range(-height, width + height, 11):
draw.line([(x, height), (x + height, 0)], fill=glow)
def _celebration_confetti(
self,
celebration: Dict,
width: int,
height: int,
palette: Palette,
) -> List[Flake]:
"""Seed the confetti once per celebration.
Seeded from the game rather than the clock, so the same score always
produces the same fall -- which is what lets a golden screen lock the
effect down instead of having to tolerate it.
"""
cached: Optional[Tuple[Tuple[int, int], List[Flake]]] = celebration.get("_confetti")
if cached is not None and cached[0] == (width, height):
return cached[1]
# Sparse on purpose. At one flake per 170 square pixels a 128x32
# panel carried 24 single-pixel specks over the headline and the
# score, which reads as a dead-pixel problem rather than as confetti.
count = max(6, min(22, (width * height) // 260))
seed = "%s/%s" % (
(celebration.get("game") or {}).get("id", "?"),
celebration.get("phrase", ""),
)
rng = random.Random(seed) # nosec B311 - confetti, not security
# Team colours, plus a pale tint of the headline rather than a flat
# white, so the fall still belongs to the team that scored.
colors = [
palette["headline"],
palette["accent"],
mix_color(palette["headline"], (255, 255, 255), 0.55),
]
flakes = [
(
float(rng.randrange(max(width, 1))), # column
rng.uniform(0.0, float(height)), # start height
rng.uniform(0.40, 1.15), # fall speed
rng.uniform(0.0, math.pi * 2), # sway phase
2 if rng.random() < 0.6 else 1, # size in pixels
colors[rng.randrange(len(colors))],
)
for _ in range(count)
]
celebration["_confetti"] = ((width, height), flakes)
return flakes
def _draw_celebration_confetti(
self,
draw,
celebration: Dict,
width: int,
height: int,
palette: Palette,
elapsed: float,
progress: float,
) -> None:
"""Draw the confetti for this instant, thinning it out as the
celebration eases back towards the scorebug."""
flakes = self._celebration_confetti(celebration, width, height, palette)
fade = 1.0
if progress > self._CELEBRATION_SETTLE:
fade = max(
0.0,
1.0
- (progress - self._CELEBRATION_SETTLE)
/ (1.0 - self._CELEBRATION_SETTLE),
)
if fade <= 0.02:
return
alpha = int(235 * fade)
for column, start, speed, phase, size, color in flakes:
y = (start + speed * elapsed * height * 0.42) % (height + 4) - 2
x = column + math.sin(elapsed * 2.1 + phase) * 2.4
draw.rectangle(
[(int(x), int(y)), (int(x) + size - 1, int(y) + size - 1)],
fill=tuple(color) + (alpha,),
)
def _celebration_crests(
self, celebration: Dict, height: int
) -> Dict[str, Optional[Image.Image]]:
"""The two crests for the takeover, with the side that did not score
dimmed so the scoring team reads at a glance."""
cached: Optional[Tuple[int, Dict[str, Optional[Image.Image]]]] = celebration.get("_crests")
if cached is not None and cached[0] == height:
return cached[1]
game = celebration["game"]
scored = celebration.get("scored_side")
crests: Dict[str, Optional[Image.Image]] = {}
for side in ("away", "home"):
logo = None
try:
logo = self._load_and_resize_logo(
game.get("%s_id" % side),
game.get("%s_abbr" % side),
game.get("%s_logo_path" % side),
game.get("%s_logo_url" % side),
)
except Exception as e: # noqa: BLE001 - a crest is never worth the panel
self.logger.debug(f"Celebration logo load failed: {e}")
if logo is not None and side != scored:
logo = dim_rgba(logo, 0.40)
crests[side] = logo
celebration["_crests"] = (height, crests)
return crests
def _draw_celebration_layout(self, celebration: Dict, force_clear: bool = False) -> None:
"""Render the full-screen goal/win takeover."""
if force_clear:
self.display_manager.clear()
display_width = (
self.display_manager.matrix.width
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_width
)
display_height = (
self.display_manager.matrix.height
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_height
)
elapsed = max(0.0, time.time() - celebration["started_at"])
# getattr throughout the render path: the golden-screen tests build a
# live manager through __new__ and set only what they draw with, and a
# celebration must never be lost to a missing knob.
duration = max(float(getattr(self, "celebration_duration", 8) or 8), 0.5)
progress = min(elapsed / duration, 1.0)
palette = self._celebration_palette(celebration)
main_img = self._celebration_backdrop(
celebration, display_width, display_height, palette
).copy()
# Crests at the edges, bleeding off as the scorebug's do.
crests = self._celebration_crests(celebration, display_height)
center_y = display_height // 2
home_logo, away_logo = crests.get("home"), crests.get("away")
if home_logo is not None:
main_img.paste(
home_logo,
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
home_logo,
)
if away_logo is not None:
main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo)
# The opening hit: the team's headline colour washes the panel and
# decays out of it. Held below opaque so the outlined text drawn on
# top still reads in whichever frame happens to catch it.
impact = max(0.0, 1.0 - progress / self._CELEBRATION_IMPACT)
if impact > 0.0:
# Scaled by how colourful the team is. A saturated crest gets the
# full hit; a silver one -- the Raiders, or the grey placeholder a
# failed logo download leaves -- would otherwise wash the whole
# panel out to the same flat grey as its own headline colour.
punch = 0.45 + 0.55 * rgb_saturation(palette["headline"])
alpha = int(140 * punch * (impact ** 1.5))
if alpha > 0:
main_img = Image.alpha_composite(
main_img,
Image.new(
"RGBA",
(display_width, display_height),
tuple(palette["headline"]) + (alpha,),
),
)
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
if getattr(self, "celebration_confetti", True):
try:
self._draw_celebration_confetti(
draw,
celebration,
display_width,
display_height,
palette,
elapsed,
progress,
)
except Exception as e: # noqa: BLE001
self.logger.debug(f"Celebration confetti skipped: {e}")
# Headline across the top, shrunk to fit the panel width, struck
# white on the opening hit and settling into the team's colour.
phrase = celebration["phrase"]
phrase_font = self._fit_font(
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
)
phrase_width = draw.textlength(phrase, font=phrase_font)
# Eased in by colour rather than by position. Sliding it down into
# place put the first frame at y=-3 with its top row cut off, and on a
# 1 FPS board that clipped frame can be the only one anyone sees.
self._draw_text_with_outline(
draw,
phrase,
((display_width - phrase_width) // 2, 1),
phrase_font,
fill=mix_color(palette["headline"], (255, 255, 255), impact),
)
# Score centred low, the scoring side's digits breathing in the team's
# headline colour so the change reads at a glance.
away_text = str(celebration["away_score"])
home_text = str(celebration["home_score"])
score_font = self.fonts["score"]
segments = [
(away_text, celebration["scored_side"] == "away"),
("-", False),
(home_text, celebration["scored_side"] == "home"),
]
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
breath = 0.72 + 0.28 * (
0.5
+ 0.5 * math.sin(2 * math.pi * elapsed / self._CELEBRATION_BREATH_SECONDS)
)
highlight = scale_color(palette["headline"], breath)
x = (display_width - total_width) // 2
# display_height - 14 was sized for the old fixed 8px score. #338
# scales the score with the panel (16px at 48 and 64 tall), which put
# the bottom of the digits off the panel. Lift it by the measured ink
# (+1 for the outline stroke) only when it would clip, so panels where
# it always fitted render exactly as before.
score_text = "".join(seg for seg, _ in segments)
ink_bottom = draw.textbbox((0, 0), score_text, font=score_font)[3]
y = min(display_height - 14, display_height - ink_bottom - 2)
for seg, is_highlight in segments:
color = highlight if is_highlight else (216, 216, 216)
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
x += draw.textlength(seg, font=score_font)
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
self.display_manager.image = main_img
self.display_manager.update_display()
+188
View File
@@ -0,0 +1,188 @@
"""Which requests a scoreboard makes: season fetches, the lookback, live odds.
Four ``SportsCore`` methods are identical (executable AST, docstrings
stripped) in all nine scoreboards' ``sports.py`` -- afl, baseball,
basketball, football, hockey, lacrosse, nrl, soccer and ufc -- and were
copied here from ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29)
under their existing names:
- ``_background_fetches_espn_ranges`` -- whether the core's background
service can fetch an ESPN date range, or the plugin must;
- ``_fetch_season_directly`` -- fetch and cache a season in chunks ESPN
accepts, on the calling thread;
- ``_needs_previous_day`` (with ``_LOOKBACK_CUTOFF_HOUR``) -- whether the
live fetch still has to ask for yesterday;
- ``_wants_live_odds`` (with ``_LIVE_ODDS_LOOKAHEAD``) -- whether a live
game is close enough to the screen to be worth an odds request.
Three other ``SportsCore`` methods are as identical and stay in the plugins,
for the reasons ``sports_shared`` gives: ``_get_timezone`` binds each
plugin's own ``resolve_timezone`` shim, and ``_extract_game_details`` /
``_fetch_data`` are the abstract sport-specific contract. So does
``SportsUpcoming.__init__``: the mixins in ``src/common`` hold no
constructor, so the plugins' constructor signature stays theirs.
A new module rather than more methods on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_fetch.py`` fails if a read is added without being
listed here.
- ``session``, ``headers``, ``cache_manager`` and ``logger`` --
``_fetch_season_directly``.
- ``_games_lock`` -- ``_wants_live_odds``, which also reads ``live_games``,
``current_game_index`` and ``_rotation_schedule`` with ``getattr``
(only ``SportsLive`` has them).
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
- ``background_service``, read with ``getattr`` --
``_background_fetches_espn_ranges``.
Add it as a base of the plugin's ``SportsCore``, e.g.
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
SportsHelpersMixin, ABC)``. It defines nothing those define; a method or
constant on the plugin's own class still wins over the mixin's.
"""
import logging
import threading
from datetime import datetime, timedelta
from typing import Any, ClassVar, Dict, Optional
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
class SportsFetchMixin:
"""Season fetch, lookback and live-odds decisions. 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.
session: Any
headers: Dict[str, str]
cache_manager: Any
logger: logging.Logger
_games_lock: threading.RLock
#: How many games past the one on screen keep their odds warm. One is
#: enough for the line to be ready when the rotation advances; more just
#: re-creates the whole-slate fetch this replaced.
_LIVE_ODDS_LOOKAHEAD: ClassVar[int] = 1
def _wants_live_odds(self, game: Dict) -> bool:
"""Whether a live game is near enough the front of the rotation to be
worth an odds request.
Odds used to be fetched for *every* live game in the league on every
update. The renderer only ever draws ``current_game``, and a full
rotation of a big slate takes minutes while ``live_odds_update_interval``
is 60s -- so all but one of those requests expired before the game they
belonged to came round.
Measured 2026-09-19 over a full college-football slate: 11,978 odds
requests in 13h on one rig, 54% of all its ESPN traffic, across only
~140 distinct games. The eager loop also cost up to 2s of ``update()``
per live game, because ``_fetch_odds`` waits on its worker thread.
Mirrors the narrowing already applied to the upcoming path and to
``_attach_odds_to_rotated_games``: only games about to be on screen are
asked about. ``get_odds`` still caches per game, so a game re-entering
the window inside its TTL costs a cache lookup, not a request.
The rotation state read here is the previous cycle's -- the new list is
still being built -- which is exactly the question being asked: is this
game at or near the position currently on the panel?
"""
# Read defensively: this predicate lives on SportsCore so it sits
# beside _fetch_odds, but live_games/_rotation_schedule belong to
# SportsLive, which is the only caller.
with self._games_lock:
games = list(getattr(self, "live_games", ()) or ())
index = getattr(self, "current_game_index", 0)
schedule = list(getattr(self, "_rotation_schedule", ()) or ())
if not games:
# Cold start: nothing is on screen yet, so let the games seen on
# this first pass through rather than render a blank line for a
# whole cycle. Bounded -- the next pass has a rotation to narrow by.
return True
order = schedule or [g.get("id") for g in games]
if not order:
return True
start = index if 0 <= index < len(order) else 0
wanted = {
order[(start + offset) % len(order)]
for offset in range(self._LIVE_ODDS_LOOKAHEAD + 1)
}
return game.get("id") in wanted
#: Hour of the Eastern day past which last night's games are assumed over.
#:
#: The live fetch asks ESPN for a two-day window so a game that started
#: yesterday and is still running is not lost. ESPN rejects date *ranges*,
#: so that window is split into one request per day -- doubling every live
#: poll. Measured 2026-09-19: 1,858 requests per rig spent on yesterday's
#: date, which after breakfast holds nothing but final games.
#:
#: No sport on these boards runs six hours past midnight, and one that
#: somehow did is still covered: a game already being tracked keeps its own
#: day in the window regardless of the hour.
_LOOKBACK_CUTOFF_HOUR: ClassVar[int] = 6
def _needs_previous_day(self, now: datetime) -> bool:
"""Whether the previous Eastern day can still hold a live game."""
if now.hour < self._LOOKBACK_CUTOFF_HOUR:
return True
previous = (now - timedelta(days=1)).strftime("%Y%m%d")
for game in (getattr(self, "live_games", None) or []):
start: Any = game.get("start_time_utc") if hasattr(game, "get") else None
try:
if start.astimezone(now.tzinfo).strftime("%Y%m%d") == previous:
return True
except (AttributeError, ValueError, OSError, OverflowError):
continue
return False
def _background_fetches_espn_ranges(self) -> bool:
"""Can the core's background service fetch an ESPN date range?
Cores from before the 2026-09-15 fix send a season range to ESPN as-is,
which now answers 400 for every sport. On those cores the managers fetch
the season themselves with _fetch_season_directly instead.
"""
service = getattr(self, "background_service", None)
return bool(getattr(service, "handles_espn_date_ranges", False))
def _fetch_season_directly(
self,
url: str,
datestring: str,
cache_key: str,
label: str,
ttl: Optional[int] = None,
) -> Optional[Dict]:
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
``label`` names the schedule in log lines, e.g. ``"2026 season"``.
"""
try:
data = fetch_espn_scoreboard(
self.session,
url,
params={"dates": datestring, "limit": ESPN_MAX_LIMIT},
headers=self.headers,
timeout=30,
logger=self.logger,
)
except Exception as e:
self.logger.error(f"Failed to fetch {label} schedule: {e}")
return None
if ttl is None:
self.cache_manager.set(cache_key, data)
else:
self.cache_manager.set(cache_key, data, ttl=ttl)
self.logger.info(
f"Fetched {label} schedule: {len(data.get('events', []))} events"
)
return data
+262
View File
@@ -0,0 +1,262 @@
"""Timezone resolution for the scoreboard plugins.
Game start times arrive from ESPN in UTC and have to be converted to the
user's local zone before they are drawn. This module owns the "which zone?"
decision so every part of a scoreboard (its scorebug, its scroll-mode game
card and its plugin manager) agrees.
The scoreboards each carried a copy of this module as ``<sport>_timezone.py``.
The copies were identical apart from two per-plugin values, which are
keyword-only arguments here: ``plugin_label``, the name the final warning
tells the user to open, and ``writeback_fixed_in`` (see below).
Resolution order, first valid wins:
1. ``timezone`` in the plugin's own config (explicit per-plugin override)
2. The LEDMatrix global timezone via ``plugin_manager.config_manager``
3. The LEDMatrix global timezone via ``cache_manager.config_manager``
4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``)
5. UTC
Steps 2 and 3 matter because the core does not consistently hang
``config_manager`` off both objects -- reading only one of them is what made
a scoreboard fall through to UTC while the clock plugin (which checks
``plugin_manager`` first) showed the right time on the same device. Step 4 is
the backstop for cores that expose no ``config_manager`` at all: a Pi with its
system clock set correctly should never end up rendering UTC.
Two traps this module exists to avoid, both of which render every start time
in UTC on a correctly-configured device:
* **The stale ``"UTC"`` artifact.** Some plugins once wrote
``"timezone": "UTC"`` into the *saved* config whenever resolution failed, and
that write-back stuck -- thereafter shadowing the real global timezone. Such
a plugin passes ``writeback_fixed_in`` (the release that fixed it), and step 1
then treats a bare ``"UTC"`` as suspect: it is honored only when nothing
downstream disagrees. ``Etc/UTC`` is the unambiguous spelling for "I really
do want UTC"; the bug never produced it, so it is always honored. A plugin
that never had the bug leaves ``writeback_fixed_in`` as ``None``, and its
plugin-level ``"UTC"`` is honored verbatim.
* **``get_timezone()``'s own default.** The core's
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')``, so
it hands back ``"UTC"`` for a config that simply has no ``timezone`` key.
Steps 2 and 3 read the raw config dict instead, so an absent key falls
through to the system zone rather than latching onto that default.
"""
import logging
import os
from typing import Any, Dict, Iterable, Optional, Tuple
import pytz
logger = logging.getLogger(__name__)
def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]:
"""Pull the global timezone out of a core ConfigManager, if it has one.
Reads the raw config dict in preference to ``get_timezone()``. The core's
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')`` --
it substitutes its own ``"UTC"`` when the key is absent, which is
indistinguishable from the user deliberately choosing UTC. Taking that at
face value would mask a missing global setting and stop resolution ever
reaching the host system zone. So: if the raw config is readable and has no
``timezone`` key, report "nothing here" and let the caller fall through.
``get_timezone()`` is only consulted for cores that expose no raw config.
"""
if config_manager is None:
return None
raw_readable = False
for loader_name in ("get_config", "load_config"):
loader = getattr(config_manager, loader_name, None)
if not callable(loader):
continue
try:
main_config = loader()
except Exception:
log.debug("config_manager.%s() failed", loader_name, exc_info=True)
continue
if isinstance(main_config, dict):
raw_readable = True
name: Optional[str] = main_config.get("timezone")
if name:
return name
if raw_readable:
return None
getter = getattr(config_manager, "get_timezone", None)
if callable(getter):
try:
name = getter()
if name:
return name
except Exception:
log.debug("config_manager.get_timezone() failed", exc_info=True)
return None
def system_timezone_name() -> Optional[str]:
"""Best-effort IANA name for the host's configured timezone."""
name = os.environ.get("TZ")
if name:
return name
# Debian / Raspberry Pi OS record the zone name here.
try:
with open("/etc/timezone", "r", encoding="utf-8") as handle:
name = handle.read().strip()
if name:
return name
except OSError:
pass
# Otherwise /etc/localtime is a symlink into the zoneinfo tree.
try:
path = os.path.realpath("/etc/localtime")
marker = "zoneinfo" + os.sep
if marker in path:
return path.split(marker, 1)[1]
except OSError:
pass
return None
def _validated(name: object, source: str, log: logging.Logger) -> Optional[str]:
"""Return a usable IANA name, or None if blank/absent/not a real zone."""
if not isinstance(name, str):
return None
name = name.strip()
if not name:
return None
try:
pytz.timezone(name)
except pytz.UnknownTimeZoneError:
log.warning("Ignoring invalid timezone %r from %s", name, source)
return None
except Exception:
# Not an unknown-zone error, so something else went wrong inside pytz.
# Log it loudly rather than silently reclassifying it as "invalid" --
# but still don't propagate: this runs in the render path, and a
# mislabelled zone beats taking the whole display down.
log.warning(
"Unexpected error validating timezone %r from %s; ignoring it",
name, source, exc_info=True,
)
return None
return name
def resolve_timezone_name(
config: Optional[Dict[str, Any]] = None,
plugin_manager: Any = None,
cache_manager: Any = None,
log: Optional[logging.Logger] = None,
*,
plugin_label: str,
writeback_fixed_in: Optional[str] = None,
) -> str:
"""Return the IANA timezone name to render game times in.
Never raises and never returns an empty string; falls back to ``"UTC"``
only when every source is missing or invalid.
``plugin_label`` names the plugin in the warning logged when nothing
resolves (e.g. ``"hockey scoreboard"``). ``writeback_fixed_in`` is the
plugin release that stopped writing ``"UTC"`` back into the saved config,
for a plugin that ever did; ``None`` (the default) otherwise.
"""
log = log or logger
def downstream():
"""Yield (source, name) for everything except the plugin's own config.
Lazy: ``SportsCore._get_timezone()`` runs this once per game and the
answer is almost always already in the plugin config, so evaluating on
demand keeps the common case from calling into both config managers and
stat-ing the host timezone files every time.
"""
yield (
"plugin_manager.config_manager",
_from_config_manager(getattr(plugin_manager, "config_manager", None), log),
)
yield (
"cache_manager.config_manager",
_from_config_manager(getattr(cache_manager, "config_manager", None), log),
)
yield "system timezone", system_timezone_name()
def first_valid(
sources: Iterable[Tuple[str, Any]],
) -> Tuple[Optional[str], Optional[str]]:
for source, name in sources:
name = _validated(name, source, log)
if name:
return source, name
return None, None
plugin_value = _validated((config or {}).get("timezone"), "plugin config", log)
if writeback_fixed_in is not None and plugin_value and plugin_value.lower() == "utc":
# Before writeback_fixed_in this plugin wrote "timezone": "UTC" into
# the saved config whenever it failed to resolve a global timezone, and
# that write-back persisted. A bare "UTC" is therefore far more likely
# to be that artifact than a deliberate choice -- it only ever appeared
# on failure. Honor it only when nothing downstream disagrees; a user
# who genuinely wants UTC writes the unambiguous "Etc/UTC", which the
# bug never produced and which falls through to the normal path below.
source, downstream_name = first_valid(downstream())
if downstream_name and downstream_name.lower() not in ("utc", "etc/utc"):
log.warning(
"Ignoring the plugin-level timezone 'UTC': it is almost "
"certainly left over from the write-back bug fixed in %s, and "
"%s says %s. Using %s. If you really do want UTC here, set "
"this plugin's timezone to 'Etc/UTC' instead.",
writeback_fixed_in, source, downstream_name, downstream_name,
)
return downstream_name
log.debug("Plugin-level timezone 'UTC' agrees with %s; using UTC", source or "no other source")
return "UTC"
if plugin_value:
log.debug("Resolved timezone %s from plugin config", plugin_value)
return plugin_value
source, name = first_valid(downstream())
if name:
log.debug("Resolved timezone %s from %s", name, source)
return name
log.warning(
"Could not determine a timezone from the plugin config, the LEDMatrix "
"config or the system; game times will be shown in UTC. Set a timezone "
"in the %s's Advanced Settings to override.",
plugin_label,
)
return "UTC"
def resolve_timezone(
config: Optional[Dict[str, Any]] = None,
plugin_manager: Any = None,
cache_manager: Any = None,
log: Optional[logging.Logger] = None,
*,
plugin_label: str,
writeback_fixed_in: Optional[str] = None,
):
"""``resolve_timezone_name`` as a ready-to-use tzinfo object."""
return pytz.timezone(
resolve_timezone_name(
config=config,
plugin_manager=plugin_manager,
cache_manager=cache_manager,
log=log,
plugin_label=plugin_label,
writeback_fixed_in=writeback_fixed_in,
)
)
+76 -14
View File
@@ -32,6 +32,7 @@ from typing import Callable, Optional
import numpy as np
from PIL import Image
from src.config_manager_atomic import _replace
from src.display_geometry import DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_ROWS
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
@@ -53,6 +54,17 @@ HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
# Serialises writes to STATUS_FILE (several threads report status) against
# its removal in stop(), so a write already under way cannot put the file back
# after the display process has shut down.
_STATUS_LOCK = threading.Lock()
def _remove_status_file() -> None:
try:
os.remove(STATUS_FILE)
except FileNotFoundError:
pass
class SyncRole(Enum):
@@ -83,6 +95,10 @@ class DisplaySyncManager:
back to its own plugins when the leader stops sending.
"""
# Set by stop(); status writes after that are dropped. Class-level so
# instances built without __init__ (tests) have it too.
_status_closed = False
def __init__(
self,
role_str: str,
@@ -112,6 +128,9 @@ class DisplaySyncManager:
self._peer_ip: Optional[str] = None
self._peer_compatible: bool = False
self._peer_chain: int = 0
# time.monotonic() readings, like _last_leader_frame_time: these only
# feed the timeout watchdogs, and a wall-clock step (NTP correcting a
# Pi with no RTC) would otherwise fake or mask a timeout.
self._last_heartbeat_time: float = 0.0
self._leader_width: int = 0 # set by display_controller after init
self._oversized_frame_warned: bool = False
@@ -138,6 +157,14 @@ class DisplaySyncManager:
self._send_sock: Optional[socket.socket] = None
if self.role == SyncRole.STANDALONE:
# Standalone never writes a status file, so one still here is
# from an earlier run as leader or follower. The web UI would
# keep reporting that run's peer as if it were live.
try:
with _STATUS_LOCK:
_remove_status_file()
except OSError as exc:
logger.debug("Sync: could not remove stale status file: %s", exc)
return
if self.role == SyncRole.LEADER:
@@ -183,7 +210,7 @@ class DisplaySyncManager:
self._handle_hello(msg, sender_ip)
elif t == "hb":
if self._peer_ip == sender_ip:
self._last_heartbeat_time = time.time()
self._last_heartbeat_time = time.monotonic()
except socket.timeout:
continue
except Exception as exc:
@@ -206,7 +233,7 @@ class DisplaySyncManager:
self._peer_ip = sender_ip
self._peer_compatible = compatible
self._peer_chain = peer_chain
self._last_heartbeat_time = time.time()
self._last_heartbeat_time = time.monotonic()
prev_state = self._leader_state
if compatible:
@@ -250,7 +277,7 @@ class DisplaySyncManager:
while self._running:
time.sleep(1.0)
if self._leader_state == LeaderState.CONNECTED:
if time.time() - self._last_heartbeat_time > PEER_TIMEOUT:
if time.monotonic() - self._last_heartbeat_time > PEER_TIMEOUT:
self.logger.info(
"Sync: follower heartbeat timeout — peer disconnected"
)
@@ -478,7 +505,7 @@ class DisplaySyncManager:
"""Note that the leader at ``sender_ip`` just sent something, and
switch from standalone to follower mode if not already following.
Returns True if this call made the switch."""
self._last_leader_frame_time = time.time()
self._last_leader_frame_time = time.monotonic()
self._leader_ip = sender_ip
if self._follower_state != FollowerState.STANDALONE:
return False
@@ -598,11 +625,13 @@ class DisplaySyncManager:
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
dest = ("<broadcast>", self.port)
last_hello = 0.0
last_hb = 0.0
# -inf, not 0.0: monotonic time starts near boot, so "now - 0.0" can
# be under the interval and would delay the first announcement.
last_hello = float("-inf")
last_hb = float("-inf")
while self._running:
now = time.time()
now = time.monotonic()
if now - last_hello >= HELLO_INTERVAL:
try:
self._send_sock.sendto(hello, dest)
@@ -621,7 +650,7 @@ class DisplaySyncManager:
while self._running:
time.sleep(1.0)
if self._follower_state == FollowerState.FOLLOWER:
if time.time() - self._last_leader_frame_time > LEADER_TIMEOUT:
if time.monotonic() - self._last_leader_frame_time > LEADER_TIMEOUT:
self.logger.info(
"Sync: leader frame timeout — returning to standalone mode"
)
@@ -647,7 +676,10 @@ class DisplaySyncManager:
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
"""Follower: register a callback fired when the leader starts a new scroll cycle.
Used to trigger a local start_new_cycle() so both Pis rebuild from same fresh data.
Nothing in core registers one: display_controller follows the leader
through set_on_scroll_image() and the scroll position instead of
rebuilding locally. The hook stays for callers that want the signal.
"""
self._on_new_cycle = callback
@@ -692,18 +724,40 @@ class DisplaySyncManager:
def write_status_file(self) -> None:
"""Write current sync status to STATUS_FILE for the web UI to read."""
tmp = None
try:
status = self.get_status()
status["ts"] = time.time()
tmp = STATUS_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(status, f)
os.replace(tmp, STATUS_FILE)
with _STATUS_LOCK:
if self._status_closed:
return
# A unique temp name per write, like frame_timing's stats
# file: the receive loop, watchdog and hello handler all
# write, and with one fixed ".tmp" name one thread's
# os.replace() could move the other's half-written file.
fd, tmp = tempfile.mkstemp(
dir=os.path.dirname(STATUS_FILE) or ".",
prefix=".led_matrix_sync_status.", suffix=".tmp")
with os.fdopen(fd, "w") as f:
json.dump(status, f)
# mkstemp makes it owner-only; the web UI may run as a
# different user from the display service.
os.chmod(tmp, 0o644)
# _replace: on Windows a rename can briefly fail with
# "Access is denied" while a scanner holds the target open.
_replace(tmp, STATUS_FILE)
tmp = None
except Exception as exc:
self.logger.debug("Sync: status file write error: %s", exc)
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def stop(self) -> None:
"""Shut down threads and close sockets."""
"""Shut down threads, close sockets and withdraw the status file."""
self._running = False
for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
if sock:
@@ -711,3 +765,11 @@ class DisplaySyncManager:
sock.close()
except Exception as exc:
self.logger.debug("Sync: error closing socket: %s", exc)
# The web UI reads this file as live status. Left behind, it went on
# reporting a connected peer after the display service had stopped.
try:
with _STATUS_LOCK:
self._status_closed = True
_remove_status_file()
except OSError as exc:
self.logger.debug("Sync: could not remove status file: %s", exc)
+2 -1
View File
@@ -206,7 +206,8 @@ class ConfigService:
# Sleep with periodic checks for stop signal
for _ in range(int(self._watch_interval)):
if self._stop_watching:
break
# Set from another thread; mypy keeps the while's narrowing.
break # type: ignore[unreachable]
time.sleep(1)
except Exception as e:
+1 -1
View File
@@ -41,7 +41,7 @@ def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F],
warnings.warn(message, DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
wrapper.__deprecated__ = message
wrapper.__deprecated__ = message # type: ignore[attr-defined] # functools' _Wrapped doesn't declare it
return wrapper # type: ignore[return-value]
return decorate
+4 -3
View File
@@ -24,7 +24,7 @@ next render. A location saved on the app itself always wins.
import json
import logging
import time
from typing import Any, Callable, Dict, Iterable, List, Optional
from typing import Any, Callable, Dict, Iterable, List, Optional, cast
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
GEOCODE_TIMEOUT = 10
@@ -112,6 +112,7 @@ def parse_location(value: Any) -> Optional[Dict[str, Any]]:
``{"timezone": ...}`` when only the timezone box is filled) all mean the
user has not given the app a place.
"""
loc: Any
if isinstance(value, dict):
loc = value
elif isinstance(value, str) and value.strip():
@@ -162,10 +163,10 @@ def geocode(city: str, state: Any = None, country: Any = None,
"""Look the city up on Open-Meteo. Raises on a network/HTTP failure."""
import requests
response = requests.get(GEOCODE_URL, params={
response = requests.get(GEOCODE_URL, params=cast(Dict[str, Any], {
"name": city, "count": GEOCODE_RESULT_COUNT,
"language": "en", "format": "json",
}, timeout=timeout)
}), timeout=timeout)
response.raise_for_status()
best = pick_geocode_result(response.json().get("results") or [], state, country)
if best is None:
+330 -44
View File
@@ -27,8 +27,9 @@ import signal
import json
import threading
import types
from collections import deque
from contextlib import contextmanager
from typing import Dict, Any, List, Optional, Callable
from typing import Dict, Any, List, Optional, Callable, Set, Tuple
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
import pytz
@@ -101,6 +102,16 @@ class DisplayController:
it and start the run loop.
"""
#: How long the run loop pauses per pass once a whole rotation has had
#: nothing to show. See _note_empty_pass.
EMPTY_ROTATION_PAUSE = 1.0
#: Consecutive passes whose mode had nothing to show, and the rotation
#: (on-demand or not, and its modes) they were counted in. Class-level so
#: controllers built without __init__ (tests) have them too.
_empty_pass_streak = 0
_empty_pass_rotation: Optional[Tuple[bool, Tuple[str, ...]]] = None
def __init__(self):
start_time = time.time()
logger.info("Starting DisplayController initialization")
@@ -186,6 +197,12 @@ class DisplayController:
# scroll image arrives.
self._follower_pending_new_image = False
self._follower_last_frame = None
# (image, array) from the leader, handed from the sync TCP thread to
# the render thread, which adopts it at the start of a follower frame
# (_adopt_follower_scroll_image). One append / one popleft, each
# atomic, so the render thread never draws from a half-swapped
# cached_image / cached_array / total_scroll_width.
self._follower_incoming_image: deque = deque(maxlen=1)
self._follower_deadline: Optional[float] = None
# Leader: time.time() of the last follower frame sent.
self._last_follower_send = 0.0
@@ -249,6 +266,10 @@ class DisplayController:
self.on_demand_last_error: Optional[str] = None
self.on_demand_last_event: Optional[str] = None
self.on_demand_schedule_override = False
# Plugins that are disabled in config and loaded only because an
# on-demand request named them. The main loop unloads each one once
# on-demand has moved off it (_release_on_demand_plugins).
self._on_demand_loaded_plugins: Set[str] = set()
self.rotation_resume_index: Optional[int] = None
# Saved rotation position when a live-priority plugin preempts the
# rotation, so it resumes where it left off (not after the live plugin)
@@ -284,7 +305,10 @@ class DisplayController:
if os.path.isabs(plugins_dir_name):
plugins_dir = plugins_dir_name
else:
# If relative, resolve relative to the project root (LEDMatrix directory)
# If relative, resolve against the current working directory.
# That is the project root only because ledmatrix.service
# sets WorkingDirectory to it; run from anywhere else, a
# relative path resolves against wherever that is.
project_root = os.getcwd()
plugins_dir = os.path.join(project_root, plugins_dir_name)
@@ -315,13 +339,19 @@ class DisplayController:
except Exception as e:
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
# Discover plugins. Before the plugin checks so they can reuse the
# list: each discover_plugins() call rescans the plugins directory
# and logs every plugin again.
discovered_plugins = self.plugin_manager.discover_plugins()
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
# Only the plugin checks: validate_all() above has run the rest,
# and running it again logged every config warning twice.
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager, self.plugin_manager,
cache_manager=self.cache_manager)
validator._validate_plugins()
validator._validate_plugins(discovered_plugins=discovered_plugins)
for warning in validator.warnings:
logger.warning("Plugin validation warning: %s", warning)
if validator.errors:
@@ -330,10 +360,6 @@ class DisplayController:
except Exception as e:
logger.warning("Plugin validation could not be completed: %s", e)
# Discover plugins
discovered_plugins = self.plugin_manager.discover_plugins()
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
# Check for on-demand plugin filter from cache
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
@@ -347,7 +373,11 @@ class DisplayController:
"""Load a single plugin and return result."""
plugin_load_start = time.time()
try:
if self.plugin_manager.load_plugin(plugin_id):
if plugin_id in self._on_demand_loaded_plugins:
loaded = self.plugin_manager.load_plugin(plugin_id, force_enabled=True)
else:
loaded = self.plugin_manager.load_plugin(plugin_id)
if loaded:
plugin_load_time = time.time() - plugin_load_start
return {
'success': True,
@@ -544,21 +574,15 @@ class DisplayController:
# temporarily replace the leader's correct one.
# When the leader sends its scroll image (TCP), update our
# cached_array so both Pis have pixel-identical images.
# cached_array so both Pis have pixel-identical images. This runs
# on the sync TCP thread, so it only converts and queues the
# image; the render thread swaps it in between frames.
import numpy as _np
def _on_leader_scroll_image(image):
vc = self.vegas_coordinator
if vc and vc.render_pipeline:
rp = vc.render_pipeline
arr = _np.asarray(image.convert("RGB"), dtype=_np.uint8)
rp.scroll_helper.cached_image = image
rp.scroll_helper.cached_array = arr
rp.scroll_helper.total_scroll_width = image.width
self._follower_pending_new_image = False
logger.info(
"Sync: follower adopted leader scroll image %dx%d",
image.width, image.height,
)
self._follower_incoming_image.append((image, arr))
self.sync_manager.set_on_scroll_image(_on_leader_scroll_image)
if self.sync_manager.role == SyncRole.LEADER:
@@ -581,6 +605,29 @@ class DisplayController:
logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True)
self.vegas_coordinator = None
def _adopt_follower_scroll_image(self, rp) -> None:
"""Swap in the leader's latest scroll image, on the render thread.
The sync TCP thread used to set cached_image, cached_array and
total_scroll_width one after another while this thread read them, so
a frame could slice the new array with the old width. It now queues
the image and this applies it between frames.
"""
try:
image, arr = self._follower_incoming_image.popleft()
except IndexError:
return
if rp is None:
return
rp.scroll_helper.cached_image = image
rp.scroll_helper.cached_array = arr
rp.scroll_helper.total_scroll_width = image.width
self._follower_pending_new_image = False
logger.info(
"Sync: follower adopted leader scroll image %dx%d",
image.width, image.height,
)
def _is_vegas_mode_active(self) -> bool:
"""Check if Vegas mode should be running."""
self._apply_pending_vegas_init()
@@ -801,7 +848,15 @@ class DisplayController:
if use_per_day:
day_config = days_config[current_day]
if not day_config.get('enabled', True):
# Past the minute gate, so the cache must say the same thing:
# returning here without it left the previous minute's dim
# value to be served for the rest of this one, and the
# brightness flipped between dim and normal every minute.
if self._was_dimmed:
logger.info(f"Dim schedule deactivated: brightness restored to {normal_brightness}%")
self.is_dimmed = False
self._was_dimmed = False
self._cached_target_brightness = normal_brightness # persist for minute-gate
return normal_brightness
start_time_str = day_config.get('start_time', '20:00')
end_time_str = day_config.get('end_time', '07:00')
@@ -1075,6 +1130,38 @@ class DisplayController:
or self.on_demand_active != on_demand):
break
def _note_empty_pass(self) -> None:
"""Record a pass whose mode had nothing to show; pause once a whole
rotation has been empty.
A mode with no content rotates to the next one at once, with no dwell.
When every mode is empty -- say, only a sports plugin enabled in its
off-season -- the loop went round with no sleep at all: 100% of a core,
a plugin-executor thread per pass and several log lines each time,
indefinitely. After one full rotation of empty passes, each further
one pauses EMPTY_ROTATION_PAUSE seconds. Live content is still picked
up within that second (the live-priority check runs at the top of
every pass), the pause services plugin updates, and it returns early
on an on-demand request or a schedule change. The streak resets as
soon as any mode shows something, and when the rotation itself
changes (on-demand starting or stopping, a plugin enabled or
disabled): a streak counted in one rotation says nothing about the
modes of another, which haven't been tried yet.
"""
modes = self.on_demand_modes if self.on_demand_active else self.available_modes
rotation_key = (bool(self.on_demand_active), tuple(modes))
if rotation_key != self._empty_pass_rotation:
self._empty_pass_rotation = rotation_key
self._empty_pass_streak = 0
self._empty_pass_streak += 1
rotation = max(1, len(modes))
if self._empty_pass_streak < rotation:
return
if self._empty_pass_streak == rotation:
logger.info("No mode has anything to show; checking one mode every %.0fs "
"until one does", self.EMPTY_ROTATION_PAUSE)
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
def _get_display_duration(self, mode_key):
"""Seconds to show a mode: the Rotation & Durations page's value for it
(display.display_durations), else the plugin's own duration.
@@ -1396,8 +1483,13 @@ class DisplayController:
On-demand still resumes on its saved mode; this only widens what gets
loaded, so normal rotation has somewhere to return to when it ends.
A plugin that is disabled in config but named by the on-demand request
is still enabled and added, since otherwise the mode being resumed
would have nothing behind it.
is still loaded, since otherwise the mode being resumed would have
nothing behind it. It is tracked as loaded for on-demand only, the
same as one loaded live by _activate_on_demand, so it is unloaded
when the session ends instead of staying loaded until the next
restart. Its config section is not touched: setting ``enabled`` in
self.config wrote into the dict config_manager caches and returns to
every later load_config() in this process.
"""
enabled_plugins = [p for p in discovered_plugins
if self.config.get(p, {}).get('enabled', False)]
@@ -1412,11 +1504,10 @@ class DisplayController:
logger.warning("Falling back to normal mode (all enabled plugins)")
return enabled_plugins
if not self.config.get(on_demand_plugin_id, {}).get('enabled', False):
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
self.config.setdefault(on_demand_plugin_id, {})['enabled'] = True
if on_demand_plugin_id not in enabled_plugins:
enabled_plugins.append(on_demand_plugin_id)
if on_demand_plugin_id not in enabled_plugins:
logger.info("Loading disabled plugin '%s' for on-demand mode only", on_demand_plugin_id)
self._on_demand_loaded_plugins.add(on_demand_plugin_id)
enabled_plugins.append(on_demand_plugin_id)
# Restore on-demand state from the cached request so it resumes.
self.on_demand_active = True
@@ -1512,6 +1603,11 @@ class DisplayController:
logger.debug("Stop request %s received but on-demand is not active", request_id)
# Still update request_id to acknowledge the request
self.on_demand_request_id = request_id
if self.on_demand_status == 'error':
# A failed request left status 'error' published, and
# without this the status route kept reporting it until
# the state aged out (120s) or another request came in.
self._clear_on_demand(reason='requested-stop')
# Stop requests are deliberately exempt from the request_id/
# processed_id guards above, so that a second click stops a mode
# that a race left running. Consuming the mailbox is therefore the
@@ -1612,7 +1708,10 @@ class DisplayController:
if plugin_instance.has_live_content():
live_with_content.append(live_mode)
except Exception:
pass
# Treated as no live content; logged so a plugin whose
# check always raises is findable.
logger.debug("has_live_content() failed for %s", live_mode,
exc_info=True)
# Build mode list: live modes with content first, then other modes, then live modes without content
if live_with_content:
@@ -1675,10 +1774,136 @@ class DisplayController:
plugin_id, ordered_modes, self.on_demand_mode_index,
ordered_modes[self.on_demand_mode_index] if ordered_modes else 'N/A')
def _load_plugin_for_on_demand(self, plugin_id: str) -> bool:
"""Load an installed plugin that isn't running so on-demand can show it.
This process only loads the plugins enabled in config, so a request
for a disabled one -- the config page's "Preview on display" button
offers it on every plugin -- failed with "invalid-mode" while the UI
said the plugin would be enabled for the session. Nothing did that
short of a restart, and restarts no longer happen on a request.
Loads through the same path as a live enable (load_plugin, then
_register_loaded_plugin), with force_enabled so the instance runs
enabled while config.json keeps saying disabled. The plugin is
recorded in _on_demand_loaded_plugins, and the main loop unloads it
once on-demand moves off it (_release_on_demand_plugins).
Returns False after publishing an error when the load fails. A
plugin that isn't installed returns True without loading anything:
the mode checks that follow report it as they always have.
"""
if self.plugin_manager is None:
return True
try:
known = self.plugin_manager.discovered_plugin_ids()
except AttributeError:
known = set(getattr(self.plugin_manager, 'plugin_manifests', ()) or ())
if plugin_id not in known:
# Installed after this process scanned: the web process checked
# its own, fresher list before posting the request.
try:
known = set(self.plugin_manager.discover_plugins())
except Exception: # pylint: disable=broad-except
logger.exception("On-demand: plugin discovery failed")
known = set()
if plugin_id not in known:
return True
logger.info("On-demand: loading disabled plugin '%s' for this session only", plugin_id)
self._on_demand_loaded_plugins.add(plugin_id)
try:
loaded = self.plugin_manager.load_plugin(plugin_id, force_enabled=True)
if loaded:
modes = self._register_loaded_plugin(plugin_id)
logger.info("On-demand: loaded plugin '%s' (modes: %s)", plugin_id, modes)
except Exception: # pylint: disable=broad-except
logger.exception("On-demand: error loading plugin '%s'", plugin_id)
loaded = False
if not loaded:
# Stays in _on_demand_loaded_plugins so the main loop removes
# whatever part of it did get registered.
logger.error("On-demand: could not load plugin '%s'", plugin_id)
self._set_on_demand_error("load-failed")
return False
return True
def _release_on_demand_plugins(self) -> None:
"""Unload plugins loaded only for on-demand that it has moved off.
Runs from the main loop, right after its own on-demand poll, not
where on-demand ends: a stop, an expiry or the next request is often
read from inside a render loop or a dwell sleep, where the plugin
being released may still be on the stack mid-display(). Unloading
goes through _unregister_plugin, as a live disable does, and nothing
is written to config.json.
A plugin the user enabled in the meantime stays loaded and takes its
place in the rotation, which is what the reconcile that the enable
queued would have done.
"""
if self.plugin_manager is None: # plugin system failed after startup restore
self._on_demand_loaded_plugins.clear()
return
keep = self.on_demand_plugin_id if self.on_demand_active else None
releasable = [p for p in self._on_demand_loaded_plugins if p != keep]
if not releasable:
return
try:
config = self.config_service.get_config()
except Exception as e: # pylint: disable=broad-except
logger.warning("On-demand release: falling back to cached config: %s", e)
config = self.config
previous_mode = self.current_display_mode
for plugin_id in releasable:
self._on_demand_loaded_plugins.discard(plugin_id)
section = config.get(plugin_id)
if isinstance(section, dict) and section.get('enabled', False):
logger.info("On-demand: keeping plugin '%s' loaded; it was enabled "
"while on-demand showed it", plugin_id)
continue
if (plugin_id in self.plugin_display_modes
or self.plugin_manager.get_plugin(plugin_id) is not None):
logger.info("On-demand: unloading plugin '%s'; it is disabled in config",
plugin_id)
self._unregister_plugin(plugin_id)
if not self.on_demand_active:
# Only outside a session: rotation_resume_index points into
# available_modes until the session ends.
self._apply_plugin_rotation_order()
self._resync_mode_index_after_change(previous_mode)
if self.current_display_mode != previous_mode:
self.force_change = True
def _rotation_index_outside_on_demand(self, start: int) -> Optional[int]:
"""First index from `start` (wrapping) whose mode is not owned by a
plugin loaded only for on-demand, or None if every mode is.
Ending a session must not resume the rotation onto the plugin that
is about to be unloaded. A live load appends that plugin's modes
after the saved resume index, but a session restored after a
restart has no saved index and its plugin was ordered in with the
rest -- the rotation resumed onto it, and a stop read during its own
screen changed nothing on the panel until that screen ended.
"""
if not self._on_demand_loaded_plugins:
return start
on_demand_only = {mode for plugin_id in self._on_demand_loaded_plugins
for mode in self.plugin_display_modes.get(plugin_id, [])}
count = len(self.available_modes)
for step in range(count):
index = (start + step) % count
if self.available_modes[index] not in on_demand_only:
return index
return None
def _activate_on_demand(self, request: Dict[str, Any]) -> None:
"""Activate on-demand mode for a specific plugin display."""
plugin_id = request.get('plugin_id')
mode = request.get('mode')
if (plugin_id and plugin_id not in self.plugin_display_modes
and not self._load_plugin_for_on_demand(plugin_id)):
return
resolved_mode = self._resolve_mode_for_plugin(plugin_id, mode)
if not resolved_mode:
@@ -1710,10 +1935,16 @@ class DisplayController:
pinned = bool(request.get('pinned', False))
now = time.time()
if self.available_modes:
self.rotation_resume_index = self.current_mode_index
else:
self.rotation_resume_index = None
# Only a request that starts a session records where rotation was.
# A request made while on-demand is already showing would otherwise
# save the previous request's mode (current_mode_index points at it
# by now), and clearing would resume there instead of where the
# normal rotation was interrupted.
if not self.on_demand_active:
if self.available_modes:
self.rotation_resume_index = self.current_mode_index
else:
self.rotation_resume_index = None
if resolved_mode in self.available_modes:
self.current_mode_index = self.available_modes.index(resolved_mode)
@@ -1778,6 +2009,15 @@ class DisplayController:
self.on_demand_last_event = 'stop-request-ignored' # Already idle
self._publish_on_demand_state()
return
if not self.on_demand_active and self.on_demand_status == 'error':
# _set_on_demand_error already ended any session and dropped
# rotation_resume_index; the full clear below would only move
# the rotation and force a redraw. Just drop the error.
self.on_demand_status = 'idle'
self.on_demand_last_error = None
self.on_demand_last_event = reason or 'cleared'
self._publish_on_demand_state()
return
self._reset_on_demand_fields()
self.on_demand_status = 'idle'
@@ -1787,17 +2027,27 @@ class DisplayController:
# Clear on-demand configuration from cache
self.cache_manager.clear_cache('display_on_demand_config')
if self.rotation_resume_index is not None and self.available_modes:
self.current_mode_index = self.rotation_resume_index % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
logger.info("Resuming rotation from saved index %d: mode '%s'",
self.rotation_resume_index, self.current_display_mode)
elif self.available_modes:
# Default to first mode if no resume index
self.current_mode_index = self.current_mode_index % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
logger.info("Resuming rotation to mode '%s' (index %d)",
self.current_display_mode, self.current_mode_index)
if self.available_modes:
saved = self.rotation_resume_index
# Default to the current index if no resume index
start = saved if saved is not None else self.current_mode_index
index = self._rotation_index_outside_on_demand(start % len(self.available_modes))
if index is None:
# Every mode belongs to a plugin loaded only for on-demand,
# which the main loop is about to unload; it then idles.
self.current_mode_index = 0
self.current_display_mode = None
logger.info("No enabled mode to resume rotation to")
elif saved is not None:
self.current_mode_index = index
self.current_display_mode = self.available_modes[index]
logger.info("Resuming rotation from saved index %d: mode '%s'",
saved, self.current_display_mode)
else:
self.current_mode_index = index
self.current_display_mode = self.available_modes[index]
logger.info("Resuming rotation to mode '%s' (index %d)",
self.current_display_mode, self.current_mode_index)
else:
logger.warning("No available modes to resume rotation to")
@@ -1843,7 +2093,9 @@ class DisplayController:
if bg_service and hasattr(bg_service, 'log_memory_stats'):
bg_service.log_memory_stats()
except Exception:
pass # Background service may not be initialized
# Background service may not be initialized
logger.debug("Background service memory stats unavailable",
exc_info=True)
# Log deferred updates stats
if hasattr(self.display_manager, '_scrolling_state'):
@@ -1992,6 +2244,14 @@ class DisplayController:
# Handle on-demand commands before rendering
self._poll_on_demand_requests()
self._check_on_demand_expiration()
# Unload plugins loaded only to show them on-demand once it
# has moved off them. Here, where no display() is on the
# stack; one ended from inside a screen is caught here on
# the next pass.
if self._on_demand_loaded_plugins:
self._release_on_demand_plugins()
if not self.available_modes:
continue # it was all there was; idle as above
self._tick_plugin_updates()
# Clean up expired WiFi status messages
@@ -2045,6 +2305,7 @@ class DisplayController:
vc = self.vegas_coordinator
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
width = self.display_manager.width
self._adopt_follower_scroll_image(rp)
local_x = self._follower_local_x
if local_x is None:
@@ -2328,6 +2589,16 @@ class DisplayController:
# If display() returned False, skip to next mode immediately
if not display_result:
was_on_demand = self.on_demand_active
self._note_empty_pass()
# The pause returns early when an on-demand request, its
# end, or the schedule decides what comes next. Rotating
# past this empty mode now would skip that: an on-demand
# start would advance past the mode just requested.
if (self.current_display_mode != active_mode
or self.on_demand_active != was_on_demand
or not self.is_display_active):
continue
if self.on_demand_active:
logger.info("No content for on-demand mode %s, skipping to next mode", active_mode)
if not self.on_demand_modes:
@@ -2376,6 +2647,7 @@ class DisplayController:
# If no exception (just no content), fall through to normal rotation logic
# This allows trying other modes (recent, upcoming) from the same plugin
else:
self._empty_pass_streak = 0
# Get base duration for current mode
base_duration = self._get_display_duration(active_mode)
dynamic_enabled = self._plugin_supports_dynamic(manager_to_display)
@@ -2804,7 +3076,8 @@ class DisplayController:
try:
self.wifi_status_file.unlink()
except Exception:
pass
logger.debug("Could not remove WiFi status file %s",
self.wifi_status_file, exc_info=True)
return None
# Validate required fields
@@ -2837,7 +3110,8 @@ class DisplayController:
try:
self.wifi_status_file.unlink()
except Exception:
pass
logger.debug("Could not remove WiFi status file %s",
self.wifi_status_file, exc_info=True)
return None
# Message is valid and not expired — cache for the throttle window
@@ -2995,6 +3269,11 @@ class DisplayController:
prepared = prepare(_pid, new_config) if callable(prepare) else None
if isinstance(prepared, dict):
new_config = prepared
if _pid in self._on_demand_loaded_plugins:
# Saved while on-demand shows it: config.json still
# says disabled, and on_config_change would switch
# the instance off mid-session.
new_config = {**new_config, 'enabled': True}
_plugin.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", _pid)
except Exception as e:
@@ -3310,6 +3589,13 @@ class DisplayController:
self.vegas_coordinator.cleanup()
except Exception as e:
logger.warning("Error cleaning up Vegas mode: %s", e)
# After Vegas, which sends through it. Stopping also withdraws the
# sync status file, which the web UI otherwise kept showing as live.
if getattr(self, 'sync_manager', None) is not None:
try:
self.sync_manager.stop()
except Exception as e:
logger.warning("Error stopping display sync: %s", e)
# Shutdown config service if it exists
if hasattr(self, 'config_service'):
try:
+1
View File
@@ -132,6 +132,7 @@ def apply_pixel_mappers(width: int, height: int, mapper_config: str,
``multiplexing`` isn't modelled: its mappers give back the configured
size for the panel sizes they are made for.
"""
param: Optional[str]
for entry in (mapper_config or '').split(';'):
name, colon, param = entry.partition(':')
name = name.lower()
+83 -33
View File
@@ -20,7 +20,10 @@ Key responsibilities
Singleton: only one ``DisplayManager`` instance exists per process. The
first call to ``DisplayManager(config)`` creates it; subsequent calls return
the same object.
the same object, but ``__init__`` runs again on it each time, so it is
re-initialised (matrix included) with the new arguments rather than handed
back as it was. Construct it once and pass that instance around;
:meth:`DisplayManager.cleanup` clears the singleton.
"""
import json
@@ -41,18 +44,23 @@ from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
compose_pixel_mapper_config, physical_size, resolve_double_sided,
)
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
from src.matrix_support import (
DEFAULT_REFRESH_LIMIT_HZ, MatrixSettingsRefused, library_refusals, refusal_message,
)
from src.pi5_matrix_support import is_raspberry_pi_5
import threading
import time
from collections import OrderedDict, deque
from typing import Dict, Any, List, Optional, Tuple
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
import math
import zlib
import freetype
from src.common import snapshot_policy
from src.common.frame_timing import FrameTimingRecorder
if TYPE_CHECKING:
from src.common.render_gate import RenderGate
from src.deprecation import deprecated
from src.logging_config import get_logger
from src.common.permission_utils import (
@@ -69,6 +77,10 @@ logger = get_logger(__name__)
#: and therefore get_font_height() -- report anything but 0.
_CALENDAR_FONT_PX = 7
#: Seconds between repeats of update_display()'s error log. It runs every
#: frame, so a fault that persists would otherwise log ~100 lines a second.
_UPDATE_ERROR_LOG_INTERVAL = 60.0
def _bdf_native_size(face) -> int:
"""The pixel height a BDF Face declares, or 0 if it does not say.
@@ -233,6 +245,11 @@ class DisplayManager:
_instance = None
# update_display()'s error-log throttle. Class defaults so instances built
# without __init__ (tests, doubles) have them too.
_update_error_logged_at: Optional[float] = None
_update_errors_suppressed = 0
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(DisplayManager, cls).__new__(cls)
@@ -327,7 +344,7 @@ class DisplayManager:
# A src.common.render_gate.RenderGate while Vegas runs with
# vegas_scroll.prefetch_gate on: opened around each swap so the
# prefetch thread only runs Python while this thread waits on vsync.
self.render_gate = None
self.render_gate: Optional['RenderGate'] = None
# Timing of every presented frame, whoever drew it, for
# scripts/frame_soak.py. See src/common/frame_timing.py.
@@ -342,6 +359,13 @@ class DisplayManager:
'max_deferred_updates': 50, # Limit queue size to prevent memory issues
'deferred_update_ttl': 300.0 # 5 minutes TTL for deferred updates
}
# Guards _scrolling_state['deferred_updates']. defer_update() is called
# from plugin update() on the update worker thread while
# process_deferred_updates() runs on the render thread, and both
# rebuild the list (TTL filter, [n:] slice) and assign it back -- an
# append landing between one side's read and its assignment was lost.
# Never held while a queued callable runs: those may defer again.
self._deferred_lock = threading.Lock()
self._setup_matrix()
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
@@ -973,7 +997,21 @@ class DisplayManager:
# Write a snapshot for the web preview (throttled)
self._write_snapshot_if_due(frame_checksum)
except Exception as e:
logger.error(f"Error updating display: {e}")
# Once with the traceback, then at most every
# _UPDATE_ERROR_LOG_INTERVAL with a count of what was skipped.
now = time.monotonic()
last = self._update_error_logged_at
if last is None:
self._update_error_logged_at = now
logger.error("Error updating display: %s", e, exc_info=True)
elif now - last >= _UPDATE_ERROR_LOG_INTERVAL:
skipped = self._update_errors_suppressed
self._update_error_logged_at = now
self._update_errors_suppressed = 0
logger.error("Error updating display: %s (%d more since the "
"last report)", e, skipped)
else:
self._update_errors_suppressed += 1
def _setup_scan_order_compensation(self) -> None:
"""Work out which rows to show a refresh behind while scrolling.
@@ -1544,7 +1582,7 @@ class DisplayManager:
options.panel_type = hardware_config.get('panel_type', '')
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', DEFAULT_REFRESH_LIMIT_HZ)
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
# Disable internal privilege dropping - we manage this via systemd or remain root
@@ -1590,7 +1628,7 @@ class DisplayManager:
value = float(hardware.get('limit_refresh_rate_hz') or 0)
except (TypeError, ValueError):
value = 0.0
return value if value > 0 else 100.0
return value if value > 0 else float(DEFAULT_REFRESH_LIMIT_HZ)
def _scrolling_now(self) -> bool:
"""Whether a scroll is running, without is_currently_scrolling()'s
@@ -1701,26 +1739,28 @@ class DisplayManager:
"""
current_time = time.time()
# Clean up expired updates before adding new ones
self._cleanup_expired_deferred_updates(current_time)
with self._deferred_lock:
# Clean up expired updates before adding new ones
self._cleanup_expired_deferred_updates(current_time)
# Limit queue size to prevent memory issues
if len(self._scrolling_state['deferred_updates']) >= self._scrolling_state['max_deferred_updates']:
# Remove oldest update to make room
self._scrolling_state['deferred_updates'].pop(0)
logger.debug("Removed oldest deferred update due to queue size limit")
# Limit queue size to prevent memory issues
if len(self._scrolling_state['deferred_updates']) >= self._scrolling_state['max_deferred_updates']:
# Remove oldest update to make room
self._scrolling_state['deferred_updates'].pop(0)
logger.debug("Removed oldest deferred update due to queue size limit")
self._scrolling_state['deferred_updates'].append({
'func': update_func,
'priority': priority,
'timestamp': current_time
})
self._scrolling_state['deferred_updates'].append({
'func': update_func,
'priority': priority,
'timestamp': current_time
})
# Only sort if we have a reasonable number of updates to avoid excessive sorting
if len(self._scrolling_state['deferred_updates']) <= 20:
self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority'])
# Only sort if we have a reasonable number of updates to avoid excessive sorting
if len(self._scrolling_state['deferred_updates']) <= 20:
self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority'])
logger.debug(f"Deferred update added. Total deferred: {len(self._scrolling_state['deferred_updates'])}")
queued = len(self._scrolling_state['deferred_updates'])
logger.debug(f"Deferred update added. Total deferred: {queued}")
def process_deferred_updates(self):
"""Process any deferred updates if not currently scrolling."""
@@ -1728,21 +1768,26 @@ class DisplayManager:
# Always clean up expired updates, even if scrolling
# This prevents memory leaks from accumulated expired updates
self._cleanup_expired_deferred_updates(current_time)
with self._deferred_lock:
self._cleanup_expired_deferred_updates(current_time)
if self.is_currently_scrolling():
return
if not self._scrolling_state['deferred_updates']:
return
with self._deferred_lock:
if not self._scrolling_state['deferred_updates']:
return
# Process only a limited number of updates per call to avoid blocking
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
self._scrolling_state['deferred_updates'] = self._scrolling_state['deferred_updates'][max_updates_per_call:]
# Process only a limited number of updates per call to avoid blocking
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
self._scrolling_state['deferred_updates'] = self._scrolling_state['deferred_updates'][max_updates_per_call:]
queued = len(self._scrolling_state['deferred_updates'])
logger.debug(f"Processing {len(updates_to_process)} deferred updates (queue size: {len(self._scrolling_state['deferred_updates'])})")
logger.debug(f"Processing {len(updates_to_process)} deferred updates (queue size: {queued})")
# The callables run outside the lock: they are plugin code of any
# length, and one that defers again would deadlock on it.
failed_updates = []
for update_info in updates_to_process:
try:
@@ -1761,10 +1806,15 @@ class DisplayManager:
# Re-add failed updates to the end of the queue (not the beginning)
if failed_updates:
self._scrolling_state['deferred_updates'].extend(failed_updates)
with self._deferred_lock:
self._scrolling_state['deferred_updates'].extend(failed_updates)
def _cleanup_expired_deferred_updates(self, current_time: float):
"""Remove expired deferred updates to prevent memory leaks."""
"""Remove expired deferred updates to prevent memory leaks.
Callers hold ``_deferred_lock``: this reads the list and assigns a
filtered copy back.
"""
ttl = self._scrolling_state['deferred_update_ttl']
initial_count = len(self._scrolling_state['deferred_updates'])
+18 -5
View File
@@ -13,13 +13,14 @@ Supported dynamic teams:
Usage:
resolver = DynamicTeamResolver()
resolved_teams = resolver.resolve_teams(["UGA", "AP_TOP_25", "AUB"])
# Returns: ["UGA", "UGA", "AUB", "MICH", "OSU", ...] (AP_TOP_25 teams)
# Returns: ["UGA", "MICH", "OSU", ..., "AUB"] -- AP_TOP_25 expanded in
# place, and UGA (also ranked) kept once, at its first position
"""
import logging
import time
import requests
from typing import Dict, List
from typing import Any, Dict, List
from src.common.api_helper import DEFAULT_HTTP_HEADERS
@@ -34,12 +35,17 @@ class DynamicTeamResolver:
"""
# Cache for rankings data
_rankings_cache: Dict[str, List[str]] = {}
_rankings_cache: Dict[str, int] = {} # team abbreviation -> AP rank
_cache_timestamp: float = 0
_cache_duration: int = 3600 # 1 hour cache
# A failed or empty fetch is remembered briefly too: during an ESPN
# outage every resolve would otherwise wait out request_timeout (30s)
# again, on each scoreboard's update.
_failure_timestamp: float = 0
_failure_backoff: int = 300 # 5 minutes
# Supported dynamic team patterns
DYNAMIC_PATTERNS = {
DYNAMIC_PATTERNS: Dict[str, Dict[str, Any]] = {
'AP_TOP_25': {'sport': 'ncaa_fb', 'limit': 25},
'AP_TOP_10': {'sport': 'ncaa_fb', 'limit': 10},
'AP_TOP_5': {'sport': 'ncaa_fb', 'limit': 5},
@@ -70,8 +76,8 @@ class DynamicTeamResolver:
if team in self.DYNAMIC_PATTERNS:
# Resolve dynamic team
dynamic_teams = self._resolve_dynamic_team(team, sport)
# _resolve_dynamic_team already logs the result.
resolved_teams.extend(dynamic_teams)
self.logger.info(f"Resolved {team} to {len(dynamic_teams)} teams: {dynamic_teams[:5]}{'...' if len(dynamic_teams) > 5 else ''}")
elif self._is_potential_dynamic_team(team):
# Unknown dynamic team, skip it
self.logger.warning(f"Unknown dynamic team '{team}' - skipping")
@@ -139,6 +145,10 @@ class DynamicTeamResolver:
current_time - self._cache_timestamp < self._cache_duration):
return self._rankings_cache
# A recent attempt failed: don't pay the request timeout again yet.
if current_time - self._failure_timestamp < self._failure_backoff:
return {}
try:
self.logger.info("Fetching fresh NCAA Football rankings from ESPN API")
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
@@ -186,6 +196,8 @@ class DynamicTeamResolver:
except Exception as e:
self.logger.error(f"Error fetching NCAA Football rankings: {e}")
# On the class, for the same reason as the rankings cache above.
DynamicTeamResolver._failure_timestamp = current_time
return {}
def get_available_dynamic_teams(self) -> List[str]:
@@ -229,6 +241,7 @@ class DynamicTeamResolver:
shadow the shared cache for this instance."""
DynamicTeamResolver._rankings_cache = {}
DynamicTeamResolver._cache_timestamp = 0
DynamicTeamResolver._failure_timestamp = 0
self.logger.info("Cleared dynamic team rankings cache")
+39 -19
View File
@@ -48,6 +48,7 @@ import json
import logging
import math
import os
import threading
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
@@ -68,8 +69,10 @@ _FONTS_SUBDIR = os.path.join('assets', 'fonts')
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
# (resolved absolute path, requested size) -> (font face, realised size).
# BDF faces are stateful in principle, but the core's own FontManager shares
# faces the same way.
# TTF only: a BDF ``freetype.Face`` must never be shared between threads
# (FreeType does not allow it, and ``load_char`` rewrites the face's glyph
# slot), and this cache is process-wide. BDF faces come from
# ``load_bdf_face`` every time, which already caches them per thread.
#
# Bounded LRU rather than the unbounded dict this started as: the display
# process runs for weeks, and every config save can introduce a new
@@ -78,14 +81,28 @@ _FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
# every other hot cache (display_manager, font_manager, adaptive_layout).
_FONT_CACHE_MAX = 256
_font_cache: 'OrderedDict[Tuple[str, int], Tuple[Any, int]]' = OrderedDict()
# load_font is called from the display thread and from plugin update threads.
# A get() then move_to_end() pair on an unguarded OrderedDict raises KeyError
# when another thread evicts the key in between.
_font_cache_lock = threading.Lock()
def _cache_get(key: Tuple[str, int]) -> Optional[Tuple[Any, int]]:
"""The cached entry for ``key`` (marked most recently used), or None."""
with _font_cache_lock:
cached = _font_cache.get(key)
if cached is not None:
_font_cache.move_to_end(key)
return cached
def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
"""Insert, evicting the least recently used entry past the bound."""
_font_cache[key] = value
_font_cache.move_to_end(key)
while len(_font_cache) > _FONT_CACHE_MAX:
_font_cache.popitem(last=False)
with _font_cache_lock:
_font_cache[key] = value
_font_cache.move_to_end(key)
while len(_font_cache) > _FONT_CACHE_MAX:
_font_cache.popitem(last=False)
# Config keys a style element block carries, in schema/UI order.
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
@@ -222,14 +239,15 @@ def _load_font_sized(font_name: str, size: int) -> Tuple[Any, int]:
logger.warning("Font file not found: %s, using fallback", font_name)
return _load_fallback_font(size)
is_bdf = path.lower().endswith('.bdf')
cache_key = (path, size)
cached = _font_cache.get(cache_key)
if cached is not None:
_font_cache.move_to_end(cache_key)
return cached
if not is_bdf:
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
if path.lower().endswith('.bdf'):
if is_bdf:
font, effective = _load_bdf(path, size)
else:
font, effective = load_truetype(path, size), size
@@ -238,7 +256,9 @@ def _load_font_sized(font_name: str, size: int) -> Tuple[Any, int]:
path, size, e)
return _load_fallback_font(size)
_cache_put(cache_key, (font, effective))
# Not BDF: load_bdf_face caches those per thread (see _font_cache).
if not is_bdf:
_cache_put(cache_key, (font, effective))
return font, effective
@@ -247,9 +267,8 @@ def _load_fallback_font(size: int) -> Tuple[Any, int]:
path = resolve_font_path(_FALLBACK_FONT_NAME)
if path is not None:
cache_key = (path, size)
cached = _font_cache.get(cache_key)
cached = _cache_get(cache_key)
if cached is not None:
_font_cache.move_to_end(cache_key)
return cached
try:
entry = (load_truetype(path, size), size)
@@ -840,7 +859,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
defaults['align'] = align_spec['default']
scale_spec = spec.get('scale')
if isinstance(scale_spec, dict) and 'default' in scale_spec:
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
layout.setdefault(element_key, {})['scale'] = scale_spec['default']
elif scale_spec is True:
layout.setdefault(element_key, {})['scale'] = 1.0
if defaults:
@@ -856,7 +875,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
continue
scale_prop = (block.get('properties') or {}).get('scale')
if isinstance(scale_prop, dict) and 'default' in scale_prop:
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
layout.setdefault(element_key, {})['scale'] = scale_prop['default']
for element_key, block in properties.items():
if element_key in ('layout', 'modes') or element_key in elements:
continue
@@ -1480,11 +1499,12 @@ class ElementStyleResolver:
mode_config, 'align')
# scale is geometry, so it lives with the offsets rather than in the
# element block -- a logo has a scale and no font.
layout_defaults = self._defaults.get('layout', {})
# The default is looked up through the aliases too, like the value:
# an exact-key lookup missed a default filed under another name, so
# a configured value equal to it counted as a user choice.
scale = self._forced(
self._layout_element(self._customization(), element_key),
layout_defaults.get(element_key, {})
if isinstance(layout_defaults, dict) else {},
_lookup_element(self._defaults.get('layout', {}), element_key),
self._layout_element(self._mode_block(mode), element_key),
'scale')
+3 -40
View File
@@ -6,7 +6,8 @@ for the LEDMatrix system. Enables automatic bug detection by tracking
error frequency, patterns, and context.
This is a local-only implementation with no external dependencies.
Errors are stored in memory with optional JSON export.
Errors are stored in memory; ErrorSnapshotPublisher shares a summary with the
web process through the cache.
"""
import math
@@ -18,7 +19,6 @@ import uuid
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Any, Callable, Tuple
import logging
@@ -105,7 +105,6 @@ class ErrorAggregator:
max_records: int = 1000,
pattern_threshold: int = 5,
pattern_window_minutes: int = 60,
export_path: Optional[Path] = None
):
"""
Initialize the error aggregator.
@@ -114,20 +113,18 @@ class ErrorAggregator:
max_records: Maximum number of error records to keep in memory
pattern_threshold: Number of occurrences to detect a pattern
pattern_window_minutes: Time window for pattern detection
export_path: Optional path for JSON export (auto-export on pattern detection)
"""
self.logger = logging.getLogger(__name__)
self.max_records = max_records
self.pattern_threshold = pattern_threshold
self.pattern_window = timedelta(minutes=pattern_window_minutes)
self.export_path = export_path
self._records: List[ErrorRecord] = []
self._error_counts: Dict[str, int] = defaultdict(int)
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
self._patterns: Dict[str, ErrorPattern] = {}
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
self._lock = threading.RLock() # RLock allows nested acquisition for export_to_file
self._lock = threading.RLock() # RLock: build_snapshot and pattern callbacks re-enter
# Track session start for relative timing
self._session_start = datetime.now()
@@ -248,10 +245,6 @@ class ErrorAggregator:
callback(pattern)
except Exception as e:
self.logger.error(f"Pattern callback failed: {e}")
# Auto-export if path configured
if self.export_path:
self._auto_export()
else:
# Update existing pattern
self._patterns[pattern_key].count = count
@@ -424,33 +417,6 @@ class ErrorAggregator:
# turned into a string here rather than failing the write.
return json.loads(json.dumps(summary, default=str))
def export_to_file(self, filepath: Path) -> None:
"""
Export error data to JSON file.
Args:
filepath: Path to export file
"""
with self._lock:
data = {
"exported_at": datetime.now().isoformat(),
"summary": self.get_error_summary(),
"all_records": [r.to_dict() for r in self._records]
}
filepath.parent.mkdir(parents=True, exist_ok=True)
filepath.write_text(json.dumps(data, indent=2))
self.logger.info(f"Exported error data to {filepath}")
def _auto_export(self) -> None:
"""Auto-export on pattern detection (if export_path configured)."""
if self.export_path:
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filepath = self.export_path / f"errors_{timestamp}.json"
self.export_to_file(filepath)
except Exception as e:
self.logger.error(f"Auto-export failed: {e}")
# Global singleton instance
_error_aggregator: Optional[ErrorAggregator] = None
@@ -461,7 +427,6 @@ def get_error_aggregator(
max_records: int = 1000,
pattern_threshold: int = 5,
pattern_window_minutes: int = 60,
export_path: Optional[Path] = None
) -> ErrorAggregator:
"""
Get or create the global error aggregator instance.
@@ -470,7 +435,6 @@ def get_error_aggregator(
max_records: Maximum records to keep (only used on first call)
pattern_threshold: Pattern detection threshold (only used on first call)
pattern_window_minutes: Pattern detection window (only used on first call)
export_path: Export path for auto-export (only used on first call)
Returns:
The global ErrorAggregator instance
@@ -483,7 +447,6 @@ def get_error_aggregator(
max_records=max_records,
pattern_threshold=pattern_threshold,
pattern_window_minutes=pattern_window_minutes,
export_path=export_path
)
return _error_aggregator
+19 -9
View File
@@ -5,11 +5,13 @@ Provides specific exception types for different error categories,
enabling better error handling and debugging.
"""
from typing import Optional
class LEDMatrixError(Exception):
"""Base exception for all LEDMatrix errors."""
def __init__(self, message: str, context: dict = None):
def __init__(self, message: str, context: Optional[dict] = None):
"""
Initialize the exception.
@@ -32,7 +34,7 @@ class LEDMatrixError(Exception):
class CacheError(LEDMatrixError):
"""Exception raised for cache-related errors."""
def __init__(self, message: str, cache_key: str = None, context: dict = None):
def __init__(self, message: str, cache_key: Optional[str] = None, context: Optional[dict] = None):
"""
Initialize cache error.
@@ -42,7 +44,9 @@ class CacheError(LEDMatrixError):
context: Optional context dictionary
"""
if cache_key:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
context['cache_key'] = cache_key
super().__init__(message, context)
self.cache_key = cache_key
@@ -51,7 +55,7 @@ class CacheError(LEDMatrixError):
class ConfigError(LEDMatrixError):
"""Exception raised for configuration-related errors."""
def __init__(self, message: str, config_path: str = None, field: str = None, context: dict = None):
def __init__(self, message: str, config_path: Optional[str] = None, field: Optional[str] = None, context: Optional[dict] = None):
"""
Initialize config error.
@@ -62,7 +66,9 @@ class ConfigError(LEDMatrixError):
context: Optional context dictionary
"""
if config_path or field:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
if config_path:
context['config_path'] = config_path
if field:
@@ -75,7 +81,7 @@ class ConfigError(LEDMatrixError):
class PluginError(LEDMatrixError):
"""Exception raised for plugin-related errors."""
def __init__(self, message: str, plugin_id: str = None, context: dict = None):
def __init__(self, message: str, plugin_id: Optional[str] = None, context: Optional[dict] = None):
"""
Initialize plugin error.
@@ -85,7 +91,9 @@ class PluginError(LEDMatrixError):
context: Optional context dictionary
"""
if plugin_id:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
context['plugin_id'] = plugin_id
super().__init__(message, context)
self.plugin_id = plugin_id
@@ -94,7 +102,7 @@ class PluginError(LEDMatrixError):
class DisplayError(LEDMatrixError):
"""Exception raised for display-related errors."""
def __init__(self, message: str, display_mode: str = None, context: dict = None):
def __init__(self, message: str, display_mode: Optional[str] = None, context: Optional[dict] = None):
"""
Initialize display error.
@@ -104,7 +112,9 @@ class DisplayError(LEDMatrixError):
context: Optional context dictionary
"""
if display_mode:
context = context or {}
# Copy so the caller's dict isn't mutated (a reused context
# dict would otherwise collect every error's keys).
context = dict(context or {})
context['display_mode'] = display_mode
super().__init__(message, context)
self.display_mode = display_mode
+68 -15
View File
@@ -28,10 +28,10 @@ for accurate width/height calculations.
import os
import logging
import freetype
import requests
import json
import hashlib
import urllib.parse
import urllib.request
import zipfile
import tempfile
import time
@@ -50,6 +50,9 @@ from src.deprecation import deprecated
logger = logging.getLogger(__name__)
# Seconds before a stalled font download gives up (connect and per-read).
_FONT_DOWNLOAD_TIMEOUT = 30
class FontManager:
"""
Comprehensive font management supporting TTF and BDF fonts with caching,
@@ -320,31 +323,59 @@ class FontManager:
extension = self._get_font_extension(url)
cache_filename = f"{family}_{url_hash}{extension}"
cache_path = self.temp_font_dir / cache_filename
is_zip = url.endswith('.zip')
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
# Check if already downloaded
if cache_path.exists():
# Check if already downloaded. For a zip the font is the file
# extracted from it, so look there first -- returning the cached
# .zip itself would register the archive as the font after a
# restart.
if is_zip:
extracted = self._find_extracted_font(extract_dir)
if extracted:
logger.info(f"Using cached font: {extracted}")
return extracted
elif cache_path.exists():
logger.info(f"Using cached font: {cache_path}")
return str(cache_path)
# Download font — restrict to http/https to prevent file:// reads
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
logger.info(f"Downloading font from {url}")
urllib.request.urlretrieve(url, cache_path) # nosec B310 - scheme validated above
if not cache_path.exists():
# Download font — restrict to http/https to prevent file:// reads
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
logger.info(f"Downloading font from {url}")
# Download to a temp file and rename into place, with a
# timeout: writing straight to cache_path left a truncated
# file after a stalled/interrupted download, and the exists()
# check above then served it forever.
fd, tmp_name = tempfile.mkstemp(dir=self.temp_font_dir, suffix='.part')
try:
with os.fdopen(fd, 'wb') as tmp_file:
response = requests.get(url, timeout=_FONT_DOWNLOAD_TIMEOUT, stream=True)
response.raise_for_status()
for chunk in response.iter_content(chunk_size=65536):
if chunk:
tmp_file.write(chunk)
os.replace(tmp_name, cache_path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
# Handle zip files
if url.endswith('.zip'):
extract_dir = self.temp_font_dir / f"{family}_{url_hash}"
if is_zip:
extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(cache_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
# Find the actual font file
for file in extract_dir.iterdir():
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
return str(file)
extracted = self._find_extracted_font(extract_dir)
if extracted:
return extracted
return str(cache_path)
@@ -352,6 +383,16 @@ class FontManager:
logger.error(f"Error downloading font from {url}: {e}")
return None
@staticmethod
def _find_extracted_font(extract_dir: Path) -> Optional[str]:
"""Return the first font file in a zip's extract dir, if any."""
if not extract_dir.is_dir():
return None
for file in extract_dir.iterdir():
if file.suffix.lower() in ['.ttf', '.otf', '.bdf']:
return str(file)
return None
def _get_font_extension(self, url: str) -> str:
"""Extract font file extension from URL."""
if '.ttf' in url.lower():
@@ -426,6 +467,9 @@ class FontManager:
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
for key in keys_to_remove:
del self.font_cache[key]
if keys_to_remove:
# Font objects someone may hold were dropped; see cache_generation.
self.cache_generation += 1
@deprecated("3.7.0")
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
@@ -495,6 +539,7 @@ class FontManager:
self.performance_stats["cache_misses"] += 1
# Load font
shareable = True
font_path = self.font_catalog.get(family)
if not font_path:
logger.warning(f"Font family '{family}' not found")
@@ -504,6 +549,7 @@ class FontManager:
try:
if font_path.endswith('.bdf'):
font = self._load_bdf_font(font_path, size_px)
shareable = False
else:
font = load_truetype(font_path, size_px)
except Exception as e:
@@ -513,7 +559,11 @@ class FontManager:
self.performance_stats["failed_loads"] += 1
font = ImageFont.load_default()
self.font_cache[cache_key] = font
# A BDF face is not cached here: font_cache is shared by every
# thread, and a freetype.Face must never be (see load_bdf_face, which
# already caches BDF faces per thread).
if shareable:
self.font_cache[cache_key] = font
return font
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
@@ -732,6 +782,9 @@ class FontManager:
"""Clear font and metrics cache."""
self.font_cache.clear()
self.metrics_cache.clear()
# Holders of derived caches (layout fits, font usage) key off this;
# without the bump they kept serving results for the dropped fonts.
self.cache_generation += 1
logger.info("Font cache cleared")
@deprecated("3.7.0", "read font_catalog")
+7 -3
View File
@@ -43,7 +43,10 @@ class StructuredFormatter(logging.Formatter):
if hasattr(record, 'operation_id'):
log_data['operation_id'] = record.operation_id
return json.dumps(log_data)
# default=str: record.context / extras can hold datetimes, Paths,
# exceptions etc.; without it one such value raised TypeError and
# the whole record was dropped by the handler's error path.
return json.dumps(log_data, default=str)
class ContextualFormatter(logging.Formatter):
@@ -122,6 +125,7 @@ def setup_logging(
root_logger.handlers.clear()
# Create formatter based on type
formatter: logging.Formatter
if format_type == 'json':
formatter = StructuredFormatter()
else:
@@ -241,7 +245,7 @@ class PluginLoggerAdapter(logging.LoggerAdapter):
def process(self, msg, kwargs):
extra = dict(kwargs.get('extra') or {})
extra.setdefault('plugin_id', self.extra.get('plugin_id'))
extra.setdefault('plugin_id', self.extra.get('plugin_id')) # type: ignore[union-attr] # get_logger always passes a dict
kwargs['extra'] = extra
return msg, kwargs
@@ -287,7 +291,7 @@ def log_with_context(
operation_id: Optional operation ID for request tracking
exc_info: Optional exception info for error logging
"""
extra = {}
extra: Dict[str, Any] = {}
if context:
extra['context'] = context
+8 -4
View File
@@ -13,7 +13,7 @@ import time
import logging
import requests
import json
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Tuple, Union
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
from src.common.font_layout import load_truetype, resolve_asset_path
@@ -235,7 +235,10 @@ def refresh_placeholder_timestamp(filepath: Path) -> bool:
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
with Image.open(filepath) as img:
img.copy().save(filepath, "PNG", pnginfo=metadata)
image = img.copy()
# Atomically, like every other logo write: a renderer can open this
# file at any moment, and an in-place save exposes a truncated PNG.
save_png_atomically(image, filepath, pnginfo=metadata)
return True
except Exception:
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
@@ -478,7 +481,7 @@ class LogoDownloader:
logger.info(f"Fetching team data for {league} from ESPN API...")
response = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout)
response.raise_for_status()
data = response.json()
data: Dict = response.json()
logger.info(f"Successfully fetched team data for {league}")
return data
@@ -502,7 +505,7 @@ class LogoDownloader:
logger.info(f"Fetching team data for team {team_id} in {league} from ESPN API...")
response = self.session.get(f"{api_url}/{team_id}", headers=self.headers, timeout=self.request_timeout)
response.raise_for_status()
data = response.json()
data: Dict = response.json()
logger.info(f"Successfully fetched team data for {team_id} in {league}")
return data
@@ -812,6 +815,7 @@ class LogoDownloader:
draw = ImageDraw.Draw(logo)
# Try to load a font, fallback to default
font: Optional[Union[ImageFont.FreeTypeFont, ImageFont.ImageFont]]
try:
font = load_truetype(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), 12)
except (OSError, IOError):
+8 -1
View File
@@ -74,6 +74,13 @@ MAPPING_OUTPUTS: Dict[str, int] = {
'classic-pi1': 1,
}
#: The refresh cap (``display.hardware.limit_refresh_rate_hz``) when config
#: omits it -- config/config.template.json's value. DisplayManager passes it to
#: the library and reports it as ``refresh_hz`` for scroll pacing, so the two
#: must be the same number: they were 90 and 100, and pacing solved against a
#: rate the panel was capped below.
DEFAULT_REFRESH_LIMIT_HZ = 100
#: What DisplayManager passes when a key is missing from display.hardware /
#: display.runtime. Config migration normally fills these from
#: config/config.template.json first, so they rarely apply.
@@ -81,7 +88,7 @@ DISPLAY_MANAGER_DEFAULTS: Dict[str, Any] = {
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90, 'pwm_bits': 10,
'pwm_lsb_nanoseconds': 150, 'led_rgb_sequence': 'RGB',
'row_address_type': 0, 'multiplexing': 0, 'limit_refresh_rate_hz': 90,
'row_address_type': 0, 'multiplexing': 0, 'limit_refresh_rate_hz': DEFAULT_REFRESH_LIMIT_HZ,
'gpio_slowdown': 3,
}
+14 -7
View File
@@ -776,28 +776,35 @@ class BasePlugin(ABC):
tighter arrangement instead of being cropped afterwards.
Vegas also narrows ``display_manager`` for the duration of the call, so
a plugin that already sizes itself from ``matrix.width`` needs no
changes. Read this only when you size content some other way.
a plugin that already sizes itself from ``display_manager.width`` needs
no changes. Read this only when you size content some other way.
Controlled by the plugin's own ``vegas_width_pct`` config value, else
the global ``display.vegas_scroll.render_width_pct``.
Returns:
Target width in pixels. Outside a Vegas content request, the full
display width.
display width: ``display_manager.width``, which falls back to the
canvas size when ``matrix`` is None (hardware init failed).
"""
requested = getattr(self, '_vegas_render_width', None)
if isinstance(requested, int) and requested > 0:
return requested
# display_manager.width first, as CLAUDE.md asks of every plugin: it
# already reads matrix.width when there is a matrix. matrix.width is
# only the fallback for a display_manager without a width (a test
# double, an older wrapper).
display_manager = getattr(self, 'display_manager', None)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
width = getattr(display_manager, 'width', None)
if callable(width):
width = width()
return int(width) if width else 128
if width:
return int(width)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
return 128
def get_vegas_content(self) -> Optional[Any]:
"""
+9 -3
View File
@@ -97,6 +97,8 @@ def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
try:
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
except ValueError:
# Reachable: str.isdigit() accepts characters int() rejects, such as
# a superscript "\u00b2" -- "1.\u00b2.0" lands here.
return None
while len(nums) < 3:
nums.append(0)
@@ -189,11 +191,11 @@ def satisfies_compatible_versions(
return any(parsed)
def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
def declared_min_version(manifest: Dict[str, Any]) -> Any:
"""The core version this plugin says it needs, or ``None`` if it doesn't say.
Checked in order of specificity. `ledmatrix_min` is the deprecated spelling
of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags
of `ledmatrix_min_version` (`store_manager._validate_manifest_version_fields` flags
it); both are read because a large share of published manifests still carry
the old one.
@@ -204,6 +206,10 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
untrustworthy-core branch of :func:`check` calls this for *every* manifest,
so one malformed file would take down the install path rather than just
itself. A shape we do not recognise means "no declared floor".
The value is returned as the manifest holds it -- normally a version
string, but nothing here checks that; callers hand it to
:func:`parse_semver`, which accepts anything.
"""
declared = manifest.get('min_ledmatrix_version')
if not declared:
@@ -220,7 +226,7 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
return None
def is_update_available(installed_version: str, latest_version: str) -> bool:
def is_update_available(installed_version: Any, latest_version: Any) -> bool:
"""Return True when the registry's ``latest_version`` is strictly newer
than the installed version.
+7 -5
View File
@@ -11,6 +11,7 @@ from datetime import datetime
from pathlib import Path
from dataclasses import dataclass, asdict
from src.config_manager_atomic import atomic_write_text
from src.logging_config import get_logger
@@ -180,15 +181,16 @@ class OperationHistory:
return
try:
# Held across the write, and written via a temp file, so two
# threads saving at once can't interleave or truncate the file.
with self._lock:
history_data = [record.to_dict() for record in self._history]
# Ensure directory exists
self.history_file.parent.mkdir(parents=True, exist_ok=True)
# Ensure directory exists
self.history_file.parent.mkdir(parents=True, exist_ok=True)
# Write to file
with open(self.history_file, 'w') as f:
json.dump(history_data, f, indent=2)
# Write to file
atomic_write_text(self.history_file, json.dumps(history_data, indent=2))
except Exception as e:
self.logger.error(f"Error saving operation history: {e}", exc_info=True)
+18 -14
View File
@@ -86,6 +86,17 @@ class PluginOperationQueue:
f"{active_op.operation_id} ({active_op.operation_type.value})"
)
# _active_operations only holds the *running* one, so a second
# request while the first still waits in the queue (a double-
# clicked Install) used to be queued too, and both ran back to
# back. Refuse it the same way.
for queued_op in self._operations.values():
if queued_op.plugin_id == plugin_id and queued_op.status == OperationStatus.PENDING:
raise ValueError(
f"Plugin {plugin_id} already has an active operation: "
f"{queued_op.operation_id} ({queued_op.operation_type.value})"
)
# Create operation
operation = PluginOperation(
operation_type=operation_type,
@@ -172,20 +183,6 @@ class PluginOperationQueue:
)
return history[:limit]
def get_active_operations(self) -> List[PluginOperation]:
"""
Get all currently active operations (pending or running).
Returns:
List of active operations
"""
with self._lock:
active = []
for operation in self._operations.values():
if operation.status in [OperationStatus.PENDING, OperationStatus.RUNNING]:
active.append(operation)
return active
def _start_worker(self) -> None:
"""Start the worker thread that processes operations."""
if self._worker_thread and self._worker_thread.is_alive():
@@ -302,7 +299,14 @@ class PluginOperationQueue:
if len(self._operation_history) > self.max_history:
# Remove oldest operations
self._operation_history.sort(key=lambda op: op.created_at)
dropped = self._operation_history[:-self.max_history]
self._operation_history = self._operation_history[-self.max_history:]
# ...and forget them in the status map too, which otherwise kept
# every operation ever enqueued for the life of the process. A
# still-pending or running one is never dropped from lookups.
for op in dropped:
if op.status not in (OperationStatus.PENDING, OperationStatus.RUNNING):
self._operations.pop(op.operation_id, None)
def shutdown(self) -> None:
"""Shutdown the operation queue and worker thread."""
+6 -5
View File
@@ -45,7 +45,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Union
from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast
from src.common.path_safety import safe_path_component
@@ -91,7 +91,7 @@ class PluginDirEntry:
"""One candidate directory and its manifest, read once."""
path: Path
status: str
manifest: Optional[Any] = None
manifest: Any = None
error: Optional[BaseException] = None
@property
@@ -103,7 +103,8 @@ class PluginDirEntry:
"""The manifest's ``id`` when the manifest is usable, else None."""
if self.status != ManifestStatus.OK:
return None
return self.manifest['id']
# OK means a dict whose "id" is a non-empty string (_read_entry).
return cast(str, self.manifest['id'])
@property
def manifest_parses(self) -> bool:
@@ -240,7 +241,7 @@ class PluginDirectoryIndex:
# -- lookup -----------------------------------------------------------
def find(self, plugin_id: str, *, prefix: bool, case_insensitive: bool,
def find(self, plugin_id: Any, *, prefix: bool, case_insensitive: bool,
by_manifest: bool = True) -> Optional[Path]:
"""Resolve ``plugin_id`` within this directory (rules in the module doc)."""
plugin_id = _lookup_id(plugin_id)
@@ -270,7 +271,7 @@ def _lookup_id(plugin_id: Any) -> Optional[str]:
plugin_id = safe_path_component(plugin_id)
if plugin_id is None or is_ignored_dir_name(plugin_id):
return None
return plugin_id
return cast(str, plugin_id) # safe_path_component returned a str
def _candidate_names(plugin_id: str, prefix: bool) -> List[str]:
+2 -2
View File
@@ -6,7 +6,7 @@ error isolation, and performance monitoring.
"""
import time
from typing import Any, Optional, Callable
from typing import Any, Dict, Optional, Callable
from threading import Thread
import logging
@@ -62,7 +62,7 @@ class PluginExecutor:
plugin_context = f"plugin {plugin_id}" if plugin_id else "plugin"
# Use threading-based timeout (more reliable than signal-based)
result_container = {'value': None, 'exception': None, 'completed': False}
result_container: Dict[str, Any] = {'value': None, 'exception': None, 'completed': False}
def target():
try:
+3 -2
View File
@@ -6,10 +6,11 @@ and circuit breaker state. Provides automatic recovery mechanisms.
"""
import time
import logging
from typing import Dict, Optional, Any, Tuple
from enum import Enum
from src.logging_config import get_logger
class CircuitState(Enum):
"""Circuit breaker states."""
@@ -43,7 +44,7 @@ class PluginHealthTracker:
self.failure_threshold = failure_threshold
self.cooldown_period = cooldown_period
self.half_open_timeout = half_open_timeout
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
# In-memory health state (also persisted to cache)
self._health_state: Dict[str, Dict[str, Any]] = {}
+105 -88
View File
@@ -23,6 +23,11 @@ from src.exceptions import PluginError
from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import resolve_plugin_dir
#: Serialises pip runs across threads. Startup loads plugins on a small
#: thread pool, and two concurrent ``pip install`` processes writing the same
#: site-packages can corrupt it or fail on each other's partial installs.
_PIP_INSTALL_LOCK = threading.Lock()
def requirements_has_real_deps(requirements_file: str) -> bool:
"""
@@ -272,6 +277,7 @@ class PluginLoader:
Path to plugin directory or None if not found. An id that is not
one plain path segment finds nothing.
"""
plugin_dir: Optional[Path]
# Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id]
@@ -325,93 +331,94 @@ class PluginLoader:
if requirements_file is None:
return True
try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
with _PIP_INSTALL_LOCK:
try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if result.returncode == 0:
self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
else:
stderr = result.stderr or ""
# uninstall-no-record-file means a system-managed copy of a package
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
# the way of the version this requirements.txt pins. Retry with
# --ignore-installed so pip lays the pinned version down alongside
# the system copy instead of trying to replace it — matching the
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
# Without this retry, the plugin would silently keep running against
# whatever version the system happened to ship.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s conflict with a system-managed package "
"(no pip RECORD); retrying with --ignore-installed: %s",
plugin_id, stderr.strip()
)
# Wrapped in its own try/except so a retry timeout is
# tolerated the same way as a retry failure, instead of
# propagating to the outer handler and returning False
# (which would contradict the "assume satisfied" fallback
# below).
try:
# sys.executable is this process's own interpreter (not
# attacker-influenced), and requirements_file is rebuilt
# by contained_plugin_dir() from a trusted listing, never raw
# external input.
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
"--ignore-installed", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if retry_result.returncode != 0:
self.logger.warning(
"Retry with --ignore-installed also failed for %s; assuming the "
"system-managed version satisfies the requirement: %s",
plugin_id, (retry_result.stderr or "").strip()
)
except subprocess.TimeoutExpired:
self.logger.warning(
"Retry with --ignore-installed timed out for %s; assuming the "
"system-managed version satisfies the requirement",
plugin_id
)
if result.returncode == 0:
self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s",
plugin_id,
stderr
)
else:
stderr = result.stderr or ""
# uninstall-no-record-file means a system-managed copy of a package
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
# the way of the version this requirements.txt pins. Retry with
# --ignore-installed so pip lays the pinned version down alongside
# the system copy instead of trying to replace it — matching the
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
# Without this retry, the plugin would silently keep running against
# whatever version the system happened to ship.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s conflict with a system-managed package "
"(no pip RECORD); retrying with --ignore-installed: %s",
plugin_id, stderr.strip()
)
# Wrapped in its own try/except so a retry timeout is
# tolerated the same way as a retry failure, instead of
# propagating to the outer handler and returning False
# (which would contradict the "assume satisfied" fallback
# below).
try:
# sys.executable is this process's own interpreter (not
# attacker-influenced), and requirements_file is rebuilt
# by contained_plugin_dir() from a trusted listing, never raw
# external input.
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
"--ignore-installed", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if retry_result.returncode != 0:
self.logger.warning(
"Retry with --ignore-installed also failed for %s; assuming the "
"system-managed version satisfies the requirement: %s",
plugin_id, (retry_result.stderr or "").strip()
)
except subprocess.TimeoutExpired:
self.logger.warning(
"Retry with --ignore-installed timed out for %s; assuming the "
"system-managed version satisfies the requirement",
plugin_id
)
return True
self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s",
plugin_id,
stderr
)
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out for %s", plugin_id)
return False
except FileNotFoundError:
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
return True
except OSError as e:
# A broken pipe (EPIPE) happens when pip's output pipe closes
# mid-download, usually a network interruption.
if e.errno == errno.EPIPE:
self.logger.error(
"Broken pipe error during dependency installation for %s. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection.", plugin_id
)
else:
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out for %s", plugin_id)
return False
except FileNotFoundError:
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
return True
except OSError as e:
# A broken pipe (EPIPE) happens when pip's output pipe closes
# mid-download, usually a network interruption.
if e.errno == errno.EPIPE:
self.logger.error(
"Broken pipe error during dependency installation for %s. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection.", plugin_id
)
else:
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
return False
@staticmethod
def _iter_plugin_bare_modules(
@@ -585,11 +592,21 @@ class PluginLoader:
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
with self._module_load_lock:
# Add plugin directory to sys.path if not already there
# Put this plugin's directory first on sys.path -- moving it there
# if it is already present. Plugins import their own modules by
# bare name (``from sports import ...``), and those resolve to the
# first directory that has the file. A directory added on an
# earlier load stays where it was, so reloading a plugin (a live
# re-enable from the web UI) after another scoreboard had loaded
# found that one's sports.py first and failed on a name only its
# own copy has.
plugin_dir_str = str(plugin_dir)
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
self.logger.debug("Added plugin %s's directory to sys.path", plugin_id)
try:
sys.path.remove(plugin_dir_str)
except ValueError:
pass
sys.path.insert(0, plugin_dir_str)
self.logger.debug("Put plugin %s's directory first on sys.path", plugin_id)
# Import the plugin module
module_name = f"plugin_{plugin_id.replace('-', '_')}"
+100 -4
View File
@@ -53,6 +53,10 @@ class PluginManager:
- PluginStateManager: Manages plugin state machine
"""
# How long unload_plugin() waits for an in-flight update() to finish
# before tearing the instance down anyway.
UNLOAD_LOCK_TIMEOUT = 5.0
def __init__(self, plugins_dir: str = "plugins",
config_manager: Optional[Any] = None,
display_manager: Optional[Any] = None,
@@ -183,6 +187,8 @@ class PluginManager:
someone opened a page -- the same log-volume problem this is meant to
help diagnose.
"""
# setdefault rather than self._skip_reported: tests build a bare
# scanner with PluginManager.__new__ and skip __init__.
reported = self.__dict__.setdefault('_skip_reported', set())
if key in reported:
return
@@ -290,7 +296,7 @@ class PluginManager:
return plugin_ids
def load_plugin(self, plugin_id: str) -> bool:
def load_plugin(self, plugin_id: str, force_enabled: bool = False) -> bool:
"""
Load a plugin by ID.
@@ -304,6 +310,10 @@ class PluginManager:
Args:
plugin_id: Plugin identifier
force_enabled: Run the plugin enabled even though config.json has
it disabled. On-demand uses this to show a disabled plugin
(DisplayController._load_plugin_for_on_demand). Only the
instance's config says enabled; config.json is not written.
Returns:
True if loaded successfully, False otherwise
@@ -370,6 +380,12 @@ class PluginManager:
# (prepare_plugin_config). In memory only: config.json is written
# by saves, never by loading a plugin.
config = self.prepare_plugin_config(plugin_id, config, schema=schema)
if force_enabled:
# A copy: prepare_plugin_config can hand back the section from
# config_manager's cached config, and setting the flag there
# would read as enabled to everything else in this process.
config = dict(config)
config['enabled'] = True
# Use PluginLoader to load plugin
plugin_instance, _module = self.plugin_loader.load_plugin(
@@ -406,10 +422,12 @@ class PluginManager:
try:
if not plugin_instance.validate_config():
self.logger.error("Plugin %s configuration validation failed", plugin_id)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR)
return False
except Exception as e:
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
@@ -433,7 +451,18 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
# Call on_enable if plugin is enabled
if hasattr(plugin_instance, 'on_enable'):
plugin_instance.on_enable()
try:
plugin_instance.on_enable()
except Exception:
# Undo the registration above before the outer
# handler marks it ERROR: left in self.plugins, the
# next load_plugin() would return True as "already
# loaded" for a plugin that never enabled.
self.plugins.pop(plugin_id, None)
with self._plugin_last_update_lock:
self.plugin_last_update.pop(plugin_id, None)
self._update_interval_cache.pop(plugin_id, None)
raise
else:
self.state_manager.set_state(plugin_id, PluginState.DISABLED)
@@ -443,16 +472,38 @@ class PluginManager:
except PluginError as e:
self.logger.error("Plugin error loading %s: %s", plugin_id, e, exc_info=True)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
except Exception as e:
self.logger.error("Unexpected error loading plugin %s: %s", plugin_id, e, exc_info=True)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
def _discard_failed_load(self, plugin_id: str) -> None:
"""Forget a plugin's imported module and font registrations after a
failed load.
load_module() reuses ``plugin_<id>`` from sys.modules, so a module
left behind by a load that failed after import (instantiation,
validate_config, on_enable) would keep serving the old code even
after the user fixes the plugin and reloads it. Never raises.
"""
try:
sys.modules.pop(f"plugin_{plugin_id.replace('-', '_')}", None)
self.plugin_loader.unregister_plugin_modules(plugin_id)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
try:
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
self.font_manager.forget_manager_fonts(plugin_id)
except Exception as e:
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
#: Config keys the **core** reads out of a plugin's own config block. The
#: plugin never declares them, so a schema with
#: ``"additionalProperties": false`` — 37 of the 42 published ones — reports
#: ``"additionalProperties": false`` — most published ones do — reports
#: them as violations and the plugin gets flagged degraded in the web UI for
#: using a documented core feature.
#:
@@ -594,6 +645,28 @@ class PluginManager:
self.logger.warning("Plugin %s not loaded", plugin_id)
return False
# Take the plugin's lock so cleanup()/on_disable() can't run while
# the update worker is mid-update() on this instance. Bounded: an
# update() that hangs past PluginExecutor's timeout keeps holding the
# lock from its lingering thread, and unload must still go through.
lock = self.get_plugin_lock(plugin_id)
lock_acquired = lock.acquire(timeout=self.UNLOAD_LOCK_TIMEOUT)
if not lock_acquired:
self.logger.warning(
"Plugin %s still busy after %.1fs; unloading without its lock",
plugin_id, self.UNLOAD_LOCK_TIMEOUT)
try:
return self._unload_plugin_locked(plugin_id)
finally:
if lock_acquired:
lock.release()
def _unload_plugin_locked(self, plugin_id: str) -> bool:
"""Body of unload_plugin(); caller holds (or gave up on) the plugin lock."""
if plugin_id not in self.plugins: # unloaded while we waited
self.logger.warning("Plugin %s not loaded", plugin_id)
return False
try:
plugin = self.plugins[plugin_id]
@@ -744,7 +817,13 @@ class PluginManager:
if plugin:
info['loaded'] = True
if hasattr(plugin, 'get_info'):
info['runtime_info'] = plugin.get_info()
# One plugin's get_info() raising must not take down the
# whole installed-plugins listing (/api/v3/plugins/installed).
try:
info['runtime_info'] = plugin.get_info()
except Exception as e:
self.logger.warning("Plugin %s get_info() failed: %s", plugin_id, e)
info['runtime_info'] = {}
else:
info['loaded'] = False
@@ -886,6 +965,14 @@ class PluginManager:
updating, since a scheduler that propagates a plugin bug stops every
other plugin too.
Precedence, first match wins: the ``get_update_interval()`` hook, then
``update_interval`` in the plugin's **manifest**, then
``update_interval`` in the plugin's section of config.json, then 60s.
So a config value only drives the scheduler for a plugin whose
manifest sets none; when the manifest sets one, the config value is
ignored here (a plugin may still read it itself, e.g. to skip fetches
inside update()).
The static result is cached per plugin_id after the first lookup, so
the manifest/config resolution is not repeated on every scheduling
tick of the display loop. A change to ``update_interval`` in
@@ -1187,6 +1274,15 @@ class PluginManager:
return
finished['done'] = True
try:
# The plugin was unloaded (or reloaded as a new instance)
# while this update() ran: unload_plugin() already cleared its
# lifecycle state, so recording success/failure here would
# resurrect a torn-down plugin as ENABLED. Only release.
if self.plugins.get(plugin_id) is not plugin_instance:
if lock is not None:
with self._pending_lock:
self._pending_updates.discard(plugin_id)
return
# Drop the queue reservation *before* the state goes back to
# ENABLED. The other order leaves a window where a scheduler
# sees ENABLED, reserves the plugin, then finds it still in
+1 -1
View File
@@ -17,7 +17,7 @@ from src.logging_config import get_logger
class PluginState(Enum):
"""Plugin state enumeration."""
UNLOADED = "unloaded" # Plugin not loaded
LOADED = "loaded" # Plugin module loaded but not instantiated
LOADED = "loaded" # load_plugin() in progress: set before the module is imported
ENABLED = "enabled" # Plugin instantiated and enabled
RUNNING = "running" # Plugin is currently executing
ERROR = "error" # Plugin encountered an error
+66 -7
View File
@@ -5,12 +5,14 @@ Tracks resource usage (memory, CPU, execution time) for plugins.
Provides resource limits and performance monitoring.
"""
import math
import time
import logging
import threading
from typing import Dict, Optional, Any, Callable
from typing import Dict, Optional, Any, Callable, cast
from dataclasses import dataclass, field, fields
from src.logging_config import get_logger
try:
import psutil
PSUTIL_AVAILABLE = True
@@ -31,6 +33,52 @@ class ResourceLimits:
warning_threshold: float = 0.8 # Warning at 80% of limit
_LIMIT_FIELDS = ('max_memory_mb', 'max_cpu_percent', 'max_execution_time',
'warning_threshold')
def invalid_limit_field(data: Any) -> Optional[str]:
"""The first field of a limits mapping that isn't a valid limit, or None.
``"limits"`` when ``data`` isn't a mapping at all. Separate from
limits_from_dict so a caller can report the problem without passing an
exception's text back to a client.
"""
if not isinstance(data, dict):
return 'limits'
for name in _LIMIT_FIELDS:
value = data.get(name)
if value is None:
continue
# bool is an int subclass; True is not a limit anyone meant.
if (isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) or value < 0):
return name
return None
def limits_from_dict(data: Any) -> ResourceLimits:
"""Build ResourceLimits from a JSON-shaped mapping, validating each value.
A dataclass does not enforce its annotations, so ResourceLimits built from
raw request JSON or a cached record happily stores ``"50"`` -- and then
every monitored update() raises TypeError comparing a float with it. Each
``max_*`` value must be absent/None (no limit) or a non-negative number;
``warning_threshold`` defaults to 0.8. Unknown keys are ignored.
Raises:
ValueError: naming the first offending field.
"""
bad = invalid_limit_field(data)
if bad == 'limits':
raise ValueError(f"limits must be an object, got {type(data).__name__}")
if bad:
raise ValueError(
f"{bad} must be a non-negative number or null, got {data.get(bad)!r}")
return ResourceLimits(**{name: data[name] for name in _LIMIT_FIELDS
if data.get(name) is not None})
@dataclass
class ResourceMetrics:
"""Resource usage metrics for a plugin.
@@ -86,11 +134,12 @@ class PluginResourceMonitor:
"""
self.cache_manager = cache_manager
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
# Resource metrics per plugin
self._metrics: Dict[str, ResourceMetrics] = {}
self._limits: Dict[str, ResourceLimits] = {}
self._bad_limits_warned: set = set()
# When each plugin's metrics last reached the cache. Metrics change on
# every call, so they cannot be de-duplicated the way health state can;
# they are rate-limited instead. See _METRICS_PERSIST_INTERVAL.
@@ -160,7 +209,7 @@ class PluginResourceMonitor:
# (not \"int\") to str"). Coerce here, where there is still a cache
# key to name in the warning.
declared = {f.name: f.type for f in fields(ResourceMetrics)}
usable = {}
usable: Dict[str, Any] = {}
for key, value in cached.items():
if key not in known:
continue
@@ -230,7 +279,17 @@ class PluginResourceMonitor:
cache_key = self._get_limits_key(plugin_id)
cached = self.cache_manager.get(cache_key, max_age=None)
if cached:
self._limits[plugin_id] = ResourceLimits(**cached)
try:
self._limits[plugin_id] = limits_from_dict(cached)
except ValueError as e:
# Treat as no limits rather than letting every update
# of this plugin raise; warn once, not on every call.
if plugin_id not in self._bad_limits_warned:
self._bad_limits_warned.add(plugin_id)
self.logger.warning(
"Ignoring cached resource limits for %s: %s",
plugin_id, e)
return None
else:
return None
return self._limits[plugin_id]
@@ -240,7 +299,7 @@ class PluginResourceMonitor:
if not self.enable_monitoring or self._process is None:
return 0.0
try:
return self._process.memory_info().rss / 1024 / 1024
return cast(float, self._process.memory_info().rss / 1024 / 1024)
except Exception:
return 0.0
@@ -254,7 +313,7 @@ class PluginResourceMonitor:
if not self.enable_monitoring or self._process is None:
return 0.0
try:
return self._process.cpu_percent(interval=None)
return cast(float, self._process.cpu_percent(interval=None))
except Exception:
return 0.0
+4 -4
View File
@@ -5,11 +5,11 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
"""
import json
import logging
import os
from pathlib import Path
from typing import List, Dict, Optional
from typing import List, Dict, Optional, cast
from src.logging_config import get_logger
from src.plugin_system.repo_urls import normalize_repo_url
@@ -24,7 +24,7 @@ class SavedRepositoriesManager:
config_path: Path to JSON file storing saved repositories
"""
self.config_path = Path(config_path)
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
self.repositories = self._load_repositories()
def _load_repositories(self) -> List[Dict[str, str]]:
@@ -37,7 +37,7 @@ class SavedRepositoriesManager:
if isinstance(data, list):
return data
elif isinstance(data, dict) and 'repositories' in data:
return data['repositories']
return cast(List[Dict[str, str]], data['repositories'])
else:
return []
return []
+57 -3
View File
@@ -8,6 +8,7 @@ Provides utilities for extracting defaults, validating configurations, and manag
import copy
import json
import logging
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import jsonschema
@@ -15,6 +16,8 @@ from jsonschema import Draft7Validator, ValidationError
from src.core_config_keys import CORE_CONFIG_KEYS
from src.element_style import expand_style_elements
from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import resolve_plugin_dir
def _renders_as_object(prop: Dict[str, Any]) -> bool:
@@ -367,7 +370,7 @@ class SchemaManager:
device-wide ``location`` that seeds plugin location defaults.
Omitting it simply leaves schema defaults untouched.
"""
self.logger = logger or logging.getLogger(__name__)
self.logger = logger or get_logger(__name__)
self.plugins_dir = plugins_dir
self.project_root = project_root or Path.cwd()
self.config_manager = config_manager
@@ -378,14 +381,35 @@ class SchemaManager:
# Default config cache: plugin_id -> default config dict
self._defaults_cache: Dict[str, Dict[str, Any]] = {}
# Schema-path misses: plugin_id -> monotonic time of the miss. A
# lookup now scans each search directory's manifests, and plugins
# without a schema are asked about on every page render.
self._schema_path_misses: Dict[str, float] = {}
self._schema_miss_logged: set = set()
#: How long a "no schema" answer is reused before the directories are
#: searched again -- short, so a plugin installed by a path that doesn't
#: call invalidate_cache() (a dev symlink, a manual copy) still shows up.
SCHEMA_MISS_TTL = 30.0
def get_schema_path(self, plugin_id: str) -> Optional[Path]:
"""
Get the path to a plugin's config_schema.json file.
Tries multiple locations in order:
Each search directory -- plugins_dir, then PROJECT_ROOT/plugins, then
PROJECT_ROOT/plugin-repos -- is first resolved the way the plugin
loader resolves it (``plugin_dirs.resolve_plugin_dir``: the directory
whose manifest declares the id, else ``<id>`` / ``ledmatrix-<id>``,
case-insensitively). Only if none of those holds a schema are the
literal locations tried:
1. plugins_dir / plugin_id / config_schema.json
2. PROJECT_ROOT / plugins / plugin_id / config_schema.json
3. PROJECT_ROOT / plugin-repos / plugin_id / config_schema.json
4. a case-insensitive match of plugin_id in plugins/ and plugin-repos/
A miss is remembered for SCHEMA_MISS_TTL seconds (or until
invalidate_cache()) and logged once, at DEBUG: PluginManager already
warns at load time about a plugin that ships no schema.
Args:
plugin_id: Plugin identifier
@@ -393,7 +417,30 @@ class SchemaManager:
Returns:
Path to schema file or None if not found
"""
missed_at = self._schema_path_misses.get(plugin_id)
if missed_at is not None and time.monotonic() - missed_at < self.SCHEMA_MISS_TTL:
return None
search_dirs = []
if self.plugins_dir:
search_dirs.append(Path(self.plugins_dir))
search_dirs.extend([self.project_root / 'plugins',
self.project_root / 'plugin-repos'])
# Resolved the way the loader does, so a plugin installed as
# ``ledmatrix-<id>`` or under a directory named differently from its
# manifest id still gets its schema. One directory at a time keeps
# the documented plugins/-before-plugin-repos/ order.
possible_paths = []
for search_dir in search_dirs:
try:
resolved = resolve_plugin_dir(
plugin_id, [search_dir], prefix=True, case_insensitive=True)
except Exception as e: # pragma: no cover - defensive
self.logger.debug(f"Could not resolve {plugin_id} in {search_dir}: {e}")
resolved = None
if resolved is not None:
possible_paths.append(resolved / 'config_schema.json')
# Try plugins_dir if set
if self.plugins_dir:
@@ -416,9 +463,14 @@ class SchemaManager:
for path in possible_paths:
if path.exists():
self.logger.debug(f"Found schema for {plugin_id} at {path}")
self._schema_path_misses.pop(plugin_id, None)
self._schema_miss_logged.discard(plugin_id)
return path
self.logger.warning(f"Schema file not found for plugin {plugin_id}")
self._schema_path_misses[plugin_id] = time.monotonic()
if plugin_id not in self._schema_miss_logged:
self._schema_miss_logged.add(plugin_id)
self.logger.debug(f"Schema file not found for plugin {plugin_id}")
return None
def load_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
@@ -481,10 +533,12 @@ class SchemaManager:
if plugin_id:
self._schema_cache.pop(plugin_id, None)
self._defaults_cache.pop(plugin_id, None)
self._schema_path_misses.pop(plugin_id, None)
self.logger.debug(f"Invalidated cache for plugin {plugin_id}")
else:
self._schema_cache.clear()
self._defaults_cache.clear()
self._schema_path_misses.clear()
self.logger.debug("Invalidated all schema caches")
def extract_defaults_from_schema(self, schema: Dict[str, Any], prefix: str = '') -> Dict[str, Any]:
+14 -10
View File
@@ -13,6 +13,7 @@ from datetime import datetime
from dataclasses import dataclass, asdict
from enum import Enum
from src.config_manager_atomic import atomic_write_text
from src.logging_config import get_logger
@@ -283,6 +284,10 @@ class PluginStateManager:
return
try:
# The write stays under the lock and goes through a temp file:
# Flask serves requests on threads, and two saves racing on a
# plain open('w') could interleave or leave a truncated file
# that _load_state then drops wholesale.
with self._lock:
# Convert states to dicts
states_data = {
@@ -296,16 +301,15 @@ class PluginStateManager:
'last_updated': datetime.now().isoformat()
}
# Ensure directory exists with proper permissions
from src.common.permission_utils import (
ensure_directory_permissions,
get_config_dir_mode
)
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
# Ensure directory exists with proper permissions
from src.common.permission_utils import (
ensure_directory_permissions,
get_config_dir_mode
)
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
# Write to file
with open(self.state_file, 'w') as f:
json.dump(state_data, f, indent=2)
# Write to file
atomic_write_text(self.state_file, json.dumps(state_data, indent=2))
except Exception as e:
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
@@ -316,7 +320,7 @@ class PluginStateManager:
return
try:
with open(self.state_file, 'r') as f:
with open(self.state_file, 'r', encoding='utf-8') as f:
state_data = json.load(f)
with self._lock:
+5 -4
View File
@@ -9,7 +9,7 @@ Detects and fixes inconsistencies between:
"""
import json
from typing import Dict, Any, List, Set
from typing import Dict, Any, List, Set, cast
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
@@ -237,7 +237,7 @@ class StateReconciliation:
state_manager_state = self._get_state_manager_state()
# Find all unique plugin IDs
all_plugin_ids = set()
all_plugin_ids: Set[str] = set()
all_plugin_ids.update(config_state.keys())
all_plugin_ids.update(disk_state.keys())
all_plugin_ids.update(manager_state.keys())
@@ -380,7 +380,7 @@ class StateReconciliation:
state_manager_state: Dict[str, Dict[str, Any]]
) -> List[Inconsistency]:
"""Check consistency for a single plugin."""
inconsistencies = []
inconsistencies: List[Inconsistency] = []
if plugin_id in CORE_CONFIG_KEYS:
# A plugin whose id is a core setting's key ('display', 'sync',
@@ -496,7 +496,8 @@ class StateReconciliation:
# Bring the state manager in sync with config rather than the reverse,
# so that manual config edits (or the state left behind after an
# uninstall+reinstall cycle) don't silently override the user's intent.
config_enabled = inconsistency.expected_state.get('enabled')
# Always set for this type (see _check_plugin_consistency).
config_enabled = cast(bool, inconsistency.expected_state.get('enabled'))
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
if success:
self.logger.info(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+852
View File
@@ -0,0 +1,852 @@
"""Plugin store: the plugin registry, GitHub metadata, search and manifest
validation.
Part of PluginStoreManager (store_manager.py), which mixes this class in;
methods reach shared state and helpers through ``self``.
"""
import json
import requests
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any
from jsonschema import Draft7Validator, ValidationError
from src.plugin_system.repo_urls import (
github_api_headers, github_owner_repo, normalize_repo_url,
)
class _RegistryMixin:
"""PluginStoreManager methods: see the module docstring."""
def _load_github_token(self) -> Optional[str]:
"""
Load GitHub API token from config_secrets.json if available.
Returns:
GitHub token or None if not configured
"""
try:
config_path = Path(__file__).parent.parent.parent / "config" / "config_secrets.json"
if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
token = config.get('github', {}).get('api_token', '').strip()
# The config template's placeholder, not a credential.
if token and token != "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN": # nosec B105 # nosemgrep
return token
except Exception as e:
self.logger.debug(f"Could not load GitHub token: {e}")
return None
def _validate_github_token(self, token: str) -> tuple[bool, Optional[str]]:
"""
Validate a GitHub token by making a lightweight API call.
Args:
token: GitHub personal access token to validate
Returns:
Tuple of (is_valid, error_message)
- is_valid: True if token is valid, False otherwise
- error_message: None if valid, error description if invalid
"""
if not token:
return (False, "No token provided")
# Check cache first
cache_key = token[:10] # Use first 10 chars as cache key for privacy
if cache_key in self._token_validation_cache:
cached_valid, cached_time, cached_error = self._token_validation_cache[cache_key]
if time.time() - cached_time < self._token_validation_cache_timeout:
return (cached_valid, cached_error)
# Validate token by making a lightweight API call to /user endpoint
try:
api_url = "https://api.github.com/user"
response = requests.get(api_url, headers=github_api_headers(token), timeout=5)
if response.status_code == 200:
# Token is valid
result = (True, None)
self._token_validation_cache[cache_key] = (True, time.time(), None)
return result
elif response.status_code == 401:
# Token is invalid or expired
error_msg = "Token is invalid or expired"
result = (False, error_msg)
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
return result
elif response.status_code == 403:
# Rate limit or forbidden (but token might be valid)
# Check if it's a rate limit issue
if 'rate limit' in response.text.lower():
# Rate limit: return error but don't cache (rate limits are temporary)
error_msg = "Rate limit exceeded"
result = (False, error_msg)
return result
else:
# Token lacks permissions: cache the result (permissions don't change)
error_msg = "Token lacks required permissions"
result = (False, error_msg)
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
return result
else:
# Other error
error_msg = f"GitHub API error: {response.status_code}"
result = (False, error_msg)
self._token_validation_cache[cache_key] = (False, time.time(), error_msg)
return result
except requests.exceptions.Timeout:
error_msg = "GitHub API request timed out"
result = (False, error_msg)
# Don't cache timeout errors
return result
except requests.exceptions.RequestException as e:
error_msg = f"Network error: {str(e)}"
result = (False, error_msg)
# Don't cache network errors
return result
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
result = (False, error_msg)
# Don't cache unexpected errors
return result
@staticmethod
def _iso_to_date(iso_timestamp: str) -> str:
"""Convert an ISO timestamp to YYYY-MM-DD string."""
if not iso_timestamp:
return ""
try:
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
return dt.strftime('%Y-%m-%d')
except Exception:
return ""
@staticmethod
def _distinct_sequence(values: List[str]) -> List[str]:
"""Return list preserving order while removing duplicates and falsey entries."""
seen = set()
ordered = []
for value in values:
if not value:
continue
if value in seen:
continue
seen.add(value)
ordered.append(value)
return ordered
def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]:
"""
Validate version-related fields in manifest for consistency.
Checks:
- compatible_versions is present and is an array
- Standardized field names are used (min_ledmatrix_version, max_ledmatrix_version)
- Deprecated fields are not used (ledmatrix_version)
- versions array entries use ledmatrix_min_version instead of ledmatrix_min
Args:
manifest: Manifest dictionary to validate
Returns:
List of validation error/warning messages (empty if valid)
"""
errors = []
# Check compatible_versions is an array
if 'compatible_versions' in manifest:
if not isinstance(manifest['compatible_versions'], list):
errors.append("compatible_versions must be an array")
elif len(manifest['compatible_versions']) == 0:
errors.append("compatible_versions array cannot be empty")
# Warn about deprecated ledmatrix_version field
if 'ledmatrix_version' in manifest:
errors.append("ledmatrix_version is deprecated, use compatible_versions instead")
# Check versions array entries use standardized field names
if 'versions' in manifest and isinstance(manifest['versions'], list):
for i, version_entry in enumerate(manifest['versions']):
if not isinstance(version_entry, dict):
continue
# Check for old ledmatrix_min field
if 'ledmatrix_min' in version_entry and 'ledmatrix_min_version' not in version_entry:
errors.append(f"versions[{i}] uses deprecated 'ledmatrix_min', should use 'ledmatrix_min_version'")
return errors
def _validate_manifest_schema(self, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
"""
Validate manifest against JSON schema if available.
Args:
manifest: Manifest dictionary to validate
plugin_id: Plugin ID for error messages
Returns:
List of validation error messages (empty if valid or schema unavailable)
"""
try:
# Load manifest schema
schema_path = Path(__file__).parent.parent.parent / "schema" / "manifest_schema.json"
if not schema_path.exists():
return [] # Schema not available, skip validation
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
# Validate schema itself
Draft7Validator.check_schema(schema)
# Validate manifest against schema
validator = Draft7Validator(schema)
errors = []
for error in validator.iter_errors(manifest):
error_path = '.'.join(str(p) for p in error.path)
errors.append(f"{error_path}: {error.message}")
return errors
except json.JSONDecodeError as e:
self.logger.warning(f"Could not parse manifest schema: {e}")
return []
except ValidationError as e:
self.logger.warning(f"Manifest schema is invalid: {e}")
return []
except Exception as e:
self.logger.debug(f"Error validating manifest schema for {plugin_id}: {e}")
return []
_EMPTY_REPO_INFO: Dict[str, Any] = {
'stars': 0,
'forks': 0,
'open_issues': 0,
'updated_at_iso': '',
'last_commit_iso': '',
'last_commit_date': '',
'language': '',
'license': '',
'default_branch': 'main',
}
def _get_github_repo_info(self, repo_url: str) -> Dict[str, Any]:
"""GitHub metadata for a repository (stars, default branch, last push).
Returns zeroed defaults (``_EMPTY_REPO_INFO``) for a non-GitHub URL or
when GitHub cannot be asked and nothing is cached.
"""
try:
owner_repo = github_owner_repo(repo_url)
if owner_repo is None:
return dict(self._EMPTY_REPO_INFO)
owner, repo = owner_repo
cache_key = f"{owner}/{repo}"
if cache_key in self.github_cache:
cached_time, cached_data = self.github_cache[cache_key]
if time.time() - cached_time < self.cache_timeout:
return cached_data
api_url = f"https://api.github.com/repos/{owner}/{repo}"
try:
response = requests.get(
api_url, headers=github_api_headers(self.github_token), timeout=10)
except requests.RequestException as req_err:
# Network error: prefer a stale cache hit over an empty
# default so the UI keeps working on a flaky Pi WiFi link.
# Bump the cached entry's timestamp into a short backoff
# window so subsequent requests serve the stale payload
# cheaply instead of re-hitting the network on every request.
if cache_key in self.github_cache:
_, stale = self.github_cache[cache_key]
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
self.logger.warning(
"GitHub repo info fetch failed for %s (%s); serving stale cache.",
cache_key, req_err,
)
return stale
raise
if response.status_code == 200:
data = response.json()
pushed_at = data.get('pushed_at', '') or data.get('updated_at', '')
repo_info = {
'stars': data.get('stargazers_count', 0),
'forks': data.get('forks_count', 0),
'open_issues': data.get('open_issues_count', 0),
'updated_at_iso': data.get('updated_at', ''),
'last_commit_iso': pushed_at,
'last_commit_date': self._iso_to_date(pushed_at),
'language': data.get('language', ''),
'license': data.get('license', {}).get('name', '') if data.get('license') else '',
'default_branch': data.get('default_branch', 'main')
}
self.github_cache[cache_key] = (time.time(), repo_info)
return repo_info
if response.status_code == 403:
# Rate limit or authentication issue. A stale star count is
# better than a reset to zero, and the backoff bump stops the
# store hammering the API while rate-limited.
if cache_key in self.github_cache:
_, stale = self.github_cache[cache_key]
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
self.logger.warning(
"GitHub API 403 for %s; serving stale cache.", cache_key,
)
return stale
if not self.github_token:
self.logger.warning(
"GitHub API rate limit likely exceeded (403). "
"Add a GitHub personal access token to config/config_secrets.json "
"under 'github.api_token' to increase rate limits from 60 to 5000/hour."
)
else:
self.logger.warning(
f"GitHub API request failed: 403 for {api_url}. "
f"Your token may have insufficient permissions or rate limit exceeded."
)
else:
self.logger.warning(f"GitHub API request failed: {response.status_code} for {api_url}")
if cache_key in self.github_cache:
_, stale = self.github_cache[cache_key]
self._record_cache_backoff(self.github_cache, cache_key, self.cache_timeout, stale)
return stale
return dict(self._EMPTY_REPO_INFO)
except requests.exceptions.RequestException as e:
# Offline, DNS or a timeout reaching GitHub: the listing still
# works without the extra repo info, so this is not an error.
self.logger.warning("GitHub repo info unavailable for %s: %s", repo_url, e)
return dict(self._EMPTY_REPO_INFO)
except Exception as e:
self.logger.error(f"Error fetching GitHub repo info for {repo_url}: {e}")
return dict(self._EMPTY_REPO_INFO)
def _http_get_with_retries(self, url: str, *, timeout: int = 10, stream: bool = False, headers: Dict[str, str] = None, max_retries: int = 3, backoff_sec: float = 0.75):
"""
HTTP GET with simple retry strategy and exponential backoff.
Returns a requests.Response or raises the last exception.
"""
last_exc = None
for attempt in range(1, max_retries + 1):
try:
resp = requests.get(url, timeout=timeout, stream=stream, headers=headers)
return resp
except requests.RequestException as e:
last_exc = e
self.logger.warning(f"HTTP GET failed (attempt {attempt}/{max_retries}) for {url}: {e}")
if attempt < max_retries:
time.sleep(backoff_sec * attempt)
# Exhausted retries
raise last_exc
def fetch_registry_from_url(self, repo_url: str) -> Optional[Dict]:
"""
Fetch a registry-style plugins.json from a custom GitHub repository URL.
This allows users to point to a registry-style monorepo (like the official
ledmatrix-plugins repo) and browse/install plugins from it.
Args:
repo_url: GitHub repository URL (e.g., https://github.com/user/ledmatrix-plugins)
Returns:
Registry dict with plugins list, or None if not found/invalid
"""
try:
repo_url = normalize_repo_url(repo_url)
# plugins.json or registry.json at the root of main, then master.
registry_urls = []
owner_repo = github_owner_repo(repo_url)
if owner_repo is not None:
owner, repo = owner_repo
for branch in ['main', 'master']:
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/plugins.json")
registry_urls.append(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/registry.json")
for url in registry_urls:
try:
response = self._http_get_with_retries(url, timeout=10)
if response.status_code == 200:
registry = response.json()
# Validate it looks like a registry
if isinstance(registry, dict) and 'plugins' in registry:
self.logger.info(f"Successfully fetched registry from {url}")
return registry
except Exception as e:
self.logger.debug(f"Failed to fetch from {url}: {e}")
continue
self.logger.warning(f"No valid registry found at {repo_url}")
return None
except Exception as e:
self.logger.error(f"Error fetching registry from URL: {e}", exc_info=True)
return None
def fetch_registry(self, force_refresh: bool = False, raise_on_failure: bool = False) -> Dict:
"""
Fetch the plugin registry from GitHub.
Args:
force_refresh: Force refresh even if cached
raise_on_failure: If True, re-raise network / JSON errors instead
of silently falling back to stale cache / empty dict. UI
callers prefer the stale-fallback default so the plugin
list keeps working on flaky WiFi; the state reconciler
needs the explicit failure signal so it can distinguish
"plugin genuinely not in registry" from "I couldn't reach
the registry at all" and not mark everything unrecoverable.
Returns:
Registry data with list of available plugins
Raises:
requests.RequestException / json.JSONDecodeError when
``raise_on_failure`` is True and the fetch fails.
"""
# Check if cache is still valid (within timeout)
current_time = time.time()
if (self.registry_cache and self.registry_cache_time and
not force_refresh and
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
with self._registry_fetch_lock:
# Re-check inside the lock — a concurrent caller that was waiting
# may have already populated the cache while we blocked.
current_time = time.time()
if (self.registry_cache and self.registry_cache_time and
not force_refresh and
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
return self.registry_cache
try:
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
response.raise_for_status()
self.registry_cache = response.json()
self.registry_cache_time = current_time
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
return self.registry_cache
except requests.RequestException as e:
self.logger.error(f"Error fetching registry: {e}")
if raise_on_failure:
raise
# Prefer stale cache over an empty list so the plugin list UI
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
# registry_cache_time into a short backoff window so the next
# request serves the stale payload cheaply instead of
# re-hitting the network on every request (matches the
# pattern used by github_cache / commit_info_cache).
if self.registry_cache:
self.logger.warning("Falling back to stale registry cache")
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
except json.JSONDecodeError as e:
self.logger.error(f"Error parsing registry JSON: {e}")
if raise_on_failure:
raise
if self.registry_cache:
self.registry_cache_time = (
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
)
return self.registry_cache
return {"plugins": []}
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
"""
Search for plugins in the registry with enhanced metadata.
GitHub supplies live metadata such as stars and last commit
timestamps; the registry supplies descriptive information (name,
description, repo URL, etc.).
Args:
query: Search query string (searches name, description, id, author)
category: Filter by category (e.g., 'sports', 'weather', 'time')
tags: Filter by tags (matches any tag in list)
fetch_commit_info: If True (default), fetch commit metadata from GitHub.
include_saved_repos: If True (default), also search the
registry-style repositories the user saved.
saved_repositories_manager: The SavedRepositoriesManager holding
those repositories; without it only the official registry is
searched.
Returns:
List of matching plugin metadata enriched with GitHub information
"""
if tags is None:
tags = []
# Fetch from official registry
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
# Also fetch from saved repositories if enabled
if include_saved_repos and saved_repositories_manager:
saved_repos = saved_repositories_manager.get_registry_repositories()
for repo_info in saved_repos:
repo_url = repo_info.get('url')
if repo_url:
try:
custom_registry = self.fetch_registry_from_url(repo_url)
if custom_registry:
custom_plugins = custom_registry.get('plugins', []) or []
# Mark these as from custom repository
for plugin in custom_plugins:
plugin['_source'] = 'custom_repository'
plugin['_repository_url'] = repo_url
plugin['_repository_name'] = repo_info.get('name', repo_url)
plugins.extend(custom_plugins)
except Exception as e:
self.logger.warning(f"Failed to fetch plugins from saved repository {repo_url}: {e}")
# First pass: apply cheap filters (category/tags/query) so we only
# fetch GitHub metadata for plugins that will actually be returned.
filtered: List[Dict] = []
for plugin in plugins:
if category and plugin.get('category') != category:
continue
if tags and not any(tag in plugin.get('tags', []) for tag in tags):
continue
if query:
query_lower = query.lower()
searchable_text = ' '.join([
plugin.get('name', ''),
plugin.get('description', ''),
plugin.get('id', ''),
plugin.get('author', ''),
]).lower()
if query_lower not in searchable_text:
continue
filtered.append(plugin)
def _enrich(plugin: Dict) -> Dict:
"""Enrich a single plugin with GitHub metadata.
Called concurrently from a ThreadPoolExecutor. Both HTTP helpers
(``_get_github_repo_info`` / ``_get_latest_commit_info``) are
thread-safe -- they use ``requests`` and write their own cache
keys on Python dicts, which is atomic under the GIL for
single-key assignments.
"""
enhanced_plugin = plugin.copy()
repo_url = plugin.get('repo', '')
if not repo_url:
return enhanced_plugin
github_info = self._get_github_repo_info(repo_url)
enhanced_plugin['stars'] = github_info.get('stars', plugin.get('stars', 0))
enhanced_plugin['default_branch'] = github_info.get('default_branch', plugin.get('branch', 'main'))
enhanced_plugin['last_updated_iso'] = github_info.get('last_commit_iso')
enhanced_plugin['last_updated'] = github_info.get('last_commit_date')
if fetch_commit_info:
branch = plugin.get('branch') or github_info.get('default_branch', 'main')
commit_info = self._get_latest_commit_info(repo_url, branch)
if commit_info:
enhanced_plugin['last_commit'] = commit_info.get('short_sha')
enhanced_plugin['last_commit_sha'] = commit_info.get('sha')
enhanced_plugin['last_updated'] = commit_info.get('date') or enhanced_plugin.get('last_updated')
enhanced_plugin['last_updated_iso'] = commit_info.get('date_iso') or enhanced_plugin.get('last_updated_iso')
enhanced_plugin['last_commit_message'] = commit_info.get('message')
enhanced_plugin['last_commit_author'] = commit_info.get('author')
enhanced_plugin['branch'] = commit_info.get('branch', branch)
enhanced_plugin['last_commit_branch'] = commit_info.get('branch')
# Intentionally NO per-plugin manifest.json fetch here.
# The registry's plugins.json already carries ``description``
# (it is generated from each plugin's manifest by
# ``update_registry.py``), and ``last_updated`` is filled in
# from the commit info above. Fetching manifest.json per
# plugin costs one extra HTTPS round trip per result; on a Pi4
# with a flaky WiFi link the tail retries of that one call
# (_http_get_with_retries does 3 attempts with exponential
# backoff) dominate wall time even with the thread pool.
return enhanced_plugin
# Fan out the per-plugin GitHub enrichment. Serially, a Pi4 with ~15
# plugins and a cold cache makes 30+ HTTP requests in strict sequence
# (the "connecting to display" hang users reported). With a thread
# pool, latency is dominated by the slowest request rather than
# their sum. Workers capped at 10 to stay well under the
# unauthenticated GitHub rate limit burst and avoid overwhelming a
# Pi's WiFi link.
if not filtered:
return []
# Not worth the pool overhead for tiny workloads. Parenthesized to
# make Python's default ``and`` > ``or`` precedence explicit: a
# single plugin, OR a small batch where we don't need commit info.
if (len(filtered) == 1) or ((not fetch_commit_info) and (len(filtered) < 4)):
return [_enrich(p) for p in filtered]
max_workers = min(10, len(filtered))
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='plugin-search') as executor:
# executor.map preserves input order, which the UI relies on.
return list(executor.map(_enrich, filtered))
def _fetch_manifest_from_github(self, repo_url: str, branch: str = "master", manifest_path: str = "manifest.json", force_refresh: bool = False) -> Optional[Dict]:
"""
Fetch manifest.json directly from a GitHub repository.
Args:
repo_url: GitHub repository URL
branch: Branch name (default: master)
manifest_path: Path to manifest within the repo (default: manifest.json).
For monorepo plugins this will be e.g. "plugins/football-scoreboard/manifest.json".
force_refresh: If True, bypass the cache.
Returns:
Manifest data or None if not found
"""
try:
owner_repo = github_owner_repo(repo_url)
if owner_repo is None:
return None
owner, repo = owner_repo
cache_key = f"{owner}/{repo}:{branch}:{manifest_path}"
if not force_refresh and cache_key in self.manifest_cache:
cached_time, cached_data = self.manifest_cache[cache_key]
if time.time() - cached_time < self.manifest_cache_timeout:
return cached_data
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200:
result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
if response.status_code == 404 and branch != "main":
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/{manifest_path}"
response = self._http_get_with_retries(raw_url, timeout=10)
if response.status_code == 200:
result = response.json()
self.manifest_cache[cache_key] = (time.time(), result)
return result
# Cache the miss too, so a plugin without a manifest at this path
# is not re-fetched on every browse.
self.manifest_cache[cache_key] = (time.time(), None)
except Exception as e:
self.logger.debug(f"Could not fetch manifest from GitHub for {repo_url}: {e}")
return None
def _get_latest_commit_info(self, repo_url: str, branch: str = "main", force_refresh: bool = False) -> Optional[Dict[str, Any]]:
"""Return metadata about the latest commit on the given branch."""
try:
owner_repo = github_owner_repo(repo_url)
if owner_repo is None:
return None
owner, repo = owner_repo
cache_key = f"{owner}/{repo}:{branch}"
if not force_refresh and cache_key in self.commit_info_cache:
cached_time, cached_data = self.commit_info_cache[cache_key]
if time.time() - cached_time < self.commit_cache_timeout:
return cached_data
branches_to_try = self._distinct_sequence([branch, 'main', 'master'])
headers = github_api_headers(self.github_token)
last_error = None
for branch_name in branches_to_try:
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{branch_name}"
try:
response = requests.get(api_url, headers=headers, timeout=10)
except requests.RequestException as req_err:
# Network failure: fall back to a stale cache hit if
# available so the plugin store UI keeps populating
# commit info on a flaky WiFi link. Bump the cached
# timestamp into the backoff window so we don't
# re-retry on every request.
if cache_key in self.commit_info_cache:
_, stale = self.commit_info_cache[cache_key]
if stale is not None:
self._record_cache_backoff(
self.commit_info_cache, cache_key,
self.commit_cache_timeout, stale,
)
self.logger.warning(
"GitHub commit fetch failed for %s (%s); serving stale cache.",
cache_key, req_err,
)
return stale
last_error = str(req_err)
continue
if response.status_code == 200:
commit_data = response.json()
commit_sha_full = commit_data.get('sha', '')
commit_sha_short = commit_sha_full[:7] if commit_sha_full else ''
commit_meta = commit_data.get('commit', {})
commit_author = commit_meta.get('author', {})
commit_date_iso = commit_author.get('date', '')
result = {
'branch': branch_name,
'sha': commit_sha_full,
'short_sha': commit_sha_short,
'date_iso': commit_date_iso,
'date': self._iso_to_date(commit_date_iso),
'author': commit_author.get('name', ''),
'message': commit_meta.get('message', ''),
}
self.commit_info_cache[cache_key] = (time.time(), result)
return result
if response.status_code == 403 and not self.github_token:
self.logger.debug("GitHub commit API rate limited (403). Consider adding a token.")
last_error = response.text
else:
last_error = response.text
if last_error:
self.logger.debug(f"Unable to fetch commit info for {repo_url}: {last_error}")
# All branches returned a non-200 response (e.g. 404 on every
# candidate, or a transient 5xx). If we already had a good
# cached value, prefer serving that — overwriting it with
# None here would wipe out commit info the UI just showed
# on the previous request. Bump the timestamp into the
# backoff window so subsequent lookups hit the cache.
if cache_key in self.commit_info_cache:
_, prior = self.commit_info_cache[cache_key]
if prior is not None:
self._record_cache_backoff(
self.commit_info_cache, cache_key,
self.commit_cache_timeout, prior,
)
return prior
# No prior good value — cache the negative result so we don't
# hammer a plugin that genuinely has no reachable commits.
self.commit_info_cache[cache_key] = (time.time(), None)
except Exception as e:
self.logger.debug(f"Error fetching latest commit metadata for {repo_url}: {e}")
return None
def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:
"""
Get detailed information about a plugin from the registry.
GitHub provides authoritative metadata such as stars and the latest
commit. The registry supplies descriptive information (name, id, repo URL).
Args:
plugin_id: Plugin identifier
fetch_latest_from_github: If True (default), augment with GitHub commit metadata.
force_refresh: If True, bypass caches for commit/manifest data.
Returns:
Plugin metadata or None if not found
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
plugin_info = self._match_registry_entry(plugins, plugin_id)
if not plugin_info:
return None
if fetch_latest_from_github:
repo_url = plugin_info.get('repo')
if repo_url:
plugin_info = plugin_info.copy()
github_info = self._get_github_repo_info(repo_url)
branch = plugin_info.get('branch') or github_info.get('default_branch', 'main')
plugin_info['default_branch'] = github_info.get('default_branch', branch)
plugin_info['stars'] = github_info.get('stars', plugin_info.get('stars', 0))
plugin_info['last_updated'] = github_info.get('last_commit_date', plugin_info.get('last_updated'))
plugin_info['last_updated_iso'] = github_info.get('last_commit_iso', plugin_info.get('last_updated_iso'))
commit_info = self._get_latest_commit_info(repo_url, branch, force_refresh=force_refresh)
if commit_info:
plugin_info['last_commit'] = commit_info.get('short_sha')
plugin_info['last_commit_sha'] = commit_info.get('sha')
plugin_info['last_commit_message'] = commit_info.get('message')
plugin_info['last_commit_author'] = commit_info.get('author')
plugin_info['last_updated'] = commit_info.get('date') or plugin_info.get('last_updated')
plugin_info['last_updated_iso'] = commit_info.get('date_iso') or plugin_info.get('last_updated_iso')
plugin_info['branch'] = commit_info.get('branch', branch)
plugin_info['last_commit_branch'] = commit_info.get('branch')
plugin_subpath = plugin_info.get('plugin_path', '')
manifest_rel = f"{plugin_subpath}/manifest.json" if plugin_subpath else "manifest.json"
github_manifest = self._fetch_manifest_from_github(repo_url, branch, manifest_rel, force_refresh=force_refresh)
if github_manifest:
if 'last_updated' in github_manifest and not plugin_info.get('last_updated'):
plugin_info['last_updated'] = github_manifest['last_updated']
if 'description' in github_manifest:
plugin_info['description'] = github_manifest['description']
return plugin_info
@staticmethod
def _match_registry_entry(plugins: List[Dict], plugin_id: str) -> Optional[Dict]:
"""Find a registry entry by its id, or by the directory it installs to.
Four shipped plugins have a registry ``id`` that differs from the ``id``
in their own manifest: ``weather`` installs to ``plugins/ledmatrix-weather``,
and likewise stocks, music and leaderboard. Installation already prefers
the manifest id for the directory name, so on disk, in ``config.json``
and in a backup manifest those plugins are called ``ledmatrix-weather``.
Only the registry calls them ``weather``, and nothing resolved that in
reverse: restoring a backup asked the store for ``ledmatrix-weather``
and got "Plugin not found in registry", silently dropping four enabled
plugins from a restored device.
Matching ``plugin_path`` fixes it without renaming any published id,
which would orphan ``plugin_state.json`` entries keyed on the old ones.
Exact id always wins, so an entry whose *path* happens to collide with
another entry's id cannot shadow it.
"""
if not plugin_id:
return None
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
if exact is not None:
return exact
for entry in plugins:
path = (entry.get('plugin_path') or '').rstrip('/')
if path and path.rsplit('/', 1)[-1] == plugin_id:
return entry
return None
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry cache only (no GitHub API calls).
Use this for lightweight lookups where only registry fields are needed
(e.g., verified status, latest_version).
Args:
plugin_id: Plugin identifier
Returns:
Plugin metadata from registry or None if not found
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
return self._match_registry_entry(plugins, plugin_id)
+732
View File
@@ -0,0 +1,732 @@
"""Plugin store: updating installed plugins, with rollback, and reading their
local git state.
Part of PluginStoreManager (store_manager.py), which mixes this class in;
methods reach shared state and helpers through ``self``.
"""
import json
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
from pathlib import Path
from typing import Dict, Optional, Tuple
from src.plugin_system.plugin_dirs import BACKUP_MARKER
from src.plugin_system.repo_urls import same_repo
class _UpdateMixin:
"""PluginStoreManager methods: see the module docstring."""
def _git_cache_signature(self, git_dir: Path) -> Optional[Tuple]:
"""Build a cache signature that invalidates on the kind of updates
a plugin user actually cares about.
Caching on ``.git/HEAD`` mtime alone is not enough: a ``git pull``
that fast-forwards the current branch updates
``.git/refs/heads/<branch>`` (or ``.git/packed-refs``) but leaves
HEAD's contents and mtime untouched. And the cached ``result``
dict includes ``remote_url`` — a value read from ``.git/config`` —
so a config-only change (e.g. a monorepo-migration re-pointing
``remote.origin.url``) must also invalidate the cache.
Signature components:
- HEAD contents (catches detach / branch switch)
- HEAD mtime
- if HEAD points at a ref, that ref file's mtime (catches
fast-forward / reset on the current branch)
- packed-refs mtime as a coarse fallback for repos using packed refs
- .git/config contents + mtime (catches remote URL changes and
any other config-only edit that affects what the cached
``remote_url`` field should contain)
Returns ``None`` if HEAD cannot be read at all (caller will skip
the cache and take the slow path).
"""
head_file = git_dir / 'HEAD'
try:
head_mtime = head_file.stat().st_mtime
head_contents = head_file.read_text(encoding='utf-8', errors='replace').strip()
except OSError:
return None
ref_mtime = None
if head_contents.startswith('ref: '):
ref_path = head_contents[len('ref: '):].strip()
# ``ref_path`` looks like ``refs/heads/main``. It lives either
# as a loose file under .git/ or inside .git/packed-refs.
loose_ref = git_dir / ref_path
try:
ref_mtime = loose_ref.stat().st_mtime
except OSError:
ref_mtime = None
packed_refs_mtime = None
if ref_mtime is None:
try:
packed_refs_mtime = (git_dir / 'packed-refs').stat().st_mtime
except OSError:
packed_refs_mtime = None
config_mtime = None
config_contents = None
config_file = git_dir / 'config'
try:
config_mtime = config_file.stat().st_mtime
config_contents = config_file.read_text(encoding='utf-8', errors='replace').strip()
except OSError:
config_mtime = None
config_contents = None
return (
head_contents, head_mtime,
ref_mtime, packed_refs_mtime,
config_contents, config_mtime,
)
def _get_local_git_info(self, plugin_path: Path) -> Optional[Dict[str, str]]:
"""Return local git branch, commit hash, and commit date if the plugin is a git checkout.
Results are cached keyed on a signature that includes HEAD
contents plus the mtime of HEAD AND the resolved ref (or
packed-refs). Repeated calls skip the ``git log`` subprocess when
nothing has changed, and a ``git pull`` that fast-forwards the
branch correctly invalidates the cache.
"""
git_dir = plugin_path / '.git'
if not git_dir.exists():
return None
cache_key = str(plugin_path)
signature = self._git_cache_signature(git_dir)
if signature is not None:
cached = self._git_info_cache.get(cache_key)
if cached is not None and cached[0] == signature:
return cached[1]
try:
# .git may be a file (worktree / submodule) containing "gitdir: <path>".
# Resolve it to the actual git directory before reading any files.
try:
if git_dir.is_file():
pointer = git_dir.read_text(encoding='utf-8', errors='replace').strip()
if pointer.startswith('gitdir:'):
resolved = (plugin_path / pointer[len('gitdir:'):].strip()).resolve()
if resolved.is_dir():
git_dir = resolved
else:
return None
else:
return None
except (OSError, NotADirectoryError):
return None
# Read branch directly from .git/HEAD (no subprocess).
branch = ''
try:
head_text = (git_dir / 'HEAD').read_text(encoding='utf-8', errors='replace').strip()
if head_text.startswith('ref: refs/heads/'):
branch = head_text[len('ref: refs/heads/'):]
elif head_text.startswith('ref: '):
branch = head_text[len('ref: '):]
# else: detached HEAD — branch stays ''
except (OSError, NotADirectoryError):
pass
# Remote URL from .git/config — parse [remote "origin"] url line.
remote_url = None
try:
config_text = (git_dir / 'config').read_text(encoding='utf-8', errors='replace')
in_origin = False
for line in config_text.splitlines():
stripped = line.strip()
if stripped == '[remote "origin"]':
in_origin = True
elif stripped.startswith('['):
in_origin = False
elif in_origin and stripped.startswith('url') and '=' in stripped:
remote_url = stripped.split('=', 1)[1].strip()
break
except (OSError, NotADirectoryError):
pass
# Single subprocess: SHA + commit date in one call.
log_result = subprocess.run(
['git', '-C', str(plugin_path), 'log', '-1', '--format=%H%n%cI', 'HEAD'],
capture_output=True,
text=True,
timeout=10,
check=True
)
lines = log_result.stdout.strip().splitlines()
sha = lines[0] if lines else ''
commit_date_iso = lines[1] if len(lines) > 1 else ''
result = {
'sha': sha,
'short_sha': sha[:7] if sha else '',
'branch': branch,
}
if remote_url:
result['remote_url'] = remote_url
if commit_date_iso:
result['date_iso'] = commit_date_iso
result['date'] = self._iso_to_date(commit_date_iso)
if signature is not None:
self._git_info_cache[cache_key] = (signature, result)
return result
except subprocess.CalledProcessError as err:
self.logger.debug(f"Failed to read git info for {plugin_path.name}: {err}")
except subprocess.TimeoutExpired:
self.logger.debug(f"Timed out reading git info for {plugin_path.name}")
return None
def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path,
previous_sha: Optional[str]) -> bool:
"""Apply the compatibility gate to a commit that arrived via git pull.
Every other route into an installed plugin goes through
``install_plugin``, which gates in ``_install_plugin_impl``. This one
did not: a ``git pull`` could deliver a manifest flooring above this
core and nothing would notice until the plugin failed to load, which
surfaces as one line in the journal and a scoreboard that silently
stopped appearing.
Checked after the pull rather than before it, for the same reason
``_install_plugin_impl`` checks after the download: the registry
carries no compatibility field, so the incoming floor is only knowable
once the new commit is on disk.
Undone with ``git reset --hard`` rather than by removing the directory.
This is a live checkout, the previous commit is still in the object
store, and the reset leaves the user on the exact version they were
already running -- the same promise ``_reinstall_with_rollback`` makes,
reached by the means this path actually has. It is also the gentler
option: no window in which the plugin directory does not exist, and no
``.standalone-backup-`` debris if the process dies mid-way.
A manifest that cannot be read is not evidence of incompatibility, so
it allows. ``compatibility.check`` refuses only on evidence for the
same reason: a wrong refusal breaks a working install, while a wrong
allowance degrades to exactly the behaviour this path had before the
gate existed.
"""
manifest_path = plugin_path / "manifest.json"
try:
with open(manifest_path, 'r', encoding='utf-8') as mf:
manifest = json.load(mf)
except (OSError, ValueError) as e:
self.logger.warning(
"Could not read %s after updating %s (%s); allowing the "
"update, as an unreadable manifest declares no floor",
manifest_path, plugin_id, e)
return True
from src.plugin_system import compatibility
core_version = compatibility.current_core_version()
compatible, reason = compatibility.check(manifest, core_version)
if compatible:
return True
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
if not previous_sha:
self.logger.error(
"Cannot roll %s back: the commit it was on before the pull is "
"unknown. It is now on a version this core cannot run — "
"reinstall it from the plugin store.", plugin_id)
return False
# Safe by construction: update_plugin returns before pulling unless the
# tree was clean or successfully stashed, so there are no uncommitted
# tracked edits for --hard to discard. The stash is not popped on the
# success path either, so the reset leaves the working tree exactly
# where a successful pull would have. Say "commit", not "changes".
reset = subprocess.run(
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
capture_output=True, text=True, timeout=60, check=False)
if reset.returncode != 0:
self.logger.error(
"CRITICAL: could not roll %s back to commit %s: %s. It is left "
"on a version this core cannot run; "
"`git -C %s reset --hard %s` restores it.",
plugin_id, previous_sha[:7],
(reset.stderr or reset.stdout or '').strip(),
plugin_path, previous_sha)
else:
self.logger.info(
"Rolled %s back to commit %s; it stays on the version it was "
"already running.", plugin_id, previous_sha[:7])
return False
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
"""Replace an installed plugin with a fresh install, atomically.
The old install is renamed aside (not deleted) until the new install
succeeds, then removed; on ANY install failure the old directory is
restored. Deleting first turns a failed download into a destroyed
plugin: during the monorepo migration a Pi with broken DNS lost every
old-remote plugin that way, with none able to be re-downloaded.
The aside name embeds BACKUP_MARKER ('.standalone-backup-') so every
plugin directory lookup (src/plugin_system/plugin_dirs.py) ignores it
even though it still contains a manifest.json.
Held for the whole operation under a per-plugin_id lock: two
overlapping requests for the same plugin (double-click, two
browser tabs — the web UI runs Flask with threaded=True) must not
interleave their renames, or the second could steal the first's
rollback safety net mid-install. Other plugin_ids are unaffected.
"""
with self._get_reinstall_lock(plugin_id):
backup_path = plugin_path.with_name(
f"{plugin_path.name}{BACKUP_MARKER}migrating")
problem = self._set_aside(plugin_path, backup_path)
if problem:
self.logger.error(
"Not updating %s: %s; the installed version is left in place",
plugin_id, problem)
return False
try:
installed = self.install_plugin(plugin_id)
except Exception as e:
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
installed = False
if installed:
self._discard_backup(plugin_id, backup_path, "update")
return True
# Bad network, registry error...: the user keeps a working plugin.
self._restore_backup(plugin_id, plugin_path, backup_path, "Reinstall")
return False
def update_plugin(self, plugin_id: str) -> bool:
"""
Update a plugin to the latest commit on its upstream branch.
"""
plugin_path = self._find_plugin_path(plugin_id)
if plugin_path is None or not plugin_path.exists():
self.logger.error(f"Plugin not installed: {plugin_id}")
return False
try:
self.logger.info(f"Checking for updates to plugin {plugin_id}")
# Check if this is a bundled/unmanaged plugin (no registry entry, no git remote)
# These are plugins shipped with LEDMatrix itself and updated via LEDMatrix updates.
metadata_path = plugin_path / ".plugin_metadata.json"
if metadata_path.exists():
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
if metadata.get('install_type') == 'bundled':
self.logger.info(f"Plugin {plugin_id} is a bundled plugin; updates are delivered via LEDMatrix itself")
return True
except (OSError, ValueError) as e:
self.logger.debug(f"[PluginStore] Could not read metadata for {plugin_id} at {metadata_path}: {e}")
# First check if it's a git repository - if so, we can update directly
git_info = self._get_local_git_info(plugin_path)
if git_info:
# Plugin is a git repository - try to update via git
local_branch = git_info.get('branch') or 'main'
local_sha = git_info.get('sha')
# Try to get remote info from registry (optional)
self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
# Try without 'ledmatrix-' prefix (monorepo migration)
resolved_id = plugin_id
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
alt_id = plugin_id[len('ledmatrix-'):]
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
if plugin_info_remote:
resolved_id = alt_id
self.logger.info(f"Plugin {plugin_id} found in registry as {resolved_id}")
remote_branch = None
remote_sha = None
if plugin_info_remote:
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
remote_sha = plugin_info_remote.get('last_commit_sha')
# Check if the local git remote still matches the registry repo URL.
# After monorepo migration, old clones point to archived individual repos
# while the registry now points to the monorepo. Detect this and reinstall.
registry_repo = plugin_info_remote.get('repo', '')
local_remote = git_info.get('remote_url', '')
if local_remote and registry_repo and not same_repo(local_remote, registry_repo):
self.logger.info(
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Reinstalling from registry to migrate to new source."
)
return self._reinstall_with_rollback(resolved_id, plugin_path)
# Check if already up to date
if remote_sha and local_sha and remote_sha.startswith(local_sha):
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
return True
# Update via git pull
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
try:
# Fetch latest changes first to get all remote branch info
# If fetch fails, we'll still try to pull (might work with existing remote refs)
fetch_result = subprocess.run(
['git', '-C', str(plugin_path), 'fetch', 'origin'],
capture_output=True,
text=True,
timeout=60,
check=False
)
if fetch_result.returncode != 0:
self.logger.warning(f"Git fetch failed for {plugin_id}: {fetch_result.stderr or fetch_result.stdout}. Will still attempt pull.")
else:
self.logger.debug(f"Successfully fetched remote changes for {plugin_id}")
# Determine which remote branch to pull from
# Strategy: Use what the local branch is tracking, or find the best match
remote_pull_branch = None
# First, check what the local branch is tracking
tracking_result = subprocess.run(
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', '--symbolic-full-name', f'{local_branch}@{{upstream}}'],
capture_output=True,
text=True,
timeout=10,
check=False
)
if tracking_result.returncode == 0 and tracking_result.stdout.strip():
# Local branch is tracking a remote branch
tracking_ref = tracking_result.stdout.strip()
# Extract branch name from refs/remotes/origin/branch-name or origin/branch-name
if tracking_ref.startswith('refs/remotes/origin/'):
remote_pull_branch = tracking_ref.replace('refs/remotes/origin/', '')
self.logger.info(f"Local branch {local_branch} is tracking origin/{remote_pull_branch}")
elif tracking_ref.startswith('origin/'):
remote_pull_branch = tracking_ref.replace('origin/', '')
self.logger.info(f"Local branch {local_branch} is tracking origin/{remote_pull_branch}")
# If not tracking anything, try to find the best remote branch match
if not remote_pull_branch:
# Check if remote branch from registry exists
if remote_branch:
remote_check = subprocess.run(
['git', '-C', str(plugin_path), 'ls-remote', '--heads', 'origin', remote_branch],
capture_output=True,
text=True,
timeout=10,
check=False
)
if remote_check.returncode == 0 and remote_check.stdout.strip():
remote_pull_branch = remote_branch
self.logger.info(f"Using remote branch {remote_branch} from registry")
# If registry branch doesn't exist, check if local branch name exists on remote
if not remote_pull_branch:
local_as_remote_check = subprocess.run(
['git', '-C', str(plugin_path), 'ls-remote', '--heads', 'origin', local_branch],
capture_output=True,
text=True,
timeout=10,
check=False
)
if local_as_remote_check.returncode == 0 and local_as_remote_check.stdout.strip():
remote_pull_branch = local_branch
self.logger.info(f"Using local branch name {local_branch} as remote branch")
# Last resort: try to get remote's default branch
if not remote_pull_branch:
default_branch_result = subprocess.run(
['git', '-C', str(plugin_path), 'symbolic-ref', 'refs/remotes/origin/HEAD'],
capture_output=True,
text=True,
timeout=10,
check=False
)
if default_branch_result.returncode == 0:
default_ref = default_branch_result.stdout.strip()
if default_ref.startswith('refs/remotes/origin/'):
remote_pull_branch = default_ref.replace('refs/remotes/origin/', '')
self.logger.info(f"Using remote default branch {remote_pull_branch}")
# If we still don't have a remote branch, use local branch name (git will handle it)
if not remote_pull_branch:
remote_pull_branch = local_branch
self.logger.info(f"Falling back to local branch name {local_branch} for pull")
# Ensure we're on the local branch
checkout_result = subprocess.run(
['git', '-C', str(plugin_path), 'checkout', local_branch],
capture_output=True,
text=True,
timeout=30,
check=False
)
if checkout_result.returncode != 0:
self.logger.warning(f"Git checkout to {local_branch} failed for {plugin_id}: {checkout_result.stderr or checkout_result.stdout}. Will still attempt pull.")
# Check for local changes and untracked files that might conflict
# First, check for untracked files that would be overwritten
try:
# Check for untracked files
untracked_result = subprocess.run(
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=all'],
capture_output=True,
text=True,
timeout=30,
check=False
)
untracked_files = []
if untracked_result.returncode == 0:
for line in untracked_result.stdout.strip().split('\n'):
if line.startswith('??'):
# Untracked file
file_path = line[3:].strip()
untracked_files.append(file_path)
# Check for tracked file changes
status_result = subprocess.run(
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
capture_output=True,
text=True,
timeout=30,
check=False
)
has_changes = bool(status_result.stdout.strip())
# If there are untracked files, stash them
if untracked_files:
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
has_changes = True
except subprocess.TimeoutExpired:
# If status check times out, assume there might be changes and proceed
self.logger.warning(f"Git status check timed out for {plugin_id}, proceeding with update")
has_changes = True
stash_info = ""
# Whether the pull can be undone without destroying work.
tree_is_recoverable = not has_changes
if has_changes:
self.logger.info(f"Stashing local changes in {plugin_id} before update")
try:
# Use -u to include untracked files in stash
stash_result = subprocess.run(
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'],
capture_output=True,
text=True,
timeout=30,
check=False
)
if stash_result.returncode == 0:
stash_info = " (local changes were stashed)"
tree_is_recoverable = True
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
else:
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
except subprocess.TimeoutExpired:
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
# Do not pull what cannot be un-pulled.
#
# The compatibility gate below can refuse the commit this
# pull brings down, and its only way back is `git reset
# --hard`, which discards uncommitted tracked edits. Those
# edits are exactly what the stash above exists to protect,
# so a stash that failed or timed out leaves the rollback
# unable to run without destroying them.
#
# A pull does not necessarily refuse on a dirty tree -- git
# merges happily as long as the incoming commit touches
# different files -- so without this the update would
# succeed, the gate would refuse, and the reset would take
# the user's work with it. Refusing here costs an update in
# a case that already went wrong; the alternative costs
# data.
if not tree_is_recoverable:
self.logger.error(
"Refusing to update %s: it has local changes that could "
"not be stashed, and an incompatible update could then "
"only be rolled back by discarding them. Commit or stash "
"them by hand, then update.", plugin_id)
return False
# Pull from the determined remote branch
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
pull_result = subprocess.run(
['git', '-C', str(plugin_path), 'pull', 'origin', remote_pull_branch],
capture_output=True,
text=True,
timeout=120,
check=True
)
pull_message = pull_result.stdout.strip() or f"Pulled latest changes for {plugin_id}"
if stash_info:
pull_message += stash_info
self.logger.info(pull_message)
updated_git_info = self._get_local_git_info(plugin_path) or {}
updated_sha = updated_git_info.get('sha', '')
if remote_sha and updated_sha and remote_sha.startswith(updated_sha):
self.logger.info(f"Plugin {plugin_id} now at remote commit {remote_sha[:7]}{stash_info}")
elif updated_sha:
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
# The install gate, at the only point on this path where
# it can be answered. Every other route in goes through
# install_plugin, which gates in _install_plugin_impl; this
# one did not, so a pull could deliver a manifest flooring
# above this core and nothing would notice.
if not self._gate_pulled_commit(plugin_id, plugin_path, local_sha):
return False
self._install_dependencies(plugin_path)
return True
except subprocess.CalledProcessError as git_error:
error_output = git_error.stderr or git_error.stdout or "Unknown error"
cmd_str = ' '.join(git_error.cmd)
self.logger.error(f"Git update failed for {plugin_id}")
self.logger.error(f"Command: {cmd_str}")
self.logger.error(f"Return code: {git_error.returncode}")
self.logger.error(f"Error output: {error_output}")
# Check for specific error conditions
error_lower = error_output.lower()
if "would be overwritten" in error_output or "local changes" in error_lower:
self.logger.warning(f"Plugin {plugin_id} has local changes that prevent update. Consider committing or stashing changes manually.")
elif "refusing to merge unrelated histories" in error_lower:
self.logger.error(f"Plugin {plugin_id} has unrelated git histories. Plugin may need to be reinstalled.")
elif "authentication" in error_lower or "permission denied" in error_lower:
self.logger.error(f"Authentication failed for {plugin_id}. Check git credentials or repository permissions.")
elif "not found" in error_lower or "does not exist" in error_lower:
self.logger.error(f"Remote branch or repository not found for {plugin_id}. Check repository URL and branch name.")
elif "conflict" in error_lower:
self.logger.error(f"Merge conflict detected for {plugin_id}. Resolve conflicts manually or reinstall plugin.")
return False
except subprocess.TimeoutExpired:
self.logger.warning(f"Git update timed out for {plugin_id}")
return False
# A plugin with its own .git that _get_local_git_info could not
# read (e.g. no commits yet) may still name a remote to reinstall
# from. Without its own .git, `git -C <plugin>` walks up and finds
# the enclosing LEDMatrix checkout when plugins live in
# plugin-repos/ -- `--local` does not prevent that -- and the
# "plugin's" remote would be LEDMatrix itself.
repo_url = None
if (plugin_path / '.git').exists():
try:
remote_url_result = subprocess.run(
['git', '-C', str(plugin_path), 'config', '--local', '--get', 'remote.origin.url'],
capture_output=True,
text=True,
timeout=10,
check=False
)
if remote_url_result.returncode == 0:
repo_url = remote_url_result.stdout.strip() or None
if repo_url:
self.logger.info(f"Found git remote URL for {plugin_id}: {repo_url}")
except (OSError, subprocess.SubprocessError) as e:
self.logger.debug(f"Could not get git remote URL: {e}")
# Try registry-based update
self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...")
self.fetch_registry(force_refresh=True)
plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
# If not found, try without 'ledmatrix-' prefix (monorepo migration)
registry_id = plugin_id
if not plugin_info_remote and plugin_id.startswith('ledmatrix-'):
alt_id = plugin_id[len('ledmatrix-'):]
plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)
if plugin_info_remote:
registry_id = alt_id
self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}")
# If not in registry but we have a repo URL, try reinstalling from that URL
if not plugin_info_remote and repo_url:
self.logger.info(f"Plugin {plugin_id} not in registry but has git remote URL. Reinstalling from {repo_url} to enable updates...")
try:
# Get current branch if possible
branch_result = subprocess.run(
['git', '-C', str(plugin_path), 'rev-parse', '--abbrev-ref', 'HEAD'],
capture_output=True,
text=True,
timeout=10,
check=False
)
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else None
if branch == 'HEAD' or not branch:
branch = 'main'
# Reinstall from URL
result = self.install_from_url(repo_url, plugin_id=plugin_id, branch=branch)
if result.get('success'):
self.logger.info(f"Successfully reinstalled {plugin_id} from {repo_url} as git repository")
return True
else:
self.logger.warning(f"Failed to reinstall {plugin_id} from {repo_url}: {result.get('error')}")
except Exception as e:
self.logger.error(f"Error reinstalling {plugin_id} from URL: {e}")
if not plugin_info_remote:
self.logger.warning(f"Plugin {plugin_id} not found in registry and not a git repository; cannot update automatically")
if not repo_url:
self.logger.warning("Plugin may have been installed via ZIP download. Try reinstalling from GitHub URL to enable updates.")
return False
repo_url = plugin_info_remote.get('repo')
remote_sha = plugin_info_remote.get('last_commit_sha')
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
# Compare local manifest version against registry latest_version
# to avoid unnecessary reinstalls for monorepo plugins. Uses the
# same semantic comparator as the web UI's update badge, so
# equivalent spellings ("v1.2.0" vs "1.2.0") never trigger a
# reinstall and a locally-ahead version is never downgraded.
try:
local_manifest_path = plugin_path / "manifest.json"
if local_manifest_path.exists():
with open(local_manifest_path, 'r', encoding='utf-8') as f:
local_manifest = json.load(f)
local_version = local_manifest.get('version', '')
remote_version = plugin_info_remote.get('latest_version', '')
from src.plugin_system.compatibility import is_update_available
# No truthiness gate: the shared comparator already treats
# a missing version on either side as "no update", and the
# store must agree with the UI badge in that case too. A
# missing manifest (not just a missing version field)
# still falls through to the reinstall recovery path.
if not is_update_available(local_version, remote_version):
self.logger.info(
f"Plugin {plugin_id} already at latest version "
f"(installed {local_version}, registry {remote_version})")
return True
except Exception as e:
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
# Plugin is not a git repo but is in registry and has a newer version - reinstall
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
# Reinstall with the old version kept aside until the new
# download succeeds — this is the path every routine store
# update takes, and a mid-update network failure must not
# destroy the user's plugin.
return self._reinstall_with_rollback(registry_id, plugin_path)
except Exception as e:
self.logger.error(f"Error updating plugin {plugin_id}: {e}", exc_info=True)
return False
+4 -4
View File
@@ -7,7 +7,7 @@ plugin discovery / manifest / config-default logic lives in exactly one place.
import json
from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Union
from typing import Any, Dict, Optional, Sequence, Union, cast
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
@@ -39,7 +39,7 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r', encoding='utf-8') as f:
return json.load(f)
return cast(Dict[str, Any], json.load(f))
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
@@ -64,7 +64,7 @@ def load_schema(plugin_dir: Union[str, Path]) -> Optional[Dict[str, Any]]:
if not schema_path.exists():
return None
with open(schema_path, 'r', encoding='utf-8') as f:
return json.load(f)
return cast(Optional[Dict[str, Any]], json.load(f))
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
@@ -124,7 +124,7 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
if not spec_path.exists():
return {}
with open(spec_path, 'r', encoding='utf-8') as f:
spec = json.load(f)
spec: Dict[str, Any] = json.load(f)
# Resolve mock_data path and inline its contents for convenience.
mock_rel = spec.get('mock_data')
+28 -7
View File
@@ -5,9 +5,21 @@ Provides mock implementations of display_manager, cache_manager, config_manager,
and plugin_manager for use in plugin unit tests.
"""
from typing import Dict, Any, Optional
import warnings
from typing import Dict, Any, List, Optional
from PIL import Image
#: Why draw_image() warns. Kept (rather than removed) so existing plugin test
#: suites that call it keep passing, but a plugin that calls it passes its
#: tests and then crashes on the Pi.
DRAW_IMAGE_DEPRECATION = (
"display_manager.draw_image() exists only on the test doubles; the real "
"DisplayManager has no such method, so this raises AttributeError on a "
"device. Paste onto the canvas instead: "
"display_manager.image.paste(img, (x, y)) (with the image as mask, "
"image.paste(rgba, (x, y), rgba), for transparency)."
)
class MockDisplayManager:
"""Mock display manager for testing."""
@@ -20,7 +32,7 @@ class MockDisplayManager:
self.image = Image.new('RGB', (width, height), color=(0, 0, 0))
self.clear_called = False
self.update_called = False
self.draw_calls = []
self.draw_calls: List[Dict[str, Any]] = []
def clear(self):
"""Clear the display."""
@@ -31,8 +43,16 @@ class MockDisplayManager:
"""Update the display."""
self.update_called = True
def draw_text(self, text: str, x: int, y: int, color: tuple = (255, 255, 255), font=None):
"""Draw text on the display."""
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None, color: tuple = (255, 255, 255),
font=None, small_font: bool = False, centered: bool = False):
"""Draw text on the display.
Accepts every argument the real ``DisplayManager.draw_text`` does, so
a plugin passing ``small_font``/``centered`` (or leaving x/y to
default) doesn't fail here while working on the device. ``font``
stays fifth for callers of the old mock signature; pass the rest by
keyword, as the real method's positional order differs.
"""
self.draw_calls.append({
'type': 'text',
'text': text,
@@ -43,7 +63,8 @@ class MockDisplayManager:
})
def draw_image(self, image: Image.Image, x: int, y: int):
"""Draw an image on the display."""
"""Draw an image on the display. Deprecated: see DRAW_IMAGE_DEPRECATION."""
warnings.warn(DRAW_IMAGE_DEPRECATION, DeprecationWarning, stacklevel=2)
self.draw_calls.append({
'type': 'image',
'image': image,
@@ -145,8 +166,8 @@ class MockConfigManager:
def __init__(self, config: Optional[Dict[str, Any]] = None):
self._config = config or {}
self.load_config_calls = []
self.save_config_calls = []
self.load_config_calls: List[Dict[str, Any]] = []
self.save_config_calls: List[Dict[str, Any]] = []
def load_config(self) -> Dict[str, Any]:
"""Load configuration."""
@@ -29,6 +29,7 @@ through src/common/bdf_font.py, so those pixels cannot drift.
import math
import os
import time
import warnings
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
@@ -38,9 +39,12 @@ from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype
from src.logging_config import get_logger
from src.plugin_system.testing.mocks import DRAW_IMAGE_DEPRECATION
logger = get_logger(__name__)
_draw_image_warning_logged = False
class _MatrixProxy:
"""Lightweight proxy so plugins can access display_manager.matrix.width/height."""
@@ -321,17 +325,26 @@ class VisualTestDisplayManager:
else:
self.draw.text((x, y), text, font=current_font, fill=color)
except Exception as e:
logger.debug(f"Error drawing text: {e}")
# WARNING, not DEBUG: the real DisplayManager logs this at ERROR,
# and a test double that hides it lets a broken draw pass.
logger.warning(f"Error drawing text: {e}")
def draw_image(self, image: Image.Image, x: int, y: int):
"""Draw an image on the display."""
"""Draw an image on the display. Deprecated: see DRAW_IMAGE_DEPRECATION."""
warnings.warn(DRAW_IMAGE_DEPRECATION, DeprecationWarning, stacklevel=2)
global _draw_image_warning_logged
if not _draw_image_warning_logged:
# Also logged once: the dev preview server drives this class
# outside pytest, where DeprecationWarning is hidden by default.
_draw_image_warning_logged = True
logger.warning(DRAW_IMAGE_DEPRECATION)
self.draw_calls.append({
'type': 'image', 'image': image, 'x': x, 'y': y,
})
try:
self.image.paste(image, (x, y))
except Exception as e:
logger.debug(f"Error drawing image: {e}")
logger.warning(f"Error drawing image: {e}")
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
"""Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left.
+27 -8
View File
@@ -268,15 +268,21 @@ class StartupValidator:
except Exception as e:
self.warnings.append(f"Could not validate display configuration: {e}")
def _validate_plugins(self) -> None:
"""Validate plugin configurations and dependencies."""
def _validate_plugins(self, discovered_plugins=None) -> None:
"""Validate plugin configurations and dependencies.
``discovered_plugins`` is a list the caller already got from
``discover_plugins()``; passing it skips a second directory scan (and
its duplicate log lines) at startup.
"""
if not self.plugin_manager:
return
try:
# Get enabled plugins from config
config = self.config_manager.get_config()
discovered_plugins = self.plugin_manager.discover_plugins()
if discovered_plugins is None:
discovered_plugins = self.plugin_manager.discover_plugins()
# Check for enabled plugins that don't exist
for plugin_id, plugin_config in config.items():
@@ -294,7 +300,11 @@ class StartupValidator:
# Validate plugin configurations
for plugin_id in discovered_plugins:
plugin_config = config.get(plugin_id, {})
plugin_config = config.get(plugin_id)
# A null block ("my-plugin": null) is not an enabled plugin;
# .get() on it raised and abandoned every remaining check.
if not isinstance(plugin_config, dict):
continue
if plugin_config.get('enabled', False):
# Check if plugin can be loaded (without actually loading it)
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
@@ -308,12 +318,21 @@ class StartupValidator:
def raise_on_errors(self) -> None:
"""
Raise exceptions if validation errors exist.
Raise one exception if validation errors exist; return None if not.
Nothing in core calls this (see the module docstring). Errors are
grouped by a keyword in their message, not by which check produced
them, and only the first non-empty group is raised, in the order
config > cache > plugin: a "plugin ... config" message counts as a
config error, and cache/plugin errors are not reported while a
config error exists. The raised exception's ``context['errors']``
holds that group's messages only.
Raises:
ConfigError: If configuration validation fails
CacheError: If cache validation fails
PluginError: If plugin validation fails
ConfigError: If any message mentions config/configuration, or if
none matches any group
CacheError: If a message mentions cache (and none config)
PluginError: If a message mentions plugin (and none of the above)
"""
if not self.errors:
return
+77 -36
View File
@@ -23,7 +23,6 @@ from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.vegas_mode.stream_manager import StreamManager
from src.vegas_mode.render_pipeline import RenderPipeline
from src.plugin_system.base_plugin import VegasDisplayMode
if TYPE_CHECKING:
from src.plugin_system.plugin_manager import PluginManager
@@ -78,8 +77,14 @@ class VegasModeCoordinator:
- Provide status and control interface
"""
#: How long a STATIC pause waits for the plugin's lock (held while its
#: update() runs) before skipping that turn.
STATIC_LOCK_TIMEOUT = 1.0
# Class-level so coordinators built without __init__ (tests) have it.
_last_live_check: float = float('-inf')
# Set only while Vegas has changed the GIL switch interval; read with getattr.
_saved_switch_interval: Optional[float]
def __init__(
self,
@@ -273,6 +278,10 @@ class VegasModeCoordinator:
self._is_active = True
self._should_stop = False
# A pause belongs to the run it happened in; carrying it into a
# new run would have run_frame() refuse every frame.
self._is_paused = False
self._live_priority_active = False
self._start_time = time.time()
# A fresh run starts with a clean health slate: no stale
# "was degraded" from the previous run, and a heartbeat that is
@@ -298,6 +307,8 @@ class VegasModeCoordinator:
self._should_stop = True
self._is_active = False
self._is_paused = False
self._live_priority_active = False
if self._start_time:
self.stats['total_runtime_seconds'] += time.time() - self._start_time
@@ -322,7 +333,7 @@ class VegasModeCoordinator:
self._saved_switch_interval = sys.getswitchinterval()
sys.setswitchinterval(ms / 1000.0)
logger.info("Vegas: GIL switch interval %.1fms (was %.1fms)",
ms, self._saved_switch_interval * 1000.0)
ms, self._saved_switch_interval * 1000.0) # type: ignore[operator] # set just above; getattr hides it
def _restore_switch_interval(self) -> None:
saved = getattr(self, '_saved_switch_interval', None)
@@ -473,6 +484,20 @@ class VegasModeCoordinator:
if not self.start():
return False
# A live-priority pause is only ever lifted by _check_live_priority(),
# and run_frame() returns before reaching it while paused -- so once
# paused, every later iteration returned False at its first frame and
# the ticker never came back until a restart. The display controller
# only calls run_iteration() when nothing preempts Vegas (no live mode,
# or live content is kept in the ticker), so being called at all means
# the live content that paused us has ended.
with self._state_lock:
paused_for_live = self._is_paused and self._live_priority_active
if paused_for_live:
self._live_priority_active = False
self.resume()
logger.info("Live priority ended - resuming Vegas")
if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says
# nothing about how long to run. This is only how often control
@@ -481,7 +506,11 @@ class VegasModeCoordinator:
duration = float(self.vegas_config.max_cycle_duration)
else:
duration = self.render_pipeline.get_dynamic_duration()
start_time = time.time()
# Monotonic for the same reason as the per-frame clock below: this
# bounds how long the iteration runs, and an NTP step on an RTC-less
# Pi would otherwise end it at once (forward) or stretch it by the
# size of the correction (backward).
start_time = time.monotonic()
frame_count = 0
fps_log_interval = 5.0 # Sample FPS every 5 seconds
# Health state lives on the coordinator, not here: run_iteration() is
@@ -490,10 +519,8 @@ class VegasModeCoordinator:
# of every iteration rather than once per interval, and a recovery
# that crossed an iteration boundary was never reported at all --
# was_degraded had already gone back to False.
# Monotonic, and deliberately not start_time: start_time is wall
# clock and is used below to report the iteration's duration. Mixing
# the two here would make every delta hugely negative and silence the
# frame-rate reporting altogether.
# Monotonic. Never mix it with a wall-clock value: every delta would
# be hugely negative and silence the frame-rate reporting altogether.
last_fps_log_time = time.monotonic()
fps_frame_count = 0
# A mean hides stutter completely. At 120fps a five-second window is
@@ -520,8 +547,7 @@ class VegasModeCoordinator:
if not self._handle_static_pause(static_plugin):
# Static pause was interrupted
return False
# After static pause, skip this segment and continue
self.stream_manager.get_next_segment() # Consume the segment
# The trigger consumed the plugin's marker; carry on scrolling.
continue
# Run frame
@@ -633,7 +659,7 @@ class VegasModeCoordinator:
).start()
# Check elapsed time
elapsed = time.time() - start_time
elapsed = time.monotonic() - start_time
if elapsed >= duration:
break
@@ -652,7 +678,7 @@ class VegasModeCoordinator:
# cycle content multiple times within one iteration — acceptable for
# a continuous ticker.
logger.info("Vegas iteration completed after %.1fs", time.time() - start_time)
logger.info("Vegas iteration completed after %.1fs", time.monotonic() - start_time)
return True
def _check_live_priority(self) -> bool:
@@ -800,32 +826,30 @@ class VegasModeCoordinator:
"""
Check if a STATIC mode plugin should take over display.
Called during iteration to detect when scroll should pause
for a static plugin display.
Called every frame. The render pipeline marks where each STATIC
plugin's turn falls in the strip, and this reports the one the scroll
has just reached.
This used to peek at the front of the stream manager's segment
buffer, which continuous scrolling (the default) never advances: it
extends the strip with take_next_group() instead. The same first
segment was examined on every frame, so a STATIC plugin paused the
scroll only if it happened to be first, once, at startup -- and
otherwise just scrolled past as ordinary content. Swap mode fared no
better: nothing advanced the buffer mid-cycle either.
Returns:
Plugin instance if static pause should begin, None otherwise
"""
# Get the next plugin that would be displayed
next_segment = self.stream_manager.peek_next_segment()
if not next_segment:
plugin_id = self.render_pipeline.next_static_trigger()
if not plugin_id:
return None
plugin_id = next_segment.plugin_id
plugin = self.plugin_manager.get_plugin(plugin_id)
plugin: Optional['BasePlugin'] = self.plugin_manager.get_plugin(plugin_id)
if not plugin:
logger.debug("[%s] STATIC turn reached, but the plugin is no longer loaded",
plugin_id)
return None
# Check if this plugin is configured for STATIC mode
try:
display_mode = plugin.get_vegas_display_mode()
if display_mode == VegasDisplayMode.STATIC:
return plugin
except (AttributeError, TypeError):
logger.exception("Error checking vegas mode for %s", plugin_id)
return None
return plugin
def _handle_static_pause(self, plugin: 'BasePlugin') -> bool:
"""
@@ -855,15 +879,32 @@ class VegasModeCoordinator:
self.display_manager.set_scrolling_state(False)
try:
# Display the plugin using its standard display() method
plugin.display(force_clear=True)
# Display the plugin using its standard display() method, under
# its plugin lock like every other display() call: without it this
# could draw while the update worker is inside the plugin's
# update(). If update() holds the lock past the wait, skip this
# turn rather than stall the marquee.
get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None)
plugin_lock = get_lock(plugin_id) if get_lock else None
if plugin_lock is not None and not plugin_lock.acquire(
timeout=self.STATIC_LOCK_TIMEOUT):
logger.info("Static pause skipped for %s: its update() is still running",
plugin_id)
return True
try:
plugin.display(force_clear=True)
finally:
if plugin_lock is not None:
plugin_lock.release()
self.display_manager.update_display()
# Wait for the plugin's display duration
# Wait for the plugin's display duration. Monotonic, like the
# iteration clock: an NTP step on an RTC-less Pi would otherwise
# end the pause at once or stretch it by the correction.
duration = plugin.get_display_duration()
start = time.time()
start = time.monotonic()
while time.time() - start < duration:
while time.monotonic() - start < duration:
# Check for interruptions
if self._should_stop:
logger.info("Static pause interrupted by stop request")
@@ -883,7 +924,7 @@ class VegasModeCoordinator:
logger.info(
"Static pause completed for %s after %.1fs",
plugin_id, time.time() - start
plugin_id, time.monotonic() - start
)
except Exception:

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