Compare commits

..
20 Commits
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 a864c3223c fix(starlark): address the review on the ownership repair
Findings from the automated review of #604.

Symlinks (CWE-59, the serious one). A root chown that follows links is a
privilege-escalation primitive: anyone able to write in starlark-apps could
point a link at a root-owned file and have the repair hand it over. Entries
are now read with os.lstat, symlinks are skipped outright, and the chown
passes follow_symlinks=False. Descendants are processed before the directory
itself, so the container does not change hands while its contents are still
being walked.

install_app() caught PermissionError in its broad handler and returned
False, which both routes report as a generic install failure -- the exact
shape of the bug this PR exists to fix, since the caller could not tell
"this app is broken" from "this process cannot write here". PermissionError
is now re-raised; every other failure still returns False.

The test fixtures skipped on bare Exception, which would have turned a
syntax error or NameError in the plugin into a green run. They now skip only
for a named absent dependency and re-raise anything else.

Also fixed the _Stat stub that failed in CI but passed locally: it carried
only st_uid/st_gid, and pathlib reads st_mode while walking. It now wraps
the real stat result and overrides ownership alone.

NOT taken: the CodeQL "information exposure through an exception" finding on
the hint response. Dropping `details` would contradict this package's
documented rule -- "if it returns 5xx, it says why" -- which
test_no_api_v3_handler_discards_its_exception enforces with an allowance
that may shrink and never grow. The Starlark routes are the ones that policy
was written for: they answered 500 with no detail for three releases.
describe_exception already redacts credentials and truncates. Keeping the
detail is the deliberate trade-off, so the finding is declined rather than
silently worked around.

Verified on hdpi with the updated code: a symlink to /etc/shadow planted in
starlark-apps was skipped while the directory was handed back, and
/etc/shadow stayed root:shadow.

Mutation-checked all three behaviours. The symlink test was vacuous on the
first attempt -- the link already had the target owner, so it was skipped
for the wrong reason and the mutation passed. It now forces the link to look
like it needs handing over, and fails when the check is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-21 18:09:28 -04:00
ChuckBuildsandClaude Opus 5 965d509864 fix(starlark): stop the root display service locking the web UI out
Reported after a fresh install: installing an app from the Starlark tab
failed with "install failed: Failed to install from repository", and so did
uploading a .star file and installing from a GitHub directory. The reporter
found the cause only by reading service logs, and fixed it with

    sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/starlark-apps

starlark-apps is gitignored, so it is never checked out -- it is created
lazily by whichever process reaches it first. Those processes run as
different users. systemd/ledmatrix.service is User=root and constructs this
plugin at startup, which is where _get_apps_directory() is called from;
systemd/ledmatrix-web.service runs as the login user and is what actually
installs apps.

The documented first step is to install pixlet and reboot, so on a fresh
machine the display service usually wins that race and mkdir() leaves the
directory root-owned. The web process then fails in _install_star_file() on
app_dir.mkdir(), which catches nothing, so PermissionError reaches the
route's outer `except Exception` and becomes the generic message the user
saw. All three install paths write to the same directory, which is why all
three failed.

The web user cannot repair this -- chown needs root. So root does it, on
every startup, which also heals machines already broken by this without the
owner having to find the chown themselves. It is a no-op when not root, when
the platform has no POSIX ownership, and when the checkout genuinely belongs
to root; a chown that fails warns rather than killing startup.

Also made the failure legible if the handover is ever prevented: a
PermissionError now names the directory, the automatic repair, and the
manual chown, instead of a message that names neither path nor cause.

Verified by mutation: dropping the handover call, chowning a genuinely
root-owned checkout, and letting a non-root process chown each fail their
own test. 121 starlark tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-21 16:15:24 -04:00
claude[bot]andClaude 967f3a0567 fix(install): parse the sudoers rules before installing them (#602)
Both installers generated the ledmatrix_web rules and copied them straight
into /etc/sudoers.d without ever parsing them. Every rule is built from
`which` lookups, so an empty or surprising path produces a malformed
drop-in -- and a malformed file in /etc/sudoers.d makes sudo refuse every
command for every user. On a headless Pi that is unrecoverable over SSH.

first_time_install.sh now runs `visudo -c` on the generated file and, if it
does not parse, prints what visudo said and leaves the installed file
untouched rather than replacing it with a broken one. configure_web_sudo.sh
does the same before it offers the rules for confirmation.

first_time_install.sh also built the file at a fixed /tmp path as root;
mktemp now picks the name.

test/test_sudoers_is_validated.py renders the installer's own sudoers
heredoc and checks the result with visudo -- the check neither installer
had -- and asserts the install stays gated on it.


Claude-Session: https://claude.ai/code/session_01Dby94z9PV3zVM25fqGNXTt

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-21 16:08:29 -04:00
claude[bot]andClaude Opus 5 21c8a54f68 chore: prepare the 3.5.0 release (#601)
* chore: prepare the 3.5.0 release

Turns the CHANGELOG's Unreleased section into `## 3.5.0` and bumps
`src.__version__`, the value plugin `ledmatrix_min_version` floors compare
against. No behaviour change; nothing outside the CHANGELOG, `src/__init__.py`
and one docs line is touched.

The staged entries are reshaped into the `### ` subsections every released
section already uses, and the "new modules a plugin may import via `src.*`"
block moves to the top as the plugin-facing summary, the same shape as 3.4.0.
Its floor, written as "the release that ships this" while it was staged, is now
3.5.0, and `docs/SPORTS_UNIFICATION.md` says 3.5.0 for `sports_helpers.py`
instead of "(unreleased)".

Four merged changes had never been written down. They are added under the
subsection each belongs to, from the commits and their measurements:

- the idle back-off clamped to the next kickoff (#599)
- concurrent ESPN date chunks (#596)
- the three web routes that consulted plugin manifests before anything had
  discovered plugins, one of which wrote a plugin API key to config.json in
  plain text (#594)
- the cache permission fix and its systemd unit changes (#593), which get
  their own subsection

No tag and no release: `scripts/check_release_version.py v3.5.0` passes, so
tagging is a separate, deliberate step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqzd6Nz2bQJp5K7DD5dS4X

* ci: let Claude Code Review run on PRs the Claude app opens

The review action refuses a workflow whose actor is a GitHub App unless the
app is named in `allowed_bots`, which this workflow never set:

  Actor is a GitHub App: claude[bot]
  Actor type: Bot
  Action failed with error: Workflow initiated by non-human actor: claude
  (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.

It aborts about two seconds in, before the diff is read, so the check is red
on every such PR and re-running cannot help: the actor does not change. Until
now no PR here had a bot author, so nothing tripped it.

`'claude'` rather than `'*'`: the action lowercases each entry and strips a
trailing `[bot]` before comparing it to the actor
(`isAllowedBot` in `src/github/validation/actor.ts`), so this admits
`claude[bot]` and no other app. `'*'` would admit any app that can trigger a
workflow here, with a prompt it controls — the action's own docs warn about
that on public repositories, and this one is public.

The write-permission check already allowed the app; `checkHumanActor` was the
only gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqzd6Nz2bQJp5K7DD5dS4X

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-21 16:08:05 -04:00
ChuckandClaude Opus 5 cf02538d2e fix(sports): ask the endpoint the league actually publishes for standings (#600)
* fix(sports): ask the endpoint the league actually publishes for standings

ESPNDataSource.fetch_standings tried /standings first regardless of league
and fell back to /rankings only on a 404. College leagues answer /standings
with a 200 that carries no poll, so the fallback never fired and the poll
came back empty every time. Nothing failed; the rank badge simply never
appeared, and anything keyed off rankings quietly did nothing.

Endpoints are now ordered by whether the league publishes a poll, a 200
that lacks the key counts as a miss so a league answering both still ends
up with whichever one carries the poll, and only a 404 is treated as
routine -- it is how a league says it has none. A connection error, a
timeout or an unparseable body is logged as an error again.

This is the implementation the football, baseball and hockey boards already
ship; core was the last copy still on the old one. Verified against live
ESPN: mens-college-basketball returns a populated rankings key where it
previously returned nothing, and nba still resolves from /standings alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(standings): stop the endpoint handler from swallowing its own bugs

Addresses both CodeRabbit findings on #600.

The handler caught `Exception`, so an AttributeError or TypeError raised
while *inspecting* the payload was indistinguishable from an endpoint that
failed. The loop would move on and, if the other endpoint had nothing
either, return {} -- silently dropping rankings for a league that has them.
That is the precise failure this function was written to fix, so the
handler was able to reintroduce it.

Only the request is guarded now. `requests.RequestException` covers the
transport failures and `ValueError` covers a body that will not parse;
payload inspection happens after the handler, where a bug surfaces instead
of being logged as a missing poll. A non-dict payload is treated as a miss
explicitly rather than by tripping over `.get`.

Tests: the fallback paths had no coverage -- the old single-endpoint code
would have passed the suite unchanged. Added order assertions for both
league kinds, a 200-without-a-poll fall-through, 404 and non-404 recovery,
a non-object payload, and a guard proving a bug is no longer swallowed.

`test_fetch_standings_returns_empty_on_error` faked a transport failure
with a bare `Exception`, which only passed because the handler caught
everything. It now raises ConnectionError, which is what actually happens.

Verified by mutation: restoring standings-first fails 5 tests, restoring
the catch-all fails the bug-not-swallowed guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:07:51 -04:00
ChuckandClaude Opus 5 81e1bc596f fix(sports): stop the idle back-off sleeping through a kickoff (#599)
* fix(sports): stop the idle back-off sleeping through a kickoff

A league with no live games backs its poll off as empty checks mount,
capped by live_idle_max_interval. The escalation counts empty looks and
nothing else, so a league three hours before kickoff is indistinguishable
from one three months out of season. Both reach the ceiling -- and the
ceiling then *is* the blind spot.

Measured on two rigs on 2026-09-19: gaps of up to 928s between looks, ten
of them at or above 900s. Reproduced in the wild on 2026-09-20, where an
unpatched rig sat for fifteen minutes with eight NFL games in progress and
had not noticed any of them. That is the "it doesn't pick up new live
games until I restart it" report -- restarting being the one thing that
forces an immediate look.

The clamp costs no extra request: the live fetch already downloads the
whole day's scoreboard, upcoming games included, so the earliest start
still ahead of us falls out of the payload the manager already has.
Before a kickoff the wait is shortened so it cannot run past it; just
after one, the live cadence is held for _KICKOFF_GRACE_SECONDS, because a
provider that has not yet flipped the status would otherwise look like
another empty check and escalate the back-off again, right when the game
is starting.

The grace window needed a second pass. A soak caught it as dead code: the
just-passed kickoff was replaced by the next fixture on the card the
instant it passed, `now < start` went true again, and the back-off
returned to its ceiling. Observed live -- the rig polled at 13:00:45,
found nothing because ESPN had not flipped the status, then went quiet for
a quarter of an hour. A kickoff inside the grace window is now kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(sports): pin absolute tolerances and correct a wrong grace expectation

pytest.approx defaults to a relative tolerance. On a unix timestamp that is
roughly 1790 seconds, so every kickoff assertion here was effectively
vacuous -- it called a kickoff half an hour away "equal". All seven now
pin abs=1.

That hid a wrong expectation. test_an_earlier_kickoff_still_wins_during_the_grace
asserted a game ten minutes out should displace one that kicked off moments
ago. It should not, and the code does not: while the grace holds, the wait
is the live cadence (30s), which is strictly tighter than clamping to the
nearer kickoff would give (~600s). Letting the candidate win would set a
ten-minute wait at the exact moment games are starting -- the dead grace
window this branch exists to fix.

The test now pins the real behaviour plus the safety property that makes it
correct, and is renamed to say what it checks.

Reported by CodeRabbit on the PR. The finding was right that code and test
disagreed; the suggested fix was the wrong way to resolve it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 18:25:33 -04:00
ChuckandClaude Opus 5 19686ab761 fix(web): plugin settings form shows schema defaults for unsaved keys (#597)
The server-rendered plugin settings partial rendered straight from the
saved config, so an option added in a plugin update (geochron 1.2.0's
show_date / show_date_line, default true) drew as an unchecked box, and
the save route's missing-checkbox handling then stored it as false.
Enum dropdowns likewise showed their first option instead of the default.

- _load_plugin_config_partial runs the stored section through
  prepare_plugin_config (as GET /plugins/config does) before masking
  secrets, so a secret's schema default is masked too.
- render_field falls back to the field's own default, covering children
  of objects that declare a default of their own (where the defaults
  extraction stops).
- The legacy-boolean parity test now compares against the config the
  plugin actually runs with (defaults included).

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 11:09:25 -04:00
ChuckandClaude Opus 5 92f1960d00 perf(sports): fetch ESPN date chunks concurrently (#596)
* perf(sports): fetch ESPN date chunks concurrently

Since ESPN started rejecting `dates=YYYYMMDD-YYYYMMDD` on 2026-09-15, one
season request became a chunk per month -- and a month over the 500-event
cap becomes a request per day. A cold college-baseball season is about 130
requests, and they went out one at a time.

That is slower than the 20s budget `_update_plugins()` shares across every
plugin at startup, so scoreboards were logging `update() timed out` on
first run and being deferred to the scheduled tick with nothing on the
panel. Measured on a Pi 4 against live ESPN, March+April college baseball
(63 requests, 3101 events): 11.2s sequential, 1.6s concurrent. Over a whole
boot that moved football-scoreboard, ledmatrix-flights and birdnet-go
inside the budget -- 13 plugins deferred before, 10 after.

Chunks now go out six at a time, in two passes: months and edge days first,
then the days of any month that came back capped. Six keeps the shared
Session under requests' default pool_maxsize of 10, so no connection is
discarded. Merged events still follow `espn_date_chunks` order -- a capped
month's days are spliced back into its own slot -- so the payload does not
depend on which request won the race.

Request order is no longer significant, so the three tests that pinned it
compare the chunks as a set and keep asserting the merged event order,
which is the part callers actually see.

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

* fix(sports): drop capped month payloads before fetching their days

Review of the concurrent chunk fetch found it raised the worst-case peak
memory more than the concurrency explains. The old loop discarded a month
that came back at the 500-event cap the moment it saw it; the rewrite kept
every capped month alive in `results`/`slots` until all of their day
requests had finished.

Measured on a Pi 4 fetching 20260201-20260531 college baseball (four capped
months, 5462 events), peak RSS growth over the call:

  sequential (main)               83 MB
  concurrent, months retained    121 MB  (+43)
  concurrent, one worker         108 MB  -- the retention alone was +25
  concurrent, months dropped      98-100 MB (+16)

docs/LOW_MEMORY_BOARDS.md puts a 1 GB Pi 3B+ at under 200 MB of headroom,
where running out makes the board unreachable until a power cycle, so the
difference matters. The remaining +16 MB is six responses parsing at once;
three workers saved about 6 MB more, within run-to-run noise, so the worker
count stays at six.

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

* docs(sports): state what ESPN_CHUNK_WORKERS was measured to do, not more

The comment claimed the sequential fetch made scoreboards blow the 20s
startup update() timeout. A boot on this branch still deferred 12 plugins
and timed out baseball-scoreboard while its season fetches took 0.74s and
1.12s: the startup budget is spent on other per-plugin work. Say what was
measured -- 17.7s sequential, 2.6-3.3s concurrent -- and nothing else.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 09:56:50 -04:00
ChuckandClaude Opus 5 116abb0daa fix: September 16 core audit — partial saves, asset path safety, auto-update, display settings the library refuses, scroll speed (#595)
* fix(sports): share the ESPN rejected-range memo with the background service

BackgroundDataService always sent a season range first and, on a 400,
fell back to chunks without recording the rejection, so every background
season fetch spent a doomed request and live scoreboards learned nothing
from it (or it from them). The worker now consults and sets the same
6-hour memo fetch_espn_scoreboard() uses: a known rejection goes straight
to month/day chunks, and if every chunk fails the range is asked once for
a real error without re-spending the chunks.

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

* fix(web): keep plugin asset and action routes inside their directories

POST /plugins/assets/upload, GET /plugins/assets/list and POST
/plugins/assets/delete joined the request's plugin_id onto assets/plugins
unchecked, so '../../config' created, wrote, listed and deleted outside
it. #561 guarded only the route that serves the files. All three now go
through path_safety.resolve_under and answer 400 for anything but a
plain name, and delete only unlinks a metadata path that resolves into
that plugin's uploads directory.

PluginManager.get_plugin_directory refuses ids that are not one plain
path segment, so /plugins/action (which runs a manifest script from the
returned directory) and every other caller get the guard; the action
route also rejects such ids up front, covering its no-manager fallback.

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

* fix(web): report a no-op plugin update as already up to date

update_plugin() returns True both for a real update and for "nothing to
do" (a ZIP-installed monorepo plugin already at the registry version, a
bundled plugin). With no git commit to compare, POST /plugins/update
called every such success "updated successfully", so Check & Update All
counted most official plugins as updated on every run.

The route now reads what changed off the plugin itself (commit, else
manifest version, else last_updated) and returns data.update_status
(updated / up_to_date / local_only). The update-all toast is summarised
by PluginInstallManager.summarizeUpdateResults from that status, falling
back to the message for older servers.

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

* fix(sports): scoreboard scroll speed no longer follows target_fps

sports_scroll computed the crisp speed ladder against the global
target_fps whenever limit_refresh_rate_hz was the 100 Hz default. Since
frame-locked presentation (#545) the helper steps a fixed number of whole
pixels per presented frame and the panel presents at its real refresh, so
the General tab's "Scroll Frame Rate" became a speed multiplier: 60 ran a
50 px/s scoreboard at 100 px/s, 200 ran it at 25 px/s.

The ladder now uses the display manager's refresh_hz, then
display.hardware.limit_refresh_rate_hz, then the default. target_fps is
not consulted. Docstrings now say scroll_delay is ignored for pacing (no
behaviour change there) and describe the fixed-step model.

Tests: replace the tests that pinned target_fps as the ladder refresh and
described time-based stepping; assert speed independence from target_fps
(unit and end-to-end presented px/s against the real helper), that the
fixed per-frame step is applied, and that scroll_delay does not change
speed.

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

* fix(web): escape registry and upload values in plugin manager inline handlers

The store, saved-repository and custom-registry buttons built
onclick='...(${JSON.stringify(id)})...'. JSON.stringify leaves ' alone,
so a custom registry entry whose id contained ' closed the attribute and
added its own handler. One helper, jsStringAttr(), now HTML-escapes the
JSON literal for every one of those handlers, and the store View button
opens only http(s) repo links.

The live window.updateImageList (plugins_manager.js loads last, so its
copy wins over the file-upload widget's) wrote the uploaded file's
original name, path and ids into markup raw; they are escaped now.

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

* docs(changelog): note plugin asset, action and inline handler guards

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

* fix(update): let the root pip wrapper install web_interface/requirements.txt

Update Code, the automatic update's health check and Install Base
Requirements install web_interface/requirements.txt through
safe_pip_install.sh, which only allowed the root requirements.txt. The
first commit changing that file would fail its dependency install, and
the automatic updater rolls back any update whose dependencies did not
install -- on every device, for every newer commit.

The wrapper now lists both core requirement files. Only their folders
are resolved, so a requirements.txt symlinked out of the project is
compared by its target and refused (previously the root file's own
symlink target was what got allowed). The updater's file list is a
named constant, and a test runs the real wrapper (pip stubbed) on
every file Update Code and the rollback install.

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

* fix(web): do not retry plugin requests that got an HTTP answer

PluginAPI.request wrapped everything that was not a structured error as
NETWORK_ERROR: a proxy's 502 HTML page (response.json() throws) and a
JSON error without error_code included. Check & Update All retries
NETWORK_ERROR, so those updates were re-sent five more times with
backoff, contrary to the #587 contract that an HTTP error response is
the server's answer.

NETWORK_ERROR now means only that fetch() rejected. Any HTTP response
without an error_code, or with a body that is not JSON, is API_ERROR
with the HTTP status attached. Tested against the shipped api_client.js.

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

* fix(scroll): restart the stats window when an idle gap is dropped by size

#582 dropped an idle gap from the frame stats two ways: the reset_scroll()
sentinel, which also restarts the 5s window timer, and a size guard for
scrollers that never call reset_scroll(), which did not. On that path the
first real frame after the gap found the boundary overdue and logged a
stats line for a one-frame window. Both paths now share one seeding helper.

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

* fix(update): leave plugins alone when update_core's own rollback fails

update_core returns rollback_failed directly when a partial pull or an
update whose health check never started cannot be rolled back. run()
only held plugins back for 'verifying', so those devices still got new
plugin versions and a display restart on top of a core in an unknown
state -- the opposite of what the health-check path does, and of the
3.4.0 changelog (plugins are left alone if the rollback fails).

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

* docs(api): make the REST reference match the api_v3 package

Every documented request body, query parameter and response shape was
re-checked against the handlers in web_interface/blueprints/api_v3/.
Fixes calls that failed as documented (repo_url, action_id/params,
files/image_id, font_file+font_family, ?font=, cache key,
auto_enable_ap_mode, plugin limit keys), removes the font-override
endpoints dropped in #566, corrects response shapes (plugins/config,
plugins/schema, health, metrics, operation history, github-status,
fonts/catalog, cache/list, logs, wifi, on-demand, SSE streams), and adds
the 26 routes it omitted (backup, system auto-update/git, wifi radio,
starlark editor, MQTT bridge, status endpoints, skins).

Documents the merge semantics of partial JSON saves to /config/main and
/plugins/config and the dim-schedule POST accepting GET's days shape,
which land in the same change set. Replaces app.py line numbers and the
removed api_v3.py path with file and function names.

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

* fix(web): remove the General-tab plugin system toggles that did nothing

plugin_system.auto_discover, auto_load_enabled and development_mode had
General-tab toggles whose help tips promised dormant plugins and verbose
logging, but nothing reads them: every enabled plugin is discovered and
loaded regardless. Remove the three toggles.

The keys stay tolerated in stored configs. The save handler now stores
a flag only when a client sends it; treating a missing key as an
unchecked box would otherwise rewrite all three to false on every
General-tab save, which still posts plugins_directory.

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

* refactor(scroll): remove dead code left by #523/#570

- Drop the optional scipy.ndimage import and HAS_SCIPY; nothing read
  them since the numpy blend replaced the scipy path.
- Drop ScrollHelper._last_integer_position and frame_time_target, which
  were written but never read.
- Keep target_fps and set_target_fps() but document them as
  informational: nothing paces off them, yet ledmatrix-elections'
  test_scroll_pacing.py reads helper.target_fps back and third-party
  plugins may call the setter.
- Fix stale comments: fixed_pixels_per_frame's "use scroll_delay to
  throttle", set_sub_pixel_scrolling's "default: True", and
  set_frame_based_scrolling's claim that it steps.

The plugins monorepo was grepped for every removed name; none is used.

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

* docs(fonts): point plugins at plugin_manager.font_manager; drop removed overrides UI

FONT_MANAGER.md told plugins to read display_manager.font_manager, which
does not exist, so a plugin following it failed to load with
AttributeError. The shared FontManager lives on the PluginManager and
BasePlugin._get_font_manager() returns it (with a fallback for harnesses).

Also removes the Fonts-tab override workflow and element-override panels
that #566 deleted, from FONT_MANAGER.md and WEB_INTERFACE_GUIDE.md.

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

* docs(store): search via /plugins/store/list?query=; send Content-Type on registry curls

/plugins/store/search does not exist (404) and the list endpoint reads
query, not q. The registry guide's curl examples omitted the JSON
Content-Type, so the handlers saw an empty body and answered 400.

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

* fix(config): use the shared core-key list in the last three private copies

StartupValidator warned "Plugin 'auto_update' is enabled but not found" on
every display start with auto-update or a dim schedule on; the reserved
plugin-id check missed auto_update, sync, location and the rest; and
ConfigManager's (uncalled) orphan cleanup would have deleted display,
schedule and auto_update. All three now read src/core_config_keys.py, which
also gains CORE_SECRETS_KEYS for the github/youtube secrets sections.

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

* fix(web): partial JSON saves to /config/main change only what they send

A JSON body with one field reset every checkbox in the sections it touched:
the MQTT bridge's brightness slider turned off disable_hardware_pulsing,
inverse_colors, show_refresh_rate and use_short_date_format, and a
timezone-only save turned off web-UI autostart and weekly auto-updates.
Missing-means-unchecked now applies only to form posts: form-encoded bodies
and the v3 forms, which mark themselves with a hidden __form_section input.

Also on the config routes:
- vegas_min/max_cycle_duration no longer match the generic *_duration rule,
  so they stop landing in display_durations and a blank one no longer
  rejects the whole Display save;
- saving from the Raw JSON editor calls start_setup_if_needed like the
  General form, so enabling auto-update there finishes its setup;
- the schedule and dim-schedule POSTs accept the per-day days.<day> shape
  their GETs return, as well as the flat form keys.

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

* fix(scripts): install plugin dependencies from the configured plugins directory

install_plugin_dependencies.sh scanned only plugins/, but the Plugin
Store installs into plugin_system.plugins_directory (default
plugin-repos), so the documented "Recommended" fix found 0 plugins on
every store install. It now reads plugins_directory from
config/config.json (relative to the project root or absolute, default
plugin-repos) and also scans plugins/ for dev symlinks, installing a
plugin reached through both only once.

With set -e alone, `pip ... | tee` took tee's exit status, so a failed
pip install was reported as success; set -o pipefail.

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

* docs: replace stale API names, line numbers and the api_v3.py path

- ADVANCED_FEATURES: StreamManager methods that exist
  (get_next_segment, take_next_group, refresh, advance_cycle, ...), and the
  real on-demand status envelope ({status, data: {state, service}})
- app.py:199 / :144 / :607-619 line citations and
  web_interface/blueprints/api_v3.py (now a package) replaced with file and
  function names in ADVANCED_FEATURES, CONFIG_DEBUGGING,
  PLUGIN_ARCHITECTURE_SPEC, PLUGIN_QUICK_REFERENCE,
  PLUGIN_CONFIGURATION_TABS, TROUBLESHOOTING and web_interface/README
- CONFIG_DEBUGGING: partial /config/main saves change only sent keys; use
  /config/raw/main to replace the file; describe where validation runs
- TROUBLESHOOTING: clear_cache.py needs --clear-all (no args only prints
  usage)

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

* fix(scripts): verify the web interface that actually ships, on port 5000

verify_installation.sh failed every healthy install: it required the
long-removed web_interface_v2.py and looked for a listener on port 5001,
while the web interface binds 5000 (web_interface/start.py). It now
checks the files ledmatrix-web.service runs (start_web_conditionally.py,
web_interface/start.py, app.py) and port 5000. verify_web_ui.sh had the
same 5001 port in its listen check, HTTP probe and printed URLs.

Port matches are anchored so :50001 no longer counts as :5000.

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

* docs(plugins): one display-size contract: display_manager.width/height

CLAUDE.md (#580) says to read display_manager.width/height because
matrix is None when hardware init fails; the development guide, the
safety-harness doc and two DisplayManager docstrings still recommended
matrix.width/height. The bundled starlark-apps plugin read matrix.width
unguarded, so its magnify recommendation and frame scaling raised in
fallback mode (e.g. after the Pi 5 hardware refusal).

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

* fix(install): make install_service.sh --help print usage instead of installing

install_service.sh parsed no arguments, so `sudo ./scripts/install/
install_service.sh --help` (presented as harmless in MIGRATION_GUIDE.md)
rewrote ledmatrix.service, ledmatrix-web.service and both update-verify
units and enabled/started them. It now handles -h/--help (usage, exit 0,
no changes) and rejects any other argument with exit 2 before doing
anything. Running it with no arguments, as first_time_install.sh does,
is unchanged.

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

* docs(scroll): describe the fixed-step model and document frame_hold

Since #545 a crisp speed from scroll_config.configure() makes the helper
advance a fixed whole-pixel step per presented frame with no clock, and the
display manager's frame hold is part of the speed. The docs still described
the removed wall-clock model:

- scroll_config's module and configure() docstrings said speed is applied
  in time-based mode and that omitting the hold "falls back to fractional
  pixels"; omitting it actually runs the scroll frame_hold times too fast.
- SCROLL_PERFORMANCE.md said ScrollHelper accumulates elapsed time in both
  modes, and read a 20 ms stats median as missed refreshes although that
  is a healthy 50 px/s (hold 2) scroll. It now explains the fixed step,
  the hold-dependent healthy median, that target_fps plays no part, and
  that a hand-added scroll_pixels_per_second loses to a schema-default pair.
- PLUGIN_API_REFERENCE.md documented set_scrolling_state(is_scrolling)
  without frame_hold; it now documents the parameter (core 3.4.0) with a
  configure() + set_scrolling_state example.
- update_scroll_position/set_scroll_speed and set_scrolling_state
  docstrings say the same.

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

* docs(config): mark target_fps legacy; describe what Vegas scroll_delay does

- General tab "Scroll Frame Rate" (target_fps) is labelled legacy: after
  the sports_scroll fix nothing in core scrolling reads it. The field and
  its API validation stay so saved configs and plugins that read
  global_config['target_fps'] keep working. CONFIG_REFERENCE says the same.
- Vegas frame_based_scrolling/scroll_delay were described as frame-count
  stepping at ~50 FPS. Neither steps nor sets a frame rate: frame-based
  mode converts the speed to px per scroll_delay, clamps it to 0.1-5, and
  still advances by elapsed time, so the applied speed is
  clamp(scroll_speed * scroll_delay, 0.1, 5) / scroll_delay px/s. The
  config comments, render_pipeline comment and CONFIG_REFERENCE rows now
  say so. No behaviour change.

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

* docs(deps): describe how plugin dependencies are really installed

The guides said the web service runs as root, that installs pick --user
from os.geteuid(), and quoted a warning and a
PluginManager._install_plugin_dependencies() method that don't exist. The
web unit runs as the installing user; store installs go through
install_requirements_file() and sudo safe_pip_install.sh (root), with a
user-level fallback that says so, and load-time installs run in the
display service's own (root) interpreter.

Manual paths now use the configured plugins directory (plugin-repos/ by
default) instead of plugins/, which store installs no longer use, and
install_plugin_dependencies.sh is described as scanning that directory.

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

* fix(update): count local changes one way for the preflight and the pull

The automatic update's preflight ignored mode-only changes and anything
whose status line contained plugins/ or plugin-repos/, then promised
"Automatic updates will not stash your changes". perform_core_update
used plain git status (modes count) and ignored only 'plugins/', then
ran 'git stash push -- :!plugins', which nothing ever pops. So an edit
to a bundled plugin under plugin-repos/, or the installer's chmods on
tracked scripts, passed the preflight and was stashed away for good.

- auto_update.local_changes() is the one predicate both use:
  core.fileMode=false, porcelain -z, and plugins/ and plugin-repos/
  excluded by leading folder rather than substring (a core file under
  web_interface/static/v3/js/plugins/ now counts).
- Update Code's explicit stash leaves out both plugin folders; the
  pull's --autostash carries their edits and mode changes across and
  reapplies them.
- The automatic updater calls perform_core_update(stash_local_changes=
  False), which refuses instead of stashing edits that appeared after
  the preflight; update_core reports that as 'blocked'.

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

* fix(scripts): diagnostics follow the web autostart default and api_v3 package

#556 made a missing web_display_autostart mean "start" (only an explicit
false/off keeps the web interface down), but the diagnostics still said
otherwise: diagnose_web_ui.sh reported a missing key as "defaults to
false", diagnose_web_interface.sh said the web interface "will not start
unless this is set to true" and recommended enabling it, and
debug_web_manual.py printed False. Troubleshooting a down web UI pointed
users at a non-cause.

Both shell scripts now evaluate the setting with the launcher's own
autostart_enabled() (inline fallback if it cannot be imported) and report
on / off / not set (on) / unparseable config; debug_web_manual.py uses
the same function. They also check web_interface/blueprints/api_v3/
__init__.py: api_v3.py became a package in #553, so every healthy
checkout was reported as missing a file.

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

* docs(install): what install_service.sh installs; verify script port; no sudo for --help

install_service.sh installs and starts ledmatrix, ledmatrix-web and the
update-verify units, not only ledmatrix.service (systemd/README.md,
README.md). MIGRATION_GUIDE presented 'sudo install_service.sh --help'
as a harmless check; it now shows --help without sudo and warns what a
real run does. SSH_UNAVAILABLE_AFTER_INSTALL: verify_installation.sh
checks the web interface on port 5000.

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

* docs(changelog): note update-all, plugin system settings and script fixes

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

* fix(display): size the preview after orientation and pixel mappers

display_geometry.physical_size claimed to give DisplayManager's answer but
only computed cols*chain x rows*parallel. RGBMatrix.width/height are measured
after the library's pixel mappers, so a Rotate:90 / orientation 90 chain
previewed 128x32 for a 32x128 panel and a U-mapper chain of four 256x32 for
128x64.

Model the built-in mappers' size effect as the pinned lib/pixel-mapper.cc
does (Rotate, U-mapper, V-mapper, StackToRow, Remap; Mirror and unknown
names leave it alone), and move the orientation composition here so
DisplayManager and the preview share it. The module docstring no longer
claims the sync handshake uses it; that imports only DEFAULT_CHAIN_LENGTH.

Audit finding F18.

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

* fix(display): refuse settings the rgbmatrix library aborts on, on every board

The library answers several settings with a NULL matrix or abort() rather
than an error, so the display service crash-looped (Restart=on-failure)
instead of reaching fallback mode: rows above 64, chain_length above 255
(uint8_t binding setter, documented as "no upper limit"), a misspelled
hardware_mapping, and parallel 2-3 on a single-output mapping, reachable
from the Display form on the default adafruit-hat(-pwm) mapping. #586 only
guarded the Pi 5 subset.

- src/matrix_support.py holds the rules for every board (Options::Validate
  ranges, binding integer types, mapping names and outputs from
  lib/hardware-mapping.c) plus the Pi 5 ones, and is the one source of the
  API's numeric ranges.
- DisplayManager checks them before building options and raises
  MatrixSettingsRefused, so a hand-edited config falls back with a logged,
  reported reason. Emulator mode only warns.
- The config API refuses them with a 400 naming the setting; combinations
  are checked against stored values but reported only when the request
  sets a field involved.
- The hardware status file gains "cause" (settings/library/forced). The
  fallback log and Display banner give the Pi 5 rebuild hint only for a
  library failure instead of rebuild + gpio_slowdown advice for every
  failure; one Pi 5 slowdown recommendation (1-3, start at 1).
- The Display form offers classic/classic-pi1 and orientation 90/270 and
  renders any other stored mapping selected with a warning, so an
  unrelated save no longer rewrites them; the API accepts 90/270.

Audit findings F03, F16, F19, F21.

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

* docs(display): library limits, template defaults and Pi 5 slowdown

- rows 8-64, chain_length 1-255, parallel limited by the mapping's outputs,
  classic/classic-pi1 mappings and orientation 90/270 documented.
- Defaults are the config.template.json values: config migration adds
  missing keys from the template, so the listed "code defaults" never
  applied.
- One Raspberry Pi 5 gpio_slowdown recommendation: 1-3 in PIO mode,
  starting at 1.
- Troubleshooting describes the refused-settings fallback, and CHANGELOG
  corrects the Unreleased "no upper limit" entry.

Audit findings F19, F20, F21.

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

* fix(scripts): scroll_speeds.py opens the panel with the service's options

--measure and --demo built RGBMatrixOptions from a private copy of the
display service's builder that had drifted: gpio_slowdown came from
display.hardware (default 2) instead of display.runtime (default 3), and
rp1_rio, panel_type, disable_hardware_pulsing, inverse_colors,
pixel_mapper_config and orientation were skipped, with different defaults
(hardware_mapping "regular", pwm_bits 11). A panel needing a high slowdown
was measured -- or garbled -- in a setup the service never drives.

The option filling in DisplayManager._setup_matrix moves, unchanged, into
DisplayManager.apply_matrix_options(options, config), which _setup_matrix
calls and the script reuses (overriding only limit_refresh_rate_hz for
--measure). The script now loads the whole config rather than the hardware
block. Tests pin the script's options to the service's attribute for
attribute.

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

* fix(scripts): scroll_speeds.py recommends keys the resolver honours

The ladder ended by telling users to set
display_options.scroll_pixels_per_second. scroll_config ranks that key
below the scroll_speed + scroll_delay pair, deliberately, and several
plugin schemas default the pair into config, so the advised key was
silently ignored (a schema-default 1/0.02 pair plus an advised 66 still
resolved to 50 px/s).

The advice is now the pair that selects the crisp speed exactly
(pixels_per_frame every frame_hold/refresh seconds), explains that the
pair outranks scroll_pixels_per_second, and gives the scoreboards'
per-league scroll_settings.scroll_speed (px/s) form. Tests resolve the
printed pair over a schema-default pair and check it lands on the
advertised speed and hold.

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

* docs: withdraw the target_fps claim for sports_scroll; fix the Vegas speed formula

- SPORTS_UNIFICATION.md still presented honouring global target_fps as
  sports_scroll's added behaviour and its one user-visible gain; note that
  it was withdrawn because it had become a speed multiplier.
- ADVANCED_FEATURES.md gave Vegas scrolling as
  (scroll_speed / target_fps) * elapsed; the real rule is scroll_speed px/s
  by elapsed time, through a 0.1-5 px per scroll_delay clamp when
  frame_based_scrolling is on.

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

* docs(changelog): scroll model fixes

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

* fix(dev): link-github links plugins from the ledmatrix-plugins monorepo

link-github <name> cloned https://github.com/ChuckBuilds/ledmatrix-<name>.git,
and those per-plugin repositories no longer exist: official plugins are
directories in the ledmatrix-plugins monorepo. It now clones (or pulls) the
monorepo once into the dev directory, finds plugins/<name>,
plugins/ledmatrix-<name> or the plugin whose manifest id is <name>, and
links it under its manifest id. With an explicit repo URL it still links a
single-repository plugin as before.

dev_plugins.json: github_user is honoured again (monorepo owner, e.g. a
fork), plus plugins_repo and plugins_branch; github_pattern, which was
documented but never read, is dropped and warned about. Ships
dev_plugins.json.example and git-ignores dev_plugins.json, both of which
the guide promised. Reading JSON falls back to python3 when jq is missing
(get_plugin_id silently returned nothing without jq).

update/status/list find the git checkout above a monorepo plugin
directory (its .git is not in the plugin dir), and update pulls a shared
checkout once. status no longer exits 1 when nothing is broken.

Docs: PLUGIN_DEVELOPMENT_GUIDE (quick start, link-github, configuration,
workflow, store integration, hello-world link, submission), and the
nonexistent scripts/git-hooks/pre-push-plugin-version and
scripts/bump_plugin_version.py replaced with the real rule: bump the
manifest version and run update_registry.py. scripts/dev/README.md and
CLAUDE.md updated to match.

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

* docs(scripts): monorepo workspace layout; fix_perms and install READMEs

MULTI_ROOT_WORKSPACE_SETUP described one sibling repository per plugin;
setup_plugin_repos.py links ../ledmatrix-plugins/plugins/* into
plugin-repos/ and update_plugin_repos.py pulls only the monorepo, and the
workspace file opens LEDMatrix plus ../ledmatrix-plugins.

scripts/fix_perms/README.md listed cache directories
fix_cache_permissions.sh never touches and a 'ledmatrix' service user
that doesn't exist (also in scripts/install/README.md); adds
safe_pip_install.sh. install/README: install_service.sh installs the web
and update-verify units too.

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

* fix(update): keep the rollback's pip retries inside the unit time limit

The health check reinstalled the previous requirements by trying the
next bash path after any failure, including a 600 s pip timeout. Two
files, two paths: up to 40 minutes of pip alone, while systemd stops
ledmatrix-update-verify.service at TimeoutStartSec=30min -- killing the
rollback half-way and leaving the update 'verifying' until the web UI
calls it lost.

- Like permission_utils.install_requirements_file, only a sudo refusal
  moves on to the next bash; a pip that ran and failed or timed out is
  not repeated. The refusal wording is one list
  (permission_utils.SUDO_REFUSAL_PHRASES), mirrored in the stdlib-only
  verifier and pinned equal by a test.
- All reinstalls in one rollback share a 600 s budget.
- WORST_CASE_SECONDS adds up every timeout on the longest path (27.5
  min); a test holds it under the unit's TimeoutStartSec and that under
  the web UI's VERIFY_LOST_SECONDS.

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

* fix(plugins): prepare plugin configs one way for load, saves, GET, hot reload and dev tools

Plugin config was prepared differently depending on how it arrived:

- JSON POST /plugins/config built a partial body on schema defaults, so
  {"enabled": true} reset every other setting of the plugin. It now merges
  onto the stored section first, as the form path already did.
- Legacy-boolean normalization (#588) ran only at load: GET /plugins/config
  returned the raw boolean, posting it back failed validation, and hot
  reload handed plugins the raw section (a legacy dynamic_duration: true
  came back as a boolean). schema_manager.prepare_plugin_config (normalize,
  then defaults) is now used by PluginManager.load_plugin, both save paths,
  GET, the save notifications and DisplayController's hot-reload callback.
- The JSON save's filter kept only enabled/display_duration/live_priority
  and dropped a submitted skin, skin_options or vegas_* tuning key. There
  is now one core-owned per-plugin list, schema_manager.CORE_PLUGIN_PROPERTIES,
  used by validation and by the save filter; PluginManager's
  CORE_OWNED_CONFIG_KEYS is its vegas subset.
- Plugin sections posted to /config/main were stored verbatim, including
  values /plugins/config rejects. They now go through the same preparation
  (_prepare_plugin_config_for_save, extracted from save_plugin_config), and
  a failing section rejects the whole save before anything is written.
- dev_server read only top-level defaults and let a schema enabled:false
  win; build_full_config shallow-merged overrides, dropping sibling
  defaults; the harness extracted defaults differently from the device.
  loading.build_config now uses the device's extraction and preparation,
  and dev_server, check_plugin, render_plugin and the harness all use it.

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

* docs(mqtt-bridge): brightness changes apply live and touch nothing else

The display service's hot reload applies a saved brightness within a few
seconds, and /config/main no longer resets other display settings on a
brightness-only JSON body.

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

* docs(changelog): automatic update hardening

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

* docs(config): rewrite PLUGIN_CONFIG_ARCHITECTURE for the v3 web UI

It described web_interface_v2.py and index_v2.html (both gone), client-side
form generation, one POST per field with {key, value}, and 'no nested
objects'. The v3 UI renders plugin forms server-side from the schema
(pages_v3 partial + plugin_config.html macros, nested sections and
x-widgets), posts the whole form once, and save_plugin_config() merges onto
the stored section, validates, splits x-secret fields and notifies the
plugin.

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

* docs(mqtt): brightness saves apply via hot reload and leave other settings alone

The bridge README said brightness is applied on the display's next
restart; the display controller's config hot reload applies it within
seconds. It also now states that the bridge's partial JSON save changes
only brightness (the /config/main merge fix in this change set).

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

* fix(update): don't log pip's output from the health check's reinstall

pip can echo a private index URL with embedded credentials;
permission_utils redacts it, the stdlib-only verifier cannot, so it
logs the exit code only.

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

* docs(config): mark the plugin_system toggles as unused legacy keys

auto_discover, auto_load_enabled and development_mode are read by
nothing and leave the General tab in this change set (F40). CONFIG_REFERENCE
said they were read by the plugin loader; PLUGIN_CONFIGURATION_GUIDE and
the REST reference listed them as live settings.

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

* docs(changelog): docs and developer tools group

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

* fix(web): legacy plugin-system toggles no longer count as a General save

auto_discover, auto_load_enabled and development_mode have left the General
form, so a post carrying only one of them is not a general-settings save and
must not treat web_display_autostart and auto_update as unchecked. The
plugin_system block itself is left as on main for the branch that reworks it.

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

* docs(changelog): config-save and plugin-config preparation fixes

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

* docs(claude): re-check matrix_support.py rules when the library submodule is bumped

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

* fix: address Codacy findings on the core audit PR

- plugin_manager.prepare_plugin_config: when the fallback legacy-boolean
  pass also fails, log a warning instead of a bare except/pass.
- api_client.js: request() refuses any endpoint that is not a plain path
  under /api/v3 ("//host", backslashes, ".." or "." segments, whitespace,
  control characters) with INVALID_ENDPOINT before calling fetch(), and
  plugin ids are URL-encoded wherever they are put into a URL (also in the
  app-shell batch load).
- test_update_all.js: pins both against the shipped client.

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

* fix(web): check endpoint control characters without a control-character regex

Codacy (ESLint no-control-regex, Biome noControlCharactersInRegex) flags
the \x00-\x1f range in checkEndpoint's regex. Test the char codes
instead; the endpoints refused are unchanged.

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

* test(auto-update): make the seed script executable on disk, not only in the index

On Linux Repo.publish() commits with -a, which recorded scripts/run.sh
as 100644 upstream because the seed file was never chmod +x. The pull
then brought in the same mode the installer chmod had made locally, so
installer_chmod saw no mode change left to check. The updater was fine:
with the upstream commit at 100755 the --autostash carries the device's
chmod across. Verified under Linux (WSL, git 2.43): the old helper fails
exactly as CI did, the fixed one passes all 63 tests in the file.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:37:29 -04:00
ChuckandClaude Opus 5 7e5967e160 fix(web): find installed plugins before anything has discovered them (#594)
The web process discovers plugins lazily: plugin_manifests is empty until
some endpoint calls discover_plugins(). Three routes consulted it without
discovering, so they misbehaved for as long as nothing else had run --
which, after every ledmatrix-web restart, is until someone opens the
dashboard:

- POST /display/on-demand/start answered 404 "Plugin <id> not found"
  (or "Mode <mode> not found"). Measured on a rig: 404 for over three
  minutes after a web restart, until GET /plugins/installed ran. The
  browser UI loads the plugin list first, so API-only callers (the Home
  Assistant MQTT bridge, scripts) are the ones who hit it.
- POST /plugins/toggle answered 404 "Plugin not found".
- POST /config/main did not recognise a plugin section, so it skipped
  secret separation and merged the section as-is: the plugin's API key
  was written to config.json in plain text instead of config_secrets.json.

Add _discovered_plugin_manifests(), which discovers when nothing has been
yet, and rescans 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. _installed_plugin_ids() now uses it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:55 -04:00
ChuckandClaude Opus 5 f475038895 fix(cache): web UI can read what the display service caches again (#593)
* fix(cache): web UI can read what the display service caches again

ledmatrix-web.service carried CacheDirectory=ledmatrix. With User= set to
the installing user, systemd re-owns /var/cache/ledmatrix and everything
in it to that user and its primary group whenever the directory's owner
differs -- for a directory root created, on the first start. That erased
the root:ledmatrix setgid layout the installers set up, so every file the
display service (root) wrote afterwards was root:root 0660 and unreadable
by the web interface:

  WARNING - Permission denied loading cache for display_current_state ...

Since #547 install_service.sh renders the web unit from the template, so
every fresh install hit this. Measured on one rig: 392 unreadable files,
and the web UI's display status, on-demand state and plugin health empty.

Existing installs only receive `git pull`, never a reinstalled unit, so
the fix for them is in the code the root display service runs:

- DiskCache.set gives each file the directory's group (when the directory
  is group-writable) and 0660 on the open descriptor before the rename,
  independent of setgid. This also closes a window where a fresh file was
  visible as mkstemp's 0600.
- DiskCache.share_existing_files repairs files an older version left
  behind, once per process from the cleanup thread. It works through
  O_NOFOLLOW descriptors and skips hard links and other users' files: the
  directory is writable by the web user, and root must not be steered
  into changing a file outside it.

For new installs, the web unit drops CacheDirectory=/CacheDirectoryMode=,
and install_web_service.sh stops replacing an existing directory's
ledmatrix group with the user's group.

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

* fix(web): on-demand and current-display status read the display's latest state

Found testing the cache-permission fix on a rig: once the web interface
could read display_on_demand_state at all, /display/on-demand/status kept
answering "active" for over 100 seconds while the file on disk said
"idle". Both status routes read the display service's keys through the
web process's memory tier, which serves the first copy it read for the
full max_age (120s). Read them with memory_ttl=0, as every other
cross-process reader (plugin health/metrics, the on-demand mailbox)
already does.

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

* fix(install): re-group the cache dir whenever the web user is outside its group

install_web_service.sh replaced an existing cache directory's group only
when it was root's. A directory in any other group the web user is not a
member of -- root:ledmatrix, for a user who is not in ledmatrix -- was left
alone, and every file root wrote there stayed unreadable to the web
interface. Replace the group whenever the installing user is not in it.

A directory whose group the user is already in (ledmatrix, or the user's
own group where CacheDirectory= left it) is still left as it is: re-grouping
a working directory strands the files already in it on the old group.

When the group does change and root-owned JSON files carrying the old group
are present, try-restart ledmatrix.service so DiskCache.share_existing_files
re-groups them through its symlink- and hard-link-safe path, rather than a
recursive chgrp.

Verified under WSL's systemd for seven directory states (user group,
ledmatrix member, ledmatrix non-member with and without root files,
root:root, missing, unnamed gid); the previous version left the non-member
case unchanged.

Addresses CodeRabbit review on #593.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:39 -04:00
ChuckandClaude Opus 5 1d51efe4c7 fix(backup): restore over existing files on hosts without os.chown (#592)
_copy_file() replaces each restored file and then carries the previous
owner across with os.chown. On Windows os.chown does not exist and
st_uid/st_gid are 0 rather than absent, so the ownership branch always
ran and raised AttributeError. That is not an OSError, so it escaped
every per-section handler in restore_backup(): a restore over any
existing config aborted at config.json and restored nothing.

Skip the ownership step where os.chown is missing, as
auto_update_setup.py already does. No change on POSIX.

test_restore_over_a_file_the_user_cannot_write simulates root-owned
files with chmod 0o444; on Windows that sets the read-only attribute,
which blocks any rename over the file, so it is skipped there. The
modes the app writes (0o644/0o640/0o600) replace fine on Windows.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:26:21 -04:00
ChuckandClaude Opus 5 7ae614aa35 fix(sports): recover from ESPN rejecting scoreboard date ranges (#591)
* fix(sports): recover from ESPN rejecting scoreboard date ranges

Since 2026-09-15 ESPN's site API answers `dates=YYYYMMDD-YYYYMMDD` with
400 "Failed to get events endpoint." for every sport. Single days, months
(`YYYYMM`) and season years still work. Every season and weeks-window fetch
in core failed, including the background service the scoreboards submit
their season schedules to.

src/common/espn_dates.py re-asks a rejected range as whole-month chunks
plus the leftover edge days, which tile the window exactly (a season is
8 requests, not 213). A month that comes back with exactly 500 events is
truncated (college baseball's March) and is re-asked day by day.

It also clamps `limit` to 500: above that ESPN truncates silently, e.g.
college football returns 25 of 68 games for one Saturday at limit=1000.

BackgroundDataService recovers rejected ranges on the worker thread and
advertises `handles_espn_date_ranges` so plugins can tell whether to hand
it a range. SportsCore, sports_shared, ESPNDataSource and APIHelper route
through the helper or the clamped limit.

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

* docs(changelog): ESPN date-range fallback and limit clamp

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

* fix(sports): stop re-sending ESPN date ranges once one is rejected

Live scoreboards refresh every 30 seconds, and each refresh sent the range
first, got the 400, then fetched the chunks: three requests where one used
to do. After a rejection, ranges now go straight to chunks for six hours,
then the range is tried again so the workaround retires itself if ESPN
reverts. A single-day 400 does not set the memo, and when every chunk fails
the range request supplies the error without the chunks being fetched a
second time. Per-fetch chunk logging drops to debug; the rejection itself
stays a warning.

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

* fix(sports): clamp limit only on ESPN scoreboard submissions

The background service is generic, and limit above 500 only truncates
scoreboards. /teams needs limit=1000 (college football has 762 teams and
limit=500 returns 500), so a teams submission must keep its limit.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:12:28 -04:00
ChuckandClaude Opus 5 2082665252 fix(config): load config_secrets.json on hosts without os.geteuid (#590)
ensure_shared_group_ownership() - the chgrp self-heal ConfigManager runs
before reading config_secrets.json (#416) - looked up os.geteuid
unguarded. That name does not exist on Windows, and the AttributeError
is not an OSError, so it escaped the helper's best-effort handling and
every except clause in load_config(). Any Windows checkout with a
config/config_secrets.json got a ConfigError from every config load and
could not import web_interface.app.

That is what made test_update_all_plugins.py error at setup: its client
fixture imports web_interface.app. It was not state leaked between test
files - the trigger is whether the checkout has a secrets file.

Return early when os.geteuid or os.chown is missing. No change on POSIX.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:12:18 -04:00
ChuckandClaude Opus 5 f9b3d6ae52 fix(web): accept every panel size and row address type the rgbmatrix library does (#586)
* fix(web): accept every panel size and row address type the rgbmatrix library does

The Display form capped columns at 128 and chain length at 24, and its
submit handler (fixInvalidNumberInputs) rewrote anything larger to the cap,
so wide panels and long chains silently saved as the wrong size. The config
API checked none of the hardware numbers, so values the library rejects (odd
rows, parallel 4, PWM dither bits 3) saved and the matrix then refused to
start.

- Form limits now match the pinned library: rows even 8-64, cols >= 16 and
  chain_length >= 1 with no upper bound, parallel 1-3, PWM dither bits 0-2,
  PWM LSB nanoseconds 50-3000.
- save_main_config rejects out-of-range rows, cols, chain_length, parallel,
  brightness, scan_mode, pwm_bits, pwm_dither_bits, pwm_lsb_nanoseconds and
  gpio_slowdown with a 400.
- A stored gpio_slowdown or pwm_dither_bits of 0 renders as 0 instead of the
  default, so saving the tab no longer overwrites it.
- Row Address Type offers 5 (SM5368 / B707 row shift register). Verified on a
  Waveshare 96x48 V2 (24S-A1) on a Pi 4 with the Adafruit Triple LED Matrix
  Bonnet: rows 48, cols 96, row address type 5, BGR, GPIO slowdown 8.
- Help text and docs: FM6124-family panels use Panel Type Standard; on a Pi 5
  the library supports only row address types 0 and 2.

No change to the rpi-rgb-led-matrix submodule.

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

* fix(web): drop the rows cap and document every display setting accurately

Rows: no upper limit in the form or the API. Still even and at least 8. The
current rgbmatrix library rejects more than 64 per panel, so a larger value
saves but the matrix won't start; the help tip, README, config reference and
troubleshooting section all say so, and nothing here needs changing if the
library lifts the limit.

limit_refresh_rate_hz: the form accepts 0 (the library's "no cap"), a stored
0 no longer renders and re-saves as 120, and the API rejects negatives.

pwm_dither_bits stays 0-2: the library rejects 3 and 4, so the old form's
0-4 only ever let users save a config the display couldn't start with.

Docs and help tips, checked against the pinned library and its README:
- panel_type and rp1_rio get README entries
- show_refresh_rate prints to stdout; it never drew on the panel
- dither bits raise the refresh rate; the tip said they lowered it
- scan_mode is about interlacing at low refresh, not wrong colours
- disable_hardware_pulsing: hardware pulsing needs OE on GPIO 18 and the
  onboard sound driver off; software timing makes rows flash brighter
- gpio_slowdown guidance agrees between the README and the UI
- all 22 multiplexing values listed; every numeric setting states its range
- troubleshooting for a blank panel after a settings change, jumping rows
  and brightness flashes

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

* fix(web): reject true and 5.5 for row_address_type and multiplexing

Both still went straight through int(), so a JSON true saved as 1 and 5.5
as 5. They now use the shared hardware range check like the other panel
fields. Review feedback on #586.

Also: the RP1 Backend tooltip said it is ignored on Pi 3/4 (it is ignored
on every model but the Pi 5), and the README gave the dynamic-duration
default cap as 90s; the code default is 180s.

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

* feat: refuse matrix settings a Raspberry Pi 5 can't drive

On a Pi 5 the pinned rgbmatrix library drives the panel through the RP1
chip, and that path supports only row address types 0 and 2, parallel 1-3
and the regular / regular-pi1 / classic / adafruit-hat(-pwm) mappings
(Rp1PioConfigSupported in lib/rp1/rp1_pio_backend.cc). For anything else
CreateFromOptions returns NULL; the Python binding doesn't check, so the
display process crashed on its first call into the matrix and systemd
restarted it into the same crash every 10 seconds.

- src/pi5_matrix_support.py: the rule and Pi 5 detection, matching the
  library's /proc/device-tree/model check
- DisplayManager raises before creating the matrix, so it is a logged init
  failure (reported by /api/v3/hardware/status) and fallback mode
- the config API rejects those settings on a Pi 5 when a request sets
  row_address_type, parallel or hardware_mapping
- the Display form offers only row address types 0 and 2 on a Pi 5, and
  warns when a stored value can't be used
- CLAUDE.md: re-check the rule whenever the submodule is bumped

Review feedback on #586.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:17:48 -04:00
ChuckandClaude Opus 5 9616a5a054 fix(web): auto_update and other core settings are not orphaned plugins (#589)
v3.4.0 shows "Plugin Config Warning - In config but not installed:
auto_update. Reinstall via the Plugin Store, or remove these entries from
config.json." auto_update is the core weekly-update setting from #581.
Reconciliation treated every top-level dict not in its private
_SYSTEM_CONFIG_KEYS list as a plugin id, and #581 could not know to extend
that list.

- Move core top-level keys into src/core_config_keys.py (CORE_CONFIG_KEYS)
  and use it in reconciliation. Tests fail if a config.template.json key or
  a key written by the general-settings save is missing from it.
- A secrets-file key only counts as a non-plugin when no installed plugin
  has that id. Plugin secrets are namespaced by id, so installed plugins
  with secrets were reported as missing from config on every run.
- still_unresolved() drops "not on disk" findings whose id is no longer a
  plugin entry in config, so a stored verdict clears without a restart.
- A plugin whose id is a core key is skipped with a warning, and the fix
  never writes a plugin stub over or in place of a core setting.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:19:39 -04:00
ChuckandClaude Opus 5 c200b5837d fix(plugins): normalize legacy boolean settings before schema validation (#588)
The news plugin's schema turned global.dynamic_duration from a boolean
into an {enabled, min_duration_seconds, ...} object. Installs that have
not saved the news settings since still hold `true`, so every start
logged "Plugin news config does not match its schema (loading anyway):
Field 'global.dynamic_duration': Expected type object, got bool" and
flagged news degraded.

The settings form already reads such a boolean as {"enabled": <bool>}
(render_nested_section in plugin_config.html) and the next save writes
the object. The loader did not. It now applies the same rule before
merging schema defaults and validating, so the defaults fill in the rest
of the object and the plugin receives it in the new shape.

The rule lives in schema_manager.legacy_bool_as_object /
normalize_legacy_booleans. It applies at any depth of nested objects
but not inside arrays, matching the form, and only to a real bool under
an object-typed property with an `enabled` child. Every other mismatch
still warns. A parity test renders the template macro against the helper
so the two cannot drift.

Nothing is written to config.json at load: the normalization is in
memory, and the next save of the plugin's settings persists the object.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:09:23 -04:00
ChuckandClaude Opus 5 9f2743471c fix(web): update-all skips Starlark apps and no longer misses plugins (#587)
* fix(web): update-all skips Starlark apps and no longer misses plugins

Check & Update All posted every entry from /plugins/installed to
POST /plugins/update, including the virtual starlark:<app_id> entries
that list installed Starlark apps. The store manager cannot find those,
so each answered 500 "plugin not found". Update-all now sends only
plugin ids (install_manager.js, and the older app-shell.js copy), and the
route answers a starlark: id with a 400 saying it is a Starlark app.

A request that got no HTTP answer was recorded as failed and never sent
again. On a device, a web-service restart mid-run killed the in-flight
request and refused the next one, stock-news, which was left on 2.6.2
with 2.8.0 available. Such requests are now re-sent with backoff
(about 30s) before being reported as failed. HTTP error answers are not
retried.

Tests: test/js/unit/test_update_all.js (run from pytest via
test/web_interface/test_update_all_plugins.py so CI covers it) and the
route contract for starlark: ids.

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

* refactor(web): walk update-all retry delays without indexed lookup

Codacy's ESLint security/detect-object-injection rule flagged
retryDelays[attempt] as a High issue. The index was a bounded loop
counter over a fixed array, but shifting a per-plugin copy of the
schedule gives the same backoff without the pattern. No behaviour
change: test/js/unit/test_update_all.js (21) and
test/web_interface/test_update_all_plugins.py (7) pass unchanged.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:09:11 -04:00
ChuckandClaude Opus 5 fddb0e06db feat(web): honour x-display: hidden in plugin settings (#585)
* feat(web): honour x-display: hidden in plugin settings

Plugins keep deprecated and internal keys declared so stored configs keep
validating (weather api_key/radar_zoom, countdown's auto-generated row id),
but the settings form drew them as live controls.

A property marked "x-display": "hidden" -- or an object whose children are
all hidden -- now gets no control at any depth: top level, nested sections,
Advanced Settings (not counted either), array-table columns and the row
editor. A hidden top-level key is not reported in __rendered_section.

Saving never changes a hidden value. Plain and nested fields aren't posted,
so the save's deep merge keeps them; _set_missing_booleans_to_false skips
hidden booleans at every depth. A posted array row replaces the stored item,
so hidden row properties are carried as JSON-encoded hidden inputs and
decoded exactly on save (an id "1" stays a string). New rows get none.
JSON API saves are unchanged.

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

* refactor(web): read hidden row keys without dynamic property access

Build the set of x-display: hidden item properties once and look values up
through Object.entries, instead of indexing objects by a variable key on
the lines this branch added (Codacy: object injection sink, 6 warnings).
Behaviour is unchanged.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:47:06 -04:00
ChuckandClaude Opus 5 8360220809 feat(common): sports_helpers — the helpers all nine scoreboards carry identical copies of (#583)
* feat(common): sports_helpers, the helpers all nine scoreboards copy verbatim

Add src/common/sports_helpers.py: the helpers the scoreboard plugins'
sports.py carry byte-identical copies of (docstring-stripped AST, checked at
ledmatrix-plugins f09bff2), so a later plugins PR can delete its copies once
it floors on the core release that ships this.

- Free functions: clamp_window, clamp_seconds, logo_needs_refresh (lazy
  src.logo_downloader import, as in the plugins), spread_weighted_order,
  MIN_WINDOW_DAYS / MAX_WINDOW_DAYS. All nine plugins.
- SportsHelpersMixin (no __init__, stateless): _mode_customization,
  _setting_int, _reset_dwell_on_reentry, _next_switch_index,
  _spread_weighted_order (all nine), _odds_color and
  _upcoming_date_and_time_text (all but ufc), plus the _favorite_key seam
  from base_classes core.py for later phases.

A new module rather than more methods on sports_shared: a plugin that
deletes a copy and relies on an existing module having grown the method
fails at runtime with AttributeError on an older core, which neither the
loader nor check_min_core_version.py can see; a missing module fails at load.

Tests: behaviour for every helper, a derived host contract, and a parity
test that AST-compares every body against every plugin copy when
LEDMATRIX_PLUGINS points at a checkout (skipped otherwise).
test_common_is_hardware_free.py imports src.common and every sports_* module
with rgbmatrix blocked and scans src/common for module-level imports of
src.base_classes, src.display_manager and src.plugin_system (no existing
violations).

Nothing in core imports the new module; no behaviour change. CHANGELOG
Unreleased entry and a converging note in docs/SPORTS_UNIFICATION.md.
__version__ is not bumped.

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

* fix(common): address review on sports_helpers and the hardware-free test

- SportsHelpersMixin docstring and CHANGELOG: constructor-free, but it keeps
  lazy state on its host (_reset_dwell_on_reentry, _next_switch_index).
- test_common_is_hardware_free: the runtime check now filters every
  FORBIDDEN package, src.plugin_system included; the AST scan resolves
  relative imports against src.common, so `from .. import plugin_system`
  and `from ..plugin_system import x` are caught. Guard tests for both.
- Parity skip reason names the CI guard that runs the same comparison:
  ledmatrix-plugins scripts/check_sports_helpers_parity.py (#495).
- _odds_color: line-level pylint disable for a not-callable false positive
  (getter is None-checked); the AST is unchanged, parity still passes.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:32:21 -04:00
145 changed files with 14578 additions and 3110 deletions
+6
View File
@@ -36,6 +36,12 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Review PRs opened by the Claude GitHub App. Without this the action
# aborts before reading the diff ("Workflow initiated by non-human
# actor"), so every such PR shows this check red. Named rather than
# '*': the allow-list is matched against the triggering actor, so
# this admits claude[bot] alone and no other app.
allowed_bots: 'claude'
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+4 -3
View File
@@ -39,11 +39,12 @@ htmlcov/
# Cache directory (root level only, not src/cache which is source code)
/cache/
# Development plugins directory
# Plugins are managed as separate repositories via multi-root workspace
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
# Development plugins directory: symlinks into a ledmatrix-plugins checkout
# See docs/PLUGIN_DEVELOPMENT_GUIDE.md and docs/MULTI_ROOT_WORKSPACE_SETUP.md
plugins/*
!plugins/.gitkeep
# Local settings for scripts/dev/dev_plugin_setup.sh (template: dev_plugins.json.example)
/dev_plugins.json
# Binary files and backups
bin/pixlet/
+401
View File
@@ -19,6 +19,407 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
## 3.5.0
New modules a plugin may import via `src.*` (floor on 3.5.0):
- `src/common/sports_helpers.py` — the helpers the scoreboards' `sports.py`
carry byte-identical copies of: `clamp_window`, `clamp_seconds`,
`logo_needs_refresh`, `spread_weighted_order` (+ `MIN_WINDOW_DAYS`,
`MAX_WINDOW_DAYS`), and `SportsHelpersMixin` with `_mode_customization`,
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
`_spread_weighted_order`, `_odds_color`, `_upcoming_date_and_time_text` under
the plugins' names and signatures, plus the `_favorite_key` override point.
Constructor-free; keeps lazy state on its host (see the module docstring,
which also gives the host contract).
A new module rather than more methods on `sports_shared`: a plugin that
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.
### Config saves and plugin config preparation
- A JSON `POST /api/v3/config/main` changes only the keys it sends. The MQTT
bridge's brightness slider used to turn off `disable_hardware_pulsing`,
`inverse_colors`, `show_refresh_rate` and `use_short_date_format`, and a
timezone- or location-only save turned off web-UI autostart and weekly
automatic updates. Missing checkboxes still save as unchecked for the
settings forms (they now send a hidden `__form_section` field) and for
form-encoded posts.
- A partial JSON `POST /api/v3/plugins/config` merges onto the plugin's stored
settings instead of resetting everything it didn't send to the schema
defaults, and keeps a submitted `skin`, `skin_options`, `vegas_width_pct`,
`vegas_overflow` or `vegas_max_width_screens` (they were silently dropped).
- Plugin sections posted to `/config/main` are validated and prepared exactly
like `/plugins/config`; a value that endpoint rejects is rejected here too,
and nothing is saved.
- Legacy boolean settings (#588) are read as `{"enabled": ...}` objects
everywhere, not just when the plugin loads: `GET /plugins/config` returns
the object, posting it back saves, and hot reload hands plugins the same
shape (schema defaults included) they were constructed with.
`schema_manager.prepare_plugin_config` is the one implementation.
- A plugin's settings tab shows schema defaults for options its saved config
doesn't have yet. A boolean added with `"default": true` in a plugin update
(geochron 1.2.0's `show_date` and `show_date_line`) used to render unchecked,
and the next save of that tab stored it as `false`. Enum dropdowns likewise
showed their first option instead of the default. The partial now runs the
stored section through `prepare_plugin_config` like `GET /plugins/config`
(secrets are still masked, after the merge), and the form falls back to a
field's own `default` inside objects that declare a default of their own.
- `scripts/dev_server.py`, `check_plugin.py`, `render_plugin.py` and the plugin
harness build configs the way a device does: nested defaults are included,
a schema `enabled: false` no longer beats the forced `enabled: true` in the
dev server, and nested overrides such as `{"nhl": {"enabled": true}}` keep
the other defaults of that section.
- Clearing Vegas "Min/Max Cycle Time" no longer rejects the whole Display save,
and those fields no longer add junk entries to `display.display_durations`.
- Turning automatic updates on from the Raw JSON editor finishes their setup
like the General tab does, instead of waiting for the next display restart.
- `POST /config/schedule` and `/config/dim-schedule` accept the per-day
`days.<day>.{enabled,start_time,end_time}` shape their GETs return, as well
as the flat form keys.
- The startup check no longer warns that `auto_update` or `dim_schedule` is
"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`.
### Sports data
- Since 2026-09-15 ESPN answers `dates=YYYYMMDD-YYYYMMDD` scoreboard queries
with `400 Bad Request` for every sport, so season schedules, the weeks window
and today's games all failed ("400 Client Error" from the NFL/NCAAFB managers
and `src.background_data_service`). A rejected range is now re-fetched as
whole months (`dates=YYYYMM`) plus the leftover days at each end, which cover
the window exactly: a football season is 8 requests. A month that returns
exactly 500 events is truncated and is re-fetched day by day.
- Scoreboard requests send `limit=500` at most. Above 500 ESPN silently returns
a short list: college football gave 25 of 68 games for one Saturday at the
`limit=1000` everything used to send.
- `BackgroundDataService.handles_espn_date_ranges` is `True`. Plugins check it
to decide whether to submit a season range to the service or fetch it
themselves on an older core.
- A league with no live games no longer backs its poll off past the next
kickoff. The escalation counted empty looks and nothing else, so a league
three hours before kickoff was indistinguishable from one out of season and
both reached `live_idle_max_interval`: measured gaps of up to 928 seconds,
and a rig that sat for a quarter of an hour with eight NFL games in progress
without noticing any of them. The wait is now clamped so it cannot run past
the earliest start still ahead, which the live fetch already downloads, so
it costs no extra request. Just after a kickoff the live cadence is held for
a grace window, because a provider that has not yet flipped the status would
otherwise read as another empty check and escalate the back-off again.
- ESPN date chunks are fetched six at a time (`ESPN_CHUNK_WORKERS`) in two
passes: months and edge days first, then the days of any month that came
back at the cap. A cold college-baseball season is about 130 requests, and
they went out one at a time; March and April measured on a Pi 4 (63
requests, 3101 events) went from 11.2s to 1.6s. Merged events still follow
`espn_date_chunks` order, so the payload does not depend on which request
won the race, and a capped month's payload is dropped before its days are
fetched, which keeps the peak memory of a four-capped-month fetch to about
16 MB over the sequential path rather than 43 MB — `docs/LOW_MEMORY_BOARDS.md`
puts a 1 GB Pi 3B+ at under 200 MB of headroom.
### Scrolling
- **Scoreboard scroll speed no longer changes with the General tab's "Scroll
Frame Rate" (`target_fps`).** Scoreboards on `src.common.sports_scroll`
computed their speed for that rate while the panel kept presenting at its
real refresh, so on a 100 Hz panel 60 ran a 50 px/s scoreboard at 100 px/s
and 200 ran it at 25 px/s. Speed now comes from `scroll_speed` and the panel
refresh only. The field is labelled legacy: nothing in core scrolling reads
it. Anyone who lowered it will see scoreboards scroll slower than before --
at the speed they configured.
- `scripts/scroll_speeds.py --measure` / `--demo` open the panel with the
display service's own options (`DisplayManager.apply_matrix_options`), so
`display.runtime.gpio_slowdown`, `rp1_rio`, `panel_type` and orientation are
honoured; the script used to read `gpio_slowdown` from `display.hardware`.
Its closing advice now gives the `scroll_speed` + `scroll_delay` pair
instead of `scroll_pixels_per_second`, which the resolver ignores whenever
the pair is present.
- The frame-stats log no longer opens a scroll with a one-frame window for
scrollers that never call `reset_scroll()`.
- Removed dead scroll code: the optional scipy import (`HAS_SCIPY`),
`ScrollHelper._last_integer_position` and `frame_time_target`.
`ScrollHelper.target_fps` / `set_target_fps()` remain, documented as
informational.
- Docs describe the fixed-step scroll model: `PLUGIN_API_REFERENCE.md`
documents `set_scrolling_state(..., frame_hold)` (omitting the hold runs a
scroll `frame_hold` times too fast), `SCROLL_PERFORMANCE.md` no longer reads a
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.
### Web interface
- The plugin settings form honours `"x-display": "hidden"` in config schemas:
the property gets no control at any depth (top level, nested objects, array
rows, Advanced Settings), and saving the form never changes its stored value.
JSON API saves are unaffected. Lets plugins keep deprecated or internal keys
declared, e.g. countdown's row `id` and weather's `api_key` / `radar_zoom`.
See `docs/widget-guide.md`.
- Display settings no longer silently cut values on save: columns were capped
at 128, chain length at 24 and PWM LSB nanoseconds at 500. Columns have no
upper limit, chain length is 1–255 and rows must be even and 8–64 (see
"Display hardware settings the library refuses" below);
parallel is 1–3 and PWM dither bits 0–2, matching the library. A stored GPIO
slowdown, PWM dither bits or refresh-rate cap of 0 no longer shows (and
re-saves) as 3, 1 or 120, and the refresh cap accepts 0 (no cap). The config
API rejects out-of-range or non-integer `rows`, `cols`, `chain_length`,
`parallel`, `brightness`, `scan_mode`, `pwm_bits`, `pwm_dither_bits`,
`pwm_lsb_nanoseconds`, `limit_refresh_rate_hz`, `row_address_type`,
`multiplexing` and `gpio_slowdown` with a 400 (JSON `true` or `5.5` used to
save as 1 or 5) instead of saving a config the matrix refuses to start with.
- Display setting help tips and README / config-reference entries corrected
and completed: `panel_type` and `rp1_rio` are documented,
`show_refresh_rate` prints to the console rather than drawing on the panel,
PWM dither bits raise the refresh rate rather than lowering it, and every
numeric setting states its range.
- Row Address Type offers 5, the SM5368 / B707 row shift register. The
Waveshare 96x48 V2 panel (back silkscreen `24S-A1`) needs it with RGB
sequence BGR and, on a Pi 4, a GPIO slowdown of 6–8. Panels with FM6124
column drivers need no Panel Type.
- On a Raspberry Pi 5 the pinned rgbmatrix library can drive only row address
types 0 and 2, parallel 1–3 and the standard mappings. For anything else it
returns no matrix, which the Python binding doesn't catch, so the display
service crashed and restarted every 10 seconds. `DisplayManager` now refuses
those settings before creating the matrix (logged, reported by
`/api/v3/hardware/status`, fallback mode), the config API rejects them, and
the Display form offers only row address types 0 and 2 on a Pi 5. The rule
lives in `src/pi5_matrix_support.py` and must be re-checked when the
submodule is bumped.
- The Plugin Config Warning no longer lists core settings as plugins that are
"in config but not installed" (seen as `auto_update` on 3.4.0, where the
advice would have deleted the weekly-update setting). Core top-level config
keys now live in one list, `src/core_config_keys.py`, which reconciliation
uses and tests pin to `config.template.json` and the settings save endpoint.
A stored warning is also dropped once its entry is no longer a plugin in
config, so an old verdict clears without a restart.
- **Check & Update All** no longer sends installed Starlark apps
(`starlark:<app_id>` entries in `/plugins/installed`) to the plugin updater,
which answered each with a 500 "plugin not found". `POST /plugins/update`
now answers a `starlark:` id with a 400 saying it is a Starlark app. A
request that gets no HTTP answer (e.g. the web service restarting mid-run) is
re-sent with backoff instead of being counted as failed and skipped — that is
how a disabled plugin with an update waiting was silently left out.
- Three routes consulted the web process's plugin manifests without
discovering plugins first, so they misbehaved from every `ledmatrix-web`
restart until something else ran a discovery — in practice until someone
opened the dashboard, measured at over three minutes on one rig.
`POST /display/on-demand/start` and `POST /plugins/toggle` answered 404
"Plugin not found", and `POST /config/main` did not recognise a plugin
section, so it skipped secret separation and wrote the plugin's API key to
`config.json` in plain text instead of `config_secrets.json`. The routes now
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.
### Security (request paths and inline handlers, siblings of #561)
- `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and
`POST .../assets/delete` validate `plugin_id` with `src/common/path_safety`
and answer 400 otherwise. A `plugin_id` of `../../config` used to create an
`uploads/` directory outside `assets/plugins`, write images and
`.metadata.json` there, list it, and delete whatever file a metadata entry
named. Delete now unlinks only a path that resolves inside that plugin's
uploads directory (any other entry is dropped without touching a file).
- `PluginManager.get_plugin_directory()` returns `None` for anything but a
plain name, so `POST /api/v3/plugins/action` can no longer run a manifest
script from a directory outside the plugins directory (`../elsewhere`); the
route also rejects such ids with 400.
- Plugin Store, saved-repository and custom-registry buttons escape registry
values for their inline `onclick` handlers (`jsStringAttr` in
`plugins_manager.js`). An entry id containing `'` used to close the attribute
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.
### Display hardware settings the library refuses
- The rgbmatrix library answers several settings with no matrix or `abort()`
rather than an error, on every board, so the display service crash-looped
instead of falling back: rows above 64, `chain_length` above 255 (the Python
binding stores it in one byte; this was documented as "no upper limit"), a
misspelled `hardware_mapping`, and `parallel` 2–3 on a mapping with one output
(`adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1`, `classic-pi1`) — the last
one reachable from the Display form on the default mapping. The config API
now refuses them with a 400 naming the setting, and `DisplayManager` refuses
a hand-edited one before creating the matrix: logged, fallback mode, reported
by `/api/v3/hardware/status`. The rules, including the Pi 5 ones, live in
`src/matrix_support.py` and must be re-checked when the submodule is bumped.
- `/api/v3/hardware/status` adds `cause`: `"settings"` when LEDMatrix refused
the config, `"library"` when the library failed. The Display tab banner and
the fallback log line give the Pi 5 rebuild hint only for a library failure;
they used to follow every failure with it and with GPIO slowdown advice.
- The Display form offers the `classic` and `classic-pi1` mappings and the
`90` / `270` orientations, and renders any other stored mapping selected with
a warning. With no option selected the browser posted the first one, so one
unrelated save rewrote those settings. The API accepts orientation `90` and
`270`, which `DisplayManager` already applied.
- The display size the web preview, Starlark magnify default and
`scripts/dev/vegas_audit.py` compute (`src/display_geometry.py`) now applies
`orientation` and `pixel_mapper_config` as the library does: `Rotate:90`
swaps width and height, `U-mapper` folds the chain.
- One Raspberry Pi 5 GPIO slowdown recommendation everywhere: 1–3 in PIO mode,
starting at 1. README and the config reference now describe the template
values as the defaults; the "code default" values they listed never apply,
because config migration fills missing keys from the template.
### Plugin system
- A plugin no longer starts with a schema warning and a degraded flag because
config.json still holds a boolean where its schema now has an object with an
`enabled` property (news' `global.dynamic_duration: true`). The loader reads
the boolean as `{"enabled": <bool>}` before merging schema defaults and
validating, the same rule the settings form already applies
(`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.
### Core
- `ConfigManager.load_config()` no longer raises on a host without the POSIX
ownership APIs. The self-heal that chgrp's `config_secrets.json` to the
shared group (added in #416) looked up `os.geteuid` unguarded; that name does
not exist on Windows, and the resulting `AttributeError` is not an `OSError`,
so it escaped the helper's own "best-effort" handling and every caller's.
Any Windows checkout with a `config/config_secrets.json` got a `ConfigError`
from every config load and could not `import web_interface.app` at all.
`ensure_shared_group_ownership()` now returns immediately when `os.geteuid`
or `os.chown` is missing. No behaviour change on the Pi.
- Restoring a backup on Windows no longer fails over files that already exist.
The restore carries each replaced file's owner across with `os.chown`, which
does not exist on Windows; the `AttributeError` escaped the per-file error
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.
### Cache permissions
- The web interface can read what the display service caches again.
`ledmatrix-web.service` carried `CacheDirectory=ledmatrix`, and systemd
re-owns `/var/cache/ledmatrix` and its contents to the unit's `User=`
whenever the directory's owner differs, which erased the `root:ledmatrix`
setgid layout the installers set up: every file the root display service
wrote afterwards was `root:root` 0660 and unreadable by the web interface
(392 unreadable files on one rig, with display status, on-demand state and
plugin health empty). Since #547 the web unit is rendered from its template
on every install, so every fresh install hit this.
`DiskCache.set` now gives each file the directory's group (when that
directory is group-writable) and 0660 on the open descriptor before the
rename, independent of setgid, which also closes a window where a fresh
file was visible as mkstemp's 0600. `DiskCache.share_existing_files`
repairs files an older version left behind, once per process, through
`O_NOFOLLOW` descriptors, skipping hard links and other users' files.
Existing installs only ever receive `git pull`, so that repair is the fix
for them; new installs also drop `CacheDirectory=` and
`CacheDirectoryMode=` from the web unit.
- `install_web_service.sh` replaces an existing cache directory's group
whenever the installing user is not in it. It used to replace only root's,
so a `root:ledmatrix` directory belonging to a user outside that group was
left alone and everything root wrote there stayed unreadable.
- `/display/on-demand/status` and the current-display status read the display
service's keys with `memory_ttl=0`, as every other cross-process reader
already does. They served the first copy the web process had read for the
full 120s `max_age`, so on-demand reported "active" for over 100 seconds
after the file on disk said "idle".
### Automatic updates and Update Code
- An update that changes `web_interface/requirements.txt` is no longer rolled
back on every auto-updating device. `safe_pip_install.sh` allowed only the
root `requirements.txt`, so the install Update Code and the health check run
for the web requirements was refused, and the health check rolls back any
update whose dependencies failed (Install Base Requirements failed the same
way). The wrapper now allows both core requirement files; a core requirement
file symlinked out of the project is refused.
- The automatic update's local-change check and Update Code now count changes
the same way (`auto_update.local_changes`): permission-only changes and
anything under `plugins/` or `plugin-repos/` don't count, and a core path
that merely contains `plugins/` does. Such edits used to pass the check and
then be stashed by the pull and never restored, despite "will not stash your
changes". The pull's `--autostash` now carries them across. Update Code
still stashes other edits; the automatic update refuses instead.
- When the automatic update's own rollback fails (a partial pull, or a health
check that never started), plugins are no longer updated and the display is
not restarted, as the 3.4.0 notes promised.
- The health check's dependency reinstall no longer retries pip failures or
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.
### Small fixes (update-all, plugin system settings, scripts)
- **Check & Update All** counts a plugin that had nothing to update as
"already up to date" instead of "updated". ZIP-installed monorepo plugins
(most official ones) already at the registry version were called "updated
successfully" on every run. `POST /plugins/update` now returns
`data.update_status` (`updated`, `up_to_date`, `local_only`).
- An update request that got an HTTP error answer without an `error_code`, or
a body that is not JSON (e.g. a reverse proxy's 502 page), is no longer
classified as `NETWORK_ERROR` and re-sent five times. Only a request that got
no HTTP answer is retried; the rest are `API_ERROR` with the HTTP status.
- The General tab no longer shows Auto Discover Plugins, Auto Load Enabled
Plugins or Development Mode. Nothing read `plugin_system.auto_discover`,
`auto_load_enabled` or `development_mode`: every enabled plugin was always
discovered and loaded. Stored values are kept, and saving the General tab no
longer rewrites them to `false`.
- `BackgroundDataService` shares the 6-hour "ESPN rejects date ranges" memo
with `fetch_espn_scoreboard`, so a background season fetch no longer spends a
doomed range request first once either path has seen a rejection.
- `scripts/install_plugin_dependencies.sh` installs from the configured
`plugin_system.plugins_directory` (default `plugin-repos`, where the Plugin
Store installs) and also scans `plugins/` for dev symlinks. It used to scan
only `plugins/` and find nothing. A failed `pip install` is now reported as a
failure instead of being hidden by `tee`.
- `scripts/verify_installation.sh` no longer fails a healthy install: it
checked for the removed `web_interface_v2.py` and port 5001. It and
`scripts/verify_web_ui.sh` now check port 5000, where the web interface
listens.
- `scripts/install/install_service.sh --help` prints usage and exits without
changes. It used to ignore the flag and reinstall and restart every service.
Unknown arguments are rejected before anything runs.
- `scripts/diagnose_web_ui.sh`, `scripts/diagnose_web_interface.sh` and
`scripts/debug/debug_web_manual.py` apply the launcher's own autostart rule
(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.
### Docs and developer tools
- `docs/REST_API_REFERENCE.md` rechecked against every handler: request
fields that made documented calls fail (`repo_url`, `action_id`/`params`,
`files`/`image_id`, `font_file`+`font_family`, `?font=`, cache `key`,
`auto_enable_ap_mode`, plugin limit keys) and response shapes are fixed, the
removed font-override endpoints are gone, and the 26 undocumented routes
(backup, git/auto-update, WiFi radio, Starlark editor, MQTT bridge, status
endpoints, skins) are listed. Store search is `/plugins/store/list?query=`.
- `FONT_MANAGER.md` no longer tells plugins to read
`display_manager.font_manager`, which does not exist; use
`plugin_manager.font_manager` / `BasePlugin._get_font_manager()`.
- Plugin docs, `DisplayManager` docstrings and the bundled `starlark-apps`
plugin now all read the display size from `display_manager.width/height`,
which works in fallback mode where `matrix` is `None`.
- `scripts/dev/dev_plugin_setup.sh link-github <name>` links the plugin from a
clone of the `ledmatrix-plugins` monorepo (per-plugin `ledmatrix-<name>`
repositories no longer exist). `dev_plugins.json` honours `github_user`,
`plugins_repo` and `plugins_branch`; `dev_plugins.json.example` ships and
`dev_plugins.json` is git-ignored. `update`/`status` handle monorepo links,
and `status` no longer exits 1 when nothing is broken.
- Rewritten for current behaviour: plugin dependency installation (web service
runs as the installing user and installs through `safe_pip_install.sh`),
`PLUGIN_CONFIG_ARCHITECTURE.md`, `MULTI_ROOT_WORKSPACE_SETUP.md`; stale
`app.py` line numbers, `api_v3.py` paths, StreamManager method names,
nonexistent version-bump scripts and `ledmatrix` service user references
removed.
## 3.4.0
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
+2 -1
View File
@@ -30,7 +30,7 @@
`config.get(...)`, never a separate accessor
## Dev Workflow
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` (or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` clones the `ledmatrix-plugins` monorepo into `~/.ledmatrix-dev-plugins/` and links its `plugins/<name>` under the manifest id (add a repo URL for a plugin with its own repo; or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up. Fork/location overrides: `dev_plugins.json` (from `dev_plugins.json.example`)
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
@@ -63,3 +63,4 @@
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
- `src/pi5_matrix_support.py` hardcodes what the pinned `rpi-rgb-led-matrix-master` can drive on a Raspberry Pi 5 (`Rp1PioConfigSupported()` in `lib/rp1/rp1_pio_backend.cc`). Re-check it whenever the submodule is bumped: a stale rule blocks Pi 5 settings the new library supports, and a missing one lets the display service crash-loop. `src/matrix_support.py` holds the same kind of rules for every board (rows, chain length, mapping names, parallel per mapping) and needs the same re-check
+107 -54
View File
@@ -148,7 +148,7 @@ The system supports live, recent, and upcoming game information for multiple spo
```bash
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
```
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and start `gpio_slowdown` at `1`, raising it a step at a time if the image flickers or shows garbage (see `gpio_slowdown` under Display Settings).
- **1GB models (Pi 3B / 3B+) and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`.
@@ -485,6 +485,10 @@ If you are copying my exact setup, you can likely leave the defaults alone. Howe
The display settings are located in `config/config.json` under the `"display"` key and are organized into three main sections: `hardware`, `runtime`, and `display_durations`.
The defaults below are the values in `config/config.template.json`. They are what applies when you haven't set a key: on every load, LEDMatrix adds any key your `config.json` lacks from the template, so `DisplayManager`'s own fallbacks are never reached on a normal install.
The web UI and the config API refuse values the rgbmatrix library can't start with. If one is written into `config.json` by hand anyway, the display logs which setting it is (`Failed to initialize RGB Matrix` in `sudo journalctl -u ledmatrix`), runs in fallback mode, and the Display tab shows the message.
### Hardware Configuration (`display.hardware`)
These settings control the physical hardware configuration and how the matrix is driven.
@@ -494,15 +498,18 @@ These settings control the physical hardware configuration and how the matrix is
- **`rows`** (integer, default: 32)
- Number of LED rows (vertical pixels) in each panel
- Common values: 16, 32, 48, 64
- An even number from 8 to 64, the most the rgbmatrix library drives per panel
- Must match your physical panel configuration
- **`cols`** (integer, default: 64)
- Number of LED columns (horizontal pixels) in each panel
- Common values: 32, 64, 96, 128
- At least 16, with no upper limit
- Must match your physical panel configuration
- **`chain_length`** (integer, default: 2)
- Number of LED panels chained together horizontally
- 1 to 255 (the library's Python binding stores it in one byte); longer chains lower the refresh rate
- If you have 2 panels side-by-side, set to 2
- If you have 4 panels in a row, set to 4
- Total display width = `cols × chain_length`
@@ -511,68 +518,70 @@ These settings control the physical hardware configuration and how the matrix is
- Number of parallel chains (panels stacked vertically)
- Use 1 for a single row of panels
- Use 2 if you have panels stacked in two rows
- 1–3, and no more than your `hardware_mapping` has outputs: `regular` and `classic` have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); `adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1` and `classic-pi1` have 1. The library stops the display service outright on a mismatch, so it is refused
- Total display height = `rows × parallel`
#### Brightness and Visual Settings
- **`brightness`** (integer, 0-100, default: 90)
- **`brightness`** (integer, 1-100, default: 90)
- Display brightness level
- Lower values (0-50) are dimmer, higher values (50-100) are brighter
- Lower values (1-50) are dimmer, higher values (50-100) are brighter
- Recommended: 70-90 for indoor use, 90-100 for bright environments
- Very high brightness may cause distortion or require more power
#### Hardware Mapping
- **`hardware_mapping`** (string, default: "adafruit-hat-pwm")
- **`hardware_mapping`** (string, default: "adafruit-hat")
- Specifies which GPIO pin mapping to use for your hardware
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic)
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
- **`"classic"`** / **`"classic-pi1"`**: the library's original pin-outs, for old adapter boards wired to them. Not used by current HATs
- Any other name is refused. `compute-module` is only compiled in when the library is built with `ENABLE_WIDE_GPIO_COMPUTE_MODULE`, which the installer doesn't do. On a Raspberry Pi 5, `classic-pi1` isn't supported
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
- Hardware pulsing (see `disable_hardware_pulsing`) needs the panel's OE line on GPIO 18, which `adafruit-hat-pwm` and `regular` provide and `adafruit-hat` does not
#### PWM (Pulse Width Modulation) Settings
These settings affect color fidelity and smoothness of color transitions:
- **`pwm_bits`** (integer, default: 9)
- Number of bits used for PWM (affects color depth)
- Higher values (9-11) = more color levels, smoother gradients
- Lower values (7-8) = fewer color levels, but may improve stability on some hardware
- Range: 1-11, recommended: 9-10
- **`pwm_bits`** (integer, 1-11, default: 9)
- Color depth per channel: how many brightness levels each LED gets
- Higher values (9-11) = more color levels, smoother gradients, lower refresh rate
- Lower values (7-8) = the subtlest shades are dropped for a higher refresh rate; `1` gives 8 colors
- Recommended: 9-10
- **`pwm_dither_bits`** (integer, default: 1)
- Additional dithering bits for smoother color transitions
- Helps reduce color banding in gradients
- Higher values (1-2) = smoother gradients but may impact performance
- Range: 0-2, recommended: 1
- **`pwm_dither_bits`** (integer, 0-2, default: 1)
- Time-dithers the lowest color bits: their brightness comes from showing them on only some frames
- Raises the refresh rate; the cost is that dark shades can shimmer slightly
- `0` = steadiest dim colors, `2` = fastest
- The rgbmatrix library accepts only 0-2; a higher value stops the display starting
- **`pwm_lsb_nanoseconds`** (integer, default: 130)
- Least significant bit timing in nanoseconds
- Controls the base timing for PWM signals
- Lower values = faster PWM, higher values = slower PWM
- **`pwm_lsb_nanoseconds`** (integer, 50-3000, default: 130)
- On-time of the least significant color bit; each higher bit doubles it
- Lower values = higher refresh rate, but can cost color accuracy or add ghosting on some panels
- Higher values = less ghosting (faint trails behind bright text on black), lower refresh rate
- Typical range: 100-300 nanoseconds
- May need adjustment if you see flickering or color issues
#### Advanced Hardware Settings
- **`scan_mode`** (integer, default: 0)
- Panel scan mode (how rows are addressed)
- Common values: 0 (progressive), 1 (interlaced)
- Most panels use 0, but some require 1
- Check your panel datasheet if colors appear incorrect
- **`scan_mode`** (integer, 0-1, default: 0)
- Order the rows are refreshed in: `0` = progressive, `1` = interlaced
- Interlaced can look a little smoother when the refresh rate is very low, but usually shows a comb effect on anything moving
- Leave at `0` unless you are tuning a slow setup
- **`limit_refresh_rate_hz`** (integer, default: 100)
- Maximum refresh rate in Hz (frames per second)
- Caps the refresh rate for better stability
- Lower values (60-80) = more stable, less CPU usage
- Higher values (100-120) = smoother animations, more CPU usage
- Recommended: 80-100 for most setups
- Caps the panel refresh rate in Hz; `0` = no cap
- A steady cap reduces flicker caused by other activity on the Pi, and in camera recordings
- Scroll speeds are worked out against this value (against 100 Hz when it is `0`), so a cap the panel can actually hold keeps scrolling even
- Recommended: 80-120. `sudo python3 scripts/scroll_speeds.py --measure` reports the rate your panel really achieves
- **`disable_hardware_pulsing`** (boolean, default: false)
- Disables hardware pulsing (usually leave as false)
- Set to `true` only if you experience timing issues
- Most users should leave this as `false`
- `false` = the Pi's hardware PWM times each brightness pulse; `true` = software timing
- Leave `false` where possible. Software timing is less exact, so a row, or the whole panel, can briefly flash brighter
- Hardware pulsing needs the panel's OE line on GPIO 18 (`adafruit-hat-pwm`, `regular`, the Adafruit Triple LED Matrix Bonnet). With `adafruit-hat` the library uses software timing anyway
- It also needs the Pi's onboard sound driver (`snd_bcm2835`) disabled, which `first_time_install.sh` does. Set `true` only if you need the Pi's own audio
- **`inverse_colors`** (boolean, default: false)
- Inverts all colors (red becomes cyan, etc.)
@@ -580,9 +589,9 @@ These settings affect color fidelity and smoothness of color transitions:
- Set to `true` only if colors appear inverted
- **`show_refresh_rate`** (boolean, default: false)
- Displays the current refresh rate on the matrix (for debugging)
- Set to `true` to see FPS on the display
- Useful for troubleshooting performance issues
- Prints the live refresh rate to the console; nothing is drawn on the panel
- Readable when you stop the service and run `sudo python3 run.py` in a terminal; under the service the output is buffered
- `sudo python3 scripts/scroll_speeds.py --measure` is an easier way to see the real refresh rate
#### Advanced Panel Configuration (Advanced Users Only)
@@ -592,6 +601,7 @@ These settings are typically only needed for non-standard panels or custom confi
- Color channel order for your LED panel
- Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR"
- Most panels use "RGB", but some use "GRB" or other orders
- If red shows as blue, try "BGR" (the Waveshare 96x48 V2 needs it)
- Check your panel datasheet if colors appear wrong
- **`pixel_mapper_config`** (string, default: "")
@@ -606,35 +616,68 @@ These settings are typically only needed for non-standard panels or custom confi
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
settings) if the panel is mounted upside down — useful for optimizing
where the Raspberry Pi and wiring sit relative to the mounting location
- `"90"` and `"270"` are for a panel mounted on its side; they swap the
display's width and height
- Applied independently of `pixel_mapper_config` (appended as a trailing
`Rotate:180` mapper), so custom mapper configs keep working alongside it
`Rotate:<degrees>` mapper), so custom mapper configs keep working alongside it
- **`row_address_type`** (integer, default: 0)
- How rows are addressed on the panel
- Most panels use 0 (direct addressing)
- Some panels require 1 (AB addressing) or 2 (ABC addressing)
- 1 = AB-addressed, 2 = direct row select, 3 = ABC-addressed,
4 = ABC shift + DE direct (SM5266), 5 = SM5368 / B707 row shift register
- ABC panels (no E line, e.g. many 128x64 FM6124 panels) use 3
- Panels with SM5368 row drivers use 5 with `led_rgb_sequence` `"BGR"` —
e.g. the Waveshare 96x48 V2 (back silkscreen `24S-A1`; the V1, `24S-A2.1`,
uses the defaults). This is what Waveshare's `96X48_1_24_SM5368` panel
type sets in their library fork.
- SM5368 row drivers are timing-sensitive: if rows jump up and down or the
bottom row shows a copy of other rows, raise `gpio_slowdown`. On a Pi 4
with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a
stable image.
- On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2
(and `parallel` 1-3). Anything else would crash the display service, so on
a Pi 5 the web UI offers only 0 and 2, the config API refuses the others,
and if one is set in `config.json` anyway the display logs why and runs in
fallback mode
- Check your panel datasheet if display appears corrupted
- **`multiplexing`** (integer, default: 0)
- Panel multiplexing type
- 0 = no multiplexing (standard panels)
- Higher values for panels with different multiplexing schemes
- Check your panel datasheet for the correct value
- **`multiplexing`** (integer, 0-22, default: 0)
- How pixels are wired on outdoor/specialty panels (P10, P8, P4 and P3 outdoor modules and similar) whose LEDs aren't laid out in straight rows
- `0` = direct (standard indoor panels)
- `1` Stripe, `2` Checkered, `3` Spiral, `4` ZStripe, `5` ZnMirrorZStripe,
`6` Coreman, `7` Kaler2Scan, `8` ZStripeUneven, `9` P10-128x4-Z,
`10` QiangLiQ8, `11` InversedZStripe, `12`–`14` P10Outdoor1R1G1B v1–v3,
`15` P10CoremanMapper, `16` P8Outdoor1R1G1B, `17` FlippedStripe,
`18` P10-32x16-HalfScan, `19` P10-32x16-QuarterScan, `20` P3Outdoor-64x64,
`21` DoubleZMultiplex, `22` P4Outdoor-80x40
- If the image is scrambled in a repeating pattern, try the value named after your panel first
- **`panel_type`** (string, default: `""`)
- Sends a start-up initialization sequence to driver chips that need one
- `""` = Standard (no initialization) — right for most panels, including FM6124 / FM6124D / FM6124DJ
- `"FM6126A"` or `"FM6127"` for panels with those chips; try `"FM6126A"` if the panel stays dark or lights only the first pixel on Standard
### Runtime Configuration (`display.runtime`)
These settings control runtime behavior and GPIO timing:
- **`gpio_slowdown`** (integer, default: 3)
- GPIO timing slowdown factor
- **Critical setting**: Must match your Raspberry Pi model for stability
- **Raspberry Pi 3**: Use 3
- **Raspberry Pi 4**: Use 4
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
- **Raspberry Pi Zero/1**: Use 1-2
- Incorrect values can cause display corruption, flickering, or system instability
- GPIO timing slowdown factor (0-10): slows GPIO writes so the panel electronics keep up. Higher is more reliable but lowers the refresh rate
- **Critical setting**: depends on your Raspberry Pi model and your panel
- **Raspberry Pi Zero/1**: 0-1
- **Raspberry Pi 2/3**: 1-3
- **Raspberry Pi 4**: 2-4 (the config template ships 3)
- **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default). Start at `1` (the library treats `0` as `1` there) and raise it a step at a time if the image flickers or shows garbage — chained panels are the likeliest to need it
- Panels on `row_address_type` 5 (SM5368 row drivers) can need 6-8 on a Pi 4
- Too low: garbage, flicker or rows jumping. Too high: a lower refresh rate
- If you experience issues, try adjusting this value up or down by 1
- **`rp1_rio`** (integer, 0 or 1, default: 0) — Raspberry Pi 5 only
- Which driver the Pi 5's RP1 chip uses: `0` = PIO (default, less CPU), `1` = RIO (registered I/O, can reach a higher refresh rate)
- In RIO mode the effect of `gpio_slowdown` is inverted: higher values may be faster
- Ignored on a Pi 0-4, and applied only if the installed rgbmatrix library supports it
### Display Durations (`display.display_durations`)
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
@@ -667,7 +710,7 @@ Controls how long each installed plugin stays visible in seconds before switchin
- Some plugins can automatically adjust their display time based on content
- This setting limits how long they can extend (prevents one display from dominating)
- Example: If set to 60, a plugin can extend up to 60 seconds even if it requests longer
- Leave unset to use the default cap (typically 90 seconds)
- Leave unset to use the default cap (180 seconds; the web UI accepts 30-1800)
### Example Configuration
@@ -714,6 +757,14 @@ Controls how long each installed plugin stays visible in seconds before switchin
- Verify `hardware_mapping` matches your HAT/connection type
- Try adjusting `gpio_slowdown`
- Ensure your display doesn't need the E-Addressable line
- If it went blank right after a settings change, the Display tab shows a "simulation mode" banner, and `sudo journalctl -u ledmatrix` shows `Failed to initialize RGB Matrix` followed by the reason. When LEDMatrix refused the settings (for example more than 64 `rows`, `parallel` 2 on an `adafruit-hat` mapping, a misspelled `hardware_mapping`, or on a Raspberry Pi 5 a `row_address_type` other than 0 or 2), the message names each one: change them, save, and restart the display service. Otherwise the library itself failed, and its own message just before names the problem
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
**Rows jump up and down, or the bottom row repeats other rows:**
- Raise `gpio_slowdown` a step at a time (SM5368 panels on `row_address_type` 5 can need 6-8 on a Pi 4)
**A row or the whole panel briefly flashes brighter:**
- Set `disable_hardware_pulsing` to `false` (needs the OE line on GPIO 18; see `hardware_mapping`)
**Colors are wrong or inverted:**
- Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work)
@@ -788,9 +839,11 @@ sudo ./scripts/install/install_service.sh
The script will:
- Detect your user account and home directory
- Install the service file with the correct paths
- Enable the service to start on boot
- Start the service immediately
- Install `ledmatrix.service` (display, runs as root), `ledmatrix-web.service`
(web interface, runs as your user) and the `ledmatrix-update-verify` units,
with the correct paths
- Enable them to start on boot
- Start them immediately
### Managing the Service
+6
View File
@@ -0,0 +1,6 @@
{
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
"github_user": "ChuckBuilds",
"plugins_repo": "ledmatrix-plugins",
"plugins_branch": "main"
}
+36 -14
View File
@@ -377,9 +377,16 @@ Vegas mode consists of four core components working together to provide smooth 1
5. Compose into continuous stream with separators
**Key Methods:**
- `get_stream_content()` - Returns current stream content as PIL Image
- `advance_stream(pixels)` - Advances stream by N pixels
- `refresh_stream()` - Regenerates stream from current plugins
- `get_next_segment()` - Returns the next buffered `ContentSegment` (or `None`)
- `take_next_group(count=None, offscreen_only=False)` - Hands over the next
slice of the rotation as `(plugin_id, images)` groups
- `get_grouped_content_for_composition()` - Buffered images grouped by plugin
- `mark_plugin_updated(plugin_id)` / `process_updates()` - Refresh one
plugin's segment in place when its data changes
- `refresh()` - Re-read the plugin list and config
- `advance_cycle()` - Clear the active buffer when a scroll cycle completes
(`src/vegas_mode/stream_manager.py`)
#### 3. PluginAdapter
@@ -433,10 +440,14 @@ Vegas mode consists of four core components working together to provide smooth 1
- **Frame Rate Control:** Precise timing to maintain 125 FPS
- **Pre-rendered Content:** Plugins pre-render during update()
**Scroll Speed Calculation:**
**Scroll Speed Calculation:** motion is by elapsed time; `target_fps` paces
the render loop, not the speed.
```python
pixels_per_frame = (scroll_speed / target_fps)
scroll_position += pixels_per_frame * elapsed_time
# frame_based_scrolling: false
scroll_position += scroll_speed * elapsed_time # scroll_speed in px/s
# frame_based_scrolling: true (the default) -- not stepping, just a clamp
applied = clamp(scroll_speed * scroll_delay, 0.1, 5) / scroll_delay
scroll_position += applied * elapsed_time
```
#### Component Interactions
@@ -552,7 +563,8 @@ time when something is active.
### REST API Reference
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
The API is mounted at `/api/v3` (the `api_v3` blueprint, registered in
`web_interface/app.py`). Full details: [REST_API_REFERENCE.md](REST_API_REFERENCE.md#display-control).
#### Start On-Demand Display
@@ -608,20 +620,30 @@ curl http://localhost:5000/api/v3/display/on-demand/status
# Response:
{
"active": true,
"plugin_id": "weather",
"mode": "weather",
"remaining": 25.5,
"pinned": false,
"status": "active"
"status": "success",
"data": {
"state": {
"active": true,
"plugin_id": "weather",
"mode": "weather",
"duration": 30,
"pinned": false,
"status": "running",
"last_updated": 1234567890.1
},
"service": {"active": true, "returncode": 0, "stdout": "active", "stderr": ""}
}
}
```
When nothing is running on demand, `data.state` is
`{"active": false, "status": "idle", "last_updated": null}`.
> There is no public Python on-demand API. The display controller's
> on-demand machinery is internal — drive it through the REST endpoints
> above (or the web UI buttons). The API handlers
> (`start_on_demand_display()` / `stop_on_demand_display()` in
> `web_interface/blueprints/api_v3.py`) write a request into the cache
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
> manager under the `display_on_demand_request` key, which
> `DisplayController._poll_on_demand_requests()`
> (`src/display_controller.py`) picks up. A separate
+14 -5
View File
@@ -250,14 +250,21 @@ WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard
## Checking Configuration via API
The API blueprint mounts at `/api/v3` (`web_interface/app.py:144`).
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
`/api/v3` in `web_interface/app.py`.
```bash
# Get full main config (includes all plugin sections)
# Get full main config (includes all plugin sections; credential-named
# fields are blanked in the response)
curl http://localhost:5000/api/v3/config/main
# Save updated main config
# Change some settings: only the keys you send are changed
curl -X POST http://localhost:5000/api/v3/config/main \
-H "Content-Type: application/json" \
-d '{"timezone": "America/Chicago", "brightness": 80}'
# Replace config.json wholesale (advanced)
curl -X POST http://localhost:5000/api/v3/config/raw/main \
-H "Content-Type: application/json" \
-d @new-config.json
@@ -269,8 +276,10 @@ curl "http://localhost:5000/api/v3/plugins/config?plugin_id=football-scoreboard"
```
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
> endpoint — config validation runs server-side automatically when you
> POST to `/config/main` or `/plugins/config`. See
> endpoint. `POST /plugins/config` validates against the plugin's schema
> and rejects an invalid config with `400`; `POST /config/main` checks the
> individual fields it knows (display hardware values, durations, Vegas
> and sync settings). See
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
## Backup and Recovery
+36 -33
View File
@@ -17,7 +17,7 @@ tooling against it.
|---|---|---|---|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config |
## `schedule` — display on/off hours
@@ -47,39 +47,44 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to
## `display.hardware` — matrix panel hardware
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
in `DisplayManager._setup_matrix` (`src/display_manager.py`). Defaults are the
`config/config.template.json` values: `ConfigManager` adds any key missing from
`config.json` from the template on load, so `DisplayManager`'s own fallbacks
don't apply on a normal install.
The ranges are what the pinned rgbmatrix library and its Python binding accept
(`src/matrix_support.py`). The config API refuses anything else; a value
hand-edited into `config.json` makes the display log the setting and run in
fallback mode instead of starting the matrix.
| Key | Type / default |
|---|---|
| `rows` / `cols` | int, `32` / `64` |
| `chain_length` | int, `2` |
| `parallel` | int, `1` |
| `brightness` | int, `90` |
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
| `scan_mode` | int, `0` |
| `pwm_bits` | int, `9` (code default 10) |
| `pwm_dither_bits` | int, `1` |
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) |
| `disable_hardware_pulsing` | bool, `false` |
| `rows` / `cols` | int, `32` / `64` — rows: even, 8–64; cols: at least 16 |
| `chain_length` | int, `2` — 1–255 (the Python binding stores it in one byte) |
| `parallel` | int, `1` — 1–3, and no more than `hardware_mapping` has outputs (`regular`, `classic`: 3; the others: 1) |
| `brightness` | int, `90` — 1–100 |
| `hardware_mapping` | string, `"adafruit-hat"` — `"adafruit-hat-pwm"`, `"adafruit-hat"`, `"regular"`, `"regular-pi1"`, `"classic"` or `"classic-pi1"` (case-insensitive; `compute-module` isn't in the installed build). A Pi 5 doesn't support `"classic-pi1"` |
| `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
| `pwm_bits` | int, `9` — 1–11 |
| `pwm_dither_bits` | int, `1` — 0–2 |
| `pwm_lsb_nanoseconds` | int, `130` — 50–3000 |
| `disable_hardware_pulsing` | bool, `false` — `true` times brightness pulses in software (less exact); hardware pulsing needs the OE line on GPIO 18 and the Pi's onboard sound driver off |
| `inverse_colors` | bool, `false` |
| `show_refresh_rate` | bool, `false` |
| `led_rgb_sequence` | string, `"RGB"` |
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
| `row_address_type` | int, `0` — non-standard panel row addressing |
| `multiplexing` | int, `0` — panel multiplexing scheme |
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
Where "code default" differs from the template value, the code default only
applies if the key is missing entirely from your config.
| `show_refresh_rate` | bool, `false` — prints the refresh rate to stdout; draws nothing on the panel |
| `led_rgb_sequence` | string, `"RGB"` — `"RGB"`, `"RBG"`, `"GRB"`, `"GBR"`, `"BRG"` or `"BGR"` |
| `limit_refresh_rate_hz` | int, `100` — `0` = no cap; scroll timing assumes 100 Hz when `0` |
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"`; mappers that rotate or fold the chain change the display size plugins and the web preview see |
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); `"90"` / `"270"` for a panel on its side, swapping width and height; composed onto `pixel_mapper_config` as a trailing `Rotate:<degrees>` mapper, so it stays independent of any custom `pixel_mapper_config` value |
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
## `display.runtime`
| Key | Type / default | Meaning |
|---|---|---|
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis |
| `rp1_rio` | int, `0` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) |
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). On a Pi 5 in PIO mode start at `1` (`0` acts as `1`) and raise it if the image flickers or shows garbage. Panels on `row_address_type` `5` (SM5368 row drivers) can need 6–8 on a Pi 4 — lower values make rows jump |
| `rp1_rio` | int, `0` | Pi 5 only: `0` = PIO (less CPU), `1` = RIO (higher refresh; `gpio_slowdown` effect inverted). Applied only if the installed matrix library supports it |
## `display.double_sided`
@@ -134,8 +139,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `dynamic_duration_enabled` | bool, `true` |
| `min_cycle_duration` | int, `60` |
| `max_cycle_duration` | int, `240` |
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
@@ -152,14 +157,12 @@ Read by `src/common/sync_manager.py` and `src/display_controller.py`.
## `plugin_system`
Read by the plugin loader/manager (`src/plugin_system/`).
| Key | Type / default | Meaning |
|---|---|---|
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins |
| `auto_discover` | bool, `true` | Scan the plugins directory at startup |
| `auto_load_enabled` | bool, `true` | Load discovered plugins automatically |
| `development_mode` | bool, `false` | Development conveniences in the web UI (editable under General settings) |
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins and the only directory the plugin loader scans. Read by `PluginManager` and `PluginStoreManager` (`src/plugin_system/`); editable under General settings |
| `auto_discover` | bool, `true` | **Unused.** Legacy key, read by nothing. Plugins are always discovered, and every plugin with `enabled: true` is loaded. Not shown in the web UI; may be left in or removed from config.json |
| `auto_load_enabled` | bool, `true` | **Unused.** Legacy key, read by nothing (see `auto_discover`). To keep a plugin installed but dormant, set its own `enabled` to `false` |
| `development_mode` | bool, `false` | **Unused.** Legacy key, read by nothing |
## Plugin config blocks
+38 -33
View File
@@ -12,10 +12,28 @@
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
- Manager font registration and detection
- Plugin font management
- Manual font overrides via web interface
- Programmatic per-element font overrides
- Performance monitoring and caching
- Dynamic font discovery
## Getting the FontManager
There is one shared FontManager per display process. The display controller
creates it and hands it to the `PluginManager`, so a plugin reaches it
through its `plugin_manager`:
```python
class MyPlugin(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.font_manager = self._get_font_manager()
```
`BasePlugin._get_font_manager()` returns `plugin_manager.font_manager`, or a
standalone FontManager when none is available (test harnesses, mocks).
`DisplayManager` has **no** `font_manager` attribute —
`display_manager.font_manager` raises `AttributeError`.
## Architecture
### Manager-Centric Design
@@ -40,8 +58,9 @@ Manager requests font → Check manual overrides → Apply manager choice → Ca
from src.font_manager import FontManager
class MyManager:
def __init__(self, config, display_manager, cache_manager):
self.font_manager = display_manager.font_manager # Access shared FontManager
def __init__(self, config, display_manager, cache_manager, plugin_manager):
self.display_manager = display_manager
self.font_manager = plugin_manager.font_manager # Shared FontManager
self.manager_id = "my_manager"
def display(self):
@@ -80,8 +99,9 @@ class MyManager:
```python
class AdvancedManager:
def __init__(self, config, display_manager, cache_manager):
self.font_manager = display_manager.font_manager
def __init__(self, config, display_manager, cache_manager, plugin_manager):
self.display_manager = display_manager
self.font_manager = plugin_manager.font_manager
self.manager_id = "advanced_manager"
# Define your font specifications
@@ -152,19 +172,13 @@ font = self.font_manager.resolve_font(
> URIs documented below are resolved relative to the plugin's
> install directory.
>
> The **Fonts** tab in the web UI that lists detected
> manager-registered fonts is still a **placeholder
> implementation** — fonts that managers register through
> `register_manager_font()` do not yet appear there. The
> programmatic per-element override workflow described in
> [Manual Font Overrides](#manual-font-overrides) below
> (`set_override()` / `remove_override()` / the
> `config/font_overrides.json` store) **does** work today and is
> the supported way to override a font for an element until the
> Fonts tab is wired up. If you can't wait and need a workaround
> right now, you can also just load the font directly with PIL
> (or `freetype-py` for BDF) inside your plugin's `manager.py`
> and skip the override system entirely.
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
> font files in `assets/fonts/`. It does not show fonts registered
> through `register_manager_font()` and has no override editor (the
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
> The programmatic override workflow in
> [Manual Font Overrides](#manual-font-overrides) below still works.
> Let users pick fonts through your plugin's own config schema.
### Plugin Font Registration
@@ -200,10 +214,10 @@ In your plugin's `manifest.json`:
### Using Plugin Fonts
```python
class PluginManager:
def __init__(self, config, display_manager, cache_manager, plugin_id):
self.font_manager = display_manager.font_manager
self.plugin_id = plugin_id
class MyPlugin(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.font_manager = self._get_font_manager()
def display(self):
# Use plugin font (automatically namespaced)
@@ -219,17 +233,8 @@ class PluginManager:
## Manual Font Overrides
Users can override any font through the web interface:
1. Navigate to **Fonts** tab
2. View **Detected Manager Fonts** to see what's currently in use
3. In **Element Overrides** section:
- Select the element (e.g., "nfl.live.score")
- Choose a different font family
- Choose a different size
- Click **Add Override**
Overrides are stored in `config/font_overrides.json` and persist across restarts.
Overrides are set in code (there is no web UI or REST endpoint for them).
They are stored in `config/font_overrides.json` and persist across restarts.
### Programmatic Overrides
+4 -4
View File
@@ -83,10 +83,10 @@ You should see:
1. Open the **Display** tab
2. Set your matrix configuration:
- **Rows**: 32 or 64 (match your hardware)
- **Columns**: commonly 64 or 96; the web UI accepts any integer
in the 1–128 range, but 64 and 96 are the values the bundled
panel hardware ships with
- **Rows**: match your panel — commonly 32 or 64; any even number
from 8 to 64
- **Columns**: match your panel — commonly 64 or 96; at least 16,
with no upper limit
- **Chain Length**: Number of panels chained horizontally
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
mod) or `adafruit-hat` (without). See the root README for the full list.
+5 -2
View File
@@ -59,9 +59,12 @@ sudo ./scripts/install/install_service.sh
After updating your scripts, verify they still work:
```bash
# Test installation scripts (if needed)
# Check the installation scripts are at their new paths
ls scripts/install/*.sh
sudo ./scripts/install/install_service.sh --help
./scripts/install/install_service.sh --help # prints usage only
# Note: running install_service.sh for real (with sudo, no --help)
# reinstalls, enables and restarts ledmatrix.service, ledmatrix-web.service
# and the update-verify units.
# Test permission scripts
ls scripts/fix_perms/*.sh
+81 -96
View File
@@ -1,169 +1,154 @@
# Multi-Root Workspace Setup Guide
This document explains how the LEDMatrix project uses a multi-root workspace to manage plugins as separate Git repositories.
This document explains how to work on LEDMatrix and the official plugins side
by side, with one editor workspace and the plugins loaded straight from your
plugin checkout.
## Overview
The LEDMatrix project has been migrated from a git submodule implementation to a **multi-root workspace** implementation for managing plugins. This allows:
Official plugins live in a single repository,
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins), with one
directory per plugin under `plugins/`. There are no separate per-plugin
repositories. For development you clone that monorepo **next to** LEDMatrix
and symlink its plugin directories into LEDMatrix's `plugin-repos/`, which is
where the plugin loader looks by default.
- ✅ Plugins to exist as independent Git repositories
- ✅ Updates to plugins without modifying the LEDMatrix project
- ✅ Easy development workflow with all repos in one workspace
- ✅ Plugin system discovers plugins via symlinks in `plugin-repos/`
- ✅ Plugin code stays in the monorepo checkout, with its own git history
- ✅ LEDMatrix discovers the plugins through symlinks in `plugin-repos/`
- ✅ `LEDMatrix.code-workspace` opens both repositories in VS Code/Cursor
## Directory Structure
```text
/home/chuck/Github/
├── LEDMatrix/ # Main project
│ ├── plugin-repos/ # Symlinks to actual repos (managed automatically)
│ │ ├── ledmatrix-clock-simple -> ../../ledmatrix-clock-simple
│ │ ├── ledmatrix-weather -> ../../ledmatrix-weather
~/Github/
├── LEDMatrix/ # Main project
│ ├── plugin-repos/ # Plugin directory the loader scans
│ │ ├── starlark-apps/ # Bundled with LEDMatrix (tracked in git)
│ │ ├── web-ui-info/ # Bundled with LEDMatrix (tracked in git)
│ │ ├── clock-simple -> ../../ledmatrix-plugins/plugins/clock-simple
│ │ ├── ledmatrix-weather -> ../../ledmatrix-plugins/plugins/ledmatrix-weather
│ │ └── ...
│ ├── LEDMatrix.code-workspace # Multi-root workspace configuration
│ ├── LEDMatrix.code-workspace # Opens LEDMatrix and ../ledmatrix-plugins
│ └── ...
├── ledmatrix-clock-simple/ # Plugin repository (actual git repo)
├── ledmatrix-weather/ # Plugin repository (actual git repo)
├── ledmatrix-football-scoreboard/ # Plugin repository (actual git repo)
└── ... # Other plugin repos
└── ledmatrix-plugins/ # Plugin monorepo (git repo)
├── plugins/
│ ├── clock-simple/
│ ├── ledmatrix-weather/
│ └── ...
├── plugins.json # Store registry
└── update_registry.py
```
## How It Works
### 1. Plugin Repositories
### 1. The plugin monorepo
All plugin repositories are cloned to `/home/chuck/Github/` (parent directory of LEDMatrix) as regular Git repositories:
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
scripts below look for `../ledmatrix-plugins` relative to the LEDMatrix
root):
- `ledmatrix-clock-simple/`
- `ledmatrix-weather/`
- `ledmatrix-football-scoreboard/`
- etc.
```bash
cd ~/Github
git clone https://github.com/ChuckBuilds/ledmatrix-plugins.git
```
### 2. Symlinks in plugin-repos/
The `LEDMatrix/plugin-repos/` directory contains symlinks pointing to the actual repositories in the parent directory. This allows the plugin system to discover plugins without modifying the project structure.
`scripts/setup_plugin_repos.py` creates one symlink per plugin in
`LEDMatrix/plugin-repos/`, named after the plugin's manifest `id` and pointing
at `../ledmatrix-plugins/plugins/<dir>`.
### 3. Multi-Root Workspace
### 3. Multi-root workspace
The `LEDMatrix.code-workspace` file configures VS Code/Cursor to open all plugin repositories as separate workspace roots, allowing easy development across all repos.
`LEDMatrix.code-workspace` has two roots: LEDMatrix itself and
`../ledmatrix-plugins`.
## Setup Scripts
### Initial Setup
If you already have plugin repositories cloned, use the setup script:
```bash
cd /home/chuck/Github/LEDMatrix
cd ~/Github/LEDMatrix
python3 scripts/setup_plugin_repos.py
```
This script:
- Reads the workspace configuration
- Creates symlinks in `plugin-repos/` pointing to actual repos
- Verifies all links are created correctly
- Reads each `manifest.json` under `../ledmatrix-plugins/plugins/`
- Creates `plugin-repos/<id>` symlinks (relative) to those directories
- Leaves correct links alone, replaces links that point elsewhere, and skips
(does not overwrite) a real directory of the same name — for example a
plugin you installed from the Plugin Store. Remove that directory first if
you want the linked copy.
### Updating Plugins
To update all plugin repositories:
```bash
cd /home/chuck/Github/LEDMatrix
cd ~/Github/LEDMatrix
python3 scripts/update_plugin_repos.py
```
This script:
- Finds all plugins in the workspace
- Runs `git pull` on each repository
- Reports which plugins were updated
This runs `git pull` in `../ledmatrix-plugins` and prints the result. The
symlinks pick up the new code; restart the display to load it.
## Configuration
The plugin system is configured in `config/config.json`:
The loader reads plugins from `plugin_system.plugins_directory` in
`config/config.json`. The default is already right for this setup:
```json
{
"plugin_system": {
"plugins_directory": "plugin-repos",
"auto_discover": true,
"auto_load_enabled": true
"plugins_directory": "plugin-repos"
}
}
```
The `plugins_directory` points to `plugin-repos/`, which contains symlinks to the actual repositories.
## Workflow
### Daily Development
1. **Open Workspace**: Open `LEDMatrix.code-workspace` in VS Code/Cursor
2. **All Repos Available**: All plugin repos appear as separate folders in the workspace
3. **Edit Plugins**: Edit plugin code directly in their repositories
4. **Update Plugins**: Run `update_plugin_repos.py` to pull latest changes
2. **Edit Plugins**: Edit code under `ledmatrix-plugins/plugins/<plugin>/`
3. **Test**: `python3 run.py -e` (emulator) or
`python3 scripts/check_plugin.py --plugin <id>` from LEDMatrix
4. **Ship**: Bump `version` in the plugin's `manifest.json`, run
`python update_registry.py` in ledmatrix-plugins, commit there
### Adding New Plugins
1. **Clone Repository**: Clone the new plugin repo to `/home/chuck/Github/`
2. **Add to Workspace**: Add the plugin folder to `LEDMatrix.code-workspace`
3. **Create Symlink**: Run `setup_plugin_repos.py` to create the symlink
### Updating Individual Plugins
Since plugins are regular Git repositories, you can update them individually:
```bash
cd /home/chuck/Github/ledmatrix-weather
git pull origin master
```
Or update all at once:
```bash
cd /home/chuck/Github/LEDMatrix
python3 scripts/update_plugin_repos.py
```
## Benefits
1. **No Submodule Hassle**: No need to update `.gitmodules` or run `git submodule update`
2. **Independent Updates**: Update plugins independently without touching LEDMatrix
3. **Clean Separation**: Each plugin is a separate repository with its own history
4. **Easy Development**: Multi-root workspace makes it easy to work across repos
5. **Automatic Discovery**: Plugin system automatically discovers plugins via symlinks
1. Create `plugins/<your-plugin-id>/` in the monorepo checkout
2. Run `python3 scripts/setup_plugin_repos.py` in LEDMatrix to link it
## Troubleshooting
### Symlinks Not Working
If plugins aren't being discovered:
### Plugins not discovered
```bash
cd /home/chuck/Github/LEDMatrix
python3 scripts/setup_plugin_repos.py
cd ~/Github/LEDMatrix
ls -la plugin-repos/ # links present and not broken?
python3 scripts/setup_plugin_repos.py # recreate them
```
This will recreate all symlinks.
Also check that `plugin_system.plugins_directory` is `plugin-repos`.
### Missing Plugins
### "Monorepo plugins directory not found"
If a plugin is in the workspace but not found:
`setup_plugin_repos.py` expects the monorepo at `../ledmatrix-plugins`. Clone
it there (or symlink it there).
1. Check if the repo exists in `/home/chuck/Github/`
2. Check if the symlink exists in `plugin-repos/`
3. Run `setup_plugin_repos.py` to recreate symlinks
### Plugin updates not showing
### Plugin Updates Not Showing
If changes to plugins aren't appearing:
1. Verify the symlink points to the correct directory: `ls -la plugin-repos/ledmatrix-weather`
2. Check that you're editing in the actual repo, not a copy
3. Restart the LEDMatrix service if running
1. Verify the link target: `ls -la plugin-repos/<id>`
2. Check that you're editing the monorepo checkout, not a store-installed copy
3. Restart the LEDMatrix service (or `run.py`)
## Notes
- The `plugin-repos/` directory is tracked in git, but only contains symlinks
- Actual plugin code lives in `/home/chuck/Github/ledmatrix-*/`
- Each plugin repo can be updated independently via `git pull`
- The LEDMatrix project doesn't need to be updated when plugins change
- `plugin-repos/` is tracked in git only for the bundled plugins
(`starlark-apps`, `web-ui-info`). The symlinks you create are untracked
files; don't commit them.
- For linking a single plugin into `plugins/` instead (without a sibling
checkout), see `scripts/dev/dev_plugin_setup.sh` in the
[Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md).
- When changing a plugin in the monorepo, bump its manifest `version` and run
`python update_registry.py`, or users won't receive the update.
+46 -7
View File
@@ -514,21 +514,59 @@ self.display_manager.draw_text_with_icons(
For plugins that implement scrolling content, use these methods to coordinate with the display system.
#### `set_scrolling_state(is_scrolling: bool) -> None`
#### `set_scrolling_state(is_scrolling: bool, frame_hold: int = 1) -> None`
Mark the display as scrolling or not scrolling. Call when scrolling starts/stops.
Mark the display as scrolling or not scrolling, and set this scroll's frame
pacing. Call it when a scroll starts (calling it on every scroll frame is fine)
and with `False` when it stops.
**Parameters**:
- `is_scrolling` (bool): True if currently scrolling, False otherwise
- `frame_hold` (int, default 1): how many panel refreshes each pushed frame is
held for (clamped to 1-255; ignored when `is_scrolling` is False, which
resets it to 1). Pass the `frame_hold` of the settings
`src.common.scroll_config.configure()` returned. Added in core 3.4.0.
**Why `frame_hold` matters**: `scroll_config.configure()` snaps the speed to
one the panel can show in whole pixels and sets the `ScrollHelper` to advance a
fixed number of pixels on every presented frame -- no clock is consulted. The
panel presents frames at its refresh rate divided by the hold, so the hold is
part of the speed. Omit it and a 50 px/s scroll (1px every 2nd refresh on a
100 Hz panel) runs at 100 px/s. The hold is not applied by `configure()`
because it must not outlive the scroll: plugins share one display manager.
**Example**:
```python
from src.common import scroll_config
from src.common.scroll_helper import ScrollHelper
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.scroll_helper = ScrollHelper(
self.display_manager.width, self.display_manager.height, self.logger)
# ...later, hand it content with self.scroll_helper.set_scrolling_image(img)
self.scroll_settings = scroll_config.configure(
self.scroll_helper,
plugin_config=self.config,
global_config=self.global_config,
display_manager=self.display_manager,
plugin_logger=self.logger,
)
def display(self, force_clear=False):
self.display_manager.set_scrolling_state(True)
# Scroll content...
self.display_manager.set_scrolling_state(False)
self.display_manager.set_scrolling_state(
True, frame_hold=self.scroll_settings.frame_hold)
self.scroll_helper.update_scroll_position()
self.display_manager.image = self.scroll_helper.get_visible_portion()
self.display_manager.update_display()
if self.scroll_helper.is_scroll_complete():
self.display_manager.set_scrolling_state(False)
```
Don't pace the loop with `time.sleep()`: `update_display()` blocks on the
panel's vsync, which is what paces a scroll. See `docs/SCROLL_PERFORMANCE.md`
for choosing a speed.
#### `is_currently_scrolling() -> bool`
Check if the display is currently in a scrolling state.
@@ -998,9 +1036,10 @@ if "weather" in enabled_plugins:
self.display_manager.update_display()
```
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods,
passing the frame hold `scroll_config.configure()` returned
```python
self.display_manager.set_scrolling_state(True)
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
# Scroll content...
self.display_manager.set_scrolling_state(False)
```
+2 -1
View File
@@ -8,7 +8,8 @@
> - Code paths reference `web_interface_v2.py`; the current web UI is
> `web_interface/app.py` with v3 Blueprint-based templates.
> - The example Flask routes use `/api/plugins/*`; the real API
> blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
> blueprint (`web_interface/blueprints/api_v3/`) is mounted at `/api/v3`
> in `web_interface/app.py`.
> - The default plugin location is `plugin-repos/` (configurable via
> `plugin_system.plugins_directory`), not `./plugins/`.
> - Example imports use `src/plugin_system/base_classes/*_plugin.py`;
+4 -6
View File
@@ -67,9 +67,7 @@ The main configuration file (`config/config.json`) now contains only essential s
"time_format": "%I:%M %p"
},
"plugin_system": {
"plugins_directory": "plugin-repos",
"auto_discover": true,
"auto_load_enabled": true
"plugins_directory": "plugin-repos"
}
}
```
@@ -93,9 +91,9 @@ The main configuration file (`config/config.json`) now contains only essential s
#### 4. Plugin System
- **plugin_system**: Plugin system configuration
- **plugins_directory**: Directory where plugins are stored
- **auto_discover**: Automatically discover plugins
- **auto_load_enabled**: Automatically load enabled plugins
- **plugins_directory**: Directory where plugins are stored (the only one the loader scans)
- `auto_discover`, `auto_load_enabled`, `development_mode` may still appear in
older configs; nothing reads them (see [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#plugin_system))
## Plugin Configuration
+2 -1
View File
@@ -6,7 +6,8 @@
> in the "Implementation Details" section below still reference the
> pre-v3 file layout (`web_interface_v2.py`, `templates/index_v2.html`).
> The current implementation lives in `web_interface/app.py`,
> `web_interface/blueprints/api_v3.py`, and `web_interface/templates/v3/`.
> `web_interface/blueprints/api_v3/` (plugin config handlers in
> `plugins.py`), and `web_interface/templates/v3/`.
> The user-facing description (Overview, Features, Form Generation
> Process) is still accurate.
+134 -382
View File
@@ -11,427 +11,179 @@
### Component Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Web Browser │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Tab Navigation Bar │ │
│ │ [Overview] [General] ... [Plugins] [Plugin X] [Plugin Y]│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────────┐ │
│ │ Plugins Tab │ │ Plugin X Configuration Tab │ │
│ │ │ │ │ │
│ │ • Install │ │ Form Generated from Schema: │ │
│ │ • Update │ │ • Boolean → Toggle │ │
│ │ • Uninstall │ │ • Number → Number Input │ │
│ │ • Enable │ │ • String → Text Input │ │
│ │ • [Configure]──────→ • Array → Comma Input │ │
│ │ │ │ • Enum → Dropdown │ │
│ └─────────────────┘ │ │ │
│ │ [Save] [Back] [Reset] │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Web browser (templates/v3/base.html, Alpine.js + HTMX) │
│ │
│ Second nav row: one tab per installed plugin │
│ Clicking a tab: GET /v3/partials/plugin-config/<plugin_id> │
│ → server-rendered form swapped into the tab │
│ │
│ Save: hx-post="/api/v3/plugins/config?plugin_id=<id>" (form data) │
└──────────────────────────────────────────────────────────────────┘
│
│ HTTP API
▼
┌─────────────────────────────────────────────────────────────────┐
│ Flask Backend │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ /api/v3/plugins/installed │ │
│ │ • Discover plugins in plugins/ directory │ │
│ │ • Load manifest.json for each plugin │ │
│ │ • Load config_schema.json if exists │ │
│ │ • Load current config from config.json │ │
│ │ • Return combined data to frontend │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ /api/v3/plugins/config │ │
│ │ • Receive key-value pair │ │
│ │ • Update config.json │ │
│ │ • Return success/error │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Flask (web_interface/app.py) │
│ │
│ pages_v3 blueprint (blueprints/pages_v3.py) │
│ _load_plugin_config_partial(plugin_id) │
│ • SchemaManager.load_schema() → config_schema.json │
│ • config.json section for the plugin │
│ • masks x-secret fields │
│ • renders partials/plugin_config.html (render_field macros) │
│ │
│ api_v3 blueprint (blueprints/api_v3/plugins.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 │
│ reset_plugin_config() POST /api/v3/plugins/config/reset │
└──────────────────────────────────────────────────────────────────┘
│
│ File System
▼
┌─────────────────────────────────────────────────────────────────┐
│ File System │
│ │
│ plugins/ │
│ ├── hello-world/ │
│ │ ├── manifest.json ───┐ │
│ │ ├── config_schema.json ─┼─→ Defines UI structure │
│ │ ├── manager.py │ │
│ │ └── requirements.txt │ │
│ └── clock-simple/ │ │
│ ├── manifest.json │ │
│ └── config_schema.json ──┘ │
│ │
│ config/ │
│ └── config.json ────────────→ Stores configuration values │
│ { │
│ "hello-world": { │
│ "enabled": true, │
│ "message": "Hello!", │
│ ... │
│ } │
│ } │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Files │
│ plugin-repos/<id>/config_schema.json JSON Schema (Draft-7) │
│ config/config.json { "<id>": { ... } } │
│ config/config_secrets.json { "<id>": { secrets } } │
└──────────────────────────────────────────────────────────────────┘
```
The plugins directory is `plugin_system.plugins_directory` in
`config/config.json` (default `plugin-repos/`). Plugin configuration lives in
`config/config.json`, not in the plugin directory, so it survives reinstalls.
## Data Flow
### 1. Page Load Sequence
### 1. Rendering a plugin's tab
```
User Opens Web Interface
│
▼
DOMContentLoaded Event
│
▼
refreshPlugins()
│
▼
GET /api/v3/plugins/installed
│
├─→ For each plugin directory:
│ ├─→ Read manifest.json
│ ├─→ Read config_schema.json (if exists)
│ └─→ Read config from config.json
│
▼
Return JSON Array:
[{
id: "hello-world",
name: "Hello World",
config: { enabled: true, message: "Hello!" },
config_schema_data: {
properties: {
enabled: { type: "boolean", ... },
message: { type: "string", ... }
}
}
}, ...]
│
▼
generatePluginTabs(plugins)
│
├─→ For each plugin:
│ ├─→ Create tab button
│ ├─→ Create tab content div
│ └─→ generatePluginConfigForm(plugin)
│ │
│ ├─→ Read schema properties
│ ├─→ Get current config values
│ └─→ Generate HTML form inputs
│
▼
Tabs Rendered in UI
User opens the plugin's tab
│
▼
GET /v3/partials/plugin-config/<plugin_id> (pages_v3)
│
├─→ Load schema (SchemaManager, no cache)
├─→ Load config.json[<plugin_id>]
├─→ Mask "x-secret" values (fails closed if the schema is unusable)
└─→ render partials/plugin_config.html
│
└─→ render_field() per property, recursively:
boolean → toggle, number/integer → input or slider,
string → input / textarea / select (enum),
array → list or table widget,
object → collapsible nested section,
"x-widget" → a registered widget
(static/v3/js/widgets/, or one the plugin ships)
```
### 2. Configuration Save Sequence
Nested objects are supported: a nested field is posted with a dotted name
(e.g. `transition.type`).
### 2. Saving
```
User Modifies Form
│
▼
User Clicks "Save"
│
▼
savePluginConfiguration(pluginId)
│
├─→ Get form data
├─→ For each field:
│ ├─→ Get schema type
│ ├─→ Convert value to correct type
│ │ • boolean: checkbox.checked
│ │ • integer: parseInt()
│ │ • number: parseFloat()
│ │ • array: split(',')
│ │ • string: as-is
│ │
│ └─→ POST /api/v3/plugins/config
│ {
│ plugin_id: "hello-world",
│ key: "message",
│ value: "Hello, World!"
│ }
│
▼
Backend Updates config.json
│
▼
Return Success
│
▼
Show Notification
│
▼
Refresh Plugins
User clicks Save
│
▼
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)
├─→ Start from the stored config.json[<id>]
├─→ Apply form fields: dotted names → nested keys, "[]" checkbox
│ groups → lists, values coerced to the schema's types
├─→ Merge schema defaults for keys that are still missing
├─→ Validate against the schema (plus core per-plugin properties);
│ invalid → 400 with the validation errors, nothing saved
├─→ Split "x-secret" fields out; masked/blank secrets are dropped so
│ an untouched secret keeps its stored value
├─→ Deep-merge regular fields into config.json[<id>] (atomic save)
├─→ Merge secrets into config_secrets.json[<id>]
└─→ Call the loaded plugin's on_config_change() (and
on_enable/on_disable if "enabled" changed)
│
▼
One response for the whole form → notification in the UI
```
## Class and Function Hierarchy
The display service picks up the new config through its config hot reload
(ConfigService) without a restart.
### Frontend (JavaScript)
JSON clients can post `{"plugin_id": ..., "config": {...}}` instead; the keys
sent are merged onto the stored config the same way. See
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#save-plugin-configuration).
```
Window Load
└── DOMContentLoaded
└── refreshPlugins()
├── fetch('/api/v3/plugins/installed')
├── renderInstalledPlugins(plugins)
└── generatePluginTabs(plugins)
└── For each plugin:
├── Create tab button
├── Create tab content
└── generatePluginConfigForm(plugin)
├── Read config_schema_data
├── Read current config
└── Generate form HTML
├── Boolean → Toggle switch
├── Number → Number input
├── String → Text input
├── Array → Comma-separated input
└── Enum → Select dropdown
### 3. Reset
User Interactions
├── configurePlugin(pluginId)
│ └── showTab(`plugin-${pluginId}`)
│
├── savePluginConfiguration(pluginId)
│ ├── Process form data
│ ├── Convert types per schema
│ └── For each field:
│ └── POST /api/v3/plugins/config
│
└── resetPluginConfig(pluginId)
├── Get schema defaults
└── For each field:
└── POST /api/v3/plugins/config
```
### Backend (Python)
```
Flask Routes
├── /api/v3/plugins/installed (GET)
│ └── api_plugins_installed()
│ ├── PluginManager.discover_plugins()
│ ├── For each plugin:
│ │ ├── PluginManager.get_plugin_info()
│ │ ├── Load config_schema.json
│ │ └── Load config from config.json
│ └── Return JSON response
│
└── /api/v3/plugins/config (POST)
└── api_plugin_config()
├── Parse request JSON
├── Load current config
├── Update config[plugin_id][key] = value
└── Save config.json
```
## File Structure
```
LEDMatrix/
│
├── web_interface_v2.py
│ └── Flask backend with plugin API endpoints
│
├── templates/
│ └── index_v2.html
│ └── Frontend with dynamic tab generation
│
├── config/
│ └── config.json
│ └── Stores all plugin configurations
│
├── plugins/
│ ├── hello-world/
│ │ ├── manifest.json ← Plugin metadata
│ │ ├── config_schema.json ← UI schema definition
│ │ ├── manager.py ← Plugin logic
│ │ └── requirements.txt
│ │
│ └── clock-simple/
│ ├── manifest.json
│ ├── config_schema.json
│ └── manager.py
│
└── docs/
├── PLUGIN_CONFIGURATION_TABS.md ← Full documentation
├── PLUGIN_CONFIG_TABS_SUMMARY.md ← Implementation summary
├── PLUGIN_CONFIG_QUICK_START.md ← Quick start guide
└── PLUGIN_CONFIG_ARCHITECTURE.md ← This file
```
`POST /api/v3/plugins/config/reset` replaces the plugin's section with the
schema defaults (keeping secrets unless `preserve_secrets` is false).
## Key Design Decisions
### 1. Dynamic Tab Generation
### 1. Server-side rendered forms
**Why**: Plugins are installed/uninstalled dynamically
**How**: JavaScript creates/removes tab elements on plugin list refresh
**Benefit**: No server-side template rendering needed
**Why**: One renderer for every plugin, no per-plugin frontend code
**How**: Jinja macros in `partials/plugin_config.html` walk the schema
**Benefit**: The settings search index is built from the same rendered HTML
(`/v3/settings/search-index`)
### 2. JSON Schema as Source of Truth
### 2. JSON Schema as source of truth
**Why**: Standard, well-documented, validation-ready
**How**: Frontend interprets schema to generate forms
**Benefit**: Plugin developers use familiar format
**Why**: Standard, well-documented, validation-ready
**How**: The same schema drives the form, the defaults and server-side validation
**Benefit**: Plugin developers use a familiar format
### 3. Individual Config Updates
### 3. Whole-form saves that merge
**Why**: Simplifies backend API
**How**: Each field saved separately via `/api/v3/plugins/config`
**Benefit**: Atomic updates, easier error handling
**Why**: A partial form (or a field the form doesn't show) must not wipe
stored values
**How**: The handler starts from the stored section and merges what was posted
**Benefit**: One request per save, atomic write
### 4. Type Conversion in Frontend
### 4. Secrets kept out of config.json
**Why**: HTML forms only return strings
**How**: JavaScript converts based on schema type before sending
**Benefit**: Backend receives correctly-typed values
### 5. No Nested Objects
**Why**: Keeps UI simple
**How**: Only flat property structures supported
**Benefit**: Easy form generation, clear to users
**Why**: `config.json` is shown in the raw editor and returned by the API
**How**: `"x-secret": true` fields go to `config_secrets.json`, which is
deep-merged back into the plugin's config at load time
**Benefit**: Plugins read secrets with plain `config.get(...)`
## Extension Points
### Adding New Input Types
### Custom input widgets
Location: `generatePluginConfigForm()` in `index_v2.html`
Set `"x-widget": "<name>"` on a property. Core widgets are in
`web_interface/static/v3/js/widgets/` (see its README); a plugin can ship its
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`.
See [widget-guide.md](widget-guide.md).
```javascript
if (type === 'your-new-type') {
formHTML += `
<!-- Your custom input HTML -->
`;
}
```
### Custom actions
### Custom Validation
Buttons that run plugin scripts are declared in the manifest's
`web_ui_actions`. See [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md).
Location: `savePluginConfiguration()` in `index_v2.html`
### Reacting to changes
```javascript
// Add validation before sending
if (!validateCustomConstraint(value, propSchema)) {
throw new Error('Validation failed');
}
```
Implement `on_config_change(new_config)` in the plugin (see
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)).
### Backend Hook
## Where to Look
Location: `api_plugin_config()` in `web_interface_v2.py`
```python
# Add custom logic before saving
if plugin_id == 'special-plugin':
value = transform_value(value)
```
## Performance Considerations
### Frontend
- **Tab Generation**: O(n) where n = number of plugins (typically < 20)
- **Form Generation**: O(m) where m = number of config properties (typically < 10)
- **Memory**: Each plugin tab ~5KB HTML
- **Total Impact**: Negligible for typical use cases
### Backend
- **Schema Loading**: Cached after first load
- **Config Updates**: Single file write (atomic)
- **API Calls**: One per config field on save (sequential)
- **Optimization**: Could batch updates in single API call
## Security Considerations
1. **Input Validation**: Schema constraints enforced client-side (UX) and should be enforced server-side
2. **Path Traversal**: Plugin paths validated against known plugin directory
3. **XSS**: All user inputs escaped before rendering in HTML
4. **CSRF**: Flask CSRF tokens should be used in production
5. **File Permissions**: config.json requires write access
| Concern | File |
|---------|------|
| 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` |
| 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/` |
## Error Handling
### Frontend
- Network errors: Show notification, don't crash
- Schema errors: Graceful fallback to no config tab
- Type errors: Log to console, continue processing other fields
### Backend
- Invalid plugin_id: 400 Bad Request
- Schema not found: Return null, frontend handles gracefully
- Config save error: 500 Internal Server Error with message
## Testing Strategy
### Unit Tests
- `generatePluginConfigForm()` for each schema type
- Type conversion logic in `savePluginConfiguration()`
- Backend schema loading logic
### Integration Tests
- Full save flow: form → API → config.json
- Tab generation from API response
- Reset to defaults
### E2E Tests
- Install plugin → verify tab appears
- Configure plugin → verify config saved
- Uninstall plugin → verify tab removed
## Monitoring
### Frontend Metrics
- Time to generate tabs
- Form submission success rate
- User interactions (configure, save, reset)
### Backend Metrics
- API response times
- Config update success rate
- Schema loading errors
### User Feedback
- Are users finding the configuration interface?
- Are validation errors clear?
- Are default values sensible?
## Future Roadmap
### Phase 2: Enhanced Validation
- Real-time validation feedback
- Custom error messages
- Dependent field validation
### Phase 3: Advanced Inputs
- Color pickers for RGB arrays
- File upload for assets
- Rich text editor for descriptions
### Phase 4: Configuration Management
- Export/import configurations
- Configuration presets
- Version history/rollback
### Phase 5: Developer Tools
- Schema editor in web UI
- Live preview while editing schema
- Validation tester
- Unknown plugin or unreadable schema: the partial renders an error message
- Validation failure: `400` with `details` and `context.validation_errors`;
the form shows them and nothing is saved
- Save failure: `500` with an error message; config.json is written
atomically, so a failed save leaves the previous file intact
+110 -184
View File
@@ -2,234 +2,160 @@
## Overview
The LEDMatrix system has smart dependency installation that adapts based on who is running it. This guide explains how it works and potential pitfalls.
A plugin lists its Python packages in its `requirements.txt`. LEDMatrix
installs them for you when a plugin is installed, updated or loaded. This
guide explains where they end up and what to do when a plugin can't import a
package.
## How It Works
The rule to remember: **packages must be importable by `ledmatrix.service`,
which runs as root.** Anything installed only into another user's
`~/.local/` is invisible to it.
### Execution Context Detection
## Who Runs What
The plugin manager checks if it's running as root:
```python
running_as_root = os.geteuid() == 0
| Service | Runs as | Set by |
|---------|---------|--------|
| `ledmatrix.service` (display) | `root` | `systemd/ledmatrix.service` |
| `ledmatrix-web.service` (web UI) | the user who ran the installer (e.g. `ledpi`) | `User=__USER__` in `systemd/ledmatrix-web.service`, filled in by `scripts/install/install_service.sh` |
## How Dependencies Get Installed
### 1. Installing or updating a plugin from the web UI
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
`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
`requirements.txt` under `plugin-repos/` or `plugins/`, then runs
`python3 -m pip install --break-system-packages --ignore-installed -r ...`
**as root**, so the display service can import the packages.
3. The sudoers rule that allows this is written by the installer
(`first_time_install.sh`) or by `scripts/install/configure_web_sudo.sh`.
If sudo refuses (the rule isn't installed), `install_requirements_file()`
falls back to installing with the web process's own interpreter, as the web
user, and prefixes the pip output with a note like:
```
[Root install unavailable (...); installed for the current process's user only.
Packages may not be visible to ledmatrix.service if it runs as a different
user — run scripts/install/configure_web_sudo.sh to fix this.]
```
Based on this, it chooses the appropriate installation method:
Fix it by running `./scripts/install/configure_web_sudo.sh` as the web
user (not with `sudo`; it asks for your password itself), then
reinstall the plugin (or use the manual install below).
| Running As | Installation Method | Location | Accessible To |
|------------|-------------------|----------|---------------|
| **root** (systemd service) | System-wide (`--break-system-packages`) | `/usr/local/lib/python3.X/dist-packages/` | All users |
| **ledpi** or other user | User-specific (`--user`) | `~/.local/lib/python3.X/site-packages/` | Only that user |
The **Reinstall Plugin Deps** button on the web UI's Tools tab goes
through the same helper for every installed plugin.
### 2. Loading a plugin
When a plugin loads, `PluginLoader.install_dependencies()`
(`src/plugin_system/plugin_loader.py`) checks its `requirements.txt`. If the
requirements are already satisfied it does nothing; otherwise it runs
`python3 -m pip install --break-system-packages -r requirements.txt` with the
interpreter of the process doing the loading (retrying with
`--ignore-installed` when a system package without a pip RECORD file is in
the way).
In `ledmatrix.service` that process is root, so restarting the display
service installs anything missing system-wide:
```bash
sudo systemctl restart ledmatrix
```
If you run `python3 run.py` by hand as a normal user instead, pip cannot
write to the system site-packages and installs into your `~/.local/`. That
works for your manual run but not for the service.
## Common Scenarios
### ✅ Scenario 1: Normal Production Use (Recommended)
### Installing plugins from the web UI (recommended)
**What:** Services running via systemd
Use the **Plugin Manager** tab. Dependencies are installed as root through
the sudo helper and the display service can use them.
### Running the display manually for debugging
```bash
sudo systemctl start ledmatrix
sudo systemctl start ledmatrix-web
cd ~/LEDMatrix
sudo python3 run.py # same user as the service
```
- **Runs as:** root (configured in .service files)
- **Installs to:** System-wide
- **Result:** ✅ Works perfectly, all dependencies accessible
Running as your own user works for plugins whose packages are already
installed system-wide, but any *missing* package lands in `~/.local/`.
### ✅ Scenario 2: Web Interface Plugin Installation
### A plugin works when run manually but fails in the service
**What:** Installing/enabling plugins via web interface at `http://pi-ip:5000`
Its packages were installed for your user only. Install them as root (see
below) and restart the service.
- **Web service runs as:** root (ledmatrix-web.service)
- **Installs to:** System-wide
- **Result:** ✅ Works perfectly, systemd service can access them
## Manual Installation
### ✅ Scenario 3: Manual Testing as ledpi (Read-only)
**What:** Running display manually as ledpi to test/debug
### All plugins
```bash
# As ledpi user
cd /home/ledpi/LEDMatrix
python3 run.py
```
- **Runs as:** ledpi
- **Can import:** ✅ System-wide packages (installed by root)
- **Result:** ✅ Works! Can use existing plugins with root-installed dependencies
### ⚠️ Scenario 4: Manual Plugin Installation as ledpi (Problematic)
**What:** Enabling a NEW plugin and running manually as ledpi
```bash
# As ledpi user
cd /home/ledpi/LEDMatrix
# Edit config to enable new plugin
nano config/config.json
# Run display - will try to install new plugin dependencies
python3 run.py
```
**What Happens:**
1. Plugin manager runs as `ledpi`
2. Installs dependencies with `--user` flag
3. Dependencies go to `~/.local/lib/python3.X/site-packages/`
4. ⚠️ **Warning logged:** "Installing plugin dependencies for current user (not root)"
**Problem:**
- When systemd service restarts (as root), it **can't see** `~/.local/` packages
- Plugin will fail to load for the systemd service
**Solution:**
After testing, restart the service to install dependencies system-wide:
```bash
sudo ~/LEDMatrix/scripts/install_plugin_dependencies.sh
sudo systemctl restart ledmatrix
```
## Best Practices
The script installs every `requirements.txt` found in the plugins directory
configured by `plugin_system.plugins_directory` in `config/config.json`
(default `plugin-repos/`). Run it with `sudo` so the packages are installed
system-wide.
### For Production/Normal Use
### One plugin
1. **Always use the web interface** to install/enable plugins
2. **Or restart the systemd service** after config changes:
```bash
sudo systemctl restart ledmatrix
```
### For Development/Testing
1. **Read existing plugins:** Safe to run as `ledpi` - can import system packages
2. **Test new plugins:** Use sudo or restart service to install dependencies:
```bash
# Option 1: Run as root
sudo python3 run.py
# Option 2: Install deps manually
sudo pip3 install --break-system-packages -r plugins/my-plugin/requirements.txt
python3 run.py
# Option 3: Let service install them
sudo systemctl restart ledmatrix
```
## Warning Messages
### If you see this warning:
```
Installing plugin dependencies for current user (not root).
These will NOT be accessible to the systemd service.
For production use, install plugins via the web interface or restart the ledmatrix service.
```
**What it means:**
- You're running as a non-root user
- Dependencies were installed to your user directory only
- The systemd service won't be able to use this plugin
**What to do:**
```bash
# Restart the service to install dependencies system-wide
cd ~/LEDMatrix/plugin-repos/PLUGIN-NAME # or your configured plugins directory
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
sudo systemctl restart ledmatrix
```
`--no-cache-dir` avoids errors about `/root/.cache/pip` not being writable.
## Troubleshooting
### Plugin works when I run manually but fails in systemd service
**Cause:** Dependencies installed to user directory (`~/.local/`) instead of system-wide
**Fix:**
```bash
# Check where package is installed
pip3 list -v | grep <package-name>
# If it shows ~/.local/, reinstall system-wide:
sudo pip3 install --break-system-packages <package-name>
# Or just restart the service:
sudo systemctl restart ledmatrix
```
### Permission denied when installing dependencies
**If you see errors like:**
```
ERROR: Could not install packages due to an OSError: [Errno 13] Permission denied: '/root/.local'
WARNING: The directory '/root/.cache/pip' or its parent directory is not owned or is not writable
```
**Quick Fix - Use the Helper Script:**
```bash
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
sudo systemctl restart ledmatrix
```
Use one of the manual installs above (they pass `--no-cache-dir`).
**Manual Fix:**
```bash
# Install dependencies with --no-cache-dir to avoid cache permission issues
cd /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
sudo systemctl restart ledmatrix
```
**For more detailed troubleshooting, see:** [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
## Architecture Summary
```
┌─────────────────────────────────────────────────────────────┐
│ LEDMatrix Services │
├─────────────────────────────────────────────────────────────┤
│ │
│ ledmatrix.service (User=root) │
│ ledmatrix-web.service (User=root) │
│ ├── Install dependencies system-wide │
│ └── Accessible to all users │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ Manual execution as ledpi │
│ ├── Can READ system-wide packages ✅ │
│ ├── WRITES go to ~/.local/ ⚠️ │
│ └── Not accessible to root service │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Recommendations
1. **For end users:** Always use the web interface for plugin management
2. **For developers:** Be aware of the user context when testing
3. **For plugin authors:** Test with `sudo systemctl restart ledmatrix` to ensure dependencies install correctly
4. **For CI/CD:** Always run installation as root or use the service
## Helper Scripts
### Install Plugin Dependencies Script
Located at: `scripts/install_plugin_dependencies.sh`
This script automatically finds and installs dependencies for all plugins:
### Checking where a package is installed
```bash
# Run as root (recommended for production)
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
# How the service sees it
sudo python3 -c "import package_name; print(package_name.__file__)"
# Make executable if needed
chmod +x /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
# A path under /home/<user>/.local/ means it was installed for that user only
python3 -m pip show -f package_name
```
Features:
- Auto-detects all plugins with requirements.txt
- Uses correct installation method (system-wide vs user)
- Bypasses pip cache to avoid permission issues
- Provides detailed logging and error messages
For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md).
## For Plugin Authors
1. Keep `requirements.txt` minimal and pin only what you need.
2. Test that it installs the way the Pi will install it:
```bash
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
```
3. Note any `apt` packages your plugin needs in its README.
## Files to Reference
- Service configs: `ledmatrix.service`, `ledmatrix-web.service`
- Plugin manager: `src/plugin_system/plugin_manager.py`
- Installation script: `first_time_install.sh`
- Dependency installer: `scripts/install_plugin_dependencies.sh`
- Troubleshooting guide: `PLUGIN_DEPENDENCY_TROUBLESHOOTING.md`
- Service units: `systemd/ledmatrix.service`, `systemd/ledmatrix-web.service`
- Store installs: `src/plugin_system/store_manager.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/configure_web_sudo.sh`
- Manual installer: `scripts/install_plugin_dependencies.sh`
+77 -82
View File
@@ -1,6 +1,7 @@
# Plugin Dependency Installation Troubleshooting
This guide helps resolve issues with automatic plugin dependency installation in the LEDMatrix system.
This guide helps resolve problems installing a plugin's Python packages. For
how installation works, see the [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md).
## Common Error Symptoms
@@ -10,109 +11,118 @@ ERROR: Could not install packages due to an OSError: [Errno 13] Permission denie
WARNING: The directory '/root/.cache/pip' or its parent directory is not owned or is not writable
```
### Context Mismatch
### Installed for the wrong user
The pip output shown after a web-UI install starts with:
```
WARNING: Installing plugin dependencies for current user (not root).
These will NOT be accessible to the systemd service.
[Root install unavailable (...); installed for the current process's user only.
Packages may not be visible to ledmatrix.service if it runs as a different
user — run scripts/install/configure_web_sudo.sh to fix this.]
```
### Plugin fails to load with `ModuleNotFoundError`
The display service can't see a package the plugin needs.
## Root Cause
Plugin dependencies must be installed in a context accessible to the LEDMatrix systemd service, which runs as root. Permission errors typically occur when:
Plugin packages must be importable by `ledmatrix.service`, which runs as
root. The web interface (`ledmatrix-web.service`) runs as the user who
installed LEDMatrix, so it installs through a sudo helper
(`scripts/fix_perms/safe_pip_install.sh`). Problems usually come from:
1. The pip cache directory has incorrect permissions
2. The process tries to install to user directories without proper permissions
3. Environment variables (like HOME) are not set correctly for the service context
1. The sudoers rule for that helper missing, so the web UI installed the
packages for its own user only
2. Running `python3 run.py` by hand as a normal user, which installs missing
packages into `~/.local/`
3. pip's cache directory not being writable for root
## Solutions
### Solution 1: Use the Manual Installation Script (Recommended)
We provide a helper script that handles dependency installation correctly:
### Solution 1: Restore the sudo rule, then reinstall
```bash
# Run as root to install system-wide (for production)
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
cd ~/LEDMatrix
./scripts/install/configure_web_sudo.sh # as the web user, not with sudo
```
# After installation, restart the service
Then reinstall the plugin from the **Plugin Manager** tab, or click
**Reinstall Plugin Deps** on the **Tools** tab.
### Solution 2: Install every plugin's dependencies from the terminal
```bash
sudo ~/LEDMatrix/scripts/install_plugin_dependencies.sh
sudo systemctl restart ledmatrix
```
This script:
- Detects all plugins with requirements.txt files
- Installs dependencies with correct permissions
- Uses `--no-cache-dir` to avoid cache permission issues
- Provides detailed logging for troubleshooting
The script finds each `requirements.txt` in the plugins directory set by
`plugin_system.plugins_directory` in `config/config.json` (default
`plugin-repos/`), installs with `--no-cache-dir`, and reports what it found.
### Solution 2: Manual Installation per Plugin
If you need to install dependencies for a specific plugin:
### Solution 3: Install one plugin's dependencies
```bash
# Navigate to the plugin directory
cd /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME
# Your configured plugins directory; plugin-repos/ by default
cd ~/LEDMatrix/plugin-repos/PLUGIN-NAME
# Install as root (system-wide)
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
# Restart the service
sudo systemctl restart ledmatrix
```
### Solution 3: Fix Cache Directory Permissions
### Solution 4: Let the display service install them
If you specifically have cache permission issues:
When a plugin loads, the display service installs any missing requirements
itself, as root:
```bash
sudo systemctl restart ledmatrix
sudo journalctl -u ledmatrix -f # watch for "Installing dependencies for plugin ..."
```
### Solution 5: Fix pip cache permissions
```bash
# Option A: Skip the cache (recommended)
sudo pip3 install --no-cache-dir --break-system-packages -r requirements.txt
sudo python3 -m pip install --no-cache-dir --break-system-packages -r requirements.txt
# Option B: Fix cache permissions (if needed)
# Option B: Fix cache permissions
sudo mkdir -p /root/.cache/pip
sudo chown -R root:root /root/.cache
sudo chmod -R 755 /root/.cache
```
### Solution 4: Install via Web Interface
The web interface handles dependency installation correctly in the service context:
1. Access the web interface (`http://ledpi:5000` or `http://your-pi-ip:5000`)
2. Open the **Plugin Manager** tab (use the **Plugin Store** section to
find the plugin, or **Install from GitHub**)
3. Install the plugin through the web UI
4. The system automatically handles dependency installation in the
service context (which has the right permissions)
## Prevention
### For Plugin Developers
When creating plugins with dependencies:
1. **Keep requirements minimal**: Only include essential packages
2. **Test installation**: Verify your requirements.txt works with:
2. **Test installation** the way the Pi does it:
```bash
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
```
3. **Document dependencies**: Note any system packages needed (via apt)
### For Users
1. **Use web interface**: Install plugins via the web UI when possible
2. **Install as root**: When using SSH/terminal, use sudo for plugin installations
3. **Restart service**: After manual installations, restart the ledmatrix service
1. **Use the web interface** to install plugins
2. **Use sudo** for installs from SSH/terminal
3. **Restart the service** after manual installations
## Technical Details
### How Dependency Installation Works
### Where installs happen
The `PluginManager._install_plugin_dependencies()` method:
1. Detects if running as root using `os.geteuid() == 0`
2. If root: Uses system-wide installation with `--break-system-packages --no-cache-dir`
3. If not root: Uses user installation with `--user --break-system-packages --no-cache-dir`
4. The `--no-cache-dir` flag prevents cache-related permission issues
- **Web UI install/update:** `PluginStoreManager._install_dependencies()`
→ `install_requirements_file()` in `src/common/permission_utils.py`, which
runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <requirements.txt>`.
The helper only accepts the project's `requirements.txt` or one under
`plugin-repos/` or `plugins/`, and runs
`pip install --break-system-packages --ignore-installed` as root. If sudo
refuses, it falls back to a pip install as the web user and says so.
- **Plugin load:** `PluginLoader.install_dependencies()` in
`src/plugin_system/plugin_loader.py` skips satisfied requirements and
otherwise runs `pip install --break-system-packages` with the loading
process's interpreter — root in `ledmatrix.service`.
### Why `--break-system-packages`?
@@ -120,26 +130,20 @@ Debian 12+ (Bookworm) and Raspberry Pi OS based on it implement PEP 668, which p
### Service Context
The ledmatrix.service runs as:
- **User**: root
- **WorkingDirectory**: /home/ledpi/LEDMatrix
- **Python**: /usr/bin/python3
- `ledmatrix.service` runs as **root** with `/usr/bin/python3`
- `ledmatrix-web.service` runs as **the installing user**
Dependencies must be installed in root's Python environment or system-wide to be accessible.
Dependencies must be installed system-wide (as root) to be visible to the
display service.
## Checking Installation
Verify dependencies are installed correctly:
```bash
# Check as root (how the service sees it)
sudo python3 -c "import package_name"
sudo python3 -c "import package_name; print(package_name.__file__)"
# List installed packages
pip3 list
# Check specific package
pip3 show package_name
# A path under /home/<user>/.local/ means a user-only install
python3 -m pip show -f package_name
```
## Getting Help
@@ -151,19 +155,11 @@ If you continue to experience issues:
sudo journalctl -u ledmatrix -f
```
2. Check pip logs (created by manual script):
2. Verify the plugin manifest and requirements (default plugins directory
shown):
```bash
cat /tmp/pip_install_*.log
```
3. Verify plugin manifest is correct:
```bash
cat /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME/manifest.json
```
4. Check plugin requirements:
```bash
cat /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME/requirements.txt
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/manifest.json
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/requirements.txt
```
## Related Documentation
@@ -171,4 +167,3 @@ If you continue to experience issues:
- [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md)
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
- [Troubleshooting](TROUBLESHOOTING.md)
+109 -88
View File
@@ -3,8 +3,10 @@
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
> **Rendering guidance:** plugins should read the display size dynamically
> (`self.display_manager.matrix.width/height`) rather than hardcoding one
> panel. For plugins that want to *scale* their layout to any panel, the
> (`self.display_manager.width/height`) rather than hardcoding one
> panel. Don't read `display_manager.matrix.width/height`: `matrix` is
> `None` when hardware init fails, while the `width`/`height` properties
> fall back to the canvas size. For plugins that want to *scale* their layout to any panel, the
> opt-in adaptive layout system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md))
> provides the shared helpers — fonts, images, and composite layouts that
> scale. Existing plugins keep their classic rendering unless they adopt
@@ -43,28 +45,45 @@ The solution uses **symbolic links** to connect plugin repositories to the `plug
## Quick Start
### 1. Link a Plugin from GitHub
Official plugins all live in one repository,
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins), with
one directory per plugin under `plugins/` (there are no per-plugin
`ledmatrix-<name>` repositories). The helper script links a plugin directory
from a checkout of that monorepo into LEDMatrix's `plugins/` directory.
The easiest way to link a plugin that's already on GitHub:
### 1. Link an Official Plugin
```bash
./scripts/dev/dev_plugin_setup.sh link-github music
./scripts/dev/dev_plugin_setup.sh link-github football-scoreboard
```
This will:
- Clone `https://github.com/ChuckBuilds/ledmatrix-music.git` to `~/.ledmatrix-dev-plugins/ledmatrix-music`
- Create a symbolic link from `plugins/music` to the cloned repository
- Validate that the plugin has a proper `manifest.json`
- Clone `https://github.com/ChuckBuilds/ledmatrix-plugins.git` to
`~/.ledmatrix-dev-plugins/ledmatrix-plugins` (or `git pull` it if it is
already there)
- Find `plugins/football-scoreboard` in it (also accepted:
`plugins/ledmatrix-<name>`, or a plugin whose manifest `id` is the name)
- Validate that it has a `manifest.json`
- Create a symbolic link named after the plugin's manifest id, e.g.
`plugins/football-scoreboard` → `~/.ledmatrix-dev-plugins/ledmatrix-plugins/plugins/football-scoreboard`
### 2. Link a Local Plugin Repository
`link-github music` finds the monorepo's `plugins/ledmatrix-music` directory
and links it into LEDMatrix as `plugins/ledmatrix-music`, because
`ledmatrix-music` is that plugin's manifest id.
If you already have a plugin repository cloned locally:
To work from your fork of the monorepo, set `github_user` in
`dev_plugins.json` (see [Configuration](#configuration)).
### 2. Link a Local Plugin Directory
If you already have the monorepo (or a third-party plugin repository) cloned
locally:
```bash
./scripts/dev/dev_plugin_setup.sh link music ../ledmatrix-music
./scripts/dev/dev_plugin_setup.sh link hello-world ../ledmatrix-plugins/plugins/hello-world
```
This creates a symlink from `plugins/music` to your local repository path.
This creates a symlink from `plugins/hello-world` to that directory.
### 3. Check Status
@@ -77,13 +96,17 @@ See which plugins are linked and their git status:
### 4. Work on Your Plugin
```bash
cd plugins/music # Actually editing the linked repository
# Make your changes
cd plugins/football-scoreboard # Actually editing the monorepo checkout
# Make your changes, then bump "version" in manifest.json
git add .
git commit -m "feat: add new feature"
git push origin main
git commit -m "feat(football-scoreboard): add new feature"
git push # to your fork, then open a PR against ledmatrix-plugins
```
In the monorepo, every plugin change must bump `version` in the plugin's
`manifest.json` and run `python update_registry.py`, or users won't receive
the update.
### 5. Update Plugins
Pull latest changes from remote:
@@ -116,7 +139,7 @@ Links a local plugin repository to the plugins directory.
**Example:**
```bash
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-football-scoreboard
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-plugins/plugins/football-scoreboard
```
**Notes:**
@@ -129,23 +152,25 @@ Links a local plugin repository to the plugins directory.
Clones a plugin from GitHub and links it.
**Arguments:**
- `plugin-name`: The name of the plugin (will be the directory name in `plugins/`)
- `repo-url`: (Optional) Full GitHub repository URL. If omitted, constructs from pattern: `https://github.com/ChuckBuilds/ledmatrix-<plugin-name>.git`
- `plugin-name`: Without `repo-url`, the plugin to link from the monorepo: a
directory under `plugins/` (`<name>` or `ledmatrix-<name>`) or a manifest
id. The link is named after the plugin's manifest id. With `repo-url`, the
name of the link in `plugins/`.
- `repo-url`: (Optional) A plugin that has its own repository (e.g. a
third-party plugin). The repository root is linked.
**Examples:**
```bash
# Auto-construct URL from plugin name
./scripts/dev/dev_plugin_setup.sh link-github music
# Official plugin, from the ledmatrix-plugins monorepo
./scripts/dev/dev_plugin_setup.sh link-github stocks
# Use explicit URL
./scripts/dev/dev_plugin_setup.sh link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
# Link from a different GitHub user
# Third-party plugin with its own repository
./scripts/dev/dev_plugin_setup.sh link-github custom-plugin https://github.com/OtherUser/custom-plugin.git
```
**Notes:**
- Repositories are cloned to `~/.ledmatrix-dev-plugins/` by default (configurable)
- The monorepo is cloned once and shared by every plugin you link from it
- If the repository already exists, it will be updated with `git pull` instead of re-cloning
- The cloned repository is preserved when you unlink the plugin
@@ -217,30 +242,28 @@ Updates plugin(s) by running `git pull` in their repositories.
### Custom Development Directory
By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`. You can customize this by creating a `dev_plugins.json` file:
By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`
and official plugins come from `ChuckBuilds/ledmatrix-plugins`. To change
either, copy `dev_plugins.json.example` (in the LEDMatrix root) to
`dev_plugins.json` and edit it. `dev_plugins.json` is git-ignored.
```json
{
"dev_plugins_dir": "/path/to/your/dev/plugins",
"github_user": "ChuckBuilds",
"github_pattern": "ledmatrix-",
"plugins": {
"music": {
"source": "github",
"url": "https://github.com/ChuckBuilds/ledmatrix-music.git",
"branch": "main"
}
}
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
"github_user": "your-github-user",
"plugins_repo": "ledmatrix-plugins",
"plugins_branch": "main"
}
```
**Configuration options:**
**Configuration options** (all optional):
- `dev_plugins_dir`: Where to clone GitHub repositories (default: `~/.ledmatrix-dev-plugins`)
- `github_user`: Default GitHub username for auto-constructing URLs
- `github_pattern`: Pattern for repository names (default: `ledmatrix-`)
- `plugins`: Plugin definitions (optional, for future auto-discovery features)
- `github_user`: Owner of the plugin monorepo that `link-github <name>` clones — set it to use your fork (default: `ChuckBuilds`)
- `plugins_repo`: Name of that monorepo (default: `ledmatrix-plugins`)
- `plugins_branch`: Branch to clone it at (default: the repository's default branch). Only applies when the clone is first made.
**Note:** Copy `dev_plugins.json.example` to `dev_plugins.json` and customize it. The `dev_plugins.json` file is git-ignored.
`github_pattern` from older versions of this guide is no longer used (the
script warns if it is set).
## Development Workflow
@@ -248,43 +271,46 @@ By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`. You c
1. **Link your plugin for development:**
```bash
./scripts/dev/dev_plugin_setup.sh link-github music
./scripts/dev/dev_plugin_setup.sh link-github clock-simple
```
2. **Test in LEDMatrix:**
```bash
# Run LEDMatrix with your plugin
python run.py
# Run LEDMatrix with your plugin (emulator shown)
python3 run.py -e
```
3. **Make changes:**
```bash
cd plugins/music
cd plugins/clock-simple
# Edit files...
# Test changes...
```
4. **Commit to plugin repository:**
4. **Commit to the plugin repository:**
```bash
cd plugins/music # This is actually your repo
cd plugins/clock-simple # This is inside your monorepo checkout
# bump "version" in manifest.json, then from the monorepo root:
# python update_registry.py
git add .
git commit -m "feat: add new feature"
git push origin main
git commit -m "feat(clock-simple): add new feature"
git push
```
5. **Update from remote (if needed):**
```bash
./scripts/dev/dev_plugin_setup.sh update music
./scripts/dev/dev_plugin_setup.sh update clock-simple
```
6. **When done developing:**
```bash
./scripts/dev/dev_plugin_setup.sh unlink music
./scripts/dev/dev_plugin_setup.sh unlink clock-simple
```
### Working with Multiple Plugins
You can have multiple plugins linked simultaneously:
You can have multiple plugins linked simultaneously. Plugins linked from the
monorepo share one checkout:
```bash
./scripts/dev/dev_plugin_setup.sh link-github music
@@ -294,7 +320,7 @@ You can have multiple plugins linked simultaneously:
# Check status of all
./scripts/dev/dev_plugin_setup.sh status
# Update all at once
# Update all at once (the shared monorepo checkout is pulled once)
./scripts/dev/dev_plugin_setup.sh update
```
@@ -409,7 +435,7 @@ If you have conflicts when updating:
1. **Manually resolve in the plugin repository:**
```bash
cd ~/.ledmatrix-dev-plugins/ledmatrix-music
cd ~/.ledmatrix-dev-plugins/ledmatrix-plugins
git pull
# Resolve conflicts...
git add .
@@ -470,18 +496,19 @@ You can mix local and GitHub plugins:
The development workflow is separate from the plugin store installation:
- **Plugin Store:** Installs plugins to `plugins/` as regular directories
- **Development Setup:** Links plugin repositories as symlinks
- **Plugin Store:** Installs plugins as regular directories in the configured
plugins directory (`plugin-repos/` by default)
- **Development Setup:** Links plugin directories as symlinks in `plugins/`
If you install a plugin via the store, you can still link it for development:
The plugin loader scans only one directory, so while developing set
`plugin_system.plugins_directory` to `plugins` (see the note at the top of
this guide). If `plugins/` already holds a regular directory of the same
name, `link`/`link-github` offers to rename it to
`<name>.backup.<timestamp>` before linking.
```bash
# Store installs to plugins/music (regular directory)
# Link for development (will prompt to replace)
./scripts/dev/dev_plugin_setup.sh link-github music
```
When you unlink, the directory is removed. If you want to switch back to the store version, re-install it via the plugin store.
`unlink` removes only the symlink. To switch back to the store version, set
`plugins_directory` back to `plugin-repos` (or reinstall the plugin from the
store).
## API Reference
@@ -525,7 +552,7 @@ Want to create and share your own plugin? Here's everything you need to know.
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Patterns and examples
2. **Start with a template**:
- Use the [Hello World plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) as a starting point
- Use the [Hello World plugin](https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hello-world) as a starting point
- Or fork an existing plugin and modify it
3. **Follow the plugin structure**:
@@ -589,24 +616,16 @@ Your plugin must:
### Versioning Best Practices
- **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
- **GitHub as source of truth**: the plugin store resolves versions in this
order: GitHub Releases → GitHub Tags → manifest from branch → git commit hash
- **Automatic version bumping**: install the self-contained pre-push hook in
your plugin repo and patch versions bump themselves on push (a git tag
`v{version}` is created and `manifest.json` staged automatically):
```bash
# From your plugin repository directory
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
chmod +x .git/hooks/pre-push
```
Set `SKIP_TAG=1` in the environment to skip auto-tagging for one push.
- **Manual versioning**: only needed for major/minor bumps, CI pipelines that
bypass hooks, or forks without the hook — use
`scripts/bump_plugin_version.py`.
- **Registry stores no versions**: `plugins.json` holds only metadata (name,
description, repo URL).
- **Bump `version` in `manifest.json` by hand** for every change you ship.
There is no automatic version-bump hook or bump script.
- **Official (monorepo) plugins**: after bumping the manifest, run
`python update_registry.py` in the `ledmatrix-plugins` checkout. It copies
each manifest's version into `plugins.json` as `latest_version`, which is
what the store compares installed versions against. Without it, users
won't be offered the update.
- **Plugins in their own repository**: still bump the manifest `version`,
so users can see which version they run; tagging releases (`v1.2.3`) to
match is a good habit.
### Submitting to Official Registry
@@ -618,12 +637,14 @@ To have your plugin added to the official plugin store:
- Follows best practices
- Tested on Raspberry Pi hardware
2. **Create GitHub repository**:
- Repository name: `ledmatrix-<plugin-name>`
- Public repository
- Proper README.md with installation instructions
2. **Choose where it lives** (see `SUBMISSION.md` in
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)):
- **In the monorepo (preferred):** fork ledmatrix-plugins, add
`plugins/<your-plugin-id>/`, and open a pull request
- **In your own public repository** (conventionally
`ledmatrix-<plugin-name>`), with a README that covers installation
3. **Contact maintainers**:
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
- Include: Repository URL, plugin description, why it's useful
+2 -1
View File
@@ -127,7 +127,8 @@ git push origin v1.0.0
### REST API
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
The API is mounted at `/api/v3` (the `api_v3` blueprint in
`web_interface/blueprints/api_v3/`, registered in `web_interface/app.py`).
```bash
# Install plugin from the registry
+3
View File
@@ -103,6 +103,7 @@ All plugins can be installed through the LEDMatrix web interface:
Or via API:
```bash
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
-H "Content-Type: application/json" \
-d '{"plugin_id": "clock-simple"}'
```
@@ -153,6 +154,7 @@ Before submitting, ensure your plugin:
```bash
# Install via URL on your Pi
curl -X POST http://your-pi:5000/api/v3/plugins/install-from-url \
-H "Content-Type: application/json" \
-d '{"repo_url": "https://github.com/you/ledmatrix-your-plugin"}'
```
@@ -312,6 +314,7 @@ git push
# 2. Review using VERIFICATION.md checklist
# 3. Test installation:
curl -X POST http://pi:5000/api/v3/plugins/install-from-url \
-H "Content-Type: application/json" \
-d '{"repo_url": "https://github.com/contributor/plugin"}'
# 4. If approved, merge PR
+4 -5
View File
@@ -131,13 +131,13 @@ else:
**Via REST API:**
```bash
# Search by query
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?q=hockey"
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?query=hockey"
# Filter by category
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?category=sports"
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?category=sports"
# Filter by tags
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?tags=nhl&tags=hockey"
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?tags=nhl&tags=hockey"
```
**Via Python:**
@@ -351,8 +351,7 @@ All API endpoints return JSON with this structure:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v3/plugins/store/list` | List all plugins in store |
| GET | `/api/v3/plugins/store/search` | Search for plugins |
| GET | `/api/v3/plugins/store/list` | List plugins in store; `?query=`, `?category=`, `?tags=` search and filter |
| GET | `/api/v3/plugins/installed` | List installed plugins |
| POST | `/api/v3/plugins/install` | Install from registry |
| POST | `/api/v3/plugins/install-from-url` | Install from GitHub URL |
+832 -380
View File
File diff suppressed because it is too large Load Diff
+46 -12
View File
@@ -83,8 +83,9 @@ second refresh, instead of half a pixel every refresh (which has no good
rendering, only a choice between blur and judder).
`scroll_config.configure()` snaps the requested speed to the nearest entry on
the ladder and reports the hold that speed needs. It does **not** apply the
hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
the ladder, sets the helper to advance that entry's whole-pixel step on every
presented frame (`ScrollHelper.set_pixels_per_frame`), and reports the hold
that speed needs. It does **not** apply the hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
share one display manager -- one set at construction is reset the moment any
other plugin finishes scrolling. Apply it yourself when the scroll starts:
@@ -102,10 +103,18 @@ self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
Passing `display_manager` only lets `configure` read the true refresh rate from
`display.hardware`, which a plugin config cannot see. Skipping the
`set_scrolling_state` call is the mistake that matters: the speed still
resolves, but the panel keeps presenting a new frame every refresh, so a slow
snapped speed falls back to fractional pixels. Pass `snap_to_crisp=False` to
keep an exact requested speed and accept the artefacts.
`set_scrolling_state(True, frame_hold=...)` call is the mistake that matters.
The helper consults no clock in this mode -- it moves the fixed step once per
`update_scroll_position()` call, and `SwapOnVSync` is what paces those calls --
so without the hold the panel presents a new frame every refresh and the scroll
runs `frame_hold` times too fast: 50 px/s (hold 2) plays at 100 px/s.
Pass `snap_to_crisp=False` to keep an exact requested speed and accept the
artefacts. The helper then paces off elapsed time instead of stepping, and the
hold is 1.
The General tab's `target_fps` ("Scroll Frame Rate") plays no part in any of
this: frames are presented at the panel refresh divided by the hold.
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
@@ -123,9 +132,12 @@ settings = scroll_config.configure(
self.scroll_helper,
plugin_config=self.config,
global_config=self.global_config,
refresh_hz=scroll_config.refresh_hz_from_config(self.global_config),
display_manager=self.display_manager,
plugin_logger=self.logger,
)
# each frame of a scroll (or at least when it starts):
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
```
It resolves every config shape in one place, applies the speed, and returns
@@ -154,6 +166,14 @@ it is always present and always wins, so the documented settings become
unreachable. That is a real, shipped bug — see
[ledmatrix-plugins#408](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/408).
The flip side: a `scroll_pixels_per_second` you add by hand is ignored whenever
the plugin's config also carries the pair, which it does whenever the pair has
a schema default. Set the speed through the pair instead.
The sports scoreboards (`src.common.sports_scroll`) are the exception to all of
the above: they read `scroll_settings.scroll_speed` per league as px/s directly,
and their `scroll_delay` is kept for compatibility but ignored for pacing.
If you are writing a plugin: do not give a deprecated key a schema default.
## What was actually wrong
@@ -198,8 +218,16 @@ zero pixels and rendered an identical frame, which dirty-tracking skipped, so
it returned in ~2 ms and the beat repeated. No `scroll_delay` value tunes this
out — a shorter delay just trades stalled frames for periodic double-steps.
`ScrollHelper` now accumulates elapsed time in both modes at the same
configured speed, so position stays proportional to real time.
A crisp speed configured through `scroll_config` no longer consults a clock at
all. Once `SwapOnVSync` blocks until the panel has taken the frame, the frame
count is a truer clock than `time.time()`, so the helper advances a fixed whole
number of pixels per presented frame (`set_pixels_per_frame`) and the display
manager holds each frame for `frame_hold` refreshes. Every frame moves the eye
by the same amount.
The time-based path remains only for callers that set a speed directly or pass
`snap_to_crisp=False`. There, frame-based mode no longer steps either: it
advances by elapsed time at `scroll_speed / scroll_delay` px/s.
## Diagnosing a juddery scroller
@@ -222,11 +250,17 @@ p95 10.11ms max 12.03ms min 7.98ms | stalls 0 (0.0%) skips 0 (0.0%)
Reading it, on a 100 Hz panel:
A healthy median is the refresh period times the scroll's frame hold: 10 ms
for a hold of 1 (100 px/s), **20 ms for 50 px/s** (hold 2), 30 ms for 33.3 px/s.
A 20 ms median on a 50 px/s scroll is the hold doing its job, not missed
refreshes. The `Scroll configured:` log line gives the hold (`1px every 2
refreshes`).
| you see | it means |
|---|---|
| median 10 ms, p95 within ~0.5 ms of it | healthy — locked to the panel |
| p95 or max at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
| non-zero **skips**, or a median *below* 10 ms | **duplicate frames** — the swap was skipped because the image did not change, so the frame never waited on vsync. The scroller is advancing less than one pixel per frame. |
| median = refresh period × hold, p95 within ~0.5 ms of it | healthy — locked to the panel |
| p95 or max a whole refresh period or more above that median | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
| non-zero **skips**, or a median *below* the expected one | **duplicate frames** — the swap was skipped because the image did not change, so the frame never waited on vsync. The scroller is advancing less than one pixel per frame, which a crisp fixed-step scroll never does; look for a plugin pacing off time or not passing the hold. |
| non-zero **stalls** | frames past 1.5× the median, which is the measure of judder that survives averaging |
`stalls` and `skips` are both counted against that window's own median, so they
+30 -2
View File
@@ -80,11 +80,30 @@ src/base_classes/sports/
src/common/
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
(content building stays in the plugins)
sports_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin
(3.5.0) — the helpers byte-identical in the
plugins' sports.py, and the _favorite_key seam
```
`from src.base_classes.sports import SportsCore` keeps working — the package
`__init__` re-exports, so the conversion is invisible to every existing importer.
### Converging on `src/common`
The scoreboards do not build on `src/base_classes`; their own `sports.py` copies
have moved past it. So shared code now lands in hardware-free `src/common`
modules taken from the plugin copies, each a **new module** rather than growth
on an existing one: a plugin that deletes a method copy and relies on an older
module having gained it fails at runtime with an `AttributeError`, while a
missing module fails at load, where the version checks can see it.
`sports_helpers.py` is the first (it holds `_favorite_key`, the override point
listed below, for later phases); its parity test compares every body against
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
`test/test_common_is_hardware_free.py` keeps `src/common` free of
`rgbmatrix`, `src.base_classes` and `src.plugin_system`. How a plugin adopts a
module and drops its copy is documented in the plugins repo's
`docs/plugin-development/08-shared-sports-code.md`.
## Override points (the plugin-facing seam)
The base class calls these; plugins implement or override them. This table is the
@@ -199,6 +218,14 @@ The one behavior the upstreamed version adds is native
Part A threaded it through each copy by hand, and this makes that threading
legacy compatibility rather than the mechanism.
> **Superseded.** Once presentation became frame-locked (#545) the helper
> steps a fixed whole-pixel amount per presented frame and the panel presents
> at its own refresh, so honouring `target_fps` only turned it into a speed
> multiplier (60 doubled a scoreboard's speed, 200 halved it). `sports_scroll`
> no longer reads it: the crisp-speed ladder uses the panel refresh
> (`display_manager.refresh_hz`), and speed comes from
> `scroll_settings.scroll_speed` alone. See `docs/SCROLL_PERFORMANCE.md`.
## Phases
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
@@ -445,8 +472,9 @@ After adoption plus the frozen legacy copies it was 10,610; removing the dead
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
to about 3,300 including the shared core module — some 2,400 fewer than before
this project started. **Until B6 runs, the adoption is net negative on disk**,
and its one delivered user-visible gain is that adopted plugins honour the
global `target_fps` instead of hardcoding ~100 FPS.
and its one delivered user-visible gain was that adopted plugins honoured the
global `target_fps` instead of hardcoding ~100 FPS (since withdrawn: see the
note under the B3 design above).
### Decision: stop adopting further modules until B6 closes
+3 -1
View File
@@ -158,9 +158,11 @@ This script will check:
- Python dependencies
- Configuration files
- File permissions
- Web interface availability
- Web interface availability (`ledmatrix-web` listening on port 5000)
- Network connectivity
Once it passes, the web interface is at `http://<pi-ip>:5000`.
## Quick Reference Commands
```bash
+2 -2
View File
@@ -273,7 +273,7 @@ sudo systemctl cat ledmatrix-web | grep User
1. **Verify file structure:**
```bash
ls -l web_interface/app.py
ls -l web_interface/blueprints/api_v3.py
ls -ld web_interface/blueprints/api_v3/
ls -l web_interface/blueprints/pages_v3.py
```
@@ -531,7 +531,7 @@ sudo systemctl cat ledmatrix-web | grep User
```bash
# Clear the cache with the helper script
sudo python3 scripts/utils/clear_cache.py
sudo python3 scripts/utils/clear_cache.py --clear-all
# Or remove files manually from the cache dir in use, e.g.:
sudo rm -rf /var/cache/ledmatrix/*
+24 -15
View File
@@ -102,7 +102,9 @@ Configure basic system settings:
check are shown under the toggle, and anything other than success raises a
banner on **Overview**.
- *Checks first:* the code update is skipped, with the reason shown, if
tracked files were edited locally, the checkout has local commits, a
tracked files were edited locally (permission-only changes and edits under
`plugins/` or `plugin-repos/` don't count; the pull carries those across
and puts them back), the checkout has local commits, a
rebase/merge is in progress, the branch has no upstream, less than 300 MB
is free, or the newest version already failed once. A failed fetch is
retried the next day.
@@ -132,18 +134,26 @@ require a display service restart from **Overview**.
Configure your LED matrix hardware:
**Matrix configuration:**
- `rows` — LED rows (typically 32 or 64)
- `cols` — LED columns (typically 64 or 96)
- `rows` — LED rows per panel (typically 32 or 64; even, at least 8 — the
current rgbmatrix library rejects more than 64)
- `cols` — LED columns per panel (typically 64 or 96; at least 16)
- `chain_length` — number of horizontally chained panels
- `parallel` — number of parallel chains
- `parallel` — number of parallel chains (1–3)
- `hardware_mapping` — `adafruit-hat-pwm` (with PWM jumper mod),
`adafruit-hat` (without), `regular`, or `regular-pi1`
- `gpio_slowdown` — must match your Pi model (3 for Pi 3, 4 for Pi 4, etc.)
- `brightness` — 0–100%
`adafruit-hat` (without), `regular` (direct wiring, and the Adafruit Triple
LED Matrix Bonnet), or `regular-pi1`
- `gpio_slowdown` — depends on your Pi and panel (roughly 1–3 on a Pi 3,
2–4 on a Pi 4); raise it if rows jump or the image is garbage
- `brightness` — 1–100%
- `pwm_bits`, `pwm_lsb_nanoseconds`, `pwm_dither_bits` — PWM tuning
- Dynamic Duration — global cap for plugins that extend their display
time based on content
The collapsed **Advanced Hardware & Display Options** section holds
multiplexing, panel type, row address type, scan mode, PWM tuning, the
refresh-rate cap and hardware pulsing. Every field has a help tip, and the
README's Display Settings section describes each one with its allowed range.
**Vegas Scroll Mode:** the Display tab also has a full Vegas Scroll
Mode section — enable toggle, scroll speed, separator width, dynamic
duration, and related settings — so you can configure Vegas mode
@@ -198,11 +208,11 @@ Manage fonts for your display:
- See font previews
- Check font sizes and styles
**Font Overrides:**
- Overrides are set per display *element* (e.g. a specific score or
clock text element), not per plugin
- Override default font choices for individual elements
- Preview font changes
**Font Preview:**
- Render sample text in any TTF/OTF font at a chosen size
Fonts used by a plugin are chosen in that plugin's own settings tab; the
Fonts tab has no per-element override editor.
**Delete Fonts:**
- Remove unused fonts
@@ -319,9 +329,8 @@ The web interface is built on a REST API that you can access programmatically:
http://your-pi-ip:5000/api/v3
```
The API blueprint mounts at `/api/v3` (see
`web_interface/app.py:199`). All endpoints below are relative to that
base.
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
`/api/v3` in `web_interface/app.py`.
**Common Endpoints:**
- `GET /api/v3/config/main` — Get main configuration
+2 -1
View File
@@ -10,7 +10,8 @@ plugin without breaking a size or screen you didn't think to test.
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
strips, tall stacks). Plugins are expected to read dimensions dynamically
(`self.display_manager.matrix.width/height`) and lay themselves out
(`self.display_manager.width/height` — not `matrix.width/height`, since
`matrix` is `None` when hardware init fails) and lay themselves out
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
here.
+36
View File
@@ -281,6 +281,42 @@ Guidelines:
their own collapsible sections) and is safely ignored by older cores, so
adding it never breaks compatibility.
## Hiding Fields From the Form (`x-display: "hidden"`)
Add `"x-display": "hidden"` to a property that must stay in the schema but
should not appear as a control: a deprecated key kept so existing configs keep
validating, or an internal value such as an auto-generated row id.
```json
{
"properties": {
"radar_zoom": {
"type": "integer",
"default": 6,
"title": "Radar Zoom Level (deprecated)",
"x-display": "hidden"
}
}
}
```
What the core does with it:
- **Not rendered** at any depth: top-level fields, children of an object
section, and properties of array-of-object items (never a table column, even
if `x-columns` names it, and never in the row editor). A hidden field flagged
`x-advanced` is not listed or counted in Advanced Settings, and an object
whose children are all hidden draws no empty section. Hidden fields don't
show up in the settings search either, since it indexes the rendered form.
- **Stored value preserved on save.** Saving the form never changes a hidden
value. The unchecked-checkbox rule ignores a hidden boolean. Array rows carry
a hidden property's stored value through the form, so the value survives the
row being posted back; a new row gets no value (the plugin fills it in).
- **The API is unaffected.** A JSON save to `POST /api/v3/plugins/config` can
still set a hidden field.
Older cores ignore the flag and render the field as a normal control.
## Creating Custom Widgets
### Step 1: Create Widget File
+34 -10
View File
@@ -1504,6 +1504,9 @@ echo "------------------------------------------------"
# Create sudoers configuration for the web interface
echo "Creating sudoers configuration..."
SUDOERS_FILE="/etc/sudoers.d/ledmatrix_web"
# A predictable name in a world-writable directory is a symlink target;
# root writes the rules here, so let mktemp pick the name.
SUDOERS_TMP=$(mktemp "${TMPDIR:-/tmp}/ledmatrix_web_sudoers.XXXXXX")
# Get command paths
PYTHON_PATH=$(which python3)
@@ -1514,7 +1517,7 @@ BASH_PATH=$(which bash)
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
# Create sudoers content
cat > /tmp/ledmatrix_web_sudoers << EOF
cat > "$SUDOERS_TMP" << EOF
# LED Matrix Web Interface passwordless sudo configuration
# This allows the web interface user to run specific commands without a password
@@ -1541,7 +1544,7 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat >> /tmp/ledmatrix_web_sudoers << EOF
cat >> "$SUDOERS_TMP" << EOF
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
# when its output is a terminal. From that pager (less) a "!sh" is a root
# shell -- the standard journalctl escalation. The web interface always passes
@@ -1555,17 +1558,38 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
EOF
fi
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
echo "Sudoers configuration already up to date"
rm /tmp/ledmatrix_web_sudoers
# Never install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
# headless Pi leaves no way in at all. If the rules do not parse, say so and
# keep whatever is already installed.
SUDOERS_VALID=1
if command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
SUDOERS_VALID=0
echo "⚠ The generated sudoers rules did not parse:" >&2
visudo -c -f "$SUDOERS_TMP" >&2 || true
echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2
echo " the display service until this is fixed." >&2
fi
else
echo "Installing/updating sudoers configuration..."
cp /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"
chmod 440 "$SUDOERS_FILE"
rm /tmp/ledmatrix_web_sudoers
echo "⚠ visudo not found; installing the sudoers rules unvalidated"
fi
echo "✓ Passwordless sudo access configured"
if [ "$SUDOERS_VALID" = "0" ]; then
rm -f "$SUDOERS_TMP"
elif [ -f "$SUDOERS_FILE" ] && cmp -s "$SUDOERS_TMP" "$SUDOERS_FILE"; then
echo "Sudoers configuration already up to date"
rm -f "$SUDOERS_TMP"
else
echo "Installing/updating sudoers configuration..."
cp "$SUDOERS_TMP" "$SUDOERS_FILE"
chmod 440 "$SUDOERS_FILE"
rm -f "$SUDOERS_TMP"
fi
if [ "$SUDOERS_VALID" = "1" ]; then
echo "✓ Passwordless sudo access configured"
fi
echo ""
CURRENT_STEP="Configure WiFi management permissions"
+7 -2
View File
@@ -114,5 +114,10 @@ python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/
discovery itself. Discovery is lazy and normally happens because somebody
opened the dashboard; without that endpoint a bridge that never does would
see an empty list.
- Brightness writes `display.hardware.brightness` through `/api/v3/config/main`.
The display service picks it up on its next restart, not instantly.
- Brightness writes `display.hardware.brightness` by posting
`{"brightness": N}` to `/api/v3/config/main`. A JSON save changes only the
keys it sends, so the other display settings are left as they were. The
display service's config hot reload notices the change within a few seconds
and applies it without a restart (unless `LEDMATRIX_HOT_RELOAD=false`; while
a dim schedule is dimming the panel, the dim level wins until the dim period
ends).
+81 -8
View File
@@ -10,6 +10,7 @@ API Version: 1.0.0
import json
import os
import re
import stat
import time
import fcntl
from pathlib import Path
@@ -239,7 +240,7 @@ class StarlarkAppsPlugin(BasePlugin):
# Calculate optimal magnification based on display size
self.calculated_magnify = self._calculate_optimal_magnify()
if self.calculated_magnify > 1:
self.logger.info(f"Display size: {self.display_manager.matrix.width}x{self.display_manager.matrix.height}, "
self.logger.info(f"Display size: {self.display_manager.width}x{self.display_manager.height}, "
f"recommended magnify: {self.calculated_magnify}")
# Load installed apps
@@ -323,8 +324,8 @@ class StarlarkAppsPlugin(BasePlugin):
Recommended magnify value (1-8)
"""
try:
display_width = self.display_manager.matrix.width
display_height = self.display_manager.matrix.height
display_width = self.display_manager.width
display_height = self.display_manager.height
# Tronbyte native resolution
NATIVE_WIDTH = 64
@@ -362,8 +363,8 @@ class StarlarkAppsPlugin(BasePlugin):
Dictionary with recommendation details
"""
try:
display_width = self.display_manager.matrix.width
display_height = self.display_manager.matrix.height
display_width = self.display_manager.width
display_height = self.display_manager.height
NATIVE_WIDTH = 64
NATIVE_HEIGHT = 32
@@ -457,12 +458,77 @@ class StarlarkAppsPlugin(BasePlugin):
apps_dir = project_root / "starlark-apps"
except Exception:
# Fallback to current working directory
apps_dir = Path.cwd() / "starlark-apps"
project_root = Path.cwd()
apps_dir = project_root / "starlark-apps"
# Create directory if it doesn't exist
apps_dir.mkdir(parents=True, exist_ok=True)
self._hand_apps_dir_to_checkout_owner(apps_dir, project_root)
return apps_dir
def _hand_apps_dir_to_checkout_owner(self, apps_dir: Path, project_root: Path) -> None:
"""Give the apps directory to whoever owns the checkout.
This directory is not in the repository, so it is created lazily by
whichever process reaches it first -- and the two that do run as
different users. The display service is `User=root`
(systemd/ledmatrix.service) and instantiates this plugin at startup,
which is where `_get_apps_directory` is called from. The web interface
is `User=<login user>` (systemd/ledmatrix-web.service) and is what
actually installs apps.
On a fresh install the display service usually wins that race -- the
documented first step is to install pixlet and reboot -- so the
directory lands root-owned, and every subsequent install from the web
UI fails on PermissionError. The user sees only "Failed to install
from repository", with nothing pointing at ownership.
The web user cannot repair this; it lacks permission to chown. Root
can, so root does it here, on every startup. That also heals installs
already broken by this, without the user having to find the chown.
"""
geteuid = getattr(os, "geteuid", None)
chown = getattr(os, "chown", None)
if geteuid is None or chown is None or geteuid() != 0:
# Not root, or not a platform with POSIX ownership. If the
# directory is wrong we cannot fix it, and must not pretend to.
return
try:
owner = project_root.stat()
except OSError:
return
if owner.st_uid == 0:
# The checkout genuinely belongs to root, so root owning the apps
# directory is correct and there is nobody to hand it to.
return
# Deepest first, with the directory itself last. Handing over the
# container before its contents would briefly let a local user rename
# entries underneath a repair that is still running.
descendants = sorted(apps_dir.rglob("*"),
key=lambda p: len(p.parts), reverse=True)
for path in (*descendants, apps_dir):
try:
st = os.lstat(path)
except OSError:
continue
if stat.S_ISLNK(st.st_mode):
# Never hand over a link's target. Anyone able to write in
# this directory could otherwise point a symlink at a
# root-owned file and have this give it away -- the whole
# point of the loop is that it runs as root.
continue
if st.st_uid == owner.st_uid and st.st_gid == owner.st_gid:
continue
try:
chown(path, owner.st_uid, owner.st_gid, follow_symlinks=False)
except OSError as e:
self.logger.warning(
"Could not hand %s to uid %s: %s -- installs from the web "
"interface will fail until this is chowned manually",
path, owner.st_uid, e,
)
def _sanitize_app_id(self, app_id: str) -> str:
"""
Sanitize app_id into a safe slug for use in file paths.
@@ -852,8 +918,8 @@ class StarlarkAppsPlugin(BasePlugin):
# Scale frames if needed
if self.config.get("scale_output", True):
width = self.display_manager.matrix.width
height = self.display_manager.matrix.height
width = self.display_manager.width
height = self.display_manager.height
# Get scaling method from config
scale_method_str = self.config.get("scale_method", "nearest")
@@ -1011,6 +1077,13 @@ class StarlarkAppsPlugin(BasePlugin):
self.logger.info(f"Installed Starlark app: {app_id} (sanitized: {safe_app_id})")
return True
except PermissionError:
# Deliberately not folded into the False below. A False here is
# reported as a generic install failure, which is how the
# directory-ownership bug stayed invisible: the caller could not
# tell "this app is broken" from "this process cannot write here".
# The routes turn this into a message that names the fix.
raise
except Exception as e:
self.logger.error(f"Error installing app {app_id}: {e}")
return False
+7 -2
View File
@@ -54,8 +54,13 @@ def main():
config = config_manager.load_config()
print(" ✅ Config loaded")
autostart = config.get('web_display_autostart', False)
print(f" 🔧 web_display_autostart: {autostart}")
# Same rule ledmatrix-web.service applies: only an explicit false/off
# keeps the web interface down; a missing key means on.
sys.path.insert(0, str(project_root / 'scripts' / 'utils'))
from start_web_conditionally import autostart_enabled
raw = config.get('web_display_autostart', '(not set, defaults to on)')
state = 'starts' if autostart_enabled(config) else 'will NOT start'
print(f" 🔧 web_display_autostart: {raw} (web interface {state})")
except Exception as e:
print(f" ❌ Config check failed: {e}")
traceback.print_exc()
+10
View File
@@ -12,9 +12,19 @@ This directory contains scripts and utilities for development and testing.
### Plugin Development Setup
```bash
# Official plugin: clones ChuckBuilds/ledmatrix-plugins (once) and links
# its plugins/<plugin-name> into plugins/
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
# Plugin with its own repository
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> <repo-url>
```
Set `plugin_system.plugins_directory` to `plugins` so the loader finds the
links. To use a fork or another clone location, copy
`dev_plugins.json.example` to `dev_plugins.json`. Details:
[docs/PLUGIN_DEVELOPMENT_GUIDE.md](../../docs/PLUGIN_DEVELOPMENT_GUIDE.md).
### Running Emulator
```bash
./scripts/dev/run_emulator.sh
+193 -75
View File
@@ -10,8 +10,14 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
GITHUB_USER="ChuckBuilds"
GITHUB_PATTERN="ledmatrix-"
# Official plugins live in one monorepo: <github_user>/<plugins_repo>, one
# directory per plugin under plugins/. Both can be overridden in
# dev_plugins.json (e.g. to work from a fork).
DEFAULT_GITHUB_USER="ChuckBuilds"
DEFAULT_PLUGINS_REPO="ledmatrix-plugins"
GITHUB_USER="$DEFAULT_GITHUB_USER"
PLUGINS_REPO="$DEFAULT_PLUGINS_REPO"
PLUGINS_BRANCH=""
# Colors for output
RED='\033[0;31m'
@@ -37,18 +43,55 @@ log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Print a top-level string field of a JSON file, or nothing if it is absent.
# Uses jq when installed, else python3.
json_field() {
local file="$1"
local key="$2"
if command -v jq >/dev/null 2>&1; then
jq -r --arg k "$key" '.[$k] // empty | select(type == "string")' "$file" 2>/dev/null || true
elif command -v python3 >/dev/null 2>&1; then
python3 - "$file" "$key" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1], encoding="utf-8") as f:
value = json.load(f).get(sys.argv[2])
except Exception:
value = None
if isinstance(value, str):
print(value)
PY
fi
}
# Load configuration file
load_config() {
DEV_PLUGINS_DIR="$DEFAULT_DEV_DIR"
if [[ -f "$CONFIG_FILE" ]]; then
DEV_PLUGINS_DIR=$(jq -r '.dev_plugins_dir // "'"$DEFAULT_DEV_DIR"'"' "$CONFIG_FILE" 2>/dev/null || echo "$DEFAULT_DEV_DIR")
# Expand ~ in path
DEV_PLUGINS_DIR="${DEV_PLUGINS_DIR/#\~/$HOME}"
else
DEV_PLUGINS_DIR="$DEFAULT_DEV_DIR"
local value
value=$(json_field "$CONFIG_FILE" dev_plugins_dir)
[[ -n "$value" ]] && DEV_PLUGINS_DIR="$value"
value=$(json_field "$CONFIG_FILE" github_user)
[[ -n "$value" ]] && GITHUB_USER="$value"
value=$(json_field "$CONFIG_FILE" plugins_repo)
[[ -n "$value" ]] && PLUGINS_REPO="$value"
value=$(json_field "$CONFIG_FILE" plugins_branch)
[[ -n "$value" ]] && PLUGINS_BRANCH="$value"
if [[ -n "$(json_field "$CONFIG_FILE" github_pattern)" ]]; then
log_warn "dev_plugins.json: github_pattern is no longer used (official plugins are in the $PLUGINS_REPO monorepo)"
fi
fi
# Expand ~ in path
DEV_PLUGINS_DIR="${DEV_PLUGINS_DIR/#\~/$HOME}"
mkdir -p "$DEV_PLUGINS_DIR"
}
# Top level of the git checkout containing a path, or nothing.
# A monorepo plugin is a subdirectory, so its .git is not in the plugin dir.
git_root_of() {
git -C "$1" rev-parse --show-toplevel 2>/dev/null || true
}
# Validate plugin structure
validate_plugin() {
local plugin_path="$1"
@@ -63,7 +106,7 @@ validate_plugin() {
get_plugin_id() {
local plugin_path="$1"
if [[ -f "$plugin_path/manifest.json" ]]; then
jq -r '.id // empty' "$plugin_path/manifest.json" 2>/dev/null || echo ""
json_field "$plugin_path/manifest.json" id
fi
}
@@ -176,50 +219,104 @@ clone_from_github() {
return 0
}
# Clone a repository into DEV_PLUGINS_DIR, or update the existing clone.
# Prints the clone's path on stdout (log output goes to stderr).
ensure_clone() {
local repo_url="$1"
local branch="${2:-}"
local repo_name
repo_name=$(basename "$repo_url" .git)
local target_dir="$DEV_PLUGINS_DIR/$repo_name"
if [[ -d "$target_dir" ]]; then
log_info "Repository already exists at $target_dir" >&2
if [[ -d "$target_dir/.git" ]]; then
log_info "Updating repository..." >&2
(cd "$target_dir" && git pull --rebase) >&2 || true
fi
else
if ! clone_from_github "$repo_url" "$target_dir" "$branch" >&2; then
return 1
fi
fi
echo "$target_dir"
}
# Find a plugin's directory inside a monorepo clone: plugins/<name>,
# plugins/ledmatrix-<name>, or the directory whose manifest id is <name>.
find_monorepo_plugin() {
local repo_dir="$1"
local name="$2"
local candidate
for candidate in "$repo_dir/plugins/$name" "$repo_dir/plugins/ledmatrix-$name"; do
if [[ -f "$candidate/manifest.json" ]]; then
echo "$candidate"
return 0
fi
done
for candidate in "$repo_dir"/plugins/*/; do
candidate="${candidate%/}"
[[ -f "$candidate/manifest.json" ]] || continue
if [[ "$(get_plugin_id "$candidate")" == "$name" ]]; then
echo "$candidate"
return 0
fi
done
return 1
}
# Link plugin from GitHub
link_github_plugin() {
local plugin_name="$1"
local plugin_name="${1:-}"
local repo_url="${2:-}"
if [[ -z "$plugin_name" ]]; then
log_error "Usage: $0 link-github <plugin-name> [repo-url]"
exit 1
fi
load_config
# Construct repo URL if not provided
if [[ -z "$repo_url" ]]; then
repo_url="https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}${plugin_name}.git"
log_info "Using default GitHub URL: $repo_url"
fi
# Determine target directory name from URL
local repo_name=$(basename "$repo_url" .git)
local target_dir="$DEV_PLUGINS_DIR/$repo_name"
# Check if already cloned
if [[ -d "$target_dir" ]]; then
log_info "Repository already exists at $target_dir"
if [[ -d "$target_dir/.git" ]]; then
log_info "Updating repository..."
(cd "$target_dir" && git pull --rebase) || true
fi
else
# Clone the repository
if ! clone_from_github "$repo_url" "$target_dir"; then
if [[ -n "$repo_url" ]]; then
# A plugin with its own repository (e.g. a third-party plugin): the
# repository root is the plugin.
local target_dir
if ! target_dir=$(ensure_clone "$repo_url"); then
exit 1
fi
if ! validate_plugin "$target_dir"; then
log_error "Cloned repository does not appear to be a valid plugin"
exit 1
fi
link_plugin "$plugin_name" "$target_dir"
return
fi
# Validate plugin structure
if ! validate_plugin "$target_dir"; then
log_error "Cloned repository does not appear to be a valid plugin"
# Official plugins: clone the monorepo once, link plugins/<dir> from it.
repo_url="https://github.com/${GITHUB_USER}/${PLUGINS_REPO}.git"
log_info "Using plugin monorepo: $repo_url"
local repo_dir
if ! repo_dir=$(ensure_clone "$repo_url" "$PLUGINS_BRANCH"); then
exit 1
fi
# Link the plugin
link_plugin "$plugin_name" "$target_dir"
local plugin_dir
if ! plugin_dir=$(find_monorepo_plugin "$repo_dir" "$plugin_name"); then
log_error "No plugin named '$plugin_name' in $repo_dir/plugins"
log_info "Plugins are the directory names under $repo_dir/plugins, or their manifest ids"
exit 1
fi
# Link under the manifest id: that is the name the plugin loader and
# config.json use, and it can differ from the directory name
# (plugins/ledmatrix-music has id ledmatrix-music, not music).
local link_name
link_name=$(get_plugin_id "$plugin_dir")
[[ -n "$link_name" ]] || link_name=$(basename "$plugin_dir")
if [[ "$link_name" != "$plugin_name" ]]; then
log_info "Linking as '$link_name' (the plugin's manifest id)"
fi
link_plugin "$link_name" "$plugin_dir"
}
# Unlink a plugin
@@ -274,7 +371,7 @@ list_plugins() {
echo " → $target"
# Check git status if it's a git repo
if [[ -d "$target/.git" ]]; then
if [[ -n "$(git_root_of "$target")" ]]; then
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
local status=$(cd "$target" && git status --porcelain 2>/dev/null | head -1)
if [[ -n "$status" ]]; then
@@ -327,7 +424,7 @@ check_status() {
echo -e "${GREEN}✓${NC} ${BLUE}$plugin_name${NC}"
echo " Path: $target"
if [[ -d "$target/.git" ]]; then
if [[ -n "$(git_root_of "$target")" ]]; then
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
local remote=$(cd "$target" && git remote get-url origin 2>/dev/null || echo "no remote")
local commits_behind=$(cd "$target" && git rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0")
@@ -360,9 +457,13 @@ check_status() {
done
echo "Summary:"
echo " ${GREEN}Clean: $clean_count${NC}"
echo " ${YELLOW}Needs attention: $dirty_count${NC}"
[[ $broken_count -gt 0 ]] && echo -e " ${RED}Broken: $broken_count${NC}"
echo -e " ${GREEN}Clean: $clean_count${NC}"
echo -e " ${YELLOW}Needs attention: $dirty_count${NC}"
# An if, not `[[ ]] &&`: as the function's last command a false test made
# `status` exit 1 whenever nothing was broken.
if [[ $broken_count -gt 0 ]]; then
echo -e " ${RED}Broken: $broken_count${NC}"
fi
}
# Update plugin(s)
@@ -384,39 +485,46 @@ update_plugins() {
fi
local target=$(get_symlink_target "$plugin_name")
if [[ ! -d "$target/.git" ]]; then
local root
root=$(git_root_of "$target")
if [[ -z "$root" ]]; then
log_error "Plugin repository is not a git repository: $target"
exit 1
fi
log_info "Updating $plugin_name from $target"
(cd "$target" && git pull --rebase)
log_info "Updating $plugin_name from $root"
(cd "$root" && git pull --rebase)
log_success "Updated $plugin_name"
else
# Update all linked plugins
# Update all linked plugins. Plugins linked from the monorepo share one
# checkout, which is pulled once.
log_info "Updating all linked plugins..."
local updated=0
local failed=0
local pulled_roots=" "
for item in "$PLUGINS_DIR"/*; do
[[ -e "$item" ]] || continue
[[ -d "$item" ]] || continue
local name=$(basename "$item")
[[ "$name" =~ ^\.|^_ ]] && continue
if is_symlink "$item"; then
local target=$(get_symlink_target "$name")
if [[ -d "$target/.git" ]]; then
log_info "Updating $name..."
if (cd "$target" && git pull --rebase); then
log_success "Updated $name"
updated=$((updated + 1))
else
log_error "Failed to update $name"
failed=$((failed + 1))
fi
local root
root=$(git_root_of "$target")
[[ -n "$root" ]] || continue
[[ "$pulled_roots" == *" $root "* ]] && continue
pulled_roots="$pulled_roots$root "
log_info "Updating $root (for $name)..."
if (cd "$root" && git pull --rebase); then
log_success "Updated $root"
updated=$((updated + 1))
else
log_error "Failed to update $root"
failed=$((failed + 1))
fi
fi
done
@@ -439,7 +547,12 @@ Commands:
link-github <plugin-name> [repo-url]
Clone and link a plugin from GitHub
If repo-url is not provided, uses: https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}<plugin-name>.git
Without repo-url: clones (or updates) the official plugin monorepo,
https://github.com/${DEFAULT_GITHUB_USER}/${DEFAULT_PLUGINS_REPO}.git, and links its
plugins/<plugin-name> (or plugins/ledmatrix-<plugin-name>) under the
plugin's manifest id
With repo-url: clones a plugin that has its own repository and links
the repository root
unlink <plugin-name>
Remove symlink for a plugin (preserves repository)
@@ -458,25 +571,30 @@ Commands:
Show this help message
Examples:
# Link a local plugin
$0 link music ../ledmatrix-music
# Link from GitHub (auto-detects URL)
$0 link-github music
# Link from GitHub with custom URL
$0 link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
# Link an official plugin from the monorepo
$0 link-github football-scoreboard
# Link a plugin from a local monorepo checkout
$0 link hello-world ../ledmatrix-plugins/plugins/hello-world
# Link a third-party plugin from its own repository
$0 link-github my-plugin https://github.com/OtherUser/ledmatrix-my-plugin.git
# Check status
$0 status
# Update all plugins
$0 update
Configuration:
Create dev_plugins.json in project root to customize:
Copy dev_plugins.json.example to dev_plugins.json (git-ignored) to customize:
- dev_plugins_dir: Where to clone GitHub repos (default: ~/.ledmatrix-dev-plugins)
- plugins: Plugin definitions (optional, for auto-discovery)
- github_user: Owner of the plugin monorepo, e.g. your fork (default: ${DEFAULT_GITHUB_USER})
- plugins_repo: Name of the plugin monorepo (default: ${DEFAULT_PLUGINS_REPO})
- plugins_branch: Branch to clone the monorepo at (default: its default branch)
Symlinks are created in plugins/. Set plugin_system.plugins_directory to
"plugins" in config/config.json so the plugin loader discovers them.
EOF
}
+16 -12
View File
@@ -142,17 +142,18 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
"""Extract default values from config_schema.json."""
"""Extract default values from config_schema.json.
The same extraction a device and the plugin harness use
(src/plugin_system/testing/loading.py), nested defaults included.
"""
from src.plugin_system.testing.loading import (
load_config_defaults as _load_config_defaults,
)
schema_path = resolve_under(plugin_dir, 'config_schema.json')
if schema_path is None or not schema_path.exists():
return {}
with open(schema_path, 'r') as f:
schema = json.load(f)
defaults: Dict[str, Any] = {}
for key, prop in schema.get('properties', {}).items():
if 'default' in prop:
defaults[key] = prop['default']
return defaults
return _load_config_defaults(schema_path.parent)
# --------------------------------------------------------------------------
@@ -303,10 +304,13 @@ def _parse_render_request(data):
with open(manifest_path, 'r') as f:
manifest = json.load(f)
# Build config: schema defaults + user overrides
config = {'enabled': True}
config.update(load_config_defaults(trusted_dir))
config.update(data.get('config', {}))
# Build config the way a device would: schema defaults under a forced
# enabled, with the user's overrides deep-merged on top
from src.plugin_system.testing.loading import build_config
overrides = data.get('config') or {}
if not isinstance(overrides, dict):
raise ValueError('config must be a JSON object')
config = build_config(trusted_dir, overrides)
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
+55 -11
View File
@@ -20,6 +20,38 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
# Report web_display_autostart the way scripts/utils/start_web_conditionally.py
# (what ledmatrix-web.service runs) decides it: only an explicit false/off keeps
# the web interface down; a missing key or an unreadable config starts it.
# Prints "on <value>", "off <value>", "default" (key not set) or "unreadable".
web_autostart_state() {
(cd "$1" && python3 - 2>/dev/null <<'PY'
import json, os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "scripts", "utils"))
try:
from start_web_conditionally import autostart_enabled
except Exception:
def autostart_enabled(config):
value = config.get("web_display_autostart", True)
if isinstance(value, str):
return value.strip().lower() not in ("off", "false", "no", "0")
return bool(value)
try:
with open(os.path.join("config", "config.json"), encoding="utf-8") as f:
config = json.load(f)
except Exception:
config = None
if not isinstance(config, dict):
print("unreadable")
elif "web_display_autostart" not in config:
print("default")
else:
raw = json.dumps(config["web_display_autostart"])
print(("on " if autostart_enabled(config) else "off ") + raw)
PY
) || echo "unknown"
}
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}1. SERVICE STATUS${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
@@ -41,14 +73,26 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then
echo -e "${GREEN}✓ Config file found${NC}"
# Check web_display_autostart setting
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$')
if [ "$AUTOSTART" == "true" ]; then
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
else
echo -e "${YELLOW}⚠ web_display_autostart: ${AUTOSTART:-not set}${NC}"
echo -e "${YELLOW} Web interface will not start unless this is set to true${NC}"
fi
AUTOSTART=$(web_autostart_state "$PROJECT_DIR")
case "$AUTOSTART" in
on\ *)
echo -e "${GREEN}✓ web_display_autostart: ${AUTOSTART#on }${NC}"
;;
off\ *)
echo -e "${YELLOW}⚠ web_display_autostart: ${AUTOSTART#off }${NC}"
echo -e "${YELLOW} Web interface will not start with this value${NC}"
;;
default)
echo -e "${GREEN}✓ web_display_autostart: not set (defaults to on)${NC}"
;;
unreadable)
echo -e "${YELLOW}⚠ config.json could not be parsed (the web interface still starts so it can be repaired)${NC}"
;;
*)
echo -e "${YELLOW}⚠ web_display_autostart: could not be evaluated (python3 unavailable?)${NC}"
;;
esac
else
echo -e "${RED}✗ Config file not found at: $PROJECT_DIR/config/config.json${NC}"
fi
@@ -63,7 +107,7 @@ declare -a REQUIRED_FILES=(
"web_interface/app.py"
"web_interface/start.py"
"web_interface/requirements.txt"
"web_interface/blueprints/api_v3.py"
"web_interface/blueprints/api_v3/__init__.py"
"web_interface/blueprints/pages_v3.py"
"scripts/utils/start_web_conditionally.py"
)
@@ -134,8 +178,8 @@ if ! sudo systemctl is-active --quiet ledmatrix-web; then
echo " sudo systemctl start ledmatrix-web"
fi
if [ "$AUTOSTART" != "true" ]; then
echo -e "${YELLOW}→ Enable web_display_autostart in config/config.json${NC}"
if [ "${AUTOSTART%% *}" = "off" ]; then
echo -e "${YELLOW}→ Set web_display_autostart to true in config/config.json (or remove it; missing means on)${NC}"
fi
if [ "$ALL_FILES_OK" = false ]; then
+53 -12
View File
@@ -22,6 +22,38 @@ fi
PROJECT_DIR="${HOME}/LEDMatrix"
# Report web_display_autostart the way scripts/utils/start_web_conditionally.py
# (what ledmatrix-web.service runs) decides it: only an explicit false/off keeps
# the web interface down; a missing key or an unreadable config starts it.
# Prints "on <value>", "off <value>", "default" (key not set) or "unreadable".
web_autostart_state() {
(cd "$1" && python3 - 2>/dev/null <<'PY'
import json, os, sys
sys.path.insert(0, os.path.join(os.getcwd(), "scripts", "utils"))
try:
from start_web_conditionally import autostart_enabled
except Exception:
def autostart_enabled(config):
value = config.get("web_display_autostart", True)
if isinstance(value, str):
return value.strip().lower() not in ("off", "false", "no", "0")
return bool(value)
try:
with open(os.path.join("config", "config.json"), encoding="utf-8") as f:
config = json.load(f)
except Exception:
config = None
if not isinstance(config, dict):
print("unreadable")
elif "web_display_autostart" not in config:
print("default")
else:
raw = json.dumps(config["web_display_autostart"])
print(("on " if autostart_enabled(config) else "off ") + raw)
PY
) || echo "unknown"
}
echo "1. Checking service status..."
echo "------------------------------"
if systemctl is-active --quiet ledmatrix-web 2>/dev/null || sudo systemctl is-active --quiet ledmatrix-web 2>/dev/null; then
@@ -47,16 +79,25 @@ echo "3. Checking configuration file..."
echo "------------------------------"
if [ -f "${PROJECT_DIR}/config/config.json" ]; then
echo -e "${GREEN}✓ Config file exists${NC}"
AUTOSTART=$(grep -o '"web_display_autostart":\s*\(true\|false\)' "${PROJECT_DIR}/config/config.json" | grep -o '\(true\|false\)' || echo "not found")
if [ "$AUTOSTART" = "true" ]; then
echo -e "${GREEN}✓ web_display_autostart is set to TRUE${NC}"
elif [ "$AUTOSTART" = "false" ]; then
echo -e "${RED}✗ web_display_autostart is set to FALSE (web UI won't start!)${NC}"
echo " Fix: Edit config.json and set 'web_display_autostart': true"
else
echo -e "${YELLOW}⚠ web_display_autostart setting not found (defaults to false)${NC}"
echo " Fix: Add 'web_display_autostart': true to config.json"
fi
AUTOSTART=$(web_autostart_state "$PROJECT_DIR")
case "$AUTOSTART" in
on\ *)
echo -e "${GREEN}✓ web_display_autostart is ${AUTOSTART#on } (web UI starts)${NC}"
;;
off\ *)
echo -e "${RED}✗ web_display_autostart is ${AUTOSTART#off } (web UI won't start!)${NC}"
echo " Fix: Edit config.json and set 'web_display_autostart': true"
;;
default)
echo -e "${GREEN}✓ web_display_autostart is not set (defaults to on; web UI starts)${NC}"
;;
unreadable)
echo -e "${YELLOW}⚠ config.json could not be parsed (the web UI still starts so it can be repaired)${NC}"
;;
*)
echo -e "${YELLOW}⚠ Could not evaluate web_display_autostart (python3 unavailable?)${NC}"
;;
esac
else
echo -e "${RED}✗ Config file NOT FOUND at ${PROJECT_DIR}/config/config.json${NC}"
fi
@@ -82,7 +123,7 @@ FILES_TO_CHECK=(
"web_interface/start.py"
"web_interface/app.py"
"web_interface/requirements.txt"
"web_interface/blueprints/api_v3.py"
"web_interface/blueprints/api_v3/__init__.py"
"web_interface/blueprints/pages_v3.py"
)
@@ -175,7 +216,7 @@ echo "Diagnostic Summary"
echo "=========================================="
echo ""
echo "Most common issues:"
echo " 1. web_display_autostart is false or missing in config.json"
echo " 1. web_display_autostart is set to false in config.json (a missing key means on)"
echo " 2. Service not enabled or not started"
echo " 3. Missing dependencies (Flask, etc.)"
echo " 4. Import errors in web_interface/app.py"
+15 -7
View File
@@ -7,8 +7,10 @@ install as the wrong user, after a manual file copy that didn't preserve
ownership, or after a permissions-related error from the display or
web service.
Most of these scripts require `sudo` since they touch directories
owned by the `ledmatrix` service user or by `root`.
Most of these scripts require `sudo` since they touch directories owned
by `root` (the display service's user) or by the user you installed
LEDMatrix as (the web service's user). There is no dedicated `ledmatrix`
system user.
## Scripts
@@ -16,11 +18,12 @@ owned by the `ledmatrix` service user or by `root`.
permissions on the `assets/` tree so plugins can download and cache
team logos, fonts, and other static content.
- **`fix_cache_permissions.sh`** — Fixes permissions on every cache
directory the project may use (`/var/cache/ledmatrix/`,
`~/.cache/ledmatrix/`, `/opt/ledmatrix/cache/`, project-local
`cache/`). Also creates placeholder logo subdirectories used by the
sports plugins.
- **`fix_cache_permissions.sh`** — Creates (if missing) and fixes
permissions on `/var/cache/ledmatrix/` and `~/.ledmatrix_cache/` of the
user running `sudo`, and creates
`/var/cache/ledmatrix/placeholder_logos/` for the sports plugins. It does
not touch the cache manager's other fallbacks (`/opt/ledmatrix/cache`,
`$TMPDIR/ledmatrix_cache`).
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
directory so both the root display service and the web service user
@@ -31,6 +34,11 @@ owned by the `ledmatrix` service user or by `root`.
systemd journal access, and the sudoers entries the web interface
needs to control the display service.
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
after checking it is the project's own or one under `plugin-repos/` or
`plugins/`. Used by the web interface (via sudo) to install plugin
dependencies where `ledmatrix.service` can import them.
- **`safe_plugin_rm.sh`** — Validates that a plugin removal path is
inside an allowed base directory before deleting it. Used by the web
interface (via sudo) when a user clicks **Uninstall** on a plugin —
+20 -7
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# safe_pip_install.sh — Install a requirements.txt as root after validating
# that the resolved path is the project's own requirements.txt or a plugin's
# that the resolved path is one of the project's own requirements files
# (requirements.txt, web_interface/requirements.txt) or a plugin's
# requirements.txt under plugin-repos/ or plugins/.
#
# This script is intended to be called via sudo from the web interface, so
@@ -25,9 +26,17 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Allowed locations (resolved, no trailing slash):
# - the project's own requirements.txt
# - the project's own requirements files. Update Code, the automatic
# update's health check and Install Base Requirements install both, so
# one missing here is refused on every device -- and the automatic
# updater rolls back any update that changes it.
# Only their folders are resolved: resolving the files too would follow
# a requirements.txt symlinked out of the project and allow its target.
# - any requirements.txt under plugin-repos/ or plugins/
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
ALLOWED_EXACT=(
"$(realpath --canonicalize-missing "$PROJECT_ROOT")/requirements.txt"
"$(realpath --canonicalize-missing "$PROJECT_ROOT/web_interface")/requirements.txt"
)
ALLOWED_BASES=(
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
@@ -43,9 +52,13 @@ if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
fi
ALLOWED=false
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
ALLOWED=true
else
for EXACT in "${ALLOWED_EXACT[@]}"; do
if [ "$RESOLVED_TARGET" = "$EXACT" ]; then
ALLOWED=true
break
fi
done
if [ "$ALLOWED" = false ]; then
for BASE in "${ALLOWED_BASES[@]}"; do
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
ALLOWED=true
@@ -56,7 +69,7 @@ fi
if [ "$ALLOWED" = false ]; then
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
echo "Allowed: ${ALLOWED_EXACT[*]}, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
exit 2
fi
+9 -5
View File
@@ -7,14 +7,18 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
- **`one-shot-install.sh`** - Single-command installer; clones the
repo, checks prerequisites, then runs `first_time_install.sh`.
Invoked via `curl ... | bash` from the project root README.
- **`install_service.sh`** - Installs the main LED Matrix display service (systemd)
- **`install_web_service.sh`** - Installs the web interface service (systemd)
- **`install_service.sh`** - Installs, enables and starts the display
service (`ledmatrix.service`), the web interface service
(`ledmatrix-web.service`) and the update-verify units (systemd)
- **`install_web_service.sh`** - Installs only the web interface service
and the update-verify units (systemd)
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
- **`setup_cache.sh`** - Sets up persistent cache directory with proper permissions
- **`configure_web_sudo.sh`** - Configures passwordless sudo access for web interface actions
- **`configure_wifi_permissions.sh`** - Grants the `ledmatrix` user
the WiFi management permissions needed by the web interface and
the WiFi monitor service
- **`configure_wifi_permissions.sh`** - Grants the web interface's user
(the user who runs the script, i.e. the one you installed LEDMatrix as;
there is no `ledmatrix` system user) the passwordless `nmcli` and related
WiFi permissions the web interface needs
- **`migrate_config.sh`** - Migrates configuration files to new formats (if needed)
- **`debug_install.sh`** - Diagnostic helper used when an install
fails; collects environment info and recent logs
+13
View File
@@ -130,6 +130,19 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
} > "$TEMP_SUDOERS"
# Never offer to install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user.
if command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
echo ""
echo "✗ The generated sudoers rules did not parse:" >&2
visudo -c -f "$TEMP_SUDOERS" >&2 || true
echo "Nothing was changed." >&2
rm -f "$TEMP_SUDOERS"
exit 1
fi
fi
echo ""
echo "Generated sudoers configuration:"
echo "--------------------------------"
+36 -1
View File
@@ -3,6 +3,41 @@
# Exit on error
set -e
usage() {
cat <<'USAGE'
Usage: sudo ./scripts/install/install_service.sh [-h|--help]
Installs (or reinstalls) the LEDMatrix systemd units from the templates in
systemd/, then enables and starts them:
- ledmatrix.service main display (runs as root)
- ledmatrix-web.service web interface (runs as the invoking user)
- ledmatrix-update-verify.service automatic-update health check
- ledmatrix-update-verify.path
Existing unit files in /etc/systemd/system are overwritten. The script takes
no other options; run it with no arguments to install.
Options:
-h, --help Show this help and exit without changing anything.
USAGE
}
# Parse arguments before touching anything: this script rewrites and restarts
# services, so an unrecognised option must not fall through to a full install.
for arg in "$@"; do
case "$arg" in
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown option: $arg" >&2
usage >&2
exit 2
;;
esac
done
# Get the actual user who invoked sudo
if [ -n "$SUDO_USER" ]; then
ACTUAL_USER="$SUDO_USER"
@@ -70,7 +105,7 @@ echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
# Rendered from systemd/ledmatrix-web.service, the same template
# install_web_service.sh uses. This was an inline heredoc until it drifted from
# the template: it had lost Wants=network-online.target, RestartSec,
# SyslogIdentifier, CacheDirectory and Environment=USE_THREADING. Because
# SyslogIdentifier and Environment=USE_THREADING. Because
# src/startup_validator.py compares the installed unit against the template,
# every boot warned "re-run install_service.sh" -- and doing so reinstalled the
# same stale copy, so the warning could never clear.
+33 -17
View File
@@ -32,7 +32,7 @@ fi
# Render the unit from systemd/ledmatrix-web.service. That template is the
# only description of the unit; this script used to carry its own heredoc copy,
# and install_service.sh a third, which is how the installed unit on real rigs
# ended up missing RestartSec, SyslogIdentifier and CacheDirectory while
# ended up missing RestartSec and SyslogIdentifier while
# src/startup_validator.py warned about drift on every boot.
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"
if [ ! -f "$TEMPLATE" ]; then
@@ -62,31 +62,47 @@ for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path;
fi
done
# Ensure cache directory exists with proper permissions
# This is a fallback for older systemd versions that don't support CacheDirectory
# Systemd 239+ will automatically create it via CacheDirectory directive
# Shared cache directory. The display service (root) and this web service both
# write here and read each other's files, which are created 0660, so the two
# share it through the directory's group: ledmatrix when the installing user
# is in it (first_time_install.sh / setup_cache.sh set that up), otherwise the
# user's own group. setgid makes new files inherit that group.
#
# An existing directory keeps its group whenever the web user can read through
# it -- ledmatrix, or the user's own group where systemd's old CacheDirectory=
# left it -- because re-grouping a working directory strands every file already
# in it on the old group. Only a group the user is not in (root's, or ledmatrix
# for a user outside it) is replaced. This used to force the user's group on
# every run, replacing the ledmatrix group setup_cache.sh had set.
echo "Setting up cache directory..."
CACHE_DIR="/var/cache/ledmatrix"
USER_GROUPS=$(id -nG "$ACTUAL_USER" 2>/dev/null | tr ' ' '\n')
if printf '%s\n' "$USER_GROUPS" | grep -qx ledmatrix; then
CACHE_GROUP="ledmatrix"
else
CACHE_GROUP=$(id -gn "$ACTUAL_USER" 2>/dev/null || echo root)
fi
if [ ! -d "$CACHE_DIR" ]; then
mkdir -p "$CACHE_DIR"
# Set group ownership to allow both root and web user access
# Try to use ACTUAL_USER's group, fallback to root if that fails
if getent group "$ACTUAL_USER" > /dev/null 2>&1; then
chown root:"$ACTUAL_USER" "$CACHE_DIR" 2>/dev/null || chown root:root "$CACHE_DIR"
else
chown root:root "$CACHE_DIR"
fi
chmod 775 "$CACHE_DIR"
chown root:"$CACHE_GROUP" "$CACHE_DIR" 2>/dev/null || true
echo "✓ Cache directory created: $CACHE_DIR"
else
# Ensure permissions are correct
chmod 775 "$CACHE_DIR" 2>/dev/null || true
# Try to set group ownership if possible
if getent group "$ACTUAL_USER" > /dev/null 2>&1; then
chown root:"$ACTUAL_USER" "$CACHE_DIR" 2>/dev/null || true
DIR_GROUP=$(stat -c %G "$CACHE_DIR" 2>/dev/null)
if ! printf '%s\n' "$USER_GROUPS" | grep -qx "$DIR_GROUP"; then
if chgrp "$CACHE_GROUP" "$CACHE_DIR" 2>/dev/null; then
echo "✓ Cache directory group changed from $DIR_GROUP to $CACHE_GROUP"
# Files already there keep the old group. The display service
# re-groups its own files when it starts (DiskCache.share_existing_files,
# which refuses symlinks and hard links); a recursive chgrp here
# would not. try-restart does nothing if the service is not running.
if find "$CACHE_DIR" -maxdepth 1 -name '*.json' -user root ! -group "$CACHE_GROUP" -print -quit 2>/dev/null | grep -q .; then
systemctl try-restart ledmatrix.service 2>/dev/null || true
fi
fi
fi
echo "✓ Cache directory exists: $CACHE_DIR"
fi
chmod 2775 "$CACHE_DIR" 2>/dev/null || true
# Reload systemd to recognize the new service
echo "Reloading systemd..."
+53 -6
View File
@@ -3,6 +3,8 @@
# Use this if automatic dependency installation fails
set -e
# A failed pip must fail the `pip ... | tee` pipeline below, not be hidden by tee.
set -o pipefail
# Colors for output
RED='\033[0;31m'
@@ -28,15 +30,50 @@ echo ""
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
LEDMATRIX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PLUGINS_DIR="$LEDMATRIX_DIR/plugins"
CONFIG_FILE="$LEDMATRIX_DIR/config/config.json"
# The Plugin Store installs into plugin_system.plugins_directory from
# config.json (default plugin-repos), resolved against the project root like
# the display and web services do. plugins/ is also scanned: it holds the
# symlinks scripts/dev/dev_plugin_setup.sh creates.
CONFIGURED_DIR="plugin-repos"
if [ -f "$CONFIG_FILE" ] && command -v python3 >/dev/null 2>&1; then
CONFIGURED_DIR="$(LEDMATRIX_CONFIG_FILE="$CONFIG_FILE" python3 -c '
import json, os
try:
with open(os.environ["LEDMATRIX_CONFIG_FILE"], encoding="utf-8") as f:
value = (json.load(f).get("plugin_system") or {}).get("plugins_directory")
except Exception:
value = None
print(value if isinstance(value, str) and value.strip() else "plugin-repos")
' 2>/dev/null)" || CONFIGURED_DIR="plugin-repos"
[ -n "$CONFIGURED_DIR" ] || CONFIGURED_DIR="plugin-repos"
fi
case "$CONFIGURED_DIR" in
/*) PLUGINS_DIR="$CONFIGURED_DIR" ;;
*) PLUGINS_DIR="$LEDMATRIX_DIR/$CONFIGURED_DIR" ;;
esac
DEV_PLUGINS_DIR="$LEDMATRIX_DIR/plugins"
echo "LEDMatrix directory: $LEDMATRIX_DIR"
echo "Plugins directory: $PLUGINS_DIR"
echo "Plugins directory: $PLUGINS_DIR (plugin_system.plugins_directory)"
SCAN_DIRS=()
PLUGINS_DIR_REAL=""
if [ -d "$PLUGINS_DIR" ]; then
SCAN_DIRS+=("$PLUGINS_DIR")
PLUGINS_DIR_REAL="$(cd "$PLUGINS_DIR" && pwd -P)"
fi
if [ -d "$DEV_PLUGINS_DIR" ] && [ "$(cd "$DEV_PLUGINS_DIR" && pwd -P)" != "$PLUGINS_DIR_REAL" ]; then
echo "Also scanning dev plugins: $DEV_PLUGINS_DIR"
SCAN_DIRS+=("$DEV_PLUGINS_DIR")
fi
echo ""
# Check if plugins directory exists
if [ ! -d "$PLUGINS_DIR" ]; then
# Check if a plugins directory exists
if [ ${#SCAN_DIRS[@]} -eq 0 ]; then
echo -e "${RED}Error: Plugins directory not found at $PLUGINS_DIR${NC}"
echo "Install a plugin from the Plugin Store first, or check plugin_system.plugins_directory in $CONFIG_FILE"
exit 1
fi
@@ -47,12 +84,21 @@ echo ""
PLUGINS_FOUND=0
PLUGINS_INSTALLED=0
PLUGINS_FAILED=0
SEEN_PLUGIN_PATHS=" "
for plugin_dir in "$PLUGINS_DIR"/*/ ; do
for scan_dir in "${SCAN_DIRS[@]}"; do
for plugin_dir in "$scan_dir"/*/ ; do
if [ -d "$plugin_dir" ]; then
plugin_name=$(basename "$plugin_dir")
requirements_file="$plugin_dir/requirements.txt"
# A dev symlink can point at a plugin already scanned; install it once.
real_plugin_dir="$(cd "$plugin_dir" && pwd -P)"
case "$SEEN_PLUGIN_PATHS" in
*" $real_plugin_dir "*) continue ;;
esac
SEEN_PLUGIN_PATHS="$SEEN_PLUGIN_PATHS$real_plugin_dir "
if [ -f "$requirements_file" ]; then
PLUGINS_FOUND=$((PLUGINS_FOUND + 1))
echo -e "${GREEN}Found plugin: ${plugin_name}${NC}"
@@ -79,6 +125,7 @@ for plugin_dir in "$PLUGINS_DIR"/*/ ; do
fi
fi
done
done
# Summary
echo ""
+69 -45
View File
@@ -47,52 +47,47 @@ from src.common import scroll_config # noqa: E402
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
def load_hardware():
def load_config():
"""The whole config.json, or {} when it is missing or unreadable."""
try:
with open(CONFIG, encoding="utf-8") as handle:
return (json.load(handle).get("display") or {}).get("hardware") or {}
config = json.load(handle)
except (OSError, ValueError):
return {}
return config if isinstance(config, dict) else {}
def build_options(hardware, refresh_override=None):
from rgbmatrix import RGBMatrixOptions
o = RGBMatrixOptions()
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
)
o.rows = int(hardware.get("rows", DEFAULT_ROWS))
o.cols = int(hardware.get("cols", DEFAULT_COLS))
o.chain_length = int(hardware.get("chain_length", DEFAULT_CHAIN_LENGTH))
o.parallel = int(hardware.get("parallel", DEFAULT_PARALLEL))
o.brightness = int(hardware.get("brightness", 80))
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
o.pwm_bits = int(hardware.get("pwm_bits", 11))
o.pwm_dither_bits = int(hardware.get("pwm_dither_bits", 0))
o.pwm_lsb_nanoseconds = int(hardware.get("pwm_lsb_nanoseconds", 130))
o.led_rgb_sequence = hardware.get("led_rgb_sequence", "RGB")
o.scan_mode = int(hardware.get("scan_mode", 0))
o.row_address_type = int(hardware.get("row_address_type", 0))
o.multiplexing = int(hardware.get("multiplexing", 0))
o.gpio_slowdown = int(hardware.get("gpio_slowdown", 2))
o.limit_refresh_rate_hz = (
int(refresh_override) if refresh_override is not None
else int(hardware.get("limit_refresh_rate_hz", 0))
)
return o
def hardware_of(config):
return (config.get("display") or {}).get("hardware") or {}
def open_matrix(hardware, refresh_override=None):
def build_options(config, refresh_override=None):
"""The matrix options the display service would use for this config.
Built by DisplayManager.apply_matrix_options, not a copy of it, so the
measurement and the demo drive the panel exactly as the service does
(runtime gpio_slowdown, rp1_rio, panel_type, orientation, defaults).
``refresh_override`` replaces limit_refresh_rate_hz; 0 means uncapped.
"""
from src.display_manager import DisplayManager, RGBMatrixOptions
options = DisplayManager.apply_matrix_options(RGBMatrixOptions(), config)
if refresh_override is not None:
options.limit_refresh_rate_hz = int(refresh_override)
return options
def open_matrix(config, refresh_override=None):
"""Construct the matrix, or explain why it will not open."""
if os.geteuid() != 0:
sys.exit("this needs root for GPIO access - rerun with sudo")
try:
from rgbmatrix import RGBMatrix
except ImportError:
sys.exit("rgbmatrix is not installed on this machine")
from src.display_manager import RGBMatrix
except ImportError as exc:
sys.exit("could not load the display stack ({}); is rgbmatrix "
"installed on this machine?".format(exc))
try:
return RGBMatrix(options=build_options(hardware, refresh_override))
return RGBMatrix(options=build_options(config, refresh_override))
except Exception as exc: # pragma: no cover - hardware dependent
sys.exit(
"could not open the panel ({}).\n"
@@ -101,7 +96,7 @@ def open_matrix(hardware, refresh_override=None):
)
def measure_refresh(hardware, seconds=6.0):
def measure_refresh(config, seconds=6.0):
"""Actual refresh rate, by running uncapped and timing the swaps.
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
@@ -109,7 +104,7 @@ def measure_refresh(hardware, seconds=6.0):
chain will really give you, as opposed to whatever limit_refresh_rate_hz
optimistically asks for.
"""
matrix = open_matrix(hardware, refresh_override=0)
matrix = open_matrix(config, refresh_override=0)
canvas = matrix.CreateFrameCanvas()
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
frames = 0
@@ -122,17 +117,17 @@ def measure_refresh(hardware, seconds=6.0):
return measured
def demo(hardware, target, seconds):
def demo(config, target, seconds):
"""Scroll text at the crisp speed nearest `target`."""
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
hz = scroll_config.refresh_hz_from_config(config)
choice = scroll_config.solve_crisp(target, hz)
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
matrix = open_matrix(hardware)
matrix = open_matrix(config)
canvas = matrix.CreateFrameCanvas()
W, H = canvas.width, canvas.height
@@ -195,9 +190,38 @@ def print_ladder(hz, highlight=None):
) else ""
print(" " + entry.describe() + mark)
print("")
print("Set one in config.json as pixels per second, e.g.")
print(' "display_options": {{"scroll_pixels_per_second": {:.0f}}}'.format(
scroll_config.solve_crisp(highlight if highlight else hz / 2, hz).pixels_per_second))
print_config_advice(scroll_config.solve_crisp(highlight if highlight else hz / 2, hz))
def config_advice(choice):
"""The config that selects ``choice``, in the keys the resolver honours.
Tickers take a ``scroll_speed`` (px per step) + ``scroll_delay`` (seconds)
pair, and scroll_config ranks that pair ABOVE ``scroll_pixels_per_second``
-- deliberately, because some plugins give the flat key a schema default.
Many schemas default the pair too, so a flat key added by hand is usually
ignored. Advise the pair: pixels_per_frame every frame_hold/refresh
seconds is exactly the crisp speed.
"""
pair = {
"scroll_speed": choice.pixels_per_frame,
"scroll_delay": round(choice.frame_hold / choice.refresh_hz, 6),
}
scoreboard = {"scroll_speed": round(choice.pixels_per_second, 2)}
return pair, scoreboard
def print_config_advice(choice):
pair, scoreboard = config_advice(choice)
print("To use {:.1f} px/s, set it where the plugin keeps its scroll speed.".format(
choice.pixels_per_second))
print("Tickers take a scroll_speed (px per step) + scroll_delay (seconds) pair:")
print(' "display_options": {}'.format(json.dumps(pair)))
print("(some plugins keep the pair at the top level or under \"display\").")
print("The pair outranks scroll_pixels_per_second, which is ignored whenever the")
print("pair is present -- and schema defaults usually put it there.")
print("Sports scoreboards take pixels per second per league instead:")
print(' "scroll_settings": {}'.format(json.dumps(scoreboard)))
def main():
@@ -214,15 +238,15 @@ def main():
help="highlight the entry nearest this speed")
args = ap.parse_args()
hardware = load_hardware()
configured = float(hardware.get("limit_refresh_rate_hz") or 0)
config = load_config()
configured = float(hardware_of(config).get("limit_refresh_rate_hz") or 0)
if args.demo is not None:
demo(hardware, args.demo, args.seconds)
demo(config, args.demo, args.seconds)
return
if args.measure:
measured = measure_refresh(hardware)
measured = measure_refresh(config)
print("measured panel refresh: {:.1f}Hz".format(measured))
if configured:
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
+51 -13
View File
@@ -42,10 +42,30 @@ HEALTH_TIMEOUT_SECONDS = 180
#: loop look healthy between attempts, so a single "is-active" proves nothing.
STABLE_SECONDS = 45
POLL_SECONDS = 5
WEB_CHECK_TIMEOUT_SECONDS = 5
SYSTEMCTL_QUERY_TIMEOUT_SECONDS = 10
RESTART_TIMEOUT_SECONDS = 90
GIT_TIMEOUT_SECONDS = 60
GIT_RESET_TIMEOUT_SECONDS = 120
PIP_TIMEOUT_SECONDS = 600
#: All of a rollback's dependency reinstalls together. A pip that times out
#: or fails is not retried: systemd stops this unit at TimeoutStartSec, and a
#: rollback killed half-way leaves the update reported as still verifying.
PIP_BUDGET_SECONDS = 600
#: sudoers matches the exact command line, so bash is named by path, the same
#: candidates src/common/permission_utils.install_requirements_file tries.
#: candidates src/common/permission_utils.install_requirements_file tries...
BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
#: ...and, like it, moves to the next one only when sudo refused the command
#: line (permission_utils.SUDO_REFUSAL_PHRASES), never after pip itself ran.
SUDO_REFUSAL_PHRASES = ('a password is required', 'is not allowed to run', 'no tty present')
#: The longest one health check can take: restart and wait, roll back
#: (diff, reset, reinstalls), restart and wait again. A wait's last poll can
#: start just before its deadline and run every query to its timeout.
_WAIT_WORST_SECONDS = (HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS + WEB_CHECK_TIMEOUT_SECONDS
+ 2 * SYSTEMCTL_QUERY_TIMEOUT_SECONDS + POLL_SECONDS)
WORST_CASE_SECONDS = (2 * (2 * RESTART_TIMEOUT_SECONDS + _WAIT_WORST_SECONDS)
+ GIT_TIMEOUT_SECONDS + GIT_RESET_TIMEOUT_SECONDS + PIP_BUDGET_SECONDS)
#: What a command that could not run at all reports: its callers only read
#: these three fields, the same ones a completed subprocess has.
@@ -83,7 +103,7 @@ def write_pending(path, data):
def _web_responds(url=WEB_HEALTH_URL):
try:
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 - fixed loopback URL
with urllib.request.urlopen(url, timeout=WEB_CHECK_TIMEOUT_SECONDS) as resp: # nosec B310 - fixed loopback URL
return resp.status == 200
except (urllib.error.URLError, OSError, ValueError):
return False
@@ -104,7 +124,7 @@ class Verifier:
self.web_responds = web_responds
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
def _run(self, args, timeout=60):
def _run(self, args, timeout=GIT_TIMEOUT_SECONDS):
try:
return self.run(args, cwd=str(self.project_root), capture_output=True,
text=True, timeout=timeout)
@@ -114,15 +134,17 @@ class Verifier:
# -- services ---------------------------------------------------------
def service_active(self, unit):
return self._run(['systemctl', 'is-active', unit], timeout=10).stdout.strip() == 'active'
return self._run(['systemctl', 'is-active', unit],
timeout=SYSTEMCTL_QUERY_TIMEOUT_SECONDS).stdout.strip() == 'active'
def restart_count(self, unit):
out = self._run(['systemctl', 'show', '-p', 'NRestarts', '--value', unit],
timeout=10).stdout.strip()
timeout=SYSTEMCTL_QUERY_TIMEOUT_SECONDS).stdout.strip()
return int(out) if out.isdigit() else None
def restart(self, unit):
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'], timeout=90)
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'],
timeout=RESTART_TIMEOUT_SECONDS)
if result.returncode != 0:
self.log(f'restarting {unit} failed: {(result.stderr or "").strip()}')
return result.returncode == 0
@@ -173,16 +195,28 @@ class Verifier:
changed = set(result.stdout.split()) if result.returncode == 0 else set(REQUIREMENT_FILES)
return [rel for rel in REQUIREMENT_FILES if rel in changed]
def install_requirements(self, rel):
def install_requirements(self, rel, deadline=None):
"""Install one requirements file through the root wrapper, by ``deadline``."""
wrapper = self.project_root / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
req = self.project_root / rel
if not req.exists():
return True
for bash in BASH_CANDIDATES:
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)],
timeout=PIP_TIMEOUT_SECONDS)
timeout = PIP_TIMEOUT_SECONDS
if deadline is not None:
timeout = min(timeout, deadline - self.clock())
if timeout <= 0:
self.log(f'no time left to reinstall {rel}')
return False
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)], timeout=timeout)
if result.returncode == 0:
return True
# Only a refused command line is worth the next candidate. A pip
# that ran and failed, or timed out, would just do it again.
if not any(phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
# Not pip's output: it can echo an index URL's credentials.
self.log(f'reinstalling {rel} failed (exit {result.returncode})')
return False
return False
def rollback(self, pending):
@@ -191,13 +225,17 @@ class Verifier:
if not old:
return False, 'the commit to roll back to is unknown'
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
# Safe to --hard: the updater refuses to run with local edits to
# tracked files, so the only thing this discards is the update.
result = self._run(['git', 'reset', '--hard', old], timeout=120)
# --hard: the updater refuses to run with local edits to tracked core
# files (web_interface/auto_update.local_changes), so outside the
# plugin folders the only thing this discards is the update. Edits
# under plugins/ and plugin-repos/, which that check leaves to the
# pull's --autostash, are reset along with it.
result = self._run(['git', 'reset', '--hard', old], timeout=GIT_RESET_TIMEOUT_SECONDS)
if result.returncode != 0:
return False, (f'"git reset --hard {old}" failed: '
f'{(result.stderr or result.stdout or "").strip()}')
failed = [rel for rel in requirements if not self.install_requirements(rel)]
deadline = self.clock() + PIP_BUDGET_SECONDS
failed = [rel for rel in requirements if not self.install_requirements(rel, deadline)]
if failed:
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
+ ' failed; run Install Base Requirements from the Tools tab')
+14 -9
View File
@@ -121,19 +121,24 @@ fi
echo ""
# 5. Check web interface
# ledmatrix-web.service runs scripts/utils/start_web_conditionally.py, which
# starts web_interface/start.py; the app binds port 5000 (web_interface/start.py).
echo "=== Web Interface ==="
if [ -f "$PROJECT_ROOT/web_interface_v2.py" ]; then
check_pass "web_interface_v2.py exists"
else
check_fail "web_interface_v2.py is missing"
fi
WEB_PORT=5000
for web_file in scripts/utils/start_web_conditionally.py web_interface/start.py web_interface/app.py; do
if [ -f "$PROJECT_ROOT/$web_file" ]; then
check_pass "$web_file exists"
else
check_fail "$web_file is missing"
fi
done
# Check if web service is listening
if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
if netstat -tuln 2>/dev/null | grep -q ":5001" || ss -tuln 2>/dev/null | grep -q ":5001"; then
check_pass "Web interface is listening on port 5001"
if netstat -tuln 2>/dev/null | grep -qE ":${WEB_PORT}([^0-9]|$)" || ss -tuln 2>/dev/null | grep -qE ":${WEB_PORT}([^0-9]|$)"; then
check_pass "Web interface is listening on port $WEB_PORT"
else
check_warn "Web service is running but port 5001 may not be listening"
check_warn "Web service is running but port $WEB_PORT may not be listening"
fi
else
check_warn "Web service is not running (cannot check port)"
@@ -204,7 +209,7 @@ if [ "$ALL_PASSED" = true ]; then
echo -e "${GREEN}Installation verification PASSED${NC}"
echo ""
echo "Next steps:"
echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):5001"
echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):$WEB_PORT"
echo "2. Check service status: sudo systemctl status ledmatrix.service"
echo "3. View logs: journalctl -u ledmatrix.service -f"
exit 0
+25 -21
View File
@@ -8,6 +8,10 @@ echo "Web UI Verification"
echo "=========================================="
echo ""
# The web interface binds port 5000 (web_interface/start.py).
WEB_PORT=5000
PORT_PATTERN=":${WEB_PORT}([^0-9]|$)"
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
@@ -32,25 +36,25 @@ else
fi
echo ""
# 2. Check if port 5001 is listening
echo "2. Checking if port 5001 is listening..."
# 2. Check if port $WEB_PORT is listening
echo "2. Checking if port $WEB_PORT is listening..."
if command -v ss >/dev/null 2>&1; then
if ss -tuln 2>/dev/null | grep -q ":5001"; then
echo -e "${GREEN}✓${NC} Port 5001 is listening"
if ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
echo ""
echo "Active connections on port 5001:"
ss -tuln | grep ":5001"
echo "Active connections on port $WEB_PORT:"
ss -tuln | grep -E "$PORT_PATTERN"
else
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tuln 2>/dev/null | grep -q ":5001"; then
echo -e "${GREEN}✓${NC} Port 5001 is listening"
if netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
echo ""
echo "Active connections on port 5001:"
netstat -tuln | grep ":5001"
echo "Active connections on port $WEB_PORT:"
netstat -tuln | grep -E "$PORT_PATTERN"
else
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
fi
else
echo -e "${YELLOW}⚠${NC} Cannot check port (ss/netstat not available)"
@@ -59,15 +63,15 @@ echo ""
# 3. Test HTTP connection
echo "3. Testing HTTP connection..."
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null)
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT > /dev/null 2>&1; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT 2>/dev/null)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
echo -e "${GREEN}✓${NC} Web interface is responding (HTTP $HTTP_CODE)"
else
echo -e "${YELLOW}⚠${NC} Web interface responded with HTTP $HTTP_CODE"
fi
else
echo -e "${RED}✗${NC} Cannot connect to web interface on port 5001"
echo -e "${RED}✗${NC} Cannot connect to web interface on port $WEB_PORT"
fi
echo ""
@@ -79,7 +83,7 @@ if [ -n "$IP_ADDRESSES" ]; then
echo ""
echo "Access web interface at:"
for ip in $IP_ADDRESSES; do
echo " http://$ip:5001"
echo " http://$ip:$WEB_PORT"
done
else
echo -e "${YELLOW}⚠${NC} Could not determine IP address"
@@ -118,12 +122,12 @@ if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
SERVICE_RUNNING=true
fi
if (ss -tuln 2>/dev/null | grep -q ":5001") || (netstat -tuln 2>/dev/null | grep -q ":5001"); then
if (ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN") || (netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"); then
PORT_LISTENING=true
fi
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null)
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT > /dev/null 2>&1; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT 2>/dev/null)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
HTTP_RESPONDING=true
fi
@@ -134,7 +138,7 @@ if [ "$SERVICE_RUNNING" = true ] && [ "$PORT_LISTENING" = true ] && [ "$HTTP_RES
echo ""
echo "You can access it at:"
for ip in $IP_ADDRESSES; do
echo " http://$ip:5001"
echo " http://$ip:$WEB_PORT"
done
exit 0
elif [ "$SERVICE_RUNNING" = false ]; then
@@ -145,7 +149,7 @@ elif [ "$SERVICE_RUNNING" = false ]; then
echo " sudo systemctl enable ledmatrix-web.service # to start on boot"
exit 1
elif [ "$PORT_LISTENING" = false ]; then
echo -e "${RED}✗ Service is running but port 5001 is not listening${NC}"
echo -e "${RED}✗ Service is running but port $WEB_PORT is not listening${NC}"
echo ""
echo "Check logs for errors:"
echo " sudo journalctl -u ledmatrix-web.service -f"
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.4.0"
__version__ = "3.5.0"
+70 -8
View File
@@ -25,6 +25,14 @@ from enum import Enum
import queue
from concurrent.futures import ThreadPoolExecutor
from src.cache_manager import CacheManager
from src.common.espn_dates import (
RANGE_RETRY_SECONDS,
_note_range_rejected,
_ranges_known_rejected,
clamp_espn_limit,
fetch_espn_date_chunks,
parse_espn_date_range,
)
# Configure logging
logger = logging.getLogger(__name__)
@@ -98,7 +106,12 @@ class BackgroundDataService:
This service manages a pool of background threads to fetch data asynchronously,
with intelligent caching, retry logic, and progress tracking.
"""
# Plugins feature-detect this. A core without it sends season ranges to
# ESPN as-is and gets 400s since 2026-09-15, so plugins fetch those
# ranges themselves instead of submitting them here.
handles_espn_date_ranges = True
def __init__(self, cache_manager: CacheManager, max_workers: int = 3, request_timeout: int = 30):
"""
Initialize the background data service.
@@ -247,6 +260,12 @@ class BackgroundDataService:
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
# college-football teams, so only scoreboards are clamped.
if url.split('?', 1)[0].rstrip('/').endswith('/scoreboard'):
params = clamp_espn_limit(params)
# Create fetch request
request = FetchRequest(
id=request_id,
@@ -254,7 +273,7 @@ class BackgroundDataService:
year=year,
cache_key=cache_key,
url=url,
params=params or {},
params=dict(params or {}),
headers={**self.default_headers, **(headers or {})},
timeout=timeout or self.request_timeout,
max_retries=max_retries,
@@ -338,12 +357,38 @@ class BackgroundDataService:
logger.info(f"Starting background fetch for {request.sport} {request.year}")
# Perform HTTP request with retry logic
response = self._make_request_with_retry(request)
response.raise_for_status()
# Parse response
data = response.json()
# ESPN stopped accepting dates=YYYYMMDD-YYYYMMDD on 2026-09-15 and
# answers 400 for every sport. Re-ask in months and days rather
# than let a whole season fail. See src/common/espn_dates.py.
# The "ranges are rejected" memo is shared with
# fetch_espn_scoreboard(): once either path has seen a range
# rejected, the other skips the doomed range request too.
is_range = parse_espn_date_range(request.params.get("dates")) is not None
data = None
chunks_tried = False
if is_range and _ranges_known_rejected():
data = self._fetch_in_date_chunks(request)
# Every chunk failed: ask for the range itself below so the
# failure carries a real HTTP error, without re-spending chunks.
chunks_tried = data is None
if data is None:
# Perform HTTP request with retry logic
response = self._make_request_with_retry(request)
if is_range and response.status_code == 400 and not chunks_tried:
_note_range_rejected()
logger.warning(
"ESPN rejected the date range %s (400); fetching it as "
"month/day chunks, and fetching ranges that way for the "
"next %d hours",
request.params.get("dates"), RANGE_RETRY_SECONDS // 3600,
)
data = self._fetch_in_date_chunks(request)
if data is None:
response.raise_for_status()
else:
response.raise_for_status()
data = response.json()
# Validate data structure
if not isinstance(data, dict):
@@ -519,6 +564,23 @@ class BackgroundDataService:
"""
result.data = None
def _fetch_in_date_chunks(self, request: FetchRequest) -> Optional[Dict[str, Any]]:
"""Re-fetch a rejected ``YYYYMMDD-YYYYMMDD`` range as month/day chunks.
None means the request was not a day range, or every chunk failed; the
caller then re-raises the original 400 instead of caching an empty
season. See src/common/espn_dates.py.
"""
logger.info("Recovering %s %s from a rejected date range", request.sport, request.year)
return fetch_espn_date_chunks(
self.session,
request.url,
params=request.params,
headers=request.headers,
timeout=request.timeout,
logger=logger,
)
def _make_request_with_retry(self, request: FetchRequest) -> requests.Response:
"""
Make HTTP request with retry logic and exponential backoff.
+4 -1
View File
@@ -530,12 +530,15 @@ def _copy_file(src: Path, dst: Path) -> None:
os.chmod(tmp_path, existing_mode)
else:
shutil.copymode(src, tmp_path)
if existing_owner is not None:
if existing_owner is not None and hasattr(os, 'chown'):
# Replacing a file creates a new inode owned by whoever is running,
# which would silently move a root-owned config to the web user.
# Carry the previous owner across when the OS permits it — only
# root can hand a file to another user, so this is best-effort and
# a plain restore as the web user simply keeps its own ownership.
# os.chown does not exist on Windows (where st_uid/st_gid are just
# 0); looking it up there raises AttributeError, which no caller
# catches, so every restore over an existing file aborted.
try:
os.chown(tmp_path, existing_owner[0], existing_owner[1])
except (OSError, PermissionError):
+70 -36
View File
@@ -10,6 +10,7 @@ from typing import Dict, List
import requests
import logging
from datetime import datetime
from src.common.espn_dates import fetch_espn_scoreboard
class DataSource(ABC):
"""Abstract base class for data sources."""
@@ -71,10 +72,10 @@ class ESPNDataSource(DataSource):
now = datetime.now()
formatted_date = now.strftime("%Y%m%d")
url = f"{self.base_url}/{sport}/{league}/scoreboard"
response = self.session.get(url, params={"dates": formatted_date, "limit": 1000}, headers=self.get_headers(), timeout=15)
response.raise_for_status()
data = response.json()
data = fetch_espn_scoreboard(
self.session, url, params={"dates": formatted_date, "limit": 1000},
headers=self.get_headers(), timeout=15, logger=self.logger,
)
events = data.get('events', [])
# Filter for live games
@@ -99,10 +100,10 @@ class ESPNDataSource(DataSource):
"limit": 1000
}
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
response.raise_for_status()
data = response.json()
data = fetch_espn_scoreboard(
self.session, url, params=params,
headers=self.get_headers(), timeout=15, logger=self.logger,
)
events = data.get('events', [])
self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}")
@@ -113,35 +114,68 @@ class ESPNDataSource(DataSource):
return []
def fetch_standings(self, sport: str, league: str) -> Dict:
"""Fetch standings from ESPN API."""
# Try standings endpoint first (for professional leagues like NFL, NBA, etc.)
try:
url = f"{self.base_url}/{sport}/{league}/standings"
response = self.session.get(url, headers=self.get_headers(), timeout=15)
response.raise_for_status()
data = response.json()
self.logger.debug(f"Fetched standings for {sport}/{league}")
"""Fetch standings, or the poll for leagues that have one.
Order matters and used to be wrong. College leagues publish a poll at
/rankings and a records table at /standings; professional leagues have
only /standings. The old code tried /standings first and fell back to
/rankings only on a 404 -- but college /standings answers 200, so the
fallback never fired and college rankings came back empty forever.
Nothing failed; the AP rank badge simply never appeared, and anything
else keyed off rankings quietly did nothing.
A 200 that lacks the key is treated as a miss, so a league answering
both endpoints still ends up with whichever one actually carries a poll.
"""
league_name = (league or "").lower()
wants_poll = "college" in league_name or "ncaa" in league_name
endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"]
for endpoint in endpoints:
url = f"{self.base_url}/{sport}/{league}/{endpoint}"
# Only the request is guarded. Inspecting the payload happens
# below, outside the handler, so that a bug in this method cannot
# be mistaken for an endpoint that failed -- that mistake would
# silently drop rankings for a league that has them, which is the
# exact failure this function was written to fix.
try:
response = self.session.get(
url, headers=self.get_headers(), timeout=15
)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError) as e:
status = getattr(getattr(e, "response", None), "status_code", None)
# Only a 404 is routine -- it is how a league says "no poll
# here". Everything else is worth an error, and `status is
# None` covers the ones that matter most: ConnectionError,
# Timeout, a body that would not parse. Silencing those left a
# board that could not reach ESPN with one debug line, and the
# ranked filter running on an empty table.
if status != 404:
self.logger.error(
f"Error fetching {endpoint} from ESPN for "
f"{sport}/{league}: {e}"
)
continue
if not isinstance(data, dict):
# A list or a bare string is not something the callers can
# read. Treat it as a miss so the other endpoint still gets a
# turn, but say so -- this means ESPN changed shape.
self.logger.error(
f"Unexpected {endpoint} payload for {sport}/{league}: "
f"got {type(data).__name__}, expected an object"
)
continue
if endpoint == "rankings" and not data.get("rankings"):
continue
self.logger.debug(f"Fetched {endpoint} for {sport}/{league}")
return data
except Exception as e:
# If standings doesn't exist, try rankings (for college sports)
if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 404:
try:
url = f"{self.base_url}/{sport}/{league}/rankings"
response = self.session.get(url, headers=self.get_headers(), timeout=15)
response.raise_for_status()
data = response.json()
self.logger.debug(f"Fetched rankings for {sport}/{league}")
return data
except Exception:
# Both endpoints failed - standings/rankings may not be available for this sport/league
self.logger.debug(f"Standings/rankings not available for {sport}/{league} from ESPN API")
return {}
else:
# Non-404 error - log at debug level since standings are optional
self.logger.debug(f"Error fetching standings from ESPN for {sport}/{league}: {e}")
return {}
self.logger.debug(
f"Standings/rankings not available for {sport}/{league} from ESPN API"
)
return {}
class MLBAPIDataSource(DataSource):
+10 -6
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import pytz
from src.common.espn_dates import fetch_espn_scoreboard
import requests
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
@@ -844,9 +845,11 @@ class SportsCore(ABC):
formatted_date_yesterday = yesterday.strftime("%Y%m%d")
# Fetch todays games only
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(url, params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10)
response.raise_for_status()
data = response.json()
data = fetch_espn_scoreboard(
self.session, url,
params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000},
headers=self.headers, timeout=10, logger=self.logger,
)
events = data.get('events', [])
self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}")
@@ -869,9 +872,10 @@ class SportsCore(ABC):
end_date = now + timedelta(weeks=1)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10)
response.raise_for_status()
data = response.json()
data = fetch_espn_scoreboard(
self.session, url, params={"dates": date_str, "limit": 1000},
headers=self.headers, timeout=10, logger=self.logger,
)
immediate_events = data.get('events', [])
if immediate_events:
+132 -15
View File
@@ -7,6 +7,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
import json
import math
import os
import stat
import time
import tempfile
import logging
@@ -139,6 +140,65 @@ else:
return json.loads(raw)
# SHARING CACHE FILES BETWEEN THE TWO SERVICES
# --------------------------------------------
# The display service runs as root and the web interface as the installing
# user, and the web interface reads records only the display writes
# (display_current_state, display_on_demand_state, plugin_metrics:*). Files are
# written 0660, so the web interface can read one only through its group.
#
# The installers rely on the directory's setgid bit to set that group. That is
# not something the cache can count on: systemd's CacheDirectory=, which
# ledmatrix-web.service carried until Sept 2026, re-owns the directory and
# everything in it to the web user and its primary group whenever the
# directory's owner does not match, and the setgid layout never survives that.
# From then on every file root creates is root:root 0660, unreadable by the web
# interface. Measured on one rig: 365 such files, and the web UI's display
# status, on-demand state and plugin health all silently empty.
#
# So a cache file takes its group from the directory explicitly, whether or
# not setgid is set. Only a group-writable directory counts as shared: that
# group can already replace any file in it, so reading them grants nothing new.
#
# Everything here works on an open descriptor, never a path. The directory is
# writable by the web user, so between a path check and a path operation that
# user could put a symlink in the file's place, and root would then chown and
# chmod whatever it points at.
_CACHE_FILE_MODE = 0o660
def _shared_group(directory: str) -> Optional[int]:
"""The group a cache file in ``directory`` should carry, if it is shared."""
try:
st = os.stat(directory)
except OSError:
return None
if not st.st_mode & stat.S_IWGRP:
return None
return st.st_gid
def _share_open_file(fd: int, group: Optional[int]) -> None:
"""Make an open cache file readable by the other service. Best effort."""
fchmod = getattr(os, 'fchmod', None) # absent on Windows before 3.13
if fchmod is not None:
try:
fchmod(fd, _CACHE_FILE_MODE)
except OSError:
pass
fchown = getattr(os, 'fchown', None) # absent on Windows
if fchown is None or group is None:
return
try:
if os.fstat(fd).st_gid != group:
fchown(fd, -1, group)
except OSError:
# Not a member of the directory's group and not root: nothing to do,
# and the file keeps the group it was created with.
pass
class DiskCache:
"""Manages persistent disk-based cache."""
@@ -350,13 +410,12 @@ class DiskCache:
try:
with os.fdopen(fd, 'wb') as tmp_file:
tmp_file.write(payload)
# Before the rename, not after: mkstemp
# creates the file 0600, and a reader that
# 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
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
try:
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
except OSError:
pass # Non-critical if chmod fails
finally:
if os.path.exists(tmp_path):
try:
@@ -368,12 +427,8 @@ class DiskCache:
try:
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
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
try:
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
except OSError:
pass # Non-critical if chmod fails
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
@@ -398,11 +453,7 @@ class DiskCache:
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
with open(fallback_path, 'wb') as tmp_file:
tmp_file.write(payload)
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
try:
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
except OSError:
pass # Non-critical if chmod fails
_share_open_file(tmp_file.fileno(), _shared_group(fallback_dir))
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
return # Successfully wrote to fallback, exit gracefully
except (IOError, OSError, PermissionError) as e2:
@@ -459,6 +510,72 @@ class DiskCache:
def get_cache_dir(self) -> Optional[str]:
"""Get the cache directory path."""
return self.cache_dir
def share_existing_files(self) -> int:
"""Give cache files already on disk the group set() now gives new ones.
set() fixes every file it writes from now on; this repairs the ones an
older version left behind as root:root, which the web interface cannot
read until each key happens to be rewritten -- and some, like a
plugin's metrics, may not be for a long time. Meant to run once per
process, off the startup path.
Only this process's own regular files are touched, and each one through
a descriptor opened with O_NOFOLLOW and checked for a single link: the
directory is writable by the web user, and a root process must not be
steered into changing a file outside it.
Returns:
Number of files whose group or mode was changed.
"""
fchown = getattr(os, 'fchown', None)
geteuid = getattr(os, 'geteuid', None)
nofollow = getattr(os, 'O_NOFOLLOW', None)
if not self.cache_dir or fchown is None or geteuid is None or nofollow is None:
return 0
group = _shared_group(self.cache_dir)
if group is None:
return 0
euid = geteuid()
changed = 0
try:
entries = list(os.scandir(self.cache_dir))
except OSError as e:
self.logger.debug("Could not scan %s to share cache files: %s", self.cache_dir, e)
return 0
for entry in entries:
if not entry.name.endswith('.json'):
continue
try:
st = entry.stat(follow_symlinks=False)
except OSError:
continue
if (not stat.S_ISREG(st.st_mode) or st.st_uid != euid
or (st.st_gid == group and stat.S_IMODE(st.st_mode) == _CACHE_FILE_MODE)):
continue
try:
fd = os.open(entry.path, os.O_RDONLY | nofollow | getattr(os, 'O_NONBLOCK', 0))
except OSError:
continue
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_uid != euid or st.st_nlink != 1:
continue
_share_open_file(fd, group)
st = os.fstat(fd)
if st.st_gid == group and stat.S_IMODE(st.st_mode) == _CACHE_FILE_MODE:
changed += 1
except OSError:
continue
finally:
os.close(fd)
if changed:
self.logger.info(
"Made %d cache file(s) in %s readable by the directory's group "
"(gid %d) so the web interface can read them",
changed, self.cache_dir, group)
return changed
@staticmethod
def _is_orphaned_temp(filename: str) -> bool:
+8
View File
@@ -762,6 +762,14 @@ class CacheManager:
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
self._disk_cleanup_interval_hours)
# Repair files an older version wrote unreadable by the web
# interface (see disk_cache.py, "SHARING CACHE FILES"). Once per
# directory per process, which is what this thread already is.
try:
self._disk_cache_component.share_existing_files()
except Exception as e:
self.logger.error("Error sharing existing cache files: %s", e, exc_info=True)
# Run initial cleanup on startup (deferred from __init__ to avoid blocking)
try:
self.logger.debug("Running initial disk cache cleanup")
+4 -1
View File
@@ -8,6 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
import logging
import time
from datetime import datetime
from src.common.espn_dates import ESPN_MAX_LIMIT
from typing import Any, Dict, Optional
import requests
@@ -153,9 +154,11 @@ class APIHelper:
cache_key = f"espn_{sport}_{league}_{date}"
# Set parameters
# limit above 500 makes ESPN truncate instead of erroring: college
# football came back with 25 of 68 games. See src/common/espn_dates.py.
params = {
'dates': date,
'limit': 1000
'limit': ESPN_MAX_LIMIT
}
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
+374
View File
@@ -0,0 +1,374 @@
"""Working around two ESPN site-API behaviours that silently break scoreboards.
Both were found on 2026-09-15, when every scoreboard on the Pi started logging
``400 Client Error: Bad Request`` against URLs that had worked the day before.
1. **Date ranges are rejected.** ``?dates=YYYYMMDD-YYYYMMDD`` answers
``400 {"code":400,"message":"Failed to get events endpoint."}`` for *every*
sport -- football, baseball, hockey, basketball, soccer alike. Single days
(``?dates=YYYYMMDD``), whole months (``?dates=YYYYMM``) and season years
(``?dates=YYYY``) still answer 200.
2. **``limit`` above 500 corrupts the response.** ``limit=1000`` -- what every
caller in this codebase used to send -- makes college-football return 25
events where the truthful answer is 68 for a single Saturday and 323 for a
month. No error, just a short list. The cutoff sits between 500 and 600.
NFL-sized days never noticed, which is why this hid for so long.
The fix for (1) is to re-ask in units ESPN still honours. Whole calendar months
covered by the range become one ``YYYYMM`` request each and the leftover days at
either end become one ``YYYYMMDD`` request each, so the chunks cover the
requested window *exactly* -- no client-side date filtering, and therefore no
guessing at which timezone ESPN means by "a game day". A full NFL season
(20260801-20270301) costs 8 requests rather than 213 per-day ones.
A month can hold more than 500 events (college baseball's March does), and
ESPN answers that with exactly ``limit`` events and no hint that more exist. A
month chunk that comes back full is therefore re-asked day by day.
Once a range has been rejected, later ranges skip straight to chunks for
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
scoreboards ask every 30 seconds. After that the range is tried again, so the
workaround retires itself if ESPN reverts.
"""
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date, timedelta
from functools import partial
from typing import Any, Dict, List, Optional, Tuple
# Above this, ESPN returns a truncated list instead of an error. See module
# docstring: 500 is the largest value measured to return complete data.
ESPN_MAX_LIMIT = 500
# How long a rejected range keeps later ranges from being tried as ranges.
RANGE_RETRY_SECONDS = 6 * 60 * 60
# How many chunk requests may be in flight at once. Four busy months of
# college baseball are ~130 chunks once each is re-asked day by day: 17.7s one
# at a time on a Pi 4, 2.6-3.3s six at a time. Kept under requests' default
# pool_maxsize of 10 so the shared Session never has to discard connections.
ESPN_CHUNK_WORKERS = 6
_range_lock = threading.Lock()
_ranges_rejected_until = 0.0
__all__ = [
"ESPN_MAX_LIMIT",
"ESPN_CHUNK_WORKERS",
"RANGE_RETRY_SECONDS",
"clamp_espn_limit",
"parse_espn_date_range",
"espn_date_chunks",
"merge_scoreboard_payloads",
"fetch_espn_date_chunks",
"fetch_espn_scoreboard",
]
def clamp_espn_limit(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Return a copy of ``params`` with any ``limit`` over 500 pulled back to 500."""
out = dict(params or {})
raw = out.get("limit")
if raw is None:
return out
try:
value = int(raw)
except (TypeError, ValueError):
return out
if value > ESPN_MAX_LIMIT:
out["limit"] = ESPN_MAX_LIMIT
return out
def parse_espn_date_range(dates: Any) -> Optional[Tuple[date, date]]:
"""Parse ``"YYYYMMDD-YYYYMMDD"`` into dates, or return None.
None means "not a day range" -- a single day, a month, a season year, or
anything unparseable. Those forms still work upstream and must be passed
through untouched rather than rewritten.
"""
if not isinstance(dates, str):
return None
halves = dates.split("-")
if len(halves) != 2 or len(halves[0]) != 8 or len(halves[1]) != 8:
return None
try:
start = date(int(halves[0][:4]), int(halves[0][4:6]), int(halves[0][6:]))
end = date(int(halves[1][:4]), int(halves[1][4:6]), int(halves[1][6:]))
except ValueError:
return None
if end < start:
return None
return start, end
def _ranges_known_rejected() -> bool:
with _range_lock:
return time.monotonic() < _ranges_rejected_until
def _note_range_rejected() -> None:
global _ranges_rejected_until
with _range_lock:
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
def _first_of_next_month(day: date) -> date:
return date(day.year + (day.month == 12), day.month % 12 + 1, 1)
def _days_of_month(chunk: str) -> List[str]:
day = date(int(chunk[:4]), int(chunk[4:6]), 1)
stop = _first_of_next_month(day)
days = []
while day < stop:
days.append(day.strftime("%Y%m%d"))
day += timedelta(days=1)
return days
def espn_date_chunks(start: date, end: date) -> List[str]:
"""Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts.
Whole calendar months inside the window collapse to one ``YYYYMM`` chunk;
partial months at the edges are spelled out day by day. The chunks tile the
window exactly -- they never reach outside it -- so merging their events
needs no date filtering afterwards.
"""
chunks: List[str] = []
cursor = start
while cursor <= end:
month_end = _first_of_next_month(cursor) - timedelta(days=1)
if cursor.day == 1 and month_end <= end:
chunks.append(cursor.strftime("%Y%m"))
cursor = month_end + timedelta(days=1)
else:
chunks.append(cursor.strftime("%Y%m%d"))
cursor += timedelta(days=1)
return chunks
def merge_scoreboard_payloads(payloads: List[Dict[str, 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
(``leagues``, ``season``, ``week``) come from the first payload that has
them, matching what a single un-chunked response would have looked like.
"""
merged: Dict[str, Any] = {}
events: List[Dict[str, Any]] = []
seen = set()
for payload in payloads:
if not isinstance(payload, dict):
continue
for key, value in payload.items():
if key != "events" and key not in merged:
merged[key] = value
for event in payload.get("events") or []:
event_id = event.get("id") if isinstance(event, dict) else None
if event_id is not None:
if event_id in seen:
continue
seen.add(event_id)
events.append(event)
merged["events"] = events
return merged
def _fetch_one_chunk(
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
) -> Optional[Dict[str, Any]]:
"""GET a single ``dates=`` chunk, or None when it failed.
One bad chunk must not sink the rest of the season, so every error is
logged and swallowed here rather than raised to the gather below.
"""
try:
response = session.get(
url,
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
headers=headers,
timeout=timeout,
)
response.raise_for_status()
return response.json()
except Exception as exc: # noqa: BLE001 - see docstring
if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
return None
def _fetch_chunks(
session, url: str, params: Dict[str, Any], headers, timeout, logger,
chunks: List[str],
) -> List[Optional[Dict[str, Any]]]:
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
over a hundred of them. The order they come back in is not significant --
callers keep ``chunks`` order from the returned list -- but it does mean
the session is shared across threads, which is why this only ever issues
GETs and never touches session state.
"""
if not chunks:
return []
fetch = partial(
_fetch_one_chunk, session, url, params, headers, timeout, logger,
)
if len(chunks) == 1:
return [fetch(chunks[0])]
workers = min(ESPN_CHUNK_WORKERS, len(chunks))
with ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="espn-chunk",
) as pool:
return list(pool.map(fetch, chunks))
def fetch_espn_date_chunks(
session,
url: str,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
timeout: int = 15,
logger=None,
) -> Optional[Dict[str, Any]]:
"""Fetch a ``YYYYMMDD-YYYYMMDD`` window as month and day chunks.
Returns None when ``params["dates"]`` is not a day range, or when every
chunk failed. Callers treat None as "re-raise the original error": caching
an empty payload would read as "no games this season".
Chunks are always asked with ``limit=500``. ESPN's default page is smaller
than a busy month (100 for NFL, 300 for college football), and 500 is the
largest value that does not corrupt the answer. A month that comes back
with 500 events is assumed truncated and re-asked day by day. A failed
chunk is logged and skipped so one bad day cannot cost a whole season.
Chunks go out ``ESPN_CHUNK_WORKERS`` at a time, in two passes: the months
and edge days first, then the days of any month that came back capped.
Merged events keep ``espn_date_chunks`` order regardless of which request
finished first, so the result does not depend on the race.
"""
params = dict(params or {})
span = parse_espn_date_range(params.get("dates"))
if span is None:
return None
chunks = espn_date_chunks(*span)
if logger:
logger.debug(
"Fetching ESPN date range %s as %d month/day chunks",
params.get("dates"), len(chunks),
)
results = _fetch_chunks(
session, url, params, headers, timeout, logger, chunks,
)
attempted = len(chunks)
# A month that came back at the cap is truncated; its days replace it in
# place, so merged events stay in chunk order however the requests raced.
slots: List[Any] = results
capped: Dict[int, List[str]] = {}
for index, chunk in enumerate(chunks):
payload = slots[index]
if payload is None or len(chunk) != 6:
continue
events = payload.get("events") if isinstance(payload, dict) else None
if len(events or []) >= ESPN_MAX_LIMIT:
if logger:
logger.info(
"ESPN month %s hit the %d-event cap; re-asking it day by day",
chunk, ESPN_MAX_LIMIT,
)
capped[index] = _days_of_month(chunk)
# Drop the truncated month now rather than after its days arrive:
# a capped college-baseball month is ~2MB of parsed JSON, and
# holding four of them through ~120 day requests added ~25MB to
# the peak -- more than the concurrency itself. Low-memory boards
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
slots[index] = None
payload = events = None
if capped:
days = [day for index in sorted(capped) for day in capped[index]]
attempted += len(days)
by_day = dict(zip(days, _fetch_chunks(
session, url, params, headers, timeout, logger, days,
)))
for index, month_days in capped.items():
slots[index] = [by_day.get(day) for day in month_days]
payloads: List[Dict[str, Any]] = []
for slot in slots:
if slot is None:
continue
if isinstance(slot, list):
payloads.extend(payload for payload in slot if payload is not None)
else:
payloads.append(slot)
if not payloads:
return None
merged = merge_scoreboard_payloads(payloads)
if logger:
logger.debug(
"Recovered %d events for %s from %d/%d chunk requests",
len(merged["events"]), params.get("dates"), len(payloads), attempted,
)
return merged
def fetch_espn_scoreboard(
session,
url: str,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
timeout: int = 15,
logger=None,
) -> Dict[str, Any]:
"""GET an ESPN scoreboard, re-asking in month/day chunks if a range 400s.
Anything that is not a ``YYYYMMDD-YYYYMMDD`` range is one request with the
caller's own parameters (``limit`` clamped), so single-day and season-year
callers see no change. A range that ESPN rejects is re-fetched in chunks,
and later ranges go straight to chunks for ``RANGE_RETRY_SECONDS``. A 400 on
a non-range request, any other error, and a range whose every chunk fails
all raise as before.
"""
params = clamp_espn_limit(params)
is_range = parse_espn_date_range(params.get("dates")) is not None
chunks_tried = False
if is_range and _ranges_known_rejected():
data = fetch_espn_date_chunks(
session, url, params=params, headers=headers,
timeout=timeout, logger=logger,
)
if data is not None:
return data
# Every chunk failed: ask for the range itself so the caller gets a
# real error to log, without spending the chunks a second time.
chunks_tried = True
response = session.get(url, params=params, headers=headers, timeout=timeout)
if is_range and response.status_code == 400 and not chunks_tried:
_note_range_rejected()
if logger:
logger.warning(
"ESPN rejected the date range %s (400); fetching it as month/day "
"chunks, and fetching ranges that way for the next %d hours",
params.get("dates"), RANGE_RETRY_SECONDS // 3600,
)
data = fetch_espn_date_chunks(
session, url, params=params, headers=headers,
timeout=timeout, logger=logger,
)
if data is not None:
return data
response.raise_for_status()
return response.json()
+19 -4
View File
@@ -186,7 +186,17 @@ def ensure_shared_group_ownership(path: Path) -> None:
is group-readable, but without this the group is root's, not the web
user's. Silently does nothing if not running as root or on any error —
this is a hardening step, not a required one.
``os.geteuid``/``os.chown`` only exist on POSIX. On Windows there is no
root and no shared group to move the file to, so the whole step is moot —
but looking the names up unguarded raises ``AttributeError``, which is not
an ``OSError`` and so escapes every caller's error handling. That took
``ConfigManager.load_config()`` down on any Windows checkout that has a
``config/config_secrets.json``, i.e. every developer machine that has ever
run the app, and with it the import of ``web_interface.app``.
"""
if not hasattr(os, 'geteuid') or not hasattr(os, 'chown'):
return
if os.geteuid() != 0:
return
gid = get_shared_group_gid()
@@ -363,6 +373,14 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
return False
#: What sudo prints when it refuses a command line outright (not in sudoers for
#: that exact argv, or it wants a password). Only then is another bash path
#: worth trying: after pip itself ran, a retry just repeats the failure. The
#: automatic update's health check keeps its own copy of this list
#: (scripts/utils/auto_update_verify.py runs without importing src/).
SUDO_REFUSAL_PHRASES = ("a password is required", "is not allowed to run", "no tty present")
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
"""
Install a requirements.txt file for a plugin (or the project itself).
@@ -424,10 +442,7 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
# Distinguish "sudo rejected this exact command line" (worth
# trying the next bash candidate) from "sudo ran it but pip
# itself failed" (a real error — stop and surface it).
denied = any(
phrase in result.stderr
for phrase in ("a password is required", "is not allowed to run", "no tty present")
)
denied = any(phrase in result.stderr for phrase in SUDO_REFUSAL_PHRASES)
if not denied:
# Deliberately don't interpolate req_file or the pip output here:
# this log line is scanner-visible, and a static analyzer can't
+33 -13
View File
@@ -22,12 +22,27 @@ shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
the requested speed will not divide evenly, because that is a real display
artefact and not a rounding detail.
Speed is always expressed to the helper as pixels per second and applied in
time-based mode. Frame-based stepping gates motion on a wall clock at
``1/scroll_delay`` steps per second; plugins set ``scroll_delay`` to the frame
period, which puts that comparison exactly on its own threshold and makes the
step count flip on sub-millisecond jitter. Accumulating elapsed time keeps
position proportional to real time instead.
How the speed is applied
------------------------
:func:`configure` snaps the requested speed to a :class:`CrispSpeed` -- a whole
number of pixels per presented frame, with each frame held for ``frame_hold``
panel refreshes -- and puts the helper in fixed-step mode
(``ScrollHelper.set_pixels_per_frame``). In that mode the helper consults no
clock: every ``update_scroll_position()`` call moves exactly that many pixels.
``SwapOnVSync`` blocks until the panel has taken each frame, so the frame count
is the clock, and the speed on the panel is::
px/s = refresh_hz / frame_hold * pixels_per_frame
That makes the hold part of the speed. The caller must pass
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True, ...)``;
a caller that omits it is presented every refresh and scrolls ``frame_hold``
times too fast. The refresh is the panel's (``display_manager.refresh_hz``,
i.e. ``display.hardware.limit_refresh_rate_hz``) -- never the global
``target_fps``, which no longer paces anything.
With ``snap_to_crisp=False`` there is no fixed step: the helper is set to
px/s and advances by elapsed time, and the hold is 1.
"""
from __future__ import annotations
@@ -330,17 +345,18 @@ def configure(
) -> ScrollSettings:
"""Resolve the config and apply it to ``scroll_helper``.
Applied in time-based mode: see the module docstring for why frame-based
stepping is not used. ``hasattr`` guards keep this usable against older
ScrollHelper builds that a plugin may be running on.
Frame-based mode is switched off, the (snapped) speed is set, and with
``snap_to_crisp`` the helper steps a fixed whole-pixel amount per presented
frame -- see the module docstring. ``hasattr`` guards keep this usable
against older ScrollHelper builds that a plugin may be running on.
:param display_manager: consulted for the panel's refresh rate only (it can
see display.hardware; a plugin cannot). The frame hold is NOT applied
here -- see the note in the body. The caller must pass
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
...)`` when it starts scrolling, or a sub-refresh speed still presents
a new frame every refresh and the motion falls back to fractional
pixels.
...)`` when it scrolls. The helper moves its fixed step on every call,
so without the hold each step is presented every refresh and the
scroll runs ``frame_hold`` times faster than the resolved speed.
:param snap_to_crisp: move the requested speed to the nearest speed the
panel can show in whole pixels. On by default because a speed that does
not divide evenly has no good rendering, only a choice of artefacts.
@@ -357,7 +373,8 @@ def configure(
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
# so deriving it afterwards described a 100Hz panel to everyone running at
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
# set_target_fps() paced the helper to 100 FPS on a 60Hz panel.
# the settings and the helper's (informational) target_fps said 100 FPS on
# a 60Hz panel.
hz = _coerce(refresh_hz)
if hz is None and display_manager is not None:
hz = _coerce(getattr(display_manager, "refresh_hz", None))
@@ -399,6 +416,9 @@ def configure(
if hasattr(scroll_helper, "set_pixels_per_frame"):
scroll_helper.set_pixels_per_frame(
choice.pixels_per_frame if choice else None)
# Informational only: nothing in the helper paces off target_fps. It is
# still recorded because plugins read it back (ledmatrix-elections'
# test_scroll_pacing.py asserts it equals the crisp presentation rate).
if choice and hasattr(scroll_helper, "set_target_fps"):
scroll_helper.set_target_fps(choice.frames_per_second)
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
+60 -44
View File
@@ -22,13 +22,6 @@ from typing import Optional, Dict, Any
from PIL import Image
import numpy as np
# Try to import scipy for sub-pixel interpolation, fallback to simpler method if not available
try:
from scipy.ndimage import shift
HAS_SCIPY = True
except ImportError:
HAS_SCIPY = False
# How often the frame-stats line is emitted, and therefore also the ceiling
# on a believable frame time: a scroll that renders at all cannot take this
@@ -138,21 +131,22 @@ class ScrollHelper:
# blend (blur) or repeat frames (judder); blending is the worse of the
# two here. Vegas mode still opts in via set_sub_pixel_scrolling().
self.sub_pixel_scrolling = False
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
# Frame-based scrolling settings
self.frame_based_scrolling = False
#: Whole pixels to advance per presented frame, or None to pace
#: off elapsed time. See set_pixels_per_frame.
self.fixed_pixels_per_frame = None # If True, use scroll_delay to throttle and move scroll_speed pixels
self.last_step_time = 0.0 # Track last step time for frame-based throttling
self.fixed_pixels_per_frame = None
self.last_step_time = 0.0 # Time of the last position update
# Time tracking for scroll updates
self.last_update_time: Optional[float] = None
# High FPS settings
self.target_fps = 120 # Target 120 FPS for smooth scrolling
self.frame_time_target = 1.0 / self.target_fps
#: Informational only: the presentation rate scroll_config chose
#: (panel refresh / frame hold). Nothing paces off it -- the helper
#: steps per call and SwapOnVSync paces the calls. Kept because
#: plugins and their tests read it back.
self.target_fps = 120
# Dynamic duration settings
self.dynamic_duration_enabled = True
@@ -288,7 +282,14 @@ class ScrollHelper:
def update_scroll_position(self) -> None:
"""
Update scroll position with high FPS control and handle wrap-around.
Advance the scroll by one presented frame and handle wrap-around.
With a fixed per-frame step (set_pixels_per_frame, which
scroll_config.configure sets for a crisp speed) every call moves
exactly that many pixels and no clock is read; the caller's
vsync-blocking swap, held for ``frame_hold`` refreshes, sets the rate.
Otherwise the position advances by elapsed time at the configured
speed.
"""
if not self.cached_image:
return
@@ -461,10 +462,9 @@ class ScrollHelper:
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
installed on the target devices (HAS_SCIPY is False there), which is why
the pre-existing sub-pixel path was dead code — get_visible_portion never
consulted the flag, and the scipy fallback would not have interpolated
anyway.
installed on the target devices, and the old scipy-based sub-pixel path
was dead code -- get_visible_portion never consulted the flag. The scipy
import was removed with it; installing scipy has no effect.
Args:
start_x: Left column of the earlier of the two frames
@@ -830,11 +830,13 @@ class ScrollHelper:
def set_scroll_speed(self, speed: float) -> None:
"""
Set the scroll speed.
In time-based mode: pixels per second (typically 10-200)
In frame-based mode: pixels per frame (typically 0.5-5 for smooth scrolling)
Set the scroll speed, and leave fixed-step mode.
In time-based mode: pixels per second (clamped to 1-500).
In frame-based mode: pixels per ``scroll_delay`` seconds (clamped to
0.1-5), still applied by elapsed time as scroll_speed / scroll_delay
px/s.
Args:
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
"""
@@ -896,14 +898,19 @@ class ScrollHelper:
def set_target_fps(self, fps: float) -> None:
"""
Set the target frames per second for scrolling.
Record the presentation rate, for diagnostics only.
Nothing paces off this value: with a fixed per-frame step the helper
advances once per call, and without one it advances by elapsed time.
The rate frames are shown at is the panel refresh divided by the frame
hold passed to ``display_manager.set_scrolling_state``. scroll_config
sets it to that rate so it can be read back.
Args:
fps: Target FPS (typically 30-200, default 120)
fps: Frames per second (clamped to 30-200)
"""
self.target_fps = max(30.0, min(200.0, fps))
self.frame_time_target = 1.0 / self.target_fps
self.logger.debug(f"Target FPS set to: {self.target_fps} FPS (frame_time_target: {self.frame_time_target:.4f}s)")
self.logger.debug("Target FPS recorded: %s FPS (informational)", self.target_fps)
def set_sub_pixel_scrolling(self, enabled: bool) -> None:
"""
@@ -914,7 +921,7 @@ class ScrollHelper:
When disabled, uses integer pixel positioning (faster but may skip pixels).
Args:
enabled: True to enable sub-pixel scrolling (default: True)
enabled: True to enable sub-pixel scrolling (default: False)
"""
self.sub_pixel_scrolling = enabled
self.logger.debug(f"Sub-pixel scrolling {'enabled' if enabled else 'disabled'}")
@@ -923,10 +930,12 @@ class ScrollHelper:
"""
Enable or disable frame-based scrolling.
When enabled, update_scroll_position() respects scroll_delay and moves
scroll_speed pixels per step. This provides a "stepped" look similar to
traditional tickers and can be visually smoother on LED matrices.
This does not step. When enabled, ``scroll_speed`` is read as pixels
per ``scroll_delay`` seconds (set_scroll_speed clamps it to 0.1-5), and
update_scroll_position() still advances by elapsed time at
``scroll_speed / scroll_delay`` px/s. A fixed per-frame step set by
set_pixels_per_frame() takes precedence over both modes.
Args:
enabled: True to enable frame-based scrolling (default: False)
"""
@@ -985,11 +994,7 @@ class ScrollHelper:
# the real stall rates being measured, so the number could not be
# trusted at all. Seed the clock and take no sample.
if self.last_frame_time is None:
self.last_frame_time = current_time
# Restart the window with the scroll. Otherwise the boundary is
# already long overdue when the second frame arrives, and the new
# scroll opens by reporting a window of exactly one frame.
self.last_fps_log_time = current_time
self._restart_stats_window(current_time)
return
# Calculate instantaneous frame time
@@ -998,9 +1003,10 @@ class ScrollHelper:
# A caller that scrolls without ever calling reset_scroll() never arms
# the sentinel above, so catch the same gap by its size. Nothing that
# renders a scroll produces a frame longer than the log interval; a
# sample that large is an idle period, not a frame.
# sample that large is an idle period, not a frame. It starts a new
# scroll exactly as the sentinel does, window timer included.
if frame_time >= FPS_LOG_INTERVAL:
self.last_frame_time = current_time
self._restart_stats_window(current_time)
return
self.frame_times.append(frame_time)
@@ -1034,7 +1040,17 @@ class ScrollHelper:
self.last_frame_time = current_time
self.frame_count += 1
def _restart_stats_window(self, current_time: float) -> None:
"""Seed the frame clock at the start of a scroll, taking no sample.
The window timer restarts with it. Otherwise the 5s boundary is
already long overdue when the next frame arrives, and the new scroll
opens by reporting a window of exactly one frame.
"""
self.last_frame_time = current_time
self.last_fps_log_time = current_time
def clear_cache(self) -> None:
"""
Clear the cached scrolling image.
+408
View File
@@ -0,0 +1,408 @@
"""Sports helpers every scoreboard's ``sports.py`` carries an identical copy of.
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
``src/common``. The bodies below were copied from those plugin copies -- not
from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main,
2026-09-14):
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
(with ``_MIN_WINDOW_DAYS`` / ``_MAX_WINDOW_DAYS``), and the ``SportsCore``
methods ``_mode_customization``, ``_setting_int``,
``_reset_dwell_on_reentry`` (with ``_DWELL_REENTRY_GAP_SECONDS``),
``_next_switch_index`` and ``_spread_weighted_order``.
- In the eight that share a lineage (all but ufc, which has neither):
``_odds_color`` and ``_upcoming_date_and_time_text``.
The module-level helpers become public free functions here (``clamp_window``,
``clamp_seconds``, ``logo_needs_refresh``, ``spread_weighted_order``,
``MIN_WINDOW_DAYS``, ``MAX_WINDOW_DAYS``); the methods keep the plugins' exact
names and signatures on ``SportsHelpersMixin`` so adopting it is deleting the
copies and adding one base. ``test/test_sports_helpers.py`` compares every body
here against every plugin copy when a plugins checkout is available
(``LEDMATRIX_PLUGINS``), so drift in either direction fails a test.
WHY A NEW MODULE, NOT MORE METHODS ON sports_shared
---------------------------------------------------
A plugin that deletes its copy of a method and relies on an *existing* module
having grown it fails at runtime on an older core: ``AttributeError`` mid-render,
usually swallowed into a blank board. The plugin loader cannot see that, and
neither can the plugins' ``scripts/check_min_core_version.py``, which checks
modules. A plugin importing a module that does not exist yet fails at load,
where both can. Plugins import this and floor ``ledmatrix_min_version`` on the
first core release that ships it (see ``CHANGELOG.md``).
``_favorite_key`` is the one method not taken from the plugins: it is the
override point from ``src/base_classes/sports/core.py``, carried here so later
phases (shared celebrations and game selection) have a hardware-free home for
the seam. No plugin defines it today and nothing in this module calls it.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_helpers.py`` fails if a read is added without being
listed here.
- ``config`` (dict) -- ``_mode_customization``.
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
read with ``getattr`` for the warning text only.
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
``_is_favorite_game`` (called with a game), which stays per-plugin and is
only called when
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
defaults to 1 on the mixin.
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
and written back.
- ``_card_option``, ``_format_game_date`` and ``_format_game_time`` --
``_upcoming_date_and_time_text``. All three are on
``SportsCoreSharedMixin`` (``src/common/sports_shared.py``).
- ``_element_color`` -- ``_odds_color``, looked up with ``getattr`` and falling
back to green, so a host without it still works. Also on
``SportsCoreSharedMixin``.
- ``SKIN_MODE`` -- optional, read with ``getattr`` by ``_mode_customization``;
without it the mode overrides are simply not merged.
State the mixin keeps on the host, created on first use (all read with
``getattr``, so nothing needs initialising): ``_last_display_call_monotonic``,
``_switch_order``, ``_switch_order_key``, ``_switch_position``.
Class-level defaults: ``_DWELL_REENTRY_GAP_SECONDS = 5.0`` and
``favorite_rotation_boost = 1``, matching the plugins. A host's own value
shadows them.
MRO
---
No ``__init__`` and no bare ``super()``, so base order does not change what
runs. By convention list it after ``SportsCoreSharedMixin``::
class SportsCore(SportsCoreSharedMixin, SportsHelpersMixin, ABC):
HARDWARE-FREE
-------------
Nothing here may import ``src.display_manager``, ``src.base_classes``,
``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
exactly as the plugin copy does.
"""
from __future__ import annotations
import time
from typing import Any, ClassVar, Dict, List, Optional, Tuple
__all__ = [
"MIN_WINDOW_DAYS",
"MAX_WINDOW_DAYS",
"clamp_window",
"clamp_seconds",
"logo_needs_refresh",
"spread_weighted_order",
"SportsHelpersMixin",
]
MIN_WINDOW_DAYS = 1
MAX_WINDOW_DAYS = 60
def clamp_window(value: Any, fallback: int) -> int:
"""Days for one side of the schedule window, or the default if unusable."""
try:
days = int(value)
except (TypeError, ValueError, OverflowError):
# OverflowError: json parses a bare Infinity and int(inf) raises,
# which crashed manager init from a hand-edited config.
return fallback
return max(MIN_WINDOW_DAYS, min(MAX_WINDOW_DAYS, days))
def clamp_seconds(value: Any, fallback: int, low: int = 5,
high: int = 86400) -> int:
"""An interval in seconds, or the fallback when the value is unusable."""
try:
seconds = int(value)
except (TypeError, ValueError, OverflowError):
# OverflowError: json parses bare Infinity by default and int(inf)
# raises -- the same gap clamp_window above already covers.
return fallback
return max(low, min(high, seconds))
def logo_needs_refresh(logo_file) -> bool:
"""True if this file is a placeholder stale enough to retry the real logo.
A failed logo download is cached as a placeholder wearing the real logo's
filename, so "the file exists" is not proof the logo was ever fetched.
Without this check one transient failure leaves a team a grey box forever.
Returns False on a core that predates placeholder marking, which keeps the
previous behaviour rather than breaking the load.
"""
# Imported from the core by its full path, never as a bare name: a
# deferred bare-name import can bind another plugin's vendored
# logo_downloader once the core isolates top-level plugin modules.
try:
from src.logo_downloader import (
PLACEHOLDER_RETRY_SECONDS,
is_placeholder_logo,
placeholder_age_seconds,
)
except ImportError:
return False
try:
if not is_placeholder_logo(logo_file):
return False
age = placeholder_age_seconds(logo_file)
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
except Exception:
return False
def spread_weighted_order(weights: List[int]) -> List[int]:
"""Indices into ``weights``, each repeated by its weight and spread out.
Each index keeps its own slot and places its extra turns at even
fractions of the rotation after it, wrapping round. That keeps the
list's schedule order for everything else and spaces a favourite's
repeats evenly *around the loop* -- the live rotation's smooth
weighted round-robin schedules a boosted game first and last, so a
rotation that wraps shows it back to back. Equal weights come back in
plain order, so a boost that applies to no card changes nothing.
Repeats are kept apart only where the ratio leaves room: once one
weight exceeds all the others combined, no cyclic order can separate
its turns ([3, 1, 1] gives [0, 1, 0, 2, 0]). Each index still gets
exactly its weight in turns -- the configured ratio wins over spacing.
"""
count = len(weights)
slots = []
for index, weight in enumerate(weights):
for turn in range(weight):
slots.append(((index + turn * count / weight) % count, turn > 0, index))
return [index for _, _, index in sorted(slots)]
class SportsHelpersMixin:
"""The ``SportsCore`` methods identical across the scoreboards' sports.py.
Constructor-free; keeps lazy state on its host (see the module docstring
for that state, the host contract and base-order guidance).
"""
#: Longest gap between two display() calls that still counts as one
#: on-screen stint. Frames arrive many times a second while a mode is on
#: the panel; between mode blocks the gap is the length of every other
#: mode's block -- a minute or more. Anything past a few seconds can only
#: be a block boundary, or the very first frame after startup.
_DWELL_REENTRY_GAP_SECONDS: ClassVar[float] = 5.0
#: Turns a favourite's card gets per turn of any other card in switch
#: mode. The plugins set it per instance from config; this default keeps
#: a host that does not on the plain rotation.
favorite_rotation_boost: int = 1
def _favorite_key(self, game: Dict, side: str) -> Optional[str]:
"""Override point: which view-model field identifies a team when
matching against ``favorite_teams``.
``side`` is ``"home"`` or ``"away"``. The default is the team
abbreviation -- what eight of the nine scoreboards match on, and what
users type into their favorites list.
NRL needs the team **id** instead, because NRL abbreviations are not
unique: "NEW" is both Newcastle Knights and New Zealand Warriors,
"CAN" both Canberra Raiders and Canterbury Bulldogs. It is a seam
rather than a branch so core never has to learn the string "nrl"::
def _favorite_key(self, game, side):
return str(game.get(f"{side}_id"))
An override that stringifies should note that a missing id becomes the
literal ``"None"``, which would spuriously match a favorites list
containing that string. The default returns ``None`` for a missing
abbreviation, which never matches.
Carried from ``src/base_classes/sports/core.py`` for later phases;
nothing in this module calls it yet.
"""
return game.get(f"{side}_abbr")
def _mode_customization(self) -> dict:
"""``customization`` with this mode's overrides merged over it.
SportsUpcoming / SportsRecent / SportsLive are separate instances
with their own SKIN_MODE, so merging once here makes every
per-element lookup mode-aware without changing one of them.
``None`` in a mode block means "inherit", which is what lets a user
restyle one element on live cards and leave everything else
following the settings above. It has to stay distinct from 0: a mode
y_offset of 0 means "sit at the base position", not "no preference".
"""
customization = self.config.get('customization', {})
if not isinstance(customization, dict):
return {}
mode = getattr(self, 'SKIN_MODE', None)
if not mode:
return customization
modes = customization.get('modes')
block = modes.get(mode) if isinstance(modes, dict) else None
if not isinstance(block, dict):
return customization
merged = dict(customization)
for element, override in block.items():
if element == 'layout' or not isinstance(override, dict):
continue
base = merged.get(element)
base = dict(base) if isinstance(base, dict) else {}
base.update({k: v for k, v in override.items() if v is not None})
merged[element] = base
mode_layout = block.get('layout')
if isinstance(mode_layout, dict):
base_layout = merged.get('layout')
new_layout = dict(base_layout) if isinstance(base_layout, dict) else {}
for element, axes in mode_layout.items():
if not isinstance(axes, dict):
continue
current = new_layout.get(element)
current = dict(current) if isinstance(current, dict) else {}
current.update({k: v for k, v in axes.items() if v is not None})
new_layout[element] = current
merged['layout'] = new_layout
return merged
def _setting_int(self, key: str, default: int, low: int, high: int) -> int:
"""A count from config, clamped to the range its schema declares.
The schema constrains these, but config.json can be hand-edited or
written by an older tool, and a string or a negative here does not
raise where anyone would see it -- it raises inside update()'s own
try/except, which shows up as a mode that silently renders nothing.
Same shape as the favorite_live_boost clamp in the plugins.
"""
try:
return max(low, min(high, int(self.mode_config.get(key, default))))
except (TypeError, ValueError, OverflowError):
# OverflowError: json parses a bare Infinity, and int(inf) raises
# -- from __init__, outside any try/except, so the manager would
# fail to construct instead of falling back.
self.logger.warning(
"%s: ignoring unusable %s=%r, using %s",
getattr(self, "league", "?"), key,
self.mode_config.get(key), default,
)
return default
def _reset_dwell_on_reentry(self) -> bool:
"""Give the current card a full turn when this mode (re)takes the panel.
The dwell clock (last_game_switch) keeps running while the mode is off
screen, so on re-entry it was always long expired and the first
display() call advanced immediately: the card cut off by the end of
the previous block was skipped instead of shown -- measured at one in
five card transitions on a 30s block of 15s cards -- and after a
service restart the clock started at manager construction, seconds
before the first frame, shaving that much off the first card. Both are
the same defect: the dwell clock counting time the viewer never saw.
Returns True when the dwell was reset, so the caller forces a redraw.
The one-frame card at the end of a block (the advance that races the
controller's mode switch) still renders -- this reset is what turns it
into the card that opens the next block with a full turn, instead of
one the rotation skipped.
"""
# getattr, and zero treated as "never displayed": the managers are
# constructed in several places -- the plugin tests among them -- not
# all of which set every attribute, and a freshly booted Pi can reach
# the first frame while time.monotonic() itself is still under the
# gap threshold, which would make `now - 0.0` look like one stint.
last = getattr(self, "_last_display_call_monotonic", 0.0)
now = time.monotonic()
self._last_display_call_monotonic = now
if last > 0.0 and now - last < self._DWELL_REENTRY_GAP_SECONDS:
return False
if getattr(self, "last_game_switch", 0) <= 0:
# Zero is the live screen's "no game shown yet" sentinel with its
# own handling; overwriting it here would hide the first game's
# arrival from that logic.
return False
self.last_game_switch = time.time()
return True
_spread_weighted_order = staticmethod(spread_weighted_order)
def _next_switch_index(self) -> int:
"""The games_list index switch mode shows next.
favorite_rotation_boost gives a favourite's card that many turns for
every one turn another card gets, spread through the rotation and kept
apart wherever the other cards leave room (a boost above the number of
other cards makes some repeats adjacent; the ratio is kept either way).
games_list itself stays one entry per game -- the
cycle-duration count, the scroll strip and the other-games re-cut all
read it -- so the weighting is an order walked over it instead.
The order is rebuilt whenever the list's games change, and the walk
resyncs from current_game_index whenever the two disagree: update()
and the other-games rotation both set the index directly when they
swap a list in, and the card on screen is where the walk resumes.
Called with _games_lock held and games_list non-empty.
"""
count = len(self.games_list)
boost = getattr(self, "favorite_rotation_boost", 1)
if boost <= 1 or count < 2:
return (self.current_game_index + 1) % count
key = (boost, tuple(g.get("id") for g in self.games_list))
if getattr(self, "_switch_order_key", None) != key:
self._switch_order = self._spread_weighted_order(
[boost if self._is_favorite_game(g) else 1 for g in self.games_list]
)
self._switch_order_key = key
self._switch_position = -1
order = self._switch_order
position = getattr(self, "_switch_position", -1)
if not 0 <= position < len(order) or order[position] != self.current_game_index:
position = (order.index(self.current_game_index)
if self.current_game_index in order else -1)
position = (position + 1) % len(order)
self._switch_position = position
return order[position]
def _odds_color(self) -> Tuple[int, int, int]:
"""Colour for the odds text; the green it always drew unless configured.
Guarded with getattr because not every class that reaches
_draw_dynamic_odds carries the element-colour helper -- the plugins'
own test harnesses build minimal manager objects, and a bare
AttributeError here is swallowed by the surrounding except, which
drops the odds off the card instead of failing loudly.
"""
getter = getattr(self, "_element_color", None)
if getter is None:
return (0, 255, 0)
try:
return getter("odds_text", (0, 255, 0)) # pylint: disable=not-callable
except Exception:
return (0, 255, 0)
def _upcoming_date_and_time_text(self, game_date: str, game_time: str,
game: Optional[Dict] = None) -> Tuple[str, str]:
"""The formatted (date, time) pair, blanked by switch_show_date/_time.
Deliberately not the shared show_date/show_time: those governed only
the scroll and Vegas cards before this display read the block, so a
config that had turned them off there would silently blank a scorebug
that has always drawn both lines. The switch keys default to True for
the same reason switch_upcoming_center defaults to "date_time" -- an
untouched panel keeps rendering exactly what it rendered before.
"""
date_text = (self._format_game_date(game_date, game)
if self._card_option("switch_show_date", True) else "")
time_text = (self._format_game_time(game_time)
if self._card_option("switch_show_time", True) else "")
return date_text, time_text
+47 -41
View File
@@ -18,10 +18,15 @@ Promoting the content layer would be exactly the mistake
``docs/SPORTS_UNIFICATION.md`` warns against — merging on the intuition that
same-named methods are the same method. Same name, different job.
The one behavior this module adds over the plugin copies is native support for
``global_config['target_fps']``: the bundled copies hardcode ~100 FPS via
``scroll_delay=0.01`` and never consult the global smooth-scrolling target. A
plugin inheriting from here gets it for free.
What this module adds over the plugin copies is pacing through
:mod:`src.common.scroll_config`, the resolver every other scroller uses: the
configured px/s is snapped to a whole-pixel speed for the panel's refresh, the
helper steps a fixed number of pixels per presented frame, and each drawn frame
publishes the frame hold to the display manager. Speed depends only on
``scroll_speed`` and the panel refresh
(``display.hardware.limit_refresh_rate_hz``) -- not on the global
``target_fps``, and not on ``scroll_delay``, which is kept in the settings for
compatibility only.
Usage::
@@ -94,9 +99,9 @@ class SportsScrollDisplay:
:param display_manager: the core display manager
:param config: the plugin's configuration
:param custom_logger: the plugin's logger, so scroll lines are attributed
:param global_config: the LEDMatrix global config — the source of
``target_fps``. Optional so an older caller that does not pass it
keeps working at the config-derived pacing.
:param global_config: the LEDMatrix global config, consulted for the
panel refresh when the display manager cannot report one.
Optional so an older caller that does not pass it keeps working.
"""
self.display_manager = display_manager
self.config = config
@@ -196,21 +201,6 @@ class SportsScrollDisplay:
return {**settings, **override}
return settings
def _resolve_target_fps(self) -> Optional[float]:
"""The global smooth-scrolling FPS target, or None to keep config pacing.
Coerced before use: a malformed value in the global config must degrade
to the existing ``scroll_delay`` pacing, never raise on a display path.
"""
raw = self.global_config.get("target_fps") or self.global_config.get(
"scroll_target_fps"
)
try:
return float(raw) if raw is not None else None
except (TypeError, ValueError):
self.logger.debug("Ignoring unusable target_fps: %r", raw)
return None
def _coerce_float(self, value: Any, default: float) -> float:
"""A usable float from config, or ``default``.
@@ -246,8 +236,8 @@ class SportsScrollDisplay:
# settings dict: the two read the same key names with different
# meanings, and the collision is a factor of 1/scroll_delay.
#
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the
# frame period used to reach px/frame.
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is ignored
# for pacing (see _resolve_pixels_per_second).
# scroll_config: scroll_speed is px per STEP, so px/s = speed/delay.
#
# Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through
@@ -276,8 +266,13 @@ class SportsScrollDisplay:
def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float:
"""This module's config shape, expressed as plain pixels per second.
``scroll_speed`` is already px/s here. ``scroll_delay`` only matters
when a caller supplied px/frame instead, which the 0 case covers.
``scroll_speed`` is already px/s here, and it is the whole answer.
``scroll_delay`` is kept in the settings for compatibility but is
ignored for pacing: the frame rate is the panel refresh divided by the
frame hold scroll_config picks, so no positive delay changes the speed.
The one exception is a delay of exactly 0 (below every scoreboard
schema's minimum), which is read as ``scroll_speed`` being px per
frame at ``ASSUMED_FPS_WHEN_UNPACED``.
"""
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
@@ -285,19 +280,29 @@ class SportsScrollDisplay:
return scroll_speed * ASSUMED_FPS_WHEN_UNPACED
return scroll_speed
def _resolve_refresh_hz(self) -> Optional[float]:
def _resolve_refresh_hz(self) -> float:
"""The panel refresh the crisp ladder should be computed against.
Prefers the configured hardware refresh. Falls back to the global
``target_fps``/``scroll_target_fps`` this module has always honoured:
under the old model that key *was* the rate frames were presented at,
so it is the faithful translation for anyone who set it. Returning
None lets scroll_config apply its own default.
This has to be the rate frames are actually presented at, because the
helper advances a fixed number of whole pixels per presented frame and
the display manager holds each frame for ``frame_hold`` refreshes. A
ladder built for any other rate plays back at the wrong speed: built
for 60 Hz and shown on a 100 Hz panel, it runs 100/60 too fast.
So it is the display manager's ``refresh_hz`` (what the panel is
driven at), then ``display.hardware.limit_refresh_rate_hz`` from the
global config, then scroll_config's default. The global ``target_fps``
is deliberately NOT consulted: before frame-locked presentation it was
the rate frames were shown at, but now honouring it only turned the
General tab's "Scroll Frame Rate" into a scoreboard speed multiplier.
"""
hardware = scroll_config.refresh_hz_from_config(self.global_config)
if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ:
return hardware
return self._resolve_target_fps() or hardware or None
reported = getattr(self.display_manager, "refresh_hz", None)
# A real number only: a stub or a mock answering for anything must not
# become the refresh rate.
if (isinstance(reported, (int, float)) and not isinstance(reported, bool)
and reported > 0):
return float(reported)
return scroll_config.refresh_hz_from_config(self.global_config)
def _scroll_frame_hold(self) -> int:
"""Refreshes to hold each frame for, from the resolved settings."""
@@ -327,11 +332,12 @@ class SportsScrollDisplay:
return False
# Tell the core the panel is scrolling, and for how many
# refreshes to hold each frame. Without this the frame hold is
# never applied -- so a speed the ladder made crisp still presents
# a new frame every refresh and judders -- and, because deferred
# updates only run while nothing is scrolling, core would run
# blocking work in the middle of this scroll.
# refreshes to hold each frame. The helper advances a fixed number
# of whole pixels per presented frame, so without the hold a new
# frame is presented every refresh and the scroll runs frame_hold
# times too fast. And because deferred updates only run while
# nothing is scrolling, core would otherwise run blocking work in
# the middle of this scroll.
if hasattr(self.display_manager, "set_scrolling_state"):
self.display_manager.set_scrolling_state(
True, frame_hold=self._scroll_frame_hold())
+97 -7
View File
@@ -84,6 +84,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, ClassVar, Dict, List, Optional, Tuple
import pytz
from src.common.espn_dates import fetch_espn_scoreboard
import requests
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
@@ -98,6 +99,13 @@ _IDLE_LONG_STREAK = 24
_IDLE_LONG_FACTOR = 6
_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
#: How long after a scheduled start to keep looking on the live cadence. ESPN
#: does not flip a game to in-progress exactly at kickoff, and an escalated
#: back-off treats each of those early looks as another empty one.
_KICKOFF_GRACE_SECONDS = 900
#: Fallback cadence around a kickoff when the manager has no update_interval.
_KICKOFF_POLL_FLOOR = 30
def _resolve_font_path(path: str) -> str:
"""Resolve a bundled font path without depending on the process cwd.
@@ -947,14 +955,14 @@ class SportsCoreSharedMixin:
end_date = now + timedelta(days=self.schedule_lookahead_days)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
response = self.session.get(
data = fetch_espn_scoreboard(
self.session,
url,
params={"dates": date_str, "limit": 1000},
headers=self.headers,
timeout=10,
logger=self.logger,
)
response.raise_for_status()
data = response.json()
immediate_events = data.get("events", [])
if immediate_events:
@@ -1309,6 +1317,11 @@ class SportsLiveSharedMixin:
Capped rather than unbounded: the cost of backing off is how late the
first game after a quiet spell is noticed, and past the cap the saving
stops being worth that.
The escalation is then clamped by the next kickoff the league already
knows about -- see _clamp_to_scheduled_start. Without that clamp the cap
*is* the miss: a league idle overnight reaches the ceiling, and the
first game of the next day is not noticed for up to that long.
"""
streak = getattr(self, "_empty_live_streak", 0)
base = self.no_data_interval
@@ -1320,10 +1333,87 @@ class SportsLiveSharedMixin:
# *shrink* as the streak grew (3600s at streak 0, 900s at streak 24),
# the opposite of what the setting named "maximum" promises.
if streak >= _IDLE_LONG_STREAK:
return min(int(base * _IDLE_LONG_FACTOR), ceiling)
if streak >= _IDLE_SHORT_STREAK:
return min(int(base * _IDLE_SHORT_FACTOR), ceiling)
return min(base, ceiling)
interval = min(int(base * _IDLE_LONG_FACTOR), ceiling)
elif streak >= _IDLE_SHORT_STREAK:
interval = min(int(base * _IDLE_SHORT_FACTOR), ceiling)
else:
interval = min(base, ceiling)
return self._clamp_to_scheduled_start(interval)
def _clamp_to_scheduled_start(self, interval: int) -> int:
"""Shorten an idle wait that would sleep through a known kickoff.
The back-off counts consecutive empty looks and nothing else, so it
cannot tell an out-of-season league from an in-season one a few hours
before kickoff. Both reach the ceiling, and the ceiling then becomes the
blind spot: measured on two rigs on 2026-09-19, gaps of up to 928s
between looks, 10 of them at or above 900s. A game starting inside such
a gap is not noticed until it ends -- which is the "it doesn't pick up
new live games until I restart it" report, restarting being the one
thing that forces an immediate look.
The fix costs no extra request: the live fetch already downloads the
whole day's scoreboard, upcoming games included, and
_note_scheduled_start_candidate keeps the earliest start still ahead of
us out of exactly that payload.
Two cases, either side of the kickoff:
* before it -- wait at most until it starts, never past it;
* just after it -- hold the live cadence for _KICKOFF_GRACE_SECONDS,
because a provider that has not yet flipped the status would
otherwise look like another empty check and escalate the back-off
again, right when the game is actually starting.
"""
start = getattr(self, "_next_scheduled_start_ts", None)
if not start:
return interval
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
now = time.time()
if now < start:
return max(live, min(interval, int(start - now)))
if now - start <= _KICKOFF_GRACE_SECONDS:
return live
return interval
def _note_scheduled_start_candidate(self, details) -> None:
"""Offer a game from the current look as the next kickoff to wake for.
Called for every event the live fetch returns, live or not, so the
earliest start still ahead of us falls out of the payload the manager
already has. Self-correcting: a stored start that has passed is
replaced by the next one offered, so a postponed game cannot pin the
cadence to a kickoff that never happens.
"""
if not isinstance(details, dict):
return
if details.get("is_live") or details.get("is_halftime"):
return
start = details.get("start_time_utc")
timestamp = getattr(start, "timestamp", None)
if timestamp is None:
return
try:
candidate = float(timestamp())
except (TypeError, ValueError, OSError, OverflowError):
return
now = time.time()
if candidate <= now:
return
current = getattr(self, "_next_scheduled_start_ts", None)
# A kickoff that has only just passed is *kept*, not replaced by the
# next one on the card. Replacing it immediately is what made the grace
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
# round, the stored start jumped to the 16:05 games, `now < start` went
# true again, and the back-off returned to its ceiling -- at exactly the
# moment the games were starting. Observed live on 2026-09-20: the rig
# polled at 13:00:45, found nothing live because ESPN had not flipped
# the status yet, and then went quiet for the next quarter of an hour,
# which is the behaviour this whole clamp exists to prevent.
if (current is None
or current <= now - _KICKOFF_GRACE_SECONDS
or candidate < current):
self._next_scheduled_start_ts = candidate
def _note_live_fetch(self, found_live: bool) -> None:
"""Record whether a look for live games found any."""
+7 -5
View File
@@ -29,6 +29,7 @@ import os
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
from src.exceptions import ConfigError
from src.logging_config import get_logger
from src.config_manager_atomic import (
@@ -756,12 +757,13 @@ class ConfigManager:
valid_set = set(valid_plugin_ids)
# Find orphaned plugins in main config
main_plugins = set(main_config.keys())
# Find orphaned plugins in main config. Core sections (display,
# schedule, auto_update, ...) are not plugins and never orphans.
main_plugins = set(main_config.keys()) - CORE_CONFIG_KEYS
orphaned_main = main_plugins - valid_set
# Find orphaned plugins in secrets config
secrets_plugins = set(secrets_config.keys())
secrets_plugins = set(secrets_config.keys()) - CORE_CONFIG_KEYS - CORE_SECRETS_KEYS
orphaned_secrets = secrets_plugins - valid_set
all_orphaned = orphaned_main | orphaned_secrets
@@ -815,8 +817,8 @@ class ConfigManager:
if not isinstance(plugin_config, dict):
continue
# Skip non-plugin config sections
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']:
# Skip core config sections
if plugin_id in CORE_CONFIG_KEYS:
continue
schema = plugin_schema_manager.load_schema(plugin_id, use_cache=True)
+53
View File
@@ -0,0 +1,53 @@
"""Top-level config.json keys that belong to the LEDMatrix core, not to plugins.
config.json mixes two kinds of top-level section: core settings (``display``,
``schedule``, ``auto_update``, ...) and one section per plugin, keyed by plugin
id. Anything that needs to tell them apart -- plugin-state reconciliation above
all -- must use this list, not a private copy of it.
A private copy is what went wrong: #581 added a top-level ``auto_update``
section, reconciliation's own list did not know about it, and every device with
the new setting was told "In config but not installed: auto_update. Reinstall
via the Plugin Store, or remove these entries from config.json" -- advice that
deletes a real core setting.
When you add a top-level core setting, add its key here. The tests in
``test/test_core_config_keys.py`` fail if a top-level key in
``config/config.template.json``, or one written by the general-settings save
endpoint, is missing from this list.
"""
CORE_CONFIG_KEYS = frozenset({
# config/config.template.json
'web_display_autostart',
'auto_update',
'schedule',
'dim_schedule',
'timezone',
'target_fps',
'location',
'display',
'sync',
'plugin_system',
# Older or optional core sections still found in existing config files.
'logging',
'network',
'system',
'vegas_scroll_speed',
'vegas_separator_width',
'vegas_target_fps',
'vegas_buffer_ahead',
'vegas_plugin_order',
'vegas_excluded_plugins',
'vegas_scroll_enabled',
})
#: Top-level keys of ``config_secrets.json`` that belong to the core rather than
#: to a plugin: the GitHub token the Plugin Store reads, and the historical
#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything
#: deciding whether a secrets section is a plugin's needs this as well as
#: ``CORE_CONFIG_KEYS``.
CORE_SECRETS_KEYS = frozenset({
'github',
'youtube',
})
+8
View File
@@ -2976,6 +2976,14 @@ class DisplayController:
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
"""Callback for plugin config changes."""
try:
# ConfigService hands over the raw config.json section.
# Prepare it as loading did (legacy booleans read as
# objects, schema defaults filled in), so the plugin gets
# the same shape it was constructed with.
prepare = getattr(self.plugin_manager, 'prepare_plugin_config', None)
prepared = prepare(_pid, new_config) if callable(prepare) else None
if isinstance(prepared, dict):
new_config = prepared
_plugin.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", _pid)
except Exception as e:
+142 -8
View File
@@ -1,17 +1,25 @@
"""Display size from config: the one computation every caller shares.
"""Display size from config: the one computation the size readers share.
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
``display.double_sided``. The web preview, the Starlark magnify default and
the multi-display sync handshake used to re-derive that size themselves,
each with its own defaults (``chain_length`` fell back to 2 in one place and
1 in three others) and none of them applying double-sided mode. They now all
call this module.
``display.double_sided``. The web preview endpoints (``/display/current``, the
SSE fallback), the Starlark magnify default and ``scripts/dev/vegas_audit.py``
used to re-derive that size themselves, each with its own defaults
(``chain_length`` fell back to 2 in one place and 1 in others) and none of
them applying double-sided mode. They now call this module. (The multi-display
sync handshake doesn't compute a size; it shares only
``DEFAULT_CHAIN_LENGTH``.)
The size includes what the rgbmatrix library's pixel mappers do to it --
``orientation`` and ``pixel_mapper_config`` (``Rotate:90`` swaps the axes,
``U-mapper`` folds the chain) -- because ``RGBMatrix.width``/``height``, which
``DisplayManager`` reports on hardware, are measured after them.
Kept free of hardware imports on purpose: the web interface imports it, and
``display_manager`` pulls in ``rgbmatrix``.
"""
import logging
import re
from typing import Any, Dict, Mapping, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -22,6 +30,10 @@ DEFAULT_COLS = 64
DEFAULT_CHAIN_LENGTH = 2
DEFAULT_PARALLEL = 1
#: ``display.hardware.orientation`` -> degrees of the ``Rotate`` mapper
#: DisplayManager appends to ``pixel_mapper_config`` (None: no mapper).
ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
# A hand-edited config.json can hold anything here; treat a non-mapping
@@ -35,10 +47,128 @@ def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
return hw if isinstance(hw, Mapping) else {}
def compose_pixel_mapper_config(hardware: Mapping[str, Any]) -> str:
"""The ``pixel_mapper_config`` DisplayManager hands the library.
``pixel_mapper_config`` stays a free-form advanced field (e.g. "U-mapper"
for chain layouts); ``orientation`` is the user-facing mounting rotation,
appended as a trailing ``Rotate:<deg>`` mapper rather than overwriting it.
"""
base = hardware.get('pixel_mapper_config') or ''
base = base.strip() if isinstance(base, str) else ''
degrees = ORIENTATION_ROTATE_DEGREES.get(hardware.get('orientation', 'normal'))
if degrees is None:
return base
rotate = f'Rotate:{degrees}'
return f'{base};{rotate}' if base else rotate
def _c_div(a: int, b: int) -> int:
"""C integer division (truncates toward zero)."""
q = abs(a) // abs(b)
return q if (a >= 0) == (b >= 0) else -q
def _c_strtol(text: str) -> Tuple[int, str]:
"""strtol(text, &end, 10): the parsed value (0 if none) and the rest."""
match = re.match(r'\s*([+-]?\d+)', text)
if not match:
return 0, text
return int(match.group(1)), text[match.end():]
def _remap_size(param: Optional[str], width: int, height: int,
chain: int, parallel: int) -> Optional[Tuple[int, int]]:
"""RemapMapper::SetParameters and GetSizeMapping (lib/pixel-mapper.cc)."""
if not param:
return None
new_w, rest = _c_strtol(param)
if not rest.startswith(','):
return None
new_h, rest = _c_strtol(rest[1:])
if not rest.startswith('|'):
return None
rest = rest[1:]
tiles = []
while rest:
x, rest = _c_strtol(rest)
if not rest.startswith(','):
return None
y, rest = _c_strtol(rest[1:])
if not rest or rest[0].lower() not in 'neswx':
return None
tiles.append((x, y, rest[0].lower()))
rest = rest[1:]
if rest.startswith('|'):
rest = rest[1:]
elif rest:
return None
if len(tiles) != chain * parallel:
return None
panel_w, panel_h = _c_div(width, chain), _c_div(height, parallel)
for x, y, kind in tiles:
if kind == 'x':
continue
# MapTile::MapToVisible of the panel's (0, 0) and far corner.
x0, y0, x1, y1 = {
'n': (x, y, x + panel_w - 1, y + panel_h - 1),
'w': (x, y + panel_w - 1, x + panel_h - 1, y),
's': (x + panel_w - 1, y + panel_h - 1, x, y),
'e': (x + panel_h - 1, y, x, y + panel_w - 1),
}[kind]
if x1 < 0 or x0 >= new_w or y1 < 0 or y0 >= new_h:
return None
return new_w, new_h
def apply_pixel_mappers(width: int, height: int, mapper_config: str,
chain: int, parallel: int) -> Tuple[int, int]:
"""The canvas size after the library applies ``mapper_config``.
Mirrors ``RGBMatrix::Impl::ApplyNamedPixelMappers`` and each built-in
mapper's ``SetParameters``/``GetSizeMapping`` in the pinned
``lib/pixel-mapper.cc``: mappers apply left to right, and one the library
doesn't know or can't configure is skipped and leaves the size alone.
``multiplexing`` isn't modelled: its mappers give back the configured
size for the panel sizes they are made for.
"""
for entry in (mapper_config or '').split(';'):
name, colon, param = entry.partition(':')
name = name.lower()
param = param if colon else None
if name == 'rotate':
if not param:
continue
angle, rest = _c_strtol(param)
if rest or angle % 90:
continue
if angle % 180:
width, height = height, width
elif name == 'u-mapper':
if chain < 2 or chain % 2 or height % parallel:
continue
width, height = _c_div(width, 64) * 32, 2 * height
elif name == 'v-mapper':
width, height = (_c_div(width * parallel, chain),
_c_div(height * chain, parallel))
elif name == 'stacktorow':
if param and any(c not in 'ZzFf, ' for c in param):
continue
width, height = width * parallel, _c_div(height, parallel)
elif name == 'remap':
size = _remap_size(param, width, height, chain, parallel)
if size is not None:
width, height = size
# "mirror" keeps the size; the library skips names it doesn't know.
return width, height
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
"""Width and height of the whole panel chain, in pixels.
``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
``cols * chain_length`` by ``rows * parallel``, then through the pixel
mappers ``orientation`` and ``pixel_mapper_config`` set up -- what
``RGBMatrix.width``/``height`` report. Raises ``ValueError`` or
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
decide their own fallback.
@@ -54,7 +184,11 @@ def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
except OverflowError as e:
raise ValueError(f"display.hardware size is not finite: {e}") from e
return max(1, cols * chain_length), max(1, rows * parallel)
width, height = max(1, cols * chain_length), max(1, rows * parallel)
if chain_length >= 1 and parallel >= 1:
width, height = apply_pixel_mappers(
width, height, compose_pixel_mapper_config(hw), chain_length, parallel)
return max(1, width), max(1, height)
def resolve_double_sided(physical_width: int, physical_height: int,
+138 -71
View File
@@ -37,8 +37,11 @@ from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
physical_size, resolve_double_sided,
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
resolve_double_sided,
)
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5
import threading
import time
from collections import OrderedDict
@@ -90,9 +93,11 @@ class _LogicalMatrix:
"""Proxy that reports a logical (per-screen) size for a physical matrix.
In double-sided mode the physical panel chain shows N identical copies of a
smaller logical screen. Plugins size themselves from ``matrix.width`` /
``matrix.height`` (the documented convention, used at 30+ call sites), so
this proxy reports the logical dimensions while delegating every real
smaller logical screen. Plugins size themselves from
``display_manager.width`` / ``height`` (the documented convention), which
defer to ``matrix.width`` / ``matrix.height`` -- and many older plugins read
``matrix.width`` directly -- so this proxy reports the logical dimensions
while delegating every real
operation — ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
``Clear`` and so on — to the underlying physical matrix. The duplication
itself happens once per frame in :meth:`DisplayManager.update_display`.
@@ -246,28 +251,41 @@ class DisplayManager:
# Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
# "normal" needs no suffix since 0 degrees is the identity transform.
_ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
"""Compose the raw pixel_mapper_config string with the orientation setting.
"""Compose pixel_mapper_config with the orientation setting.
`pixel_mapper_config` stays available as a free-form advanced field (e.g.
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
for physical mounting (e.g. panels mounted upside down) and is appended as
a "Rotate:<deg>" mapper rather than overwriting any existing config.
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
web preview shares so it sizes the canvas the same way.
"""
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
orientation = hardware_config.get('orientation', 'normal')
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
if degrees is None:
return base_mapper
rotate_mapper = f'Rotate:{degrees}'
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
return compose_pixel_mapper_config(hardware_config)
@staticmethod
def _fallback_advice(cause: str, error: Exception) -> str:
"""What to do about a failed matrix init, for the log.
Only a library failure gets the rebuild hint: advice about the build
or GPIO timing sends someone whose settings were refused the wrong way.
"""
if cause == "settings":
return (f"{error} Change these in the web interface's Display tab "
"(or display.hardware / display.runtime in config.json) "
"and restart the display service.")
if cause == "forced":
return f"Error: {error}."
advice = (f"Error: {error}. If the rgbmatrix library printed a message "
"just before this, it names the problem.")
if is_raspberry_pi_5():
advice += (" On a Raspberry Pi 5, an mmap error means the library was "
"built without Pi 5 support: sudo RPI_RGB_FORCE_REBUILD=1 "
"./first_time_install.sh")
return advice
def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings."""
_init_error_str = None
_init_cause = None
try:
# Allow callers (e.g., web UI) to force non-hardware fallback mode
if getattr(self, '_force_fallback', False):
@@ -277,51 +295,22 @@ class DisplayManager:
# Hardware configuration
hardware_config = self.config.get('display', {}).get('hardware', {})
runtime_config = self.config.get('display', {}).get('runtime', {})
# The library has no error path for many settings it can't use:
# it returns no matrix (which the binding doesn't check, so the
# process crashes on its next call) or calls abort(), and systemd
# restarts the service into the same crash. Refuse those first so
# they become a logged, reported fallback (src/matrix_support.py).
refused = refusal_message(library_refusals(
hardware_config, runtime_config, pi5=is_raspberry_pi_5()))
if refused:
if os.getenv("EMULATOR", "false") != "true":
raise MatrixSettingsRefused(refused)
logger.warning("Emulator mode: continuing, but on a real panel the display would not start. %s", refused)
# Basic hardware settings
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
options.cols = hardware_config.get('cols', DEFAULT_COLS)
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
# Performance and stability settings
options.brightness = hardware_config.get('brightness', 90)
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
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.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
# Disable internal privilege dropping - we manage this via systemd or remain root
# This prevents the library from dropping to 'daemon' user which breaks file permissions
options.drop_privileges = False
# Additional settings from config
if 'scan_mode' in hardware_config:
options.scan_mode = hardware_config.get('scan_mode')
if 'pwm_dither_bits' in hardware_config:
options.pwm_dither_bits = hardware_config.get('pwm_dither_bits')
if 'inverse_colors' in hardware_config:
options.inverse_colors = hardware_config.get('inverse_colors')
# Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU),
# 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode)
if 'rp1_rio' in runtime_config:
if hasattr(options, 'rp1_rio'):
options.rp1_rio = runtime_config.get('rp1_rio')
else:
logger.warning(
"rp1_rio is set in config but the installed rgbmatrix library does "
"not support it — the library was likely built without Pi 5 RP1 "
"support (mmap to 0x3f000000 instead of RP1 chip). "
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
)
# Every option comes from display.hardware / display.runtime, in
# one place that scripts/scroll_speeds.py shares.
self.apply_matrix_options(options, self.config)
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
@@ -369,7 +358,12 @@ class DisplayManager:
except Exception as e:
_init_error_str = str(e)
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
if isinstance(e, MatrixSettingsRefused):
_init_cause = "settings"
logger.error("Failed to initialize RGB Matrix: %s", e)
else:
_init_cause = "forced" if getattr(self, '_force_fallback', False) else "library"
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
# Create a fallback image for web preview using configured dimensions when available
self.matrix = None
try:
@@ -396,15 +390,18 @@ class DisplayManager:
# Best-effort; ignore drawing errors in fallback
pass
logger.error(
f"Matrix initialization failed — running in fallback/simulation mode "
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
"submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
)
"Matrix initialization failed — running in fallback/simulation mode "
"(size %dx%d). %s",
fallback_width, fallback_height, self._fallback_advice(_init_cause, e))
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
# Write hardware status file so the web UI can surface init failures
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
# cause: None when ok; "settings" when LEDMatrix refused the config
# (fix the named settings), "library" when the library itself failed,
# "forced" for a caller-requested fallback. The Display tab keys its
# advice on it.
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str,
"cause": None if self.matrix is not None else _init_cause}
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
if os.path.islink(_status_path):
@@ -672,8 +669,10 @@ class DisplayManager:
def render_size(self, width: int, height: Optional[int] = None):
"""Temporarily present a smaller logical canvas to plugins.
Plugins lay out against ``display_manager.matrix.width`` (and the
``width``/``height`` properties, which defer to it), so the only way to
Plugins lay out against the ``display_manager.width``/``height``
properties (which defer to ``matrix.width`` when hardware is present,
and to the canvas when it is not; some older plugins read
``matrix.width`` directly), so the only way to
get a *narrower layout* rather than a cropped one is to tell the plugin
the screen is narrower while it renders. Trimming after the fact cannot
fix a forecast spread across five columns or a progress bar drawn at
@@ -1360,6 +1359,68 @@ class DisplayManager:
return dt.strftime(f"%b %-d{suffix}")
@classmethod
def apply_matrix_options(cls, options, config: Dict[str, Any]):
"""Fill ``options`` (an ``RGBMatrixOptions``) from the LEDMatrix config.
This is exactly what the display service drives the panel with, so a
tool that opens the matrix itself (``scripts/scroll_speeds.py``) gets
the same panel -- same runtime ``gpio_slowdown``, ``rp1_rio``,
``panel_type``, orientation and defaults -- rather than a private copy
that drifts. Does not open the matrix. Returns ``options``.
"""
display = config.get('display', {}) if isinstance(config, dict) else {}
hardware_config = display.get('hardware', {})
runtime_config = display.get('runtime', {})
# Basic hardware settings
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
options.cols = hardware_config.get('cols', DEFAULT_COLS)
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
# Performance and stability settings
options.brightness = hardware_config.get('brightness', 90)
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
# _build_pixel_mapper_config reads only class attributes, so the class
# stands in for an instance here.
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
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.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
# Disable internal privilege dropping - we manage this via systemd or remain root
# This prevents the library from dropping to 'daemon' user which breaks file permissions
options.drop_privileges = False
# Additional settings from config
if 'scan_mode' in hardware_config:
options.scan_mode = hardware_config.get('scan_mode')
if 'pwm_dither_bits' in hardware_config:
options.pwm_dither_bits = hardware_config.get('pwm_dither_bits')
if 'inverse_colors' in hardware_config:
options.inverse_colors = hardware_config.get('inverse_colors')
# Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU),
# 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode)
if 'rp1_rio' in runtime_config:
if hasattr(options, 'rp1_rio'):
options.rp1_rio = runtime_config.get('rp1_rio')
else:
logger.warning(
"rp1_rio is set in config but the installed rgbmatrix library does "
"not support it — the library was likely built without Pi 5 RP1 "
"support (mmap to 0x3f000000 instead of RP1 chip). "
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
)
return options
@property
def refresh_hz(self) -> float:
"""The panel's refresh rate in Hz, from the hardware config.
@@ -1405,6 +1466,12 @@ class DisplayManager:
pixel every second refresh, which is how a scroll runs at half the
refresh rate without fractional pixel positions.
The hold is part of the scroll's speed. A ScrollHelper configured by
``scroll_config.configure()`` advances a fixed whole-pixel step per
presented frame and reads no clock, so pass the returned
``settings.frame_hold`` here: a scroll that leaves it at 1 is
presented every refresh and runs ``frame_hold`` times too fast.
The hold is set here rather than once at plugin construction because
it must not outlive the scroll that asked for it: plugins share one
display manager, so a hold left set by whoever scrolled last would
+205
View File
@@ -0,0 +1,205 @@
"""Display settings the pinned rgbmatrix library refuses, on every board.
The library does not raise on a setting it can't use. For most it returns no
matrix -- ``RGBMatrix::CreateFromOptions()`` gives back NULL when
``Options::Validate()`` (``lib/options-initialize.cc``) or its GPIO slowdown
check (``lib/led-matrix.cc``) fails -- and the Python binding stores that
pointer without checking, so the display process crashes on its first call
into the matrix. For two it calls ``abort()``: an unknown hardware mapping
name, and more parallel chains than the mapping has outputs
(``Framebuffer::InitHardwareMapping()`` and the ``Framebuffer`` constructor in
``lib/framebuffer.cc``). Either way systemd restarts the service into the same
crash, and no fallback or hardware status is ever reported.
``DisplayManager`` runs :func:`library_refusals` before creating the matrix,
turning those crashes into a logged error and the usual fallback mode. The
config API uses the same rules to refuse the settings up front, and
:data:`INT_SETTING_LIMITS` is the one place the numeric ranges live.
Pi 5-only limits come from :mod:`src.pi5_matrix_support` and are added when
``pi5=True``.
**Re-check this when the submodule is bumped** (pinned at 1ee4f76): the
ranges in ``Options::Validate()``, the mapping table in
``lib/hardware-mapping.c`` and the setter types in
``bindings/python/rgbmatrix/core.pyx``. A stale rule here blocks settings a
new library accepts; a missing one lets the display service crash-loop.
"""
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple
from src.pi5_matrix_support import pi5_unsupported_settings
#: The binding's setters for rows, chain_length, parallel and the other small
#: integers are declared ``uint8_t`` (``core.pyx``), cols ``uint32_t``, and
#: limit_refresh_rate_hz lands in a C ``int``; a larger value raises
#: ``OverflowError`` before the library sees it.
UINT8_MAX = 255
UINT32_MAX = 2 ** 32 - 1
INT32_MAX = 2 ** 31 - 1
#: field -> (config section, lowest, highest, must be even). The library's own
#: ranges where it has one, otherwise the binding's integer type.
INT_SETTING_LIMITS: Dict[str, Tuple[str, int, int, bool]] = {
'rows': ('hardware', 8, 64, True),
'cols': ('hardware', 16, UINT32_MAX, False),
'chain_length': ('hardware', 1, UINT8_MAX, False),
# 3 is the most any mapping in the default build has (see MAPPING_OUTPUTS).
'parallel': ('hardware', 1, 3, False),
'brightness': ('hardware', 1, 100, False),
'scan_mode': ('hardware', 0, 1, False),
'pwm_bits': ('hardware', 1, 11, False),
'pwm_dither_bits': ('hardware', 0, 2, False),
'pwm_lsb_nanoseconds': ('hardware', 50, 3000, False),
# 0 = no cap; the library has no upper bound.
'limit_refresh_rate_hz': ('hardware', 0, INT32_MAX, False),
'row_address_type': ('hardware', 0, 5, False),
# 22 registered multiplexers (lib/multiplex-mappers.cc).
'multiplexing': ('hardware', 0, 22, False),
'gpio_slowdown': ('runtime', 0, 10, False),
'rp1_rio': ('runtime', 0, 1, False),
}
#: Hardware mapping name -> parallel chains it has outputs for, as
#: ``lib/hardware-mapping.c`` defines them. ``compute-module`` exists only when
#: the library is built with ENABLE_WIDE_GPIO_COMPUTE_MODULE, which the pinned
#: Makefile leaves commented out and the installer does not set, so the library
#: LEDMatrix installs aborts on it like any other unknown name.
MAPPING_OUTPUTS: Dict[str, int] = {
'regular': 3,
'adafruit-hat': 1,
'adafruit-hat-pwm': 1,
'regular-pi1': 1,
'classic': 3,
'classic-pi1': 1,
}
#: 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.
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,
'gpio_slowdown': 3,
}
class Refusal(NamedTuple):
"""One reason the library can't start with a config.
``fields`` are the settings involved; the config API reports a refusal
only when the request sets one of them, so a problem already stored
doesn't block an unrelated save.
"""
fields: Tuple[str, ...]
message: str
#: A Raspberry Pi 5-only limit, whose message is already a full sentence.
pi5: bool = False
class MatrixSettingsRefused(RuntimeError):
"""Raised by DisplayManager instead of handing the library such a config."""
def _as_int(value: Any) -> Optional[int]:
"""The integer the binding would receive, or None if it isn't one.
A value that isn't a number is left to the binding, which raises a
``TypeError`` DisplayManager already turns into fallback mode.
"""
try:
return int(value)
except (TypeError, ValueError, OverflowError):
return None
def _setting(hardware: Mapping[str, Any], runtime: Mapping[str, Any], field: str) -> Any:
section = runtime if INT_SETTING_LIMITS.get(field, ('hardware',))[0] == 'runtime' else hardware
if field in section:
return section[field]
return DISPLAY_MANAGER_DEFAULTS.get(field)
def describe_range(low: int, high: int, even: bool = False) -> str:
kind = "an even integer" if even else "an integer"
if high >= INT32_MAX:
return f"{kind} of at least {low}"
return f"{kind} from {low} to {high}"
def library_refusals(hardware: Optional[Mapping[str, Any]],
runtime: Optional[Mapping[str, Any]] = None,
pi5: bool = False) -> List[Refusal]:
"""Every reason the pinned library would refuse (NULL, abort or overflow)
to start with this ``display.hardware`` / ``display.runtime`` config.
Missing keys take DisplayManager's defaults. ``pi5`` adds the Raspberry
Pi 5 limits from :func:`pi5_unsupported_settings`.
"""
hardware = hardware if isinstance(hardware, Mapping) else {}
runtime = runtime if isinstance(runtime, Mapping) else {}
refusals: List[Refusal] = []
for field, (section, low, high, even) in INT_SETTING_LIMITS.items():
# DisplayManager sets these only when present, and scan_mode /
# pwm_dither_bits / rp1_rio have no default of its own.
source = runtime if section == 'runtime' else hardware
if field not in source and field not in DISPLAY_MANAGER_DEFAULTS:
continue
value = _as_int(_setting(hardware, runtime, field))
if value is None:
continue
if not low <= value <= high or (even and value % 2):
refusals.append(Refusal(
(field,), f"{field} {value} (must be {describe_range(low, high, even)})"))
mapping = _setting(hardware, runtime, 'hardware_mapping')
outputs = None
if not isinstance(mapping, str):
refusals.append(Refusal(
('hardware_mapping',), f"hardware mapping {mapping!r} (must be a mapping name)"))
else:
# The library matches names case-insensitively and reads an empty
# name as "regular".
outputs = MAPPING_OUTPUTS.get((mapping or 'regular').lower())
if outputs is None:
refusals.append(Refusal(
('hardware_mapping',),
f'hardware mapping "{mapping}" (the installed library has '
+ ", ".join(MAPPING_OUTPUTS) + ")"))
parallel = _as_int(_setting(hardware, runtime, 'parallel'))
if outputs is not None and parallel is not None and 1 <= parallel <= 3 and parallel > outputs:
refusals.append(Refusal(
('parallel', 'hardware_mapping'),
f'parallel {parallel} with hardware mapping "{mapping}", which has '
f'{outputs} output{"s" if outputs != 1 else ""}'))
sequence = _setting(hardware, runtime, 'led_rgb_sequence')
if isinstance(sequence, str) and not (
len(sequence) == 3 and set(sequence.upper()) == {'R', 'G', 'B'}):
refusals.append(Refusal(
('led_rgb_sequence',),
f'LED RGB sequence "{sequence}" (must be R, G and B in some order)'))
if pi5:
unsupported = pi5_unsupported_settings(hardware)
if unsupported:
refusals.append(Refusal(
('row_address_type', 'parallel', 'hardware_mapping'), unsupported, pi5=True))
return refusals
def refusal_message(refusals: List[Refusal]) -> Optional[str]:
"""One sentence naming every refusal, or None when there are none."""
general = [r.message for r in refusals if not r.pi5]
pi5 = [r.message for r in refusals if r.pi5]
parts = []
if general:
parts.append("The installed rgbmatrix library can't start with these "
"display settings: " + "; ".join(general) + ".")
parts.extend(pi5)
return " ".join(parts) or None
+70
View File
@@ -0,0 +1,70 @@
"""Which matrix settings the pinned rgbmatrix library can drive on a Raspberry Pi 5.
On a Pi 5 the library drives the panel through the RP1 chip, and at the pinned
rpi-rgb-led-matrix-master commit (1ee4f76) that path supports only some
settings -- ``Rp1PioConfigSupported()`` in ``lib/rp1/rp1_pio_backend.cc``. For
anything else ``RGBMatrix::CreateFromOptions()`` returns NULL. The Python
binding does not check for that, so instead of raising, the display process
crashes on its first call into the matrix, and systemd restarts it into the
same crash every 10 seconds.
``DisplayManager`` checks this rule before creating the matrix, turning that
crash into a logged error and the usual fallback mode. The config API and the
Display form use the same rule to refuse the settings up front.
**Re-check this when the submodule is bumped.** Upstream is extending Pi 5
support (e.g. 4e326c1b, "Pi5 - Improvements, additional led-row-addr-type
support"); a stale rule here would block settings the new library drives.
"""
from typing import Any, Mapping, Optional
#: Read the way the library's ``Rp1PioPlatformDetected()`` reads it.
MODEL_PATH = "/proc/device-tree/model"
PI5_MODEL_MARKERS = ("Raspberry Pi 5", "Compute Module 5")
PI5_ROW_ADDRESS_TYPES = (0, 2)
PI5_MAX_PARALLEL = 3
PI5_HARDWARE_MAPPINGS = ("regular", "regular-pi1", "classic", "adafruit-hat", "adafruit-hat-pwm")
def is_raspberry_pi_5() -> bool:
"""True on a Pi 5-family board: Pi 5, Pi 500 or Compute Module 5."""
try:
with open(MODEL_PATH, "rb") as f:
model = f.read(256).decode("utf-8", "replace")
except OSError:
return False
return any(marker in model for marker in PI5_MODEL_MARKERS)
def _as_int(value: Any, default: int) -> Optional[int]:
if value is None or value == "":
return default
try:
return int(value)
except (TypeError, ValueError, OverflowError):
return None
def pi5_unsupported_settings(hardware: Mapping[str, Any]) -> Optional[str]:
"""Why a Pi 5 can't drive this ``display.hardware`` config, or None if it can.
Missing keys take DisplayManager's defaults. A value that isn't a number is
left to the caller's own validation rather than reported here.
"""
problems = []
row_address_type = _as_int(hardware.get("row_address_type"), 0)
if row_address_type is not None and row_address_type not in PI5_ROW_ADDRESS_TYPES:
problems.append(f"row address type {row_address_type} (only 0 and 2 are supported)")
parallel = _as_int(hardware.get("parallel"), 1)
if parallel is not None and not 1 <= parallel <= PI5_MAX_PARALLEL:
problems.append(f"parallel {parallel} (1 to {PI5_MAX_PARALLEL} are supported)")
mapping = hardware.get("hardware_mapping", "adafruit-hat-pwm")
# The library treats an empty mapping as "regular".
if isinstance(mapping, str) and (mapping or "regular") not in PI5_HARDWARE_MAPPINGS:
problems.append(f'hardware mapping "{mapping}"')
if not problems:
return None
return ("Not supported on a Raspberry Pi 5 by the installed rgbmatrix library: "
+ "; ".join(problems) + ".")
+71 -15
View File
@@ -22,7 +22,10 @@ from src.logging_config import get_logger
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_executor import PluginExecutor
from src.plugin_system.plugin_state import PluginStateManager, PluginState
from src.plugin_system.schema_manager import SchemaManager
from src.plugin_system.schema_manager import (
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
)
from src.common.path_safety import safe_path_component
from src.common.permission_utils import (
ensure_directory_permissions,
get_plugin_dir_mode
@@ -351,6 +354,7 @@ class PluginManager:
config = {}
# Check if plugin has a config schema
schema = None
schema_path = self.schema_manager.get_schema_path(plugin_id)
if schema_path is None:
# Schema file doesn't exist
@@ -368,14 +372,11 @@ class PluginManager:
f"The schema may be invalid. Please verify the schema file at: {schema_path}"
)
# Merge config with schema defaults to ensure all defaults are applied
try:
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
config = self.schema_manager.merge_with_defaults(config, defaults)
self.logger.debug(f"Merged config with schema defaults for {plugin_id}")
except Exception as e:
self.logger.warning(f"Could not apply schema defaults for {plugin_id}: {e}")
# Continue with original config if defaults can't be applied
# Legacy booleans read as objects, then schema defaults: the same
# preparation saves, GET /plugins/config and hot reload apply
# (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)
# Use PluginLoader to load plugin
plugin_instance, module = self.plugin_loader.load_plugin(
@@ -470,11 +471,57 @@ class PluginManager:
#:
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``).
CORE_OWNED_CONFIG_KEYS = frozenset({
'vegas_width_pct',
'vegas_overflow',
'vegas_max_width_screens',
})
#:
#: The list itself lives with the other core-owned per-plugin properties in
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
#: uses to keep these keys.
CORE_OWNED_CONFIG_KEYS = CORE_VEGAS_TUNING_KEYS
def prepare_plugin_config(self, plugin_id: str, config: Any,
schema: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""The config a plugin runs with, built from its raw config.json section.
A plugin that turned an on/off boolean into an ``{enabled, ...}``
object still finds the boolean in config.json until its settings are
next saved; it is read as the object, and schema defaults fill in the
rest (``SchemaManager.prepare_plugin_config``). Used when loading a
plugin and on hot reload (DisplayController), so ``on_config_change``
receives the same shape the plugin was constructed with.
Never raises: on failure the legacy-boolean pass alone is applied, or
failing that the section is returned as it was.
"""
if schema is None:
try:
schema = self.schema_manager.load_schema(plugin_id)
except Exception as e:
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
schema = None
upgraded: List[str] = []
try:
prepared = self.schema_manager.prepare_plugin_config(
plugin_id, config, schema=schema, changed_paths=upgraded)
self.logger.debug("Merged config with schema defaults for %s", plugin_id)
except Exception as e:
self.logger.warning("Could not apply schema defaults for %s: %s", plugin_id, e)
# Continue without defaults if they can't be applied
upgraded = []
prepared = config if isinstance(config, dict) else {}
if schema:
try:
prepared = normalize_legacy_booleans(prepared, schema, upgraded)
except Exception as legacy_error:
self.logger.warning(
"Could not read legacy boolean settings for %s: %s",
plugin_id, legacy_error)
if upgraded:
self.logger.info(
"Plugin %s: reading legacy boolean setting %s as "
"{\"enabled\": ...}; saving the plugin's settings "
"stores the new shape",
plugin_id, ", ".join(upgraded),
)
return prepared
def _strip_core_owned_keys(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""A shallow copy of ``config`` without the core's own tuning keys.
@@ -724,11 +771,20 @@ class PluginManager:
Returns:
Directory path as string or None if not found
``plugin_id`` often comes straight from a request, so anything that is
not one plain path segment (``..``, ``a/b``, an absolute path) is
refused instead of being joined onto ``plugins_dir``. The join is not
resolved further: dev plugins are symlinks into ``plugins_dir``.
"""
with self._discovery_lock:
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id])
plugin_id = safe_path_component(plugin_id)
if plugin_id is None:
return None
plugin_dir = self.plugins_dir / plugin_id
if plugin_dir.exists():
return str(plugin_dir)
+347 -188
View File
@@ -13,6 +13,303 @@ from typing import Any, Dict, List, Optional, Tuple
import jsonschema
from jsonschema import Draft7Validator, ValidationError
from src.core_config_keys import CORE_CONFIG_KEYS
def _renders_as_object(prop: Dict[str, Any]) -> bool:
"""``field_type == 'object'`` as ``plugin_config.html`` computes it.
The template takes a type list's *first* entry, so ``["object", "null"]``
is an object and ``["boolean", "object"]`` is a checkbox.
"""
field_type = prop.get('type')
if isinstance(field_type, list):
field_type = field_type[0] if field_type else None
return field_type == 'object'
def legacy_bool_as_object(value: Any, prop: Any) -> Any:
"""Read a boolean stored where the schema now has an ``{enabled, ...}`` object.
Plugins turn an on/off switch into a settings object (news'
``global.dynamic_duration: true`` became ``{enabled, min_duration_seconds,
...}``), but config.json keeps the boolean until the user next saves that
plugin's form. The boolean was the switch the object's ``enabled`` now
holds, so it becomes ``{"enabled": value}`` and schema defaults fill the rest.
Returns ``value`` itself when the rule does not apply: anything that is not
a real ``bool`` (``1``, ``"true"``, ``None``) or a schema property that is not
an object with an ``enabled`` child. Those stay as they are, so validation
still reports a genuine mismatch.
This is the rule ``render_nested_section`` in
``web_interface/templates/v3/partials/plugin_config.html`` applies when it
draws the form. ``test/test_legacy_boolean_config.py`` renders that macro
against this function, so change both together.
"""
if not isinstance(value, bool) or not isinstance(prop, dict):
return value
properties = prop.get('properties')
if (_renders_as_object(prop) and isinstance(properties, dict)
and 'enabled' in properties):
return {'enabled': value}
return value
def normalize_legacy_booleans(config: Any, schema: Any,
changed_paths: Optional[List[str]] = None,
_prefix: str = '') -> Any:
"""Apply :func:`legacy_bool_as_object` at every depth of a plugin config.
Walks the config along the schema's ``properties`` the way the settings
form does: into nested objects, not into array items (the form hands arrays
to widgets and never applies the rule there).
Never mutates ``config``. Returns the same object when nothing changed, and
otherwise copies only the dicts on the path to each upgraded value. When
``changed_paths`` is given, the dotted path of each upgraded value is
appended to it.
"""
if not isinstance(config, dict) or not isinstance(schema, dict):
return config
properties = schema.get('properties')
if not isinstance(properties, dict):
return config
result = config
for key, value in config.items():
prop = properties.get(key)
if not isinstance(prop, dict) or not _renders_as_object(prop):
continue
path = f"{_prefix}.{key}" if _prefix else key
new_value = legacy_bool_as_object(value, prop)
if new_value is not value:
if changed_paths is not None:
changed_paths.append(path)
elif isinstance(value, dict):
new_value = normalize_legacy_booleans(value, prop, changed_paths, path)
if new_value is not value:
if result is config:
result = dict(config)
result[key] = new_value
return result
#: Per-plugin settings the **core** owns: it reads them out of each plugin's
#: config section, so they are allowed in every plugin's config whether or not
#: the plugin's schema declares them. The one list for validation, for the web
#: save filter and for the load-time checks -- a private copy is how JSON saves
#: came to drop ``skin`` and the ``vegas_*`` keys while the validator accepted
#: them.
#:
#: Values are the schema used when the plugin does not declare the property.
CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
# Defaults match BasePlugin behavior: enabled=True, display_duration=15,
# live_priority=False.
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds"
},
"live_priority": {
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
},
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an enum here:
# validation must keep passing when a configured skin gets uninstalled
# (rendering falls back to built-in). The install-dependent enum is
# injected only at serve time (inject_skin_selector) for the web UI
# dropdown.
"skin": {
"type": ["string", "object", "null"],
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
},
"skin_options": {
"type": "object",
"description": "Options passed through to the selected skin"
},
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
# Left untyped: the adapter validates them itself and ignores a bad
# value with a log line, so a stored one must never block a save.
"vegas_width_pct": {
"description": "Vegas mode: width of this plugin's card, as a percentage of the panel"
},
"vegas_overflow": {
"description": "Vegas mode: 'rotate' or 'truncate' when this plugin's content overflows"
},
"vegas_max_width_screens": {
"description": "Vegas mode: widest this plugin's card may be, in screens"
},
}
#: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin
#: state. PluginManager strips these before its soft validation (see
#: PluginManager.CORE_OWNED_CONFIG_KEYS).
CORE_VEGAS_TUNING_KEYS = frozenset({
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
})
def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
"""A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed.
Properties the plugin declares itself are left as declared. Core
properties are removed from ``required``: they are system-managed.
"""
enhanced = copy.deepcopy(schema) if isinstance(schema, dict) else {}
properties = enhanced.setdefault("properties", {})
for name, definition in CORE_PLUGIN_PROPERTIES.items():
if name not in properties:
properties[name] = copy.deepcopy(definition)
if "required" in enhanced:
enhanced["required"] = [field for field in enhanced["required"]
if field not in CORE_PLUGIN_PROPERTIES]
return enhanced
def extract_schema_defaults(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Default values of a JSON Schema's properties, recursively.
A property's own ``default`` wins; otherwise a nested object contributes
its children's defaults, and an array contributes ``[]`` (or a one-item
list of its ``items`` default). This is what a device runs with, so the
dev tools use it too (src/plugin_system/testing/loading.py).
"""
defaults: Dict[str, Any] = {}
properties = schema.get('properties', {}) if isinstance(schema, dict) else {}
if not isinstance(properties, dict):
return defaults
for key, prop_schema in properties.items():
if not isinstance(prop_schema, dict):
continue
# If property has a default, use it
if 'default' in prop_schema:
defaults[key] = prop_schema['default']
continue
# Handle nested objects
if prop_schema.get('type') == 'object' and 'properties' in prop_schema:
nested_defaults = extract_schema_defaults(prop_schema)
if nested_defaults:
defaults[key] = nested_defaults
# Handle arrays with object items
elif prop_schema.get('type') == 'array' and 'items' in prop_schema:
items_schema = prop_schema['items']
if items_schema.get('type') == 'object' and 'properties' in items_schema:
# For arrays of objects, use empty array as default
# Individual objects will use their defaults when created
defaults[key] = []
elif 'default' in items_schema:
# Array with default item value
defaults[key] = [items_schema['default']]
else:
# Empty array as default
defaults[key] = []
# For other types without defaults, don't add to defaults dict
# This allows plugins to handle missing values as needed
return defaults
def plugin_config_defaults(schema: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Every default a plugin's config gets: the schema's plus the core ones.
A plugin with no schema gets the minimal ``enabled: False,
display_duration: 15``. Device location is not applied here; that needs a
config manager (SchemaManager.generate_default_config).
"""
if not schema:
return {
'enabled': False,
'display_duration': 15
}
defaults = extract_schema_defaults(schema)
# Ensure core properties have defaults (they may not be in the schema)
# These match BasePlugin behavior
for name in ('enabled', 'display_duration', 'live_priority'):
if name not in defaults:
defaults[name] = CORE_PLUGIN_PROPERTIES[name]['default']
return defaults
def merge_config_defaults(config: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]:
"""Merge configuration with defaults, preserving user values.
Also replaces None values with defaults so a config never starts with
None where a default exists. Neither argument is mutated.
"""
merged = copy.deepcopy(defaults)
def deep_merge(target: Dict[str, Any], source: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively merge source into target, replacing None with defaults."""
for key, value in source.items():
default_value = default_dict.get(key)
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
# Both are dicts, recursively merge
if isinstance(default_value, dict):
deep_merge(target[key], value, default_value)
else:
deep_merge(target[key], value, {})
elif value is None and default_value is not None:
# Value is None and we have a default, use the default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
else:
# Normal merge: user value takes precedence (copy if dict/list)
if isinstance(value, (dict, list)):
target[key] = copy.deepcopy(value)
else:
target[key] = value
deep_merge(merged, config, defaults)
# Final pass: replace any remaining None values at any level with defaults
def replace_none_with_defaults(target: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively replace None values with defaults."""
for key in list(target.keys()):
value = target[key]
default_value = default_dict.get(key)
if value is None and default_value is not None:
# Replace None with default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
elif isinstance(value, dict) and isinstance(default_value, dict):
# Recursively process nested dicts
replace_none_with_defaults(value, default_value)
replace_none_with_defaults(merged, defaults)
return merged
def prepare_plugin_config(config: Any, schema: Optional[Dict[str, Any]],
defaults: Dict[str, Any],
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
"""The config a plugin runs with, from its stored (or submitted) section.
Legacy booleans are read as ``{"enabled": ...}`` objects
(normalize_legacy_booleans), then schema defaults fill in whatever is
missing. Loading a plugin, both config saves, GET /plugins/config, hot
reload and the dev tools all go through this, so a plugin sees the same
shape however its config reached it.
"""
config = config if isinstance(config, dict) else {}
if schema:
config = normalize_legacy_booleans(config, schema, changed_paths)
return merge_config_defaults(config, defaults)
class SchemaManager:
"""
@@ -183,57 +480,12 @@ class SchemaManager:
def extract_defaults_from_schema(self, schema: Dict[str, Any], prefix: str = '') -> Dict[str, Any]:
"""
Recursively extract default values from a JSON Schema.
Handles nested objects, arrays, and all schema types.
Args:
schema: JSON Schema dictionary
prefix: Optional prefix for logging/debugging
Returns:
Dictionary of default values
See :func:`extract_schema_defaults`; ``prefix`` is accepted for
compatibility and unused.
"""
defaults = {}
# Handle schema with properties
properties = schema.get('properties', {})
if not properties:
return defaults
for key, prop_schema in properties.items():
field_path = f"{prefix}.{key}" if prefix else key
# If property has a default, use it
if 'default' in prop_schema:
defaults[key] = prop_schema['default']
self.logger.debug(f"Found default for {field_path}: {prop_schema['default']}")
continue
# Handle nested objects
if prop_schema.get('type') == 'object' and 'properties' in prop_schema:
nested_defaults = self.extract_defaults_from_schema(prop_schema, field_path)
if nested_defaults:
defaults[key] = nested_defaults
# Handle arrays with object items
elif prop_schema.get('type') == 'array' and 'items' in prop_schema:
items_schema = prop_schema['items']
if items_schema.get('type') == 'object' and 'properties' in items_schema:
# For arrays of objects, use empty array as default
# Individual objects will use their defaults when created
defaults[key] = []
elif 'default' in items_schema:
# Array with default item value
defaults[key] = [items_schema['default']]
else:
# Empty array as default
defaults[key] = []
# For other types without defaults, don't add to defaults dict
# This allows plugins to handle missing values as needed
return defaults
return extract_schema_defaults(schema)
def get_device_location(self) -> Optional[Dict[str, Any]]:
"""
Return the device-wide ``location`` block from config.json, or None.
@@ -312,31 +564,39 @@ class SchemaManager:
schema = self.load_schema(plugin_id, use_cache=use_cache)
if not schema:
# Return minimal defaults if no schema
return {
'enabled': False,
'display_duration': 15
}
# Extract defaults from schema
defaults = self.extract_defaults_from_schema(schema)
# Ensure core properties have defaults (they may not be in the schema)
# These match BasePlugin behavior
if 'enabled' not in defaults:
defaults['enabled'] = schema.get('properties', {}).get('enabled', {}).get('default', True)
if 'display_duration' not in defaults:
defaults['display_duration'] = schema.get('properties', {}).get('display_duration', {}).get('default', 15)
if 'live_priority' not in defaults:
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
return plugin_config_defaults(None)
# Schema defaults plus the core properties' (they may not be in the
# schema)
defaults = plugin_config_defaults(schema)
# Cache the defaults *before* the device location is layered on, so a
# later change to the device location is picked up by the next call.
self._defaults_cache[plugin_id] = defaults.copy()
return self.apply_device_location(defaults)
def prepare_plugin_config(self, plugin_id: str, config: Any,
schema: Optional[Dict[str, Any]] = None,
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
"""
The config a plugin runs with: see :func:`prepare_plugin_config`.
Args:
plugin_id: Plugin identifier
config: The plugin's stored or submitted config section
schema: The plugin's schema, when the caller already has it
changed_paths: Receives the dotted path of each legacy boolean
read as an object
Returns:
A new dict; ``config`` is not mutated
"""
if schema is None:
schema = self.load_schema(plugin_id, use_cache=True)
defaults = self.generate_default_config(plugin_id, use_cache=True)
return prepare_plugin_config(config, schema, defaults, changed_paths)
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
"""
@@ -357,80 +617,18 @@ class SchemaManager:
errors = []
try:
# Core plugin properties that should always be allowed
# These are handled by the base plugin system and should not cause validation failures
# Defaults match BasePlugin behavior: enabled=True, display_duration=15, live_priority=False
core_properties = {
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds"
},
"live_priority": {
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
},
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
# enum here: validation must keep passing when a configured
# skin gets uninstalled (rendering falls back to built-in).
# The install-dependent enum is injected only at serve time
# (inject_skin_selector) for the web UI dropdown.
"skin": {
"type": ["string", "object", "null"],
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
},
"skin_options": {
"type": "object",
"description": "Options passed through to the selected skin"
}
}
# Create a deep copy of the schema to modify (to avoid mutating the original)
enhanced_schema = copy.deepcopy(schema)
if "properties" not in enhanced_schema:
enhanced_schema["properties"] = {}
# Inject core properties if they're not already defined in the schema
# This ensures core properties are always allowed even if not in the plugin's schema
properties_added = []
for prop_name, prop_def in core_properties.items():
if prop_name not in enhanced_schema["properties"]:
enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def)
properties_added.append(prop_name)
# Log if we added any core properties (for debugging)
if properties_added and plugin_id:
# Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by
# the base plugin system and should not cause validation failures:
# they are allowed even when the plugin's schema doesn't declare
# them, and never required.
enhanced_schema = with_core_plugin_properties(schema)
if plugin_id:
declared = schema.get("properties", {}) if isinstance(schema, dict) else {}
self.logger.debug(
f"Injected core properties into schema for {plugin_id}: {properties_added}"
"Injected core properties into schema for %s: %s", plugin_id,
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
)
# Remove core properties from required array (they're system-managed)
# Core properties should be allowed but not required for validation
if "required" in enhanced_schema:
core_prop_names = list(core_properties.keys())
removed_from_required = [
field for field in enhanced_schema["required"]
if field in core_prop_names
]
enhanced_schema["required"] = [
field for field in enhanced_schema["required"]
if field not in core_prop_names
]
# Log if we removed any core properties from required (for debugging)
if removed_from_required and plugin_id:
self.logger.debug(
f"Removed core properties from required array for {plugin_id}: {removed_from_required}"
)
# Create validator with enhanced schema
validator = Draft7Validator(enhanced_schema)
@@ -547,55 +745,15 @@ class SchemaManager:
"""
Merge configuration with defaults, preserving user values.
Also replaces None values with defaults to ensure config never has None from the start.
Args:
config: User configuration
defaults: Default values from schema
Returns:
Merged configuration with defaults applied where missing or None
"""
merged = copy.deepcopy(defaults)
def deep_merge(target: Dict[str, Any], source: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively merge source into target, replacing None with defaults."""
for key, value in source.items():
default_value = default_dict.get(key)
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
# Both are dicts, recursively merge
if isinstance(default_value, dict):
deep_merge(target[key], value, default_value)
else:
deep_merge(target[key], value, {})
elif value is None and default_value is not None:
# Value is None and we have a default, use the default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
else:
# Normal merge: user value takes precedence (copy if dict/list)
if isinstance(value, (dict, list)):
target[key] = copy.deepcopy(value)
else:
target[key] = value
deep_merge(merged, config, defaults)
# Final pass: replace any remaining None values at any level with defaults
def replace_none_with_defaults(target: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively replace None values with defaults."""
for key in list(target.keys()):
value = target[key]
default_value = default_dict.get(key)
if value is None and default_value is not None:
# Replace None with default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
elif isinstance(value, dict) and isinstance(default_value, dict):
# Recursively process nested dicts
replace_none_with_defaults(value, default_value)
replace_none_with_defaults(merged, defaults)
return merged
return merge_config_defaults(config, defaults)
def detect_config_key_collisions(
self,
@@ -619,10 +777,11 @@ class SchemaManager:
"""
collisions = []
# Reserved top-level config keys that plugins should not use as IDs
reserved_keys = {
'display', 'schedule', 'timezone', 'plugin_system',
'display_modes', 'system', 'hardware', 'debug',
# Reserved top-level config keys that plugins should not use as IDs:
# every core section (src/core_config_keys.py), plus a few names that
# read as core even though no current section uses them.
reserved_keys = set(CORE_CONFIG_KEYS) | {
'display_modes', 'hardware', 'debug',
'log_level', 'emulator', 'web_interface'
}
+49 -32
View File
@@ -14,6 +14,7 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS
from src.plugin_system.state_manager import PluginStateManager
from src.logging_config import get_logger
@@ -72,9 +73,16 @@ def secrets_top_level_keys(config_manager) -> Set[str]:
return set(secrets) if isinstance(secrets, dict) else set()
def ignored_config_keys(config_manager) -> Set[str]:
"""Config keys that are not plugin ids: system keys plus secrets keys."""
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
def ignored_config_keys(config_manager, installed_ids=frozenset()) -> Set[str]:
"""Config keys that are not plugin ids: core keys plus secrets keys.
A secrets key that is also an installed plugin's id is not ignored: plugin
secrets are namespaced by plugin id, so that key is the plugin's own entry.
Ignoring it made every installed plugin with secrets read as "installed but
missing from config" on each run.
"""
secrets_only = secrets_top_level_keys(config_manager) - set(installed_ids)
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_only
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
@@ -132,6 +140,13 @@ def still_unresolved(entries: List[Dict[str, Any]],
plugins were all present in config kept being told, for hours, that four of
them were missing and should be removed from config.json.
An "in config but not on disk" finding is stale once the plugin is
installed, and equally once the id is no longer a plugin entry in config --
whether the user removed it or it was never a plugin (a core key such as
``auto_update`` that an older build misread). Without the second check a
verdict written by such a build kept telling users to delete a real core
setting until the next full reconciliation run.
Entry kinds this cannot re-check are kept, so filtering only ever removes
findings that are provably stale.
"""
@@ -143,7 +158,7 @@ def still_unresolved(entries: List[Dict[str, Any]],
if plugin_id not in config_keys:
live.append(entry)
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
if plugin_id not in installed_ids:
if plugin_id in config_keys and plugin_id not in installed_ids:
live.append(entry)
else:
live.append(entry)
@@ -280,39 +295,22 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}"
)
# Top-level config keys that are NOT plugins.
# Includes both config.json structural keys and config_secrets.json top-level
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
_SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule',
# Multi-display sync config (config.json structural key)
'sync',
# Secrets file top-level keys (merged in by load_config)
'github', 'youtube',
})
def _secrets_top_level_keys(self) -> Set[str]:
"""Top-level keys that load_config() merges in from the secrets file.
load_config() merges config_secrets.json into the config it returns, so
those keys sit alongside plugin ids. _SYSTEM_CONFIG_KEYS named them
individually ('github', 'youtube'), which broke the moment anything else
was written there: a 'data' key became a phantom plugin, permanently
reported as "in config but not on disk". Reading the file keeps this
correct no matter what it holds.
"""
return secrets_top_level_keys(self.config_manager)
# Top-level config keys that are NOT plugins. The core keys come from the
# shared list in src/core_config_keys.py -- a private copy here missed
# #581's 'auto_update' and reported it as a plugin missing from disk.
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
# itself is read at run time too (ignored_config_keys): load_config() merges
# it in, and naming its keys one by one let a 'data' key become a phantom
# plugin permanently reported as "in config but not on disk".
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file."""
state = {}
try:
config = self.config_manager.load_config()
ignored = self._SYSTEM_CONFIG_KEYS | self._secrets_top_level_keys()
ignored = ignored_config_keys(self.config_manager,
disk_plugin_ids(self.plugins_dir))
for plugin_id in config_plugin_ids(config, ignored):
plugin_config = config[plugin_id]
state[plugin_id] = {
@@ -389,7 +387,20 @@ class StateReconciliation:
) -> List[Inconsistency]:
"""Check consistency for a single plugin."""
inconsistencies = []
if plugin_id in CORE_CONFIG_KEYS:
# A plugin whose id is a core setting's key ('display', 'sync',
# ...) can never have a config section of its own: that section
# is the core setting. Every check below would misfire -- "not in
# config" forever, and an auto-fix that writes {'enabled': False}
# where a core setting belongs -- so report it in the log instead.
if disk_state.get(plugin_id, {}).get('exists_on_disk'):
self.logger.warning(
"Plugin id %r is reserved for a core config setting; "
"the plugin cannot be configured and is skipped by "
"reconciliation. Rename the plugin.", plugin_id)
return inconsistencies
config = config_state.get(plugin_id, {})
disk = disk_state.get(plugin_id, {})
state_mgr = state_manager_state.get(plugin_id, {})
@@ -468,6 +479,12 @@ class StateReconciliation:
"""Attempt to fix an inconsistency."""
try:
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
if inconsistency.plugin_id in CORE_CONFIG_KEYS:
# Never create or replace a core setting with a plugin stub.
self.logger.warning(
"Refusing to add plugin entry %r: that key is a core "
"config setting", inconsistency.plugin_id)
return False
config = self.config_manager.load_config()
if inconsistency.plugin_id in config:
# Detection said "not in config" but it is there -- the
+2 -4
View File
@@ -27,7 +27,7 @@ from PIL import Image, ImageChops
from src.logging_config import get_logger
from .bounds_display_manager import BoundsCheckingDisplayManager
from .loading import load_config_defaults, load_manifest, merge_config
from .loading import build_config, load_manifest
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
logger = get_logger("[Plugin Harness]")
@@ -305,9 +305,7 @@ def render_plugin_matrix(
manifest = load_manifest(plugin_dir)
# Start from config_schema.json defaults so the plugin behaves like a real
# install; explicit caller config still wins over a schema default.
config = merge_config(
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
config or {})
config = build_config(plugin_dir, config)
sizes = sizes or DEFAULT_TEST_SIZES
results: List[RenderResult] = []
+45 -39
View File
@@ -42,29 +42,6 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
return json.load(f)
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
"""Defaults for one `properties` block, recursing into nested objects.
An object property carries its defaults on its children, not on itself, so
reading only the top level dropped everything nested. That is most of the
fleet: config organised by league, or under customization/display_options,
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
539 of 565 -- and the harness rendered them with a config no install would
ever have.
"""
defaults: Dict[str, Any] = {}
for key, prop in (properties or {}).items():
if not isinstance(prop, dict):
continue
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
nested = _defaults_from_properties(prop['properties'])
if nested:
defaults[key] = nested
elif 'default' in prop:
defaults[key] = prop['default']
return defaults
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Deep-merge override onto base, without dropping sibling defaults.
@@ -81,14 +58,45 @@ def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, An
return merged
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Extract default values from a plugin's config_schema.json (empty if none)."""
def load_schema(plugin_dir: Union[str, Path]) -> Optional[Dict[str, Any]]:
"""A plugin's config_schema.json, or None when it has none."""
schema_path = Path(plugin_dir) / 'config_schema.json'
if not schema_path.exists():
return {}
return None
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
return _defaults_from_properties(schema.get('properties', {}))
return json.load(f)
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Default values from a plugin's config_schema.json (empty if none).
The device's own extraction (schema_manager.extract_schema_defaults), so a
harness run starts from the config an install would have: nested objects
contribute their children's defaults (organised-by-league configs lost
thousands of them when only the top level was read), and arrays without a
default start as [].
"""
from src.plugin_system.schema_manager import extract_schema_defaults
schema = load_schema(plugin_dir)
return extract_schema_defaults(schema) if schema else {}
def build_config(plugin_dir: Union[str, Path],
overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""The config a device would give this plugin, with ``overrides`` applied.
Starts from a forced ``enabled: True``, deep-merges ``overrides`` onto it
(merge_config), then prepares the result exactly as the device does when it
loads a plugin: legacy booleans read as objects, and schema plus core
defaults filled in (schema_manager.prepare_plugin_config). Used by the
harness, check_plugin, render_plugin and the dev preview server.
"""
from src.plugin_system.schema_manager import (
plugin_config_defaults, prepare_plugin_config,
)
schema = load_schema(plugin_dir)
requested = merge_config({"enabled": True}, overrides or {})
return prepare_plugin_config(requested, schema, plugin_config_defaults(schema))
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
@@ -143,16 +151,14 @@ def build_full_config(
Merge order: config_schema.json defaults, then a forced ``enabled: True``,
then harness.json's config overlay, then the caller's explicit config --
most specific wins. `enabled` is re-asserted *after* the schema defaults
so a plugin that reasonably ships `enabled: false` (e.g. a seasonal or
opt-in plugin) can't silently make every harness run test "disabled, do
nothing" by accident -- callers that genuinely want to test the disabled
path can still do so via `cli_config={"enabled": False}`.
most specific wins, and each layer deep-merges (a nested override such as
``{"nhl": {"enabled": true}}`` keeps the other nhl defaults). `enabled` is
asserted over the schema defaults so a plugin that reasonably ships
`enabled: false` (e.g. a seasonal or opt-in plugin) can't silently make
every harness run test "disabled, do nothing" by accident -- callers that
genuinely want to test the disabled path can still do so via
`cli_config={"enabled": False}`. See build_config.
"""
spec = spec or {}
config: Dict[str, Any] = {}
config.update(load_config_defaults(plugin_dir))
config["enabled"] = True
config.update(spec.get("config", {}))
config.update(cli_config or {})
return config
overrides = merge_config(spec.get("config", {}) or {}, cli_config or {})
return build_config(plugin_dir, overrides)
+4 -2
View File
@@ -8,6 +8,7 @@ Fails fast with clear error messages to prevent runtime issues.
import os
from typing import Any, List, Optional, Tuple
from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS
from src.exceptions import ConfigError, PluginError, CacheError
from src.logging_config import get_logger
@@ -275,8 +276,9 @@ class StartupValidator:
# Check for enabled plugins that don't exist
for plugin_id, plugin_config in config.items():
# Skip non-plugin config sections
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']:
# Skip core sections: auto_update and dim_schedule have an
# 'enabled' key too, and are not plugins that went missing.
if plugin_id in CORE_CONFIG_KEYS:
continue
if not isinstance(plugin_config, dict):
+7 -2
View File
@@ -155,9 +155,14 @@ class VegasModeConfig:
target_fps: int = 125 # Target frame rate
buffer_ahead: int = 2 # Number of plugins to buffer ahead
# Scroll behavior
# Scroll behavior. Neither key steps the scroll or sets a frame rate:
# motion is always by elapsed time at scroll_speed px/s. With
# frame_based_scrolling the speed is first converted to px per
# scroll_delay and clamped to 0.1-5 (ScrollHelper.set_scroll_speed), so
# the speed actually applied is clamp(scroll_speed * scroll_delay, 0.1, 5)
# / scroll_delay -- at the 0.02 default, speeds under 5 px/s run at 5.
frame_based_scrolling: bool = True
scroll_delay: float = 0.02 # 50 FPS effective scroll updates
scroll_delay: float = 0.02 # only feeds the clamp above; not a frame period
# Dynamic duration
dynamic_duration_enabled: bool = True
+7 -5
View File
@@ -127,12 +127,14 @@ class RenderPipeline:
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
# Config scroll_speed is always pixels per second, but ScrollHelper
# interprets it differently based on frame_based_scrolling mode:
# - Frame-based: pixels per frame step
# - Time-based: pixels per second
# takes it in different units depending on frame_based_scrolling:
# - Frame-based: pixels per scroll_delay seconds (clamped to 0.1-5)
# - Time-based: pixels per second (clamped to 1-500)
# Both modes then advance by elapsed time; frame-based mode does not
# step. So frame-based with scroll_delay only adds the clamp: the
# applied speed is clamp(scroll_speed * scroll_delay, 0.1, 5) /
# scroll_delay px/s.
if self.config.frame_based_scrolling:
# Convert pixels/second to pixels/frame
# pixels_per_frame = pixels_per_second * seconds_per_frame
pixels_per_frame = self.config.scroll_speed * self.config.scroll_delay
self.scroll_helper.set_scroll_speed(pixels_per_frame)
else:
+5 -3
View File
@@ -52,9 +52,11 @@ This directory contains systemd service unit files for LEDMatrix services.
## Installation
These service files are installed by the installation scripts in `scripts/install/`:
- `install_service.sh` installs `ledmatrix.service`
- `install_web_service.sh` installs `ledmatrix-web.service` and the
`ledmatrix-update-verify` service and path units
- `install_service.sh` installs `ledmatrix.service`, `ledmatrix-web.service`
and the `ledmatrix-update-verify` service and path units, then enables and
starts them
- `install_web_service.sh` installs only `ledmatrix-web.service` and the
`ledmatrix-update-verify` units
- `install_wifi_monitor.sh` installs `ledmatrix-wifi-monitor.service`
- `install_dns_fix.sh` installs `ledmatrix-dns-fix.service` (opt-in, not run
by the normal installer)
+7 -3
View File
@@ -25,9 +25,13 @@ RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=ledmatrix-web
# Automatically create and manage cache directory
CacheDirectory=ledmatrix
CacheDirectoryMode=0775
# No CacheDirectory=. /var/cache/ledmatrix is shared with ledmatrix.service,
# which runs as root, and the installers set it up as root:ledmatrix 2775 so
# both services can read what the other writes. CacheDirectory= makes systemd
# re-own that directory and everything in it to this unit's User= and that
# user's primary group whenever the owner differs -- which, for a directory
# root created, is on the first start. That erased the shared group, and from
# then on the web interface could not read a single file the display wrote.
[Install]
WantedBy=multi-user.target
+2
View File
@@ -35,10 +35,12 @@ nothing is listening, so it stays useful in a bare checkout.
| Suite | Needs a server | Covers |
|---|---|---|
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
+2 -1
View File
@@ -17,7 +17,8 @@ const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js'];
'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
@@ -0,0 +1,215 @@
// Registry- and upload-supplied strings must not escape the inline handlers
// plugins_manager.js builds for them.
//
// The store, saved-repository and custom-registry renderers wrote
//
// <button onclick='... installFromCustomRegistry(${JSON.stringify(id)}) ...'>
//
// JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id
// of x' onmouseover='alert(1) closed the single-quoted attribute and added a
// handler of its own. The live window.updateImageList (plugins_manager.js loads
// last, so its copy beats the file-upload widget's) put the uploaded file's
// original name into the markup unescaped.
//
// Each case renders with the shipped function, parses the tag the way a browser
// does (quoted attribute values, entities decoded), and checks two things: no
// attribute appeared that the template did not write, and the decoded handler
// runs and passes the hostile value through intact as data.
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
function extract(opener) {
const start = SRC.indexOf(opener);
if (start < 0) { console.error('FAIL: cannot find ' + JSON.stringify(opener)); process.exit(1); }
let depth = 0;
for (let j = SRC.indexOf('{', start); j < SRC.length; j++) {
if (SRC[j] === '{') depth++;
else if (SRC[j] === '}' && --depth === 0) return SRC.slice(start, j + 1);
}
console.error('FAIL: unbalanced braces after ' + opener); process.exit(1);
}
// ── DOM shim ───────────────────────────────────────────────────────────────
class FakeEl {
constructor() { this.innerHTML = ''; this.value = ''; this.textContent = ''; }
}
class TextEl {
set textContent(v) { this._t = String(v == null ? '' : v); }
get innerHTML() {
return (this._t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
const els = {};
global.document = {
getElementById: id => (els[id] ||= new FakeEl()),
createElement: () => new TextEl(),
};
global.window = global;
global.pluginLog = () => {};
global.isStorePluginInstalled = () => false;
global.isNewPlugin = () => false;
global.formatDate = () => '';
global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html; };
// eslint-disable-next-line no-eval
eval([
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {',
'function renderCustomRegistryPlugins(plugins, registryUrl) {',
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
// eslint-disable-next-line no-eval
eval(extract('window.updateImageList = function(fieldId, images) {'));
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
function decodeEntities(s) {
return s.replace(/&(#x[0-9a-f]+|#\d+|quot|amp|lt|gt|apos);/gi, (m, e) => {
const k = e.toLowerCase();
if (k === 'quot') return '"';
if (k === 'amp') return '&';
if (k === 'lt') return '<';
if (k === 'gt') return '>';
if (k === 'apos') return "'";
return String.fromCodePoint(k[1] === 'x' ? parseInt(k.slice(2), 16) : parseInt(k.slice(1), 10));
});
}
function tags(html, name) {
const out = [];
let i = 0;
while ((i = html.indexOf('<' + name, i)) >= 0) {
let j = i + name.length + 1;
const attrs = [];
for (;;) {
while (/\s/.test(html[j])) j++;
if (html[j] === '>' || j >= html.length) break;
if (html[j] === '/') { j++; continue; }
let n = '';
while (j < html.length && !/[\s=>]/.test(html[j])) n += html[j++];
let v = '';
while (/\s/.test(html[j])) j++;
if (html[j] === '=') {
j++;
while (/\s/.test(html[j])) j++;
const q = html[j];
if (q === '"' || q === "'") {
const end = html.indexOf(q, j + 1);
v = html.slice(j + 1, end); j = end + 1;
} else {
while (j < html.length && !/[\s>]/.test(html[j])) v += html[j++];
}
}
attrs.push([n.toLowerCase(), decodeEntities(v)]);
}
out.push(attrs);
i = j;
}
return out;
}
const names = attrs => attrs.map(a => a[0]);
const attr = (attrs, n) => (attrs.find(a => a[0] === n) || [])[1];
// Run a decoded inline handler with window/document stubs; return the calls.
function runHandler(code) {
const calls = [];
const win = new Proxy({}, {
get: (_, prop) => (...args) => { calls.push([prop, ...args]); },
has: () => true,
});
const doc = { getElementById: () => ({ value: '' }) };
// eslint-disable-next-line no-new-func
new Function('window', 'document', 'console', code)(win, doc, { error() {} });
return calls;
}
const SQ = "x' onmouseover='alert(1)";
const DQ = 'x" onmouseover="alert(1)';
const AMP = 'x&#39; onmouseover=&#39;alert(1)';
console.log('\n1. custom registry install button');
for (const hostile of [SQ, DQ, AMP]) {
renderCustomRegistryPlugins([{ id: hostile, name: 'n', plugin_path: hostile }], hostile);
const buttons = tags(els['custom-registry-grid'].innerHTML, 'button');
const b = buttons.find(a => attr(a, 'onclick'));
ok(`${JSON.stringify(hostile)}: only onclick and class attributes`,
b && names(b).join(',') === 'onclick,class', b && names(b));
let calls = [];
try { calls = runHandler(attr(b, 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
ok(`${JSON.stringify(hostile)}: handler passes id, url and path intact`,
calls.length === 1 && calls[0][0] === 'installFromCustomRegistry'
&& calls[0][1] === hostile && calls[0][2] === hostile && calls[0][3] === hostile, calls);
}
console.log('\n2. store install and view buttons');
for (const hostile of [SQ, DQ, AMP]) {
renderPluginStore([{ id: hostile, name: 'n', repo: 'https://github.com/o/r', plugin_path: 'p' }]);
const buttons = tags(els['plugin-store-grid'].innerHTML, 'button');
ok(`${JSON.stringify(hostile)}: two buttons, no extra attributes`,
buttons.length === 2 && buttons.every(b => names(b).every(n => ['onclick', 'class', 'disabled'].includes(n))),
buttons.map(names));
let calls = [];
try { calls = runHandler(attr(buttons[0], 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
ok(`${JSON.stringify(hostile)}: install handler gets the id intact`,
calls.length === 1 && calls[0][0] === 'installPlugin' && calls[0][1] === hostile, calls);
}
console.log('\n3. store view button only opens http(s) links');
renderPluginStore([{ id: 'a', name: 'n', repo: 'https://github.com/o/r', plugin_path: 'plugins/a' }]);
{
const view = tags(els['plugin-store-grid'].innerHTML, 'button')[1];
const calls = runHandler(attr(view, 'onclick'));
ok('https repo opens the plugin tree',
calls.length === 1 && calls[0][0] === 'open' && calls[0][1] === 'https://github.com/o/r/tree/main/plugins/a', calls);
}
renderPluginStore([{ id: 'a', name: 'n', repo: 'javascript:alert(document.domain)//' }]);
{
const view = tags(els['plugin-store-grid'].innerHTML, 'button')[1];
const calls = runHandler(attr(view, 'onclick'));
ok('javascript: repo opens nothing', calls.length === 0, calls);
ok('javascript: repo button is disabled', names(view).includes('disabled'), names(view));
}
console.log('\n4. saved repository remove button');
for (const hostile of [SQ, DQ, AMP]) {
renderSavedRepositories([{ url: hostile, name: hostile }]);
const b = tags(els['saved-repositories-list'].innerHTML, 'button')[0];
ok(`${JSON.stringify(hostile)}: no extra attributes`,
b && names(b).join(',') === 'onclick,class,title,aria-label', b && names(b));
let calls = [];
try { calls = runHandler(attr(b, 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
ok(`${JSON.stringify(hostile)}: handler gets the url intact`,
calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls);
}
console.log('\n5. live window.updateImageList escapes the uploaded file name');
window.getUploadConfig = () => ({ plugin_id: SQ });
window.currentPluginConfig = null;
{
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
const html = els['f_image_list'].innerHTML;
ok('no raw markup from original_filename', !html.includes('<img src=x onerror'), html);
const imgs = tags(html, 'img');
ok('one <img>, only the template attributes',
imgs.length === 1 && names(imgs[0]).join(',') === 'src,alt,loading,decoding,class,onerror', imgs.map(names));
ok('alt carries the stored filename as text', attr(imgs[0], 'alt') === DQ, imgs[0]);
const buttons = tags(html, 'button');
ok('two buttons, no extra attributes',
buttons.length === 2 && buttons.every(b => names(b).join(',') === 'type,onclick,class,title,aria-label'),
buttons.map(names));
const del = runHandler(attr(buttons[1], 'onclick'));
ok('delete handler gets field, image and plugin ids intact',
del.length === 1 && del[0][0] === 'deleteUploadedImage' && del[0][1] === 'f' && del[0][2] === SQ && del[0][3] === SQ, del);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+234
View File
@@ -0,0 +1,234 @@
// "Check & Update All" -- which ids it sends to POST /api/v3/plugins/update.
//
// Pins two bugs seen on a real device (core 3.4.0):
//
// 1. /plugins/installed lists installed Starlark apps as virtual
// `starlark:<app_id>` entries. Update-all sent those to the plugin
// updater, which answered 500 "plugin not found" for each one.
//
// 2. A web-service restart landed mid-run. The request in flight died with
// the old process and the next one (stock-news) was refused while the
// service was coming back; both were recorded as failures and never sent
// again, so stock-news -- installed, disabled, with an update waiting --
// was silently not updated.
//
// Runs the shipped install_manager.js (it exports itself under node) against
// a fake PluginAPI. The installed list is the device's, trimmed.
const path = require('path');
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
global.window = {};
const Manager = require(path.join(V3, 'js/plugins/install_manager.js'));
const INSTALLED = [
{ id: 'ledmatrix-flights', enabled: true, version: '1.14.0' },
{ id: 'stock-news', enabled: false, version: '2.6.2', latest_version: '2.8.0', update_available: true },
{ id: 'static-image', enabled: false, version: '1.1.3' },
{ id: 'starlark-apps', enabled: false, version: '1.0.0' }, // a real plugin -- keep it
{ id: 'pomodoro-timer', enabled: false, version: '1.3.6' },
{ id: 'starlark:analogtime', enabled: false, version: 'starlark', is_starlark_app: true },
{ id: 'starlark:analogclock', enabled: true, version: 'starlark', is_starlark_app: true },
];
const EXPECTED = ['ledmatrix-flights', 'stock-news', 'static-image', 'starlark-apps', 'pomodoro-timer'];
const netErr = () => ({ error_code: 'NETWORK_ERROR', message: 'Failed to fetch' });
function fakeApi(behaviour = {}) {
const calls = [];
return {
calls,
updatePlugin: async (id) => {
calls.push(id);
const b = behaviour[id];
if (typeof b === 'function') return b(calls.filter(c => c === id).length);
return { status: 'success', message: `Plugin ${id} updated successfully` };
},
};
}
function setup(api, { stateList, windowList } = {}) {
global.window = {
PluginAPI: api,
installedPlugins: windowList,
PluginStateManager: stateList === undefined ? undefined : {
installedPlugins: stateList,
loadInstalledPlugins: async () => stateList,
},
};
}
const noSleep = { sleep: async () => {} };
(async () => {
console.log('\nselection');
ok('starlark app entries are not updatable',
!Manager.isUpdatablePlugin(INSTALLED[5]) && !Manager.isUpdatablePlugin(INSTALLED[6]));
ok('a starlark: id without the flag is still excluded',
!Manager.isUpdatablePlugin({ id: 'starlark:foo' }));
ok('the starlark-apps plugin itself is updatable', Manager.isUpdatablePlugin(INSTALLED[3]));
ok('a disabled plugin with an update is updatable', Manager.isUpdatablePlugin(INSTALLED[1]));
ok('entries without a usable id are skipped',
!Manager.isUpdatablePlugin({}) && !Manager.isUpdatablePlugin(null) && !Manager.isUpdatablePlugin({ id: '' }));
const sel = Manager.updatablePlugins(INSTALLED).map(p => p.id);
ok('updatablePlugins keeps list order and drops only starlark apps',
JSON.stringify(sel) === JSON.stringify(EXPECTED), sel);
console.log('\nupdateAll sends only plugin ids');
{
const api = fakeApi();
setup(api, { windowList: INSTALLED });
const progress = [];
const results = await Manager.updateAll((i, n, id) => progress.push([i, n, id]), noSleep);
ok('POSTs exactly the non-starlark ids, in order',
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
ok('no starlark: id reached the plugin updater', !api.calls.some(id => id.startsWith('starlark:')));
ok('one result per plugin sent', results.length === EXPECTED.length, results.length);
ok('progress total counts only what is sent',
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
}
{
const api = fakeApi();
setup(api, { stateList: INSTALLED, windowList: [] });
await Manager.updateAll(null, noSleep);
ok('the PluginStateManager list is filtered the same way',
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
}
{
const api = fakeApi();
setup(api, { windowList: INSTALLED.filter(p => p.is_starlark_app) });
const results = await Manager.updateAll(null, noSleep);
ok('a list of only starlark apps sends nothing', api.calls.length === 0 && results.length === 0);
}
console.log('\nweb service restarting mid-run (the stock-news case)');
{
// ledmatrix-flights is in flight when the service stops (reset); stock-news
// is refused twice while it comes back; then everything answers.
const api = fakeApi({
'ledmatrix-flights': (n) => { if (n === 1) throw netErr(); return { status: 'success', message: 'ok' }; },
'stock-news': (n) => { if (n <= 2) throw netErr(); return { status: 'success', message: 'Plugin stock-news updated successfully' }; },
});
setup(api, { windowList: INSTALLED });
const slept = [];
const results = await Manager.updateAll(null, { sleep: async ms => { slept.push(ms); }, retryDelaysMs: [5, 10, 20] });
const byId = Object.fromEntries(results.map(r => [r.pluginId, r]));
ok('stock-news is sent again until the server answers',
api.calls.filter(id => id === 'stock-news').length === 3, api.calls);
ok('stock-news ends up updated, not skipped', byId['stock-news'] && byId['stock-news'].success === true, byId['stock-news']);
ok('the request lost with the old process is re-sent too',
byId['ledmatrix-flights'] && byId['ledmatrix-flights'].success === true);
ok('it backs off between attempts', JSON.stringify(slept) === JSON.stringify([5, 5, 10]), slept);
ok('every plugin still gets exactly one result',
JSON.stringify(results.map(r => r.pluginId)) === JSON.stringify(EXPECTED), results.map(r => r.pluginId));
}
{
const api = fakeApi({ 'stock-news': () => { throw netErr(); } });
setup(api, { windowList: INSTALLED });
const results = await Manager.updateAll(null, { sleep: async () => {}, retryDelaysMs: [1, 1] });
const r = results.find(x => x.pluginId === 'stock-news');
ok('a server that never comes back is retried a bounded number of times',
api.calls.filter(id => id === 'stock-news').length === 3, api.calls);
ok('...then reported as a failure, and the run continues',
r && r.success === false && api.calls[api.calls.length - 1] === 'pomodoro-timer');
}
{
const api = fakeApi({ 'static-image': () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'check logs' }; } });
setup(api, { windowList: INSTALLED });
const results = await Manager.updateAll(null, { sleep: async () => { throw new Error('must not sleep'); } });
ok('an HTTP error answer is not retried',
api.calls.filter(id => id === 'static-image').length === 1, api.calls);
ok('...and is reported as a failure', results.find(x => x.pluginId === 'static-image').success === false);
}
console.log('\nthe real api_client.js: only a missing HTTP answer is retried');
{
// The fake PluginAPI above decides error_code itself. This runs the shipped
// client so its classification is what gets tested: a proxy's 502 page or
// a JSON 500 without error_code used to come back as NETWORK_ERROR and be
// re-sent five more times.
const PluginAPI = require(path.join(V3, 'js/plugins/api_client.js'));
const run = async (makeFetch) => {
let requests = 0, sleeps = 0;
global.fetch = async () => { requests++; return makeFetch(requests); };
global.window = { PluginAPI, installedPlugins: [{ id: 'stock-news' }] };
const results = await Manager.updateAll(null, { sleep: async () => { sleeps++; }, retryDelaysMs: [1, 1, 1] });
return { requests, sleeps, result: results[0] };
};
const httpAnswer = (status, json) => ({ ok: status < 400, status, json });
let r = await run(() => httpAnswer(502, async () => { throw new SyntaxError('Unexpected token <'); }));
ok('a 502 with an HTML body is sent once', r.requests === 1 && r.sleeps === 0, r);
ok('...and is an API_ERROR carrying the status, not NETWORK_ERROR',
r.result.success === false && r.result.error.error_code === 'API_ERROR' && r.result.error.status === 502, r.result.error);
r = await run(() => httpAnswer(500, async () => ({ status: 'error', message: 'boom' })));
ok('a JSON 500 without error_code is sent once', r.requests === 1 && r.sleeps === 0, r);
ok('...and keeps the server message', r.result.error.message === 'boom', r.result.error);
r = await run(() => httpAnswer(500, async () => ({ status: 'error', error_code: 'PLUGIN_UPDATE_FAILED', message: 'x' })));
ok('a structured error is passed through unchanged',
r.requests === 1 && r.result.error.error_code === 'PLUGIN_UPDATE_FAILED', r.result.error);
r = await run((n) => {
if (n === 1) throw new TypeError('Failed to fetch');
return httpAnswer(200, async () => ({ status: 'success', message: 'ok', data: { update_status: 'updated' } }));
});
ok('a fetch() that rejects is NETWORK_ERROR and re-sent', r.requests === 2 && r.sleeps === 1 && r.result.success, r);
r = await run(() => httpAnswer(200, async () => { throw new SyntaxError('Unexpected end of JSON input'); }));
ok('an unreadable 200 is not retried either', r.requests === 1 && r.result.error.error_code === 'API_ERROR', r);
// Every endpoint is one of the client's own API paths; one that could
// leave baseURL never reaches fetch(), and plugin ids are encoded.
const urls = [];
global.fetch = async (url) => { urls.push(url); return httpAnswer(200, async () => ({ status: 'success' })); };
const refusal = async (endpoint) => {
try { await PluginAPI.request(endpoint, 'POST'); return null; } catch (e) { return e.error_code; }
};
const bad = ['//evil.example/x', '/plugins/../../x', '/a\b', '/a b', 'plugins', null];
const codes = [];
for (const endpoint of bad) codes.push(await refusal(endpoint));
ok('an endpoint that could leave the API path is refused before fetch()',
codes.every(c => c === 'INVALID_ENDPOINT') && urls.length === 0, { codes, urls });
await PluginAPI.resetPluginConfig('a/../b&x=1');
ok('a plugin id is encoded into the URL, not spliced into it',
urls[0] === '/api/v3/plugins/config/reset?plugin_id=a%2F..%2Fb%26x%3D1', urls);
delete global.fetch;
}
console.log('\nsummary: a no-op update is not counted as updated');
{
const answer = (update_status, message) => ({ success: true, result: { status: 'success', message, data: { update_status } } });
const results = [
answer('updated', 'Plugin a updated to version 2.8.0'),
// ZIP-installed monorepo plugin already at the registry version: the
// route used to call this "updated successfully".
answer('up_to_date', 'Plugin stock-news already up to date (version 2.8.0)'),
answer('up_to_date', 'Plugin clock already up to date (commit abcdef1)'),
answer('local_only', 'Plugin mine is managed locally and does not receive registry updates'),
{ success: false, error: { error_code: 'PLUGIN_UPDATE_FAILED' } },
];
const s = Manager.summarizeUpdateResults(results);
ok('counts come from update_status',
s.updated === 1 && s.upToDate === 2 && s.localOnly === 1 && s.failed === 1, s);
ok('toast text names each outcome',
s.text === '1 updated, 2 already up to date, 1 managed locally, 1 failed', s.text);
ok('a failure alongside an update is a warning', s.type === 'warning', s.type);
ok('an older server that only says so in the message is still up to date',
Manager.updateOutcome({ success: true, result: { message: 'Plugin x already up to date (commit 1234567)' } }) === 'up_to_date');
ok('a success without a status or telltale message counts as updated',
Manager.updateOutcome({ success: true, result: { message: 'Plugin x updated successfully' } }) === 'updated');
const allNoop = Manager.summarizeUpdateResults([answer('up_to_date', ''), answer('up_to_date', '')]);
ok('nothing to do is a success toast with no "updated"',
allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+3 -2
View File
@@ -17,6 +17,7 @@ import requests
from freezegun import freeze_time
import src.common.api_helper as api_helper_module
from src.common.espn_dates import ESPN_MAX_LIMIT
from src.common.api_helper import APIHelper
@@ -152,7 +153,7 @@ class TestEspnHelpers:
assert result == {'ok': 1}
helper.get.assert_called_once_with(
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
params={'dates': '20260807', 'limit': 1000},
params={'dates': '20260807', 'limit': ESPN_MAX_LIMIT},
cache_key='espn_football_nfl_20260807',
cache_ttl=300,
)
@@ -163,7 +164,7 @@ class TestEspnHelpers:
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
kwargs = helper.get.call_args.kwargs
assert kwargs['params'] == {'dates': '20250115', 'limit': 1000}
assert kwargs['params'] == {'dates': '20250115', 'limit': ESPN_MAX_LIMIT}
assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
def test_fetch_espn_standings_url_and_cache_key(self, helper):
+416
View File
@@ -0,0 +1,416 @@
"""Display hardware settings accept what the rgbmatrix library accepts.
Held to what the pinned library and its Python binding accept
(src/matrix_support.py: RGBMatrix::Options::Validate in
lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc, the
mapping table in lib/hardware-mapping.c and the binding's uint8_t setters).
Ways this used to go wrong:
- The Display form capped cols at 128, chain_length at 24 and
pwm_lsb_nanoseconds at 500, and its submit handler (fixInvalidNumberInputs)
rewrites anything past an input's min/max to that bound -- so a wide panel or
a long chain silently saved as the wrong size.
- The API checked none of these, so a value the library rejects (odd rows,
parallel 4, pwm_dither_bits 3) saved, and the matrix then refused to start.
- After that, rows above 64, chain_length above 255, a misspelled hardware
mapping and parallel 2-3 on a single-output HAT mapping still saved. The
library answers those with no matrix or abort(), not an error, so the
display service crash-looped instead of falling back.
Row address type 5 is the SM5368 / B707 row shift register the Waveshare 96x48
V2 needs (Waveshare's own "96X48_1_24_SM5368" panel type in their library fork
just sets rows/cols, row_address_type=5 and BGR); the API used to stop at 4.
"""
import copy
import json
import re
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
from test.test_web_settings_ui import REALISTIC_CONFIG # noqa: E402
from src import pi5_matrix_support # noqa: E402
#: What a Waveshare RGB-Matrix-P2.5-96x48 V2 (back silkscreen 24S-A1) needed on
#: a Pi 4 with an Adafruit Triple LED Matrix Bonnet, checked on the panel.
WAVESHARE_96X48_V2 = {
'rows': 48, 'cols': 96, 'chain_length': 1, 'parallel': 1,
'hardware_mapping': 'regular', 'panel_type': '', 'row_address_type': 5,
'led_rgb_sequence': 'BGR', 'gpio_slowdown': 8,
}
#: Fields stored under display.runtime; the rest go under display.hardware.
RUNTIME_FIELDS = {'gpio_slowdown'}
PI5_MODEL = 'Raspberry Pi 5 Model B Rev 1.0'
@pytest.fixture(autouse=True)
def board(tmp_path, monkeypatch):
"""Not a Pi 5 unless a test says so, whatever machine runs the suite.
Returns a setter: board(PI5_MODEL) makes the API and the form see a Pi 5.
"""
path = tmp_path / 'device-tree-model'
def set_model(model):
path.write_bytes(model.encode() + b'\x00')
set_model('Raspberry Pi 4 Model B Rev 1.5')
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
return set_model
def _stored(config, field):
section = 'runtime' if field in RUNTIME_FIELDS else 'hardware'
return config['display'][section][field]
def _post(client, body):
return client.post('/api/v3/config/main', data=json.dumps(body),
content_type='application/json')
@pytest.fixture
def saved(api_v3_module, monkeypatch):
"""Capture what save_main_config would write.
Asserting on the stored value, not just the status code, is what shows the
value passed validation and landed where DisplayManager reads it.
"""
captured = {}
api_v3_module.api_v3.config_manager.load_config.return_value = {}
def fake_save(_manager, config, **_kwargs):
captured['config'] = config
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
return captured
@pytest.mark.parametrize('as_strings', [False, True], ids=['json-numbers', 'form-strings'])
def test_waveshare_96x48_v2_settings_all_save(api_v3_client, saved, as_strings):
"""The Display form posts every value as a string (json-enc); API clients send numbers."""
body = {k: str(v) if as_strings else v for k, v in WAVESHARE_96X48_V2.items()}
response = _post(api_v3_client, body)
assert response.status_code == 200, response.get_data(as_text=True)[:200]
for field, value in WAVESHARE_96X48_V2.items():
assert _stored(saved['config'], field) == value, field
@pytest.mark.parametrize('field,value', [
('rows', 8), ('rows', 64),
('cols', 16), ('cols', 192), ('cols', 512),
('chain_length', 1), ('chain_length', 32), ('chain_length', 255),
('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0),
('multiplexing', 0), ('multiplexing', 22),
('gpio_slowdown', 0), ('gpio_slowdown', 10),
('pwm_bits', 1), ('pwm_bits', 11),
('pwm_dither_bits', 0), ('pwm_dither_bits', 2),
('pwm_lsb_nanoseconds', 50), ('pwm_lsb_nanoseconds', 3000),
('scan_mode', 1),
('brightness', 1), ('brightness', 100),
('limit_refresh_rate_hz', 0), ('limit_refresh_rate_hz', 1000),
])
def test_values_in_range_are_saved(api_v3_client, saved, field, value):
response = _post(api_v3_client, {field: value})
assert response.status_code == 200, response.get_data(as_text=True)[:200]
assert _stored(saved['config'], field) == value
@pytest.mark.parametrize('field,value', [
('rows', 6), ('rows', 47), ('rows', 66), ('rows', 96), ('rows', 128), ('rows', '48.5'),
('cols', 15), ('cols', 96.5), ('cols', True), ('cols', 'wide'),
('chain_length', 0), ('chain_length', 256), ('chain_length', 300),
('parallel', 0), ('parallel', 4),
('row_address_type', -1), ('row_address_type', 6),
('row_address_type', True), ('row_address_type', 5.5),
('multiplexing', -1), ('multiplexing', 23), ('multiplexing', True),
('gpio_slowdown', -1), ('gpio_slowdown', 11),
('pwm_bits', 0), ('pwm_bits', 12),
('pwm_dither_bits', 3),
('pwm_lsb_nanoseconds', 49), ('pwm_lsb_nanoseconds', 3001),
('scan_mode', 2),
('brightness', 0), ('brightness', 101),
('limit_refresh_rate_hz', -1),
])
def test_values_out_of_range_are_refused(api_v3_client, saved, field, value):
"""Refused with a message naming the field, and nothing written."""
response = _post(api_v3_client, {field: value})
assert response.status_code == 400
assert field in response.get_json()['message']
assert 'config' not in saved
@pytest.mark.parametrize('body', [
{'hardware_mapping': 'regular', 'parallel': 3},
{'hardware_mapping': 'classic', 'parallel': 2},
{'hardware_mapping': 'Regular', 'parallel': 3},
{'hardware_mapping': 'classic-pi1'},
{'hardware_mapping': 'adafruit-hat', 'parallel': 1},
])
def test_mappings_the_library_has_are_saved(api_v3_client, saved, body):
response = _post(api_v3_client, body)
assert response.status_code == 200, response.get_data(as_text=True)[:200]
for field, value in body.items():
assert saved['config']['display']['hardware'][field] == value
@pytest.mark.parametrize('body,named', [
# Framebuffer() abort()s: the HAT mappings define one output.
({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, 'parallel 2'),
({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, 'parallel 3'),
({'hardware_mapping': 'regular-pi1', 'parallel': 2}, 'parallel 2'),
# InitHardwareMapping() abort()s on a name it doesn't have; compute-module
# isn't compiled into the default build.
({'hardware_mapping': 'adafruit-hat-pwn'}, 'adafruit-hat-pwn'),
({'hardware_mapping': 'compute-module'}, 'compute-module'),
({'hardware_mapping': 5}, 'hardware mapping'),
])
def test_combinations_the_library_aborts_on_are_refused(api_v3_client, saved, body, named):
response = _post(api_v3_client, body)
assert response.status_code == 400
assert named in response.get_json()['message']
assert 'config' not in saved
def test_parallel_is_checked_against_the_stored_mapping(api_v3_client, api_v3_module, saved):
api_v3_module.api_v3.config_manager.load_config.return_value = {
'display': {'hardware': {'hardware_mapping': 'adafruit-hat'}}}
response = _post(api_v3_client, {'parallel': 2})
assert response.status_code == 400
assert 'adafruit-hat' in response.get_json()['message']
assert 'config' not in saved
def test_stored_refusal_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved):
api_v3_module.api_v3.config_manager.load_config.return_value = {
'display': {'hardware': {'hardware_mapping': 'adafruit-hat', 'parallel': 2}}}
response = _post(api_v3_client, {'brightness': 70})
assert response.status_code == 200, response.get_data(as_text=True)[:200]
def test_a_request_value_is_checked_not_the_stored_one(api_v3_client, api_v3_module, saved):
"""Fixing a stored bad value in the same save as a mapping change must work."""
api_v3_module.api_v3.config_manager.load_config.return_value = {
'display': {'hardware': {'rows': 128, 'parallel': 2}}}
response = _post(api_v3_client, {'rows': 64, 'hardware_mapping': 'regular'})
assert response.status_code == 200, response.get_data(as_text=True)[:200]
@pytest.mark.parametrize('orientation', ['normal', '90', '180', '270'])
def test_every_orientation_display_manager_applies_is_saved(api_v3_client, saved, orientation):
response = _post(api_v3_client, {'orientation': orientation})
assert response.status_code == 200, response.get_data(as_text=True)[:200]
assert saved['config']['display']['hardware']['orientation'] == orientation
@pytest.fixture
def display_page(monkeypatch):
"""Render the Display settings partial for a given config."""
from web_interface.blueprints import pages_v3 as pv
def render(config):
base = PROJECT_ROOT / 'web_interface'
app = Flask(__name__, template_folder=str(base / 'templates'),
static_folder=str(base / 'static'))
app.config['TESTING'] = True
config_manager = MagicMock()
config_manager.load_config.return_value = config
config_manager.get_raw_file_content.return_value = config
config_manager.get_config_path.return_value = 'config/config.json'
config_manager.get_secrets_path.return_value = 'config/config_secrets.json'
monkeypatch.setattr(pv.pages_v3, 'config_manager', config_manager, raising=False)
monkeypatch.setattr(pv.pages_v3, 'plugin_manager', MagicMock(plugins={}), raising=False)
app.register_blueprint(pv.pages_v3, url_prefix='/v3')
response = app.test_client().get('/v3/partials/display')
assert response.status_code == 200
return response.get_data(as_text=True)
return render
def _config_with(hardware=None, runtime=None):
config = copy.deepcopy(REALISTIC_CONFIG)
config['display']['hardware'].update(hardware or {})
config['display']['runtime'].update(runtime or {})
return config
def _input_tag(body, input_id):
match = re.search(r'<input[^>]*\bid="%s"[^>]*>' % re.escape(input_id), body)
assert match, f'no <input id="{input_id}">'
return match.group(0)
def _attr(tag, name):
match = re.search(r'\s%s="([^"]*)"' % name, tag)
return match.group(1) if match else None
def _selected_option(body, select_id):
select = re.search(r'<select id="%s".*?</select>' % select_id, body, re.S)
assert select, f'no <select id="{select_id}">'
return re.findall(r'<option value="([^"]*)"\s+selected\s*>', select.group(0))
@pytest.mark.parametrize('input_id,expected', [
('rows', {'min': '8', 'max': None, 'step': '2'}),
('cols', {'min': '16', 'max': None}),
('chain_length', {'min': '1', 'max': None}),
('parallel', {'min': '1', 'max': '3'}),
('gpio_slowdown', {'min': '0', 'max': '10'}),
('pwm_bits', {'min': '1', 'max': '11'}),
('pwm_dither_bits', {'min': '0', 'max': '2'}),
('pwm_lsb_nanoseconds', {'min': '50', 'max': '3000'}),
('limit_refresh_rate_hz', {'min': '0', 'max': '1000'}),
])
def test_form_limits_match_the_library(display_page, input_id, expected):
"""fixInvalidNumberInputs rewrites a value past min/max on submit, so these
attributes are the real limits: a max below the library's clamps panels
that would work, and one above it saves a value the matrix rejects."""
tag = _input_tag(display_page(_config_with()), input_id)
for name, value in expected.items():
assert _attr(tag, name) == value, f'{input_id} {name}'
def test_waveshare_96x48_v2_config_renders_back_unchanged(display_page):
"""Saving the Display tab posts what it rendered, so each value must render as stored.
Before row address type 5 was in the dropdown no option was selected, the
browser posted the first one (0), and one save scrambled the panel again.
"""
hardware = {k: v for k, v in WAVESHARE_96X48_V2.items() if k not in RUNTIME_FIELDS}
body = display_page(_config_with(hardware=hardware, runtime={'gpio_slowdown': 8}))
assert _selected_option(body, 'row_address_type') == ['5']
assert _selected_option(body, 'led_rgb_sequence') == ['BGR']
assert _selected_option(body, 'hardware_mapping') == ['regular']
for input_id in ('rows', 'cols', 'chain_length', 'parallel', 'gpio_slowdown'):
assert _attr(_input_tag(body, input_id), 'value') == str(WAVESHARE_96X48_V2[input_id]), input_id
@pytest.mark.parametrize('hardware,select_id,expected', [
({'hardware_mapping': 'classic'}, 'hardware_mapping', 'classic'),
({'hardware_mapping': 'classic-pi1'}, 'hardware_mapping', 'classic-pi1'),
({'hardware_mapping': 'adafruit-hat'}, 'hardware_mapping', 'adafruit-hat'),
({'hardware_mapping': 'Regular'}, 'hardware_mapping', 'regular'),
({'hardware_mapping': ''}, 'hardware_mapping', 'regular'),
({'orientation': '90'}, 'orientation', '90'),
({'orientation': '270'}, 'orientation', '270'),
])
def test_stored_mapping_and_orientation_render_back_unchanged(display_page, hardware, select_id, expected):
"""With nothing selected the browser posts the first option, so one unrelated
Display save turned classic into adafruit-hat-pwm and 90 degrees into normal."""
body = display_page(_config_with(hardware=hardware))
assert _selected_option(body, select_id) == [expected]
assert "saved hardware mapping" not in body
def test_missing_mapping_renders_display_managers_default(display_page):
config = _config_with()
del config['display']['hardware']['hardware_mapping']
assert _selected_option(display_page(config), 'hardware_mapping') == ['adafruit-hat-pwm']
@pytest.mark.parametrize('stored', ['compute-module', 'adafruit-hat-pwn'])
def test_unusable_stored_mapping_renders_selected_with_a_warning(display_page, stored):
"""Kept selected rather than silently swapped for the first option; the API
refuses it on save, and the warning says why."""
body = display_page(_config_with(hardware={'hardware_mapping': stored}))
assert _selected_option(body, 'hardware_mapping') == [stored]
assert f'Your saved hardware mapping ("{stored}")' in body
@pytest.mark.parametrize('field,section', [
('gpio_slowdown', 'runtime'), ('pwm_dither_bits', 'hardware'),
('limit_refresh_rate_hz', 'hardware'),
])
def test_a_stored_zero_renders_as_zero(display_page, field, section):
"""`value or default` showed a stored 0 as the default, and the next save wrote it back."""
body = display_page(_config_with(**{section: {field: 0}}))
assert _attr(_input_tag(body, field), 'value') == '0'
# --- Raspberry Pi 5 -------------------------------------------------------
# The pinned library's Pi 5 path drives only row address types 0 and 2,
# parallel 1-3 and the standard mappings; anything else crashes the display
# service (src/pi5_matrix_support.py), so the API and the form refuse it.
@pytest.mark.parametrize('body', [
{'row_address_type': 5}, {'row_address_type': '1'},
{'hardware_mapping': 'classic-pi1'},
])
def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body):
board(PI5_MODEL)
response = _post(api_v3_client, body)
assert response.status_code == 400
assert 'Raspberry Pi 5' in response.get_json()['message']
assert 'config' not in saved
def test_pi5_saves_what_it_can_drive(api_v3_client, saved, board):
board(PI5_MODEL)
response = _post(api_v3_client, dict(WAVESHARE_96X48_V2, row_address_type=2))
assert response.status_code == 200, response.get_data(as_text=True)[:200]
assert saved['config']['display']['hardware']['row_address_type'] == 2
def test_pi5_check_uses_the_stored_value_for_fields_not_sent(api_v3_client, api_v3_module, saved, board):
"""Changing only the mapping is still checked against the stored row address type."""
board(PI5_MODEL)
api_v3_module.api_v3.config_manager.load_config.return_value = {
'display': {'hardware': {'row_address_type': 5}}}
response = _post(api_v3_client, {'hardware_mapping': 'regular'})
assert response.status_code == 400
assert 'config' not in saved
def test_pi5_stored_combination_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved, board):
board(PI5_MODEL)
api_v3_module.api_v3.config_manager.load_config.return_value = {
'display': {'hardware': {'row_address_type': 5}}}
response = _post(api_v3_client, {'brightness': 70})
assert response.status_code == 200, response.get_data(as_text=True)[:200]
def _option_values(body, select_id):
select = re.search(r'<select id="%s".*?</select>' % select_id, body, re.S)
assert select, f'no <select id="{select_id}">'
return re.findall(r'<option value="([^"]*)"', select.group(0))
def test_pi5_form_offers_only_supported_row_address_types(display_page, board):
board(PI5_MODEL)
body = display_page(_config_with())
assert _option_values(body, 'row_address_type') == ['0', '2']
assert "can't be used on this Raspberry Pi 5" not in body
def test_pi5_form_offers_only_mappings_it_can_drive(display_page, board):
board(PI5_MODEL)
body = display_page(_config_with())
assert 'classic-pi1' not in _option_values(body, 'hardware_mapping')
body = display_page(_config_with(hardware={'hardware_mapping': 'classic-pi1'}))
assert _selected_option(body, 'hardware_mapping') == ['classic-pi1']
assert 'can use on a Raspberry Pi 5' in body
def test_other_boards_offer_every_row_address_type(display_page):
body = display_page(_config_with())
assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5']
def test_pi5_form_warns_about_a_stored_unsupported_row_address_type(display_page, board):
"""The unsupported option isn't offered, so the browser posts 0 -- say so."""
board(PI5_MODEL)
body = display_page(_config_with(hardware={'row_address_type': 5}))
assert "Your saved row address type (5) can't be used on this Raspberry Pi 5" in body
+165
View File
@@ -0,0 +1,165 @@
"""Tests that api_v3 routes find installed plugins before anything else has.
The web process discovers plugins lazily (web_interface/app.py): nothing scans
the plugins directory at startup, and plugin_manifests stays empty until some
endpoint calls discover_plugins(). Routes that consulted plugin_manifests
without discovering therefore misbehaved for as long as nothing else had run.
Measured on a rig after restarting ledmatrix-web:
POST /api/v3/display/on-demand/start {"plugin_id": "ledmatrix-stocks"}
-> 404 "Plugin ledmatrix-stocks not found", for over three minutes,
until GET /api/v3/plugins/installed happened to discover plugins.
The browser UI loads the plugin list first, so people rarely saw it; API-only
callers (the Home Assistant MQTT bridge, scripts) saw it after every restart.
/plugins/toggle answered the same 404, and /config/main was worse: an
undiscovered plugin section skipped secret separation and wrote its API key
into config.json in plain text.
A real PluginManager over a temporary plugins directory, so "empty until
discovered" is the real behaviour rather than a mock's.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config_manager import ConfigManager # noqa: E402
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
PLUGIN_ID = 'ledmatrix-stocks'
MODE = 'stocks'
def _install(plugins_dir, plugin_id, modes, schema=None):
plugin_dir = plugins_dir / plugin_id
plugin_dir.mkdir(parents=True)
(plugin_dir / 'manifest.json').write_text(json.dumps({
'id': plugin_id, 'name': plugin_id, 'version': '1.0.0',
'entry_point': 'manager.py', 'class_name': 'Plugin',
'display_modes': modes,
}))
(plugin_dir / 'manager.py').write_text('')
if schema is not None:
(plugin_dir / 'config_schema.json').write_text(json.dumps(schema))
@pytest.fixture
def plugins_dir(tmp_path):
path = tmp_path / 'plugin-repos'
path.mkdir()
_install(path, PLUGIN_ID, [MODE], schema={
'type': 'object',
'properties': {
'enabled': {'type': 'boolean'},
'symbols': {'type': 'string'},
'api_key': {'type': 'string', 'x-secret': True},
},
})
return path
@pytest.fixture
def fresh_web_process(api_v3_module, plugins_dir):
"""The web process right after a restart: nothing discovered yet."""
manager = PluginManager(plugins_dir=str(plugins_dir))
assert not manager.plugin_manifests
api_v3_module.api_v3.plugin_manager = manager
return manager
@pytest.fixture
def display_service():
"""Keep on-demand start away from systemctl and the real cache."""
with patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') as cache, \
patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status:
cache.return_value = MagicMock()
status.return_value = {'active': True}
yield cache.return_value
def _start(client, **body):
body.setdefault('start_service', False)
return client.post('/api/v3/display/on-demand/start', json=body)
class TestOnDemandStart:
def test_by_plugin_id(self, api_v3_client, fresh_web_process, display_service):
response = _start(api_v3_client, plugin_id=PLUGIN_ID)
assert response.status_code == 200, response.get_json()
request = display_service.set.call_args.args[1]
assert (request['plugin_id'], request['mode']) == (PLUGIN_ID, MODE)
def test_by_mode_alone(self, api_v3_client, fresh_web_process, display_service):
response = _start(api_v3_client, mode=MODE)
assert response.status_code == 200, response.get_json()
assert display_service.set.call_args.args[1]['plugin_id'] == PLUGIN_ID
def test_a_plugin_installed_since_the_last_scan(
self, api_v3_client, fresh_web_process, plugins_dir, display_service):
fresh_web_process.discover_plugins()
_install(plugins_dir, 'ledmatrix-weather', ['weather'])
assert _start(api_v3_client, plugin_id='ledmatrix-weather').status_code == 200
assert _start(api_v3_client, mode=MODE).status_code == 200
def test_a_mode_installed_since_the_last_scan(
self, api_v3_client, fresh_web_process, plugins_dir, display_service):
fresh_web_process.discover_plugins()
_install(plugins_dir, 'ledmatrix-weather', ['weather'])
response = _start(api_v3_client, mode='weather')
assert response.status_code == 200, response.get_json()
def test_an_unknown_plugin_is_still_not_found(
self, api_v3_client, fresh_web_process, display_service):
assert _start(api_v3_client, plugin_id='no-such-plugin').status_code == 404
assert _start(api_v3_client, mode='no-such-mode').status_code == 404
display_service.set.assert_not_called()
def test_toggle_finds_the_plugin(api_v3_client, api_v3_module, fresh_web_process):
config_manager = api_v3_module.api_v3.config_manager
config_manager.load_config.return_value = {PLUGIN_ID: {'enabled': False}}
config_manager.save_config_atomic.return_value = MagicMock(
status=MagicMock(value='success'))
response = api_v3_client.post('/api/v3/plugins/toggle',
json={'plugin_id': PLUGIN_ID, 'enabled': True})
assert response.status_code == 200, response.get_json()
saved = config_manager.save_config_atomic.call_args.args[0]
assert saved[PLUGIN_ID]['enabled'] is True
def test_main_config_save_keeps_a_plugin_secret_out_of_config_json(
api_v3_client, api_v3_module, fresh_web_process, plugins_dir, tmp_path):
# /config/main validates a plugin section like POST /plugins/config does,
# so it needs a real schema manager rather than the helper's mock.
api_v3_module.api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir)
config_file = tmp_path / 'config.json'
config_file.write_text('{}')
secrets_file = tmp_path / 'config_secrets.json'
config_manager = ConfigManager(config_path=str(config_file),
secrets_path=str(secrets_file))
config_manager.template_path = str(tmp_path / 'no-template.json')
api_v3_module.api_v3.config_manager = config_manager
response = api_v3_client.post('/api/v3/config/main', json={
PLUGIN_ID: {'symbols': 'AAPL', 'api_key': 's3cret-key'},
})
assert response.status_code == 200, response.get_json()
assert 's3cret' not in config_file.read_text()
assert json.loads(secrets_file.read_text())[PLUGIN_ID]['api_key'] == 's3cret-key'
assert json.loads(config_file.read_text())[PLUGIN_ID]['symbols'] == 'AAPL'
+288
View File
@@ -0,0 +1,288 @@
"""POST /config/main: a partial JSON body changes only what it sends.
The settings forms post every field, and a browser leaves an unchecked box out,
so for a form a missing checkbox means False. JSON API clients send only what
they change. Treating their missing keys as unchecked meant:
- the MQTT bridge's Home Assistant brightness slider (``{"brightness": N}``)
turned off disable_hardware_pulsing, inverse_colors, show_refresh_rate and
use_short_date_format on every change;
- the documented timezone/location update turned off web_display_autostart and
weekly automatic updates.
The v3 forms post JSON as well (htmx json-enc), so they mark themselves with a
hidden ``__form_section`` input; these tests pin both halves of that contract.
Also here: the Vegas cycle-time fields no longer land in display_durations
(and a blank one no longer 400s the Display save), the Raw JSON editor starts
auto-update setup like the General form does, and both schedule POSTs accept
the per-day shape their GETs return.
"""
import copy
import json
import re
from pathlib import Path
import pytest
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
REPO = Path(__file__).resolve().parent.parent
PARTIALS = REPO / 'web_interface' / 'templates' / 'v3' / 'partials'
STORED = {
'web_display_autostart': True,
'auto_update': {'enabled': True},
'timezone': 'America/Chicago',
'plugin_system': {'auto_discover': True, 'auto_load_enabled': True,
'development_mode': True},
'display': {
'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90,
'disable_hardware_pulsing': True, 'inverse_colors': True,
'show_refresh_rate': True},
'runtime': {'gpio_slowdown': 4},
'use_short_date_format': True,
'double_sided': {'enabled': True, 'copies': 2, 'axis': 'horizontal'},
'vegas_scroll': {'enabled': True, 'auto_trim': True,
'dynamic_duration_enabled': True,
'continuous_scroll': True, 'smooth_scroll': True},
},
}
@pytest.fixture
def saved(api_v3_module, monkeypatch):
captured = {}
api_v3_module.api_v3.config_manager.load_config.side_effect = \
lambda *a, **k: copy.deepcopy(STORED)
def fake_save(_manager, config, **_kwargs):
captured['config'] = config
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
from web_interface import auto_update
monkeypatch.setattr(auto_update, 'start_setup_if_needed', lambda *a, **k: None)
return captured
def _post_json(client, body):
return client.post('/api/v3/config/main', data=json.dumps(body),
content_type='application/json')
class TestJsonPartialSaves:
def test_mqtt_bridge_brightness_changes_only_brightness(self, api_v3_client, saved):
# integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py set_brightness
resp = _post_json(api_v3_client, {'brightness': 40})
assert resp.status_code == 200, resp.get_json()
display = saved['config']['display']
assert display['hardware']['brightness'] == 40
assert display['hardware']['disable_hardware_pulsing'] is True
assert display['hardware']['inverse_colors'] is True
assert display['hardware']['show_refresh_rate'] is True
assert display['use_short_date_format'] is True
def test_timezone_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'timezone': 'UTC'})
assert resp.status_code == 200, resp.get_json()
config = saved['config']
assert config['timezone'] == 'UTC'
assert config['web_display_autostart'] is True
assert config['auto_update'] == {'enabled': True}
def test_location_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'city': 'Paris', 'country': 'FR'})
assert resp.status_code == 200, resp.get_json()
config = saved['config']
assert config['location'] == {'city': 'Paris', 'country': 'FR'}
assert config['web_display_autostart'] is True
assert config['auto_update'] == {'enabled': True}
@pytest.mark.parametrize('key', ['auto_discover', 'auto_load_enabled', 'development_mode'])
def test_a_legacy_plugin_system_toggle_is_not_a_general_save(self, api_v3_client, saved, key):
# These left the General form; a client still sending one must not
# have the general-settings checkboxes treated as unchecked.
resp = api_v3_client.post('/api/v3/config/main', data={key: 'on'},
content_type='application/x-www-form-urlencoded')
assert resp.status_code == 200, resp.get_json()
assert saved['config']['web_display_autostart'] is True
assert saved['config']['auto_update'] == {'enabled': True}
def test_vegas_speed_only_keeps_vegas_toggles(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'vegas_scroll_speed': 80})
assert resp.status_code == 200, resp.get_json()
vegas = saved['config']['display']['vegas_scroll']
assert vegas['scroll_speed'] == 80
for key in ('enabled', 'auto_trim', 'dynamic_duration_enabled',
'continuous_scroll', 'smooth_scroll'):
assert vegas[key] is True, key
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['double_sided']['enabled'] is True
def test_json_can_still_turn_a_checkbox_off(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'inverse_colors': False, 'auto_update_enabled': False})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['inverse_colors'] is False
assert saved['config']['display']['hardware']['disable_hardware_pulsing'] is True
assert saved['config']['auto_update'] == {'enabled': False}
def test_json_with_charset_is_still_json(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data=json.dumps({'brightness': 41}),
content_type='application/json; charset=utf-8')
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['brightness'] == 41
assert saved['config']['display']['hardware']['inverse_colors'] is True
def test_a_non_object_body_is_refused(self, api_v3_client, saved):
assert _post_json(api_v3_client, [1, 2]).status_code == 400
class TestFormSavesStillUncheck:
"""Unchecking a box in the UI must still save false."""
def test_marked_json_form_post_unchecks_missing_display_boxes(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
'vegas_scroll_speed': '50'})
assert resp.status_code == 200, resp.get_json()
display = saved['config']['display']
for key in ('disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate'):
assert display['hardware'][key] is False, key
assert display['use_short_date_format'] is False
assert display['vegas_scroll']['enabled'] is False
assert '__form_section' not in saved['config']
def test_marked_json_form_post_keeps_checked_boxes(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
'inverse_colors': 'on'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['inverse_colors'] is True
assert saved['config']['display']['hardware']['show_refresh_rate'] is False
def test_marked_general_form_unchecks_autostart_and_auto_update(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'__form_section': 'general', 'timezone': 'UTC'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['web_display_autostart'] is False
assert saved['config']['auto_update'] == {'enabled': False}
def test_form_encoded_post_unchecks_missing_boxes(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data={'brightness': '40'},
content_type='application/x-www-form-urlencoded')
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['hardware']['inverse_colors'] is False
@pytest.mark.parametrize('partial', ['general.html', 'display.html', 'durations.html'])
def test_every_config_main_form_carries_the_marker(self, partial):
html = (PARTIALS / partial).read_text(encoding='utf-8')
form = re.search(r'<form hx-post="/api/v3/config/main".*?</form>', html, re.S)
assert form, f'{partial} no longer posts to /config/main'
assert re.search(r'<input type="hidden" name="__form_section" value="\w+">',
form.group(0)), f'{partial} form lost its __form_section marker'
class TestVegasCycleDurations:
def test_cycle_durations_are_not_display_durations(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data={
'vegas_scroll_enabled': 'on', 'vegas_min_cycle_duration': '90',
'vegas_max_cycle_duration': '300'})
assert resp.status_code == 200, resp.get_json()
display = saved['config']['display']
assert display['vegas_scroll']['min_cycle_duration'] == 90
assert display['vegas_scroll']['max_cycle_duration'] == 300
durations = display.get('display_durations', {})
assert 'vegas_min_cycle_duration' not in durations
assert 'vegas_max_cycle_duration' not in durations
assert 'vegas_min_cycle_duration' not in saved['config']
def test_blank_cycle_duration_does_not_reject_the_save(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data={
'vegas_scroll_enabled': 'on', 'vegas_scroll_speed': '60',
'vegas_min_cycle_duration': ''})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['scroll_speed'] == 60
def test_real_display_durations_still_save(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'clock_duration': '45'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations']['clock_duration'] == 45
class TestRawSaveStartsAutoUpdateSetup:
@pytest.fixture
def raw_env(self, api_v3_module, monkeypatch):
cm = api_v3_module.api_v3.config_manager
cm.get_raw_file_content.return_value = {'timezone': 'UTC', 'auto_update': {'enabled': False}}
calls = []
from web_interface import auto_update
monkeypatch.setattr(auto_update, 'start_setup_if_needed',
lambda was, config: calls.append((was, config)) or 'Setup note.')
return cm, calls
def test_enabling_from_raw_json_calls_setup(self, api_v3_client, raw_env):
cm, calls = raw_env
body = {'timezone': 'UTC', 'auto_update': {'enabled': True}}
resp = api_v3_client.post('/api/v3/config/raw/main', json=body)
assert resp.status_code == 200, resp.get_json()
cm.save_raw_file_content.assert_called_once_with('main', body)
assert calls == [(False, body)]
assert 'Setup note.' in resp.get_json()['message']
def test_previously_enabled_is_passed_through(self, api_v3_client, raw_env):
cm, calls = raw_env
cm.get_raw_file_content.return_value = {'auto_update': {'enabled': True}}
body = {'auto_update': {'enabled': True}}
assert api_v3_client.post('/api/v3/config/raw/main', json=body).status_code == 200
assert calls == [(True, body)]
class TestSchedulesAcceptTheirGetShape:
PER_DAY = {
'enabled': True, 'mode': 'per-day', 'dim_brightness': 20,
'days': {
'monday': {'enabled': True, 'start_time': '21:15', 'end_time': '06:45'},
'tuesday': {'enabled': False},
'wednesday': {'enabled': True, 'start_time': '22:00', 'end_time': '05:30'},
'thursday': {'enabled': True, 'start_time': '20:00', 'end_time': '07:00'},
'friday': {'enabled': True, 'start_time': '23:00', 'end_time': '08:00'},
'saturday': {'enabled': True, 'start_time': '23:30', 'end_time': '09:00'},
'sunday': {'enabled': True, 'start_time': '21:00', 'end_time': '07:00'},
},
}
@pytest.fixture
def store(self, api_v3_module, monkeypatch):
state = {'config': {}}
api_v3_module.api_v3.config_manager.load_config.side_effect = \
lambda *a, **k: copy.deepcopy(state['config'])
def fake_save(_manager, config, **_kwargs):
state['config'] = copy.deepcopy(config)
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
return state
@pytest.mark.parametrize('route,section,extra', [
('/api/v3/config/dim-schedule', 'dim_schedule', {'dim_brightness': 20}),
('/api/v3/config/schedule', 'schedule', {}),
])
def test_get_output_posts_back_unchanged(self, api_v3_client, store, route, section, extra):
body = {k: v for k, v in self.PER_DAY.items() if k != 'dim_brightness'}
body.update(extra)
store['config'] = {section: copy.deepcopy(body)}
read = api_v3_client.get(route).get_json()['data']
resp = api_v3_client.post(route, json=read)
assert resp.status_code == 200, resp.get_json()
assert store['config'][section]['days'] == body['days']
def test_flat_form_keys_still_work(self, api_v3_client, store):
body = {'enabled': True, 'mode': 'per-day', 'dim_brightness': 25,
'monday_enabled': 'on', 'monday_start': '19:00', 'monday_end': '06:00'}
resp = api_v3_client.post('/api/v3/config/dim-schedule', json=body)
assert resp.status_code == 200, resp.get_json()
assert store['config']['dim_schedule']['days']['monday'] == {
'enabled': True, 'start_time': '19:00', 'end_time': '06:00'}
+175 -4
View File
@@ -72,7 +72,7 @@ class Repo:
def real_pull(device, **extra):
def core_update():
def core_update(**kwargs):
before = git(device, 'rev-parse', 'HEAD')
r = subprocess.run(['git', 'pull', '-q', '--rebase', '--autostash'],
cwd=str(device), capture_output=True, text=True)
@@ -339,6 +339,145 @@ class TestPreflightRefuses:
assert repo.head() == old and h.sudo == []
# -- what counts as a local change ---------------------------------------------
def _device_with_plugin_and_script(tmp_path):
"""A clone carrying a bundled plugin and an executable script, as LEDMatrix does."""
repo = Repo(tmp_path)
for rel, text in (('plugin-repos/web-ui-info/manager.py', 'x = 1\n'),
('web_interface/static/v3/js/plugins/store.js', 'var a;\n'),
('scripts/run.sh', 'echo hi\n')):
(repo.seed / rel).parent.mkdir(parents=True, exist_ok=True)
(repo.seed / rel).write_text(text)
# Executable on disk too, not only in the index: Repo.publish() commits
# with -a, which on Linux would otherwise record run.sh as 100644 upstream
# and hide the device's own mode change behind the pull.
import os
os.chmod(repo.seed / 'scripts' / 'run.sh', 0o755)
git(repo.seed, 'add', '.')
git(repo.seed, 'update-index', '--chmod=+x', 'scripts/run.sh')
git(repo.seed, 'commit', '-qm', 'layout')
git(repo.seed, 'push', '-q', 'origin', 'main')
git(repo.device, 'pull', '-q')
return repo
def _chmod_like_the_installer(device):
"""first_time_install.sh sets tracked 100755 files to 644: a mode-only change.
Windows has no exec bit, so there core.fileMode=true alone shows it."""
import os
git(device, 'config', 'core.fileMode', 'true')
os.chmod(device / 'scripts' / 'run.sh', 0o644)
assert 'mode change 100755 => 100644 scripts/run.sh' in git(device, 'diff', '--summary')
@needs_git
class TestLocalChangesAreCountedOnce:
"""The preflight promises the pull will not stash. That holds only if both
count local changes the same way (auto_update.local_changes)."""
def test_mode_changes_and_plugin_folders_do_not_count(self, tmp_path):
repo = _device_with_plugin_and_script(tmp_path)
_chmod_like_the_installer(repo.device)
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
assert au.local_changes(repo.device) == []
def test_a_core_folder_named_plugins_still_counts(self, tmp_path):
"""The old filter matched 'plugins/' anywhere in the line."""
repo = _device_with_plugin_and_script(tmp_path)
(repo.device / 'web_interface/static/v3/js/plugins/store.js').write_text('var mine;\n')
assert au.local_changes(repo.device) == ['web_interface/static/v3/js/plugins/store.js']
def test_a_staged_rename_is_reported_by_its_new_name(self, tmp_path):
repo = Repo(tmp_path)
git(repo.device, 'mv', 'app.py', 'main.py')
assert au.local_changes(repo.device) == ['main.py']
def test_the_preflight_refuses_a_core_edit_under_a_plugins_folder(self, tmp_path):
repo = _device_with_plugin_and_script(tmp_path)
old = repo.head()
repo.publish()
(repo.device / 'web_interface/static/v3/js/plugins/store.js').write_text('var mine;\n')
result = Harness(tmp_path, repo).updater.run()
assert result['core_outcome'] == 'blocked'
assert 'store.js' in result['core_message']
assert repo.head() == old
@pytest.fixture
def core_update(self, monkeypatch):
"""The real perform_core_update, pointed at a test clone."""
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
def point_at(device):
monkeypatch.setattr(system, 'PROJECT_ROOT', device)
monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False)
monkeypatch.setattr(system, '_pip_install_requirements',
lambda *a, **k: pytest.fail('no requirements changed'))
return system.perform_core_update
return point_at
@pytest.mark.parametrize('change', [
'bundled_plugin_edit',
pytest.param('installer_chmod', marks=pytest.mark.skipif(
sys.platform == 'win32',
reason='no exec bit: the reset inside --autostash cannot clear a mode change, '
'so git will not reapply it (on Linux it reapplies cleanly)')),
])
def test_an_update_that_passed_the_preflight_leaves_no_stash(self, tmp_path, core_update, change):
"""Found by audit: a bundled-plugin edit or the installer's chmods
passed the preflight, then perform_core_update stashed them for good.
Now --autostash carries them across the pull and puts them back."""
repo = _device_with_plugin_and_script(tmp_path)
new = repo.publish()
if change == 'installer_chmod':
_chmod_like_the_installer(repo.device)
else:
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
h = Harness(tmp_path, repo, core_update=core_update(repo.device))
assert h.updater.preflight({})[0] == 'ready'
result = h.updater.run()
assert result['core_outcome'] == 'verifying', result['core_message']
assert repo.head() == new
assert git(repo.device, 'stash', 'list') == ''
if change == 'installer_chmod':
assert 'mode change 100755 => 100644 scripts/run.sh' in git(repo.device, 'diff', '--summary')
else:
assert 'store update' in (repo.device / 'plugin-repos/web-ui-info/manager.py').read_text()
def test_edits_made_after_the_preflight_are_refused_not_stashed(self, tmp_path, core_update):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
perform = core_update(repo.device)
def edit_then_update(**kwargs):
(repo.device / 'app.py').write_text('mine\n')
return perform(**kwargs)
result = Harness(tmp_path, repo, core_update=edit_then_update).updater.run()
assert result['core_outcome'] == 'blocked'
assert 'app.py' in result['core_message'] and 'will not stash' in result['core_message']
assert repo.head() == old
assert git(repo.device, 'stash', 'list') == ''
assert (repo.device / 'app.py').read_text() == 'mine\n'
def test_update_code_still_stashes_core_edits_but_not_plugin_folders(self, tmp_path, core_update):
repo = _device_with_plugin_and_script(tmp_path)
new = repo.publish()
(repo.device / 'scripts/run.sh').write_text('echo mine\n')
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
payload = core_update(repo.device)()
assert payload['status'] == 'success', payload['message']
assert 'stashed' in payload['message']
assert repo.head() == new
assert 'LEDMatrix auto-stash before update' in git(repo.device, 'stash', 'list')
stashed = git(repo.device, 'stash', 'show', '--name-only', 'stash@{0}')
assert stashed.split() == ['scripts/run.sh']
assert 'store update' in (repo.device / 'plugin-repos/web-ui-info/manager.py').read_text()
# -- the update and its hand-off to the health check ---------------------------
@needs_git
@@ -395,7 +534,7 @@ class TestUpdateIsVerified:
old = repo.head()
repo.publish()
h = Harness(tmp_path, repo,
core_update=lambda: {'status': 'error', 'message': 'Update failed: network'})
core_update=lambda **kw: {'status': 'error', 'message': 'Update failed: network'})
result = h.updater.run()
assert result['core_outcome'] == 'error'
assert repo.head() == old
@@ -407,13 +546,43 @@ class TestUpdateIsVerified:
repo.publish()
pull = real_pull(repo.device)
def half_failed():
def half_failed(**kwargs):
pull()
return {'status': 'error', 'message': 'Update failed: interrupted'}
result = Harness(tmp_path, repo, core_update=half_failed).updater.run()
assert repo.head() == old
assert 'rolled back' in result['core_message']
@pytest.mark.parametrize('how', ['partial_pull', 'check_never_started'])
def test_a_failed_rollback_leaves_plugins_and_the_display_alone(self, tmp_path, how):
"""update_core returns rollback_failed itself on two paths. The device
is then in an unknown state, exactly as when the health check reports
rollback_failed, so no plugin updates and no restart may follow."""
repo = Repo(tmp_path)
repo.publish()
store = FakeStore(tmp_path / 'plugins', {'clock': '1.0.0'}, bump={'clock'})
pull = real_pull(repo.device)
def half_failed(**kwargs):
pull()
return {'status': 'error', 'message': 'Update failed: interrupted'}
h = Harness(tmp_path, repo, store=store, pickup=how != 'check_never_started',
core_update=half_failed if how == 'partial_pull' else None)
real_run = h.updater.run_command
def reset_fails(args, **kwargs):
if args[:3] == ['git', 'reset', '--hard']:
return subprocess.CompletedProcess(args, 1, stdout='', stderr='index.lock exists')
return real_run(args, **kwargs)
h.updater.run_command = reset_fails
result = h.updater.run()
assert result['core_outcome'] == 'rollback_failed'
assert store.updated_calls == [], "plugins must not pile onto a core in an unknown state"
assert h.restarts == []
assert h.state['plugins_pending'] is False and result['plugins_deferred'] is False
assert h.state['alert']
# -- reporting the health check's outcome --------------------------------------
@@ -668,7 +837,9 @@ class TestSettingsSave:
def test_unchecked_toggle_on_general_save_disables(self, api_client):
client, cm, setup_calls = api_client
resp = client.post('/api/v3/config/main', json={'timezone': 'UTC'})
# The General form marks its posts; an unchecked box is then absent.
resp = client.post('/api/v3/config/main',
json={'__form_section': 'general', 'timezone': 'UTC'})
assert resp.status_code == 200
assert self._saved(cm)['auto_update'] == {'enabled': False}
assert setup_calls == [True]
+76
View File
@@ -69,6 +69,8 @@ class FakeHost:
self.running_head = None # not restarted yet: still the old, healthy code
self.restarts = [] # (unit, commit it was restarted onto)
self.pip_installs = []
self.pip_timeouts = []
self.pip = None # optional (args, host) -> result, or raises, instead of pip_ok
self.now = 0.0
self.nrestarts = 0
@@ -90,6 +92,9 @@ class FakeHost:
return done(args, f'{self.nrestarts}\n')
if args[0] == 'sudo' and any(a.endswith('safe_pip_install.sh') for a in args):
self.pip_installs.append(args[-1])
self.pip_timeouts.append(kwargs.get('timeout'))
if self.pip:
return self.pip(args, self)
return done(args, rc=0 if self.pip_ok else 1)
if args[:4] == ['sudo', '-n', 'systemctl', 'restart']:
if self.restart_failures:
@@ -171,6 +176,77 @@ def test_a_failed_dependency_reinstall_is_reported(tmp_path):
assert 'Install Base Requirements' in result['detail']
def _rollback_with_pip(tmp_path, pip, web_requirements_too=False):
"""Roll back an update that changed the requirements, with pip faked by ``pip``."""
repo, old, new = updated_repo(tmp_path, new_requirements=True)
if web_requirements_too:
(repo / 'web_interface').mkdir()
(repo / 'web_interface' / 'requirements.txt').write_text('flask\n')
git(repo, 'add', '.')
git(repo, 'commit', '-qm', 'web reqs')
(repo / 'web_interface' / 'requirements.txt').write_text('flask\nnew\n')
(repo / 'requirements.txt').write_text('requests\nnewer\n')
git(repo, 'commit', '-qam', 'bump both')
old, new = git(repo, 'rev-parse', 'HEAD~1'), git(repo, 'rev-parse', 'HEAD')
host = FakeHost(repo, new)
host.pip = pip
ok, detail = host.verifier().rollback({'old_head': old, 'new_head': new})
return ok, detail, host
def test_a_failed_pip_is_not_run_again_with_the_other_bash(tmp_path):
"""Retrying after pip itself ran only repeats it, and every repeat can
take PIP_TIMEOUT_SECONDS of the unit's time limit."""
ok, detail, host = _rollback_with_pip(
tmp_path, lambda args, h: subprocess.CompletedProcess(args, 1, '', 'ERROR: No matching distribution'))
assert ok and 'requirements.txt' in detail
assert len(host.pip_installs) == 1
def test_a_pip_timeout_is_not_retried(tmp_path):
def slow(args, h):
h.now += h.pip_timeouts[-1]
raise subprocess.TimeoutExpired(args, h.pip_timeouts[-1])
ok, detail, host = _rollback_with_pip(tmp_path, slow)
assert ok and 'Install Base Requirements' in detail
assert len(host.pip_installs) == 1
def test_a_sudo_refusal_tries_the_next_bash(tmp_path):
def refused_once(args, h):
if len(h.pip_installs) == 1:
return subprocess.CompletedProcess(args, 1, '', 'sudo: a password is required')
return done(args)
ok, detail, host = _rollback_with_pip(tmp_path, refused_once)
assert ok and detail == ''
assert len(host.pip_installs) == 2
def test_reinstalls_share_one_time_budget(tmp_path):
def hangs(args, h):
h.now += h.pip_timeouts[-1]
raise subprocess.TimeoutExpired(args, h.pip_timeouts[-1])
ok, detail, host = _rollback_with_pip(tmp_path, hangs, web_requirements_too=True)
assert ok and 'requirements.txt' in detail and 'web_interface/requirements.txt' in detail
assert sum(host.pip_timeouts) <= av.PIP_BUDGET_SECONDS
assert len(host.pip_installs) == 1, "the first file used the whole budget"
def test_the_worst_case_fits_the_unit_time_limit():
"""systemd kills the check at TimeoutStartSec, mid-rollback, and the update
then sits in "verifying" until the web interface calls it lost."""
from web_interface import auto_update as au
service = (ROOT / 'systemd' / 'ledmatrix-update-verify.service').read_text(encoding='utf-8')
minutes = int(re.search(r'^TimeoutStartSec=(\d+)min$', service, re.M).group(1))
assert av.WORST_CASE_SECONDS < minutes * 60
assert minutes * 60 < au.VERIFY_LOST_SECONDS, "the web UI must not call a running check lost"
def test_sudo_refusal_wording_matches_permission_utils():
from src.common import permission_utils
assert set(av.SUDO_REFUSAL_PHRASES) == set(permission_utils.SUDO_REFUSAL_PHRASES)
def test_still_broken_after_rolling_back_is_rollback_failed(tmp_path):
code, result, host, head, old, new = check(tmp_path, 'always')
assert code == 1 and result['status'] == 'rollback_failed'
@@ -0,0 +1,216 @@
"""BackgroundDataService recovers from ESPN rejecting a date range.
This is the path that broke on the Pi: NFLRecentManager submits the whole
season as ``dates=20260801-20270301``, the service fetches it on a worker
thread, and from 2026-09-15 every one of those fetches came back
``400 Client Error: Bad Request``. The service must re-ask in chunks and cache
the merged season rather than mark it failed.
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from src.background_data_service import BackgroundDataService
from src.common import espn_dates
from src.common.espn_dates import (
ESPN_MAX_LIMIT,
fetch_espn_scoreboard,
parse_espn_date_range,
)
URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
class FakeResponse:
def __init__(self, status_code, payload=None):
self.status_code = status_code
self._payload = payload if payload is not None else {"events": []}
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(str(self.status_code) + " Client Error: Bad Request")
class RangeRejectingSession:
"""Rejects day ranges the way ESPN has since 2026-09-15."""
def __init__(self, events_by_chunk=None, fail_chunks=()):
self.calls = []
self.events_by_chunk = events_by_chunk or {}
self.fail_chunks = set(fail_chunks)
def get(self, url, params=None, headers=None, timeout=None):
params = dict(params or {})
dates = str(params.get("dates", ""))
self.calls.append(params)
if parse_espn_date_range(dates) is not None:
return FakeResponse(400)
if dates in self.fail_chunks:
return FakeResponse(500)
return FakeResponse(200, {"events": self.events_by_chunk.get(dates, [])})
@pytest.fixture(autouse=True)
def ranges_not_yet_rejected(monkeypatch):
# The "ranges are rejected" memo is process-wide; every test starts clean.
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
@pytest.fixture
def cache():
manager = MagicMock()
manager.get.return_value = None # always a miss: force the fetch path
return manager
@pytest.fixture
def service(cache):
svc = BackgroundDataService(cache, max_workers=1, request_timeout=5)
yield svc
svc.shutdown(wait=False)
def submit_and_wait(service, session, dates, limit=1000):
results = []
with patch.object(service, "session", session):
request_id = service.submit_fetch_request(
sport="nfl",
year=2026,
url=URL,
cache_key="nfl_schedule_2026",
params={"dates": dates, "limit": limit},
max_retries=0,
callback=results.append,
)
deadline = time.time() + 5
while not service.is_request_complete(request_id) and time.time() < deadline:
time.sleep(0.02)
assert results, "the fetch never completed"
return results[0]
def test_the_service_advertises_that_it_handles_ranges():
# Plugins read this to decide whether to submit a season range here or
# fetch it themselves on a core that predates the fix.
assert BackgroundDataService.handles_espn_date_ranges is True
def test_a_rejected_season_is_recovered_and_cached(service, cache):
session = RangeRejectingSession(
{"202609": [{"id": "a"}, {"id": "b"}], "20261001": [{"id": "c"}]}
)
result = submit_and_wait(service, session, "20260901-20261001")
assert result.success, result.error
cached_key, cached_payload = cache.set.call_args[0][:2]
assert cached_key == "nfl_schedule_2026"
assert [event["id"] for event in cached_payload["events"]] == ["a", "b", "c"]
def test_a_full_season_costs_chunks_not_one_request_per_day(service):
session = RangeRejectingSession({"202609": [{"id": "a"}]})
submit_and_wait(service, session, "20260801-20270301")
sent = [call["dates"] for call in session.calls]
# Eight chunks rather than 213 per-day requests. They are fetched
# concurrently, so the range is the only one pinned to a position.
assert sent[0] == "20260801-20270301"
assert sorted(sent[1:]) == [
"202608",
"202609",
"202610",
"202611",
"202612",
"202701",
"202702",
"20270301",
]
def test_the_limit_that_truncates_never_reaches_espn(service):
session = RangeRejectingSession({"202609": [{"id": "a"}]})
submit_and_wait(service, session, "20260901-20260930", limit=1000)
assert all(call["limit"] == ESPN_MAX_LIMIT for call in session.calls)
def test_a_non_scoreboard_endpoint_keeps_its_limit(service):
# /teams needs limit=1000: college football has 762 teams, and limit=500
# returns 500 of them. Only scoreboards truncate above 500.
session = RangeRejectingSession()
with patch.object(service, "session", session):
request_id = service.submit_fetch_request(
sport="ncaa_fb",
year=2026,
url="https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams",
cache_key="ncaa_fb_teams",
params={"limit": 1000},
max_retries=0,
)
deadline = time.time() + 5
while not service.is_request_complete(request_id) and time.time() < deadline:
time.sleep(0.02)
assert session.calls[0]["limit"] == 1000
def test_losing_every_chunk_is_a_failure_not_an_empty_season(service, cache):
session = RangeRejectingSession(fail_chunks={"202609"})
result = submit_and_wait(service, session, "20260901-20260930")
# An empty payload would be cached and read as "no games all year".
assert not result.success
cache.set.assert_not_called()
def test_a_400_on_a_single_day_is_still_a_failure(service, cache):
class AlwaysBad(RangeRejectingSession):
def get(self, url, params=None, headers=None, timeout=None):
self.calls.append(dict(params or {}))
return FakeResponse(400)
session = AlwaysBad()
result = submit_and_wait(service, session, "20260913")
assert not result.success
assert len(session.calls) == 1
cache.set.assert_not_called()
def test_a_rejection_seen_by_the_service_is_remembered_for_scoreboards(service):
submit_and_wait(service, RangeRejectingSession({"202609": [{"id": "a"}]}),
"20260901-20260930")
# A live scoreboard asking for a range next must not spend a doomed 400.
session = RangeRejectingSession({"202609": [{"id": "a"}]})
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20260930"})
assert [call["dates"] for call in session.calls] == ["202609"]
assert [event["id"] for event in data["events"]] == ["a"]
def test_a_rejection_seen_by_a_scoreboard_skips_the_range_in_the_service(service, cache):
fetch_espn_scoreboard(RangeRejectingSession(), URL,
params={"dates": "20260901-20260930"})
session = RangeRejectingSession({"202609": [{"id": "a"}]})
result = submit_and_wait(service, session, "20260901-20261001")
assert result.success, result.error
assert [call["dates"] for call in session.calls] == ["202609", "20261001"]
def test_known_rejection_with_every_chunk_failing_asks_the_range_once(service, cache):
espn_dates._note_range_rejected()
session = RangeRejectingSession(fail_chunks={"202609"})
result = submit_and_wait(service, session, "20260901-20260930")
assert not result.success
# Chunks once, then the range for a real error -- not the chunks again.
assert [call["dates"] for call in session.calls] == ["202609", "20260901-20260930"]
cache.set.assert_not_called()
def test_ranges_are_tried_again_once_the_memo_expires(service, monkeypatch):
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", time.monotonic() - 1)
session = RangeRejectingSession({"202609": [{"id": "a"}]})
submit_and_wait(service, session, "20260901-20260930")
assert [call["dates"] for call in session.calls] == ["20260901-20260930", "202609"]
+58
View File
@@ -3,9 +3,12 @@
from __future__ import annotations
import json
import os
import stat
import sys
import zipfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
@@ -300,6 +303,11 @@ def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> N
assert any("unsafe" in e.lower() for e in result.errors)
@pytest.mark.skipif(
sys.platform == "win32",
reason="simulates root-owned POSIX files with chmod 0o444, which on Windows sets the "
"read-only attribute, and Windows refuses to rename over a read-only file",
)
def test_restore_over_a_file_the_user_cannot_write(
project: Path, empty_project: Path, tmp_path: Path
) -> None:
@@ -334,3 +342,53 @@ def test_restore_over_a_file_the_user_cannot_write(
# The destination's mode is preserved rather than widened to the umask.
assert stat.S_IMODE((empty_project / "config" / "config_secrets.json").stat().st_mode) == 0o444
def _existing_config(empty_project: Path) -> None:
(empty_project / "config").mkdir(parents=True, exist_ok=True)
for name in ("config.json", "config_secrets.json", "wifi_config.json", "ytm_auth.json"):
(empty_project / "config" / name).write_text("{}", encoding="utf-8")
def test_restore_over_existing_files_without_os_chown(
project: Path, empty_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Restore must work where the OS has no file ownership API (Windows).
Replacing a file tries to carry its previous owner across with os.chown.
That name does not exist on Windows, and the AttributeError is not an
OSError, so it escaped every per-section handler: restoring over any
existing config aborted the whole restore and left the old files in place.
"""
zip_path = create_backup(project, output_dir=tmp_path / "exports")
_existing_config(empty_project)
monkeypatch.delattr(os, "chown", raising=False)
result = restore_backup(zip_path, empty_project, RestoreOptions())
assert result.success, result.errors
for section in ("config", "secrets", "wifi", "ytm_auth"):
assert section in result.restored, f"{section} not restored: {result.errors}"
restored = json.loads((empty_project / "config" / "config.json").read_text())
assert restored["my-plugin"]["favorites"] == ["A", "B"]
def test_restore_still_carries_the_previous_owner_across(
project: Path, empty_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Where os.chown exists, the replaced file keeps the old file's owner."""
zip_path = create_backup(project, output_dir=tmp_path / "exports")
_existing_config(empty_project)
target = empty_project / "config" / "config.json"
old = target.stat()
chown = MagicMock()
monkeypatch.setattr(os, "chown", chown, raising=False)
result = restore_backup(zip_path, empty_project, RestoreOptions(
restore_secrets=False, restore_wifi=False,
restore_fonts=False, restore_plugin_uploads=False, reinstall_plugins=False,
))
assert result.success, result.errors
owners = {(c.args[1], c.args[2]) for c in chown.call_args_list}
assert owners == {(old.st_uid, old.st_gid)}
+267
View File
@@ -0,0 +1,267 @@
"""Tests that the web interface can read what the display service caches.
The display service runs as root and the web interface as the installing user;
cache files are 0660, so the web interface reads them only through their group.
The installers made that the directory's group via setgid, but the web unit's
CacheDirectory= let systemd re-own /var/cache/ledmatrix to the web user and its
primary group on first start, erasing it. Every file root wrote afterwards was
root:root 0660:
WARNING - Permission denied loading cache for display_current_state from
/var/cache/ledmatrix/display_current_state.json: [Errno 13] Permission denied
Measured on one rig: 365 unreadable files, and the web UI's display status,
on-demand state and plugin health all silently empty.
Most of these need POSIX ownership calls, so they skip on Windows. The ones
that only make sense as root (another user reading, another user's files) skip
without it; run them with `sudo python3 -m pytest test/test_cache_shared_group.py`.
"""
import os
import shutil
import stat
import tempfile
import time
from pathlib import Path
import pytest
from src.cache import disk_cache as disk_cache_module
from src.cache.disk_cache import DiskCache
posix_only = pytest.mark.skipif(
not hasattr(os, 'fchown') or not hasattr(os, 'geteuid'),
reason="needs POSIX file ownership")
IS_ROOT = hasattr(os, 'geteuid') and os.geteuid() == 0
root_only = pytest.mark.skipif(not IS_ROOT, reason="needs root")
# Ids nothing on a test host is likely to use, for root-only scenarios.
OTHER_GID = 48213
OTHER_UID = 48214
def _another_group():
"""A group this process may chown its files to, other than its own."""
if IS_ROOT:
return OTHER_GID
for gid in os.getgroups():
if gid != os.getegid():
return gid
pytest.skip("process belongs to no supplementary group")
def _shared_dir(path, gid, mode=0o775):
"""The rig's broken layout: group-writable, no setgid, a foreign group."""
os.chown(path, -1, gid)
os.chmod(path, mode)
assert not os.stat(path).st_mode & stat.S_ISGID
return path
@posix_only
class TestNewFilesTakeTheDirectoryGroup:
def test_without_setgid(self, tmp_path):
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
cache.set('display_current_state', {'mode': 'clock'})
st = os.stat(tmp_path / 'display_current_state.json')
assert st.st_gid == gid
assert stat.S_IMODE(st.st_mode) == 0o660
def test_the_file_is_never_visible_with_a_narrower_mode(self, tmp_path, monkeypatch):
"""mkstemp creates 0600; the rename used to publish it before the chmod."""
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
seen = []
real_replace = os.replace
def spy(src, dst):
st = os.stat(src)
seen.append((stat.S_IMODE(st.st_mode), st.st_gid))
return real_replace(src, dst)
monkeypatch.setattr(disk_cache_module.os, 'replace', spy)
cache.set('k', {'v': 1})
assert seen == [(0o660, gid)]
def test_a_rewrite_fixes_a_file_left_with_the_wrong_group(self, tmp_path):
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
path = tmp_path / 'k.json'
path.write_text('{}')
os.chown(path, -1, os.getegid())
cache.set('k', {'v': 2})
assert os.stat(path).st_gid == gid
def test_the_direct_write_fallback_shares_too(self, tmp_path, monkeypatch):
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
def no_temp(*args, **kwargs):
raise OSError("no temp files")
monkeypatch.setattr(disk_cache_module.tempfile, 'mkstemp', no_temp)
cache.set('k', {'v': 3})
st = os.stat(tmp_path / 'k.json')
assert (stat.S_IMODE(st.st_mode), st.st_gid) == (0o660, gid)
def test_a_directory_not_shared_with_its_group_is_left_alone(self, tmp_path):
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid, mode=0o755)))
cache.set('k', {'v': 4})
assert os.stat(tmp_path / 'k.json').st_gid == os.getegid()
@posix_only
class TestExistingFilesAreRepaired:
def test_a_root_style_file_becomes_readable(self, tmp_path):
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
path = tmp_path / 'display_on_demand_state.json'
path.write_text('{}')
os.chown(path, -1, os.getegid())
os.chmod(path, 0o600)
assert cache.share_existing_files() == 1
st = os.stat(path)
assert (stat.S_IMODE(st.st_mode), st.st_gid) == (0o660, gid)
def test_files_already_shared_are_not_counted(self, tmp_path):
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
cache.set('k', {'v': 1})
assert cache.share_existing_files() == 0
def test_only_json_files(self, tmp_path):
gid = _another_group()
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
other = tmp_path / 'tile.png'
other.write_bytes(b'x')
os.chown(other, -1, os.getegid())
cache.share_existing_files()
assert os.stat(other).st_gid == os.getegid()
def test_a_symlink_is_not_followed(self, tmp_path):
"""The directory is writable by the web user; root must not be aimed elsewhere."""
gid = _another_group()
cache_dir = tmp_path / 'cache'
cache_dir.mkdir()
_shared_dir(cache_dir, gid)
outside = tmp_path / 'outside'
outside.write_text('secret')
os.chown(outside, -1, os.getegid())
os.chmod(outside, 0o600)
(cache_dir / 'evil.json').symlink_to(outside)
assert DiskCache(str(cache_dir)).share_existing_files() == 0
st = os.stat(outside)
assert (stat.S_IMODE(st.st_mode), st.st_gid) == (0o600, os.getegid())
def test_a_hard_linked_file_is_skipped(self, tmp_path):
gid = _another_group()
cache_dir = tmp_path / 'cache'
cache_dir.mkdir()
_shared_dir(cache_dir, gid)
outside = tmp_path / 'outside'
outside.write_text('secret')
os.chown(outside, -1, os.getegid())
os.chmod(outside, 0o600)
os.link(outside, cache_dir / 'linked.json')
assert DiskCache(str(cache_dir)).share_existing_files() == 0
assert stat.S_IMODE(os.stat(outside).st_mode) == 0o600
@root_only
def test_another_users_file_is_skipped(self, tmp_path):
cache = DiskCache(str(_shared_dir(tmp_path, OTHER_GID)))
path = tmp_path / 'theirs.json'
path.write_text('{}')
os.chown(path, OTHER_UID, 0)
os.chmod(path, 0o600)
assert cache.share_existing_files() == 0
assert os.stat(path).st_gid == 0
@posix_only
@root_only
def test_a_non_root_member_of_the_group_can_read_what_root_wrote():
"""The rig, end to end: root writes, the web user reads."""
# Not tmp_path: pytest's root-owned base directory is 0700, which the
# reader could not traverse no matter what the cache file's group was.
base = tempfile.mkdtemp()
try:
_root_writes_web_user_reads(Path(base))
finally:
shutil.rmtree(base, ignore_errors=True)
def _root_writes_web_user_reads(base):
os.chmod(base, 0o755)
cache_dir = base / 'cache'
cache_dir.mkdir()
# What systemd's CacheDirectory= left behind: the web user's own group.
os.chown(cache_dir, OTHER_UID, OTHER_GID)
os.chmod(cache_dir, 0o775)
DiskCache(str(cache_dir)).set('display_current_state', {'mode': 'clock'})
pid = os.fork()
if pid == 0: # pragma: no cover - child
code = 1
try:
os.setgroups([])
os.setgid(OTHER_GID)
os.setuid(OTHER_UID)
with open(cache_dir / 'display_current_state.json', 'rb') as f:
code = 0 if b'clock' in f.read() else 2
except PermissionError:
code = 13
finally:
os._exit(code)
_, status = os.waitpid(pid, 0)
assert os.WEXITSTATUS(status) == 0, "web user could not read the display's cache file"
def test_the_web_unit_does_not_let_systemd_re_own_the_cache():
template = Path(__file__).resolve().parent.parent / 'systemd' / 'ledmatrix-web.service'
directives = [line.strip() for line in template.read_text(encoding='utf-8').splitlines()
if line.strip() and not line.strip().startswith('#')]
offending = [d for d in directives if d.startswith(('CacheDirectory', 'StateDirectory'))]
assert not offending, (
f"{offending}: systemd re-owns that directory to the web user, and the "
"display service (root) shares it")
def test_the_cleanup_thread_repairs_existing_files(tmp_path, monkeypatch):
from src.cache_manager import CacheManager
calls = []
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
monkeypatch.setattr(DiskCache, 'share_existing_files',
lambda self: calls.append(self.cache_dir) or 0)
CacheManager._cleanup_owners.clear()
manager = CacheManager()
try:
deadline = time.monotonic() + 5
while not calls and time.monotonic() < deadline:
time.sleep(0.01)
finally:
manager.stop_cleanup_thread()
CacheManager._cleanup_owners.clear()
assert calls == [str(tmp_path)]
+163
View File
@@ -0,0 +1,163 @@
"""src/common must stay importable without display hardware.
Plugins import src.common.sports_* in place of their bundled copies. If any of
those modules reaches src.display_manager (and through it rgbmatrix) or the
src.base_classes package (whose core.py imports DisplayManager), a scoreboard
adopting it acquires a hardware dependency it never had, and the headless
tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops
being able to load it.
Two checks, because each misses what the other catches:
1. A real import in a fresh interpreter with rgbmatrix made unimportable.
Catches indirect imports (common -> X -> display_manager) that no text scan
of src/common would see.
2. An AST scan of src/common/*.py for module-level imports of the hardware and
plugin-system packages. Catches a direct import even where the target
happens to import cleanly on this machine.
"""
import ast
import subprocess
import sys
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
COMMON = REPO_ROOT / "src" / "common"
FORBIDDEN = ("src.base_classes", "src.display_manager", "src.plugin_system")
#: Existing module-level violations, by file name, each with the reason it is
#: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers):
#: no src/common module imported any of FORBIDDEN at module level. Add to this
#: only with a comment saying why, never to get a new import past the test.
ALLOWLIST = {}
def test_sports_modules_import_without_hardware():
modules = sorted(p.stem for p in COMMON.glob("sports_*.py"))
assert "sports_helpers" in modules
script = textwrap.dedent(f"""
import importlib, json, sys
sys.modules["rgbmatrix"] = None # any import of it now raises
importlib.import_module("src.common")
for name in {modules!r}:
importlib.import_module("src.common." + name)
loaded = sorted(m for m in sys.modules
if m.startswith({FORBIDDEN!r}))
print(json.dumps(loaded))
""")
result = subprocess.run(
[sys.executable, "-c", script], cwd=str(REPO_ROOT),
capture_output=True, text=True, timeout=120)
assert result.returncode == 0, (
f"importing src.common without rgbmatrix failed:\n{result.stderr}")
loaded = result.stdout.strip().splitlines()[-1]
assert loaded == "[]", f"src.common pulled in hardware modules: {loaded}"
def _module_level_imports(tree):
"""Imports executed at import time: top level, including inside if/try
blocks, but not inside functions or classes (those are deferred)."""
stack = list(tree.body)
while stack:
node = stack.pop()
if isinstance(node, (ast.Import, ast.ImportFrom)):
yield node
elif isinstance(node, (ast.If, ast.Try, ast.With)):
stack.extend(node.body)
stack.extend(getattr(node, "orelse", []))
stack.extend(getattr(node, "finalbody", []))
for handler in getattr(node, "handlers", []):
stack.extend(handler.body)
def _targets(node, package="src.common"):
"""Absolute dotted names an import can load. Relative imports resolve
against ``package``, the scanned module's package."""
if isinstance(node, ast.Import):
return [alias.name for alias in node.names]
base = node.module
if node.level:
parts = package.split(".")
if node.level > len(parts):
return [] # beyond the top-level package; Python rejects it too
anchor = ".".join(parts[:len(parts) - node.level + 1])
base = f"{anchor}.{node.module}" if node.module else anchor
if base is None:
return []
# `from pkg import name` may load the submodule pkg.name.
return [base] + [f"{base}.{alias.name}" for alias in node.names
if alias.name != "*"]
def _violations():
found = {}
for path in sorted(COMMON.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in _module_level_imports(tree):
for target in _targets(node, "src.common"):
if target == FORBIDDEN or any(
target == f or target.startswith(f + ".") for f in FORBIDDEN):
found.setdefault(path.name, []).append(f"line {node.lineno}: {target}")
return found
def test_no_module_level_hardware_or_plugin_system_imports():
found = {name: hits for name, hits in _violations().items() if name not in ALLOWLIST}
assert found == {}, (
"src/common must not import these at module level (defer the import "
f"into the function that needs it): {found}")
def test_allowlist_has_no_stale_entries():
stale = sorted(set(ALLOWLIST) - set(_violations()))
assert stale == [], f"allowlisted but no longer violating, remove: {stale}"
def test_the_scan_sees_a_direct_import(tmp_path):
# Guard the guard: the walker must find imports nested in try/if.
tree = ast.parse(textwrap.dedent("""
try:
from src.display_manager import DisplayManager
except ImportError:
pass
if True:
import src.base_classes.sports
from src import plugin_system
def later():
from src.display_manager import DisplayManager
"""))
targets = {t for n in _module_level_imports(tree) for t in _targets(n)}
assert {"src.base_classes.sports", "src.display_manager",
"src.plugin_system"} <= targets
def test_the_scan_resolves_relative_imports():
# A module in src/common reaches src.plugin_system through `..`.
tree = ast.parse(textwrap.dedent("""
from .. import plugin_system
from ..plugin_system import plugin_manager
from . import sports_shared
"""))
targets = {t for n in _module_level_imports(tree)
for t in _targets(n, "src.common")}
assert {"src.plugin_system", "src.plugin_system.plugin_manager",
"src.common.sports_shared"} <= targets
# The package decides where `..` points.
node = ast.parse("from .. import plugin_system").body[0]
assert _targets(node, "src.common") == ["src", "src.plugin_system"]
assert _targets(node, "a.b") == ["a", "a.plugin_system"]
def test_the_scan_flags_relative_imports_in_src_common(monkeypatch, tmp_path):
# End to end through _violations(): both relative forms are reported.
(tmp_path / "bad.py").write_text(
"from .. import plugin_system\nfrom ..plugin_system import x\n",
encoding="utf-8")
monkeypatch.setitem(globals(), "COMMON", tmp_path)
found = {name: sorted(hits) for name, hits in _violations().items()}
assert found == {"bad.py": ["line 1: src.plugin_system",
"line 2: src.plugin_system",
"line 2: src.plugin_system.x"]}
+24
View File
@@ -333,3 +333,27 @@ class TestArraySecretStripAndMerge:
manager._deep_merge(stripped, secrets)
assert stripped == full # round trip restores the items
class TestLoadWithoutPosixOwnershipApis:
"""A secrets file must load on platforms that have no uid/gid at all.
load_config() chgrp's the secrets file to the shared group before reading
it, to self-heal a root-written file the non-root web user can't read.
That helper is documented as best-effort, but it looked up os.geteuid
unguarded -- absent on Windows -- and the resulting AttributeError is not
an OSError, so it escaped every except clause on the way out. The symptom
was a ConfigError from load_config on any Windows checkout carrying a
config/config_secrets.json, which took `import web_interface.app` with it.
"""
def test_secrets_still_merge_without_geteuid(self, tmp_path, monkeypatch):
monkeypatch.delattr(os, "geteuid", raising=False)
monkeypatch.delattr(os, "chown", raising=False)
manager = make_manager(
tmp_path,
config={"weather": {"city": "Austin"}},
secrets={"weather": {"api_key": "s3cret"}},
)
assert manager.load_config()["weather"] == {"city": "Austin", "api_key": "s3cret"}
+108
View File
@@ -0,0 +1,108 @@
"""Every "is this top-level key a plugin?" decision uses src/core_config_keys.py.
#589 moved plugin-state reconciliation onto the shared list, but three private
copies stayed behind and did not know about auto_update, dim_schedule, sync,
location, target_fps, ...:
- StartupValidator warned "Plugin 'auto_update' is enabled but not found in
plugins directory" on every display start with auto-update or a dim
schedule switched on;
- SchemaManager.detect_config_key_collisions let a plugin id collide with
those sections silently;
- ConfigManager.cleanup_orphaned_plugin_configs deleted display, schedule,
auto_update, ... as orphans, and validate_all_plugin_configs validated core
sections as plugins.
"""
import json
import re
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.config_manager import ConfigManager
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
from src.plugin_system.schema_manager import SchemaManager
from src.startup_validator import StartupValidator
REPO = Path(__file__).resolve().parent.parent
class TestStartupValidator:
def test_enabled_core_sections_are_not_missing_plugins(self):
config = {
'display': {'hardware': {}},
'auto_update': {'enabled': True},
'dim_schedule': {'enabled': True},
'schedule': {'enabled': True},
'gone-plugin': {'enabled': True},
}
cm = MagicMock()
cm.get_config.return_value = config
pm = MagicMock()
pm.discover_plugins.return_value = []
validator = StartupValidator(cm, plugin_manager=pm, cache_manager=MagicMock())
validator.warnings = []
validator.errors = []
validator._validate_plugins()
missing = [w for w in validator.warnings if 'not found in plugins directory' in w]
assert missing == ["Plugin 'gone-plugin' is enabled but not found in plugins directory"]
class TestReservedPluginIds:
@pytest.mark.parametrize('key', sorted(CORE_CONFIG_KEYS))
def test_every_core_key_is_reserved(self, key):
collisions = SchemaManager().detect_config_key_collisions([key])
assert [c['type'] for c in collisions] == ['reserved_key_collision']
def test_an_ordinary_plugin_id_is_not(self):
assert SchemaManager().detect_config_key_collisions(['clock-simple']) == []
class TestConfigManagerHelpers:
@pytest.fixture
def manager(self, tmp_path):
config = {key: {'enabled': True} for key in ('display', 'schedule', 'auto_update',
'dim_schedule', 'sync', 'location')}
config.update({'timezone': 'UTC', 'installed': {'enabled': True},
'orphan': {'enabled': True}})
secrets = {'github': {'token': 't'}, 'youtube': {'api_key': 'k'},
'installed': {'api_key': 'a'}, 'orphan': {'api_key': 'o'}}
(tmp_path / 'config.json').write_text(json.dumps(config))
(tmp_path / 'secrets.json').write_text(json.dumps(secrets))
manager = ConfigManager(config_path=str(tmp_path / 'config.json'),
secrets_path=str(tmp_path / 'secrets.json'))
manager.template_path = str(tmp_path / 'no-template.json')
return manager
def test_cleanup_removes_only_real_orphans(self, manager, tmp_path):
removed = manager.cleanup_orphaned_plugin_configs(['installed'])
assert removed == ['orphan']
config = json.loads((tmp_path / 'config.json').read_text())
assert {'display', 'schedule', 'auto_update', 'dim_schedule', 'sync',
'location', 'timezone', 'installed'} == set(config)
secrets = json.loads((tmp_path / 'secrets.json').read_text())
assert set(secrets) == {'github', 'youtube', 'installed'}
def test_validate_all_skips_core_sections(self, manager):
schema_manager = MagicMock()
schema_manager.load_schema.return_value = None
results = manager.validate_all_plugin_configs(schema_manager)
assert set(results) == {'installed', 'orphan'}
def test_core_secrets_keys_are_not_core_config_keys():
assert not (CORE_SECRETS_KEYS & CORE_CONFIG_KEYS)
@pytest.mark.parametrize('relpath', [
'src/startup_validator.py',
'src/config_manager.py',
'src/plugin_system/schema_manager.py',
])
def test_no_private_copy_of_the_list_remains(relpath):
"""The old copies all started with this literal; a new copy likely would too."""
source = (REPO / relpath).read_text(encoding='utf-8')
assert not re.search(r"""\[\s*['"]display['"]\s*,\s*['"]schedule['"]""", source), \
f'{relpath} has a private core-key list again; use src/core_config_keys.py'

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