Compare commits

..
Author SHA1 Message Date
ChuckandClaude Fable 5 ced173e95a test: sync mock cache manager signature with CacheManager.get
test_circuit_breaker has been failing on main: plugin_health passes
memory_ttl= to cache_manager.get(), and the conftest mock's signature
was never updated — the same component/double drift class as the
monitored_update bug (#392).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
2026-07-12 10:31:52 -04:00
ChuckandClaude Fable 5 acfd704d13 fix(core): harden text-measurement caches; surface snapshot failures
Deep-dive findings, all three latent on every 24/7 install:

- font_manager.metrics_cache and display_manager._text_width_cache were
  unbounded dicts keyed by (text, id(font)). Two problems: keys embed
  the measured TEXT, so ever-changing strings (a clock, a live score, a
  ticker) grow them without limit; and id()-keying without holding a
  reference means a garbage-collected font's id can be recycled by a
  DIFFERENT font, silently returning wrong widths/metrics (classic
  plugins create fonts per render, so this is reachable). Both caches
  are now LRU-bounded (1024) and pin the font in the entry so its id
  stays valid. metrics_cache also keyed on the text itself instead of
  hash(text), removing a collision path.

- _write_snapshot_if_due logged failures at DEBUG — invisible at the
  default level. The snapshot's mtime is the web UI's display mirror
  AND its hardware-liveness proxy, so a quiet failure freezes the
  mirror and makes health checks lie (seen in the field: a stale
  root-owned /tmp file froze it for a day). Failures now WARN, rate-
  limited to once per 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
2026-07-12 10:29:20 -04:00
05e7c43b27 fix(plugins): replace dependency marker files with a real satisfaction check (#390)
* fix(plugins): replace dependency marker files with a real satisfaction check

The .dependencies_installed hash-marker system only tracked "was this exact
requirements.txt hashed before" — not whether the packages it names are
actually present. That made it fragile (a wiped venv, a manually removed
package, or a lost/corrupted marker forces a needless full pip reinstall or,
worse, a false skip) and produced dead weight for the ~10 plugins whose
requirements.txt is comment-only (they still paid a pip subprocess on first
boot before a marker existed).

Replace it with requirements_are_satisfied() in plugin_loader.py, which
checks each real requirement line against importlib.metadata directly, so
install_dependencies() only shells out to pip when something is actually
missing or version-mismatched. Drops the marker file entirely: removed all
marker read/write sites in plugin_loader.py and store_manager.py, the
now-pointless marker-cleanup step in the git-update path, the unused legacy
marker implementation in plugin_manager.py, and the already-stale
clear_dependency_markers.sh script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix(security): close path-injection gap in dependency-satisfaction checks

CodeQL flagged 2 new high-severity "uncontrolled data used in path
expression" alerts at the open() calls inside this PR's new
requirements_has_real_deps()/requirements_are_satisfied() -- both are
reachable from paths that were never run through the basename+trusted-base
sanitiser this codebase already uses elsewhere:

- PluginLoader.install_dependencies() only applied that sanitiser when its
  optional plugins_dir argument was actually passed; the "no plugins_dir"
  branch trusted plugin_dir_real directly. Made plugins_dir required (not
  Optional) so that branch can't exist, and added an explicit guard in
  load_plugin() so install_deps=True without a plugins_dir fails loudly
  instead of silently. Production's only real caller (PluginManager) always
  passes plugins_dir already; the harness/dev-server/render-plugin callers
  all use install_deps=False and are unaffected.

- StoreManager._install_dependencies() never sanitised plugin_path at all,
  and its call sites ultimately derive that path from a plugin's own
  manifest.json "id" field (install_plugin_from_url) -- a malicious plugin
  could otherwise point requirements_file outside plugins_dir. Applied the
  same os.path.basename()-based containment pattern PluginLoader already
  uses (and that CodeQL recognises as a real sanitiser).

Added test_install_dependencies_requires_plugins_dir and
test_install_dependencies_rejects_path_outside_plugins_dir to lock in the
actual security property, not just quiet the scanner. Verified: all 20
tests in test_plugin_loader.py pass, plus the PR's existing test plan
(test_plugin_system.py, test_store_manager_caches.py: 53 passed) and the
full CI plugin-safety suite (test_harness.py, test_visual_rendering.py,
test_plugin_matrix.py: 52 passed, 2 pre-existing skips) all still pass.

* fix(security): replace basename-only sanitiser with a trusted-enumeration check

The previous commit's os.path.basename() + os.path.join() pattern (which a
pre-existing code comment claimed CodeQL recognises as a sanitiser) did not
actually clear the alert -- the next CodeQL run still flagged the same 2
sink lines, plus a new one at the os.path.join() call itself. Taking a
substring of tainted data apparently isn't treated as a barrier by this
query, whatever the comment assumed.

Replaced it with find_trusted_subdir(): enumerate the trusted plugins_dir
via os.scandir() and only use a name that scandir itself produced, matched
by equality against the caller's requested name. The path is then built
from that enumerated entry, not from the caller's string -- a value
sourced from iterating a trusted, non-tainted directory carries no taint
regardless of what it happens to equal, which is a stronger and more
conventional allowlist-style barrier than string-stripping. Applied
identically in both PluginLoader.install_dependencies() and
StoreManager._install_dependencies(), sharing one implementation.

Re-verified: all 65 tests across test_plugin_loader.py (20, including the
2 new security regression tests), test_store_manager_caches.py (35),
test_plugin_system.py (10) pass, plus the full CI plugin-safety suite
(test_harness.py/test_visual_rendering.py/test_plugin_matrix.py: 52
passed, 2 pre-existing skips).

* fix(security): redact URL credentials from pip subprocess output before logging

CodeQL flagged 3 clear-text-logging-of-secrets alerts in
install_requirements_file() (src/common/permission_utils.py:353,360,371).
Pre-existing on main, unrelated to this PR's own diff, but now visible
since the path-injection alerts that previously took priority in the
annotation list are fixed.

The underlying risk is real: pip can echo a private index URL's embedded
basic-auth credentials (from a requirements.txt --index-url line or
PIP_INDEX_URL) back verbatim in its own stderr/stdout on failure, and this
function both logs that output directly and returns it to callers --
store_manager.py's _install_dependencies() logs result.stderr from this
same function too.

Added _redact_url_credentials(), applied immediately after each of the two
subprocess.run() calls (mutating result.stderr/stdout in place) rather
than patching each log call site individually. This closes the leak at
the source: every downstream use -- the three flagged log lines, the
"note" string embedded in the returned stdout, and store_manager.py's own
logging of the returned result -- gets the redacted text for free.

Verified the fixed-phrase "denied" check (`"a password is required" in
result.stderr`) is unaffected, since URL syntax and those phrases don't
overlap -- covered explicitly by
test_does_not_touch_denied_check_phrases. Added
test/test_permission_utils.py (6 tests) covering the redaction helper
directly and both subprocess.run() call sites (the sudo-wrapper branch,
which this repo's scripts/fix_perms/safe_pip_install.sh makes live, and
the no-wrapper fallback branch). All pass.

* fix(security): stop interpolating req_file/pip-output into log calls

The previous commit's redaction (mutating result.stderr/stdout right after
each subprocess.run()) didn't clear CodeQL's clear-text-logging alerts --
same lesson as the path-injection fix earlier in this PR: a static
analyzer can't tell "this value was already sanitised two lines up" from
"this is still the raw tainted value" just by looking at a single log
call in isolation, so it conservatively keeps flagging it regardless of
what the redaction function actually does.

Removed all dynamic interpolation (req_file, result.stderr) from the 3
flagged logger.warning() calls entirely, replacing them with fixed
messages plus (for the one that had it) result.returncode, which is a
plain int with no possible taint. The full redacted detail is still
available where it actually matters -- in the returned
CompletedProcess.stderr/stdout and the "note" text -- just not duplicated
into a log line a scanner has to reason about in isolation.

Re-verified: all 6 test_permission_utils.py tests still pass (they assert
on the returned result, not log call arguments), plus the full
test_plugin_loader.py/test_store_manager_caches.py/test_plugin_system.py
suite (71 passed, 1 pre-existing deselect, 4 subtests).

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 08:55:50 -04:00
ChuckandGitHub 2ffc57cf40 fix(plugin-harness): add no-op process_deferred_updates to test double (#391)
The safety harness's VisualTestDisplayManager (base of
BoundsCheckingDisplayManager) doesn't implement process_deferred_updates(),
which 5 first-party ledmatrix-plugins call unconditionally between
set_scrolling_state() and their scroll-position update: news, odds-ticker,
ledmatrix-leaderboard, stock-news, and ledmatrix-stocks. Any of them fails
the harness with AttributeError the moment it's touched (surfaced when
ledmatrix-plugins#177 had to add a local hasattr guard in ledmatrix-stocks
just to pass CI). Add the method as a no-op, mirroring the existing
"no-op for testing" pattern already used for set_scrolling_state, so these
plugins render under the harness without every touching PR needing its own
guard.
2026-07-11 08:55:03 -04:00
ChuckandGitHub aab0e9ade0 fix(plugin-manager): fix TypeError breaking every plugin's scheduled update (#392)
run_scheduled_updates()'s resource-monitor branch wrapped the update call
in a closure stored as a *class* attribute on a dynamically-built type
(type('obj', (object,), {'update': monitored_update})()). The descriptor
protocol turns a function found via class-attribute lookup into a bound
method on instance access, silently prepending the synthetic instance as
an implicit first argument -- but monitored_update() takes none, so every
call raised "monitored_update() takes 0 positional arguments but 1 was
given", was caught by run_scheduled_updates' try/except, and recorded as
an update failure.

self.resource_monitor is None by default and was dormant until PR #388
("activate dormant plugin health/metrics subsystem") wired it up in both
display_controller.py and web_interface/app.py -- meaning this bug went
live in every real deployment as of that merge (2026-07-09) despite the
buggy line itself dating back to 2025-12-27. In practice this means no
plugin's update() has succeeded since upgrading past #388: circuit
breakers cycle through half-open -> immediate failure -> reopened every
health-check interval forever, and all plugin data (scores, odds, prices,
etc.) goes stale from whatever was last fetched before the upgrade.
Confirmed live on a running instance: odds-ticker (and stock-news,
ledmatrix-stocks, baseball-scoreboard, ledmatrix-leaderboard, of-the-day)
failing this exact way every 5-minute circuit-breaker retry.

Fixed by using types.SimpleNamespace(update=monitored_update) instead of
a dynamic class: SimpleNamespace stores attributes on the instance
itself, so attribute lookup returns the plain function unchanged --
never routed through the class-attribute descriptor protocol that
injects an implicit self.

Added test_run_scheduled_updates_calls_update_with_resource_monitor to
test/test_plugin_system.py using a real PluginResourceMonitor (not a
mock of it), so the test exercises the actual descriptor-binding
behavior that caused this. Verified the test fails with the exact
reported error against the pre-fix code and passes against the fix.
2026-07-11 08:54:30 -04:00
978a03b42d Add settings tooltips and search to the web UI (#387)
* Add settings tooltips and search to the web UI

Help users quickly find settings and understand how each one works.

Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an
accessible (i) info tooltip that appears on hover, keyboard focus, and tap.
A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger;
the plugin config macro and the core settings partials now surface help
text through it. Per the design, the always-visible field help paragraphs
are folded into the tooltip to declutter the forms, and the hardware/display
settings carry authored detail (default, range, recommendation).

Search: a global header search box finds settings across every settings tab
— even ones not yet opened — via a lazy client-side index built by scanning
the same field markup (static/v3/js/settings-search.js). Selecting a result
switches tabs, waits for the field to load, then scrolls to and flashes it.
A per-tab filter box hides non-matching fields on the current tab.

Plugin settings get tooltips for free by reusing each field's schema
`description`; every settings field also gets a stable `setting-<tab>-<key>`
anchor id for search navigation.

Styling uses the existing --color-* theme vars so light/dark mode both work,
and honors prefers-reduced-motion. Adds Flask render smoke tests that assert
each settings partial ships tooltips, anchors, and a filter box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Address Codacy static-analysis findings in settings JS

Refactor the two new modules to clear the flagged patterns without any
behavior change:

- Build the search dropdown with DOM nodes + textContent instead of
  innerHTML string concatenation, removing the XSS sinks and the manual
  escapeHtml helper it needed.
- Replace numeric index access (index[i], terms[j], opts[idx],
  currentResults[i]) with array iteration methods, NodeList.item(), and
  Array.prototype.at() to clear detect-object-injection.
- Use === via a shared termsMatch() helper, optional-catch binding, and
  drop a useless initial assignment.

Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero
findings and re-ran the headless-Chromium behavior test (tooltip, per-tab
filter, global search navigation) — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Reduce complexity of revealAncestors in settings search

Extract isNodeHidden() and revealNode() helpers so revealAncestors drops
below the cyclomatic-complexity threshold. No behavior change; verified with
the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Resolve remaining Codacy findings in settings search

- Validate plugin ids against a strict allowlist (mirroring the server's
  _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request
  URL is never built from unvalidated input (Codacy: user-controlled URL).
- Document that the fetched HTML is parsed into an inert document (scripts
  never run, never inserted into the live DOM) purely to read field text for
  the search index.
- Declare block-scoped locals with const instead of var where they were
  nested inside conditionals (Codacy: var not at function root).

No behavior change; re-verified with ESLint and the headless-Chromium test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Serve the settings search index from the server as JSON

Move index building off the client so there is no client-side HTML fetching
or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the
read-only, same-origin index build).

- Add GET /v3/settings/search-index (pages_v3.py): renders the settings
  partials server-side and extracts each field's anchor id, key, label,
  tooltip, and section with a small stdlib HTMLParser, then caches the result
  keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor
  ids identical to the live DOM, so the index cannot drift.
- settings-search.js: buildIndex() now does a single fetch of the literal
  endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc,
  CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation,
  and the per-tab filter are unchanged.

Net: fewer requests and no client-side HTML parsing. Verified with a new
endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium
test (tooltip, filter, search navigate + flash all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Address CodeRabbit review on settings search/tooltips

- pages_v3: include Durations tab in the search index so
  setting-durations-* fields are actually indexed
- test_web_settings_ui: assert setting-durations-clock is present in
  the search-index endpoint response
- settings-search.js: on index fetch failure, reset buildPromise
  instead of caching an empty (truthy) index so search can retry
- settings-search.js: filterScope returns null (not document) when no
  tab container matches, and the caller guards, so the per-tab filter
  can't hide fields across unrelated tabs
- settings-search.js: refresh the stale header comment to describe the
  server-side JSON index flow
- app.css: cap #settings-search-results height with overflow-y so the
  dropdown scrolls instead of overflowing small screens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Fix stuck search dropdown; add plugin-tab nested-settings filter

Global search:
- Close the results dropdown on input blur (guarded, with a short delay)
  so it reliably dismisses when focus leaves — previously it could linger
  because the only outside-close was a document click that Alpine/HTMX
  handlers can swallow.
- Clear the query text after navigating to a result so refocusing the box
  doesn't re-open stale results.
- Also dismiss on htmx:afterSwap (tab changes / navigation).

Per-tab filter (now on plugin tabs too):
- Render the shared settings_filter box in the plugin Configuration panel.
  It auto-wires: the delegated input handler and filterScope already target
  .plugin-config-tab.
- Teach applyTabFilter to reveal matches inside collapsed nested sections
  (render_nested_section defaults them shut), hide nested-section wrappers
  with no matches, and restore the original collapsed layout when cleared
  (only re-collapsing sections the filter itself opened).
- Count a visible nested-section as content for its parent heading so the
  heading isn't hidden while a subsection below still has matches.

Adds a plugin-config render test (filter box + nested anchors + tooltips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Close search dropdown via capture-phase outside-click

The dropdown could stay open after clicking away because the only
outside-click close was a bubble-phase document listener. The v3 UI is
one Alpine app() component full of HTMX/Alpine/widget click handlers;
when a click lands inside an element that calls stopPropagation(), the
event never bubbles to document and the close never runs.

- Replace the bubble-phase document 'click' close with a capture-phase
  'pointerdown' listener scoped to #settings-search-wrap. Capture runs
  before any bubbling stopPropagation can swallow the event, so it always
  fires; pointerdown also covers touch on the Pi screen. Clicking a result
  stays inside the wrap, so selection is unaffected.
- Guard the debounced input handler so a delayed render can't re-open the
  box after focus has left (type-then-click-away race).

Keeps the existing blur / Escape / htmx:afterSwap closes as secondary paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz

* Fix settings search dropdown not visually closing

.hidden has no effect in this app: app.css is a hand-picked utility
subset (no Tailwind build step) and never defines .hidden { display:
none }. openResults()/closeResults() only toggled the class, so the
dropdown stayed rendered (display: block) even once closeResults()
ran - confirmed via computed style in a headless browser. Set
style.display directly, matching the fallback already used by
revealNode()/collapseNode() elsewhere in this file.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 09:22:24 -04:00
bd9f461f70 Add system diagnostics, power controls, and WiFi radio toggle to Tools tab (#389)
* Add system diagnostics, power controls, and WiFi radio toggle to Tools tab

Expands the web UI Tools tab with safe, purpose-built controls so users can
manage the Pi without SSHing in, instead of an arbitrary-command terminal
(the web UI has no auth and CSRF is disabled, so a shell would be unsafe).

- System Diagnostics card: renders the existing but previously-unused
  GET /api/v3/system/status endpoint (CPU, memory, temp, disk, uptime),
  with a manual refresh and a 10s poll.
- System Power section: reboot/shutdown buttons wired to the existing
  reboot_system / shutdown_system actions, behind a confirm step, with a
  dedicated powerAction() helper that treats the dropped connection as the
  expected "going offline" outcome rather than an error.
- Network Radio section: WiFi on/off toggle backed by new
  GET/POST /api/v3/wifi/radio endpoints and WiFiManager.set_wifi_radio() /
  get_wifi_radio_state(). Disabling WiFi is refused unless a wired
  connection is present (reusing the existing lockout guards), with an
  explicit force-off confirmation for advanced users.

No new privileged commands: uses nmcli radio wifi on|off (already
sudo-allowlisted) and the existing reboot/poweroff grants, so the
sudoers-alignment guard test stays green. Bluetooth toggle intentionally
omitted since the installer removes the BlueZ stack for LED timing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE

* Harden WiFi radio endpoint and diagnostics poll (review feedback)

Addresses code review feedback on the Tools tab additions:

- api_v3.py: parse the `enabled` POST field with the same string-aware
  coercion as `force`. A plain bool() cast turned {"enabled":"false"} into
  True (enabling instead of disabling) for any non-UI API caller.
- wifi_manager.set_wifi_radio() now returns a reason code alongside
  (success, message); the /wifi/radio error response includes it. The Tools
  UI only shows the force-off confirmation when reason == 'no_ethernet', so a
  genuine nmcli failure surfaces its real error instead of a misleading
  "no wired connection" prompt that would just retry into the same failure.
- tools.html: gate the 10s diagnostics poll on panel visibility
  (document.hidden / offsetParent), so switching to another tab stops the
  recurring /api/v3/system/status calls instead of churning the Pi off-screen.
  The initial load and manual Refresh remain unconditional.

Verified: {"enabled":"false"} now disables (refused w/ reason:no_ethernet),
{"enabled":"true"} enables; inline JS passes node --check; py_compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE

* Narrow WiFi-disable fallback except and add stack trace (review)

Addresses a CodeRabbit nitpick: the fallback handler in set_wifi_radio()'s
disable path caught bare Exception and logged without a traceback. Narrow it to
(OSError, subprocess.SubprocessError) — the errors subprocess.run realistically
raises — and log with exc_info=True for full context on the Pi. Anything
genuinely unexpected now propagates to the endpoint's outer handler (500),
matching the codebase's specific-exception convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019X3he87Ggr1qt8y7mnFeuE

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 09:22:11 -04:00
3b93024993 feat: activate dormant plugin health/metrics subsystem and surface it in the web UI (#388)
* feat(plugin-system): activate dormant plugin health & metrics subsystem

PluginManager shipped a fully-built health tracker, resource monitor and
circuit breaker that were never instantiated (health_tracker/resource_monitor
were left as None), so the circuit breaker never engaged and the existing
health/metrics API routes always returned "not available".

- DisplayController now wires a PluginHealthTracker and PluginResourceMonitor
  onto the plugin manager, enabling the circuit breaker (a repeatedly-failing
  plugin's update() is skipped after consecutive failures, then retried after
  a cooldown) and per-plugin execution-time metrics. Both persist to the
  shared cache.
- load_plugin() now validates each plugin's config against its JSON schema in
  a strictly warn/degrade-only way: a violation logs a warning and flags the
  plugin degraded in the health tracker, but never changes whether the plugin
  loads or its pass/fail behaviour. Adds PluginHealthTracker.set_degraded(),
  which never touches the circuit breaker.
- ResourceMonitor CPU/memory sampling now reuses a cached psutil.Process and
  reads cpu_percent(interval=None), so monitoring no longer blocks ~100ms per
  call on the display loop's update path.
- Fix DiskCache.get() raising TypeError for max_age=None ("never expires"),
  which silently discarded persisted plugin health/metrics on read and thus
  broke cross-process and post-restart surfacing.
- Fix two dead PluginManager helpers that called non-existent tracker methods.

Tests: new test_resource_monitor, test_plugin_health,
test_plugin_manager_schema_soft; extended test_cache_manager and
test_display_controller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

* feat(web-ui): surface plugin health, metrics and load state

With the health/metrics subsystem now active in the display service, expose it
in the web UI (which runs as a separate process from the display loop):

- Wire a health tracker / resource monitor backed by the shared on-disk cache
  into the web process so /api/v3/plugins/health and /plugins/metrics read the
  data the display service persists.
- Build those route responses per installed plugin id (the tracker's in-memory
  view is empty in a fresh web process) so cross-process data is included.
- Add state + error_info to /plugins/installed entries so the UI can show why a
  plugin isn't running instead of just loaded:false.
- Add a "Plugin Health" panel to the Tools page (circuit status, avg/max update
  time, update count, last error) plus PluginAPI.getPluginMetrics().

Tests: route-level tests for the health/metrics endpoints in test_web_api.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

* fix(plugin-metrics): refresh cross-process health/metrics reads; type hints

Addresses CodeRabbit review on #388:

- Major: the web process's health/resource trackers cached the first persisted
  read in an in-memory dict (and the CacheManager memory tier held max_age=None
  entries indefinitely), so a long-lived web process showed the first snapshot
  and never reflected the display service's later updates. Add an opt-in
  force_reload path (get_health_summary/get_health_state/_load_health_state and
  get_metrics_summary/get_metrics) that bypasses the in-memory copy and, via a
  new memory_ttl passthrough on CacheManager.get, the cache manager's memory
  tier — so each /plugins/health and /plugins/metrics poll reads fresh persisted
  state. Default behaviour (force_reload=False) is unchanged for the display
  process and existing callers.
- Minor: DiskCache.get type hint is now Optional[int] with the None ("never
  expires") semantics documented, matching MemoryCache.get.

Tests: new force_reload staleness cases in test_plugin_health and
test_resource_monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTav268UXv44ub9K11LYq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-09 07:54:18 -04:00
85d321cf33 fix: plugin_loader retries with --ignore-installed on apt/pip RECORD conflicts (#386)
* fix: plugin_loader retries with --ignore-installed before assuming apt package satisfies pin

install_dependencies treated any "uninstall-no-record-file" pip failure as
"dependency satisfied" and wrote the success marker without ever attempting
--ignore-installed, unlike install_dependencies_apt.py and
safe_pip_install.sh (added in #385 for the Plugin Store/first-time-install
paths). A plugin pinning a newer version of a system-managed package (e.g.
requests) would silently keep running against whatever version apt shipped,
while the marker file claimed the pinned requirement was met.

Now retries the same install with --ignore-installed on that specific
failure so pip actually lays the pinned version down (shadowing the
system-managed copy) before falling back to the prior tolerant behavior if
the retry itself fails too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* chore: suppress Codacy finding on new retry subprocess.run call

Same generic Bandit/semgrep pattern-match on non-literal subprocess.run
argv flagged in #385's install_requirements_file, now on the new
--ignore-installed retry call added here: list-form argv (no shell=True),
sys.executable is this process's own interpreter, and requirements_file is
built internally by find_plugin_directory, never raw external input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* fix: tolerate a timed-out --ignore-installed retry, dedupe marker-write logic

CodeRabbit review caught a real inconsistency: if the --ignore-installed
retry itself timed out, subprocess.TimeoutExpired propagated to the outer
handler and returned False, failing plugin load — contradicting the
intended "tolerate this specific apt/pip conflict" behavior, where a mere
non-zero retry return code already returns True. Wraps the retry in its own
try/except so a timeout is logged and tolerated the same way as any other
retry failure.

Also extracts the marker-writing logic (open/write/chmod, ignoring OSError)
into _write_dependency_marker, since it was duplicated identically between
the direct-success path and the apt-conflict-retry-fallback path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* fix: revert marker-write dedup helper, resolves CodeQL path-injection alert

CodeQL flagged _write_dependency_marker's open(marker_file, ...) as
"uncontrolled data used in path expression" (high severity) once the
marker-write logic was extracted into its own method. marker_file is
actually safe — it's built from safe_plugin_dir, which install_dependencies
sanitizes via os.path.basename() (CodeQL's own recognized py/path-injection
sanitizer, per the existing comment a few lines above) — but CodeQL's
interprocedural analysis doesn't carry that sanitized status across the new
method boundary, since the sanitizer call and the open() sink were no
longer in the same function.

This exact code produced zero CodeQL findings before the extraction (in two
duplicated inline blocks) and is unchanged in what data reaches it — only
its location moved. Reverting the extraction (keeping CodeRabbit's other,
independent timeout-handling fix) restores the previously-clean shape rather
than trying to convince the analyzer's cross-function taint tracking that a
refactor changed nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-08 11:19:37 -04:00
63a233f3ed fix: dependency installation gaps in Plugin Store and first-time install (#385)
* fix: install plugin dependencies through root-visible installer in Plugin Store

install_plugin/update_plugin (store_manager.py) installed requirements.txt
with a bare `pip3` off PATH, bypassing the root-visible installer added in
#380 for the "Reinstall Plugin Deps" button. Two bugs stacked: (1) `pip3`
can resolve to a different Python install than the one that actually runs
ledmatrix.service, and (2) even when it resolves correctly, ledmatrix-web
runs as a non-root user so the package lands in that user's local
site-packages, invisible to root-run ledmatrix.service. Either way the
install reports success and writes the .dependencies_installed hash marker,
so plugin_loader's own (correct) install-on-load path skips reinstalling —
leaving the dependency permanently missing until a user finds and clicks
the separate "Reinstall Plugin Deps" tool. This is why users kept hitting
"No module named 'astral'" for the weather plugin even after installing it
from the Store.

Extracts the sudo-wrapper-then-fallback install logic from api_v3.py's
_pip_install_requirements into src/common/permission_utils.py as
install_requirements_file, and routes store_manager.py's dependency
installation through it so the automatic Store install/update path now
matches the manual "Reinstall Plugin Deps" path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* chore: suppress Codacy false-positive on subprocess.run in install_requirements_file

Codacy's generic subprocess-security rule (Bandit B603 equivalent) flagged
the pip/sudo subprocess.run calls in install_requirements_file for lacking a
"static string argument" — the standard pattern-based flag for any
subprocess.run() call with a variable in its argv list. Both calls use
list-form argv (no shell=True, so no shell-injection surface), and the only
dynamic value is req_file, a Path built internally by callers rather than
raw external input; safe_pip_install.sh independently re-validates it before
installing anything as root. Suppresses with inline `# nosec B603` comments
matching this codebase's existing convention (see permission_utils.py's own
PROTECTED_SYSTEM_DIRECTORIES, display_manager.py, sync_manager.py, etc.).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* fix: first-time install script fails on apt-managed requests package

web_interface/requirements.txt and requirements.txt both pin
requests>=2.33.0,<3.0.0, but Raspberry Pi OS ships an apt-managed
python3-requests with no pip RECORD file. Upgrading it via plain
`pip install` aborts with "uninstall-no-record-file" because pip refuses to
uninstall a package it has no record of, in place — which is exactly the
"Some web interface dependencies failed to install" warning first-time
install hits.

scripts/install_dependencies_apt.py and scripts/fix_perms/safe_pip_install.sh
already work around this with --ignore-installed (lets pip lay the new
version down in /usr/local, shadowing the apt copy, instead of trying to
remove it first). first_time_install.sh's own direct pip invocations —
the per-package requirements.txt loop, the web_interface/requirements.txt
install, and the requirements_web_v2.txt fallback — didn't have it. Adds
--ignore-installed to all three so first-time install no longer fails on
this well-known apt/pip conflict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* chore: add nosemgrep to subprocess.run calls Codacy still flagged

The prior # nosec B603 comments suppressed Bandit's check but Codacy's
semgrep-based rule ("subprocess function 'run' without a static string")
kept flagging the same two lines as a critical security issue even after
that fix landed. install_dependencies_apt.py's _run() already needed both
tags together (# nosec B603 B607 ... # nosemgrep) for the identical
subprocess.run pattern, so apply the same double suppression here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

* fix: add --ignore-installed to install_requirements_file fallback path

CodeRabbit review caught this (confirming a gap already flagged in
conversation): the non-sudo fallback pip install in install_requirements_file
was missing --ignore-installed, unlike the sudo-wrapper branch and
safe_pip_install.sh. Without it, the same apt/pip RECORD-file conflict this
PR fixes elsewhere (first_time_install.sh, install_dependencies_apt.py) could
still hit installs that fall back to this path (e.g. a plugin's
requirements.txt on a host where safe_pip_install.sh isn't set up yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1NnDduw53kTe67i5zWwYx

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-08 09:59:12 -04:00
7a9d01342a Fix inconsistent Vegas scroll transition gaps caused by plugin-baked padding (#384)
* Strip plugin-baked scroll padding when capturing content for Vegas mode

Plugins that build their own ticker image via ScrollHelper.create_scrolling_image()
(or that manually pad both ends for a clean standalone loop) carry a solid-black
margin up to display_width wide on one or both edges. Vegas mode already adds its
own configurable gap around every item, so leaving that margin in place stacked an
extra, uncontrolled blank stretch on top of separator_width for whichever plugin
took the ScrollHelper-capture path — producing inconsistent transition gaps between
modules compared to plugins that provide content natively via get_vegas_content().

_get_scroll_helper_content() now detects and crops any such margin before handing
the image to the Vegas render pipeline, so every plugin's gap is governed solely by
vegas_scroll.separator_width regardless of which capture path produced its content.

* Address CodeRabbit nitpick: warn on double-edge padding crop, add unit tests

Logging a double-edge match at warning level (vs. info for a single edge)
makes it easy to spot an unexpected crop in the field, since two edges
matching at once is a much stronger signal of genuine baked-in padding than
one edge coinciding with real all-black content.

Also adds test/test_vegas_plugin_adapter.py covering _strip_scroll_padding's
branch logic: leading-only, trailing-only, both-edges, no-match, degenerate
all-black, missing/undersized display_width, and the info-vs-warning log level.

* Add type hints and docstring to test _solid helper (CodeRabbit nitpick)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-08 09:43:13 -04:00
9b2f02681d feat(web-ui): detect and surface Raspberry Pi under-voltage/throttling (#383)
* feat(web-ui): detect and surface Raspberry Pi under-voltage/throttling

Adds a vcgencmd get_throttled check to the system-status SSE stream and
surfaces it in the web UI:

- A header badge (next to CPU/Memory/Temp) that stays hidden when healthy,
  turns red when under-voltage/throttling is happening right now, and
  yellow if it happened earlier this session but has since cleared.
- A dismissible top banner (same pattern as the update-available banner)
  that appears while under-voltage/throttling is actively occurring, with
  guidance to check the power supply. Re-appears on a fresh occurrence
  even if a previous one was dismissed.
- A "Power Supply" card on the Overview tab alongside CPU/Memory/Temp/
  Display Status.

Motivated by a real device showing intermittent brightness flicker that
turned out to be ~1 under-voltage event every 30-90s (visible live via
`vcgencmd get_throttled` and dmesg's "Undervoltage detected!" messages) --
there was no way to see this from the web UI, only by SSHing in.

Returns None on non-Pi platforms (no vcgencmd on PATH), matching the
existing guard pattern used for the CPU temperature read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* feat(web-ui): add power supply diagnostics detail to Tools tab

The header badge/banner/Overview card added in the previous commit only
show a collapsed "is it bad right now" signal. This adds a "Power Supply"
section to the Tools tab with the full 8-flag breakdown (under-voltage,
throttled, freq-capped, soft-temp-limit -- each split into "right now" vs
"occurred since boot") for actually troubleshooting a recurring issue,
plus a pointer to the README's power supply sizing guidance when something
is or was flagged.

Reuses the existing stats SSE stream (window.statsSource) rather than
adding a new endpoint -- the same payload already drives the header/
banner/Overview card, so this just listens for it too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix(web-ui): address review findings on power supply monitoring

- Overview card left its "--" placeholder forever on non-Pi platforms since
  the update only handled a truthy data.power. Now explicitly renders "Not
  available" with a neutral icon/color for that case.
- vcgencmd failures logged at debug, invisible in default remote log
  output. Bumped to warning to match the nearby systemctl failure logging.
- The banner/badge/card and the Tools summary line only looked at
  under_voltage_now/throttled_now (+ occurred), silently ignoring
  freq_capped_now/occurred and soft_temp_limit_now/occurred from
  _get_power_status() -- a Pi that's actively soft-thermal-limited or
  frequency-capped showed a green "OK" everywhere except the detailed
  flag table buried in Tools. All four surfaces now fold all four "now"/
  "occurred" flags into the same active/occurred state.
- The banner text was hardcoded to "Under-voltage detected..." even when
  the actual active condition was throttling/freq-capping/thermal limiting.
  Added _activePowerConditionLabels() (shared, non-module global scope) to
  build the banner/tooltip text from whichever flags are actually set.

Skipped: TTL-caching _get_power_status() to avoid "multiplying forks
across browser tabs" -- that premise doesn't hold against this codebase.
_StreamBroadcaster (its own docstring says as much) already runs exactly
one shared generator per tick regardless of client count, identical to
the uncached cpu_temp file-read two lines above it; there's nothing to
multiply.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix(web-ui): address second round of review findings on power monitoring

- updatePowerStatus's falsy-power branch only hid power-stat, leaving
  power-warning-banner visible with stale text if _get_power_status()
  fails transiently. Now hides the banner too and resets the dismissed
  flag, same as the "not active" branch.
- The Tools tab's status badge (and the pre-existing dirty/clean badge
  right next to it) build class names like bg-${color}-100/text-${color}-800
  at runtime. This project hand-rolls its own Tailwind-named utility
  classes in app.css rather than running a real Tailwind build, and the
  light-mode base rules for bg-red-100/bg-yellow-100/bg-green-100/
  text-red-800/text-yellow-800/text-green-800 were simply never defined --
  only some had dark-mode overrides, which are no-ops without a base rule
  in light mode. Added the missing light-mode bases plus the two missing
  dark-mode overrides (bg-yellow-100/bg-green-100), fixing both badges.
- The "occurred earlier" tooltip was a hardcoded string regardless of
  which flag(s) actually fired. Generalized _activePowerConditionLabels()
  to take a suffix ('_now' or '_occurred') and reused it for both tooltips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* refactor(web-ui): move Power Supply status off the Overview tab

The Overview tab's "Power Supply" stat card duplicated what the Tools
tab's diagnostics section already shows (summary badge + full flag
breakdown), so drop the card and its now-dead JS rather than keep two
copies in sync. The header badge and warning banner (visible on every
page) are unaffected -- only the Overview-tab card is removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 09:35:55 -04:00
7a6bad29fe feat(display-controller): hot-reload plugin enable/disable without a restart (#374)
* feat(display-controller): hot-reload plugin enable/disable without a restart

Enabling or disabling a plugin in config previously required restarting the
display service: the plugin list and available_modes were built once at init
and the run loop never revisited them. (Per-plugin config *values* already
hot-reloaded; only the enabled set was restart-only.)

Now the controller reconciles its running plugins against the config's enabled
set whenever that set changes:

- The ConfigService watcher thread only sets a `_pending_plugin_reconcile`
  flag (via a cheap enabled-set diff). It never mutates loop state.
- The run loop applies the reconcile on its own thread (top of each
  iteration, deferred while on-demand is active), so loading/unloading and
  rebuilding available_modes can't race with rendering.
- `_reconcile_enabled_plugins` diffs desired vs running plugins, unloads the
  removed ones (cleanup + on_disable + config-unsubscribe via the new
  `_unregister_plugin`) and loads the added ones, then clamps the rotation
  index so the current mode stays valid.

The per-plugin registration done at startup is extracted into
`_register_loaded_plugin` and reused by the live-enable path so both build
identical state. Extracting it also fixes a latent late-binding bug: the
per-plugin config-change callbacks were closures over the loop variable, so
every plugin's callback targeted the last-loaded instance; each now binds its
own id/instance.

Adds test/test_display_controller_plugin_toggle.py covering live enable,
live disable, index clamping, no-op when unchanged, and the enabled-set diff.

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

* fix(display-controller): don't exit on empty available_modes, guard rotation modulo

Hot-reload means available_modes can legitimately be empty at startup (no
plugins enabled yet) and become non-empty later via the web UI, or vice
versa mid-run. Fix four issues found reviewing this PR:

- run() exited the process entirely when available_modes was empty at
  startup instead of idling, permanently defeating the point of live
  enable/disable for anyone who starts with zero plugins enabled.
- The mode-rotation step divided by len(available_modes) unconditionally,
  raising ZeroDivisionError if the last enabled plugin is disabled between
  frames.
- _reconcile_enabled_plugins() called .get('enabled', False) on a config
  section without checking it was a dict first, raising AttributeError on
  a malformed config value.
- Minor: pop the config-change callback only after attempting to
  unsubscribe it, and log the exception in the config-read fallback
  instead of swallowing it silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix(display-controller): address review findings on the hot-reload PR

- Idle-wait tick was a fixed 30s sleep, delaying pickup of a plugin
  enabled via the web UI while no modes were active. Shortened to ~1s so
  it's roughly as responsive as the per-frame check once modes exist.
- _unregister_plugin popped the config-change callback from
  _plugin_config_callbacks even when config_service.unsubscribe() raised,
  losing the only reference to it. Now only pops on a successful
  unsubscribe.
- _pending_plugin_reconcile was cleared before _reconcile_enabled_plugins()
  ran, so a retryable failure (e.g. plugin discovery erroring) silently
  dropped the enable/disable request. _reconcile_enabled_plugins() now
  returns True/False and the caller only clears the flag on True.
- Added a warning log for the malformed-config case (a plugin's config
  section present but not a dict) so it's actually visible, and updated
  the existing test to assert it via caplog.

Left the broad `except Exception` around config_service.unsubscribe() as
Exception -- the current implementation is a simple lock+dict/list op that
doesn't document or realistically raise a narrower type, so this is a
defensive catch-all, not user error handling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: ChuckBuilds <charlesmynard@gmail.com>
2026-07-06 17:37:01 -04:00
ChuckandGitHub bea00448d3 update mlb logos (#382)
update mlb logos

Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-07-06 08:55:43 -04:00
deaa3d7a98 fix: array-table boolean columns always saved as unchecked (#381)
Reported bug: countdown plugin always shows "No Active" even with a
countdown configured and enabled — because every save silently unchecked
it. Root cause: the boolean column's hidden-sentinel input (needed so
unchecked checkboxes still submit "false", since browsers omit unchecked
checkboxes entirely) was hardcoded to value="false" and shared the same
`name` as the checkbox, with no sync between them. Whichever of the two
same-named inputs the save request's form-collection happens to prefer,
the hidden's stale "false" could silently override an actually-checked
box on every save, independent of what the user set.

Fixed in both places this pattern is rendered:
- plugin_config.html: the initial server-rendered row for every array-table
  boolean column (affects countdown's per-countdown "enabled" and the
  custom-feeds widget's per-feed "enabled") — now sets the hidden's initial
  value from the actual data, and syncs it via onchange on every toggle.
- array-table.js: the client-side row renderer used when adding/rebuilding
  rows in the browser — same fix, keeping both inputs in sync at render
  time and via a change listener.

Verified other checkbox/hidden-input patterns in plugin_config.html and
confirmed they're unrelated/already-safe: the default single-checkbox
renderer (no hidden pair), checkbox-group (array-bracket names with its
own explicit sync), the array-table advanced-props modal editor (single
hidden per name, explicitly written on Save), the top-level plugin
enable/disable toggle (separate immediate hx-post), and the no-schema
fallback checkbox. custom-feeds.js's own client-side row renderer never
creates a hidden sentinel at all, so it isn't affected either.

Verified the fix's rendered output directly with an isolated Jinja render
of the exact snippet: hidden and checkbox now agree ("true"/checked or
"false"/unchecked) for both states, where before the hidden was always
"false" regardless of the actual value.


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

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 08:05:06 -04:00
cbb8ec41e8 Fix: plugin/base requirements installed as web-user, invisible to root-run display service (#380)
* fix: install plugin/base requirements as root so ledmatrix.service can see them

ledmatrix-web.service runs as a non-root user, so "Reinstall plugin
requirements" installed packages into that user's ~/.local site-packages.
ledmatrix.service (the actual display, which loads and runs plugin code)
runs as root and can't see another user's user-site packages, so plugins
with dependencies not already present system-wide would silently fail at
runtime with ModuleNotFoundError even after a "successful" reinstall.
Reproduced and fixed live against a real device (weather plugin's astral
dependency, used for moon-phase data): confirmed the exact failure
("No module named 'astral'" on every almanac cycle) and confirmed it's
gone after this fix.

Adds scripts/fix_perms/safe_pip_install.sh, a root-owned wrapper (mirroring
the existing safe_plugin_rm.sh pattern) that validates the target is
requirements.txt at the project root or under plugin-repos/ or plugins/
before running pip install as root. configure_web_sudo.sh provisions a
narrowly-scoped sudoers rule for it. api_v3.py's install_base_requirements
and install_plugin_requirements actions now use it via `sudo -n`, falling
back to today's current-user-only install (with an explanatory note) if
the wrapper isn't set up yet, so existing installs don't regress.

Also uses --ignore-installed in the wrapper: root's site-packages often has
apt/dpkg-managed copies of common libraries (requests, etc.) with no pip
RECORD file, which pip refuses to upgrade in place and aborts the *entire*
requirements.txt install over — discovered this while testing the fix live,
since a plugin's other already-satisfied-for-the-web-user dependencies had
never actually been attempted as root before.

Also fixes a pre-existing bug in configure_web_sudo.sh where the
display_controller.py/start_display.sh/stop_display.sh sudoers entries used
PROJECT_DIR (scripts/install/, where this script lives) instead of
PROJECT_ROOT (where those files actually live) — visible as the script's
own "File access test" self-check failing. Verified fixed live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix: invoke safe_pip_install.sh via explicit bash, matching sudoers rule

CodeRabbit caught this on review: the sudoers rule configure_web_sudo.sh
provisions is scoped to "$BASH_PATH $SAFE_PIP_INSTALL_PATH *" (matching
the existing safe_plugin_rm.sh precedent in
src/common/permission_utils.py), but _pip_install_requirements() called
`sudo -n <wrapper> <req_file>` directly, relying on the script's shebang
instead of an explicit bash prefix. sudo matches the literal command line,
so this never matched the allowlisted rule on an install with only the
specific sudoers entries this script provisions — it silently fell back
to the non-root install path every time, which is the exact bug this PR
set out to fix.

This wasn't caught by live testing on ledpi.local because that device
also has a broader, non-standard "NOPASSWD: ALL" grant which masked the
mismatch. Confirmed the fix is correct by reading sudo's documented
command-matching semantics and mirroring the already-proven-working
bash-prefix pattern from permission_utils.py's safe_plugin_rm.sh call
exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix: harden safe_pip_install.sh invocation against bash-path drift + address CodeRabbit nitpicks

CodeRabbit follow-up findings on the bash-prefix fix (7558aaab):

1. (Actionable) shutil.which('bash') at runtime could in principle resolve
   to a different absolute path than configure_web_sudo.sh's `command -v
   bash`, which is resolved once at setup time and baked into the static
   sudoers file as a literal string — sudo requires an exact match. Now
   tries /usr/bin/bash and /bin/bash (the standard Debian/Raspberry Pi OS
   locations, matching what the setup script virtually always produces)
   before falling back to this process's own PATH resolution, so a
   divergence in just one of them doesn't break the install.

2. (Nitpick) Any nonzero returncode was treated as "sudo denied", so a
   real pip failure (bad package, build error) would trigger a pointless
   duplicate non-root install attempt and a misleading error message.
   Now distinguishes "sudo -n rejected this exact command line" from
   "sudo ran it but the command itself failed" via sudo's own diagnostic
   text, and surfaces genuine failures immediately without retrying other
   bash candidates or falling back.

3. (Nitpick) Added structured logging for every fallback/failure path
   (wrapper missing, sudo denied, real install failure), previously only
   visible via the returned stdout note — needed for remote debugging on
   a headless Pi.

Verified: function-level smoke test confirms a real failure (this sandbox's
system python3 lacking pip) is now correctly classified as non-denial and
returned immediately without retrying candidates or double-installing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 08:04:49 -04:00
c6ce332d49 fix(web): restore missing brace in Tools tab HTMX-fallback path (#378)
The `else if (++tries > 100)` block added by #373 was missing its
closing `}`, leaving the setInterval arrow function syntactically
unclosed. This caused a JS parse error that silenced the entire
1400-line script block — including the EventSource setup — so the
connection-status indicator never left its default "Disconnected"
state for all users after updating.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:05:33 -04:00
ChuckandGitHub 8e5f66501a Add Claude Code GitHub Workflow (#377)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-06-29 14:30:41 -04:00
639e1c3a93 fix(web): repair news ticker custom-feeds save for JSON path (#376)
* feat(install): surface root cause of web dependency install failures

install_dependencies_apt.py previously reported only which packages
failed, not why - the actual apt/pip error was discarded (apt) or
could scroll out of the on_error log tail (pip), leaving "Step 7:
Install web interface dependencies (line 915)" as the only visible
detail.

Capture command output for each install attempt and print a compact
DEPENDENCY INSTALLATION FAILURES summary with the last lines of error
output per package. Also run the installer with `python3 -u` for
real-time, correctly-ordered logging, and widen the on_error tail from
50 to 100 lines so the summary isn't cut off.

* fix(web): repair news ticker custom-feeds save for JSON path

The JS dotToNested() helper converts indexed form fields like
feeds.custom_feeds.0.name into a dict {'0': {name:...}} rather than a
proper array. The form-data path already had fix_array_structures() to
convert those dicts back to arrays before schema validation, but the
JSON path (used by all web-UI saves) never ran that fix, so saving any
custom feed produced a schema validation error: "Expected type array,
got object".

Add _fix_json_arrays() immediately after schema loading on the JSON
path, mirroring the existing fix_array_structures() logic.

Also fix custom-feeds.js getValue() to omit the logo key entirely when
no logo is present instead of returning logo:null, which would fail
schema validation (logo expects type object).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(install,tools): address PR 376 review findings

- first_time_install.sh: add _clone_rpi_rgb() wrapper so retry() cleans
  up any partial rpi-rgb-led-matrix-master dir before each clone attempt
- first_time_install.sh: use apt-get -o DPkg::Lock::Timeout=180 so apt
  handles lock contention natively instead of relying solely on flock TOCTOU check
- install_dependencies_apt.py: pass DPkg::Lock::Timeout=180 to apt-get
  install to avoid failing when unattended-upgrades holds the lock
- install_dependencies_apt.py: add type annotations to all public helpers
- api_v3.py: fix install_plugin_requirements to read plugin_manager from
  api_v3 blueprint attribute instead of the always-None module variable
- tools.html: loadGitInfo() now checks r.ok before parsing JSON and
  surfaces d.status === 'error' with the server's message in the panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tools,api): address three additional review findings

- api_v3.py install_plugin_requirements: replace hardcoded plugin-repos
  fallback with config-driven resolution (plugin_system.plugins_directory),
  matching the pattern used elsewhere in the module
- api_v3.py _fix_json_arrays: recurse into converted and existing array
  elements when items.type is object, so nested numeric-keyed dicts inside
  array items are also normalized
- tools.html toolsAction: check r.ok before r.json() and recover
  gracefully from non-JSON error bodies (HTML 500 pages), consistent
  with the existing loadGitInfo guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:21:05 -04:00
6096a22c3d feat(web): add Tools tab and row address type display setting (#373)
* feat(web): add Tools tab and row address type setting

Adds a Tools/Utilities tab to the web interface with one-click
maintenance buttons that previously required SSH:
- Git status panel (branch, dirty state, recent commits)
- Pull latest (rebase) and force reset to origin/main
- Reinstall base requirements (pip, with output)
- Reinstall per-plugin requirements (pass/fail per plugin)
- Clear __pycache__ directories
- Quick-access restart for display and web services

Also exposes the hzeller row_address_type option (0–4) in the
Display settings tab. The backend already read this value from
config; the UI, API field list, and validation were missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tools-tab): address code review findings

- Add _GIT = shutil.which('git') alongside _SUDO/_JOURNALCTL; return
  503 in force_git_reset and get_git_info if git is unavailable
- Check git branch/status returncodes in get_git_info(); return a clear
  500 error instead of silently treating a failed run as a clean repo
- Cap pip stdout+stderr at 50 KB via _truncate_output() helper to
  avoid OOM on verbose dependency resolution or build failures
- Scrub embedded HTTPS credentials from remote_url via
  _scrub_git_remote_url() using urllib.parse before returning to UI
- Fix clear_pycache to track and report failed deletions separately
  instead of counting them as successes (removed ignore_errors=True,
  wrapped in try/except OSError)

Skipped: plugin_manager-vs-api_v3.plugin_manager (api_v3 is the
Blueprint object; accessing .plugin_manager on it would fail — module-
level variable is the correct pattern used throughout this blueprint);
pages_v3 broad-except (identical to every other _load_*_partial in the
file); base.html HTMX fallback (loadTabContent handles all tabs
generically; named fallbacks only exist for tabs needing JS re-init);
tools.html auth (pre-existing architectural decision — reboot/shutdown
on the same endpoint are also unauthenticated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tools-tab): resolve remaining PR review comments

- api_v3: use getattr(api_v3, 'plugin_manager', None) instead of the
  module-level plugin_manager (always None); app.py sets the blueprint
  attribute, not the module global, so the fallback to plugin-repos was
  always taken
- pages_v3: replace broad except Exception in _load_tools_partial with
  specific TemplateNotFound / OSError handlers and add [Pages V3][Tools]
  context prefix to log messages and error responses for easier Pi
  debugging
- base.html: add Tools tab branch to the HTMX-unavailable fallback block
  in loadTabContent so the tab loads gracefully via direct fetch if HTMX
  never initialises

Skipped: auth on execute_system_action — pre-existing app-wide design;
reboot/shutdown and all other system actions share the same exposure.
An app-level auth layer is the correct fix and is out of scope here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tools-tab): resolve second-pass review findings

- Wrap per-plugin subprocess.run in try/except TimeoutExpired/OSError so
  one plugin's failure appends a result entry and continues the loop
  rather than collapsing the whole batch into a 500
- Validate double_sided_copies divisibility against chain_length
  (horizontal axis) or parallel (vertical axis) after the range check;
  reads effective axis from the current request or stored config
- Exclude double_sided_fields from the generic key-merge loop so
  double_sided_enabled/copies/axis are never written as root-level keys
- Fix tools.html copy: "then restores the stash" removed — git_pull
  stashes changes but never pops them
- Check r.ok and d.status in loadGitInfo before building the panel;
  backend error messages now surface instead of silently showing a
  false-clean state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tools-tab): don't expose filesystem paths in OSError messages

CodeQL flagged str(exc) flowing into the JSON response for the
install_plugin_requirements action. Use exc.strerror instead, which
gives the OS error description ("No such file or directory",
"Permission denied") without the internal filesystem path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 12:19:54 -04:00
fefc2d44a2 feat(display): double-sided mode — mirror one screen across the panel chain (#375)
* feat(display): add double-sided mode to mirror one screen across the panel chain

Renders a plugin once at a logical (per-screen) size, then tiles the
rendered frame across the full physical chain so two (or more) panels show
identical content. A 128x32 chain configured with 2 copies drives two 64x32
screens; vertical axis splits parallel outputs instead of the chain.

Plugins size themselves from matrix.width/height, so a thin _LogicalMatrix
proxy reports the logical size while delegating every real operation
(CreateFrameCanvas, SwapOnVSync, brightness, Clear) to the physical matrix —
no plugin changes required. Duplication is a single PIL paste per copy in
update_display(), so render cost is unchanged.

Config: display.double_sided { enabled, copies, axis }. Invalid config
(non-divisible dimension, bad axis/copies) logs a warning and falls back to
single-screen rather than failing to light up.

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

* feat(web): expose double-sided display config in the settings UI

Adds a Double-Sided Display section to the Display settings page (enabled
checkbox, copies, horizontal/vertical axis) and wires the save handler to
persist it under display.double_sided. Validates copies (2-8) and axis,
returning 400 on bad input; an omitted checkbox is saved as disabled.
Like the other hardware fields, changes take effect after a display restart.

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

* refactor(display): add type hints and docstrings to double-sided proxy

Addresses CodeRabbit nits: sort _LogicalMatrix.__slots__ (Ruff RUF023),
annotate the proxy's __init__/properties/dunders and _resolve_double_sided's
return type, and add docstrings to the property/dunder methods.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:53 -04:00
d297dd6217 feat(display-controller): round-robin between simultaneous live-priority games (#372)
_check_live_priority() was stateless first-match-wins: it returned the
first plugin in registration order with live content, and the post-dwell
hold pinned the carousel to it, so when two games were live at once (e.g.
a baseball game and a soccer match) the second never showed until the
first ended.

Add _collect_live_modes() (all currently-live modes, deduped, in
registration order) and give _check_live_priority an 'advance' flag. The
main rotation calls it with advance=True, which returns the live mode
after the one currently shown -- using current_display_mode as the cursor
-- so each dwell advances to the next live game and they take turns. The
Vegas coordinator and the vegas-active check keep the default
non-advancing peek (advance=False), so they only report whether any game
is live without spinning the cursor. should_rotate and _apply_live_priority
are unchanged; a single live game still holds as before.

Adds regression tests to TestDisplayControllerLivePriority.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:07:21 -04:00
974d7ea57a fix(install): avoid apt-package uninstall failure during web dep install (#371)
* fix(install): avoid apt-package uninstall failure on web deps

On a fresh Pi install, requests is installed via apt (python3-requests),
which ships no pip RECORD file. When pip later installs
google-api-python-client, its dependency tree pulls a newer requests and
attempts to uninstall the apt copy, failing with "uninstall-no-record-file"
and aborting the whole install at step 7 (web interface dependencies).

Add --ignore-installed to install_via_pip so pip lays the new version down
in /usr/local (shadowing the apt copy) instead of trying to remove an
apt-managed package. This resolves the failure for any transitive
dependency pip needs to upgrade over an apt-installed package, not just
requests.

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

* fix(install): also pass --ignore-installed for local rgbmatrix install

Keeps the rgbmatrix pip install consistent with install_via_pip so it
won't fail trying to uninstall an apt-managed dependency either.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:06:56 -04:00
ab0cfd2362 fix(web): preserve dotted schema keys when saving plugin config (#370)
The plugin config form posts form-data with dot-notation paths
(e.g. "leagues.fifa.world.enabled"). _get_schema_property and
_set_nested_value split those paths on every dot, so a schema key that
itself contains a dot (soccer league keys like "fifa.world", "eng.1")
was mistaken for nested "fifa" -> "world" objects. Per-league edits
(enable, favorite_teams, nested booleans) were written to a fabricated
"leagues.fifa.world" branch while the real league object was never
updated, so saves silently dropped the change and produced a
byte-identical config.

Both helpers now greedily match the longest path segment that exists in
the schema (_get_schema_property) or the config being updated
(_set_nested_value), mirroring the frontend's dotted-key handling.

Adds regression tests covering schema lookup, value typing, and writes
under dotted league keys, plus a guard that plain nested paths still work.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:06:32 -04:00
d22d0a3754 fix(plugins): stop core updates from resurrecting uninstalled built-in plugins (#368)
* fix(plugins): stop core updates from resurrecting uninstalled built-in plugins

Built-in plugins (e.g. web-ui-info, starlark-apps) are committed into the
repo under plugin-repos/. When a user uninstalls one, a subsequent core
`git pull` update restores the committed files, so the plugin reappears on
every update. The update endpoint stashes the deletion and never pops it,
and `git pull` faithfully restores any committed file whose deletion was
never committed — so excluding plugin-repos/ from the stash can't fix this
(it would only make `git pull --rebase` fail on a dirty tree).

Add a persistent uninstall registry (config/uninstalled_plugins.json,
gitignored) that survives restarts, unlike the existing in-memory tombstone:

- Uninstall records the plugin id; install clears it.
- purge_uninstalled_plugins() re-removes any recorded plugin whose directory
  reappears on disk; called after a successful git-pull update and at web
  startup (covers manual `git pull` on the Pi too).
- The state reconciler also refuses to auto-repair a persistently
  uninstalled plugin.

Wires up mark_recently_uninstalled in the uninstall flow (previously only
referenced by tests) via the new persistent record.

Adds regression tests covering record/forget/purge lifecycle, persistence
across manager instances, and corrupt-registry tolerance.

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

* fix(plugins): validate uninstall-registry ids and lock registry writes

Address review feedback on the persistent uninstall registry:

- Critical: validate plugin ids on read/record and add a containment guard
  in purge_uninstalled_plugins. A corrupt or hand-edited registry entry of
  "" resolves to the plugins root, so purge could have deleted every plugin;
  traversal ids ("..", "../x") could target paths outside the root. Invalid
  ids are now dropped on read, refused on record, and never removed unless
  the path is a direct child of the plugins directory.
- Major: guard record/forget read-modify-write with a lock so concurrent
  install/uninstall requests can't lose updates.
- Minor: narrow the startup and post-update purge exception handlers from
  bare Exception to (OSError, RuntimeError).

Adds regression tests for empty-id, traversal-id, and invalid-record cases.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:18:28 -04:00
5beef0aa01 Improve first-time install error diagnostics and resilience (#369)
* fix(install): don't let outer ERR trap mask first_time_install.sh failures

set +e alone doesn't suppress bash's ERR trap, so any non-zero exit from
first_time_install.sh inside the one-shot installer immediately triggered
the outer on_error handler with a generic "Main installation, line 370"
message — before the script could report the real exit code or point to
logs/. Suspend the trap for that block so the existing if/else handling
runs instead.

* feat(install): surface root cause of web dependency install failures

install_dependencies_apt.py previously reported only which packages
failed, not why - the actual apt/pip error was discarded (apt) or
could scroll out of the on_error log tail (pip), leaving "Step 7:
Install web interface dependencies (line 915)" as the only visible
detail.

Capture command output for each install attempt and print a compact
DEPENDENCY INSTALLATION FAILURES summary with the last lines of error
output per package. Also run the installer with `python3 -u` for
real-time, correctly-ordered logging, and widen the on_error tail from
50 to 100 lines so the summary isn't cut off.

* feat(install): harden first-time install against common Pi failure modes

- wait_for_apt_lock: apt_update/apt_install now wait (up to 3min) for
  unattended-upgrades to release the dpkg lock instead of failing
  outright with "Command failed after 3 attempts" right after first boot.
- check_disk_space: new pre-flight check (Step 1) so a full SD card fails
  fast with a clear message instead of a cryptic mid-build error.
- Step 6: wrap rpi-rgb-led-matrix git clone/submodule operations in retry
  for resilience to transient network issues.
- Step 6: capture `pip install .` build output and print the last 50
  lines on failure, so the actual cmake/compiler error is visible instead
  of just "Failed to install rpi-rgb-led-matrix Python package".

* fix(install): bound subprocess output and dedupe apt update in dependency installer

Address coderabbitai review on PR #369:
- _run() now streams combined stdout/stderr to a temp file and returns
  only the last ERROR_TAIL_LINES lines, instead of buffering full
  output in memory (Codacy also flagged the previous capture_output
  call as a subprocess-without-static-string security issue; the new
  call is annotated as safe since cmd is built from hardcoded args).
- `apt update` now runs once in main() instead of once per package
  needing an apt fallback.

* fix(install): suppress remaining Codacy subprocess false-positive

Codacy's Semgrep-based check still flagged the cmd-built subprocess.run
call as "without a static string" even with the Bandit nosec applied.
Add a nosemgrep marker alongside it - cmd is always a hardcoded
apt/pip argument list, never user input.

* fix(install): correctly detect already-installed dateutil/websocket-client

Address remaining coderabbitai findings on PR #369:
- check_package_installed() did __import__(package_name) directly, but
  python-dateutil and websocket-client import as dateutil/websocket. Both
  always failed the "already installed" check and were reinstalled on
  every run. Add an IMPORT_NAME_MAP for the mismatched names.
- _run() still read the entire temp file into memory before slicing the
  tail. Stream it line-by-line into a deque(maxlen=ERROR_TAIL_LINES)
  instead so memory use stays bounded for very chatty commands.

---------

Co-authored-by: Chuck <chuck@example.com>
2026-06-11 18:12:35 -04:00
cf28a8c0d5 fix(display): restore early-continue guard for mid-loop mode changes (#367)
When the display loop breaks early because current_display_mode changed
(on-demand activation, live priority, etc.), it would fall through to the
"honour minimum duration" sleep for the *previous* mode — blocking for up
to that mode's full display_duration (default 30s) without polling
on-demand requests or re-checking the mode. New modes could sit unrendered
for up to 30s, or get clobbered by a queued stop request before ever
displaying.

This guard was added in #298 to fix #196 (live priority not interrupting
long display durations) and was accidentally dropped in #330 as collateral
damage of an unrelated time.monotonic() -> time.time() cleanup in the same
diff hunk. Restoring it fixes both the original #196 regression and a new
symptom found via the on-air MQTT plugin, where ON/OFF toggles could be
delayed by up to 30s or missed entirely depending on timing within the
previous mode's display cycle.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:16:30 -04:00
a06682981c fix(web): allow up to 24 panels in chain length config (#366)
Raises the Chain Length input's max from 8 to 24 to support longer
LED panel strings.

Co-authored-by: Chuck <chuck@example.com>
2026-06-09 21:31:07 -04:00
bc027c921d fix: check_plugin.py honors per-plugin test/harness.json (#365)
check_one() always compares the render against committed golden images, but
the CLI never loaded the plugin's test/harness.json — so the deterministic
settings the goldens were generated with (config, mock data, frozen time,
sizes) weren't applied. For any time/data-dependent plugin this means the CLI
(and the plugins-repo CI workflow that calls it) renders live data and the
golden drifts on every run, even with no real regression. The pytest matrix
path already reads harness.json via load_harness_spec; the CLI now does too.

- check_one loads load_harness_spec(plugin_dir) and layers it under explicit
  CLI flags: config = schema defaults < harness.json < --config; sizes =
  --sizes > LEDMATRIX_TEST_SIZES env > harness.json > default sample;
  mock_data/freeze_time/skip_update fall back to harness.json when not given
  on the CLI.
- parse_sizes returns None (not DEFAULT_TEST_SIZES) when --sizes is omitted,
  so the env/harness.json/default fallback chain in resolve_test_sizes applies.
- Regression tests: harness.json supplies render settings, and CLI flags
  override it. Use a temp fixture plugin so they run in core CI (no plugins).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:52:33 -04:00
e0bd7088fa fix: make requirements-test.txt installable alongside requirements.txt (#364)
requirements.txt already pins pytest>=9.0.3,<10 (from #331), but
requirements-test.txt re-pinned pytest>=7.4,<9. The two ranges are
disjoint, so `pip install -r requirements.txt -r requirements-test.txt`
fails with ResolutionImpossible — breaking the core test workflow and the
plugins-repo safety workflow that installs both files.

pytest, pytest-cov, pytest-mock, and jsonschema are all already pinned
with major-version caps in requirements.txt, so drop them from
requirements-test.txt and keep only freezegun (the one test dep
requirements.txt doesn't provide).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:32:05 -04:00
313e35a98f Add cross-size/cross-screen plugin safety harness (#361)
* feat(testing): add cross-size/cross-screen plugin safety harness

Render every plugin across all supported matrix sizes (64x32, 128x32,
128x64, 256x32) and every declared screen, failing on crashes, content
drawn past the panel edge, or visual drift vs committed golden images.

- BoundsCheckingDisplayManager: oversized-canvas overflow detection
- harness.py: multi-size/multi-screen render engine + golden compare
- scripts/check_plugin.py: CLI (functional+bounds, --out-dir, --update-golden,
  --freeze-time); render_plugin.py refactored onto shared loading helpers
- test/plugins/test_harness.py + test_plugin_matrix.py (parametrized,
  honors per-plugin test/harness.json; skips when no plugins present)
- MockCacheManager.cache_dir so cache-dir-using plugins load headlessly
- .github/workflows/test.yml + docs/plugin-safety-harness.md

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

* fix(testing): address PR review feedback on plugin safety harness

- check_plugin: friendly error for non-numeric --sizes; reject non-object
  --config / --mock-data JSON; sanitize plugin mode before using as a
  filename; stop --update-golden from masking crash/overflow failures
- bounds_display_manager: pad the canvas out to the largest supported panel
  (not a fixed 16px) so far-overshoot coordinates are caught, not clipped
- harness: merge config_schema defaults inside render_plugin_matrix; surface
  update() failures as a non-fatal warning + result field instead of a debug
  log; sanitize mode in golden_path
- loading: fail fast when harness.json references a missing mock_data fixture
- mocks: clean up the per-instance temp cache dir via weakref.finalize
- test_plugin_matrix: add a discovery guard that fails when
  LEDMATRIX_REQUIRE_PLUGINS=1 but none found (still skips locally); type hints
- bound test deps with upper version pins for deterministic CI

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

* feat(testing): render plugins across arbitrary panel sizes, not a fixed list

Addresses maintainer feedback that there is no canonical set of supported
panel sizes — a build can be any size/configuration (square, 2x2, 4x4, 8x2,
long strips, tall stacks).

- sizes.py: SUPPORTED_SIZES -> DEFAULT_TEST_SIZES (back-compat alias kept),
  reframed as a representative SAMPLE of real panel-grid arrangements rather
  than an authoritative list; add parse_size_token / coerce_sizes /
  resolve_test_sizes helpers
- sizes are now fully overridable: LEDMATRIX_TEST_SIZES env (global, e.g. test
  on your exact hardware) > per-plugin harness.json "sizes" > default sample;
  CLI --sizes unchanged
- bounds_display_manager: pad the canvas to the largest panel IN THE CURRENT
  RUN (via overflow_extent) instead of a hardcoded max, so cross-size overflow
  detection scales to whatever sizes a run uses
- harness: compute per-run extent and thread it into the bounds manager
- tests: arbitrary-shape + size-parsing/precedence coverage
- docs: rewrite "Supported sizes" -> "Sizes: a sample, not a fixed list"

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

* fix(testing): fail the harness on non-connectivity update() errors

Addresses the remaining review thread: recording every update() exception as a
non-fatal warning still let a real update() regression pass green as long as
display() survived. Now update() failures are classified — a tolerated set of
connectivity errors (ConnectionError/TimeoutError/socket/ssl/urllib/http/
requests) is recorded non-fatally (expected with no network in CI), while any
other exception is treated as a genuine bug and fails that render.

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

* ci(security): pin actions to SHAs and disable checkout credential persistence

Addresses the CodeRabbit/zizmor workflow-hardening finding: pin
actions/checkout and actions/setup-python to full commit SHAs and set
persist-credentials: false on checkout to reduce supply-chain and
token-exposure risk.

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

* fix(testing): validate positive sizes; narrow requests import except

Two review findings:
- sizes.py: parse_size_token / coerce_sizes now reject non-positive
  dimensions (0x32, -64x32) with a clear message instead of passing invalid
  sizes downstream (CodeRabbit).
- harness.py: the optional `requests` import now catches ImportError
  specifically and logs instead of `except Exception: pass`, clearing the
  Codacy medium "Try, Except, Pass" (harness.py L52) and Ruff S110/BLE001.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:32:52 -04:00
122e6d6863 fix(web): use fully-qualified .service unit names for privileged systemctl (#360)
The web interface runs headless, so every privileged systemctl call must be
covered by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web. The sudo command
matches the command line exactly, but the code called 'systemctl start
ledmatrix' while configure_web_sudo.sh grants 'systemctl start
ledmatrix.service'. The rule never matched, so start/stop/enable/disable/
restart fell back to a password prompt and failed with 'a terminal is
required to read the password'.

Align all privileged systemctl calls on the fully-qualified unit names the
sudoers grants use. Add a regression test that cross-checks api_v3.py calls
against the grants in configure_web_sudo.sh.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:17:00 -04:00
d488e8a2ad fix(api): don't coerce all-digit strings to int when schema type is string (#363)
* docs(core): add module and class docstrings to the 5 undocumented core files

Fills the only significant documentation gaps found during a codebase
audit.  All other core files (plugin_system/, logging_config.py, etc.)
already have complete module, class, and function docstrings.

Files changed (documentation only — zero logic changes):

  display_controller.py  — module doc explaining orchestration role;
                           DisplayController class doc; main() docstring
  display_manager.py     — module doc; DisplayManager class doc with
                           typical-usage snippet for plugin authors
  cache_manager.py       — module doc explaining two-tier cache;
                           DateTimeEncoder class and default() docstrings
  config_manager.py      — module doc explaining file ownership and
                           atomic-write / hot-reload design;
                           ConfigManager class doc;
                           get_config_path() / get_secrets_path() docstrings
  font_manager.py        — module doc (class docstring already existed)

Also noted (but not changed to avoid behaviour risk):
  display_manager.py and font_manager.py use logging.getLogger() directly
  instead of the project's get_logger() wrapper.  display_manager.py also
  calls setLevel(logging.INFO) immediately after, which would be lost if
  switched to get_logger().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(display_controller): three targeted hot-path optimizations

Opt 1 — cache inspect.signature() per plugin_id
  inspect.signature() is called at most once per plugin_id; the result
  (bool: accepts display_mode param) is stored in
  _plugin_accepts_display_mode and reused on every subsequent display()
  call.  Eliminates all reflection from the display path at runtime.
  Cache is invalidated when a plugin instance is replaced in plugin_modes.

Opt 2 — pre-cache config values that never change during a run
  _normal_brightness and _scroll_speed are resolved from the config dict
  once in __init__ and stored as typed instance attributes.
  - Removes 2+ chained dict.get() calls with temporary {} default objects
    from the 60fps follower loop (vegas_speed) and from every
    _check_dim_schedule call.
  - current_brightness init now uses _normal_brightness directly.

Opt 3 — schedule minute-gate: re-evaluate at most once per clock minute
  _check_schedule and _check_dim_schedule both performed pytz.timezone(),
  datetime.now(), strftime(), and datetime.strptime() on every outer loop
  call.  Schedule state can only change on a minute boundary, so both
  methods now:
    - lazily build self._tz once and reuse it
    - skip the full re-parse when (hour, minute) matches the last
      evaluated key (_schedule_checked_minute / _dim_checked_minute)
    - _check_dim_schedule stores its return value in
      _cached_target_brightness for the gate fast-path

Tests: 23 new tests in test_display_controller_optimizations.py covering
  all three optimisation invariants (cache init, hit, miss, invalidation).
  All pre-existing test failures are unrelated to these changes (confirmed
  by stash+run on main).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve 22 pre-existing test failures across 6 groups

Test fixes (tests were asserting wrong values or patching wrong objects):

  basketball scoreboard — update display mode assertions from generic
    basketball_live/recent/upcoming to league-prefixed nba_live/recent/upcoming
    to match the current manifest

  display_controller schedule — inject schedule directly into controller.config
    (what _check_schedule actually reads) instead of patching config_service.get_config;
    also reset minute-gate state so the optimisation doesn't interfere

  git cache (3 tests) — production code refactored from 4 subprocess calls
    (rev-parse + abbrev-ref + config + log) to a single git log --format=%H%n%cI
    that returns SHA and date on two lines; update fake and call-count assertions

  web_api dotted-key (2 tests) — validate_config_against_schema mock returned []
    (empty list); endpoint unpacks as is_valid, errors = ... causing ValueError;
    fix: return_value = (True, [])

  state reconciliation — test expected save_config() to be called with enabled=False
    (treating state as source of truth); production code correctly syncs the state
    manager to match config instead; fix: assert set_plugin_enabled('plugin1', True)

Production fixes (production code had bugs or missing features):

  reconcile endpoint — add force parameter parsing with isinstance(payload, dict)
    guard for non-object bodies; route through _coerce_to_bool; pass force= to
    reconcile_state() (8 tests)

  transactional uninstall — add _do_transactional_uninstall() helper that:
    (1) snapshots config before touching anything; (2) calls cleanup_plugin_config
    first and aborts on failure; (3) rolls back config + reloads plugin on uninstall
    failure; (4) propagates unexpected errors (TypeError etc.) instead of swallowing
    them (6 tests)

  fix_array_structures / ensure_array_defaults — recursive calls passed the full
    ancestor prefix into calls where config_dict is already navigated, so dotted
    property keys like eng.1 caused parent_parts.split('.') to mis-navigate; fix:
    drop prefix on recursive calls; also add _fix_none_arrays pass after
    merge_with_defaults so None arrays in JSON requests are replaced with schema
    defaults (2 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf: four targeted optimizations across the display pipeline

Opt 1 — cache data-fetch interval per plugin (plugin_manager.py)
  _get_plugin_update_interval fell back to config_manager.get_config()
  (a full dict copy) when the manifest lacked an interval.  Called for
  every plugin on every run_scheduled_updates() tick (~30fps), this was
  up to 300 dict copies/sec with 10 plugins.
  Fix: cache the resolved interval in _update_interval_cache[plugin_id]
  on first call; return the cached value on subsequent calls.  Cache is
  cleared on load_plugin and unload_plugin.

Opt 2 — demote noisy per-cycle INFO logs to DEBUG (display_controller.py)
  Four logger.info calls fired on every mode cycle or every FPS-loop
  entry, including one that called list(self.plugin_modes.keys())
  unconditionally (allocating a list every outer loop iteration).
  - "Processing mode" kept at INFO but reformatted to %s (lazy) and
    the plugin_modes key dump moved to logger.debug
  - "Attempting/Got cycle duration" → logger.debug
  - "Entering high/normal FPS loop" → logger.debug
  Mode name at INFO is preserved for black-screen troubleshooting.

Opt 3 — use Image.frombytes instead of Image.fromarray in scroll hot path
  (scroll_helper.py)
  Image.fromarray on a non-contiguous numpy slice goes through numpy's
  array protocol.  Image.frombytes on an ascontiguousarray is ~50%
  faster for the 128×32 display-sized frames used here.  Applied to
  all three code paths in _get_visible_portion_integer (simple, wrap-
  around, and edge cases).

Opt 5 — cache get_text_width per (text, font) pair (display_manager.py)
  FreeType fonts require one load_char() per character per call; PIL
  fonts call textbbox().  Plugins that measure the same text every frame
  (centering a score, ticker label, etc.) were re-measuring from scratch
  on every display() call.
  Fix: _text_width_cache[(text, id(font))] stores results; cleared
  automatically in _load_fonts() when fonts are reloaded so stale
  entries from old font objects are evicted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(scroll_helper): fix edge-case bug exposed by frombytes switch

The previous commit replaced Image.fromarray with Image.frombytes in
_get_visible_portion_integer.  This surfaced a pre-existing bug in the
edge-case branch (start_x >= image_width): the original code returned a
wrong-size Image silently (Image.fromarray accepts a too-short array);
Image.frombytes raises ValueError instead.

Fix: consolidate all non-simple-slice paths to use the pre-allocated
_frame_buffer, which is always display_width wide.  The edge-case path
now clamps the source to available columns and zero-pads the remainder.

Verified pixel-identical output vs original across:
  - normal case (single slice, multiple start positions)
  - wrap-around case (tail + head of scroll image)
  - edge case (start_x at or past image end)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review comments on PR #358

1. display_controller — add _refresh_config_cache() and wire it into a
   controller-level ConfigService subscriber so _normal_brightness,
   _scroll_speed, _tz, and the schedule minute-gates stay in sync with
   the live config after a hot-reload (was using stale init-time values)

2. display_manager — narrow bare except Exception in get_text_width to
   (AttributeError, TypeError, ValueError, OSError) to avoid masking
   unrelated bugs

3. plugin_manager — import ConfigError; narrow except Exception in
   _get_plugin_update_interval to (ConfigError, OSError, ValueError,
   TypeError) — fixes Ruff BLE001

4. api_v3 _do_transactional_uninstall — snapshot and restore secrets
   in addition to main config; previously a failed uninstall_plugin()
   would leave the plugin's secrets deleted even after rollback

5. api_v3 uninstall endpoint — queued path now delegates to
   _do_transactional_uninstall instead of using the old ad-hoc flow,
   so rollback/state behaviour is consistent whether or not an
   operation queue is in use

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(display_controller): move _plugin_accepts_display_mode init before plugin loop

Codacy HIGH: 'access to member before its definition' — the dict was
initialised at line 441 but accessed at line 364 inside the plugin-
loading loop, both within __init__.

Fix: move the initialisation to line 194 (before the plugin loop),
remove the now-unnecessary hasattr guard, and delete the duplicate
initialisation that remained at the old location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(api): don't coerce all-digit strings to int when schema type is string

_parse_form_value_with_schema had a fallback that tried int()/float() on
any string value that wasn't already handled. Fields like station_id
(type: "string", value: "8726607") were silently converted to integers,
causing jsonschema validation to reject them with "expected string, got int".

Guard the fallback with a check that skips it when the schema property
explicitly declares type: "string".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:01:42 -04:00
106 changed files with 7935 additions and 735 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
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 }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+50
View File
@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+33
View File
@@ -0,0 +1,33 @@
name: Tests
on:
pull_request:
push:
branches: [main]
jobs:
plugin-safety:
name: Plugin safety harness + unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator
- name: Run harness + visual rendering tests
run: |
pytest --no-cov \
test/plugins/test_harness.py \
test/plugins/test_visual_rendering.py \
test/plugins/test_plugin_matrix.py
+1
View File
@@ -8,6 +8,7 @@ config/config_secrets.json
config/config.json config/config.json
config/config.json.backup config/config.json.backup
config/wifi_config.json config/wifi_config.json
config/uninstalled_plugins.json
credentials.json credentials.json
token.pickle token.pickle
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 140 KiB

+5
View File
@@ -115,6 +115,11 @@
"gpio_slowdown": 3, "gpio_slowdown": 3,
"rp1_rio": 0 "rp1_rio": 0
}, },
"double_sided": {
"enabled": false,
"copies": 2,
"axis": "horizontal"
},
"display_durations": {}, "display_durations": {},
"use_short_date_format": true, "use_short_date_format": true,
"vegas_scroll": { "vegas_scroll": {
+136
View File
@@ -0,0 +1,136 @@
# Plugin Safety Harness
Renders a plugin across **every declared screen (mode)** and **a spread of
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
or — for plugins that ship golden images — drifts visually. The goal: change a
plugin without breaking a size or screen you didn't think to test.
## Sizes: a sample, not a fixed list
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
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
here.
The harness therefore renders against a **representative sample** that spans the
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
not an authoritative list:
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
| Size | Grid | Why it's in the sample |
|---------|------|--------------------------------------------|
| 64×32 | 1×1 | single panel — tightest common rectangle |
| 128×32 | 2×1 | the baseline most plugins are tuned for |
| 64×64 | 1×2 | stacked — tall-narrow centering |
| 128×64 | 2×2 | block — icon scaling / vertical centering |
| 256×32 | 4×1 | long strip — wide horizontal layout |
| 128×96 | 2×3 | tall — vertical overflow |
| 256×128 | 4×4 | large block — both dimensions big at once |
**Override the sizes entirely** to test your actual hardware (or any shape):
```bash
# CLI — one-off:
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
# pytest — force every plugin onto your panel(s):
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
# { "sizes": [[8, 16], [64, 64]] }
```
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
uses — the backing canvas is padded out to the **largest** panel in the run, so
a coordinate meant for a big build is still caught when rendering a small one.
## Quick start
```bash
# Functional + bounds check across all sizes/screens:
python scripts/check_plugin.py --plugin clock-simple
# Every discovered plugin:
python scripts/check_plugin.py --all
# Dump PNGs to eyeball each size/screen:
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
```
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
## What it checks (Phase 1 — always on)
1. **Loads** and builds its mode list.
2. **Renders every screen** at every size without raising. `update()` may fail
(no network in CI) and is tolerated; a crash in `display()` is a failure —
`display()` must handle the no-data state.
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
`BoundsCheckingDisplayManager`, which backs the declared panel with an
oversized canvas and flags any pixels that land in the margin. (Left/top
overflow at negative coordinates and BDF text are not flagged — golden images
cover those.)
## Golden images (Phase 2 — opt-in per plugin)
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
```
plugins/<id>/test/harness.json # how to render deterministically
plugins/<id>/test/fixtures/mock.json # optional cached data
plugins/<id>/test/golden/<WxH>/<mode>.png
```
`test/harness.json` keys (all optional):
```json
{
"config": { "timezone": "UTC" },
"mock_data": "fixtures/mock.json",
"freeze_time": "2025-08-01 15:25:00",
"skip_update": false,
"sizes": [[128, 32], [128, 64]]
}
```
Generate / refresh goldens after an intentional visual change, then review the
diff before committing:
```bash
python scripts/check_plugin.py --plugin clock-simple --update-golden \
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
```
Comparison is exact by default (`compare_images` in `harness.py` accepts a
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
and the bundled fonts — keep both stable when regenerating goldens.
## Tests & CI
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
comparison, and mode enumeration (run anywhere).
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
when no plugins are present (e.g. a fresh core checkout); set
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
turn an empty discovery into a hard failure instead. Point it at the monorepo
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
against changed plugins on every PR.
## Developer workflow
1. Change the plugin on a branch.
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
eyeball the PNGs.
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
`plugins.json`.
5. Push — CI re-runs the harness across all sizes and gates the PR.
+88 -18
View File
@@ -15,8 +15,8 @@ on_error() {
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2 echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
if [ -n "${LOG_FILE:-}" ]; then if [ -n "${LOG_FILE:-}" ]; then
echo "See the log for details: $LOG_FILE" >&2 echo "See the log for details: $LOG_FILE" >&2
echo "-- Last 50 lines from log --" >&2 echo "-- Last 100 lines from log --" >&2
tail -n 50 "$LOG_FILE" >&2 || true tail -n 100 "$LOG_FILE" >&2 || true
fi fi
echo "\nCommon fixes:" >&2 echo "\nCommon fixes:" >&2
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2 echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
@@ -202,8 +202,33 @@ retry() {
done done
} }
apt_update() { retry apt update; } # Wait for another apt/dpkg process (commonly unattended-upgrades running
apt_install() { retry apt install -y "$@"; } # shortly after first boot) to release its lock before we try apt ourselves.
# Without this, apt_update/apt_install can fail outright in the first couple
# minutes after a fresh Pi OS boot with a generic "Command failed after 3
# attempts" error.
wait_for_apt_lock() {
command -v flock >/dev/null 2>&1 || return 0
local lock_file="/var/lib/dpkg/lock-frontend"
local max_wait=180
local waited=0
local printed=0
while ! flock -n "$lock_file" -c true 2>/dev/null; do
if [ "$printed" -eq 0 ]; then
echo "⚠ Waiting for another apt/dpkg process to finish (e.g. unattended-upgrades on first boot)..."
printed=1
fi
if [ "$waited" -ge "$max_wait" ]; then
echo "⚠ Still waiting after ${max_wait}s; proceeding anyway."
break
fi
sleep 5
waited=$((waited+5))
done
}
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
apt_remove() { apt-get remove -y "$@" || true; } apt_remove() { apt-get remove -y "$@" || true; }
check_network() { check_network() {
@@ -222,6 +247,22 @@ check_network() {
exit 1 exit 1
} }
check_disk_space() {
command -v df >/dev/null 2>&1 || return 0
local available_mb
available_mb=$(df -m "$PROJECT_ROOT_DIR" | awk 'NR==2{print $4}')
available_mb=${available_mb:-0}
if [ "$available_mb" -lt 500 ]; then
echo "✗ ERROR: Insufficient disk space: ${available_mb}MB available (need at least 500MB)"
echo " Free up space first, e.g.: sudo apt clean && sudo apt autoremove"
exit 1
elif [ "$available_mb" -lt 1024 ]; then
echo "⚠ Limited disk space: ${available_mb}MB available (recommend at least 1GB for the rpi-rgb-led-matrix build in Step 6)"
else
echo "✓ Disk space sufficient: ${available_mb}MB available"
fi
}
echo "" echo ""
echo "This script will perform the following steps:" echo "This script will perform the following steps:"
echo "1. Install system dependencies" echo "1. Install system dependencies"
@@ -271,8 +312,9 @@ CURRENT_STEP="Install system dependencies"
echo "Step 1: Installing system dependencies..." echo "Step 1: Installing system dependencies..."
echo "----------------------------------------" echo "----------------------------------------"
# Ensure network is available before APT operations # Pre-flight checks before APT operations
check_network check_network
check_disk_space
# Update package list # Update package list
apt_update apt_update
@@ -684,7 +726,11 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
if command -v timeout >/dev/null 2>&1; then if command -v timeout >/dev/null 2>&1; then
# Use timeout if available (10 minutes = 600 seconds) # Use timeout if available (10 minutes = 600 seconds)
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then # --ignore-installed: apt-managed packages (e.g. python3-requests)
# ship no pip RECORD file, so upgrading them would otherwise abort
# with "uninstall-no-record-file"; this lays the new version down
# alongside instead of trying to uninstall the apt copy first.
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
INSTALL_SUCCESS=true INSTALL_SUCCESS=true
else else
EXIT_CODE=$? EXIT_CODE=$?
@@ -692,7 +738,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo "✗ Timeout (10 minutes) installing: $line" echo "✗ Timeout (10 minutes) installing: $line"
echo " This package may require building from source, which can be slow on Raspberry Pi." echo " This package may require building from source, which can be slow on Raspberry Pi."
echo " You can try installing it manually later with:" echo " You can try installing it manually later with:"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'" echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
else else
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)" echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
fi fi
@@ -700,7 +746,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
else else
# No timeout command available, install without timeout # No timeout command available, install without timeout
echo " Note: timeout command not available, installation may take a while..." echo " Note: timeout command not available, installation may take a while..."
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
INSTALL_SUCCESS=true INSTALL_SUCCESS=true
else else
EXIT_CODE=$? EXIT_CODE=$?
@@ -752,7 +798,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo " 1. Ensure you have enough disk space: df -h" echo " 1. Ensure you have enough disk space: df -h"
echo " 2. Check available memory: free -h" echo " 2. Check available memory: free -h"
echo " 3. Try installing failed packages individually with verbose output:" echo " 3. Try installing failed packages individually with verbose output:"
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>" echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
echo " 4. For packages that build from source (like numpy), consider:" echo " 4. For packages that build from source (like numpy), consider:"
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>" echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
echo " - Or installing via apt if available: sudo apt install python3-<package>" echo " - Or installing via apt if available: sudo apt install python3-<package>"
@@ -774,7 +820,10 @@ echo ""
# Install web interface dependencies # Install web interface dependencies
echo "Installing web interface dependencies..." echo "Installing web interface dependencies..."
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then # --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
# pip RECORD file, so upgrading them to the version pinned here would
# otherwise abort the whole install with "uninstall-no-record-file".
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
echo "✓ Web interface dependencies installed" echo "✓ Web interface dependencies installed"
# Create marker file to indicate dependencies are installed # Create marker file to indicate dependencies are installed
touch "$PROJECT_ROOT_DIR/.web_deps_installed" touch "$PROJECT_ROOT_DIR/.web_deps_installed"
@@ -815,6 +864,12 @@ if [ "$_SKIP_BUILD" = "1" ]; then
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)." echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
else else
# Ensure rpi-rgb-led-matrix submodule is initialized # Ensure rpi-rgb-led-matrix submodule is initialized
# Wrapper used with retry(): removes any partial clone dir before each attempt
# so git clone doesn't fail with "destination path already exists".
_clone_rpi_rgb() {
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
}
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..." echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
cd "$PROJECT_ROOT_DIR" cd "$PROJECT_ROOT_DIR"
@@ -822,14 +877,14 @@ else
# Try to initialize submodule if .gitmodules exists # Try to initialize submodule if .gitmodules exists
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
echo "Initializing rpi-rgb-led-matrix submodule..." echo "Initializing rpi-rgb-led-matrix submodule..."
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
echo "⚠ Submodule init failed, cloning directly from GitHub..." echo "⚠ Submodule init failed, cloning directly from GitHub..."
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master retry _clone_rpi_rgb
fi fi
else else
# Fallback: clone directly if submodule not configured # Fallback: clone directly if submodule not configured
echo "Submodule not configured, cloning directly from GitHub..." echo "Submodule not configured, cloning directly from GitHub..."
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master retry _clone_rpi_rgb
fi fi
fi fi
@@ -841,9 +896,9 @@ else
cd "$PROJECT_ROOT_DIR" cd "$PROJECT_ROOT_DIR"
rm -rf rpi-rgb-led-matrix-master rm -rf rpi-rgb-led-matrix-master
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
git submodule update --init --recursive rpi-rgb-led-matrix-master retry git submodule update --init --recursive rpi-rgb-led-matrix-master
else else
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master retry _clone_rpi_rgb
fi fi
fi fi
@@ -851,13 +906,24 @@ else
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..." echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
echo " Build deps required: python-dev-is-python3 cmake" echo " Build deps required: python-dev-is-python3 cmake"
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..." echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
if ! python3 -m pip install --break-system-packages .; then BUILD_OUTPUT=$(mktemp)
BUILD_SUCCESS=false
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
BUILD_SUCCESS=true
fi
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
if [ "$BUILD_SUCCESS" != true ]; then
echo "✗ Failed to install rpi-rgb-led-matrix Python package" echo "✗ Failed to install rpi-rgb-led-matrix Python package"
echo " Ensure build tools are installed:" echo " Ensure build tools are installed:"
echo " sudo apt install -y python-dev-is-python3 cmake build-essential" echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
echo ""
echo "-- Last 50 lines of build output --"
tail -n 50 "$BUILD_OUTPUT"
rm -f "$BUILD_OUTPUT"
popd >/dev/null popd >/dev/null
exit 1 exit 1
fi fi
rm -f "$BUILD_OUTPUT"
popd >/dev/null popd >/dev/null
else else
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR" echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
@@ -912,11 +978,15 @@ else
# Try to install dependencies using the smart installer if available # Try to install dependencies using the smart installer if available
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
echo "Using smart dependency installer..." echo "Using smart dependency installer..."
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" # -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
# real time and in order relative to this script's own echo statements
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
else else
echo "Using pip to install dependencies..." echo "Using pip to install dependencies..."
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt # --ignore-installed: see the Step 5 web_interface/requirements.txt
# install above — same apt/pip RECORD-file conflict applies here.
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
else else
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install" echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
fi fi
+9
View File
@@ -0,0 +1,9 @@
# Test-only dependencies for the plugin safety harness and pytest suite.
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
#
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
# major-version caps) in requirements.txt, so they are intentionally NOT
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
# pytest>=9.0.3,<10 and made the two files impossible to install together.
# Only declare what requirements.txt doesn't already provide.
freezegun>=1.2,<2 # deterministic time for golden-image tests
+3
View File
@@ -43,6 +43,9 @@ websocket-client>=1.8.0,<2.0.0
# JSON Schema validation # JSON Schema validation
jsonschema>=4.20.0,<5.0.0 jsonschema>=4.20.0,<5.0.0
# Requirement specifier parsing (plugin dependency satisfaction checks)
packaging>=23.0,<27.0
# Testing dependencies # Testing dependencies
pytest>=9.0.3,<10.0.0 pytest>=9.0.3,<10.0.0
pytest-cov>=4.1.0,<5.0.0 pytest-cov>=4.1.0,<5.0.0
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
Plugin safety checker.
Renders a plugin across every declared screen (mode) and every supported matrix
size, and fails if any screen crashes, overflows the panel, or (for plugins with
committed golden images) drifts visually.
Usage:
# Functional + bounds check across all sizes/modes:
python scripts/check_plugin.py --plugin clock-simple
# Every discovered plugin:
python scripts/check_plugin.py --all
# Dump PNGs for each size/mode so you can eyeball them:
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
# Refresh committed golden images after an intentional visual change:
python scripts/check_plugin.py --plugin clock-simple --update-golden \
--mock-data plugins/clock-simple/test/fixtures/mock.json
Exit code is non-zero if any (plugin, size, mode) fails.
"""
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
os.environ['EMULATOR'] = 'true'
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
find_plugin_dir, load_config_defaults, load_harness_spec,
)
from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
)
from src.plugin_system.testing.sizes import ( # noqa: E402
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
)
logger = get_logger("[Check Plugin]")
DEFAULT_SEARCH_DIRS = [
str(PROJECT_ROOT / 'plugins'),
str(PROJECT_ROOT / 'plugin-repos'),
]
def discover_plugins(search_dirs: List[str]) -> List[str]:
"""All plugin ids found across the search dirs (dirs containing manifest.json)."""
found = []
for d in search_dirs:
base = Path(d)
if not base.exists():
continue
for child in sorted(base.iterdir()):
if (child / 'manifest.json').exists() and child.name not in found:
found.append(child.name)
return found
def parse_sizes(spec: Optional[str]):
if not spec:
return None
sizes = []
for token in spec.split(','):
if not token.strip():
continue
try:
sizes.append(parse_size_token(token))
except ValueError as exc:
raise SystemExit(str(exc)) from exc
return sizes
def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
config: Dict, run_update: bool, out_dir: Optional[Path],
update_golden: bool, golden_dir_override: Optional[Path],
freeze_time: Optional[str]) -> List[RenderResult]:
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
if not plugin_dir:
logger.error("Plugin '%s' not found in: %s", plugin_id, search_dirs)
return [RenderResult(plugin_id, 0, 0, "<not-found>", error="plugin directory not found")]
# Per-plugin test/harness.json holds the deterministic settings the committed
# goldens were generated with (config, mock data, frozen time, sizes). Load
# them so the CLI/CI render reproduces the golden the same way the pytest
# matrix path does; explicit CLI flags still override the file.
spec = load_harness_spec(plugin_dir)
# config_schema defaults (real-install behavior), then harness.json config,
# then CLI --config — most specific wins.
full_config = {"enabled": True}
full_config.update(load_config_defaults(plugin_dir))
full_config.update(spec.get("config", {}))
full_config.update(config)
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
# CLI value wins when provided, else fall back to the harness.json setting.
effective_mock_data = mock_data or spec.get("mock_data_contents", {})
effective_freeze = freeze_time or spec.get("freeze_time")
effective_run_update = run_update and not spec.get("skip_update", False)
results = render_plugin_matrix(
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
mock_data=effective_mock_data, sizes=effective_sizes,
run_update=effective_run_update, freeze_time=effective_freeze,
)
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
if update_golden:
written = write_goldens(results, golden_dir)
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
else:
compare_to_goldens(results, golden_dir)
if out_dir:
for r in results:
if r.image is None:
continue
dest = out_dir / plugin_id / size_label(r.width, r.height)
dest.mkdir(parents=True, exist_ok=True)
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
return results
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
"""Print a per-plugin grid. Returns True if everything passed."""
everything_ok = True
for plugin_id, results in all_results.items():
print(f"\n=== {plugin_id} ===")
for r in results:
if r.ok:
status = "PASS"
detail = ""
if r.golden_checked:
detail = " (golden ✓)"
if r.update_error is not None:
detail += f" (update warn: {r.update_error})"
else:
everything_ok = False
if r.error is not None:
status, detail = "FAIL", f" error={r.error}"
elif r.overflow is not None:
status, detail = "FAIL", f" overflow bbox={r.overflow}"
elif r.golden_ok is False:
status = "FAIL"
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
else:
status, detail = "FAIL", ""
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
print()
return everything_ok
def main() -> int:
parser = argparse.ArgumentParser(description="Check a plugin renders safely across sizes & screens")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--plugin', '-p', help='Plugin id to check')
group.add_argument('--all', action='store_true', help='Check every discovered plugin')
parser.add_argument('--plugin-dir', '-d', default=None, help='Directory to search for plugins')
parser.add_argument('--sizes', default=None, help='Comma-separated WxH list (default: all supported)')
parser.add_argument('--config', '-c', default='{}', help='Plugin config overrides as JSON')
parser.add_argument('--mock-data', '-m', default=None, help='Path to JSON file with mock cache data')
parser.add_argument('--out-dir', '-o', default=None, help='Also dump rendered PNGs here')
parser.add_argument('--skip-update', action='store_true', help='Skip calling update()')
parser.add_argument('--update-golden', action='store_true', help='Write/refresh golden images')
parser.add_argument('--golden-dir', default=None, help='Override golden dir (default: <plugin>/test/golden)')
parser.add_argument('--freeze-time', default=None,
help='Freeze wall clock, e.g. "2025-08-01 15:25:00" (for time-dependent plugins)')
args = parser.parse_args()
search_dirs = [args.plugin_dir] if args.plugin_dir else DEFAULT_SEARCH_DIRS
sizes = parse_sizes(args.sizes)
try:
config = json.loads(args.config)
except json.JSONDecodeError as e:
logger.error("Invalid --config JSON: %s", e)
return 2
if not isinstance(config, dict):
logger.error("--config must be a JSON object, got %s", type(config).__name__)
return 2
mock_data = {}
if args.mock_data:
mock_path = Path(args.mock_data)
if not mock_path.exists():
logger.error("Mock data file not found: %s", args.mock_data)
return 2
with open(mock_path) as f:
mock_data = json.load(f)
if not isinstance(mock_data, dict):
logger.error("--mock-data must be a JSON object (key -> cache value), got %s",
type(mock_data).__name__)
return 2
plugin_ids = discover_plugins(search_dirs) if args.all else [args.plugin]
if not plugin_ids:
logger.error("No plugins found in: %s", search_dirs)
return 2
out_dir = Path(args.out_dir) if args.out_dir else None
golden_dir_override = Path(args.golden_dir) if args.golden_dir else None
all_results: Dict[str, List[RenderResult]] = {}
for plugin_id in plugin_ids:
all_results[plugin_id] = check_one(
plugin_id=plugin_id, search_dirs=search_dirs, sizes=sizes,
mock_data=mock_data, config=config, run_update=not args.skip_update,
out_dir=out_dir, update_golden=args.update_golden,
golden_dir_override=golden_dir_override, freeze_time=args.freeze_time,
)
# When refreshing goldens we skip drift comparison, but a crash or overflow
# still means the plugin is broken — never let --update-golden mask that.
ok = print_report(all_results)
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
-29
View File
@@ -1,29 +0,0 @@
#!/bin/bash
# Clear all plugin dependency markers to force fresh dependency check
# Useful after updating plugins or troubleshooting dependency issues
echo "Clearing plugin dependency markers..."
# Check both possible cache locations
CACHE_DIRS=(
"/var/cache/ledmatrix"
"$HOME/.cache/ledmatrix"
)
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
if [ -d "$CACHE_DIR" ]; then
echo "Checking $CACHE_DIR..."
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
if [ "$marker_count" -gt 0 ]; then
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
echo "Cleared $marker_count marker(s)"
else
echo "No dependency markers found in $CACHE_DIR"
fi
fi
done
echo "Done! Dependency markers cleared."
echo "Next startup will check and install dependencies as needed."
+74
View File
@@ -0,0 +1,74 @@
#!/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
# requirements.txt under plugin-repos/ or plugins/.
#
# This script is intended to be called via sudo from the web interface, so
# that packages a plugin declares end up visible to ledmatrix.service (which
# runs as root) rather than only to whichever non-root user runs the web
# interface. Plugin code already runs as root once loaded, so installing its
# declared dependencies as root is not a new trust boundary.
#
# Usage: safe_pip_install.sh <requirements_txt_path>
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <requirements_txt_path>" >&2
exit 1
fi
TARGET="$1"
# Determine the project root (parent of scripts/fix_perms/)
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
# - any requirements.txt under plugin-repos/ or plugins/
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
ALLOWED_BASES=(
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
)
# Resolve the target path (follow symlinks); works even if it doesn't exist.
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
# Must be named requirements.txt — never install from an arbitrary file.
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
exit 2
fi
ALLOWED=false
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
ALLOWED=true
else
for BASE in "${ALLOWED_BASES[@]}"; do
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
ALLOWED=true
break
fi
done
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
exit 2
fi
if [ ! -f "$RESOLVED_TARGET" ]; then
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
exit 3
fi
PYTHON_PATH="$(command -v python3)"
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
# found"). This tells pip to install the newer version alongside rather than
# aborting the whole requirements.txt install over one such conflict.
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
+28 -5
View File
@@ -33,6 +33,7 @@ POWEROFF_PATH=$(command -v poweroff) || true
BASH_PATH=$(command -v bash) || true BASH_PATH=$(command -v bash) || true
JOURNALCTL_PATH=$(command -v journalctl) || true JOURNALCTL_PATH=$(command -v journalctl) || true
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh" SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
# Validate required commands (systemctl, bash, python3 are essential) # Validate required commands (systemctl, bash, python3 are essential)
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
@@ -48,11 +49,15 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
exit 1 exit 1
fi fi
# Validate helper script exists # Validate helper scripts exist
if [ ! -f "$SAFE_RM_PATH" ]; then if [ ! -f "$SAFE_RM_PATH" ]; then
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2 echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
exit 1 exit 1
fi fi
if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2
exit 1
fi
echo "Command paths:" echo "Command paths:"
echo " Python: $PYTHON_PATH" echo " Python: $PYTHON_PATH"
@@ -62,6 +67,7 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
echo " Bash: $BASH_PATH" echo " Bash: $BASH_PATH"
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}" echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
echo " Safe plugin rm: $SAFE_RM_PATH" echo " Safe plugin rm: $SAFE_RM_PATH"
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
# Create a temporary sudoers file # Create a temporary sudoers file
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$" TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
@@ -101,13 +107,22 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
fi fi
# Required: python3, bash # Required: python3, bash
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py" # NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh" # project root, not under scripts/install/ (where this script lives) —
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh" # must use PROJECT_ROOT here, not PROJECT_DIR.
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh"
echo "" echo ""
echo "# Allow web user to remove plugin directories via vetted helper script" echo "# Allow web user to remove plugin directories via vetted helper script"
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/" echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *" echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
echo ""
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
echo "# helper script, so packages are visible to root-run ledmatrix.service"
echo "# (not just the web interface's own user). The helper validates the target"
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
} > "$TEMP_SUDOERS" } > "$TEMP_SUDOERS"
echo "" echo ""
@@ -126,6 +141,7 @@ echo "- Run display_controller.py directly"
echo "- Execute start_display.sh and stop_display.sh" echo "- Execute start_display.sh and stop_display.sh"
echo "- Reboot and shutdown the system" echo "- Reboot and shutdown the system"
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)" echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
echo "" echo ""
# Ask for confirmation # Ask for confirmation
@@ -147,6 +163,13 @@ fi
if ! sudo chmod 755 "$SAFE_RM_PATH"; then if ! sudo chmod 755 "$SAFE_RM_PATH"; then
echo "Warning: Could not set permissions on $SAFE_RM_PATH" echo "Warning: Could not set permissions on $SAFE_RM_PATH"
fi fi
echo "Hardening safe_pip_install.sh ownership..."
if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then
echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH"
fi
if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH"
fi
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "Configuration applied successfully!" echo "Configuration applied successfully!"
@@ -160,7 +183,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "✗ systemctl status ledmatrix.service - Failed" echo "✗ systemctl status ledmatrix.service - Failed"
fi fi
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
echo "✓ File access test - OK" echo "✓ File access test - OK"
else else
echo "✗ File access test - Failed" echo "✗ File access test - Failed"
+7 -1
View File
@@ -340,8 +340,13 @@ main() {
echo "" echo ""
# Execute with proper error handling and non-interactive mode # Execute with proper error handling and non-interactive mode
# Temporarily disable errexit to capture exit code instead of exiting immediately # Temporarily disable errexit AND the ERR trap to capture exit code instead of
# exiting immediately. `set +e` alone does not suppress the ERR trap, so without
# `trap '' ERR` a non-zero exit from first_time_install.sh would trigger on_error
# here with the generic "Main installation" message instead of the detailed
# if/else handling below.
set +e set +e
trap '' ERR
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios) # Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
# When running manually, /tmp usually has correct permissions (1777) # When running manually, /tmp usually has correct permissions (1777)
@@ -370,6 +375,7 @@ main() {
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
fi fi
INSTALL_EXIT_CODE=$? INSTALL_EXIT_CODE=$?
trap 'on_error $LINENO' ERR # Re-enable ERR trap
set -e # Re-enable errexit set -e # Re-enable errexit
if [ $INSTALL_EXIT_CODE -eq 0 ]; then if [ $INSTALL_EXIT_CODE -eq 0 ]; then
+110 -43
View File
@@ -6,12 +6,40 @@ then falls back to pip with --break-system-packages
import subprocess import subprocess
import sys import sys
import tempfile
import warnings import warnings
from collections import deque
from pathlib import Path from pathlib import Path
from typing import List, Tuple
def install_via_apt(package_name): # How many trailing lines of a failed command's output to keep for the
"""Try to install a package via apt.""" # end-of-run failure summary. Keeps the root cause near the end of the log,
try: # which is where first_time_install.sh's error handler tails from.
ERROR_TAIL_LINES = 15
def _run(cmd: List[str]) -> Tuple[bool, str]:
"""Run a command, streaming combined stdout/stderr to a temp file.
Returns (success, output) instead of raising, so callers can report
*why* a command failed rather than just that it failed. `output` is
bounded to the last ERROR_TAIL_LINES lines so failures from very
chatty commands (e.g. pip build logs) don't get buffered in memory.
"""
with tempfile.TemporaryFile(mode='w+b') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
f.seek(0)
# Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
# in memory, regardless of how much output the command produced.
tail = deque(
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
maxlen=ERROR_TAIL_LINES,
)
return result.returncode == 0, '\n'.join(tail)
def install_via_apt(package_name: str) -> Tuple[bool, str]:
"""Try to install a package via apt. Returns (success, output)."""
# Map pip package names to apt package names # Map pip package names to apt package names
apt_package_map = { apt_package_map = {
'flask': 'python3-flask', 'flask': 'python3-flask',
@@ -32,56 +60,89 @@ def install_via_apt(package_name):
apt_package = apt_package_map.get(package_name, f'python3-{package_name}') apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
print(f"Trying to install {apt_package} via apt...") print(f"Trying to install {apt_package} via apt...")
subprocess.check_call([ success, output = _run(['sudo', 'apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', apt_package])
'sudo', 'apt', 'update' if success:
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.check_call([
'sudo', 'apt', 'install', '-y', apt_package
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(f"Successfully installed {apt_package} via apt") print(f"Successfully installed {apt_package} via apt")
return True return True, ""
except subprocess.CalledProcessError: print(f"Failed to install {apt_package} via apt, will try pip")
print(f"Failed to install {package_name} via apt, will try pip") return False, output
return False
def install_via_pip(package_name):
def install_via_pip(package_name: str) -> Tuple[bool, str]:
"""Install a package via pip with --break-system-packages and --prefer-binary. """Install a package via pip with --break-system-packages and --prefer-binary.
--break-system-packages allows pip to install into the system Python on --break-system-packages allows pip to install into the system Python on
Debian/Ubuntu-based systems without a virtual environment. Debian/Ubuntu-based systems without a virtual environment.
--prefer-binary prefers pre-built wheels over source distributions to avoid --prefer-binary prefers pre-built wheels over source distributions to avoid
exhausting /tmp space during compilation. exhausting /tmp space during compilation.
""" --ignore-installed stops pip from trying to *uninstall* packages that were
try: installed by apt (e.g. python3-requests). Those Debian packages ship no
print(f"Installing {package_name} via pip...") pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
subprocess.check_call([ and aborts the whole install. With --ignore-installed, pip lays the new
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name version down in /usr/local where it shadows the apt copy instead of removing
]) it. This matters when a pip dependency (google-api-python-client pulls a
print(f"Successfully installed {package_name} via pip") newer requests) needs to upgrade an apt-managed package.
return True
except subprocess.CalledProcessError as e:
print(f"Failed to install {package_name} via pip: {e}")
return False
def check_package_installed(package_name): Returns (success, output).
"""
print(f"Installing {package_name} via pip...")
success, output = _run([
sys.executable, '-m', 'pip', 'install',
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
])
if success:
print(f"Successfully installed {package_name} via pip")
return True, ""
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
return False, output
# Distribution (pip/apt) names whose importable module name differs.
IMPORT_NAME_MAP = {
'python-dateutil': 'dateutil',
'websocket-client': 'websocket',
}
def check_package_installed(package_name: str) -> bool:
"""Check if a package is already installed.""" """Check if a package is already installed."""
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
# Suppress deprecation warnings when checking if packages are installed # Suppress deprecation warnings when checking if packages are installed
# (we're just checking, not using them) # (we're just checking, not using them)
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning) warnings.filterwarnings('ignore', category=DeprecationWarning)
try: try:
__import__(package_name) __import__(import_name)
return True return True
except ImportError: except ImportError:
return False return False
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
print("\n" + "=" * 60)
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
print("=" * 60)
for package in failed_packages:
print(f"\nPackage: {package}")
print("-" * 40)
output = failure_details.get(package, "").strip()
if not output:
print(" (no output captured)")
continue
for line in output.splitlines()[-ERROR_TAIL_LINES:]:
print(f" {line}")
print("=" * 60)
def main(): def main():
"""Main installation function.""" """Main installation function."""
print("Installing dependencies for LED Matrix Web Interface V2...") print("Installing dependencies for LED Matrix Web Interface V2...")
print("Refreshing apt package index...")
_run(['sudo', 'apt', 'update']) # best-effort; individual installs surface their own errors
# List of required packages # List of required packages
required_packages = [ required_packages = [
'flask', 'flask',
@@ -100,6 +161,7 @@ def main():
] ]
failed_packages = [] failed_packages = []
failure_details = {}
for package in required_packages: for package in required_packages:
if check_package_installed(package): if check_package_installed(package):
@@ -107,9 +169,12 @@ def main():
continue continue
# Try apt first, then pip # Try apt first, then pip
if not install_via_apt(package): ok, apt_output = install_via_apt(package)
if not install_via_pip(package): if not ok:
ok, pip_output = install_via_pip(package)
if not ok:
failed_packages.append(package) failed_packages.append(package)
failure_details[package] = pip_output or apt_output
# Install packages that don't have apt equivalents # Install packages that don't have apt equivalents
special_packages = [ special_packages = [
@@ -124,8 +189,10 @@ def main():
] ]
for package in special_packages: for package in special_packages:
if not install_via_pip(package): ok, pip_output = install_via_pip(package)
if not ok:
failed_packages.append(package) failed_packages.append(package)
failure_details[package] = pip_output
# Install rgbmatrix module from local source (optional - may already be installed in Step 6) # Install rgbmatrix module from local source (optional - may already be installed in Step 6)
# Check if already installed first # Check if already installed first
@@ -133,7 +200,6 @@ def main():
print("rgbmatrix module already installed, skipping...") print("rgbmatrix module already installed, skipping...")
else: else:
print("Installing rgbmatrix module from local source...") print("Installing rgbmatrix module from local source...")
try:
# Get project root (parent of scripts directory) # Get project root (parent of scripts directory)
PROJECT_ROOT = Path(__file__).parent.parent PROJECT_ROOT = Path(__file__).parent.parent
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python' rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
@@ -143,26 +209,27 @@ def main():
if setup_py.exists(): if setup_py.exists():
# Try installing - use regular install, not editable mode # Try installing - use regular install, not editable mode
# This is optional for web interface and should already be installed in Step 6 # This is optional for web interface and should already be installed in Step 6
subprocess.check_call([ ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path) if ok:
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("rgbmatrix module installed successfully") print("rgbmatrix module installed successfully")
else:
# Don't fail the whole installation - rgbmatrix is optional for web interface
# and should be installed in Step 6 of first_time_install.sh
print("Warning: Failed to install rgbmatrix module:")
for line in output.strip().splitlines()[-ERROR_TAIL_LINES:]:
print(f" {line}")
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
print(" The web interface will work without it.")
else: else:
print("Warning: rgbmatrix setup.py not found, module may need to be built first") print("Warning: rgbmatrix setup.py not found, module may need to be built first")
print(" This is normal if Step 6 hasn't completed yet.") print(" This is normal if Step 6 hasn't completed yet.")
else: else:
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)") print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
except subprocess.CalledProcessError as e:
# Don't fail the whole installation - rgbmatrix is optional for web interface
# and should be installed in Step 6 of first_time_install.sh
print(f"Warning: Failed to install rgbmatrix module: {e}")
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
print(" The web interface will work without it.")
# Don't add to failed_packages since it's optional
if failed_packages: if failed_packages:
print(f"\nFailed to install the following packages: {failed_packages}") print(f"\nFailed to install the following packages: {failed_packages}")
print("You may need to install them manually or check your system configuration.") print("You may need to install them manually or check your system configuration.")
print_failure_summary(failed_packages, failure_details)
return False return False
else: else:
print("\nAll dependencies installed successfully!") print("\nAll dependencies installed successfully!")
+3 -38
View File
@@ -17,7 +17,6 @@ import os
import json import json
import argparse import argparse
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Union
# Add project root to path # Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -28,49 +27,15 @@ os.environ['EMULATOR'] = 'true'
# Import logger after path setup so src.logging_config is importable # Import logger after path setup so src.logging_config is importable
from src.logging_config import get_logger # noqa: E402 from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
find_plugin_dir, load_manifest, load_config_defaults,
)
logger = get_logger("[Render Plugin]") logger = get_logger("[Render Plugin]")
MIN_DIMENSION = 1 MIN_DIMENSION = 1
MAX_DIMENSION = 512 MAX_DIMENSION = 512
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
"""Find a plugin directory by searching multiple paths."""
from src.plugin_system.plugin_loader import PluginLoader
loader = PluginLoader()
for search_dir in search_dirs:
search_path = Path(search_dir)
if not search_path.exists():
continue
result = loader.find_plugin_directory(plugin_id, search_path)
if result:
return Path(result)
return None
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
"""Load and return manifest.json from plugin directory."""
manifest_path = plugin_dir / 'manifest.json'
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r') as f:
return json.load(f)
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
"""Extract default values from config_schema.json."""
schema_path = plugin_dir / 'config_schema.json'
if 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
def main() -> int: def main() -> int:
"""Load a plugin, call update() + display(), and save the result as a PNG image.""" """Load a plugin, call update() + display(), and save the result as a PNG image."""
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image') parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
+10 -3
View File
@@ -68,13 +68,14 @@ class DiskCache:
return None return None
return os.path.join(self.cache_dir, f"{key}.json") return os.path.join(self.cache_dir, f"{key}.json")
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]: def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
""" """
Get data from disk cache. Get data from disk cache.
Args: Args:
key: Cache key key: Cache key
max_age: Maximum age in seconds max_age: Maximum age in seconds; None disables age-based expiry
(the record never counts as stale). Mirrors MemoryCache.get.
Returns: Returns:
Cached data or None if not found or expired Cached data or None if not found or expired
@@ -105,7 +106,13 @@ class DiskCache:
record_ts = None record_ts = None
now = time.time() now = time.time()
if record_ts is None or (now - record_ts) <= max_age: # max_age=None means "never expires" (mirrors MemoryCache and the
# cache_manager docstring). Guard it explicitly — otherwise the
# comparison below raises TypeError and the record is treated as a
# miss, which silently breaks callers that persist long-lived state
# via get(key, max_age=None) (e.g. plugin health/metrics that must
# survive restarts and be read cross-process).
if record_ts is None or max_age is None or (now - record_ts) <= max_age:
return record return record
else: else:
# Stale on disk; keep file for potential diagnostics but treat as miss # Stale on disk; keep file for potential diagnostics but treat as miss
+13 -3
View File
@@ -574,9 +574,19 @@ class CacheManager:
} }
return self.save_cache(data_type, cache_data) return self.save_cache(data_type, cache_data)
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]: def get(self, key: str, max_age: Optional[int] = 300,
"""Get data from cache if it exists and is not stale.""" memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
cached_data = self.get_cached_data(key, max_age) """Get data from cache if it exists and is not stale.
Args:
key: Cache key
max_age: Max age (seconds) for the on-disk entry; None never expires.
memory_ttl: Max age (seconds) for the in-memory entry. Pass 0 to
bypass the memory tier and force a fresh read from disk — used by
cross-process readers that must observe another process's latest
write rather than a stale first snapshot. Defaults to max_age.
"""
cached_data = self.get_cached_data(key, max_age, memory_ttl=memory_ttl)
if cached_data and 'data' in cached_data: if cached_data and 'data' in cached_data:
return cached_data['data'] return cached_data['data']
return cached_data return cached_data
+137
View File
@@ -8,13 +8,34 @@ files that need to be accessible by both root service and web user.
import os import os
import logging import logging
import re
import shutil as _shutil import shutil as _shutil
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
# own error output can be logged/displayed without echoing back a private
# index URL's embedded basic-auth secret verbatim (e.g. from a
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
def _redact_url_credentials(text: Optional[str]) -> str:
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
Safe to call on any subprocess output destined for logs: it only ever
shortens/replaces the credential substring, never changes the presence
or absence of the specific fixed phrases callers check for
(e.g. "a password is required"), so it can't affect control flow.
"""
if not text:
return text or ""
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
# System directories that should never have their permissions modified # System directories that should never have their permissions modified
# These directories have special system-level permissions that must be preserved # These directories have special system-level permissions that must be preserved
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
@@ -287,3 +308,119 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
logger.error(f"Unexpected error during sudo helper for {path}: {e}") logger.error(f"Unexpected error during sudo helper for {path}: {e}")
return False return False
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
"""
Install a requirements.txt file for a plugin (or the project itself).
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
packages end up visible to root-run ledmatrix.service, not just to
whichever non-root user happens to run the calling process (e.g. the web
interface). Falls back to installing with the calling process's own
interpreter if the wrapper isn't set up yet (the admin hasn't run
scripts/install/configure_web_sudo.sh), so dependency installation still
does *something* useful rather than hard-failing.
Always installs with the interpreter that will actually run the code
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
silently resolve to a different Python installation (e.g. system Python
vs. a virtualenv) than the one importing the package at runtime.
Args:
req_file: Path to a requirements.txt file
timeout: Subprocess timeout in seconds
Returns:
subprocess.CompletedProcess from the pip (or wrapper) invocation.
Never raises on a non-zero exit; callers should check ``returncode``.
``stdout`` is prefixed with an explanatory note when the root wrapper
was unavailable and the fallback path was used.
"""
project_root = Path(__file__).resolve().parent.parent.parent
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
if wrapper.exists():
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
# be invoked with an explicit, known path rather than relying on the
# wrapper's shebang: sudoers matches the exact command line.
bash_candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in bash_candidates:
bash_candidates.append(candidate)
result = None
for bash_path in bash_candidates:
# bash_path and wrapper are fixed, known-good paths, and
# safe_pip_install.sh independently re-validates req_file is an
# allowed requirements.txt before installing anything as root.
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
# Redact immediately: pip can echo a private index URL's embedded
# basic-auth credentials back in its own error/progress output
# (e.g. from a requirements.txt --index-url line). Doesn't affect
# the fixed-phrase "denied" check below -- those phrases never
# overlap with URL syntax.
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = _redact_url_credentials(result.stdout)
if result.returncode == 0:
return result
# 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")
)
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
# tell "already redacted above" from "still raw" just by looking
# at this call in isolation. The full (redacted) text is still
# available to callers via the returned CompletedProcess.
logger.warning(
"Root pip install failed (rc=%s); see the returned "
"CompletedProcess.stderr for details.",
result.returncode,
)
return result
# Same reasoning as above: no req_file / pip-output interpolation in
# this log line, only in the returned note/CompletedProcess.
logger.warning(
"Root pip install wrapper denied via sudo for all candidates; "
"falling back to user-level install. See the returned "
"CompletedProcess.stderr for details."
)
note = (
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
"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.]\n"
)
else:
logger.warning(
"safe_pip_install.sh not found; falling back to user-level install."
)
note = (
"[safe_pip_install.sh not found; installed for the current process's "
"user only. Run scripts/install/configure_web_sudo.sh to enable "
"root installs visible to ledmatrix.service.]\n"
)
# sys.executable is this process's own interpreter (not
# attacker-influenced), and req_file is a Path built internally by callers
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
# upgrading them would otherwise abort with "uninstall-no-record-file".
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = note + _redact_url_credentials(result.stdout)
return result
+301 -63
View File
@@ -24,7 +24,7 @@ import time
import os import os
import json import json
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional, Callable
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
import pytz import pytz
@@ -163,6 +163,13 @@ class DisplayController:
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
self.mode_to_plugin_id: Dict[str, str] = {} self.mode_to_plugin_id: Dict[str, str] = {}
self.plugin_display_modes: Dict[str, List[str]] = {} self.plugin_display_modes: Dict[str, List[str]] = {}
# Per-plugin config-change callbacks, kept so we can unsubscribe a
# plugin when it is disabled live.
self._plugin_config_callbacks: Dict[str, Callable] = {}
# Set by the config-watcher thread when the enabled-plugin set changes;
# the main run loop reconciles (loads/unloads) on its own thread so
# mutating available_modes never races with rendering.
self._pending_plugin_reconcile = False
self.on_demand_active = False self.on_demand_active = False
self.on_demand_mode: Optional[str] = None self.on_demand_mode: Optional[str] = None
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
@@ -224,6 +231,23 @@ class DisplayController:
font_manager=self.font_manager font_manager=self.font_manager
) )
# Activate the plugin health/metrics subsystem. PluginManager leaves
# health_tracker/resource_monitor as None by default; wiring real
# instances here turns on the circuit breaker (a repeatedly-failing
# plugin's update() is skipped after consecutive failures, then
# retried after a cooldown) and per-plugin execution-time metrics.
# Both persist to the shared cache so the web UI can surface them.
# Done before discovery/loading so load-time schema warnings have a
# tracker to record against.
try:
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
self.plugin_manager.health_tracker = PluginHealthTracker(self.cache_manager)
self.plugin_manager.resource_monitor = PluginResourceMonitor(self.cache_manager)
logger.info("Plugin health tracking and resource monitoring enabled")
except Exception as e:
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
# Validate plugins after plugin manager is created # Validate plugins after plugin manager is created
try: try:
from src.startup_validator import StartupValidator from src.startup_validator import StartupValidator
@@ -331,46 +355,9 @@ class DisplayController:
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)", logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
plugin_id, result['load_time'], loaded_count, enabled_count) plugin_id, result['load_time'], loaded_count, enabled_count)
# Get plugin instance and manifest # Register the loaded plugin's modes, config subscription
plugin_instance = self.plugin_manager.get_plugin(plugin_id) # and dispatch maps (shared with live enable hot-reload).
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {}) self._register_loaded_plugin(plugin_id)
# Prefer plugin's modes attribute if available (dynamic based on enabled leagues)
# Fall back to manifest display_modes if plugin doesn't provide modes
if plugin_instance and hasattr(plugin_instance, 'modes') and plugin_instance.modes:
display_modes = list(plugin_instance.modes)
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
else:
display_modes = manifest.get('display_modes', [plugin_id])
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
if isinstance(display_modes, list) and display_modes:
self.plugin_display_modes[plugin_id] = list(display_modes)
else:
display_modes = [plugin_id]
self.plugin_display_modes[plugin_id] = list(display_modes)
# Subscribe plugin to config changes for hot-reload
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
"""Callback for plugin config changes."""
try:
plugin_instance.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", plugin_id)
except Exception as e:
logger.error("Error in plugin %s config change handler: %s", plugin_id, e, exc_info=True)
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
logger.debug("Subscribed plugin %s to config changes", plugin_id)
# Add plugin modes to available modes
for mode in display_modes:
self.available_modes.append(mode)
self.plugin_modes[mode] = plugin_instance
self.mode_to_plugin_id[mode] = plugin_id
logger.debug(" Added mode: %s", mode)
# Invalidate signature cache so the new instance is re-inspected
self._plugin_accepts_display_mode.pop(plugin_id, None)
# Show progress # Show progress
progress_pct = int((loaded_count / enabled_count) * 100) progress_pct = int((loaded_count / enabled_count) * 100)
@@ -447,6 +434,10 @@ class DisplayController:
# when the user saves settings via the web UI. # when the user saves settings via the web UI.
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None: def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
self._refresh_config_cache(new_config) self._refresh_config_cache(new_config)
# If a plugin was enabled/disabled, flag a reconcile for the main
# loop to apply (loading/unloading off the watcher thread is unsafe).
if self._enabled_set_changed(old_config, new_config):
self._pending_plugin_reconcile = True
self.config_service.subscribe(_controller_config_change) self.config_service.subscribe(_controller_config_change)
@@ -1505,37 +1496,76 @@ class DisplayController:
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode) logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
self._live_resume_index = None self._live_resume_index = None
def _check_live_priority(self): def _collect_live_modes(self):
""" """Return every currently live-priority mode, in registration order.
Check all plugins for live priority content.
Returns the mode that should be displayed if live content is found, None otherwise. Scans all registered plugin modes; for each plugin that has live
priority *and* live content, collects the specific live mode(s) it
reports via get_live_modes() (only those actually registered), falling
back to the scanned mode name when it ends in '_live'. Deduplicated,
preserving order. A plugin registered under several mode keys (the
sports plugins register one per league) contributes each live mode once.
""" """
live = []
seen = set()
for mode_name, plugin_instance in self.plugin_modes.items(): for mode_name, plugin_instance in self.plugin_modes.items():
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'): if not (hasattr(plugin_instance, 'has_live_priority')
and hasattr(plugin_instance, 'has_live_content')):
continue
try: try:
if plugin_instance.has_live_priority() and plugin_instance.has_live_content(): if not (plugin_instance.has_live_priority()
# Get the specific live mode from the plugin if available and plugin_instance.has_live_content()):
continue
resolved = []
if hasattr(plugin_instance, 'get_live_modes'): if hasattr(plugin_instance, 'get_live_modes'):
live_modes = plugin_instance.get_live_modes() for suggested_mode in (plugin_instance.get_live_modes() or []):
if live_modes and len(live_modes) > 0:
# Verify the mode actually exists before returning it
for suggested_mode in live_modes:
if suggested_mode in self.plugin_modes: if suggested_mode in self.plugin_modes:
return suggested_mode resolved.append(suggested_mode)
# If suggested modes don't exist, fall through to check current mode if not resolved and mode_name.endswith('_live'):
# Fallback: if this mode ends with _live, return it resolved.append(mode_name)
if mode_name.endswith('_live'): for m in resolved:
return mode_name if m not in seen:
seen.add(m)
live.append(m)
except Exception as e: except Exception as e:
logger.warning("Error checking live priority for %s: %s", mode_name, e) logger.warning("Error checking live priority for %s: %s", mode_name, e)
return live
def _check_live_priority(self, advance=False):
"""Return the live-priority mode to display, or None if nothing is live.
When several plugins report live content at once (e.g. a baseball game
and a soccer match), this round-robins between them so the display
alternates each dwell instead of pinning to whichever plugin is first in
registration order.
advance=False (default): a non-advancing peek — returns the live mode
already on screen if it is still live, otherwise the first live mode.
Used by the Vegas coordinator and the vegas-active check, which only
need to know whether *any* game is live (and must not spin the cursor).
advance=True: the rotation pick — returns the live mode *after* the one
currently shown, so each dwell advances to the next live game. The
currently-displayed mode is the cursor, so this stays correct as games
start and end (no separate index to keep in sync).
"""
live_modes = self._collect_live_modes()
if not live_modes:
return None return None
if self.current_display_mode in live_modes:
if advance:
idx = live_modes.index(self.current_display_mode)
return live_modes[(idx + 1) % len(live_modes)]
return self.current_display_mode
return live_modes[0]
def run(self): def run(self):
"""Run the display controller, switching between displays.""" """Run the display controller, switching between displays."""
if not self.available_modes: if not self.available_modes:
logger.warning("No display modes are enabled. Exiting.") logger.warning(
self.display_manager.cleanup() "No display modes are enabled at startup; idling until a "
return "plugin is enabled via the web UI."
)
try: try:
# Initialize with cached data for fast startup - let background updates refresh naturally # Initialize with cached data for fast startup - let background updates refresh naturally
@@ -1544,6 +1574,25 @@ class DisplayController:
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})") logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
while True: while True:
# Apply plugin enable/disable edits saved via the web UI. The
# config-watcher thread only sets the flag; loading/unloading and
# rebuilding available_modes happens here on the render thread so
# it can't race with rendering. Deferred while on-demand is active
# (the flag stays set) so we don't fight its temporary-enable.
if self._pending_plugin_reconcile and not self.on_demand_active:
# Only clear the flag on success -- a retryable failure
# (e.g. discovery) leaves it set so the request isn't lost.
if self._reconcile_enabled_plugins():
self._pending_plugin_reconcile = False
if not self.available_modes:
# Nothing to render yet. Re-check _pending_plugin_reconcile
# every ~1s (rather than a long sleep) so enabling a plugin
# via the web UI is picked up about as promptly as it would
# be once modes exist and the loop is iterating per-frame.
self._sleep_with_plugin_updates(1)
continue
# Handle on-demand commands before rendering # Handle on-demand commands before rendering
self._poll_on_demand_requests() self._poll_on_demand_requests()
self._check_on_demand_expiration() self._check_on_demand_expiration()
@@ -1689,9 +1738,11 @@ class DisplayController:
# Display failed, clear the status and continue normally # Display failed, clear the status and continue normally
wifi_status_data = None wifi_status_data = None
# Check for live priority content and switch to it immediately # Check for live priority content and switch to it immediately.
# advance=True so multiple simultaneously-live games take turns
# (round-robin) instead of pinning to the first plugin.
if not self.on_demand_active and not wifi_status_data: if not self.on_demand_active and not wifi_status_data:
live_priority_mode = self._check_live_priority() live_priority_mode = self._check_live_priority(advance=True)
self._apply_live_priority(live_priority_mode) self._apply_live_priority(live_priority_mode)
# Vegas scroll mode - continuous ticker across all plugins # Vegas scroll mode - continuous ticker across all plugins
@@ -2186,6 +2237,23 @@ class DisplayController:
loop_completed = True loop_completed = True
break break
# LOAD-BEARING: if current_display_mode changed mid-loop (on-demand
# activation, live priority, etc.), restart the main loop now instead
# of falling into the "honour minimum duration" sleep below. That sleep
# can run for up to the *previous* mode's full display_duration (default
# 30s) and doesn't poll on-demand requests or re-check the mode, so a
# freshly-requested mode switch would sit invisible for up to 30s — or
# get clobbered by a queued stop request — before ever rendering.
#
# This guard was added in #298 (live priority interrupting long display
# durations) and was accidentally dropped in #330 as collateral damage of
# an unrelated time.monotonic() -> time.time() cleanup in the same hunk.
# Removing it again will silently reintroduce both issues. _activate_on_demand
# already sets force_change=True and clears the display, so the next loop
# iteration renders the new mode immediately.
if self.current_display_mode != active_mode:
continue
# Ensure we honour minimum duration when not dynamic and loop ended early # Ensure we honour minimum duration when not dynamic and loop ended early
if ( if (
not dynamic_enabled not dynamic_enabled
@@ -2262,7 +2330,7 @@ class DisplayController:
except Exception as e: except Exception as e:
logger.warning("Error checking live priority for %s: %s", active_mode, e) logger.warning("Error checking live priority for %s: %s", active_mode, e)
if should_rotate: if should_rotate and self.available_modes:
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes) self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index] self.current_display_mode = self.available_modes[self.current_mode_index]
self.last_mode_change = time.time() self.last_mode_change = time.time()
@@ -2450,6 +2518,176 @@ class DisplayController:
self.wifi_status_active = False self.wifi_status_active = False
self.wifi_status_expires_at = None self.wifi_status_expires_at = None
def _register_loaded_plugin(self, plugin_id: str) -> List[str]:
"""Register an already-loaded plugin's display modes, config-change
subscription and dispatch maps with the controller.
Shared by startup loading and live enable hot-reload so both paths
build identical controller state. Returns the registered modes.
"""
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
# Prefer the plugin's dynamic modes attribute (e.g. based on enabled
# leagues), else fall back to manifest display_modes, else the id.
if plugin_instance is not None and getattr(plugin_instance, 'modes', None):
display_modes = list(plugin_instance.modes)
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
else:
display_modes = manifest.get('display_modes', [plugin_id])
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
if not (isinstance(display_modes, list) and display_modes):
display_modes = [plugin_id]
self.plugin_display_modes[plugin_id] = list(display_modes)
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
# and instance as defaults so each plugin's callback targets its own
# instance (avoids late-binding when registering many plugins), and
# remember the callback so we can unsubscribe on disable.
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any],
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
"""Callback for plugin config changes."""
try:
_plugin.on_config_change(new_config)
logger.debug("Plugin %s notified of config change", _pid)
except Exception as e:
logger.error("Error in plugin %s config change handler: %s", _pid, e, exc_info=True)
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
self._plugin_config_callbacks[plugin_id] = config_change_callback
logger.debug("Subscribed plugin %s to config changes", plugin_id)
# Add modes to the dispatch maps.
for mode in display_modes:
if mode not in self.available_modes:
self.available_modes.append(mode)
self.plugin_modes[mode] = plugin_instance
self.mode_to_plugin_id[mode] = plugin_id
logger.debug(" Added mode: %s", mode)
# Invalidate signature cache so the new instance is re-inspected.
self._plugin_accepts_display_mode.pop(plugin_id, None)
return display_modes
def _unregister_plugin(self, plugin_id: str) -> None:
"""Remove a plugin's modes, config subscription and instance, then
unload it. Used by live disable hot-reload."""
modes = self.plugin_display_modes.pop(plugin_id, [])
for mode in modes:
if mode in self.available_modes:
self.available_modes.remove(mode)
self.plugin_modes.pop(mode, None)
self.mode_to_plugin_id.pop(mode, None)
# Unsubscribe the plugin's config-change callback. Pop only on a
# successful unsubscribe -- if it raises, keep our reference so a
# later retry (or at least cleanup) still has the real callback
# instead of a lost one.
callback = self._plugin_config_callbacks.get(plugin_id)
if callback is not None and hasattr(self, 'config_service'):
try:
self.config_service.unsubscribe(callback, plugin_id=plugin_id)
except Exception as e:
logger.debug("Error unsubscribing plugin %s from config changes: %s", plugin_id, e)
else:
self._plugin_config_callbacks.pop(plugin_id, None)
else:
self._plugin_config_callbacks.pop(plugin_id, None)
self._plugin_accepts_display_mode.pop(plugin_id, None)
# Tear down the instance (cleanup + on_disable + module unload).
try:
self.plugin_manager.unload_plugin(plugin_id)
except Exception as e:
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
"""True if any top-level section's ``enabled`` flag differs between two
configs. A cheap watcher-thread check that gates the full reconcile.
Non-plugin sections (e.g. schedule) may match too; the reconcile
no-ops for anything that isn't a discovered plugin."""
def enabled_map(cfg: Dict[str, Any]) -> Dict[str, bool]:
return {
key: bool(value.get('enabled', False))
for key, value in cfg.items()
if isinstance(value, dict)
}
return enabled_map(old_config) != enabled_map(new_config)
def _reconcile_enabled_plugins(self) -> bool:
"""Load/unload plugins so the running set matches the enabled set in
config. Runs on the main display thread (never the config-watcher
thread) so mutating available_modes is race-free against rendering.
Returns True if reconciliation completed (including a no-op), or
False on a retryable failure -- the caller keeps the pending-reconcile
flag set in that case so the request isn't silently dropped."""
if self.plugin_manager is None:
return True
try:
config = self.config_service.get_config()
except Exception as e:
logger.warning("Plugin reconcile: falling back to cached config: %s", e)
config = self.config
try:
discovered = set(self.plugin_manager.discover_plugins())
except Exception as e:
logger.error("Plugin reconcile: discovery failed: %s", e, exc_info=True)
return False
for p in discovered:
if p in config and not isinstance(config.get(p), dict):
logger.warning(
"Plugin reconcile: config for %s is a %s, not a dict; treating as disabled",
p, type(config.get(p)).__name__
)
desired = {
p for p in discovered
if isinstance(config.get(p), dict) and config.get(p, {}).get('enabled', False)
}
current = set(self.plugin_display_modes.keys())
to_add = desired - current
to_remove = current - desired
if not to_add and not to_remove:
return True
previous_mode = self.current_display_mode
for plugin_id in to_remove:
self._unregister_plugin(plugin_id)
for plugin_id in to_add:
try:
if self.plugin_manager.load_plugin(plugin_id):
modes = self._register_loaded_plugin(plugin_id)
logger.info("Enabled plugin %s live (modes: %s)", plugin_id, modes)
else:
logger.warning("Plugin reconcile: failed to load %s", plugin_id)
except Exception as e:
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
self._resync_mode_index_after_change(previous_mode)
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes))
return True
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
"""Clamp rotation state after available_modes changed. Stays on the
previous mode if it survived, otherwise restarts cleanly within range."""
if not self.available_modes:
self.current_mode_index = 0
self.current_display_mode = None
return
if previous_mode in self.available_modes:
self.current_mode_index = self.available_modes.index(previous_mode)
else:
self.current_mode_index %= len(self.available_modes)
self.current_display_mode = self.available_modes[self.current_mode_index]
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None: def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
"""Refresh all config-derived caches when a hot-reload fires. """Refresh all config-derived caches when a hot-reload fires.
+186 -11
View File
@@ -33,7 +33,8 @@ else:
from contextlib import contextmanager from contextlib import contextmanager
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import time import time
from typing import Dict, Any, List from collections import OrderedDict
from typing import Dict, Any, List, Optional, Tuple
import logging import logging
import math import math
import freetype import freetype
@@ -42,6 +43,106 @@ import freetype
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level logger.setLevel(logging.INFO) # Set to INFO level
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
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`.
"""
__slots__ = ("_logical_height", "_logical_width", "_matrix")
def __init__(self, matrix: RGBMatrix, logical_width: int, logical_height: int) -> None:
object.__setattr__(self, "_matrix", matrix)
object.__setattr__(self, "_logical_width", logical_width)
object.__setattr__(self, "_logical_height", logical_height)
@property
def width(self) -> int:
"""Logical (per-screen) width reported to plugins."""
return self._logical_width
@property
def height(self) -> int:
"""Logical (per-screen) height reported to plugins."""
return self._logical_height
def __getattr__(self, name: str) -> Any:
"""Forward any non-overridden attribute access to the physical matrix.
Reached only when normal lookup fails (i.e. not width/height/_*).
"""
return getattr(object.__getattribute__(self, "_matrix"), name)
def __setattr__(self, name: str, value: Any) -> None:
"""Forward attribute writes (e.g. ``matrix.brightness = 80``) to it."""
setattr(object.__getattribute__(self, "_matrix"), name, value)
def _resolve_double_sided(physical_width: int, physical_height: int,
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Validate the ``display.double_sided`` config against the physical size.
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
feature is enabled and the physical panel divides evenly into ``copies``
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
config is logged and disabled rather than raised — a misconfigured panel
should still light up.
"""
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
return None
copies = ds_config.get('copies', 2)
if not isinstance(copies, int) or copies < 2:
logger.warning(
"double_sided: 'copies' must be an integer >= 2 (got %r); "
"disabling double-sided mode", copies)
return None
axis = ds_config.get('axis', 'horizontal')
if axis not in ('horizontal', 'vertical'):
logger.warning(
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
"(got %r); defaulting to 'horizontal'", axis)
axis = 'horizontal'
# Horizontal splits the chain (panels side by side); vertical splits the
# parallel outputs (panels stacked). The split axis must divide evenly.
if axis == 'horizontal':
if physical_width % copies != 0:
logger.warning(
"double_sided: physical width %d is not divisible by copies "
"%d; disabling double-sided mode", physical_width, copies)
return None
logical_width = physical_width // copies
logical_height = physical_height
else:
if physical_height % copies != 0:
logger.warning(
"double_sided: physical height %d is not divisible by copies "
"%d; disabling double-sided mode", physical_height, copies)
return None
logical_width = physical_width
logical_height = physical_height // copies
logger.info(
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
"tiled across physical %dx%d", copies, axis, logical_width,
logical_height, physical_width, physical_height)
return {
'copies': copies,
'axis': axis,
'logical_width': logical_width,
'logical_height': logical_height,
}
class DisplayManager: class DisplayManager:
""" """
Singleton hardware abstraction layer for the RGB LED matrix. Singleton hardware abstraction layer for the RGB LED matrix.
@@ -76,14 +177,29 @@ class DisplayManager:
self._suppress_test_pattern = suppress_test_pattern self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture) # When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False self._capture_mode_active = False
# Text-width measurement cache: (text, id(font)) -> pixel_width # Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
self._physical_image = None # full-chain buffer reused each frame when tiling
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
# Avoids re-measuring the same string+font on every display() call. # Avoids re-measuring the same string+font on every display() call.
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
# live score) would otherwise grow it forever on a 24/7 service.
# Entries hold a strong reference to the font so its id() can't be
# recycled by a different font object — an id-keyed cache without
# the reference can return the WRONG width after garbage collection.
# Cleared on _load_fonts() so stale entries don't survive a font reload. # Cleared on _load_fonts() so stale entries don't survive a font reload.
self._text_width_cache: Dict[tuple, int] = {} self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
self._TEXT_WIDTH_CACHE_MAX = 1024
# Snapshot settings for web preview integration (service writes, web reads) # Snapshot settings for web preview integration (service writes, web reads)
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
self._snapshot_min_interval_sec = 0.2 # max ~5 fps self._snapshot_min_interval_sec = 0.2 # max ~5 fps
self._last_snapshot_ts = 0.0 self._last_snapshot_ts = 0.0
# Snapshot failures are logged as warnings, rate-limited so a
# persistent failure (e.g. an unwritable file) can't spam the log —
# but is never silent: the snapshot's mtime doubles as the web UI's
# hardware-liveness signal, so a quiet failure makes health checks lie.
self._snapshot_fail_log_ts = 0.0
# Scrolling state tracking for graceful updates # Scrolling state tracking for graceful updates
self._scrolling_state = { self._scrolling_state = {
@@ -169,12 +285,25 @@ class DisplayManager:
self.matrix = RGBMatrix(options=options) self.matrix = RGBMatrix(options=options)
logger.info("RGB Matrix initialized successfully") logger.info("RGB Matrix initialized successfully")
# Create double buffer for smooth updates # Create double buffer for smooth updates. The canvases are always
# full physical size — they back the real chain regardless of mode.
self.offscreen_canvas = self.matrix.CreateFrameCanvas() self.offscreen_canvas = self.matrix.CreateFrameCanvas()
self.current_canvas = self.matrix.CreateFrameCanvas() self.current_canvas = self.matrix.CreateFrameCanvas()
logger.info("Frame canvases created successfully") logger.info("Frame canvases created successfully")
# Create image with full chain width # Double-sided mode: wrap the physical matrix so plugins see the
# logical (per-screen) size, and keep a full-chain buffer to tile
# the rendered screen into once per frame.
ds_config = self.config.get('display', {}).get('double_sided', {})
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
self._double_sided = ds
if ds is not None:
self._physical_image = Image.new(
'RGB', (self.matrix.width, self.matrix.height))
self.matrix = _LogicalMatrix(
self.matrix, ds['logical_width'], ds['logical_height'])
# Create image with the (logical) display dimensions
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
@@ -201,8 +330,16 @@ class DisplayManager:
rows = int(hardware_config.get('rows', 32)) rows = int(hardware_config.get('rows', 32))
cols = int(hardware_config.get('cols', 64)) cols = int(hardware_config.get('cols', 64))
chain_length = int(hardware_config.get('chain_length', 2)) chain_length = int(hardware_config.get('chain_length', 2))
parallel = int(hardware_config.get('parallel', 1))
fallback_width = max(1, cols * chain_length) fallback_width = max(1, cols * chain_length)
fallback_height = max(1, rows) fallback_height = max(1, rows * parallel)
# Mirror double-sided in fallback so the preview shows one screen.
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds
if ds is not None:
fallback_width = ds['logical_width']
fallback_height = ds['logical_height']
except Exception: except Exception:
fallback_width, fallback_height = 128, 32 fallback_width, fallback_height = 128, 32
@@ -364,6 +501,25 @@ class DisplayManager:
finally: finally:
self._capture_mode_active = False self._capture_mode_active = False
def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain.
Renders once into ``self._physical_image`` by pasting the rendered
logical image ``copies`` times along the configured axis. The paste is
a single memcpy per copy, so the per-frame cost is negligible and the
plugin render path is untouched.
"""
ds = self._double_sided
phys = self._physical_image
lw = ds['logical_width']
lh = ds['logical_height']
for i in range(ds['copies']):
if ds['axis'] == 'vertical':
phys.paste(self.image, (0, i * lh))
else:
phys.paste(self.image, (i * lw, 0))
return phys
def update_display(self): def update_display(self):
"""Update the display using double buffering with proper sync.""" """Update the display using double buffering with proper sync."""
try: try:
@@ -377,7 +533,11 @@ class DisplayManager:
if self._capture_mode_active: if self._capture_mode_active:
return # Skip hardware write — content is being captured off-screen return # Skip hardware write — content is being captured off-screen
# Copy the current image to the offscreen canvas # Copy the current image to the offscreen canvas. In double-sided
# mode the logical screen is first tiled across the full chain.
if self._double_sided is not None:
self.offscreen_canvas.SetImage(self._composite_double_sided())
else:
self.offscreen_canvas.SetImage(self.image) self.offscreen_canvas.SetImage(self.image)
# Swap buffers immediately # Swap buffers immediately
@@ -551,12 +711,15 @@ class DisplayManager:
Results are cached by (text, font identity) so plugins that measure Results are cached by (text, font identity) so plugins that measure
the same string every frame (e.g. to centre a score) pay only one the same string every frame (e.g. to centre a score) pay only one
measurement per unique (text, font) pair. measurement per unique (text, font) pair. The entry keeps the font
alive so its id() can't be recycled, and the cache is LRU-bounded so
ever-changing text (clocks, tickers) can't grow it without limit.
""" """
cache_key = (text, id(font)) cache_key = (text, id(font))
cached = self._text_width_cache.get(cache_key) cached = self._text_width_cache.get(cache_key)
if cached is not None: if cached is not None:
return cached self._text_width_cache.move_to_end(cache_key)
return cached[0]
try: try:
if isinstance(font, freetype.Face): if isinstance(font, freetype.Face):
@@ -571,7 +734,9 @@ class DisplayManager:
logger.error("Error getting text width: %s", e) logger.error("Error getting text width: %s", e)
return 0 return 0
self._text_width_cache[cache_key] = width self._text_width_cache[cache_key] = (width, font)
while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX:
self._text_width_cache.popitem(last=False)
return width return width
def get_font_height(self, font): def get_font_height(self, font):
@@ -1016,5 +1181,15 @@ class DisplayManager:
pass pass
self._last_snapshot_ts = now self._last_snapshot_ts = now
except Exception as e: except Exception as e:
# Snapshot failures should never break display; log at debug to avoid noise # Snapshot failures must never break display — but they must not
# be silent either: the snapshot's mtime is the web UI's display
# mirror AND its hardware-liveness proxy, so a quietly failing
# write freezes the mirror and makes health checks lie (seen in
# the field: a stale root-owned /tmp file froze it for a day).
# Warn at most once per 5 minutes to avoid log spam.
if (now - self._snapshot_fail_log_ts) > 300:
self._snapshot_fail_log_ts = now
logger.warning("Snapshot write failing (web preview/health "
"mirror is stale): %s", e)
else:
logger.debug(f"Snapshot write skipped: {e}") logger.debug(f"Snapshot write skipped: {e}")
+18 -5
View File
@@ -35,6 +35,7 @@ import urllib.request
import zipfile import zipfile
import tempfile import tempfile
import time import time
from collections import OrderedDict
from pathlib import Path from pathlib import Path
from PIL import ImageFont from PIL import ImageFont
from typing import Dict, Tuple, Optional, Union, Any, List from typing import Dict, Tuple, Optional, Union, Any, List
@@ -58,7 +59,13 @@ class FontManager:
# Font discovery and catalog # Font discovery and catalog
self.font_catalog: Dict[str, str] = {} # family_name -> file_path self.font_catalog: Dict[str, str] = {} # family_name -> file_path
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline) # (text, id(font)) -> ((width, height, baseline), font_ref).
# LRU-bounded — keys embed the measured TEXT, so changing strings
# (clocks, live scores) would otherwise grow it forever. Entries
# keep the font alive so its id() can't be recycled by a different
# font object (which would silently return wrong metrics).
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
self._METRICS_CACHE_MAX = 1024
# Plugin font management # Plugin font management
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
@@ -507,10 +514,14 @@ class FontManager:
Returns: Returns:
Tuple of (width, height, baseline_offset) Tuple of (width, height, baseline_offset)
""" """
cache_key = f"{hash(text)}_{id(font)}" # Key on the text itself (hash(text) could collide) + font identity;
# the entry below keeps the font referenced so the id stays valid.
cache_key = (text, id(font))
if cache_key in self.metrics_cache: cached = self.metrics_cache.get(cache_key)
return self.metrics_cache[cache_key] if cached is not None:
self.metrics_cache.move_to_end(cache_key)
return cached[0]
try: try:
if isinstance(font, freetype.Face): if isinstance(font, freetype.Face):
@@ -547,7 +558,9 @@ class FontManager:
baseline = 10 baseline = 10
result = (width, height, baseline) result = (width, height, baseline)
self.metrics_cache[cache_key] = result self.metrics_cache[cache_key] = (result, font)
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
self.metrics_cache.popitem(last=False)
return result return result
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int: def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
+52 -10
View File
@@ -52,10 +52,17 @@ class PluginHealthTracker:
"""Get cache key for plugin health data.""" """Get cache key for plugin health data."""
return f"plugin_health:{plugin_id}" return f"plugin_health:{plugin_id}"
def _load_health_state(self, plugin_id: str) -> Dict[str, Any]: def _load_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Load health state from cache or return defaults.""" """Load health state from cache or return defaults.
``force_reload=True`` bypasses the cache manager's in-memory tier so a
read-only consumer (e.g. the web process) observes the writer process's
latest persisted state instead of a stale first snapshot.
"""
cache_key = self._get_health_key(plugin_id) cache_key = self._get_health_key(plugin_id)
cached = self.cache_manager.get(cache_key, max_age=None) cached = self.cache_manager.get(
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
if cached: if cached:
return cached return cached
@@ -79,10 +86,17 @@ class PluginHealthTracker:
self.cache_manager.set(cache_key, state) # Persist indefinitely self.cache_manager.set(cache_key, state) # Persist indefinitely
self._health_state[plugin_id] = state self._health_state[plugin_id] = state
def get_health_state(self, plugin_id: str) -> Dict[str, Any]: def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get current health state for a plugin.""" """Get current health state for a plugin.
if plugin_id not in self._health_state:
self._health_state[plugin_id] = self._load_health_state(plugin_id) ``force_reload=True`` re-reads the persisted state from the cache,
bypassing the in-memory copy needed by cross-process readers that
would otherwise be pinned to the first snapshot they loaded.
"""
if force_reload or plugin_id not in self._health_state:
self._health_state[plugin_id] = self._load_health_state(
plugin_id, force_reload=force_reload
)
return self._health_state[plugin_id] return self._health_state[plugin_id]
def record_success(self, plugin_id: str) -> None: def record_success(self, plugin_id: str) -> None:
@@ -139,6 +153,28 @@ class PluginHealthTracker:
self._save_health_state(plugin_id, state) self._save_health_state(plugin_id, state)
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
Used for non-fatal issues e.g. a config that no longer satisfies the
plugin's schema — that should be surfaced to the user but must NOT cause
the plugin to be skipped or counted as a runtime failure. Passing
``reason=None`` clears the flag. The write is skipped when nothing
actually changes, so calling this on every load is cheap.
Args:
plugin_id: Plugin identifier
reason: Human-readable reason string, or None to clear the flag
"""
state = self.get_health_state(plugin_id)
new_degraded = bool(reason)
new_reason = reason if reason else None
if state.get('degraded', False) == new_degraded and state.get('degraded_reason') == new_reason:
return # No change — avoid a redundant cache write
state['degraded'] = new_degraded
state['degraded_reason'] = new_reason
self._save_health_state(plugin_id, state)
def should_skip_plugin(self, plugin_id: str) -> bool: def should_skip_plugin(self, plugin_id: str) -> bool:
""" """
Check if plugin should be skipped due to circuit breaker. Check if plugin should be skipped due to circuit breaker.
@@ -181,9 +217,13 @@ class PluginHealthTracker:
return False return False
def get_health_summary(self, plugin_id: str) -> Dict[str, Any]: def get_health_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get health summary for a plugin.""" """Get health summary for a plugin.
state = self.get_health_state(plugin_id)
``force_reload=True`` refreshes from the persisted cache first so
cross-process readers reflect the writer's latest state.
"""
state = self.get_health_state(plugin_id, force_reload=force_reload)
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0) total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
success_rate = 0.0 success_rate = 0.0
@@ -201,6 +241,8 @@ class PluginHealthTracker:
'last_failure_time': state.get('last_failure_time'), 'last_failure_time': state.get('last_failure_time'),
'last_error': state.get('last_error'), 'last_error': state.get('last_error'),
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value, 'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
'degraded': state.get('degraded', False),
'degraded_reason': state.get('degraded_reason'),
'circuit_opened_time': state.get('circuit_opened_time'), 'circuit_opened_time': state.get('circuit_opened_time'),
'half_open_start_time': state.get('half_open_start_time') 'half_open_start_time': state.get('half_open_start_time')
} }
+171 -64
View File
@@ -5,10 +5,10 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns. Extracted from PluginManager to improve separation of concerns.
""" """
import hashlib
import json
import importlib import importlib
import importlib.metadata
import importlib.util import importlib.util
import json
import os import os
import sys import sys
import subprocess import subprocess
@@ -17,12 +17,101 @@ from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Type from typing import Dict, Any, Optional, Tuple, Type
import logging import logging
from packaging.requirements import InvalidRequirement, Requirement
from src.exceptions import PluginError from src.exceptions import PluginError
from src.logging_config import get_logger from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_file_permissions,
get_plugin_file_mode def requirements_has_real_deps(requirements_file: str) -> bool:
) """
Check whether a requirements.txt actually specifies anything to install.
Plugins that ship all their dependencies with LEDMatrix core often keep a
requirements.txt where every line is commented out, for documentation
purposes only. Running pip against such a file still pays the full
subprocess/resolver cost for zero effect, so callers should skip the
install step entirely when this returns False.
"""
try:
with open(requirements_file, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if line and not line.startswith('#'):
return True
except OSError:
# Let the caller's own file handling report the error.
return True
return False
def requirements_are_satisfied(requirements_file: str) -> bool:
"""
Check whether every real requirement line in requirements.txt is already
satisfied by packages installed in the current interpreter.
This replaces marker-file tracking with a direct fact check, so it's
immune to stale/missing/corrupted markers: it looks at what's actually
importable right now rather than trusting a hash comparison from a
previous run. Anything ambiguous (pip options, unparseable lines,
extras, unresolvable versions) conservatively returns False so the
caller falls through to running pip this check only ever saves work,
never masks a real install.
"""
try:
with open(requirements_file, 'r', encoding='utf-8') as fh:
lines = fh.readlines()
except OSError:
return False
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('-'):
return False # pip option (-r, --index-url, ...), can't verify
try:
req = Requirement(line)
except InvalidRequirement:
return False
if req.extras:
return False # verifying extras' sub-dependencies isn't worth it here
if req.marker is not None and not req.marker.evaluate():
continue # not applicable on this platform/interpreter
try:
installed_version = importlib.metadata.version(req.name)
except importlib.metadata.PackageNotFoundError:
return False
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
return False
return True
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
Used as a containment check for a directory name derived from untrusted
input (a manifest-declared plugin id, an externally-supplied plugin
path): the returned value always comes from enumerating trusted_dir
itself via os.scandir(), so a caller that builds a path by joining
trusted_dir with this return value is joining against a name the
filesystem produced under a trusted root -- not the caller's original
string, which could otherwise smuggle a traversal sequence through.
"""
try:
with os.scandir(trusted_dir) as entries:
for entry in entries:
if entry.name == name and entry.is_dir():
return entry.name
except OSError:
pass
return None
class PluginLoader: class PluginLoader:
@@ -139,7 +228,7 @@ class PluginLoader:
self, self,
plugin_dir: Path, plugin_dir: Path,
plugin_id: str, plugin_id: str,
plugins_dir: Optional[Path] = None, plugins_dir: Path,
timeout: int = 300 timeout: int = 300
) -> bool: ) -> bool:
""" """
@@ -148,7 +237,12 @@ class PluginLoader:
Args: Args:
plugin_dir: Plugin directory path plugin_dir: Plugin directory path
plugin_id: Plugin identifier plugin_id: Plugin identifier
plugins_dir: Trusted base plugins directory for path containment check plugins_dir: Trusted base plugins directory for path containment check.
Required (not optional) so every caller reconstructs the plugin
path through the sanitiser below rather than trusting plugin_dir
directly -- CodeQL's path-injection query (and a malicious
manifest/plugin_id in practice) can't tell a legitimate
plugin_dir from one crafted to traverse outside plugins_dir.
timeout: Installation timeout in seconds timeout: Installation timeout in seconds
Returns: Returns:
@@ -160,59 +254,42 @@ class PluginLoader:
# Resolve to a canonical absolute path (normalises .. and symlinks) # Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir)) plugin_dir_real = os.path.realpath(str(plugin_dir))
if plugins_dir is not None:
# Reconstruct the plugin path from a trusted base + a sanitised
# directory name. os.path.basename() is CodeQL's recognised
# py/path-injection sanitiser: it strips all directory components
# so the result cannot contain traversal sequences. Joining it
# with the resolved, trusted plugins_dir produces a path that
# CodeQL considers untainted.
plugins_dir_real = os.path.realpath(str(plugins_dir)) plugins_dir_real = os.path.realpath(str(plugins_dir))
safe_dir_name = os.path.basename(plugin_dir_real) requested_name = os.path.basename(plugin_dir_real)
if not safe_dir_name:
self.logger.error("Could not determine plugin directory name for %s", plugin_id) # Match the requested directory against an entry actually enumerated
return False # from the trusted plugins_dir, and build the path from that entry --
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name) # not from requested_name. A name that came out of os.scandir() on a
if not os.path.isdir(safe_plugin_dir): # trusted root carries no taint regardless of what the caller asked
# for, so this is a real containment guarantee (an allowlist check
# against a trusted source), not a string-sanitisation of untrusted
# input that a static analyzer has to trust blindly.
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
if matched_name is None:
self.logger.error( self.logger.error(
"Plugin directory for %s not found inside plugins dir", plugin_id "Plugin directory for %s not found inside plugins dir", plugin_id
) )
return False return False
else:
safe_plugin_dir = plugin_dir_real
if not os.path.isdir(safe_plugin_dir):
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
return False
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt") requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
if not os.path.isfile(requirements_file): if not os.path.isfile(requirements_file):
return True # No dependencies needed return True # No dependencies needed
try: if not requirements_has_real_deps(requirements_file):
with open(requirements_file, 'rb') as fh: self.logger.debug(
current_hash = hashlib.sha256(fh.read()).hexdigest() "requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
except OSError as e: plugin_id
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e) )
return False return True
# Skip if requirements.txt hasn't changed since last install if requirements_are_satisfied(requirements_file):
if os.path.isfile(marker_file): self.logger.debug(
try: "Dependencies for %s already satisfied in current environment, skipping pip",
with open(marker_file, 'r', encoding='utf-8') as fh: plugin_id
stored_hash = fh.read().strip()
except OSError as e:
self.logger.warning(
"Could not read dependency marker for %s (%s), will reinstall dependencies",
plugin_id, e
) )
else:
if stored_hash == current_hash:
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
return True return True
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
try: try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id) self.logger.info("Installing dependencies for plugin %s...", plugin_id)
@@ -225,32 +302,54 @@ class PluginLoader:
) )
if result.returncode == 0: if result.returncode == 0:
try:
with open(marker_file, 'w', encoding='utf-8') as fh:
fh.write(current_hash)
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
self.logger.info("Dependencies installed successfully for %s", plugin_id) self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True return True
else: else:
stderr = result.stderr or "" stderr = result.stderr or ""
# uninstall-no-record-file means the package is already present at the # uninstall-no-record-file means a system-managed copy of a package
# system level (e.g. installed via dnf/apt without a pip RECORD file). # (e.g. apt's python3-requests, which ships no pip RECORD file) is in
# pip can't replace it, but it IS installed — write the marker so we # the way of the version this requirements.txt pins. Retry with
# don't retry on every restart. # --ignore-installed so pip lays the pinned version down alongside
# the system copy instead of trying to replace it — matching the
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
# Without this retry, the plugin would silently keep running against
# whatever version the system happened to ship.
if "uninstall-no-record-file" in stderr: if "uninstall-no-record-file" in stderr:
self.logger.warning( self.logger.warning(
"Dependencies for %s include system-managed packages (no pip RECORD). " "Dependencies for %s conflict with a system-managed package "
"Assuming they are satisfied: %s", "(no pip RECORD); retrying with --ignore-installed: %s",
plugin_id, stderr.strip() plugin_id, stderr.strip()
) )
# Wrapped in its own try/except so a retry timeout is
# tolerated the same way as a retry failure, instead of
# propagating to the outer handler and returning False
# (which would contradict the "assume satisfied" fallback
# below).
try: try:
with open(marker_file, 'w', encoding='utf-8') as fh: # sys.executable is this process's own interpreter (not
fh.write(current_hash) # attacker-influenced), and requirements_file is a path
ensure_file_permissions(Path(marker_file), get_plugin_file_mode()) # built internally by find_plugin_directory, never raw
except OSError as marker_err: # external input.
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err) retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
"--ignore-installed", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if retry_result.returncode != 0:
self.logger.warning(
"Retry with --ignore-installed also failed for %s; assuming the "
"system-managed version satisfies the requirement: %s",
plugin_id, (retry_result.stderr or "").strip()
)
except subprocess.TimeoutExpired:
self.logger.warning(
"Retry with --ignore-installed timed out for %s; assuming the "
"system-managed version satisfies the requirement",
plugin_id
)
return True return True
self.logger.warning( self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s", "Dependency installation returned non-zero exit code for %s: %s",
@@ -618,6 +717,14 @@ class PluginLoader:
""" """
# Install dependencies if needed # Install dependencies if needed
if install_deps: if install_deps:
if plugins_dir is None:
raise PluginError(
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
"(needed for path containment; pass install_deps=False if the caller "
"doesn't have a trusted plugins directory to supply)",
plugin_id=plugin_id,
context={'plugin_dir': str(plugin_dir)},
)
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir): if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
raise PluginError( raise PluginError(
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}", f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
+75 -88
View File
@@ -9,9 +9,9 @@ API Version: 1.0.0
import json import json
import sys import sys
import subprocess
import time import time
import threading import threading
import types
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
import logging import logging
@@ -177,90 +177,6 @@ class PluginManager:
return plugin_ids return plugin_ids
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
"""Get path to dependency installation marker file."""
plugin_dir = self.plugins_dir / plugin_id
if not plugin_dir.exists():
# Try with ledmatrix- prefix
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
return plugin_dir / ".dependencies_installed"
def _check_dependencies_installed(self, plugin_id: str) -> bool:
"""Check if dependencies are already installed for a plugin."""
marker_path = self._get_dependency_marker_path(plugin_id)
return marker_path.exists()
def _mark_dependencies_installed(self, plugin_id: str) -> None:
"""Mark dependencies as installed for a plugin."""
marker_path = self._get_dependency_marker_path(plugin_id)
try:
marker_path.touch()
# Set proper file permissions after creating marker
from src.common.permission_utils import (
ensure_file_permissions,
get_plugin_file_mode
)
ensure_file_permissions(marker_path, get_plugin_file_mode())
except (OSError, PermissionError) as e:
self.logger.warning("Could not create dependency marker for %s: %s", plugin_id, e)
def _remove_dependency_marker(self, plugin_id: str) -> None:
"""Remove dependency installation marker."""
marker_path = self._get_dependency_marker_path(plugin_id)
try:
if marker_path.exists():
marker_path.unlink()
except (OSError, PermissionError) as e:
self.logger.warning("Could not remove dependency marker for %s: %s", plugin_id, e)
def _install_plugin_dependencies(self, requirements_file: Path) -> bool:
"""
Install plugin dependencies from requirements.txt.
Args:
requirements_file: Path to requirements.txt
Returns:
True if installation succeeded or not needed, False on error
"""
try:
self.logger.info("Installing dependencies from %s", requirements_file)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--no-cache-dir", "-r", str(requirements_file)],
capture_output=True,
text=True,
timeout=300,
check=False
)
if result.returncode == 0:
self.logger.info("Dependencies installed successfully")
return True
else:
self.logger.warning("Dependency installation returned non-zero exit code: %s", result.stderr)
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out")
return False
except FileNotFoundError as e:
self.logger.warning("Command not found: %s. Skipping dependency installation", e)
return True
except (BrokenPipeError, OSError) as e:
# Handle broken pipe errors (errno 32) which can occur during pip downloads
# Often caused by network interruptions or output buffer issues
if isinstance(e, OSError) and e.errno == 32:
self.logger.error(
"Broken pipe error during dependency installation. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection."
)
else:
self.logger.error("OS error during dependency installation: %s", e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies: %s", e, exc_info=True)
return True
def load_plugin(self, plugin_id: str) -> bool: def load_plugin(self, plugin_id: str) -> bool:
""" """
Load a plugin by ID. Load a plugin by ID.
@@ -391,6 +307,14 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e) self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False return False
# Schema validation (warn/degrade only — never blocks loading).
# A config that violates the plugin's JSON schema is surfaced to the
# user (log warning + degraded flag in the health tracker) but the
# plugin still loads exactly as it does today. This deliberately does
# NOT change load_plugin()'s pass/fail behaviour for any plugin that
# loads under the current code.
self._validate_config_schema_soft(plugin_id, config)
# Store plugin instance # Store plugin instance
self.plugins[plugin_id] = plugin_instance self.plugins[plugin_id] = plugin_instance
self.plugin_last_update[plugin_id] = 0.0 self.plugin_last_update[plugin_id] = 0.0
@@ -419,6 +343,59 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e) self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False return False
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
"""Validate a plugin's config against its JSON schema — warn/degrade only.
On a schema violation this logs a warning and marks the plugin degraded
in the health tracker (when one is wired), so the problem is visible in
the web UI. It never raises, never changes plugin state, and never
affects whether the plugin loads. ``config`` here has already been
merged with schema defaults by the caller, so fields that ship a default
never appear "missing" only genuinely user-supplied required fields
(e.g. an API key) can trip the required-field check.
"""
try:
schema = self.schema_manager.load_schema(plugin_id)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
return
if not schema:
# No schema shipped — nothing to validate. Clear any stale flag.
self._set_degraded_safe(plugin_id, None)
return
try:
is_valid, errors = self.schema_manager.validate_config_against_schema(
config, schema, plugin_id
)
except Exception as e: # pragma: no cover - defensive
# Validation machinery itself failed — do not penalise the plugin.
self.logger.debug("Schema validation raised for %s: %s", plugin_id, e)
return
if is_valid or not errors:
self._set_degraded_safe(plugin_id, None)
return
summary = "; ".join(errors[:5])
if len(errors) > 5:
summary += f" (+{len(errors) - 5} more)"
self.logger.warning(
"Plugin %s config does not match its schema (loading anyway): %s",
plugin_id, summary,
)
self._set_degraded_safe(plugin_id, f"Config schema: {summary}")
def _set_degraded_safe(self, plugin_id: str, reason: Optional[str]) -> None:
"""Best-effort ``health_tracker.set_degraded`` that never raises."""
if not self.health_tracker:
return
try:
self.health_tracker.set_degraded(plugin_id, reason)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not set degraded flag for %s: %s", plugin_id, e)
def unload_plugin(self, plugin_id: str) -> bool: def unload_plugin(self, plugin_id: str) -> bool:
""" """
Unload a plugin by ID. Unload a plugin by ID.
@@ -767,8 +744,18 @@ class PluginManager:
# If resource monitor exists, wrap the call # If resource monitor exists, wrap the call
def monitored_update(): def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update) self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
# SimpleNamespace stores `update` as an *instance*
# attribute, so attribute lookup returns the plain
# function object as-is. A dynamically-built class
# (`type(..., {'update': monitored_update})`) instead
# stores it as a *class* attribute, which the
# descriptor protocol turns into a bound method on
# access -- silently prepending the instance as an
# implicit first argument to a function that takes
# none, raising "monitored_update() takes 0
# positional arguments but 1 was given" on every call.
success = self.plugin_executor.execute_update( success = self.plugin_executor.execute_update(
type('obj', (object,), {'update': monitored_update})(), types.SimpleNamespace(update=monitored_update),
plugin_id plugin_id
) )
else: else:
@@ -836,7 +823,7 @@ class PluginManager:
# Get health tracker metrics if available # Get health tracker metrics if available
if self.health_tracker: if self.health_tracker:
health_info = self.health_tracker.get_plugin_health(plugin_id) health_info = self.health_tracker.get_health_summary(plugin_id)
plugin_metrics['health'] = health_info plugin_metrics['health'] = health_info
else: else:
plugin_metrics['health'] = {'status': 'unknown'} plugin_metrics['health'] = {'status': 'unknown'}
@@ -861,7 +848,7 @@ class PluginManager:
# Get resource monitor metrics if available # Get resource monitor metrics if available
if self.resource_monitor: if self.resource_monitor:
resource_info = self.resource_monitor.get_plugin_metrics(plugin_id) resource_info = self.resource_monitor.get_metrics_summary(plugin_id)
plugin_metrics['resources'] = resource_info plugin_metrics['resources'] = resource_info
else: else:
plugin_metrics['resources'] = {'status': 'unknown'} plugin_metrics['resources'] = {'status': 'unknown'}
+45 -15
View File
@@ -82,6 +82,21 @@ class PluginResourceMonitor:
# Lock for thread-safe access # Lock for thread-safe access
self._lock = threading.Lock() self._lock = threading.Lock()
# Cache a single psutil.Process handle. Reusing the same handle is what
# lets cpu_percent() be read non-blocking (interval=None): psutil returns
# the utilisation since the *previous* call on that same object. Creating
# a fresh Process() per call would force interval-based sampling that
# blocks the caller — unacceptable on the display loop's update path.
self._process = None
if self.enable_monitoring:
try:
self._process = psutil.Process()
# Prime cpu_percent so the first real measurement returns a
# meaningful delta instead of 0.0.
self._process.cpu_percent(interval=None)
except Exception: # pragma: no cover - psutil edge cases
self._process = None
if not PSUTIL_AVAILABLE and enable_monitoring: if not PSUTIL_AVAILABLE and enable_monitoring:
self.logger.warning( self.logger.warning(
"psutil not available - resource monitoring will be limited to execution time only" "psutil not available - resource monitoring will be limited to execution time only"
@@ -95,13 +110,21 @@ class PluginResourceMonitor:
"""Get cache key for plugin limits.""" """Get cache key for plugin limits."""
return f"plugin_limits:{plugin_id}" return f"plugin_limits:{plugin_id}"
def get_metrics(self, plugin_id: str) -> ResourceMetrics: def get_metrics(self, plugin_id: str, force_reload: bool = False) -> ResourceMetrics:
"""Get current metrics for a plugin.""" """Get current metrics for a plugin.
``force_reload=True`` bypasses both the in-memory copy and the cache
manager's memory tier so a read-only consumer (e.g. the web process)
sees the writer process's latest persisted metrics rather than a stale
first snapshot.
"""
with self._lock: with self._lock:
if plugin_id not in self._metrics: if force_reload or plugin_id not in self._metrics:
# Try to load from cache # Try to load from cache
cache_key = self._get_metrics_key(plugin_id) cache_key = self._get_metrics_key(plugin_id)
cached = self.cache_manager.get(cache_key, max_age=None) cached = self.cache_manager.get(
cache_key, max_age=None, memory_ttl=0 if force_reload else None
)
if cached: if cached:
metrics = ResourceMetrics(**cached) metrics = ResourceMetrics(**cached)
else: else:
@@ -137,21 +160,24 @@ class PluginResourceMonitor:
def _get_process_memory_mb(self) -> float: def _get_process_memory_mb(self) -> float:
"""Get current process memory usage in MB.""" """Get current process memory usage in MB."""
if not self.enable_monitoring: if not self.enable_monitoring or self._process is None:
return 0.0 return 0.0
try: try:
process = psutil.Process() return self._process.memory_info().rss / 1024 / 1024
return process.memory_info().rss / 1024 / 1024
except Exception: except Exception:
return 0.0 return 0.0
def _get_process_cpu_percent(self, interval: float = 0.1) -> float: def _get_process_cpu_percent(self) -> float:
"""Get current process CPU usage percentage.""" """Get current process CPU usage percentage (non-blocking).
if not self.enable_monitoring:
Reads cpu_percent(interval=None) against the cached process handle, so
it returns immediately with the utilisation observed since the previous
call rather than blocking to sample a fresh interval.
"""
if not self.enable_monitoring or self._process is None:
return 0.0 return 0.0
try: try:
process = psutil.Process() return self._process.cpu_percent(interval=None)
return process.cpu_percent(interval=interval)
except Exception: except Exception:
return 0.0 return 0.0
@@ -281,9 +307,13 @@ class PluginResourceMonitor:
self.logger.error(error_msg) self.logger.error(error_msg)
raise ResourceLimitExceeded(error_msg) raise ResourceLimitExceeded(error_msg)
def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]: def get_metrics_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
"""Get metrics summary for a plugin.""" """Get metrics summary for a plugin.
metrics = self.get_metrics(plugin_id)
``force_reload=True`` refreshes from the persisted cache first so
cross-process readers reflect the writer's latest metrics.
"""
metrics = self.get_metrics(plugin_id, force_reload=force_reload)
limits = self.get_limits(plugin_id) limits = self.get_limits(plugin_id)
avg_execution_time = 0.0 avg_execution_time = 0.0
@@ -322,10 +322,19 @@ class StateReconciliation:
and hasattr(self.store_manager, 'was_recently_uninstalled') and hasattr(self.store_manager, 'was_recently_uninstalled')
and self.store_manager.was_recently_uninstalled(plugin_id) and self.store_manager.was_recently_uninstalled(plugin_id)
) )
# Also refuse to resurrect a plugin the user has persistently
# uninstalled. Unlike the in-memory race guard above, this record
# survives restarts, so the user's removal sticks across updates.
persistently_uninstalled = (
self.store_manager is not None
and hasattr(self.store_manager, 'is_plugin_uninstalled')
and self.store_manager.is_plugin_uninstalled(plugin_id)
)
can_repair = ( can_repair = (
self.store_manager is not None self.store_manager is not None
and not previously_unrecoverable and not previously_unrecoverable
and not recently_uninstalled and not recently_uninstalled
and not persistently_uninstalled
) )
inconsistencies.append(Inconsistency( inconsistencies.append(Inconsistency(
plugin_id=plugin_id, plugin_id=plugin_id,
+218 -40
View File
@@ -5,8 +5,8 @@ Handles plugin discovery, installation, updates, and uninstallation
from both the official registry and custom GitHub repositories. from both the official registry and custom GitHub repositories.
""" """
import hashlib
import os import os
import re
import json import json
import stat import stat
import subprocess import subprocess
@@ -19,12 +19,15 @@ import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple from typing import List, Dict, Optional, Any, Tuple, Set
import logging import logging
from urllib.parse import urlparse from urllib.parse import urlparse
from src.common.permission_utils import sudo_remove_directory from src.common.permission_utils import sudo_remove_directory, install_requirements_file
from src.plugin_system.plugin_loader import (
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
)
try: try:
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
@@ -44,12 +47,23 @@ class PluginStoreManager:
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json" REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
def __init__(self, plugins_dir: str = "plugins"): # A valid plugin id is a single path component: starts alphanumeric, then
# alphanumerics / dot / dash / underscore. Used to keep the uninstall
# registry from ever turning a corrupt or hand-edited entry (e.g. "",
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
# would delete — an empty id resolves to the plugins root itself.
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
def __init__(self, plugins_dir: str = "plugins",
uninstalled_registry_path: Optional[str] = None):
""" """
Initialize the plugin store manager. Initialize the plugin store manager.
Args: Args:
plugins_dir: Directory where plugins are installed plugins_dir: Directory where plugins are installed
uninstalled_registry_path: Path to the JSON file recording plugins
the user has uninstalled. Defaults to
``config/uninstalled_plugins.json`` under the project root.
""" """
self.plugins_dir = Path(plugins_dir) self.plugins_dir = Path(plugins_dir)
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@@ -84,6 +98,25 @@ class PluginStoreManager:
self._uninstall_tombstones: Dict[str, float] = {} self._uninstall_tombstones: Dict[str, float] = {}
self._uninstall_tombstone_ttl = 300 # 5 minutes self._uninstall_tombstone_ttl = 300 # 5 minutes
# Persistent record of plugins the user has uninstalled. Unlike the
# in-memory tombstones above (a short-lived race guard), this survives
# restarts so that a core ``git pull`` update cannot resurrect a
# built-in plugin the user removed. Built-in plugins (e.g.
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
# ``plugin-repos/``, so a plain ``git pull`` restores their files even
# after the user deleted them. ``purge_uninstalled_plugins`` re-removes
# any such resurrected directory; ``install_plugin`` clears the record
# when the user deliberately reinstalls. The file is gitignored.
if uninstalled_registry_path is not None:
self._uninstalled_registry_path = Path(uninstalled_registry_path)
else:
self._uninstalled_registry_path = (
Path(__file__).parent.parent.parent / "config" / "uninstalled_plugins.json"
)
# Serializes read-modify-write of the registry file so concurrent
# install/uninstall requests can't lose updates.
self._uninstalled_registry_lock = threading.Lock()
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)} # Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime, # where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
# head_contents) so a fast-forward update to the current branch # head_contents) so a fast-forward update to the current branch
@@ -143,6 +176,135 @@ class PluginStoreManager:
return False return False
return True return True
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
"""Return True if ``plugin_id`` is a safe single-component plugin id.
Rejects empty strings, anything with a path separator, and traversal
sequences like ``..`` so a registry entry can never escape (or target
the root of) ``self.plugins_dir`` during a purge.
"""
return isinstance(plugin_id, str) and bool(self._PLUGIN_ID_RE.match(plugin_id))
def _read_uninstalled_registry(self) -> Set[str]:
"""Read the persistent set of uninstalled plugin IDs.
Returns an empty set if the file is missing, unreadable, or corrupt
a broken registry must never block normal plugin operations. Invalid
ids are dropped here so callers never turn them into paths.
"""
try:
if not self._uninstalled_registry_path.exists():
return set()
with open(self._uninstalled_registry_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, list):
self.logger.warning(
"Uninstalled-plugin registry at %s is not a list; ignoring it",
self._uninstalled_registry_path,
)
return set()
valid: Set[str] = set()
for pid in data:
if self._is_valid_plugin_id(pid):
valid.add(pid)
else:
self.logger.warning(
"Ignoring invalid plugin id in uninstall registry: %r", pid
)
return valid
except (OSError, ValueError) as e:
self.logger.warning(
"Could not read uninstalled-plugin registry at %s: %s",
self._uninstalled_registry_path, e,
)
return set()
def _write_uninstalled_registry(self, plugin_ids: Set[str]) -> None:
"""Persist the set of uninstalled plugin IDs (sorted, atomically)."""
path = self._uninstalled_registry_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(sorted(plugin_ids), f, indent=2)
os.replace(tmp_path, path)
except OSError as e:
self.logger.error(
"Failed to write uninstalled-plugin registry at %s: %s", path, e
)
def record_uninstalled_plugin(self, plugin_id: str) -> None:
"""Persistently record that the user uninstalled ``plugin_id``.
Survives restarts so a core update cannot resurrect the plugin.
"""
if not self._is_valid_plugin_id(plugin_id):
self.logger.error("Refusing to record invalid plugin id: %r", plugin_id)
return
with self._uninstalled_registry_lock:
recorded = self._read_uninstalled_registry()
if plugin_id not in recorded:
recorded.add(plugin_id)
self._write_uninstalled_registry(recorded)
self.logger.info("Recorded %s as uninstalled (persistent)", plugin_id)
def forget_uninstalled_plugin(self, *plugin_ids: str) -> None:
"""Drop ``plugin_ids`` from the persistent uninstall registry.
Called when a plugin is deliberately (re)installed so future updates
keep it.
"""
with self._uninstalled_registry_lock:
recorded = self._read_uninstalled_registry()
to_remove = {pid for pid in plugin_ids if pid in recorded}
if to_remove:
self._write_uninstalled_registry(recorded - to_remove)
self.logger.info(
"Cleared uninstall record for %s", ", ".join(sorted(to_remove))
)
def get_uninstalled_plugins(self) -> Set[str]:
"""Return the persistent set of user-uninstalled plugin IDs."""
return self._read_uninstalled_registry()
def is_plugin_uninstalled(self, plugin_id: str) -> bool:
"""Return True if ``plugin_id`` is in the persistent uninstall registry."""
return plugin_id in self._read_uninstalled_registry()
def purge_uninstalled_plugins(self) -> List[str]:
"""Remove on-disk directories for plugins the user has uninstalled.
Built-in plugins committed into the repo are restored on disk by a
core ``git pull``; this re-removes any that the user previously
uninstalled. The registry entries are kept so the purge is idempotent
across every future update (until the user reinstalls). Returns the
list of plugin IDs whose directories were actually removed.
"""
removed: List[str] = []
plugins_root = self.plugins_dir.resolve()
for plugin_id in sorted(self._read_uninstalled_registry()):
plugin_path = self.plugins_dir / plugin_id
# Defense in depth: ids are already validated on read, but never
# remove anything that isn't a direct child of the plugins root.
resolved = plugin_path.resolve()
if resolved == plugins_root or resolved.parent != plugins_root:
self.logger.error(
"Refusing to purge unsafe plugin path for id %r", plugin_id
)
continue
if not plugin_path.exists():
continue
self.logger.info(
"Purging resurrected uninstalled plugin: %s", plugin_id
)
if self._safe_remove_directory(plugin_path):
removed.append(plugin_id)
else:
self.logger.error(
"Failed to purge resurrected plugin directory: %s", plugin_path
)
return removed
def _load_github_token(self) -> Optional[str]: def _load_github_token(self) -> Optional[str]:
""" """
Load GitHub API token from config_secrets.json if available. Load GitHub API token from config_secrets.json if available.
@@ -1024,6 +1186,10 @@ class PluginStoreManager:
branch_info = f" (branch: {branch})" if branch else " (latest branch head)" branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}") self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
# Remember the originally-requested id so we can clear its uninstall
# record on success even if the manifest renames the directory below.
requested_id = plugin_id
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True) plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
if not plugin_info: if not plugin_info:
self.logger.error(f"Plugin not found in registry: {plugin_id}") self.logger.error(f"Plugin not found in registry: {plugin_id}")
@@ -1162,6 +1328,9 @@ class PluginStoreManager:
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown') branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})") self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
# User deliberately (re)installed this plugin — clear any persistent
# uninstall record so future core updates keep it.
self.forget_uninstalled_plugin(requested_id, plugin_id)
return True return True
except Exception as e: except Exception as e:
@@ -1740,33 +1909,56 @@ class PluginStoreManager:
Returns: Returns:
True if successful or no requirements file True if successful or no requirements file
""" """
requirements_file = plugin_path / "requirements.txt" # Reconstruct the plugin path from the trusted self.plugins_dir base +
# an entry actually enumerated from it, rather than trusting
# plugin_path directly -- callers ultimately derive it from a
# plugin-supplied manifest "id" field (see install_plugin_from_url),
# so without this a malicious manifest could point requirements_file
# outside plugins_dir. find_trusted_subdir()'s return value always
# comes from os.scandir() on the trusted root, so building the path
# from it (not from the caller's string) is a real containment
# guarantee, matching the pattern in PluginLoader.install_dependencies().
plugin_dir_real = os.path.realpath(str(plugin_path))
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
requested_name = os.path.basename(plugin_dir_real)
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
if matched_name is None:
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
return False
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
requirements_file = safe_plugin_path / "requirements.txt"
if not requirements_file.exists(): if not requirements_file.exists():
self.logger.debug(f"No requirements.txt found in {plugin_path.name}") self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
return True return True
try: if not requirements_has_real_deps(str(requirements_file)):
self.logger.info(f"Installing dependencies for {plugin_path.name}") self.logger.debug(f"requirements.txt for {plugin_path.name} has no real dependencies, skipping pip")
subprocess.run(
['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)],
check=True,
capture_output=True,
text=True,
timeout=300
)
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
# Write hash marker so plugin_loader skips redundant pip run on next startup
try:
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
return True return True
except subprocess.CalledProcessError as e: if requirements_are_satisfied(str(requirements_file)):
self.logger.error(f"Error installing dependencies: {e.stderr}") self.logger.debug(f"Dependencies for {plugin_path.name} already satisfied, skipping pip")
return True
try:
self.logger.info(f"Installing dependencies for {plugin_path.name}")
# Routed through the shared root-visible installer (same one the
# web UI's "Reinstall Plugin Deps" tool uses) rather than a bare
# `pip`/`pip3` off PATH: a bare pip binary can silently resolve to
# a different Python installation than the one that actually runs
# ledmatrix.service, so pip reports success while the package
# stays invisible to the running plugin (e.g. missing `astral`
# for the weather plugin even though "install" succeeded).
result = install_requirements_file(requirements_file, timeout=300)
if result.returncode != 0:
self.logger.error(
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
)
return False return False
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
return True
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out") self.logger.error("Dependency installation timed out")
return False return False
@@ -2262,19 +2454,6 @@ class PluginStoreManager:
file_path = line[3:].strip() file_path = line[3:].strip()
untracked_files.append(file_path) untracked_files.append(file_path)
# Remove marker files that are safe to delete (they'll be regenerated)
safe_to_remove = ['.dependencies_installed']
removed_files = []
for file_name in safe_to_remove:
file_path = plugin_path / file_name
if file_path.exists() and file_name in untracked_files:
try:
file_path.unlink()
removed_files.append(file_name)
self.logger.info(f"Removed marker file {file_name} from {plugin_id} before update")
except Exception as e:
self.logger.warning(f"Could not remove {file_name} from {plugin_id}: {e}")
# Check for tracked file changes # Check for tracked file changes
status_result = subprocess.run( status_result = subprocess.run(
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'], ['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
@@ -2285,10 +2464,9 @@ class PluginStoreManager:
) )
has_changes = bool(status_result.stdout.strip()) has_changes = bool(status_result.stdout.strip())
# If there are remaining untracked files (not safe to remove), stash them # If there are untracked files, stash them
remaining_untracked = [f for f in untracked_files if f not in removed_files] if untracked_files:
if remaining_untracked: self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
self.logger.info(f"Found {len(remaining_untracked)} untracked files in {plugin_id}, will stash them")
has_changes = True has_changes = True
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# If status check times out, assume there might be changes and proceed # If status check times out, assume there might be changes and proceed
+9
View File
@@ -7,13 +7,22 @@ Provides base classes and utilities for testing LEDMatrix plugins.
from .plugin_test_base import PluginTestCase from .plugin_test_base import PluginTestCase
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
from .visual_display_manager import VisualTestDisplayManager from .visual_display_manager import VisualTestDisplayManager
from .bounds_display_manager import BoundsCheckingDisplayManager
from .sizes import (
DEFAULT_TEST_SIZES, SUPPORTED_SIZES, resolve_test_sizes, size_label,
)
__all__ = [ __all__ = [
'PluginTestCase', 'PluginTestCase',
'VisualTestDisplayManager', 'VisualTestDisplayManager',
'BoundsCheckingDisplayManager',
'MockDisplayManager', 'MockDisplayManager',
'MockCacheManager', 'MockCacheManager',
'MockConfigManager', 'MockConfigManager',
'MockPluginManager', 'MockPluginManager',
'DEFAULT_TEST_SIZES',
'SUPPORTED_SIZES',
'resolve_test_sizes',
'size_label',
] ]
@@ -0,0 +1,129 @@
"""
Bounds-checking display manager.
A VisualTestDisplayManager that draws onto an oversized canvas (the declared
panel size plus a right/bottom margin) while still reporting the declared size
to the plugin. Content that a plugin draws past the right or bottom edge lands
in the margin instead of being silently clipped by PIL, so the harness can
detect overflow the classic symptom of hardcoded coordinates or fonts/icons
that don't scale down to a smaller panel.
Limitations (documented on purpose):
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
PIL and not detected here. The dominant real-world breakage is content that is
too wide/tall for a smaller panel, which this catches.
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
BDF overflow is not flagged. Golden-image regression covers those plugins.
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
the margin can't be measured and overflow is reported as undetermined (None).
"""
from typing import Optional, Tuple
from .sizes import DEFAULT_TEST_SIZES
from .visual_display_manager import VisualTestDisplayManager, _MatrixProxy
# Smallest extra band kept on the right/bottom so a few pixels of overflow are
# still visible even on the largest panel in a run.
_BASE_MARGIN = 16
# Fallback overflow reference when a caller doesn't pass one: the largest shape
# in the default sample. We extend every (smaller) canvas out to at least this
# size so content drawn at a coordinate meant for a bigger build — e.g. x=200 on
# a 64-wide panel — lands in the padded region and is flagged, instead of being
# clipped off-canvas and read as a false pass.
_DEFAULT_EXTENT_WIDTH = max(w for w, _ in DEFAULT_TEST_SIZES)
_DEFAULT_EXTENT_HEIGHT = max(h for _, h in DEFAULT_TEST_SIZES)
class BoundsCheckingDisplayManager(VisualTestDisplayManager):
"""Detects drawing that overflows the declared panel size."""
# Kept for backwards compatibility; real padding is computed per-axis below.
MARGIN = _BASE_MARGIN
def __init__(self, width: int = 128, height: int = 32,
overflow_extent: Optional[Tuple[int, int]] = None):
self._declared_width = int(width)
self._declared_height = int(height)
# Pad the canvas out to at least `overflow_extent` (the largest panel
# this run cares about) plus a base margin, so coordinates meant for a
# bigger build are caught — not clipped — when rendering a smaller panel.
# Defaults to the largest shape in the sample when no run is known.
ext_w, ext_h = overflow_extent or (_DEFAULT_EXTENT_WIDTH, _DEFAULT_EXTENT_HEIGHT)
self._canvas_width = max(self._declared_width, int(ext_w)) + _BASE_MARGIN
self._canvas_height = max(self._declared_height, int(ext_h)) + _BASE_MARGIN
# Parent builds the (oversized) backing canvas + fonts.
super().__init__(self._canvas_width, self._canvas_height)
# Plugins must see the DECLARED size, not the padded canvas size.
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
# -- declared dimensions (override parent's image-derived properties) --
@property
def width(self) -> int:
return self._declared_width
@property
def height(self) -> int:
return self._declared_height
@property
def display_width(self) -> int:
return self._declared_width
@property
def display_height(self) -> int:
return self._declared_height
# -- overflow detection --
def _canvas_is_padded(self) -> bool:
return self.image.size == (self._canvas_width, self._canvas_height)
def check_overflow(self) -> Optional[Tuple[int, int, int, int]]:
"""Bounding box (in full-canvas coords) of any drawing beyond the
declared panel, or None if nothing overflowed / undetermined."""
if not self._canvas_is_padded():
return None
exp_w = self._canvas_width
exp_h = self._canvas_height
boxes = []
right = self.image.crop((self._declared_width, 0, exp_w, exp_h)).getbbox()
if right:
boxes.append((right[0] + self._declared_width, right[1],
right[2] + self._declared_width, right[3]))
bottom = self.image.crop((0, self._declared_height, exp_w, exp_h)).getbbox()
if bottom:
boxes.append((bottom[0], bottom[1] + self._declared_height,
bottom[2], bottom[3] + self._declared_height))
if not boxes:
return None
return (
min(b[0] for b in boxes), min(b[1] for b in boxes),
max(b[2] for b in boxes), max(b[3] for b in boxes),
)
# -- snapshot/image accessors return the cropped, true-panel image --
def declared_image(self):
"""The visible panel: the canvas cropped to the declared size."""
if self._canvas_is_padded():
return self.image.crop((0, 0, self._declared_width, self._declared_height))
return self.image
def save_snapshot(self, path: str) -> None:
self.declared_image().save(path, format='PNG')
def get_image(self):
return self.declared_image()
def get_image_base64(self) -> str:
import base64
import io
buffer = io.BytesIO()
self.declared_image().save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode('utf-8')
+314
View File
@@ -0,0 +1,314 @@
"""
Plugin safety harness.
Renders a plugin across every declared screen (mode) and every supported matrix
size, capturing crashes and overflow. Used by scripts/check_plugin.py and the
pytest matrix test to guarantee a plugin change doesn't break a screen at a size
the author didn't try.
The render flow mirrors scripts/render_plugin.py (same PluginLoader call), but
this module adds: multi-size iteration, per-mode rendering, overflow detection
via BoundsCheckingDisplayManager, and golden-image comparison.
"""
import contextlib
import http.client
import inspect
import socket
import ssl
import urllib.error
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
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
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
logger = get_logger("[Plugin Harness]")
def _tolerated_update_errors() -> Tuple[type, ...]:
"""Exception types from update() we treat as a tolerated no-connectivity
failure (expected in CI / headless dev) rather than a real plugin bug.
Anything NOT in this set is a genuine regression a plugin that lets a
non-network exception escape update() should fail the harness, not pass
green because display() happened to survive.
"""
types: List[type] = [
ConnectionError, TimeoutError, # builtins
socket.gaierror, socket.timeout, # DNS / socket timeouts
ssl.SSLError,
urllib.error.URLError,
http.client.HTTPException,
]
try: # requests is optional; cover its whole error tree when present
import requests
types.append(requests.exceptions.RequestException)
except ImportError: # pragma: no cover - requests not installed
logger.debug("requests not installed; its connectivity errors won't be specifically tolerated")
return tuple(types)
_TOLERATED_UPDATE_ERRORS = _tolerated_update_errors()
@dataclass
class RenderResult:
"""Outcome of rendering one (size, mode) of a plugin."""
plugin_id: str
width: int
height: int
mode: str
image: Optional[Image.Image] = None
error: Optional[str] = None # fatal: load/display crash, or a non-network update() error
update_error: Optional[str] = None # tolerated: connectivity error from update() (no network in CI)
overflow: Optional[Tuple[int, int, int, int]] = None # bbox past the panel
# golden comparison (populated only when a golden was provided)
golden_checked: bool = False
golden_ok: Optional[bool] = None
golden_diff_pixels: int = 0
golden_max_delta: int = 0
@property
def size_label(self) -> str:
return size_label(self.width, self.height)
@property
def ok(self) -> bool:
"""Phase-1 pass: rendered without crashing and without overflow, and if a
golden was checked it matched."""
if self.error is not None or self.overflow is not None:
return False
if self.golden_checked and self.golden_ok is False:
return False
return True
def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
"""Enumerate a plugin's screens: instance.modes wins, then manifest
display_modes, then the plugin id as a single mode."""
modes = getattr(plugin_instance, "modes", None)
if modes:
return [str(m) for m in modes]
declared = manifest.get("display_modes")
if declared:
return [str(m) for m in declared]
return [plugin_id]
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
config: Dict[str, Any], mock_data: Dict[str, Any],
display_manager: Any) -> Any:
"""Load and construct a plugin instance with mocked managers."""
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.testing import MockCacheManager, MockPluginManager
cache_manager = MockCacheManager()
for key, value in (mock_data or {}).items():
cache_manager.set(key, value)
loader = PluginLoader()
plugin_instance, _module = loader.load_plugin(
plugin_id=plugin_id,
manifest=manifest,
plugin_dir=plugin_dir,
config=config,
display_manager=display_manager,
cache_manager=cache_manager,
plugin_manager=MockPluginManager(),
install_deps=False,
)
return plugin_instance
def _render_mode(plugin_instance: Any, mode: str) -> None:
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
drive the plugin's internal mode state machine (first display() call renders
modes[current_mode_index] when current_display_mode is None)."""
sig = inspect.signature(plugin_instance.display)
if "display_mode" in sig.parameters:
plugin_instance.display(force_clear=True, display_mode=mode)
return
modes = getattr(plugin_instance, "modes", None)
if modes and mode in modes:
plugin_instance.current_mode_index = list(modes).index(mode)
if hasattr(plugin_instance, "current_display_mode"):
plugin_instance.current_display_mode = None
plugin_instance.display(force_clear=False)
def _freeze(freeze_time: Optional[str]):
"""Context manager that freezes wall-clock time when freeze_time is given,
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
if not freeze_time:
return contextlib.nullcontext()
try:
from freezegun import freeze_time as _ft
except ImportError as e: # pragma: no cover - only hit without the dep
raise RuntimeError(
"freeze_time requires the 'freezegun' package (pip install freezegun)"
) from e
return _ft(freeze_time)
def render_plugin_matrix(
plugin_id: str,
plugin_dir: Path,
config: Optional[Dict[str, Any]] = None,
mock_data: Optional[Dict[str, Any]] = None,
sizes: Optional[List[Tuple[int, int]]] = None,
run_update: bool = True,
freeze_time: Optional[str] = None,
) -> List[RenderResult]:
"""Render every (size, mode) combination for a plugin.
Returns a flat list of RenderResult. A fresh plugin instance is built per
(size, mode) so state never leaks between screens. Pass freeze_time (e.g.
"2025-08-01 15:25:00") to make time-dependent plugins reproducible.
"""
plugin_dir = Path(plugin_dir)
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 = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
sizes = sizes or DEFAULT_TEST_SIZES
results: List[RenderResult] = []
# The largest panel in this run. Every (smaller) canvas is padded out to it
# so a coordinate meant for the biggest configuration is still caught when
# rendering a smaller one, instead of being clipped into a false pass.
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
with _freeze(freeze_time):
for width, height in sizes:
results.extend(_render_size(
plugin_id, manifest, plugin_dir, config, mock_data or {},
width, height, run_update, extent,
))
return results
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
width, height, run_update, extent) -> List[RenderResult]:
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
results: List[RenderResult] = []
# Discover modes once per size (instance build can depend on config).
try:
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
modes = list_modes(probe, manifest, plugin_id)
except Exception as e: # noqa: BLE001 — surface any load failure as a result
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
for mode in modes:
result = RenderResult(plugin_id, width, height, mode)
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
try:
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
if run_update:
try:
inst.update()
except _TOLERATED_UPDATE_ERRORS as e:
# Expected when CI / headless dev has no network: record it
# (surfaced in the report) but don't fail the run.
result.update_error = repr(e)
logger.debug("update() connectivity error for %s [%s]: %s", plugin_id, mode, e)
except Exception as e: # noqa: BLE001 — a non-network update() failure is a real bug
# A regression in update() must not pass green just because
# display() survives, so treat it as a failure of this render.
result.error = repr(e)
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
plugin_id, mode, e)
if result.error is None:
_render_mode(inst, mode)
result.image = dm.get_image()
result.overflow = dm.check_overflow()
except Exception as e: # noqa: BLE001 — a display crash is a real failure
result.error = repr(e)
results.append(result)
return results
# ---------------------------------------------------------------------------
# Golden-image comparison
# ---------------------------------------------------------------------------
def compare_images(rendered: Image.Image, golden: Image.Image,
max_delta: int = 0, max_diff_pixels: int = 0) -> Tuple[bool, int, int]:
"""Compare two images. Returns (ok, diff_pixel_count, max_per_channel_delta).
Tolerances default to exact match; bump them only to absorb known platform
anti-aliasing noise (requires a pinned Pillow + bundled fonts for stability).
"""
if rendered.size != golden.size:
return False, rendered.size[0] * rendered.size[1], 255
a = rendered.convert("RGB")
b = golden.convert("RGB")
diff = ImageChops.difference(a, b)
bbox = diff.getbbox()
if bbox is None:
return True, 0, 0
# Count pixels whose largest per-channel delta exceeds the allowed tolerance,
# and track the worst delta seen (for reporting).
diff_pixels = 0
observed_max = 0
for px in diff.crop(bbox).getdata():
m = max(px) if isinstance(px, tuple) else px
if m > observed_max:
observed_max = m
if m > max_delta:
diff_pixels += 1
# Pass when the number of out-of-tolerance pixels is within budget.
ok = diff_pixels <= max_diff_pixels
return ok, diff_pixels, observed_max
def golden_path(golden_dir: Path, width: int, height: int, mode: str) -> Path:
"""Location of a golden image: <golden_dir>/<WxH>/<mode>.png.
The mode is sanitized to a safe basename so a mode name with '/' or '..'
can't read or write outside the golden directory.
"""
return Path(golden_dir) / size_label(width, height) / f"{safe_mode_filename(mode)}.png"
def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
max_delta: int = 0, max_diff_pixels: int = 0) -> List[RenderResult]:
"""Compare rendered results against committed goldens, mutating each result's
golden_* fields. Results with no golden file on disk are left unchecked."""
for r in results:
if r.image is None:
continue
gp = golden_path(golden_dir, r.width, r.height, r.mode)
if not gp.exists():
continue
r.golden_checked = True
with Image.open(gp) as g:
ok, diff_pixels, observed_max = compare_images(
r.image, g, max_delta=max_delta, max_diff_pixels=max_diff_pixels)
r.golden_ok = ok
r.golden_diff_pixels = diff_pixels
r.golden_max_delta = observed_max
return results
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
"""Write each successfully-rendered result to its golden path. Returns count."""
written = 0
for r in results:
if r.image is None or r.error is not None:
continue
gp = golden_path(golden_dir, r.width, r.height, r.mode)
gp.parent.mkdir(parents=True, exist_ok=True)
r.image.save(gp, format="PNG")
written += 1
return written
+82
View File
@@ -0,0 +1,82 @@
"""
Shared helpers for loading a plugin headlessly.
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
plugin discovery / manifest / config-default logic lives in exactly one place.
"""
import json
from pathlib import Path
from typing import Any, Dict, Optional, Sequence, Union
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
"""Find a plugin directory by searching multiple paths."""
from src.plugin_system.plugin_loader import PluginLoader
loader = PluginLoader()
for search_dir in search_dirs:
search_path = Path(search_dir)
if not search_path.exists():
continue
result = loader.find_plugin_directory(plugin_id, search_path)
if result:
return Path(result)
return None
def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Load and return manifest.json from a plugin directory."""
manifest_path = Path(plugin_dir) / 'manifest.json'
if not manifest_path.exists():
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
with open(manifest_path, 'r') as f:
return json.load(f)
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)."""
schema_path = Path(plugin_dir) / 'config_schema.json'
if 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 isinstance(prop, dict) and 'default' in prop:
defaults[key] = prop['default']
return defaults
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
Lets a plugin opt into golden-image testing by declaring how to render it
deterministically. All keys optional:
{
"config": {...}, # config overrides
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
"freeze_time": "2025-08-01 15:25:00",
"skip_update": false
}
Returns {} when no harness.json exists.
"""
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
if not spec_path.exists():
return {}
with open(spec_path, 'r') as f:
spec = json.load(f)
# Resolve mock_data path and inline its contents for convenience.
mock_rel = spec.get('mock_data')
if mock_rel:
mock_path = Path(plugin_dir) / mock_rel
if not mock_path.exists():
# A declared-but-missing fixture is a harness config error: failing
# loudly beats silently rendering the plugin with no mock data.
raise FileNotFoundError(
f"harness.json references mock_data '{mock_rel}' but "
f"{mock_path} does not exist"
)
with open(mock_path, 'r') as mf:
spec['mock_data_contents'] = json.load(mf)
return spec
+12
View File
@@ -63,11 +63,23 @@ class MockCacheManager:
"""Mock cache manager for testing.""" """Mock cache manager for testing."""
def __init__(self): def __init__(self):
import shutil
import tempfile
import weakref
self._cache: Dict[str, Any] = {} self._cache: Dict[str, Any] = {}
self._cache_timestamps: Dict[str, float] = {} self._cache_timestamps: Dict[str, float] = {}
self.get_calls = [] self.get_calls = []
self.set_calls = [] self.set_calls = []
self.delete_calls = [] self.delete_calls = []
# Real temp dir for plugins that write/read files under cache_dir.
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
self._finalizer = weakref.finalize(
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
def cleanup(self) -> None:
"""Remove the temp cache directory created for this instance."""
self._finalizer()
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]: def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
"""Get a value from cache.""" """Get a value from cache."""
+120
View File
@@ -0,0 +1,120 @@
"""
LED matrix sizes the plugin safety harness renders against.
There is no fixed set of "supported" panel sizes an RGB matrix build can be
any width/height and configuration (square, rectangle, 2x2, 4x4, 8x2, long
strips, tall stacks, ...). Plugins are expected to read width/height
dynamically and lay themselves out accordingly, so the harness's job is to
prove a plugin survives a *spread* of shapes, not a canonical list.
`DEFAULT_TEST_SIZES` is therefore a representative SAMPLE chosen to span the
axes of variation (narrow, wide, square, tall, small, long), not an
exhaustive or authoritative list. Callers can override it entirely:
- CLI: scripts/check_plugin.py --sizes 8x16,64x64,256x32
- pytest: LEDMATRIX_TEST_SIZES="8x16,64x64" env var (all plugins), or
per-plugin test/harness.json {"sizes": [[8, 16], [64, 64]]}
so anyone can point the harness at the exact panel(s) their build uses.
"""
import os
from typing import Iterable, List, Optional, Sequence, Tuple, Union
# A spread of real panel-grid arrangements (each module is 64x32), not a list of
# "blessed" sizes. Each entry exercises a different layout assumption a plugin
# might accidentally bake in. Annotations are the panel grid (cols x rows).
DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
(64, 32), # 1x1 — single panel, the tightest common rectangle
(128, 32), # 2x1 — the baseline most plugins are tuned for
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
(128, 64), # 2x2 — block, icon scaling / vertical centering
(256, 32), # 4x1 — long strip, wide horizontal layout
(128, 96), # 2x3 — tall, exercises vertical overflow
(256, 128), # 4x4 — large block, both dimensions big at once
]
# Backwards-compatible alias. Prefer DEFAULT_TEST_SIZES in new code — the old
# name implied these were the only valid panel sizes, which they are not.
SUPPORTED_SIZES = DEFAULT_TEST_SIZES
def size_label(width: int, height: int) -> str:
"""Human/path-friendly label for a size, e.g. '128x32'."""
return f"{width}x{height}"
def parse_size_token(token: str) -> Tuple[int, int]:
"""Parse a single 'WxH' token into an (int, int) pair.
Raises ValueError (with a user-friendly message) on malformed input so
callers can surface it however they like.
"""
cleaned = token.strip().lower()
if "x" not in cleaned:
raise ValueError(f"Invalid size '{token}' (expected WxH, e.g. 128x32)")
w, h = cleaned.split("x", 1)
try:
width, height = int(w), int(h)
except ValueError as exc:
raise ValueError(
f"Invalid size '{token}' (expected numeric WxH, e.g. 128x32)"
) from exc
if width <= 0 or height <= 0:
raise ValueError(
f"Invalid size '{token}' (width and height must be positive, e.g. 128x32)"
)
return (width, height)
def coerce_sizes(
value: Union[str, Iterable[Sequence[int]], None]
) -> Optional[List[Tuple[int, int]]]:
"""Normalize a size spec into a list of (w, h) tuples, or None if empty.
Accepts a comma-separated 'WxH,WxH' string (CLI / env var) or an iterable
of [w, h] / (w, h) pairs (harness.json). Returns None when value is falsy
so callers can fall back to the default sample.
"""
if not value:
return None
if isinstance(value, str):
return [parse_size_token(tok) for tok in value.split(",") if tok.strip()]
sizes: List[Tuple[int, int]] = []
for pair in value:
w, h = pair # raises if not a 2-element sequence
width, height = int(w), int(h)
if width <= 0 or height <= 0:
raise ValueError(f"Invalid size pair {pair!r} (width and height must be positive)")
sizes.append((width, height))
return sizes or None
def resolve_test_sizes(
spec_sizes: Union[str, Iterable[Sequence[int]], None] = None,
) -> List[Tuple[int, int]]:
"""Decide which sizes to render, by precedence:
1. LEDMATRIX_TEST_SIZES env var a global "test on my hardware" override
that wins for every plugin.
2. spec_sizes e.g. a per-plugin harness.json "sizes" list.
3. DEFAULT_TEST_SIZES the representative sample.
"""
env = coerce_sizes(os.environ.get("LEDMATRIX_TEST_SIZES"))
if env:
return env
spec = coerce_sizes(spec_sizes)
if spec:
return spec
return list(DEFAULT_TEST_SIZES)
def safe_mode_filename(mode: str) -> str:
"""A filesystem-safe basename for a plugin mode.
Mode names come from plugin metadata/render state, so a value containing
'/' or '..' could otherwise escape the intended output directory. Collapse
anything that isn't alphanumeric / dash / underscore to '_'.
"""
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in mode)
return cleaned or "mode"
@@ -454,6 +454,18 @@ class VisualTestDisplayManager:
"""Check if display is currently scrolling.""" """Check if display is currently scrolling."""
return self._scrolling_state['is_scrolling'] return self._scrolling_state['is_scrolling']
def process_deferred_updates(self):
"""Process any deferred updates (no-op for testing).
Several ticker-style plugins (news, odds-ticker, leaderboard,
stock-news, stocks) call this unconditionally between
set_scrolling_state() and their scroll-position update, mirroring the
real display_manager's deferred-update queue. This double has no such
queue, so there is nothing to process the no-op just lets those
plugins render under the harness instead of raising AttributeError.
"""
pass
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Utility methods # Utility methods
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+76
View File
@@ -279,6 +279,19 @@ class PluginAdapter:
# Copy the image to prevent modification # Copy the image to prevent modification
img = cached_image.copy() img = cached_image.copy()
# Plugins that build their own ticker image via this shared
# ScrollHelper's create_scrolling_image() get a solid-black
# leading margin exactly `display_width` columns wide baked in
# (scroll_helper.py's "initial gap before first item"). Vegas mode
# adds its own leading gap/separator around every item already,
# so leaving this in stacks a second, uncontrolled blank margin on
# top of vegas_scroll.separator_width — making this plugin's
# transitions look inconsistent with plugins that provide content
# via get_vegas_content() (which carries no such margin). Strip it
# here so every plugin contributes only its real content and the
# gap between items is governed solely by separator_width.
img = self._strip_scroll_padding(img, scroll_helper, plugin_id)
# Ensure correct height # Ensure correct height
if img.height != self.display_height: if img.height != self.display_height:
logger.info( logger.info(
@@ -306,6 +319,69 @@ class PluginAdapter:
logger.exception("[%s] Error getting scroll_helper content", plugin_id) logger.exception("[%s] Error getting scroll_helper content", plugin_id)
return None return None
def _strip_scroll_padding(
self, img: Image.Image, scroll_helper: Any, plugin_id: str
) -> Image.Image:
"""
Crop off a plugin's own leading/trailing blank margins, if present.
create_scrolling_image() always pads the *start* of its cached image
with exactly `scroll_helper.display_width` columns of solid black
(0, 0, 0) ("initial gap before first item"). Some ticker-style plugins
also pad the *end* of their own cached image (e.g. so their standalone
display exits cleanly before looping). Vegas mode already adds its own
gap/separator around every item, so either margin left in place stacks
an extra, uncontrolled blank stretch on top of `separator_width`
only when running inside Vegas mode does this matter, since the
plugin's own standalone display still wants that margin. Detect solid
black margins up to `scroll_helper.display_width` wide on each edge and
crop them here. Images built via set_scrolling_image() (no such
margins) are left untouched.
Args:
img: Captured scroll_helper.cached_image (already copied)
scroll_helper: The plugin's ScrollHelper instance
plugin_id: Plugin identifier for logging
Returns:
img, cropped on whichever edge(s) had a matching blank margin
"""
pad_width = getattr(scroll_helper, 'display_width', None)
if not isinstance(pad_width, int) or pad_width <= 0 or pad_width >= img.width:
return img
def is_solid_black(strip: Image.Image) -> bool:
return strip.convert('RGB').getextrema() == ((0, 0), (0, 0), (0, 0))
left = pad_width if is_solid_black(img.crop((0, 0, pad_width, img.height))) else 0
right = (
pad_width
if is_solid_black(img.crop((img.width - pad_width, 0, img.width, img.height)))
else 0
)
if not left and not right:
return img
# Degenerate case (e.g. an all-black cached image): don't crop past
# zero width, just leave the image as-is.
if left + right >= img.width:
return img
cropped = img.crop((left, 0, img.width - right, img.height))
# Both edges matching at once is a much stronger signal of genuine
# baked-in padding than a single edge (which has a small chance of
# coinciding with real all-black content, e.g. a dark logo touching
# one boundary). Log that case at warning level so an unexpected
# double-edge crop is easy to spot in the field.
log = logger.warning if (left and right) else logger.info
log(
"[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx",
plugin_id, left, right, img.width, cropped.width
)
return cropped
def _trigger_scroll_content_generation( def _trigger_scroll_content_generation(
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
) -> Optional[Image.Image]: ) -> Optional[Image.Image]:
+89
View File
@@ -1884,6 +1884,95 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts") logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False return False
def get_wifi_radio_state(self) -> Dict:
"""
Report whether the WiFi radio is currently enabled, plus whether a wired
fallback exists. Used by the web UI's radio toggle so it can warn before
an action that could disconnect the browser.
Returns:
{
'enabled': Optional[bool], # True/False, or None if undeterminable
'ethernet_connected': bool, # wired fallback present
'available': bool, # nmcli present / radio state readable
}
"""
ethernet_connected = self._is_ethernet_connected()
enabled: Optional[bool] = None
available = False
try:
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
status = result.stdout.strip().lower()
if status in ("enabled", "disabled"):
enabled = status == "enabled"
available = True
except Exception as e:
logger.debug(f"Could not read WiFi radio state: {e}")
return {
'enabled': enabled,
'ethernet_connected': ethernet_connected,
'available': available,
}
def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]:
"""
Turn the WiFi radio on or off.
Turning the radio OFF from the web interface is dangerous: if the device
is reachable only over WiFi, disabling it disconnects the very page that
issued the request. To prevent that lockout, disabling is refused unless a
wired (Ethernet) fallback is present, or the caller explicitly passes
force=True to acknowledge the risk.
Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles
rfkill soft-blocks + retries). Both directions rely only on
`nmcli radio wifi on|off`, which is already covered by the passwordless
sudoers allowlist (configure_wifi_permissions.sh) no new privileged
command is introduced.
Returns:
(success, human-readable message, reason_code). reason_code is
'no_ethernet' when a disable is refused for lockout safety, or a
short failure code otherwise; None on success. The web UI keys on
'no_ethernet' to decide whether to offer a force-off prompt.
"""
if enabled:
if self._ensure_wifi_radio_enabled():
return True, "WiFi radio enabled.", None
return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed'
# Disabling — guard against locking the user out of the web interface.
if not force and not self._is_ethernet_connected():
return False, (
"Refusing to disable WiFi: no wired (Ethernet) connection was "
"detected, so turning off WiFi would disconnect you from this "
"page. Connect Ethernet first, or force it if you're sure."
), 'no_ethernet'
try:
result = subprocess.run(
["sudo", "nmcli", "radio", "wifi", "off"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
logger.info("WiFi radio disabled via web interface (force=%s)", force)
return True, "WiFi radio disabled.", None
logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip())
return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed'
except subprocess.TimeoutExpired:
return False, "Command timed out while disabling WiFi radio.", 'timeout'
except (OSError, subprocess.SubprocessError) as e:
logger.error("Error disabling WiFi radio: %s", e, exc_info=True)
return False, "An error occurred while disabling WiFi radio.", 'error'
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]: def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
""" """
Enable access point mode Enable access point mode
+5 -1
View File
@@ -38,7 +38,11 @@ def mock_cache_manager():
mock._memory_cache_timestamps = {} mock._memory_cache_timestamps = {}
mock.cache_dir = "/tmp/test_cache" mock.cache_dir = "/tmp/test_cache"
def mock_get(key: str, max_age: int = 300) -> Optional[Dict]: def mock_get(key: str, max_age: Optional[int] = 300,
memory_ttl: Optional[int] = None) -> Optional[Dict]:
# Signature mirrors CacheManager.get — keep in sync or callers
# passing keyword args (health tracker, resource monitor) break
# only in tests, hiding real-API compatibility.
return mock._memory_cache.get(key) return mock._memory_cache.get(key)
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None: def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
+255
View File
@@ -0,0 +1,255 @@
"""
Unit tests for the plugin safety harness primitives:
bounds detection, image comparison, and mode enumeration.
These don't load real plugins, so they run anywhere (including core CI where
plugin-repos is empty).
"""
import importlib.util
import json
from pathlib import Path
import pytest
from PIL import Image
from src.plugin_system.testing.bounds_display_manager import BoundsCheckingDisplayManager
from src.plugin_system.testing.harness import (
_TOLERATED_UPDATE_ERRORS, compare_images, list_modes,
)
from src.plugin_system.testing.sizes import (
DEFAULT_TEST_SIZES, coerce_sizes, parse_size_token, resolve_test_sizes,
)
class TestBoundsDetection:
def test_reports_declared_size_not_canvas_size(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
assert dm.width == 64 and dm.height == 32
assert dm.matrix.width == 64 and dm.matrix.height == 32
# Backing canvas is padded out past the declared panel so far-overshoot
# coordinates land on-canvas and get flagged instead of clipped.
canvas_w, canvas_h = dm.image.size
assert canvas_w > 64 and canvas_h > 32
def test_far_overshoot_on_small_panel_is_detected(self):
# A coordinate meant for a wide build (x past 64) must still be caught
# when the declared panel is only 64 wide.
dm = BoundsCheckingDisplayManager(width=64, height=32)
dm.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
bbox = dm.check_overflow()
assert bbox is not None
assert bbox[0] >= 64
def test_in_bounds_drawing_has_no_overflow(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
dm.draw.rectangle([0, 0, 63, 31], fill=(255, 255, 255))
assert dm.check_overflow() is None
def test_right_overflow_is_detected(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
# Draw a few pixels past the right edge.
dm.draw.rectangle([60, 5, 70, 10], fill=(255, 0, 0))
bbox = dm.check_overflow()
assert bbox is not None
assert bbox[0] >= 64 # overflow starts at or past the declared width
def test_bottom_overflow_is_detected(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
dm.draw.rectangle([5, 30, 10, 40], fill=(0, 255, 0))
bbox = dm.check_overflow()
assert bbox is not None
assert bbox[3] > 32 # overflow extends past the declared height
def test_declared_image_is_cropped_to_panel(self):
dm = BoundsCheckingDisplayManager(width=64, height=32)
assert dm.get_image().size == (64, 32)
def test_snapshot_saves_cropped_panel(self, tmp_path):
dm = BoundsCheckingDisplayManager(width=128, height=32)
out = tmp_path / "snap.png"
dm.save_snapshot(str(out))
with Image.open(out) as img:
assert img.size == (128, 32)
class TestArbitraryPanelSizes:
"""The harness must handle any panel shape, not a fixed supported list."""
def test_overflow_extent_pads_to_largest_in_run(self):
# A wide run (extent 256) means content at x=200 on a 64-wide panel is
# caught; the same draw with a small extent would be clipped (false pass).
wide = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(256, 32))
wide.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
assert wide.check_overflow() is not None
tight = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(64, 32))
tight.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
assert tight.check_overflow() is None # clipped beyond the small canvas
def test_unusual_shapes_report_their_declared_size(self):
for w, h in [(8, 2), (6, 6), (200, 8), (64, 96)]:
dm = BoundsCheckingDisplayManager(width=w, height=h)
assert dm.width == w and dm.height == h
assert dm.matrix.width == w and dm.matrix.height == h
class TestUpdateErrorClassification:
"""update() may fail for lack of network (tolerated) but a logic bug must
not pass green just because display() survives."""
def test_connectivity_errors_are_tolerated(self):
import socket
import urllib.error
for exc in (ConnectionError("x"), TimeoutError("x"), socket.gaierror("x"),
urllib.error.URLError("x")):
assert isinstance(exc, _TOLERATED_UPDATE_ERRORS)
def test_logic_errors_are_not_tolerated(self):
for exc in (ValueError("x"), KeyError("x"), AttributeError("x"), TypeError("x")):
assert not isinstance(exc, _TOLERATED_UPDATE_ERRORS)
class TestSizeParsing:
def test_parse_size_token_ok(self):
assert parse_size_token(" 128X32 ") == (128, 32)
def test_parse_size_token_rejects_garbage(self):
with pytest.raises(ValueError):
parse_size_token("128xabc")
with pytest.raises(ValueError):
parse_size_token("128-32")
def test_rejects_non_positive_dimensions(self):
for bad in ("0x32", "-64x32", "64x0", "64x-1"):
with pytest.raises(ValueError):
parse_size_token(bad)
with pytest.raises(ValueError):
coerce_sizes([[0, 32]])
with pytest.raises(ValueError):
coerce_sizes("64x-1")
def test_coerce_sizes_from_string_and_pairs(self):
assert coerce_sizes("8x16,64x64") == [(8, 16), (64, 64)]
assert coerce_sizes([[8, 16], (64, 64)]) == [(8, 16), (64, 64)]
assert coerce_sizes(None) is None
assert coerce_sizes("") is None
def test_resolve_precedence_env_then_spec_then_default(self, monkeypatch):
monkeypatch.delenv("LEDMATRIX_TEST_SIZES", raising=False)
assert resolve_test_sizes(None) == list(DEFAULT_TEST_SIZES)
assert resolve_test_sizes([[8, 16]]) == [(8, 16)]
monkeypatch.setenv("LEDMATRIX_TEST_SIZES", "5x5")
# env wins over a per-plugin spec
assert resolve_test_sizes([[8, 16]]) == [(5, 5)]
class TestCompareImages:
def test_identical_images_match(self):
a = Image.new("RGB", (16, 16), (10, 20, 30))
b = a.copy()
ok, diff_pixels, max_delta = compare_images(a, b)
assert ok and diff_pixels == 0 and max_delta == 0
def test_different_images_fail_at_zero_tolerance(self):
a = Image.new("RGB", (16, 16), (0, 0, 0))
b = a.copy()
b.putpixel((1, 1), (255, 255, 255))
ok, diff_pixels, max_delta = compare_images(a, b)
assert not ok and diff_pixels == 1 and max_delta == 255
def test_tolerance_absorbs_small_noise(self):
a = Image.new("RGB", (16, 16), (100, 100, 100))
b = a.copy()
b.putpixel((2, 2), (103, 100, 100)) # delta 3
ok, _, max_delta = compare_images(a, b, max_delta=5, max_diff_pixels=0)
assert ok and max_delta == 3
def test_size_mismatch_fails(self):
a = Image.new("RGB", (16, 16))
b = Image.new("RGB", (32, 16))
ok, _, _ = compare_images(a, b)
assert not ok
class TestListModes:
def test_instance_modes_take_precedence(self):
inst = type("P", (), {"modes": ["a", "b"]})()
assert list_modes(inst, {"display_modes": ["x"]}, "pid") == ["a", "b"]
def test_falls_back_to_manifest_display_modes(self):
inst = type("P", (), {})()
assert list_modes(inst, {"display_modes": ["x", "y"]}, "pid") == ["x", "y"]
def test_falls_back_to_plugin_id(self):
inst = type("P", (), {})()
assert list_modes(inst, {}, "pid") == ["pid"]
def _load_check_plugin_cli():
"""Load scripts/check_plugin.py by path (it isn't an importable package)."""
root = Path(__file__).resolve().parents[2]
path = root / "scripts" / "check_plugin.py"
spec = importlib.util.spec_from_file_location("check_plugin_cli", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _make_fixture_plugin(tmp_path, harness):
"""Create a minimal plugin dir with a test/harness.json; return its parent
(the search dir)."""
pdir = tmp_path / "plugins" / "demo-clock"
(pdir / "test").mkdir(parents=True)
(pdir / "manifest.json").write_text(json.dumps({
"id": "demo-clock", "name": "Demo Clock", "version": "1.0.0",
"author": "test", "entry_point": "manager.py", "class_name": "DemoClock",
"display_modes": ["demo-clock"], "compatible_versions": ["*"],
}))
(pdir / "test" / "harness.json").write_text(json.dumps(harness))
return pdir.parent
class TestCheckPluginHonorsHarnessJson:
"""Regression: check_plugin.py (the CI tool) must apply test/harness.json so
its render reproduces the committed goldens otherwise time/data-dependent
plugins drift on every CI run."""
def test_harness_json_supplies_render_settings(self, tmp_path, monkeypatch):
mod = _load_check_plugin_cli()
search = _make_fixture_plugin(tmp_path, {
"config": {"timezone": "UTC"},
"freeze_time": "2025-08-01 15:25:00",
"sizes": [[128, 32]],
})
captured = {}
monkeypatch.setattr(mod, "render_plugin_matrix",
lambda **kw: captured.update(kw) or [])
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
mod.check_one(
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
mock_data={}, config={}, run_update=True, out_dir=None,
update_golden=False, golden_dir_override=None, freeze_time=None,
)
assert captured["freeze_time"] == "2025-08-01 15:25:00"
assert captured["config"]["timezone"] == "UTC"
assert captured["sizes"] == [(128, 32)]
def test_cli_flags_override_harness_json(self, tmp_path, monkeypatch):
mod = _load_check_plugin_cli()
search = _make_fixture_plugin(tmp_path, {
"config": {"timezone": "UTC"},
"freeze_time": "2025-08-01 15:25:00",
})
captured = {}
monkeypatch.setattr(mod, "render_plugin_matrix",
lambda **kw: captured.update(kw) or [])
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
mod.check_one(
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
mock_data={}, config={"timezone": "America/New_York"},
run_update=True, out_dir=None, update_golden=False,
golden_dir_override=None, freeze_time="2030-01-01 00:00:00",
)
assert captured["freeze_time"] == "2030-01-01 00:00:00"
assert captured["config"]["timezone"] == "America/New_York"
+115
View File
@@ -0,0 +1,115 @@
"""
Cross-size / cross-screen plugin safety test.
For every discovered plugin, render every declared screen at every supported
matrix size and assert it: loads, renders without crashing, stays within the
panel bounds, and for plugins that ship golden images matches them.
Plugin discovery (first match wins):
- $LEDMATRIX_PLUGINS_DIR (os.pathsep-separated list of dirs), else
- <project_root>/plugin-repos and <project_root>/plugins
A plugin opts into golden-image checks by adding test/golden/<WxH>/<mode>.png
(and usually test/harness.json for deterministic config / mock data / time).
"""
import os
from pathlib import Path
from typing import Dict, List
import pytest
from src.plugin_system.testing.harness import (
render_plugin_matrix, compare_to_goldens,
)
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
from src.plugin_system.testing.sizes import resolve_test_sizes
PROJECT_ROOT = Path(__file__).resolve().parents[2]
# Set LEDMATRIX_REQUIRE_PLUGINS=1 in any CI/hardware pipeline where plugins are
# expected to be present, so a discovery drift (empty search path) fails loudly
# instead of silently skipping and losing this safety signal.
_REQUIRE_PLUGINS = os.environ.get("LEDMATRIX_REQUIRE_PLUGINS") == "1"
def _plugin_search_dirs() -> List[Path]:
env = os.environ.get("LEDMATRIX_PLUGINS_DIR")
if env:
return [Path(p) for p in env.split(os.pathsep) if p]
return [PROJECT_ROOT / "plugin-repos", PROJECT_ROOT / "plugins"]
def _discover() -> Dict[str, Path]:
"""Map plugin_id -> plugin_dir for all plugins on the search path."""
found: Dict[str, Path] = {}
for base in _plugin_search_dirs():
if not base.exists():
continue
for child in sorted(base.iterdir()):
if (child / "manifest.json").exists() and child.name not in found:
found[child.name] = child
return found
_PLUGINS = _discover()
@pytest.mark.plugin
def test_plugins_were_discovered() -> None:
"""Guard against silently skipping the whole matrix when discovery drifts.
Local dev and the plugin-less core CI legitimately have no plugins, so we
skip there; but when LEDMATRIX_REQUIRE_PLUGINS=1 an empty search path is a
hard failure rather than a green no-op.
"""
if _PLUGINS:
return
search = [str(p) for p in _plugin_search_dirs()]
if _REQUIRE_PLUGINS:
pytest.fail(
"LEDMATRIX_REQUIRE_PLUGINS=1 but no plugins were discovered on the "
f"search path: {search}"
)
pytest.skip(f"no plugins found on the search path: {search}")
@pytest.mark.plugin
@pytest.mark.skipif(not _PLUGINS, reason="no plugins found on the search path")
@pytest.mark.parametrize("plugin_id", sorted(_PLUGINS))
def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
plugin_dir = _PLUGINS[plugin_id]
spec = load_harness_spec(plugin_dir)
config = {"enabled": True}
config.update(load_config_defaults(plugin_dir))
config.update(spec.get("config", {}))
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
# plugin's own harness.json "sizes", else the default representative sample.
sizes = resolve_test_sizes(spec.get("sizes"))
results = render_plugin_matrix(
plugin_id=plugin_id,
plugin_dir=plugin_dir,
config=config,
mock_data=spec.get("mock_data_contents", {}),
sizes=sizes,
run_update=not spec.get("skip_update", False),
freeze_time=spec.get("freeze_time"),
)
compare_to_goldens(results, plugin_dir / "test" / "golden")
failures = []
for r in results:
if r.error is not None:
failures.append(f"{r.size_label} {r.mode}: crashed: {r.error}")
elif r.overflow is not None:
failures.append(f"{r.size_label} {r.mode}: overflow past panel bbox={r.overflow}")
elif r.golden_checked and r.golden_ok is False:
failures.append(
f"{r.size_label} {r.mode}: golden drift {r.golden_diff_pixels}px "
f"(max Δ={r.golden_max_delta})"
)
assert not failures, f"{plugin_id} failed:\n " + "\n ".join(failures)
+10
View File
@@ -172,6 +172,16 @@ class TestVisualDisplayManager:
vdm.set_scrolling_state(False) vdm.set_scrolling_state(False)
assert vdm.is_currently_scrolling() is False assert vdm.is_currently_scrolling() is False
def test_process_deferred_updates_is_noop(self):
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
# stocks) call this unconditionally alongside set_scrolling_state();
# it must exist and be harmless so those plugins render under the
# harness instead of raising AttributeError.
vdm = VisualTestDisplayManager(width=128, height=32)
vdm.set_scrolling_state(True)
vdm.process_deferred_updates() # should not raise
assert vdm.is_currently_scrolling() is True
def test_format_date_with_ordinal(self): def test_format_date_with_ordinal(self):
from datetime import datetime from datetime import datetime
vdm = VisualTestDisplayManager(width=128, height=32) vdm = VisualTestDisplayManager(width=128, height=32)
+13
View File
@@ -284,6 +284,19 @@ class TestDiskCache:
result = cache.get("test_key", max_age=0) result = cache.get("test_key", max_age=0)
assert result is None assert result is None
def test_get_max_age_none_never_expires(self, tmp_path):
"""max_age=None must return persisted records regardless of age.
Regression: the age comparison raised TypeError for max_age=None,
which was swallowed and treated as a miss silently breaking
long-lived state (plugin health/metrics) read across processes.
"""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("test_key", {"data": "value", "timestamp": 0}) # epoch → very old
result = cache.get("test_key", max_age=None)
assert result is not None
assert result["data"] == "value"
def test_get_nonexistent(self, tmp_path): def test_get_nonexistent(self, tmp_path):
"""Test getting non-existent key.""" """Test getting non-existent key."""
cache = DiskCache(cache_dir=str(tmp_path)) cache = DiskCache(cache_dir=str(tmp_path))
+111
View File
@@ -214,6 +214,104 @@ class TestDisplayControllerLivePriority:
assert controller.current_mode_index == 1 assert controller.current_mode_index == 1
assert controller.current_display_mode == "b" assert controller.current_display_mode == "b"
# --- Round-robin between multiple simultaneous live games --------------
@staticmethod
def _live_plugin(live_modes):
"""A mock plugin that is live and reports the given live mode names."""
p = MagicMock()
p.has_live_priority = MagicMock(return_value=True)
p.has_live_content = MagicMock(return_value=True)
p.get_live_modes = MagicMock(return_value=list(live_modes))
return p
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
"""A sports plugin registered under several mode keys (one per league)
contributes each live mode once, in registration order; plugins with no
live content are skipped."""
controller = test_display_controller
baseball = self._live_plugin(["baseball_live"])
soccer = self._live_plugin(["soccer_fifa.world_live"])
idle = MagicMock()
idle.has_live_priority = MagicMock(return_value=True)
idle.has_live_content = MagicMock(return_value=False)
controller.plugin_modes = {
"baseball_live": baseball,
"baseball_recent": baseball,
"soccer_fifa.world_live": soccer,
"soccer_usa.1_live": soccer,
"soccer_recent": soccer,
"clock": idle,
}
assert controller._collect_live_modes() == [
"baseball_live", "soccer_fifa.world_live"
]
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
"""Regression: with two games live at once, the live-priority pick
round-robins each dwell instead of pinning to the first plugin in
registration order (the bug where a baseball game hid a live World Cup
match)."""
controller = test_display_controller
baseball = self._live_plugin(["baseball_live"])
soccer = self._live_plugin(["soccer_fifa.world_live"])
controller.plugin_modes = {
"baseball_live": baseball,
"soccer_fifa.world_live": soccer,
}
# First entry into live priority from an ambient mode -> first live game.
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) == "baseball_live"
# The controller switches to it; the next dwell advances to the other.
controller.current_display_mode = "baseball_live"
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
# And wraps back again.
controller.current_display_mode = "soccer_fifa.world_live"
assert controller._check_live_priority(advance=True) == "baseball_live"
def test_single_live_game_holds_without_flipping(self, test_display_controller):
"""One live game: advancing returns the same mode, so the hold is stable."""
controller = test_display_controller
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
controller.current_display_mode = "baseball_live"
assert controller._check_live_priority(advance=True) == "baseball_live"
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
"""The default (advance=False) peek used by the Vegas coordinator must
not spin the cursor: it returns the live mode already on screen."""
controller = test_display_controller
controller.plugin_modes = {
"baseball_live": self._live_plugin(["baseball_live"]),
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
}
controller.current_display_mode = "soccer_fifa.world_live"
assert controller._check_live_priority() == "soccer_fifa.world_live"
assert controller._check_live_priority() == "soccer_fifa.world_live"
# From an ambient mode the peek reports the first live game (truthy).
controller.current_display_mode = "clock"
assert controller._check_live_priority() == "baseball_live"
def test_no_live_content_returns_none(self, test_display_controller):
controller = test_display_controller
idle = MagicMock()
idle.has_live_priority = MagicMock(return_value=True)
idle.has_live_content = MagicMock(return_value=False)
controller.plugin_modes = {"clock": idle}
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) is None
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
"""A live plugin whose get_live_modes returns nothing registered falls
back to its own '_live' mode name (legacy behavior preserved)."""
controller = test_display_controller
legacy = MagicMock()
legacy.has_live_priority = MagicMock(return_value=True)
legacy.has_live_content = MagicMock(return_value=True)
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
controller.plugin_modes = {"hockey_live": legacy}
controller.current_display_mode = "clock"
assert controller._check_live_priority(advance=True) == "hockey_live"
class TestDisplayControllerDynamicDuration: class TestDisplayControllerDynamicDuration:
"""Test dynamic duration handling.""" """Test dynamic duration handling."""
@@ -293,3 +391,16 @@ class TestDisplayControllerSchedule:
controller._check_schedule() controller._check_schedule()
assert controller.is_display_active is False assert controller.is_display_active is False
class TestPluginHealthWiring:
"""Phase 1: DisplayController activates the dormant plugin health/metrics
subsystem by wiring real tracker/monitor instances onto the plugin manager."""
def test_health_tracker_and_resource_monitor_wired(self, test_display_controller):
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
pm = test_display_controller.plugin_manager
assert isinstance(pm.health_tracker, PluginHealthTracker)
assert isinstance(pm.resource_monitor, PluginResourceMonitor)
@@ -0,0 +1,255 @@
"""Tests for live plugin enable/disable hot-reload in DisplayController.
Enabling or disabling a plugin in config used to require a full display
restart because the plugin list and available_modes were built once at init.
These tests cover the reconcile path that loads/unloads plugins and rebuilds
the dispatch maps on the main thread when the enabled set changes.
"""
from unittest.mock import MagicMock
def _make_plugin(modes):
plugin = MagicMock()
plugin.modes = list(modes)
return plugin
def _wire_plugin_manager(controller, plugins, discovered=None):
"""Point the controller's mock plugin_manager at a set of fake plugins.
`plugins` maps plugin_id -> mock instance (with a .modes list).
"""
pm = controller.plugin_manager
pm.discover_plugins.return_value = list(discovered if discovered is not None else plugins.keys())
pm.load_plugin.return_value = True
pm.unload_plugin.return_value = True
pm.plugin_manifests = {}
pm.get_plugin.side_effect = lambda pid: plugins.get(pid)
return pm
def _set_config(controller, cfg):
controller.config_service.get_config = lambda: cfg
class TestPluginEnableDisableHotReload:
def test_enable_plugin_live(self, test_display_controller):
controller = test_display_controller
assert controller.available_modes == []
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin})
_set_config(controller, {"foo": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "foo" in controller.plugin_display_modes
assert "foo" in controller.available_modes
assert controller.plugin_modes["foo"] is plugin
assert controller.mode_to_plugin_id["foo"] == "foo"
controller.plugin_manager.load_plugin.assert_any_call("foo")
def test_disable_plugin_live(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["live", "recent"])
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
# Enable, then disable.
_set_config(controller, {"sports": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "sports" in controller.plugin_display_modes
assert "live" in controller.available_modes and "recent" in controller.available_modes
assert "sports" in controller._plugin_config_callbacks
_set_config(controller, {"sports": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert "sports" not in controller.plugin_display_modes
assert "live" not in controller.available_modes
assert "recent" not in controller.available_modes
assert "live" not in controller.plugin_modes
assert "recent" not in controller.mode_to_plugin_id
controller.plugin_manager.unload_plugin.assert_any_call("sports")
assert "sports" not in controller._plugin_config_callbacks
def test_disable_clamps_current_mode_index(self, test_display_controller):
controller = test_display_controller
p1 = _make_plugin(["a"])
p2 = _make_plugin(["b"])
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
controller._reconcile_enabled_plugins()
# Add order across multiple plugins is set-driven (as at init), so
# compare membership, not order.
assert set(controller.available_modes) == {"a", "b"}
# Pretend we're currently showing p2's mode.
controller.current_mode_index = controller.available_modes.index("b")
controller.current_display_mode = "b"
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert controller.available_modes == ["a"]
# Index must be back in range and the display mode no longer the removed one.
assert 0 <= controller.current_mode_index < len(controller.available_modes)
assert controller.current_display_mode == "a"
def test_enable_keeps_current_mode(self, test_display_controller):
controller = test_display_controller
p1 = _make_plugin(["a"])
p2 = _make_plugin(["b"])
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
_set_config(controller, {"p1": {"enabled": True}})
controller._reconcile_enabled_plugins()
controller.current_mode_index = 0
controller.current_display_mode = "a"
# Enabling p2 should not disturb the currently-showing mode.
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "b" in controller.available_modes
assert controller.current_display_mode == "a"
assert controller.available_modes[controller.current_mode_index] == "a"
def test_noop_when_enabled_set_unchanged(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": {"enabled": True}})
controller._reconcile_enabled_plugins()
load_calls = controller.plugin_manager.load_plugin.call_count
unload_calls = controller.plugin_manager.unload_plugin.call_count
# Reconcile again with no change — must not load/unload anything.
controller._reconcile_enabled_plugins()
assert controller.plugin_manager.load_plugin.call_count == load_calls
assert controller.plugin_manager.unload_plugin.call_count == unload_calls
def test_reconcile_ignores_non_dict_config_value(self, test_display_controller, caplog):
"""A malformed config value (e.g. a stray string where a plugin's
section should be a dict) must be treated as disabled, not crash
the reconcile with AttributeError, and should be logged so it's
visible to whoever has to debug the malformed config."""
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": "not-a-dict"})
with caplog.at_level("WARNING"):
controller._reconcile_enabled_plugins() # must not raise
assert "foo" not in controller.plugin_display_modes
assert "foo" not in controller.available_modes
assert any("foo" in r.message and "not a dict" in r.message for r in caplog.records)
def test_disable_keeps_callback_when_unsubscribe_fails(self, test_display_controller):
"""If config_service.unsubscribe() raises, _unregister_plugin must
keep the callback in _plugin_config_callbacks rather than losing the
only reference to it (it still tears down the plugin itself)."""
controller = test_display_controller
plugin = _make_plugin(["live"])
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
_set_config(controller, {"sports": {"enabled": True}})
controller._reconcile_enabled_plugins()
assert "sports" in controller._plugin_config_callbacks
controller.config_service.unsubscribe = MagicMock(side_effect=RuntimeError("boom"))
_set_config(controller, {"sports": {"enabled": False}})
controller._reconcile_enabled_plugins()
assert "sports" not in controller.plugin_display_modes
assert "sports" in controller._plugin_config_callbacks
class TestReconcileReturnValue:
"""_reconcile_enabled_plugins() returns True/False so the caller (run()'s
loop) only clears _pending_plugin_reconcile on success, keeping a
retryable failure's request alive instead of silently dropping it."""
def test_returns_true_on_success(self, test_display_controller):
controller = test_display_controller
plugin = _make_plugin(["foo"])
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
_set_config(controller, {"foo": {"enabled": True}})
assert controller._reconcile_enabled_plugins() is True
def test_returns_true_for_noop(self, test_display_controller):
controller = test_display_controller
_wire_plugin_manager(controller, {}, discovered=[])
_set_config(controller, {})
assert controller._reconcile_enabled_plugins() is True
def test_returns_false_on_discovery_failure(self, test_display_controller):
controller = test_display_controller
controller.plugin_manager.discover_plugins.side_effect = RuntimeError("boom")
_set_config(controller, {})
assert controller._reconcile_enabled_plugins() is False
def test_returns_true_when_no_plugin_manager(self, test_display_controller):
controller = test_display_controller
controller.plugin_manager = None
assert controller._reconcile_enabled_plugins() is True
class TestRunWithNoModesEnabled:
"""Before hot-reload, an empty available_modes at startup was permanent
-- the display never came back without a restart. Now that a plugin can
be enabled live from the web UI, run() must idle rather than exit."""
def test_idles_instead_of_exiting(self, test_display_controller):
controller = test_display_controller
assert controller.available_modes == []
sleep_calls = []
def fake_sleep(duration, tick_interval=1.0):
sleep_calls.append(duration)
if len(sleep_calls) >= 3:
# Stand in for the process being torn down; run() catches
# this via its broad except + finally, same as any other
# unexpected error during the loop.
raise RuntimeError("stop-test-loop")
controller._sleep_with_plugin_updates = fake_sleep
controller.run()
# Old behavior returned before ever reaching the loop body, so
# _sleep_with_plugin_updates would never have been called. The idle
# tick is short (not a long sleep) so a plugin enabled via the web
# UI while idle is picked up about as promptly as it would be once
# modes exist and the loop is iterating per-frame.
assert sleep_calls == [1, 1, 1]
class TestEnabledSetChanged:
def test_detects_toggle(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed({"a": {"enabled": True}}, {"a": {"enabled": False}}) is True
def test_no_change(self, test_display_controller):
c = test_display_controller
cfg = {"a": {"enabled": True}, "b": {"enabled": False}}
assert c._enabled_set_changed(cfg, dict(cfg)) is False
def test_new_enabled_section(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed(
{"a": {"enabled": True}},
{"a": {"enabled": True}, "b": {"enabled": True}},
) is True
def test_ignores_non_enabled_value_edits(self, test_display_controller):
c = test_display_controller
assert c._enabled_set_changed(
{"a": {"enabled": True, "duration": 30}},
{"a": {"enabled": True, "duration": 45}},
) is False
+103
View File
@@ -117,3 +117,106 @@ class TestDisplayManagerResourceManagement:
dm.cleanup() dm.cleanup()
dm.matrix.Clear.assert_called() dm.matrix.Clear.assert_called()
class TestDisplayManagerDoubleSided:
"""Double-sided mode: render once at logical size, tile across the chain."""
def _config(self, **double_sided):
"""Build a config (physical 128x32) with the given double_sided block."""
return {
'display': {
'hardware': {
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
},
'runtime': {'gpio_slowdown': 2},
'double_sided': double_sided,
},
'timezone': 'UTC',
'plugin_system': {'plugins_directory': 'plugins'},
}
def _captured_physical(self, mock_rgb_matrix):
"""Return the image handed to the canvas on the last update_display()."""
canvas = mock_rgb_matrix['matrix_instance'].CreateFrameCanvas.return_value
return canvas.SetImage.call_args[0][0]
def test_horizontal_reports_logical_dimensions(self, mock_rgb_matrix):
"""Plugins see the per-screen size, not the full physical chain."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
# Physical chain is 128x32; two side-by-side copies -> logical 64x32.
assert dm.matrix.width == 64
assert dm.matrix.height == 32
assert (dm.width, dm.height) == (64, 32)
assert dm.image.size == (64, 32)
def test_horizontal_tiles_image_across_chain(self, mock_rgb_matrix):
"""The logical screen is duplicated left/right into a full-chain frame."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
logical = Image.new('RGB', (64, 32), (0, 0, 0))
logical.putpixel((5, 5), (255, 0, 0))
dm.image = logical
dm.update_display()
physical = self._captured_physical(mock_rgb_matrix)
assert physical.size == (128, 32)
assert physical.getpixel((5, 5)) == (255, 0, 0)
assert physical.getpixel((69, 5)) == (255, 0, 0) # copy shifted +64
def test_vertical_axis_tiles_stacked(self, mock_rgb_matrix):
"""Vertical axis stacks copies (for panels on parallel outputs)."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='vertical'),
suppress_test_pattern=True)
# 128x32 split vertically -> logical 128x16.
assert (dm.matrix.width, dm.matrix.height) == (128, 16)
logical = Image.new('RGB', (128, 16), (0, 0, 0))
logical.putpixel((10, 3), (0, 255, 0))
dm.image = logical
dm.update_display()
physical = self._captured_physical(mock_rgb_matrix)
assert physical.size == (128, 32)
assert physical.getpixel((10, 3)) == (0, 255, 0)
assert physical.getpixel((10, 19)) == (0, 255, 0) # copy shifted +16
def test_indivisible_dimension_disables_mode(self, mock_rgb_matrix):
"""A physical size that doesn't divide evenly falls back to single."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=3, axis='horizontal'),
suppress_test_pattern=True)
assert dm._double_sided is None # 128 % 3 != 0
assert dm.matrix.width == 128
assert dm.image.size == (128, 32)
def test_disabled_blits_logical_image_unchanged(self, mock_rgb_matrix):
"""With the feature off, the rendered image is sent through untouched."""
from PIL import Image
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=False), suppress_test_pattern=True)
assert dm._double_sided is None
img = Image.new('RGB', (128, 32))
dm.image = img
dm.update_display()
assert self._captured_physical(mock_rgb_matrix) is img
def test_brightness_write_forwards_through_proxy(self, mock_rgb_matrix):
"""Setting brightness via the proxy reaches the real matrix."""
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
suppress_test_pattern=True)
assert dm.set_brightness(70) is True
assert mock_rgb_matrix['matrix_instance'].brightness == 70
+83
View File
@@ -0,0 +1,83 @@
"""
Tests for src.common.permission_utils's URL-credential redaction.
Covers the fix for a CodeQL clear-text-logging-of-secrets alert:
install_requirements_file() must never let a private index URL's embedded
user:pass@ credentials reach logs or its returned CompletedProcess, since
pip can echo that URL back verbatim in its own stderr/stdout on failure.
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
from src.common.permission_utils import _redact_url_credentials, install_requirements_file
class TestRedactUrlCredentials:
def test_redacts_embedded_basic_auth(self):
text = "Could not fetch URL https://alice:s3cr3t@pypi.example.com/simple/: 403"
redacted = _redact_url_credentials(text)
assert "s3cr3t" not in redacted
assert "alice" not in redacted
assert "https://***:***@pypi.example.com/simple/" in redacted
def test_leaves_credential_free_text_unchanged(self):
text = "ERROR: Could not find a version that satisfies the requirement foo==1.0"
assert _redact_url_credentials(text) == text
def test_handles_none_and_empty(self):
assert _redact_url_credentials(None) == ""
assert _redact_url_credentials("") == ""
def test_does_not_touch_denied_check_phrases(self):
"""The fixed phrases install_requirements_file greps for must survive
redaction untouched -- they don't overlap with URL syntax, but this
pins that assumption so a regex change can't silently break it."""
text = "sudo: a password is required"
assert _redact_url_credentials(text) == text
class TestInstallRequirementsFileRedaction:
@patch('src.common.permission_utils.subprocess.run')
def test_wrapper_path_redacts_stderr_and_stdout(self, mock_run, tmp_path):
"""safe_pip_install.sh exists in this repo, so install_requirements_file
takes the sudo-wrapper branch; a failing result must come back
with any embedded index-URL credentials already redacted."""
req_file = tmp_path / "requirements.txt"
req_file.write_text("requests\n")
mock_run.return_value = MagicMock(
returncode=1,
stdout="Looking in indexes: https://bob:hunter2@pypi.internal/simple\n",
stderr="ERROR https://bob:hunter2@pypi.internal/simple/foo: 401",
)
result = install_requirements_file(req_file, timeout=5)
assert "hunter2" not in result.stdout
assert "hunter2" not in result.stderr
assert "https://***:***@pypi.internal" in result.stdout
assert "https://***:***@pypi.internal" in result.stderr
@patch('src.common.permission_utils.subprocess.run')
@patch('src.common.permission_utils.Path.exists', return_value=False)
def test_no_wrapper_fallback_path_redacts_stderr_and_stdout(self, mock_exists, mock_run, tmp_path):
"""No safe_pip_install.sh wrapper -> falls straight to the
sys.executable pip fallback (the second subprocess.run call site);
its result must come back redacted too, independent of the wrapper
branch's own redaction above."""
req_file = tmp_path / "requirements.txt"
req_file.write_text("requests\n")
mock_run.return_value = MagicMock(
returncode=1,
stdout="Looking in indexes: https://carol:swordfish@pypi.internal/simple\n",
stderr="ERROR https://carol:swordfish@pypi.internal/simple/foo: 401",
)
result = install_requirements_file(req_file, timeout=5)
assert "swordfish" not in result.stdout
assert "swordfish" not in result.stderr
assert "https://***:***@pypi.internal" in result.stdout
assert "https://***:***@pypi.internal" in result.stderr
+93
View File
@@ -0,0 +1,93 @@
"""
Tests for src/plugin_system/plugin_health.py
Focus on the additive ``set_degraded`` mechanism used by the warn-only schema
validation path: it must surface a degraded reason without touching the circuit
breaker or causing the plugin to be skipped.
"""
from unittest.mock import MagicMock
from src.plugin_system.plugin_health import PluginHealthTracker, CircuitState
def _cache():
cache = MagicMock()
cache.get.return_value = None
return cache
def test_set_degraded_marks_and_surfaces_reason():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
assert summary["degraded"] is True
assert summary["degraded_reason"] == "bad config"
def test_set_degraded_none_clears():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
tracker.set_degraded("p", None)
summary = tracker.get_health_summary("p")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_set_degraded_does_not_affect_circuit_breaker():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
# Degraded is a *separate* signal from circuit health: the plugin is not
# counted as failing, the circuit stays closed, and it is not skipped.
assert summary["circuit_state"] == CircuitState.CLOSED.value
assert summary["consecutive_failures"] == 0
assert summary["is_healthy"] is True
assert tracker.should_skip_plugin("p") is False
def test_set_degraded_skips_redundant_cache_write():
cache = _cache()
tracker = PluginHealthTracker(cache)
tracker.set_degraded("p", "x")
writes_after_first = cache.set.call_count
assert writes_after_first >= 1
tracker.set_degraded("p", "x") # unchanged → no extra write
assert cache.set.call_count == writes_after_first
def test_default_summary_has_degraded_fields():
tracker = PluginHealthTracker(_cache())
summary = tracker.get_health_summary("never-seen")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_force_reload_refreshes_stale_in_memory_snapshot():
"""A long-lived reader (e.g. the web process) must not be pinned to the
first snapshot: force_reload re-reads persisted state and bypasses the
cache manager's memory tier so cross-process updates are visible."""
cache = _cache()
tracker = PluginHealthTracker(cache)
# First read snapshots an empty (healthy) state into the in-memory copy.
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# The display service later persists a failing/open state.
cache.get.return_value = {
"consecutive_failures": 5,
"circuit_state": "open",
"total_failures": 5,
"total_successes": 0,
}
# A plain read is still pinned to the stale snapshot...
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# ...but force_reload observes the new persisted state.
fresh = tracker.get_health_summary("p", force_reload=True)
assert fresh["consecutive_failures"] == 5
assert fresh["circuit_state"] == "open"
# and it asked the cache to bypass the in-memory tier (memory_ttl=0).
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
+125 -3
View File
@@ -4,6 +4,8 @@ Tests for PluginLoader.
Tests plugin directory discovery, module loading, and class instantiation. Tests plugin directory discovery, module loading, and class instantiation.
""" """
import subprocess
import pytest import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from src.plugin_system.plugin_loader import PluginLoader from src.plugin_system.plugin_loader import PluginLoader
@@ -191,7 +193,7 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=0) mock_subprocess.return_value = MagicMock(returncode=0)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
mock_subprocess.assert_called_once() mock_subprocess.assert_called_once()
@@ -202,7 +204,7 @@ class TestPluginLoader:
plugin_dir = tmp_plugins_dir / "test_plugin" plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir() plugin_dir.mkdir()
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True assert result is True
mock_subprocess.assert_not_called() mock_subprocess.assert_not_called()
@@ -217,6 +219,126 @@ class TestPluginLoader:
mock_subprocess.return_value = MagicMock(returncode=1) mock_subprocess.return_value = MagicMock(returncode=1)
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin") result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is False assert result is False
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_retries_with_ignore_installed_on_apt_conflict(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""An apt-managed package with no pip RECORD file triggers a retry with
--ignore-installed rather than silently assuming the old version satisfies
the requirement. requirements_are_satisfied() is mocked False here because
this scenario is exactly the case where the installed (apt) version does
NOT satisfy the pin that's why pip attempts a reinstall in the first place."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
retry_attempt = MagicMock(returncode=0, stderr="")
mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
retry_cmd = mock_subprocess.call_args_list[1][0][0]
assert "--ignore-installed" in retry_cmd
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_apt_conflict_retry_also_fails(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""Still tolerates the failure (returns True) if the --ignore-installed
retry itself fails, matching the prior soft-fallback behavior."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
mock_subprocess.side_effect = [first_attempt, retry_attempt]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
@patch('subprocess.run')
def test_install_dependencies_apt_conflict_retry_times_out(
self, mock_subprocess, mock_satisfied, plugin_loader, tmp_plugins_dir
):
"""A retry timeout must be tolerated the same way as a retry failure
(return True), not propagate to the outer TimeoutExpired handler and
return False."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("requests>=2.33.0,<3.0.0\n")
first_attempt = MagicMock(
returncode=1,
stderr="ERROR: Cannot uninstall requests 2.32.3\nuninstall-no-record-file"
)
mock_subprocess.side_effect = [
first_attempt,
subprocess.TimeoutExpired(cmd="pip", timeout=300),
]
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
assert mock_subprocess.call_count == 2
@patch('subprocess.run')
def test_install_dependencies_already_satisfied_skips_pip(self, mock_subprocess, plugin_loader, tmp_plugins_dir):
"""A requirement already satisfied in the current environment shouldn't invoke pip."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
requirements_file = plugin_dir / "requirements.txt"
requirements_file.write_text("pytest>=1.0\n")
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
assert result is True
mock_subprocess.assert_not_called()
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
"""plugins_dir is a required argument, not an optional trust-me flag --
calling without it must fail loudly (TypeError) rather than silently
falling back to trusting plugin_dir unchecked."""
plugin_dir = tmp_plugins_dir / "test_plugin"
plugin_dir.mkdir()
with pytest.raises(TypeError):
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
@patch('subprocess.run')
def test_install_dependencies_rejects_path_outside_plugins_dir(
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
):
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
manifest-derived id crafted to traverse elsewhere) must be rejected
rather than read from -- this is the path-injection containment
check CodeQL flagged as missing."""
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
assert result is False
mock_subprocess.assert_not_called()
+79
View File
@@ -0,0 +1,79 @@
"""
Tests for PluginManager._validate_config_schema_soft (Phase 1, warn-only schema
validation).
Contract:
- A schema violation logs a warning and marks the plugin degraded in the health
tracker, but never raises and never changes load pass/fail behaviour.
- A valid config (or no schema) clears any stale degraded flag.
- The method is safe when no health tracker is wired.
"""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.plugin_manager import PluginManager
@pytest.fixture
def pm():
with tempfile.TemporaryDirectory() as tmp:
manager = PluginManager(plugins_dir=str(Path(tmp) / "plugins"))
manager.schema_manager = MagicMock()
yield manager
def test_invalid_config_marks_degraded_without_raising(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (
False,
["Missing required field: 'api_key'"],
)
pm._validate_config_schema_soft("youtube-stats", {})
pm.health_tracker.set_degraded.assert_called_once()
plugin_id, reason = pm.health_tracker.set_degraded.call_args[0]
assert plugin_id == "youtube-stats"
assert "api_key" in reason
def test_valid_config_clears_degraded(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (True, [])
pm._validate_config_schema_soft("p", {"api_key": "x"})
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
def test_no_schema_clears_degraded(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = None
pm._validate_config_schema_soft("p", {})
pm.health_tracker.set_degraded.assert_called_once_with("p", None)
def test_validation_exception_is_swallowed(pm):
pm.health_tracker = MagicMock()
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.side_effect = RuntimeError("boom")
# Must not raise — the validation machinery failing must never break loading.
pm._validate_config_schema_soft("p", {})
def test_safe_without_health_tracker(pm):
pm.health_tracker = None
pm.schema_manager.load_schema.return_value = {"type": "object"}
pm.schema_manager.validate_config_against_schema.return_value = (False, ["err"])
# Must not raise even though there is no tracker to record against.
pm._validate_config_schema_soft("p", {})
+52 -2
View File
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
from pathlib import Path from pathlib import Path
from src.plugin_system.plugin_manager import PluginManager from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.plugin_state import PluginState from src.plugin_system.plugin_state import PluginState
from src.plugin_system.resource_monitor import PluginResourceMonitor
class TestPluginManager: class TestPluginManager:
"""Test PluginManager functionality.""" """Test PluginManager functionality."""
@@ -78,14 +79,63 @@ class TestPluginManager:
assert result is False assert result is False
assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR assert pm.state_manager.get_state("non_existent_plugin") == PluginState.ERROR
def test_run_scheduled_updates_calls_update_with_resource_monitor(
self, mock_config_manager, mock_display_manager, mock_cache_manager
):
"""Regression test: run_scheduled_updates() must actually call a
plugin's update() when self.resource_monitor is set (as it is in
every real deployment -- display_controller.py and web_interface/
app.py both assign a real PluginResourceMonitor after construction).
Previously, the resource_monitor branch wrapped the call in a
function stored as a *class* attribute on a dynamically-built type
(`type('obj', (object,), {'update': monitored_update})()`), which
the descriptor protocol turns into a bound method on access --
silently passing the synthetic instance as an implicit first
argument to monitored_update(), which takes none. Every plugin's
scheduled update failed with "monitored_update() takes 0 positional
arguments but 1 was given" and was silently swallowed into a
circuit-breaker retry loop that never succeeded, so plugin data
(scores, odds, etc.) never refreshed.
"""
with patch('src.plugin_system.plugin_manager.ensure_directory_permissions'):
pm = PluginManager(
plugins_dir="plugins",
config_manager=mock_config_manager,
display_manager=mock_display_manager,
cache_manager=mock_cache_manager
)
plugin_instance = MagicMock()
plugin_instance.enabled = True
plugin_instance.update = MagicMock()
pm.plugins["test_plugin"] = plugin_instance
pm.plugin_manifests["test_plugin"] = {"update_interval": 10}
pm.state_manager.set_state("test_plugin", PluginState.ENABLED)
# Plain MagicMock, not the mock_cache_manager fixture: this test
# is about run_scheduled_updates() actually invoking update()
# through the resource-monitor wrapper, not about
# PluginResourceMonitor's own cache-backed metrics persistence
# (which calls cache_manager.get(..., memory_ttl=...) --
# a kwarg the fixture's mock_get() doesn't accept).
pm.resource_monitor = PluginResourceMonitor(MagicMock())
pm.run_scheduled_updates(current_time=time.time())
plugin_instance.update.assert_called_once()
assert "test_plugin" in pm.plugin_last_update
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
class TestPluginLoader: class TestPluginLoader:
"""Test PluginLoader functionality.""" """Test PluginLoader functionality."""
def test_dependency_check(self): def test_dependency_check(self):
"""Test dependency checking logic.""" """Test dependency checking logic."""
# This would test _check_dependencies_installed and _install_plugin_dependencies # Covered by test_plugin_loader.py's install_dependencies tests,
# which requires mocking subprocess calls and file operations # which exercise requirements_has_real_deps/requirements_are_satisfied
# and the pip subprocess fallback.
class TestPluginExecutor: class TestPluginExecutor:
+129
View File
@@ -0,0 +1,129 @@
"""
Tests for src/plugin_system/resource_monitor.py
Focus areas:
- Execution-time metrics are captured regardless of psutil availability.
- CPU sampling is non-blocking (regression guard for the previous
``cpu_percent(interval=0.1)`` call that blocked 100 ms per monitored call).
- Resource limits are enforced.
"""
import time
import pytest
from unittest.mock import MagicMock
from src.plugin_system.resource_monitor import (
PluginResourceMonitor,
ResourceLimits,
ResourceLimitExceeded,
PSUTIL_AVAILABLE,
)
def _cache():
cache = MagicMock()
cache.get.return_value = None
return cache
class TestExecutionTimeMetrics:
def test_monitor_call_returns_value_and_records_call(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
result = mon.monitor_call("p", lambda: 42)
assert result == 42
metrics = mon.get_metrics("p")
assert metrics.call_count == 1
assert metrics.total_execution_time >= 0.0
def test_avg_and_max_execution_time(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
mon.monitor_call("p", lambda: time.sleep(0.01))
mon.monitor_call("p", lambda: None)
summary = mon.get_metrics_summary("p")
assert summary["call_count"] == 2
assert summary["max_execution_time"] >= summary["avg_execution_time"] >= 0.0
def test_exception_propagates_but_is_still_timed(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
def boom():
raise ValueError("nope")
with pytest.raises(ValueError):
mon.monitor_call("p", boom)
# Execution time is still recorded even when the call raised.
assert mon.get_metrics("p").execution_time >= 0.0
class TestNonBlockingCpu:
def test_cpu_sampling_is_fast_when_disabled(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
start = time.time()
for _ in range(50):
mon._get_process_cpu_percent()
# The old implementation blocked ~0.1s/call (~5s for 50). Non-blocking
# must complete near-instantly.
assert time.time() - start < 0.5
assert mon._get_process_cpu_percent() == 0.0
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not installed")
def test_cpu_sampling_is_fast_with_psutil(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=True)
assert mon._process is not None
start = time.time()
for _ in range(30):
mon._get_process_cpu_percent()
# 30 blocking 0.1s samples would be ~3s; non-blocking must be well under.
assert time.time() - start < 0.5
def test_monitor_call_does_not_block_on_cpu_sampling(self):
mon = PluginResourceMonitor(_cache()) # enable depends on psutil
start = time.time()
for _ in range(25):
mon.monitor_call("p", lambda: None)
# 25 * 0.1s = 2.5s under the old blocking bug; must be far faster now.
assert time.time() - start < 1.0
class TestResourceLimits:
def test_execution_time_limit_raises(self):
mon = PluginResourceMonitor(_cache(), enable_monitoring=False)
mon.set_limits("p", ResourceLimits(max_execution_time=0.001))
with pytest.raises(ResourceLimitExceeded):
mon.monitor_call("p", lambda: time.sleep(0.02))
def test_reset_metrics_clears_counts(self):
cache = _cache()
mon = PluginResourceMonitor(cache, enable_monitoring=False)
mon.monitor_call("p", lambda: None)
assert mon.get_metrics("p").call_count == 1
mon.reset_metrics("p")
assert mon.get_metrics("p").call_count == 0
class TestForceReload:
def test_force_reload_refreshes_stale_snapshot(self):
"""A read-only consumer must see the writer process's latest persisted
metrics rather than a pinned first snapshot."""
cache = MagicMock()
persisted = {"value": None} # only the metrics key returns data
def cache_get(key, max_age=None, memory_ttl=None):
return persisted["value"] if key.startswith("plugin_metrics:") else None
cache.get.side_effect = cache_get
mon = PluginResourceMonitor(cache, enable_monitoring=False)
# First read snapshots empty metrics.
assert mon.get_metrics_summary("p")["call_count"] == 0
# The display service later persists real metrics.
persisted["value"] = {"call_count": 7, "total_execution_time": 1.4}
# Plain read stays stale...
assert mon.get_metrics_summary("p")["call_count"] == 0
# ...force_reload picks up the persisted values and bypasses memory.
fresh = mon.get_metrics_summary("p", force_reload=True)
assert fresh["call_count"] == 7
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
+109
View File
@@ -43,6 +43,115 @@ class TestUninstallTombstone(unittest.TestCase):
self.assertNotIn("foo", self.sm._uninstall_tombstones) self.assertNotIn("foo", self.sm._uninstall_tombstones)
class TestPersistentUninstallRegistry(unittest.TestCase):
"""Regression tests for the persistent uninstall registry that stops a
core `git pull` update from resurrecting built-in plugins the user
removed (plugins committed under plugin-repos/)."""
def setUp(self):
self._tmp = TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.plugins_dir = Path(self._tmp.name) / "plugin-repos"
self.plugins_dir.mkdir()
self.registry_path = Path(self._tmp.name) / "config" / "uninstalled_plugins.json"
self.sm = PluginStoreManager(
plugins_dir=str(self.plugins_dir),
uninstalled_registry_path=str(self.registry_path),
)
def _make_plugin_dir(self, plugin_id):
"""Simulate a built-in plugin restored on disk (e.g. by git pull)."""
d = self.plugins_dir / plugin_id
d.mkdir(parents=True)
(d / "manifest.json").write_text('{"id": "%s"}' % plugin_id)
return d
def test_unrecorded_plugin_is_not_uninstalled(self):
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
def test_record_persists_across_instances(self):
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertTrue(self.registry_path.exists())
# A fresh manager (simulating a service restart after update) still sees it.
fresh = PluginStoreManager(
plugins_dir=str(self.plugins_dir),
uninstalled_registry_path=str(self.registry_path),
)
self.assertTrue(fresh.is_plugin_uninstalled("web-ui-info"))
def test_forget_clears_record(self):
self.sm.record_uninstalled_plugin("web-ui-info")
self.sm.forget_uninstalled_plugin("web-ui-info")
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
def test_purge_removes_resurrected_plugin(self):
# The bug: user removed web-ui-info, then a git pull restored its
# committed files. Recorded uninstall + purge must re-remove it.
self._make_plugin_dir("web-ui-info")
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertTrue((self.plugins_dir / "web-ui-info").exists())
removed = self.sm.purge_uninstalled_plugins()
self.assertEqual(removed, ["web-ui-info"])
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
# Record is kept so the purge stays idempotent across future updates.
self.assertTrue(self.sm.is_plugin_uninstalled("web-ui-info"))
def test_purge_leaves_non_uninstalled_plugins_alone(self):
self._make_plugin_dir("baseball-scoreboard") # present, not recorded
self._make_plugin_dir("web-ui-info")
self.sm.record_uninstalled_plugin("web-ui-info")
self.sm.purge_uninstalled_plugins()
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
self.assertFalse((self.plugins_dir / "web-ui-info").exists())
def test_purge_noop_when_plugin_absent(self):
# Recorded but never restored on disk — nothing to remove.
self.sm.record_uninstalled_plugin("web-ui-info")
self.assertEqual(self.sm.purge_uninstalled_plugins(), [])
def test_corrupt_registry_is_ignored(self):
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
self.registry_path.write_text("{ not valid json")
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
self.assertFalse(self.sm.is_plugin_uninstalled("web-ui-info"))
def _write_raw_registry(self, value):
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
import json as _json
self.registry_path.write_text(_json.dumps(value))
def test_empty_id_does_not_wipe_plugins_root(self):
# An empty id resolves to plugins_dir itself; purge must never delete it.
self._make_plugin_dir("baseball-scoreboard")
self._write_raw_registry([""])
removed = self.sm.purge_uninstalled_plugins()
self.assertEqual(removed, [])
self.assertTrue(self.plugins_dir.exists())
self.assertTrue((self.plugins_dir / "baseball-scoreboard").exists())
# Invalid id is filtered out entirely.
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
def test_traversal_ids_are_ignored(self):
for bad in ["..", "../evil", "a/b", "."]:
with self.subTest(bad=bad):
self.assertFalse(self.sm._is_valid_plugin_id(bad))
self._write_raw_registry(["../evil", "..", "web-ui-info"])
# Only the safe id survives the read.
self.assertEqual(self.sm.get_uninstalled_plugins(), {"web-ui-info"})
def test_record_rejects_invalid_id(self):
self.sm.record_uninstalled_plugin("")
self.sm.record_uninstalled_plugin("../escape")
self.assertEqual(self.sm.get_uninstalled_plugins(), set())
class TestGitInfoCache(unittest.TestCase): class TestGitInfoCache(unittest.TestCase):
def setUp(self): def setUp(self):
self._tmp = TemporaryDirectory() self._tmp = TemporaryDirectory()
+138
View File
@@ -0,0 +1,138 @@
"""
Tests for src/vegas_mode/plugin_adapter.py
Covers PluginAdapter._strip_scroll_padding(): the heuristic that crops a
plugin's own baked-in leading/trailing blank margins before Vegas mode
composites the content, so vegas_scroll.separator_width is the only gap
applied between items.
"""
import logging
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.plugin_adapter import PluginAdapter
class FakeDisplayManager:
width = 64
height = 32
class FakePlugin:
def __init__(self, scroll_helper):
self.scroll_helper = scroll_helper
@pytest.fixture
def adapter():
return PluginAdapter(FakeDisplayManager())
def _solid(width: int, height: int, color: tuple) -> Image.Image:
"""Create a solid-color RGB image of the given dimensions."""
return Image.new('RGB', (width, height), color)
class TestStripScrollPadding:
def test_leading_pad_from_create_scrolling_image_is_stripped(self, adapter):
sh = ScrollHelper(64, 32)
item = _solid(40, 32, (200, 50, 50))
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == 40
assert images[0].getpixel((0, 0)) == (200, 50, 50)
def test_leading_and_trailing_pad_both_stripped(self, adapter):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
sh.set_scrolling_image(full)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == content_w
assert images[0].getpixel((0, 0)) == (10, 220, 30)
assert images[0].getpixel((content_w - 1, 0)) == (10, 220, 30)
def test_leading_only_pad_stripped_trailing_content_kept(self, adapter):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(64 + content_w, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (5, 5, 250)), (64, 0))
sh.set_scrolling_image(full)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == content_w
assert images[0].getpixel((0, 0)) == (5, 5, 250)
def test_trailing_only_pad_stripped_leading_content_kept(self, adapter):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(content_w + 64, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (5, 5, 250)), (0, 0))
sh.set_scrolling_image(full)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == content_w
assert images[0].getpixel((0, 0)) == (5, 5, 250)
def test_no_margin_image_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
raw = _solid(150, 32, (5, 5, 5))
raw.paste(_solid(50, 32, (123, 45, 67)), (0, 0))
sh.set_scrolling_image(raw)
images = adapter._get_scroll_helper_content(FakePlugin(sh), "no_margin")
assert images[0].width == 150
def test_degenerate_all_black_image_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
sh.set_scrolling_image(_solid(50, 32, (0, 0, 0)))
images = adapter._get_scroll_helper_content(FakePlugin(sh), "all_black")
assert images[0].width == 50
def test_missing_display_width_attribute_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
item = _solid(40, 32, (200, 50, 50))
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
original_width = sh.cached_image.width
del sh.display_width
images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert images[0].width == original_width
def test_pad_width_not_smaller_than_image_left_untouched(self, adapter):
sh = ScrollHelper(64, 32)
sh.set_scrolling_image(_solid(64, 32, (0, 0, 0)))
images = adapter._get_scroll_helper_content(FakePlugin(sh), "narrow")
assert images[0].width == 64
def test_both_edges_matching_logs_warning(self, adapter, caplog):
sh = ScrollHelper(64, 32)
content_w = 80
full = _solid(64 + content_w + 64, 32, (0, 0, 0))
full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0))
sh.set_scrolling_image(full)
with caplog.at_level(logging.WARNING, logger="src.vegas_mode.plugin_adapter"):
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
assert any("Stripping scroll_helper padding" in r.message for r in caplog.records)
def test_single_edge_match_logs_info_not_warning(self, adapter, caplog):
sh = ScrollHelper(64, 32)
item = _solid(40, 32, (200, 50, 50))
sh.create_scrolling_image([item], item_gap=10, element_gap=0)
with caplog.at_level(logging.INFO, logger="src.vegas_mode.plugin_adapter"):
adapter._get_scroll_helper_content(FakePlugin(sh), "ticker")
strip_records = [r for r in caplog.records if "Stripping scroll_helper padding" in r.message]
assert len(strip_records) == 1
assert strip_records[0].levelno == logging.INFO
+104
View File
@@ -144,6 +144,59 @@ class TestConfigAPI:
assert response.status_code in [400, 500] assert response.status_code in [400, 500]
def test_save_double_sided_settings(self, client, mock_config_manager):
"""Double-sided form fields are persisted under display.double_sided."""
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'vertical',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
saved = mock_config_manager.save_config_atomic.call_args[0][0]
assert saved['display']['double_sided'] == {
'enabled': True, 'copies': 2, 'axis': 'vertical',
}
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
response = client.post(
'/api/v3/config/main',
data={'double_sided_copies': '4', 'double_sided_axis': 'horizontal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
assert ds['copies'] == 4
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
"""copies < 2 is rejected with a 400 before any save."""
response = client.post(
'/api/v3/config/main',
data={'double_sided_enabled': 'true', 'double_sided_copies': '1'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_invalid_axis_rejected(self, client, mock_config_manager):
"""An unknown axis is rejected with a 400 before any save."""
response = client.post(
'/api/v3/config/main',
data={'double_sided_enabled': 'true', 'double_sided_axis': 'diagonal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
mock_config_manager.save_config_atomic.assert_not_called()
def test_get_secrets_config(self, client, mock_config_manager): def test_get_secrets_config(self, client, mock_config_manager):
"""Test getting secrets configuration.""" """Test getting secrets configuration."""
response = client.get('/api/v3/config/secrets') response = client.get('/api/v3/config/secrets')
@@ -706,3 +759,54 @@ class TestDottedKeyNormalization:
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams') teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
assert isinstance(teams, list), f"Expected list, got: {type(teams)}" assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
assert teams == [], f"Expected empty default list, got: {teams}" assert teams == [], f"Expected empty default list, got: {teams}"
class TestPluginHealthRoutes:
"""Phase 1: /plugins/health and /plugins/metrics build per-installed-id so
they surface cross-process data persisted by the display service."""
def test_health_route_builds_per_installed_id(self, client, mock_plugin_manager):
from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.plugin_health import PluginHealthTracker
cache = MagicMock()
cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugin_manifests = {'p1': {}, 'p2': {}}
mock_plugin_manager.health_tracker = PluginHealthTracker(cache)
resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200
data = resp.get_json()['data']
assert set(data.keys()) == {'p1', 'p2'}
assert data['p1']['is_healthy'] is True
assert data['p1']['degraded'] is False
def test_health_route_reports_not_available_without_tracker(self, client, mock_plugin_manager):
from web_interface.blueprints.api_v3 import api_v3
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.health_tracker = None
resp = client.get('/api/v3/plugins/health')
assert resp.status_code == 200
body = resp.get_json()
assert body['data'] == {}
assert 'not available' in body['message'].lower()
def test_metrics_route_builds_per_installed_id(self, client, mock_plugin_manager):
from web_interface.blueprints.api_v3 import api_v3
from src.plugin_system.resource_monitor import PluginResourceMonitor
cache = MagicMock()
cache.get.return_value = None
api_v3.plugin_manager = mock_plugin_manager
mock_plugin_manager.plugin_manifests = {'p1': {}}
mock_plugin_manager.resource_monitor = PluginResourceMonitor(
cache, enable_monitoring=False
)
resp = client.get('/api/v3/plugins/metrics')
assert resp.status_code == 200
data = resp.get_json()['data']
assert 'p1' in data
assert data['p1']['call_count'] == 0
+184
View File
@@ -0,0 +1,184 @@
"""
Smoke tests for the settings tooltips + search UI.
These render the settings partials through Flask and assert that every settings
field carries:
- a stable search anchor id (`id="setting-..."` on its .form-group), and
- an info tooltip (`class="help-tip"` emitted by the help_tip macro).
They guard against macro/import breakage and against fields losing their anchor
or tooltip when partials are edited. See web_interface/static/v3/js/tooltips.js
and settings-search.js for the consumers of this markup.
"""
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))
# A realistic-enough config so the hand-written partials render every field.
REALISTIC_CONFIG = {
"web_display_autostart": True,
"timezone": "America/Chicago",
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
"plugin_system": {
"auto_discover": True,
"auto_load_enabled": True,
"development_mode": False,
"plugins_directory": "plugin-repos",
},
"schedule": {},
"dim_schedule": {"dim_brightness": 30},
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
"display": {
"hardware": {
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
"inverse_colors": False, "show_refresh_rate": False,
},
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
"use_short_date_format": False,
"dynamic_duration": {"max_duration_seconds": 180},
"vegas_scroll": {
"enabled": False, "scroll_speed": 50, "separator_width": 32,
"target_fps": 125, "buffer_ahead": 2,
"plugin_order": [], "excluded_plugins": [],
},
"display_durations": {"clock": 15, "weather": 30},
},
}
@pytest.fixture
def client():
base = PROJECT_ROOT / "web_interface"
app = Flask(
__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"),
)
app.config["TESTING"] = True
from web_interface.blueprints import pages_v3 as pv
mock_cm = MagicMock()
mock_cm.load_config.return_value = REALISTIC_CONFIG
mock_cm.get_raw_file_content.return_value = REALISTIC_CONFIG
mock_cm.get_config_path.return_value = "config/config.json"
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
pv.pages_v3.config_manager = mock_cm
pv.pages_v3.plugin_manager = MagicMock(plugins={})
app.register_blueprint(pv.pages_v3, url_prefix="/v3")
return app.test_client()
# Settings tabs that must expose searchable, tooltipped fields.
SETTINGS_TABS = ["general", "display", "durations", "schedule", "wifi"]
@pytest.mark.parametrize("tab", SETTINGS_TABS)
def test_settings_partial_has_tooltips_and_anchors(client, tab):
resp = client.get(f"/v3/partials/{tab}")
assert resp.status_code == 200, f"{tab} partial failed to render"
body = resp.get_data(as_text=True)
assert 'class="help-tip"' in body, f"{tab}: no tooltips rendered"
assert 'id="setting-' in body, f"{tab}: no search anchors rendered"
# Every settings field should be both anchored and tooltipped; tooltip count
# should not exceed anchor count (each field has at most one help_tip).
anchors = body.count('id="setting-')
tips = body.count('class="help-tip"')
assert tips >= 1 and anchors >= 1
assert tips <= anchors, f"{tab}: more tooltips ({tips}) than anchors ({anchors})"
@pytest.mark.parametrize("tab", SETTINGS_TABS)
def test_settings_partial_has_per_tab_filter(client, tab):
body = client.get(f"/v3/partials/{tab}").get_data(as_text=True)
assert 'class="settings-filter' in body, f"{tab}: per-tab filter box missing"
def test_display_tooltip_carries_rich_text(client):
# The brightness tooltip should include the authored guidance, not just a label.
body = client.get("/v3/partials/display").get_data(as_text=True)
assert 'id="setting-display-brightness"' in body
assert "Recommended:" in body # rich detail authored into a tooltip
def test_search_index_endpoint(client):
"""The server-built search index powers the global settings search."""
resp = client.get("/v3/settings/search-index")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict) and isinstance(data.get("fields"), list)
fields = data["fields"]
assert len(fields) >= 40, "expected the core settings fields to be indexed"
by_id = {f["anchorId"]: f for f in fields}
# Representative fields across tabs must be present with usable text.
for anchor in ("setting-general-timezone", "setting-display-brightness",
"setting-wifi-password", "setting-durations-clock"):
assert anchor in by_id, f"{anchor} missing from search index"
entry = by_id[anchor]
assert entry["label"], f"{anchor} has no label"
assert entry["help"], f"{anchor} has no tooltip help"
assert entry["tab"] and entry["tabLabel"]
# Every entry must carry a non-empty label and a stable anchor id.
assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields)
# Section context is captured for grouped fields (e.g. Display hardware).
assert by_id["setting-display-brightness"]["section"] == "Hardware Configuration"
def test_plugin_config_partial_has_filter_and_nested_anchors():
"""Plugin config tabs expose the per-tab filter and anchor nested fields.
The client fixture has no installed plugins, so render the partial directly
with a schema that includes a nested section (render_nested_section).
"""
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {
"id": "demo-plugin", "name": "Demo Plugin", "description": "A demo",
"enabled": True, "author": "me", "version": "1.0.0",
}
schema = {
"type": "object",
"properties": {
"title_text": {"type": "string", "title": "Title Text",
"description": "The heading."},
"advanced": {
"type": "object", "title": "Advanced Options",
"description": "Nested options.",
"properties": {
"scroll_speed": {"type": "integer", "title": "Scroll Speed",
"description": "Pixels per second."},
},
},
},
}
config = {"title_text": "Hi", "advanced": {"scroll_speed": 50}}
html = env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=schema, config=config
)
assert 'class="settings-filter' in html, "plugin config: per-tab filter box missing"
assert "nested-content" in html, "plugin config: nested section not rendered"
assert 'id="setting-' in html, "plugin config: no search anchors rendered"
assert 'class="help-tip"' in html, "plugin config: no tooltips rendered"
@@ -0,0 +1,93 @@
"""
Regression test for saving plugin config fields whose schema keys contain dots
(e.g. soccer league keys like "fifa.world", "eng.1", "usa.1").
Bug: the web config form posts form-data with dotted paths such as
"leagues.fifa.world.enabled". The helpers that resolve those paths split on every
dot, so the dotted league key "fifa.world" was mistaken for nested "fifa" ->
"world" objects. Per-league edits (enable, favorite_teams, nested booleans) were
written to a fabricated "leagues.fifa.world" branch while the real league object
was never updated, so the save silently dropped the change and the saved config
came out byte-identical.
"""
import unittest
from web_interface.blueprints.api_v3 import (
_get_schema_property,
_set_nested_value,
_parse_form_value_with_schema,
)
SCHEMA = {
"type": "object",
"properties": {
"leagues": {
"type": "object",
"properties": {
"fifa.world": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"favorite_teams": {
"type": "array",
"items": {"type": "string"},
},
"display_modes": {
"type": "object",
"properties": {"live": {"type": "boolean"}},
},
},
}
},
}
},
}
class TestDottedLeagueKeys(unittest.TestCase):
def test_schema_lookup_resolves_dotted_league_key(self):
prop = _get_schema_property(SCHEMA, "leagues.fifa.world.favorite_teams")
self.assertIsNotNone(prop, "dotted league key path should resolve")
self.assertEqual(prop.get("type"), "array")
def test_schema_lookup_resolves_nested_object_beneath_dotted_key(self):
live = _get_schema_property(SCHEMA, "leagues.fifa.world.display_modes.live")
self.assertIsNotNone(live)
self.assertEqual(live.get("type"), "boolean")
def test_parse_typed_value_for_dotted_key(self):
# Comma-separated text input "USA" must become an array, not the raw string.
parsed = _parse_form_value_with_schema(
"USA", "leagues.fifa.world.favorite_teams", SCHEMA
)
self.assertEqual(parsed, ["USA"])
def test_set_value_updates_real_league_not_fabricated_branch(self):
config = {"leagues": {"fifa.world": {"enabled": False, "favorite_teams": []}}}
_set_nested_value(config, "leagues.fifa.world.enabled", True)
_set_nested_value(config, "leagues.fifa.world.favorite_teams", ["USA"])
self.assertTrue(config["leagues"]["fifa.world"]["enabled"])
self.assertEqual(config["leagues"]["fifa.world"]["favorite_teams"], ["USA"])
# The real league must be updated and no fabricated "fifa" branch created.
self.assertNotIn("fifa", config["leagues"])
def test_set_value_into_missing_leaf_lands_in_real_league(self):
# A leaf that does not exist yet still resolves into the real dotted league.
config = {"leagues": {"fifa.world": {"enabled": False}}}
_set_nested_value(config, "leagues.fifa.world.display_modes.live", True)
self.assertTrue(
config["leagues"]["fifa.world"]["display_modes"]["live"]
)
self.assertNotIn("fifa", config["leagues"])
def test_plain_nested_paths_still_work(self):
config = {}
_set_nested_value(config, "customization.text.font", "small")
self.assertEqual(config["customization"]["text"]["font"], "small")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,76 @@
"""Guards that every privileged systemctl call the web interface makes is
covered by a passwordless-sudo grant in configure_web_sudo.sh.
The web interface runs headless (no TTY), so any `sudo` call that is not
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
password prompt and fails with:
sudo: a terminal is required to read the password
sudo matches the command line by exact string, so `systemctl start ledmatrix`
and `systemctl start ledmatrix.service` are NOT interchangeable. This test
parses both the production blueprint and the sudoers-generator script and
asserts the (verb, unit) pairs line up, catching the suffix-mismatch class of
bug before it ships.
"""
import ast
import re
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
"""Return (verb, unit) for every list literal beginning with
['sudo', 'systemctl', ...] passed to a subprocess call in the source."""
calls: set[tuple[str, str]] = set()
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.List):
continue
elts = node.elts
if len(elts) < 4:
continue
if not all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in elts[:4]):
continue
if elts[0].value == "sudo" and elts[1].value == "systemctl":
calls.add((elts[2].value, elts[3].value))
return calls
def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
"""Return (verb, unit) for each `$SYSTEMCTL_PATH <verb> <unit>` NOPASSWD
grant emitted by the sudoers-generator script."""
rules: set[tuple[str, str]] = set()
for match in re.finditer(r"\$SYSTEMCTL_PATH\s+(\S+)\s+(\S+)", script):
verb, unit = match.group(1), match.group(2).rstrip('"')
rules.add((verb, unit))
return rules
def test_every_sudo_systemctl_call_is_granted() -> None:
calls = _sudo_systemctl_calls(API_V3.read_text())
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
assert calls, "expected to find sudo systemctl calls in api_v3.py"
uncovered = {c for c in calls if c not in rules}
assert not uncovered, (
"These sudo systemctl calls have no matching NOPASSWD grant in "
"configure_web_sudo.sh; they will fail headless with "
"'sudo: a terminal is required to read the password': "
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
)
def test_units_are_fully_qualified() -> None:
"""Privileged systemctl calls must name the unit as <name>.service so they
match the sudoers grants, which use the fully-qualified unit name."""
calls = _sudo_systemctl_calls(API_V3.read_text())
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
assert not unqualified, (
"sudo systemctl calls must use fully-qualified .service unit names: "
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(unqualified))
)
+66 -1
View File
@@ -3,6 +3,7 @@ import json
import logging import logging
import os import os
import queue import queue
import re
import shutil import shutil
import sys import sys
import subprocess import subprocess
@@ -27,6 +28,7 @@ from src.plugin_system.health_monitor import PluginHealthMonitor
_JOURNALCTL = shutil.which('journalctl') _JOURNALCTL = shutil.which('journalctl')
_SYSTEMCTL = shutil.which('systemctl') _SYSTEMCTL = shutil.which('systemctl')
_VCGENCMD = shutil.which('vcgencmd')
# Create Flask app # Create Flask app
app = Flask(__name__) app = Flask(__name__)
@@ -79,6 +81,21 @@ plugin_manager = PluginManager(
cache_manager=None # Not needed for web interface cache_manager=None # Not needed for web interface
) )
plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir)) plugin_store_manager = PluginStoreManager(plugins_dir=str(plugins_dir))
# A core `git pull` update (or any checkout) restores built-in plugins
# committed under plugin-repos/, even ones the user uninstalled. Re-remove any
# the user previously uninstalled at startup so a manual update on the Pi
# doesn't resurrect them.
try:
_purged = plugin_store_manager.purge_uninstalled_plugins()
if _purged:
logging.getLogger(__name__).info(
"Re-removed %d uninstalled plugin(s) restored since last run: %s",
len(_purged), ", ".join(_purged),
)
except (OSError, RuntimeError) as _purge_err:
logging.getLogger(__name__).warning(
"Startup plugin purge failed: %s", _purge_err
)
saved_repositories_manager = SavedRepositoriesManager() saved_repositories_manager = SavedRepositoriesManager()
# Initialize schema manager # Initialize schema manager
@@ -143,6 +160,22 @@ api_v3.health_monitor = health_monitor
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager() api_v3.cache_manager = CacheManager()
# Wire plugin health/metrics for the web process. The display service records
# health and execution-time metrics to the shared on-disk cache; giving the web
# process its own tracker/monitor backed by that same cache lets the health API
# routes (/api/v3/plugins/health, /plugins/metrics) read that persisted data.
# Guarded so any init failure degrades to "not available" rather than breaking
# the web server.
try:
from src.plugin_system.plugin_health import PluginHealthTracker
from src.plugin_system.resource_monitor import PluginResourceMonitor
plugin_manager.health_tracker = PluginHealthTracker(api_v3.cache_manager)
plugin_manager.resource_monitor = PluginResourceMonitor(api_v3.cache_manager)
except Exception as _hm_err: # pragma: no cover - defensive startup guard
logging.getLogger(__name__).warning(
"Could not enable plugin health/metrics for web UI: %s", _hm_err
)
app.register_blueprint(pages_v3, url_prefix='/v3') app.register_blueprint(pages_v3, url_prefix='/v3')
app.register_blueprint(api_v3, url_prefix='/api/v3') app.register_blueprint(api_v3, url_prefix='/api/v3')
@@ -483,6 +516,37 @@ class _StreamBroadcaster:
except queue.Full: except queue.Full:
pass pass
def _get_power_status():
"""Check Raspberry Pi under-voltage/throttling status via vcgencmd.
Returns a dict of decoded flags, or None on non-Pi platforms (no
vcgencmd) or if the call fails for any reason. See:
https://www.raspberrypi.com/documentation/computers/os.html#get_throttled
"""
if not _VCGENCMD:
return None
try:
result = subprocess.run(
[_VCGENCMD, 'get_throttled'], capture_output=True, text=True, timeout=2
)
match = re.search(r'0x([0-9a-fA-F]+)', result.stdout)
if not match:
return None
bits = int(match.group(1), 16)
return {
'under_voltage_now': bool(bits & 0x1),
'freq_capped_now': bool(bits & 0x2),
'throttled_now': bool(bits & 0x4),
'soft_temp_limit_now': bool(bits & 0x8),
'under_voltage_occurred': bool(bits & 0x10000),
'freq_capped_occurred': bool(bits & 0x20000),
'throttled_occurred': bool(bits & 0x40000),
'soft_temp_limit_occurred': bool(bits & 0x80000),
}
except (subprocess.SubprocessError, OSError, ValueError) as e:
app.logger.warning("vcgencmd get_throttled failed: %s", e)
return None
# System status generator for SSE # System status generator for SSE
def system_status_generator(): def system_status_generator():
"""Generate system status updates""" """Generate system status updates"""
@@ -529,7 +593,8 @@ def system_status_generator():
'cpu_percent': cpu_percent, 'cpu_percent': cpu_percent,
'memory_used_percent': memory_used_percent, 'memory_used_percent': memory_used_percent,
'cpu_temp': cpu_temp, 'cpu_temp': cpu_temp,
'disk_used_percent': 0 'disk_used_percent': 0,
'power': _get_power_status()
} }
yield status yield status
except Exception as e: except Exception as e:
+480 -37
View File
@@ -14,6 +14,7 @@ import logging
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,9 +26,51 @@ from src.web_interface.validators import (
validate_file_upload validate_file_upload
) )
from src.error_aggregator import get_error_aggregator from src.error_aggregator import get_error_aggregator
from src.common.permission_utils import install_requirements_file
_SUDO = shutil.which('sudo') _SUDO = shutil.which('sudo')
_JOURNALCTL = shutil.which('journalctl') _JOURNALCTL = shutil.which('journalctl')
_GIT = shutil.which('git')
# Cap subprocess output returned to the browser — pip can produce MBs on build failures.
_MAX_OUTPUT_BYTES = 51_200 # 50 KB
def _truncate_output(stdout: str, stderr: str) -> str:
"""Combine stdout+stderr and truncate to _MAX_OUTPUT_BYTES (keeping the tail)."""
combined = (stdout + stderr).strip()
if len(combined) > _MAX_OUTPUT_BYTES:
combined = '[...output truncated...]\n' + combined[-_MAX_OUTPUT_BYTES:]
return combined
def _pip_install_requirements(req_file: Path, timeout: int) -> subprocess.CompletedProcess:
"""Install a requirements.txt file, preferring the vetted sudo wrapper so
the packages are visible to root-run ledmatrix.service not just to
whichever non-root user runs this web process. Falls back to installing
for the current process only if the wrapper isn't set up yet (i.e. the
admin hasn't run scripts/install/configure_web_sudo.sh since upgrading),
so the button still does *something* useful rather than hard-failing.
Thin wrapper around the shared implementation in permission_utils so the
Plugin Store's own dependency installation (store_manager.py) follows the
exact same root-visible install path instead of a divergent one.
"""
return install_requirements_file(req_file, timeout=timeout)
def _scrub_git_remote_url(url: str) -> str:
"""Strip embedded username/password from an HTTPS remote URL before returning it to the UI."""
try:
p = urlparse(url)
if p.scheme in ('http', 'https') and (p.username or p.password):
netloc = p.hostname or ''
if p.port:
netloc += f':{p.port}'
return urlunparse(p._replace(netloc=netloc))
except Exception:
pass
return url
# Will be initialized when blueprint is registered # Will be initialized when blueprint is registered
config_manager = None config_manager = None
@@ -190,7 +233,7 @@ def _ensure_display_service_running():
if status.get('active'): if status.get('active'):
status['started'] = False status['started'] = False
return status return status
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix']) result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service'])
service_status = _get_display_service_status() service_status = _get_display_service_status()
result['started'] = result.get('returncode') == 0 result['started'] = result.get('returncode') == 0
result['active'] = service_status.get('active') result['active'] = service_status.get('active')
@@ -199,7 +242,7 @@ def _ensure_display_service_running():
def _stop_display_service(): def _stop_display_service():
"""Stop the ledmatrix display service.""" """Stop the ledmatrix display service."""
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix']) result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service'])
status = _get_display_service_status() status = _get_display_service_status()
result['active'] = status.get('active') result['active'] = status.get('active')
result['status'] = status result['status'] = status
@@ -705,7 +748,8 @@ def save_main_config():
display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', display_fields = ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping',
'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate', 'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format', 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format',
'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type'] 'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type',
'row_address_type']
if any(k in data for k in display_fields): if any(k in data for k in display_fields):
if 'display' not in current_config: if 'display' not in current_config:
@@ -736,14 +780,23 @@ def save_main_config():
except (ValueError, TypeError): except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400 return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
# Validate row_address_type
if 'row_address_type' in data:
try:
rat_val = int(data['row_address_type'])
if rat_val < 0 or rat_val > 4:
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
# Handle hardware settings # Handle hardware settings
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode', for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
'led_rgb_sequence', 'multiplexing', 'panel_type']: 'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type']:
if field in data: if field in data:
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode', if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
'multiplexing']: 'multiplexing', 'row_address_type']:
current_config['display']['hardware'][field] = int(data[field]) current_config['display']['hardware'][field] = int(data[field])
else: else:
current_config['display']['hardware'][field] = data[field] current_config['display']['hardware'][field] = data[field]
@@ -773,6 +826,46 @@ def save_main_config():
current_config['display']['dynamic_duration'] = {} current_config['display']['dynamic_duration'] = {}
current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds']) current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds'])
# Handle double-sided display settings
double_sided_fields = ['double_sided_enabled', 'double_sided_copies', 'double_sided_axis']
if any(k in data for k in double_sided_fields):
if 'display' not in current_config:
current_config['display'] = {}
if 'double_sided' not in current_config['display']:
current_config['display']['double_sided'] = {}
ds_config = current_config['display']['double_sided']
# Enabled checkbox: omitted from the form when unchecked.
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled'))
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
try:
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if not (2 <= copies <= 8):
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
# Validate divisibility against the relevant hardware dimension.
# Use axis from this request if provided, else from stored config.
hw = current_config.get('display', {}).get('hardware', {})
effective_axis = (data.get('double_sided_axis')
or current_config.get('display', {}).get('double_sided', {}).get('axis', 'horizontal'))
if effective_axis == 'horizontal':
chain_length = int(hw.get('chain_length', 2) or 2)
if chain_length % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400
elif effective_axis == 'vertical':
parallel = int(hw.get('parallel', 1) or 1)
if parallel % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400
ds_config['copies'] = copies
if 'double_sided_axis' in data:
axis = data['double_sided_axis']
if axis not in ('horizontal', 'vertical'):
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
ds_config['axis'] = axis
# Handle Vegas scroll mode settings # Handle Vegas scroll mode settings
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins'] 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
@@ -1018,6 +1111,8 @@ def save_main_config():
continue continue
if key in vegas_fields: if key in vegas_fields:
continue continue
if key in double_sided_fields:
continue
# For any remaining keys (including plugin keys), use deep merge to preserve existing settings # For any remaining keys (including plugin keys), use deep merge to preserve existing settings
if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict): if key in current_config and isinstance(current_config[key], dict) and isinstance(data[key], dict):
# Deep merge to preserve existing settings # Deep merge to preserve existing settings
@@ -1461,7 +1556,7 @@ def execute_system_action():
# For on-demand modes, we would need to integrate with the display controller # For on-demand modes, we would need to integrate with the display controller
# For now, just start the display service # For now, just start the display service
try: try:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired as e:
logger.error("start_display (%s) timed out: %s", mode, e) logger.error("start_display (%s) timed out: %s", mode, e)
@@ -1478,16 +1573,16 @@ def execute_system_action():
resp['stderr'] = result.stderr.strip() resp['stderr'] = result.stderr.strip()
return jsonify(resp) return jsonify(resp)
else: else:
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
elif action == 'stop_display': elif action == 'stop_display':
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
elif action == 'enable_autostart': elif action == 'enable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
elif action == 'disable_autostart': elif action == 'disable_autostart':
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
elif action == 'reboot_system': elif action == 'reboot_system':
result = subprocess.run(['sudo', 'reboot'], result = subprocess.run(['sudo', 'reboot'],
@@ -1559,6 +1654,20 @@ def execute_system_action():
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}" pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
if result.stdout and "Already up to date" not in result.stdout: if result.stdout and "Already up to date" not in result.stdout:
pull_message = f"Code updated successfully.{stash_info}" pull_message = f"Code updated successfully.{stash_info}"
# A `git pull` restores built-in plugins (committed under
# plugin-repos/) even if the user uninstalled them. Re-remove
# any the user previously uninstalled so the update doesn't
# resurrect them.
if api_v3.plugin_store_manager:
try:
purged = api_v3.plugin_store_manager.purge_uninstalled_plugins()
if purged:
logger.info(
"Re-removed %d uninstalled plugin(s) restored by update: %s",
len(purged), ", ".join(purged),
)
except (OSError, RuntimeError) as purge_err:
logger.warning("Post-update plugin purge failed: %s", purge_err)
else: else:
logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr) logger.warning("git pull failed (returncode=%d): %s", result.returncode, result.stderr)
pull_message = "Update failed; check logs for details" pull_message = "Update failed; check logs for details"
@@ -1568,12 +1677,87 @@ def execute_system_action():
'message': pull_message, 'message': pull_message,
}) })
elif action == 'restart_display_service': elif action == 'restart_display_service':
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'], result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
elif action == 'restart_web_service': elif action == 'restart_web_service':
# Try to restart the web service (assuming it's ledmatrix-web.service) # Try to restart the web service (assuming it's ledmatrix-web.service)
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'], result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=10)
elif action == 'install_base_requirements':
req_file = PROJECT_ROOT / 'requirements.txt'
if not req_file.exists():
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
result = _pip_install_requirements(req_file, timeout=120)
return jsonify({
'status': 'success' if result.returncode == 0 else 'error',
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
'output': _truncate_output(result.stdout, result.stderr)
})
elif action == 'install_plugin_requirements':
active_pm = getattr(api_v3, 'plugin_manager', None)
if active_pm:
plugins_dir = Path(active_pm.plugins_dir)
else:
_cm = getattr(api_v3, 'config_manager', None)
_cfg = _cm.load_config() if _cm else {}
_dir_name = _cfg.get('plugin_system', {}).get('plugins_directory', 'plugin-repos')
plugins_dir = Path(_dir_name) if os.path.isabs(_dir_name) else PROJECT_ROOT / _dir_name
results = []
if plugins_dir.exists():
for p in sorted(plugins_dir.iterdir()):
req = p / 'requirements.txt'
if p.is_dir() and req.exists():
try:
r = _pip_install_requirements(req, timeout=60)
results.append({
'plugin': p.name,
'ok': r.returncode == 0,
'output': _truncate_output(r.stdout, r.stderr)
})
except subprocess.TimeoutExpired:
results.append({'plugin': p.name, 'ok': False, 'output': 'pip install timed out'})
except OSError as exc:
results.append({'plugin': p.name, 'ok': False, 'output': exc.strerror or 'OS error'})
ok_count = sum(1 for r in results if r['ok'])
all_ok = all(r['ok'] for r in results) if results else True
return jsonify({
'status': 'success' if all_ok else 'error',
'message': f'Processed {len(results)} plugin(s) — {ok_count} succeeded' if results else 'No plugin requirements.txt files found',
'details': results
})
elif action == 'force_git_reset':
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
project_dir = str(PROJECT_ROOT)
fetch = subprocess.run(
[_GIT, 'fetch', 'origin'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
if fetch.returncode != 0:
return jsonify({'status': 'error', 'message': 'git fetch failed', 'output': fetch.stderr.strip()})
reset = subprocess.run(
[_GIT, 'reset', '--hard', 'origin/main'],
capture_output=True, text=True, timeout=30, cwd=project_dir
)
return jsonify({
'status': 'success' if reset.returncode == 0 else 'error',
'message': 'Reset to origin/main successfully' if reset.returncode == 0 else 'git reset failed',
'output': (reset.stdout + reset.stderr).strip()
})
elif action == 'clear_pycache':
cleared = 0
failed = 0
for d in PROJECT_ROOT.rglob('__pycache__'):
if d.is_dir():
try:
shutil.rmtree(d)
cleared += 1
except OSError:
failed += 1
msg = f'Cleared {cleared} __pycache__ directories'
if failed:
msg += f' ({failed} could not be removed)'
return jsonify({'status': 'success', 'message': msg})
else: else:
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400 return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
@@ -1596,6 +1780,35 @@ def execute_system_action():
logger.error("execute_system_action failed: %s", e, exc_info=True) logger.error("execute_system_action failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
@api_v3.route('/system/git-info', methods=['GET'])
def get_git_info():
"""Return branch, dirty state, recent commits and remote URL for the Tools tab."""
if not _GIT:
return jsonify({'status': 'error', 'message': 'git not found on this system'}), 503
d = str(PROJECT_ROOT)
try:
branch = subprocess.run([_GIT, 'branch', '--show-current'], capture_output=True, text=True, timeout=10, cwd=d)
if branch.returncode != 0:
return jsonify({'status': 'error', 'message': f'git branch failed: {branch.stderr.strip()}'}), 500
status = subprocess.run([_GIT, 'status', '--short', '--untracked-files=no'], capture_output=True, text=True, timeout=15, cwd=d)
if status.returncode != 0:
return jsonify({'status': 'error', 'message': f'git status failed: {status.stderr.strip()}'}), 500
log = subprocess.run([_GIT, 'log', '--oneline', '-5'], capture_output=True, text=True, timeout=10, cwd=d)
remote = subprocess.run([_GIT, 'remote', 'get-url', 'origin'], capture_output=True, text=True, timeout=10, cwd=d)
return jsonify({
'branch': branch.stdout.strip(),
'dirty': bool(status.stdout.strip()),
'status': status.stdout.strip(),
'recent_commits': log.stdout.strip() if log.returncode == 0 else '',
'remote_url': _scrub_git_remote_url(remote.stdout.strip()) if remote.returncode == 0 else '',
})
except Exception as e:
logger.error("get_git_info failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'Failed to get git info'}), 500
@api_v3.route('/hardware/status', methods=['GET']) @api_v3.route('/hardware/status', methods=['GET'])
def get_hardware_status(): def get_hardware_status():
"""Return LED matrix hardware initialization status written by display_manager at startup.""" """Return LED matrix hardware initialization status written by display_manager at startup."""
@@ -1860,6 +2073,18 @@ def get_installed_plugins():
return None return None
def _build_plugin_entry_inner(plugin_info, plugin_id): def _build_plugin_entry_inner(plugin_info, plugin_id):
# Capture runtime state (state machine + error context) before the
# manifest merge below can shadow the 'state' key. get_all_plugin_info
# attaches this via PluginStateManager.get_state_info(); surfacing it
# lets the UI show *why* a plugin isn't running instead of just
# 'loaded: false'.
state_info = plugin_info.get('state')
plugin_state = None
plugin_error_info = None
if isinstance(state_info, dict):
plugin_state = state_info.get('state')
plugin_error_info = state_info.get('error_info')
# Re-read manifest from disk to ensure we have the latest metadata # Re-read manifest from disk to ensure we have the latest metadata
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json" manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
if manifest_path.exists(): if manifest_path.exists():
@@ -1941,6 +2166,8 @@ def get_installed_plugins():
'enabled': enabled, 'enabled': enabled,
'verified': verified, 'verified': verified,
'loaded': plugin_info.get('loaded', False), 'loaded': plugin_info.get('loaded', False),
'state': plugin_state,
'error_info': plugin_error_info,
'last_updated': last_updated, 'last_updated': last_updated,
'last_commit': last_commit, 'last_commit': last_commit,
'last_commit_message': last_commit_message, 'last_commit_message': last_commit_message,
@@ -1960,6 +2187,31 @@ def get_installed_plugins():
logger.error('Error in get_installed_plugins', exc_info=True) logger.error('Error in get_installed_plugins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
def _installed_plugin_ids():
"""Best-effort list of installed plugin IDs for the web process.
Health/metrics state is written by the separate display service to the
shared on-disk cache, so the tracker's in-memory set is empty here. We
enumerate the installed plugins and read each one's persisted summary by ID
instead of relying on the tracker's in-memory `get_all_*` view.
"""
pm = api_v3.plugin_manager
manifests = getattr(pm, 'plugin_manifests', None)
if not manifests:
# Only pay for a discovery scan when we haven't discovered anything yet;
# subsequent polls reuse the already-populated manifest map.
try:
pm.discover_plugins()
except Exception:
logger.debug('discover_plugins failed while listing plugin ids', exc_info=True)
manifests = getattr(pm, 'plugin_manifests', None)
try:
return list(manifests.keys()) if manifests else []
except Exception:
logger.debug('listing plugin_manifests failed while building plugin ids', exc_info=True)
return []
@api_v3.route('/plugins/health', methods=['GET']) @api_v3.route('/plugins/health', methods=['GET'])
def get_plugin_health(): def get_plugin_health():
"""Get health metrics for all plugins""" """Get health metrics for all plugins"""
@@ -1975,8 +2227,23 @@ def get_plugin_health():
'message': 'Health tracking not available' 'message': 'Health tracking not available'
}) })
# Get health summaries for all plugins tracker = api_v3.plugin_manager.health_tracker
health_summaries = api_v3.plugin_manager.health_tracker.get_all_health_summaries() # Build per-plugin summaries by ID so persisted (cross-process) health
# is included, then fold in any in-memory-only entries.
health_summaries = {}
for pid in _installed_plugin_ids():
try:
# force_reload: this process only reads; bypass the in-memory
# snapshot so each poll reflects the display service's latest
# persisted state.
health_summaries[pid] = tracker.get_health_summary(pid, force_reload=True)
except Exception:
logger.debug('Could not read health summary for %s', pid, exc_info=True)
try:
for pid, summary in tracker.get_all_health_summaries().items():
health_summaries.setdefault(pid, summary)
except Exception:
logger.debug('get_all_health_summaries failed', exc_info=True)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -2051,8 +2318,22 @@ def get_plugin_metrics():
'message': 'Resource monitoring not available' 'message': 'Resource monitoring not available'
}) })
# Get metrics summaries for all plugins monitor = api_v3.plugin_manager.resource_monitor
metrics_summaries = api_v3.plugin_manager.resource_monitor.get_all_metrics_summaries() # Build per-plugin summaries by ID so persisted (cross-process) metrics
# are included, then fold in any in-memory-only entries.
metrics_summaries = {}
for pid in _installed_plugin_ids():
try:
# force_reload: read-only path — bypass the in-memory snapshot so
# each poll reflects the display service's latest persisted metrics.
metrics_summaries[pid] = monitor.get_metrics_summary(pid, force_reload=True)
except Exception:
logger.debug('Could not read metrics summary for %s', pid, exc_info=True)
try:
for pid, summary in monitor.get_all_metrics_summaries().items():
metrics_summaries.setdefault(pid, summary)
except Exception:
logger.debug('get_all_metrics_summaries failed', exc_info=True)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -2933,6 +3214,13 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
api_v3.schema_manager.invalidate_cache(plugin_id) api_v3.schema_manager.invalidate_cache(plugin_id)
if api_v3.plugin_state_manager: if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.remove_plugin_state(plugin_id) api_v3.plugin_state_manager.remove_plugin_state(plugin_id)
# Persistently record the uninstall so a later core `git pull` update
# cannot resurrect a built-in plugin (committed under plugin-repos/) that
# the user removed. Best-effort: never fail the uninstall over this.
try:
api_v3.plugin_store_manager.record_uninstalled_plugin(plugin_id)
except Exception as record_err:
logger.warning("Could not record uninstall for %s: %s", plugin_id, record_err)
return True, None return True, None
@@ -3537,21 +3825,29 @@ def _get_schema_property(schema, key_path):
parts = key_path.split('.') parts = key_path.split('.')
current = schema['properties'] current = schema['properties']
i = 0
for i, part in enumerate(parts): while i < len(parts):
if part not in current: # Try progressively longer candidates, longest first, so schema keys that
return None # themselves contain dots (e.g. league keys like "fifa.world") are matched
# instead of being mistaken for nested "fifa" -> "world" objects.
prop = current[part] matched = False
for j in range(len(parts), i, -1):
# If this is the last part, return the property candidate = '.'.join(parts[i:j])
if i == len(parts) - 1: if isinstance(current, dict) and candidate in current:
prop = current[candidate]
# Consumed all remaining parts — this is the target property.
if j == len(parts):
return prop return prop
# Navigate deeper through an object with properties.
# If this is an object with properties, navigate deeper
if isinstance(prop, dict) and 'properties' in prop: if isinstance(prop, dict) and 'properties' in prop:
current = prop['properties'] current = prop['properties']
else: i = j
matched = True
break
# Matched a non-object before consuming the path — can't go deeper.
return None
if not matched:
return None return None
return None return None
@@ -3709,7 +4005,8 @@ def _parse_form_value_with_schema(value, key_path, schema):
except ValueError: except ValueError:
return prop.get('default', 0.0) return prop.get('default', 0.0)
# Try parsing as number (fallback) # Try parsing as number (fallback) — skip when schema explicitly says string
if not (prop and prop.get('type') == 'string'):
try: try:
if '.' in stripped: if '.' in stripped:
return float(stripped) return float(stripped)
@@ -3723,10 +4020,45 @@ def _parse_form_value_with_schema(value, key_path, schema):
return value return value
def _resolve_key_segments(key_path, config):
"""Split a dot-notation path into segments, greedily preserving keys that
themselves contain dots (e.g. league keys like "fifa.world").
At each level the longest candidate that matches a key already present in the
config wins; otherwise the path splits on the next dot (the normal
nested-create case). Because dotted keys such as ``leagues."fifa.world"``
always exist in the saved config being updated, this routes the value to the
real league object instead of fabricating a ``leagues.fifa.world`` tree.
"""
parts = key_path.split('.')
segments = []
node = config
i = 0
while i < len(parts):
matched = False
if isinstance(node, dict):
for j in range(len(parts), i, -1):
candidate = '.'.join(parts[i:j])
if candidate in node:
segments.append(candidate)
node = node[candidate]
i = j
matched = True
break
if not matched:
part = parts[i]
segments.append(part)
node = node.get(part) if isinstance(node, dict) else None
i += 1
return segments
def _set_nested_value(config, key_path, value): def _set_nested_value(config, key_path, value):
""" """
Set a value in a nested dict using dot notation path. Set a value in a nested dict using dot notation path.
Handles existing nested dicts correctly by merging instead of replacing. Handles existing nested dicts correctly by merging instead of replacing.
Keys containing dots (e.g. league keys like "fifa.world") are preserved when
they already exist in the config rather than being split into nested objects.
Args: Args:
config: The config dict to modify config: The config dict to modify
@@ -3737,21 +4069,21 @@ def _set_nested_value(config, key_path, value):
if value is _SKIP_FIELD: if value is _SKIP_FIELD:
return return
parts = key_path.split('.') segments = _resolve_key_segments(key_path, config)
current = config current = config
# Navigate/create intermediate dicts # Navigate/create intermediate dicts
for i, part in enumerate(parts[:-1]): for seg in segments[:-1]:
if part not in current: if seg not in current:
current[part] = {} current[seg] = {}
elif not isinstance(current[part], dict): elif not isinstance(current[seg], dict):
# If the existing value is not a dict, replace it with a dict # If the existing value is not a dict, replace it with a dict
current[part] = {} current[seg] = {}
current = current[part] current = current[seg]
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure) # Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
if value is not None or parts[-1] not in current: if value is not None or segments[-1] not in current:
current[parts[-1]] = value current[segments[-1]] = value
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None): def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
@@ -4371,6 +4703,49 @@ def save_plugin_config():
if 'application/json' in content_type: if 'application/json' in content_type:
schema = schema_mgr.load_schema(plugin_id, use_cache=False) schema = schema_mgr.load_schema(plugin_id, use_cache=False)
# JSON path: fix numeric-keyed dicts that should be arrays.
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}}
# instead of [{name:...}]. The form-data path has fix_array_structures for this;
# mirror that logic here for JSON submissions.
if 'application/json' in content_type and schema and 'properties' in schema:
def _fix_json_arrays(cfg, props):
for k, ps in props.items():
if not isinstance(cfg, dict) or k not in cfg:
continue
pt = ps.get('type')
val = cfg[k]
if pt == 'array':
items_schema = ps.get('items', {})
item_type = items_schema.get('type')
if isinstance(val, dict):
keys = list(val.keys())
if keys and all(str(x).isdigit() for x in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
arr = [val[sk] for sk in sorted_keys]
if item_type in ('integer', 'number'):
converted = []
for v in arr:
if isinstance(v, str):
try:
converted.append(int(v) if item_type == 'integer' else float(v))
except (ValueError, TypeError):
converted.append(v)
else:
converted.append(v)
arr = converted
cfg[k] = arr
elif not keys:
cfg[k] = []
# Recurse into each element when items are objects with properties,
# covering both freshly-converted and already-list values.
if item_type == 'object' and 'properties' in items_schema:
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
if isinstance(elem, dict):
_fix_json_arrays(elem, items_schema['properties'])
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
_fix_json_arrays(val, ps['properties'])
_fix_json_arrays(plugin_config, schema['properties'])
# PRE-PROCESSING: Preserve 'enabled' state if not in request # PRE-PROCESSING: Preserve 'enabled' state if not in request
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle # This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
if 'enabled' not in plugin_config: if 'enabled' not in plugin_config:
@@ -6877,6 +7252,74 @@ def set_auto_enable_ap_mode():
'message': 'An error occurred; see logs for details' 'message': 'An error occurred; see logs for details'
}), 500 }), 500
@api_v3.route('/wifi/radio', methods=['GET'])
def get_wifi_radio():
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
state = wifi_manager.get_wifi_radio_state()
return jsonify({
'status': 'success',
'data': state
})
except Exception as e:
logger.error("Error getting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/wifi/radio', methods=['POST'])
def set_wifi_radio():
"""Turn the WiFi radio on or off.
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
unless Ethernet is connected or force=True, to avoid locking the user out
of this web interface.
"""
try:
from src.wifi_manager import WiFiManager
data = request.get_json(silent=True) or {}
if 'enabled' not in data:
return jsonify({
'status': 'error',
'message': 'enabled is required'
}), 400
# Parse defensively: bool("false") is True, so mirror the string-aware
# coercion used for `force` — the endpoint is a public contract, not just
# the shipped UI (which always sends real JSON booleans).
_enabled_raw = data['enabled']
enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes'))
_force_raw = data.get('force', False)
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes'))
wifi_manager = WiFiManager()
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
if success:
return jsonify({
'status': 'success',
'message': message,
'data': wifi_manager.get_wifi_radio_state()
})
else:
return jsonify({
'status': 'error',
'message': message,
'reason': reason
}), 400
except Exception as e:
logger.error("Error setting WiFi radio state", exc_info=True)
return jsonify({
'status': 'error',
'message': 'An error occurred; see logs for details'
}), 500
@api_v3.route('/cache/list', methods=['GET']) @api_v3.route('/cache/list', methods=['GET'])
def list_cache_files(): def list_cache_files():
"""List all cache files with metadata""" """List all cache files with metadata"""
+175 -1
View File
@@ -1,5 +1,7 @@
from flask import Blueprint, render_template, flash from flask import Blueprint, render_template, flash, jsonify
from jinja2 import TemplateNotFound
from markupsafe import escape from markupsafe import escape
from html.parser import HTMLParser
import json import json
import logging import logging
import os import os
@@ -21,6 +23,114 @@ plugin_store_manager = None
pages_v3 = Blueprint('pages_v3', __name__) pages_v3 = Blueprint('pages_v3', __name__)
class _SettingsIndexParser(HTMLParser):
"""Extract searchable settings fields from a rendered partial's HTML.
Captures one entry per ``<div class="form-group" id="setting-…">``: the
anchor id, ``data-setting-key``, the field's ``<label>`` text, the
``.help-tip`` tooltip text (``data-tooltip``), and the nearest preceding
``<h3>``/``<h4>`` section heading. Parsing the *rendered* HTML (rather than
the schema) guarantees the anchor ids match the live DOM exactly, so the
search index cannot drift from what users actually see.
"""
def __init__(self, tab, tab_label):
super().__init__(convert_charrefs=True)
self.tab = tab
self.tab_label = tab_label
self.fields = []
self._section = ''
self._field = None
self._depth = 0 # open-div depth within the current field
self._in_label = False
self._label_parts = []
self._in_heading = False
self._heading_parts = []
def handle_starttag(self, tag, attrs):
a = {k: (v or '') for k, v in attrs}
classes = a.get('class', '').split()
# Section headings (only when not already inside a field)
if tag in ('h3', 'h4') and self._field is None:
self._in_heading = True
self._heading_parts = []
if tag == 'div':
fid = a.get('id', '')
if self._field is None and 'form-group' in classes and fid.startswith('setting-'):
self._field = {
'anchorId': fid,
'key': a.get('data-setting-key', '') or fid[len('setting-'):],
'label': '',
'help': '',
'section': self._section,
'tab': self.tab,
'tabLabel': self.tab_label,
}
self._depth = 1
return
if self._field is not None:
self._depth += 1
if self._field is not None:
if tag == 'label' and not self._field['label']:
self._in_label = True
self._label_parts = []
if tag == 'button' and 'help-tip' in classes and not self._field['help']:
self._field['help'] = a.get('data-tooltip', '')
def handle_data(self, data):
if self._in_label:
self._label_parts.append(data)
elif self._in_heading:
self._heading_parts.append(data)
def handle_endtag(self, tag):
if tag in ('h3', 'h4') and self._in_heading:
self._in_heading = False
self._section = ' '.join(''.join(self._heading_parts).split()).strip()
return
if self._field is None:
return
if tag == 'label' and self._in_label:
self._in_label = False
self._field['label'] = ' '.join(''.join(self._label_parts).split()).strip()
elif tag == 'div':
self._depth -= 1
if self._depth <= 0:
if self._field['label']:
self.fields.append(self._field)
self._field = None
self._depth = 0
def _partial_html(loader):
"""Run a partial loader and return its HTML string ('' on error)."""
try:
result = loader()
except Exception:
logger.warning("search-index: partial render failed", exc_info=True)
return ''
if isinstance(result, str):
return result
if isinstance(result, tuple): # loaders return (msg, status) on error
return ''
try:
return result.get_data(as_text=True)
except Exception:
return ''
def _extract_settings_fields(html, tab, tab_label):
parser = _SettingsIndexParser(tab, tab_label)
parser.feed(html)
return parser.fields
# Cache the built index keyed on the installed-plugin set. Core labels/tooltips
# are static template text, so only a change in installed plugins invalidates it.
_SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
@pages_v3.route('/') @pages_v3.route('/')
def index(): def index():
"""Main v3 interface page""" """Main v3 interface page"""
@@ -90,6 +200,8 @@ def load_partial(partial_name):
return _load_cache_partial() return _load_cache_partial()
elif partial_name == 'operation-history': elif partial_name == 'operation-history':
return _load_operation_history_partial() return _load_operation_history_partial()
elif partial_name == 'tools':
return _load_tools_partial()
else: else:
return "Partial not found", 404 return "Partial not found", 404
@@ -108,6 +220,56 @@ def load_plugin_config_partial(plugin_id):
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500 return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
@pages_v3.route('/settings/search-index')
def settings_search_index():
"""Return a flat JSON index of every searchable setting (core + plugin).
Powers the web UI's global settings search. Built by rendering the settings
partials server-side and extracting field metadata, then cached per
installed-plugin set so it is off the display's hot path.
"""
# Core settings tabs: (activeTab value, human label, loader).
core_tabs = [
('general', 'General', _load_general_partial),
('display', 'Display', _load_display_partial),
('durations', 'Durations', _load_durations_partial),
('schedule', 'Schedule', _load_schedule_partial),
('wifi', 'WiFi', _load_wifi_partial),
]
try:
plugin_ids = []
if pages_v3.plugin_manager:
try:
pages_v3.plugin_manager.discover_plugins()
plugin_ids = sorted(
pi.get('id') for pi in pages_v3.plugin_manager.get_all_plugin_info()
if pi.get('id')
)
except Exception:
logger.warning("search-index: could not enumerate plugins", exc_info=True)
sig = tuple(plugin_ids)
if _SEARCH_INDEX_CACHE['sig'] == sig and _SEARCH_INDEX_CACHE['fields'] is not None:
return jsonify({'fields': _SEARCH_INDEX_CACHE['fields']})
fields = []
for tab, label, loader in core_tabs:
fields.extend(_extract_settings_fields(_partial_html(loader), tab, label))
for pid in plugin_ids:
info = pages_v3.plugin_manager.get_plugin_info(pid) or {}
label = info.get('name', pid)
html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid))
fields.extend(_extract_settings_fields(html, pid, label))
_SEARCH_INDEX_CACHE['sig'] = sig
_SEARCH_INDEX_CACHE['fields'] = fields
return jsonify({'fields': fields})
except Exception:
logger.error("Error building settings search index", exc_info=True)
return jsonify({'fields': []}), 500
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>') @pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
def serve_plugin_web_ui(plugin_id, filename): def serve_plugin_web_ui(plugin_id, filename):
"""Serve a plugin's web_ui/ HTML fragment as a standalone page. """Serve a plugin's web_ui/ HTML fragment as a standalone page.
@@ -448,6 +610,18 @@ def _load_operation_history_partial():
return "Error loading partial", 500 return "Error loading partial", 500
def _load_tools_partial():
"""Load tools/utilities partial."""
try:
return render_template('v3/partials/tools.html')
except TemplateNotFound:
logger.error("[Pages V3][Tools] Template not found: v3/partials/tools.html", exc_info=True)
return "[Pages V3][Tools] Template is missing.", 500
except OSError as exc:
logger.error("[Pages V3][Tools] I/O error loading tools partial: %s", exc, exc_info=True)
return "[Pages V3][Tools] Failed to load due to a file system error. Check logs.", 500
def _load_plugin_config_partial(plugin_id): def _load_plugin_config_partial(plugin_id):
""" """
Load plugin configuration partial - server-side rendered form. Load plugin configuration partial - server-side rendered form.
+180
View File
@@ -84,6 +84,8 @@
[data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; } [data-theme="dark"] .hover\:text-gray-700:hover { color: #e5e7eb; }
[data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; } [data-theme="dark"] .hover\:border-gray-300:hover { border-color: #6b7280; }
[data-theme="dark"] .bg-red-100 { background-color: #450a0a; } [data-theme="dark"] .bg-red-100 { background-color: #450a0a; }
[data-theme="dark"] .bg-yellow-100 { background-color: #422006; }
[data-theme="dark"] .bg-green-100 { background-color: #022c22; }
[data-theme="dark"] .text-red-700 { color: #fca5a5; } [data-theme="dark"] .text-red-700 { color: #fca5a5; }
[data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; } [data-theme="dark"] .hover\:bg-red-200:hover { background-color: #7f1d1d; }
@@ -137,6 +139,14 @@ body {
.text-green-600 { color: #059669; } .text-green-600 { color: #059669; }
.text-red-600 { color: #dc2626; } .text-red-600 { color: #dc2626; }
/* Status badge chips (e.g. tools.html's dirty/clean and power-status badges) */
.bg-red-100 { background-color: #fee2e2; }
.bg-yellow-100 { background-color: #fef9c3; }
.bg-green-100 { background-color: #dcfce7; }
.text-red-800 { color: #991b1b; }
.text-yellow-800 { color: #854d0e; }
.text-green-800 { color: #166534; }
.border-gray-200 { border-color: #e5e7eb; } .border-gray-200 { border-color: #e5e7eb; }
.border-gray-300 { border-color: #d1d5db; } .border-gray-300 { border-color: #d1d5db; }
.border-gray-700 { border-color: #374151; } .border-gray-700 { border-color: #374151; }
@@ -756,6 +766,153 @@ button.bg-white {
} }
} }
/* ============================================================================ */
/* Settings tooltips (help_tip macro + tooltips.js) */
/* ============================================================================ */
/* The (i) info trigger placed next to a setting label. */
.help-tip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
margin-left: 0.375rem;
padding: 0;
border: none;
background: transparent;
color: var(--color-text-tertiary);
font-size: 0.8125rem;
line-height: 1;
cursor: help;
vertical-align: middle;
border-radius: 9999px;
transition: color 0.12s ease;
}
.help-tip:hover,
.help-tip:focus-visible {
color: var(--color-primary);
}
.help-tip:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Singleton tooltip panel appended to <body> by tooltips.js. */
#ledm-tooltip {
position: fixed;
z-index: 1000;
max-width: 20rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: var(--shadow-lg);
font-size: 0.8125rem;
line-height: 1.45;
white-space: pre-line; /* renders authored "\n" line breaks */
pointer-events: none; /* never steals hover/click from the page */
animation: tooltipFade 0.12s ease;
}
#ledm-tooltip[hidden] {
display: none;
}
@keyframes tooltipFade {
from { opacity: 0; }
to { opacity: 1; }
}
/* ============================================================================ */
/* Settings search — global header dropdown + per-tab filter */
/* ============================================================================ */
#settings-search-results {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: var(--shadow-lg);
z-index: 50;
padding: 0.25rem;
max-height: min(60vh, 24rem);
overflow-y: auto;
}
.ssr-group {
padding: 0.375rem 0.625rem 0.25rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-tertiary);
position: sticky;
top: 0;
background: var(--color-surface);
}
.ssr-option {
display: flex;
flex-direction: column;
width: 100%;
text-align: left;
padding: 0.4rem 0.625rem;
border: none;
background: transparent;
border-radius: 0.375rem;
cursor: pointer;
color: var(--color-text-primary);
}
.ssr-option:hover,
.ssr-option.active {
background: var(--color-info-bg);
}
.ssr-label {
font-size: 0.875rem;
font-weight: 500;
}
.ssr-help {
font-size: 0.75rem;
color: var(--color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.ssr-empty {
padding: 0.75rem 0.625rem;
font-size: 0.8125rem;
color: var(--color-text-tertiary);
}
/* Flash highlight applied to a field after search navigation. */
@keyframes settingFlash {
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
25% { box-shadow: 0 0 0 3px var(--color-primary); }
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
}
.setting-flash {
animation: settingFlash 1.4s ease;
border-radius: 0.5rem;
}
@media (prefers-reduced-motion: reduce) {
#ledm-tooltip { animation: none; }
.setting-flash {
animation: none;
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
/* Removed .divider and .divider-light - not used anywhere */ /* Removed .divider and .divider-light - not used anywhere */
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */ /* Enhanced Spacing Utilities - Only unique classes not in main utility section */
@@ -1040,3 +1197,26 @@ button.bg-white {
[data-theme="dark"] .update-banner-dismiss { [data-theme="dark"] .update-banner-dismiss {
color: #93c5fd; color: #93c5fd;
} }
/* Under-voltage / throttling warning banner */
.power-warning-banner {
background-color: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.power-warning-banner-dismiss {
color: #991b1b;
opacity: 0.6;
}
.power-warning-banner-dismiss:hover {
opacity: 1;
}
[data-theme="dark"] .power-warning-banner {
background-color: #450a0a;
border-color: #7f1d1d;
color: #fca5a5;
}
[data-theme="dark"] .power-warning-banner-dismiss {
color: #fca5a5;
}
@@ -345,6 +345,20 @@ const PluginAPI = {
: '/plugins/health'; : '/plugins/health';
const response = await this.request(endpoint); const response = await this.request(endpoint);
return response.data || {}; return response.data || {};
},
/**
* Get plugin resource metrics (execution time, memory, cpu).
*
* @param {string} pluginId - Optional plugin identifier (null for all)
* @returns {Promise<Object>} Metrics data keyed by plugin id
*/
async getPluginMetrics(pluginId = null) {
const endpoint = pluginId
? `/plugins/metrics/${pluginId}`
: '/plugins/metrics';
const response = await this.request(endpoint);
return response.data || {};
} }
}; };
@@ -0,0 +1,477 @@
/*
* settings-search.js global settings search + per-tab filter for the v3 UI.
*
* Two features share one lightweight index built from the same markup the
* tooltip work standardizes (.form-group[id^="setting-"] + <label> +
* .help-tip[data-tooltip]), so it can never drift from what is rendered:
*
* 1. Global search (header box): finds settings across ALL tabs, even ones
* not yet opened, by fetching a single server-side JSON index
* (/v3/settings/search-index) built from all rendered partials.
* Clicking a result switches to the tab, waits for the field to load,
* then scrolls to and flashes it.
* 2. Per-tab filter (the .settings-filter box under a partial title):
* hides non-matching fields on the current tab. Delegated, so it keeps
* working across HTMX swaps.
*
* The server owns index generation (including plugin enumeration) and caches
* it per installed-plugin set, so the client makes exactly one JSON request.
*/
(function () {
'use strict';
if (window._settingsSearchInit) return;
window._settingsSearchInit = true;
var MAX_RESULTS = 25;
function debounce(fn, ms) {
var t;
return function () {
var args = arguments, ctx = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(ctx, args); }, ms);
};
}
// True when every search term is present in the haystack.
function termsMatch(hay, terms) {
return terms.every(function (t) { return hay.indexOf(t) !== -1; });
}
function textOf(el) {
return (el && el.textContent ? el.textContent : '').replace(/\s+/g, ' ').trim();
}
// --- Index building -------------------------------------------------------
var buildPromise = null;
// Fetch the prebuilt index from the server (one literal-URL JSON request)
// and cache it for the session. Each entry gets a lowercased `hay` haystack
// for matching. The server owns which tabs/plugins are included.
function buildIndex(force) {
if (window._settingsIndex && !force) return Promise.resolve(window._settingsIndex);
if (buildPromise && !force) return buildPromise;
buildPromise = fetch('/v3/settings/search-index', { headers: { 'X-Requested-With': 'settings-search' } })
.then(function (r) { return r.ok ? r.json() : { fields: [] }; })
.then(function (data) {
var fields = (data && data.fields) || [];
fields.forEach(function (f) {
f.hay = [f.label, f.help, f.key, f.tabLabel, f.section].join(' ').toLowerCase();
});
window._settingsIndex = fields;
return fields;
})
.catch(function () {
// Don't cache the failure: clear the in-flight promise so a
// later call can retry after a transient fetch error.
buildPromise = null;
return [];
});
return buildPromise;
}
// --- Global search UI -----------------------------------------------------
var input = null, resultsBox = null, activeIndex = -1, currentResults = [];
function search(q) {
q = q.trim().toLowerCase();
if (!q) return [];
var terms = q.split(/\s+/);
var out = [];
(window._settingsIndex || []).some(function (entry) {
if (termsMatch(entry.hay, terms)) out.push(entry);
return out.length >= MAX_RESULTS; // stop once we have enough
});
return out;
}
function span(cls, text) {
var s = document.createElement('span');
s.className = cls;
s.textContent = text;
return s;
}
// Build the dropdown with DOM nodes + textContent (never innerHTML) so
// setting labels/help can never be interpreted as markup.
function renderResults(results) {
currentResults = results;
activeIndex = -1;
resultsBox.textContent = '';
if (!results.length) {
resultsBox.appendChild(span('ssr-empty', 'No settings found.'));
openResults();
return;
}
var lastTab = null;
results.forEach(function (r, i) {
if (r.tabLabel !== lastTab) {
const group = document.createElement('div');
group.className = 'ssr-group';
group.textContent = r.tabLabel;
resultsBox.appendChild(group);
lastTab = r.tabLabel;
}
var sub = r.section ? (r.section + ' · ') : '';
var snippet = r.help ? r.help.split('\n')[0] : '';
var opt = document.createElement('button');
opt.type = 'button';
opt.className = 'ssr-option';
opt.setAttribute('role', 'option');
opt.id = 'ssr-' + i;
opt.setAttribute('data-idx', String(i));
opt.appendChild(span('ssr-label', r.label));
var helpText = snippet ? (sub + snippet) : (sub ? r.section : '');
if (helpText) opt.appendChild(span('ssr-help', helpText));
resultsBox.appendChild(opt);
});
openResults();
}
function openResults() {
resultsBox.classList.remove('hidden');
// .hidden has no effect without a matching CSS rule (this app's stylesheet
// is a hand-picked utility subset, not full Tailwind) - force it directly,
// same as the revealNode/collapseNode fallback below.
resultsBox.style.display = '';
if (input) input.setAttribute('aria-expanded', 'true');
}
function closeResults() {
resultsBox.classList.add('hidden');
resultsBox.style.display = 'none';
activeIndex = -1;
if (input) {
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
}
}
function highlight(idx) {
var opts = resultsBox.querySelectorAll('.ssr-option');
opts.forEach(function (o) { o.classList.remove('active'); });
if (idx < 0 || idx >= opts.length) { activeIndex = -1; return; }
activeIndex = idx;
var el = opts.item(idx);
el.classList.add('active');
el.scrollIntoView({ block: 'nearest' });
input.setAttribute('aria-activedescendant', el.id);
}
// --- Navigation to a setting ---------------------------------------------
function getAppData() {
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
if (!appEl) return null;
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
return null;
}
function setActiveTab(tab) {
var data = getAppData();
if (data) { data.activeTab = tab; return true; }
return false;
}
function waitForElement(id, timeout) {
return new Promise(function (resolve) {
var existing = document.getElementById(id);
if (existing) { resolve(existing); return; }
var host = document.getElementById('tab-content') || document.body;
var done = false;
var obs = new MutationObserver(function () {
var el = document.getElementById(id);
if (el && !done) {
done = true;
obs.disconnect();
resolve(el);
}
});
obs.observe(host, { childList: true, subtree: true });
setTimeout(function () {
if (!done) { done = true; obs.disconnect(); resolve(document.getElementById(id)); }
}, timeout || 6000);
});
}
function isNodeHidden(node) {
return node.classList.contains('hidden') ||
(node.style && node.style.display === 'none') ||
window.getComputedStyle(node).display === 'none';
}
function revealNode(node) {
// toggleSection handles the class, inline display, and chevron.
if (node.id && typeof window.toggleSection === 'function') {
window.toggleSection(node.id);
} else {
node.classList.remove('hidden');
node.style.display = 'block';
}
}
// Re-collapse a nested section the filter previously opened. toggleSection is
// state-based, so only toggle while the node is actually visible.
function collapseNode(node) {
if (isNodeHidden(node)) return;
if (node.id && typeof window.toggleSection === 'function') {
window.toggleSection(node.id);
} else {
node.classList.add('hidden');
node.style.display = 'none';
}
}
// Reveal any collapsed nested section (from render_nested_section) so the
// target field is actually visible before we scroll to it.
function revealAncestors(el) {
var node = el.parentElement;
while (node && node !== document.body) {
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
revealNode(node);
}
node = node.parentElement;
}
}
// Like revealAncestors, but tags each section we open so the per-tab filter
// can restore the original collapsed layout once the query is cleared.
function expandNestedFor(el) {
var node = el.parentElement;
while (node && node !== document.body) {
if (node.classList && node.classList.contains('nested-content') && isNodeHidden(node)) {
revealNode(node);
node.dataset.filterExpanded = '1';
}
node = node.parentElement;
}
}
function flash(el) {
el.classList.remove('setting-flash');
// force reflow so re-adding the class restarts the animation
void el.offsetWidth;
el.classList.add('setting-flash');
var clear = function () { el.classList.remove('setting-flash'); el.removeEventListener('animationend', clear); };
el.addEventListener('animationend', clear);
}
function navigateToSetting(entry) {
closeResults();
// Clear the box so it doesn't re-open stale results when refocused.
if (input) input.value = '';
setActiveTab(entry.tab);
waitForElement(entry.anchorId, 6000).then(function (el) {
if (!el) return;
revealAncestors(el);
// Let the tab transition settle before scrolling.
setTimeout(function () {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
flash(el);
}, 60);
});
}
// --- Wire up the header search box ----------------------------------------
function initSearchBox() {
input = document.getElementById('settings-search');
resultsBox = document.getElementById('settings-search-results');
if (!input || !resultsBox) return;
// Warm the index in the background so the first search is instant.
var warm = function () { buildIndex().catch(function () {}); };
if ('requestIdleCallback' in window) {
requestIdleCallback(warm, { timeout: 4000 });
} else {
setTimeout(warm, 3000);
}
input.addEventListener('focus', function () {
buildIndex().then(function () {
if (input.value.trim()) renderResults(search(input.value));
});
});
input.addEventListener('input', debounce(function () {
var q = input.value;
if (!q.trim()) { closeResults(); return; }
// Focus may have left during the debounce (typed then clicked away);
// don't re-open a dropdown the user has already dismissed.
if (document.activeElement !== input) return;
buildIndex().then(function () {
if (document.activeElement === input) renderResults(search(q));
});
}, 200));
input.addEventListener('keydown', function (e) {
var opts = resultsBox.querySelectorAll('.ssr-option');
if (e.key === 'ArrowDown') {
e.preventDefault();
if (resultsBox.classList.contains('hidden')) { renderResults(search(input.value)); return; }
highlight(Math.min(activeIndex + 1, opts.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlight(Math.max(activeIndex - 1, 0));
} else if (e.key === 'Enter') {
const chosen = currentResults.at(activeIndex >= 0 ? activeIndex : 0);
if (chosen) {
e.preventDefault();
navigateToSetting(chosen);
}
} else if (e.key === 'Escape') {
closeResults();
input.blur();
}
});
resultsBox.addEventListener('mousedown', function (e) {
// mousedown (not click) so it fires before the input blur closes us
var opt = e.target.closest('.ssr-option');
if (!opt) return;
e.preventDefault();
const idx = parseInt(opt.getAttribute('data-idx'), 10);
const chosen = currentResults.at(idx);
if (chosen) navigateToSetting(chosen);
});
// Close when a click/tap lands outside the search widget. Capture phase
// (the `true`) runs on the way DOWN, before any bubbling stopPropagation
// from Alpine/HTMX/widget handlers can swallow the event — a plain
// bubble-phase document listener was being eaten and never closing us.
// pointerdown also covers touch (Raspberry Pi screen).
document.addEventListener('pointerdown', function (e) {
if (!input || resultsBox.classList.contains('hidden')) return;
var wrap = document.getElementById('settings-search-wrap');
var inside = wrap ? wrap.contains(e.target)
: (e.target === input || resultsBox.contains(e.target));
if (!inside) closeResults();
}, true);
// Reliable dismiss: close shortly after focus leaves the box. Result
// selection uses mousedown + preventDefault (focus stays on the input),
// so this never fires on a result click; the guard covers focus landing
// in the results list (e.g. dragging its scrollbar).
input.addEventListener('blur', function () {
setTimeout(function () {
if (resultsBox && resultsBox.contains(document.activeElement)) return;
closeResults();
}, 120);
});
// A tab swap (including our own search navigation) should dismiss it.
document.body.addEventListener('htmx:afterSwap', closeResults);
}
// --- Per-tab filter (delegated) -------------------------------------------
function filterScope(input) {
// Return the nearest tab/content container, or null — never `document`,
// which would let the filter hide setting fields across unrelated tabs.
return input.closest('.plugin-config-tab') ||
input.closest('[id$="-content"]') ||
input.closest('.bg-white') ||
null;
}
function fieldHay(fg) {
var label = textOf(fg.querySelector('label'));
var tip = fg.querySelector('.help-tip');
var help = tip ? (tip.getAttribute('data-tooltip') || '') : '';
var key = fg.getAttribute('data-setting-key') || fg.id.replace(/^setting-/, '');
return (label + ' ' + help + ' ' + key).toLowerCase();
}
function applyTabFilter(scope, q) {
q = q.trim().toLowerCase();
var terms = q ? q.split(/\s+/) : [];
var fields = scope.querySelectorAll('.form-group[id^="setting-"]');
var anyVisible = false;
fields.forEach(function (fg) {
var show = !terms.length || termsMatch(fieldHay(fg), terms);
fg.style.display = show ? '' : 'none';
if (show) {
anyVisible = true;
// Expand any collapsed nested section holding this match so it
// is actually visible (plugin tabs default their sections shut).
if (terms.length) expandNestedFor(fg);
}
});
if (!terms.length) {
// Filter cleared: restore the sections we opened and un-hide every
// nested-section wrapper, leaving user-expanded sections untouched.
scope.querySelectorAll('.nested-content[data-filter-expanded]').forEach(function (nc) {
collapseNode(nc);
delete nc.dataset.filterExpanded;
});
scope.querySelectorAll('.nested-section').forEach(function (ns) { ns.style.display = ''; });
} else {
// Hide nested-section wrappers whose fields all filtered out.
scope.querySelectorAll('.nested-section').forEach(function (ns) {
var secFields = ns.querySelectorAll('.form-group[id^="setting-"]');
var visible = 0;
secFields.forEach(function (f) { if (f.style.display !== 'none') visible++; });
ns.style.display = (secFields.length > 0 && visible === 0) ? 'none' : '';
});
}
// Hide section headings whose settings all got filtered out. A visible
// nested-section (plugin tabs) counts as content for its parent heading,
// so a heading isn't hidden while a subsection below it still has matches.
var nodes = scope.querySelectorAll('h3, h4, .form-group, .nested-section');
var headings = [];
var current = null;
nodes.forEach(function (node) {
if (node.tagName === 'H3' || node.tagName === 'H4') {
current = { el: node, total: 0, visible: 0 };
headings.push(current);
} else if (current && node.matches('.form-group[id^="setting-"]')) {
current.total++;
if (node.style.display !== 'none') current.visible++;
} else if (current && node.classList.contains('nested-section')) {
current.total++;
if (node.style.display !== 'none') current.visible++;
}
});
headings.forEach(function (h) {
// Only auto-hide headings that exclusively group settings fields.
h.el.style.display = (terms.length && h.total > 0 && h.visible === 0) ? 'none' : '';
});
// Toggle the "no matches" note if the filter box provides one.
const wrap = scope.querySelector('.settings-filter-wrap');
if (wrap) {
const empty = wrap.querySelector('.settings-filter-empty');
if (empty) empty.classList.toggle('hidden', !(terms.length && !anyVisible));
}
}
document.addEventListener('input', function (e) {
var box = e.target.closest ? e.target.closest('.settings-filter') : null;
if (!box) return;
var scope = filterScope(box);
if (scope) applyTabFilter(scope, box.value);
});
// --- Boot -----------------------------------------------------------------
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSearchBox);
} else {
initSearchBox();
}
// Expose for debugging / programmatic use.
window.LEDMatrixSettingsSearch = {
buildIndex: buildIndex,
navigateToSetting: navigateToSetting
};
console.log('[SettingsSearch] registered');
})();
+161
View File
@@ -0,0 +1,161 @@
/*
* tooltips.js accessible, delegated tooltip controller for the v3 web UI.
*
* A single controller handles every `.help-tip` trigger on the page, including
* ones inside partials that HTMX swaps in later, with zero per-field wiring.
* Triggers are emitted by the `help_tip` Jinja macro (partials/_macros.html) as
* <button class="help-tip" data-tooltip="..."><i class="fas fa-circle-info">.
*
* Behaviour:
* - hover (mouse) -> show / hide
* - keyboard focus -> show / hide (only for :focus-visible)
* - click / tap -> toggle (the touch path)
* - Escape / outside click -> hide
* The tooltip text is set via textContent (XSS-safe) and supports "\n" line
* breaks via CSS `white-space: pre-line`. Styling lives in app.css and uses the
* --color-* theme vars, so light/dark mode work automatically.
*/
(function () {
'use strict';
if (window._tooltipsInit) return;
window._tooltipsInit = true;
var panel = null;
var currentTrigger = null;
function getPanel() {
if (panel) return panel;
panel = document.createElement('div');
panel.id = 'ledm-tooltip';
panel.setAttribute('role', 'tooltip');
panel.hidden = true;
document.body.appendChild(panel);
return panel;
}
function positionPanel(trigger) {
var p = getPanel();
var margin = 8;
var rect = trigger.getBoundingClientRect();
var pw = p.offsetWidth;
var ph = p.offsetHeight;
var vw = document.documentElement.clientWidth;
var vh = document.documentElement.clientHeight;
// Prefer above the trigger; flip below if it would clip the top.
var top = rect.top - ph - margin;
var placedBelow = false;
if (top < margin) {
top = rect.bottom + margin;
placedBelow = true;
}
// Keep it on screen vertically as a last resort.
if (top + ph > vh - margin) top = Math.max(margin, vh - ph - margin);
// Center horizontally on the trigger, clamped to the viewport.
var left = rect.left + rect.width / 2 - pw / 2;
if (left < margin) left = margin;
if (left + pw > vw - margin) left = Math.max(margin, vw - pw - margin);
p.style.top = Math.round(top) + 'px';
p.style.left = Math.round(left) + 'px';
p.setAttribute('data-placement', placedBelow ? 'below' : 'above');
}
function show(trigger) {
var text = trigger.getAttribute('data-tooltip');
if (!text) return;
var p = getPanel();
p.textContent = text;
p.hidden = false;
// Measure after it is displayed, then position.
positionPanel(trigger);
trigger.setAttribute('aria-describedby', 'ledm-tooltip');
currentTrigger = trigger;
}
function hide() {
if (!panel) return;
panel.hidden = true;
if (currentTrigger) {
currentTrigger.removeAttribute('aria-describedby');
currentTrigger = null;
}
}
function triggerFrom(target) {
return target && target.closest ? target.closest('.help-tip') : null;
}
// --- Delegated listeners on document (survive HTMX swaps) ---
document.addEventListener('mouseover', function (e) {
var t = triggerFrom(e.target);
if (t) show(t);
});
document.addEventListener('mouseout', function (e) {
var t = triggerFrom(e.target);
if (!t) return;
// Ignore moves that stay within the same trigger.
var to = e.relatedTarget;
if (to && t.contains(to)) return;
if (currentTrigger === t) hide();
});
document.addEventListener('focusin', function (e) {
var t = triggerFrom(e.target);
if (!t) return;
// Only auto-show on keyboard focus, so a mouse/touch focus does not
// fight the click handler below.
var focusVisible;
try {
focusVisible = t.matches(':focus-visible');
} catch { // older browsers without :focus-visible
focusVisible = true;
}
if (focusVisible) show(t);
});
document.addEventListener('focusout', function (e) {
var t = triggerFrom(e.target);
if (t && currentTrigger === t) hide();
});
document.addEventListener('click', function (e) {
var t = triggerFrom(e.target);
if (t) {
// Prevent an enclosing <label> from toggling its control, and
// prevent form submission.
e.preventDefault();
e.stopPropagation();
if (currentTrigger === t && !getPanel().hidden) {
hide();
} else {
show(t);
}
return;
}
// Click anywhere else closes an open tooltip.
if (panel && !panel.hidden && !panel.contains(e.target)) hide();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && panel && !panel.hidden) hide();
});
// Reposition while visible; close when content is swapped out.
window.addEventListener('scroll', function () {
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
}, true);
window.addEventListener('resize', function () {
if (currentTrigger && panel && !panel.hidden) positionPanel(currentTrigger);
});
document.body.addEventListener('htmx:afterSwap', function () {
// The current trigger may have been removed by the swap.
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
});
console.log('[Tooltips] controller registered');
})();
@@ -174,11 +174,16 @@
cell.style.verticalAlign = 'middle'; cell.style.verticalAlign = 'middle';
if (colType === 'boolean') { if (colType === 'boolean') {
// Boolean: hidden sentinel + visible checkbox // Boolean: hidden sentinel + visible checkbox, same `name` (so
// unchecked boxes still submit "false"). Keep the hidden's value
// synced to the checkbox at all times — some form-collection
// paths prefer whichever of the two same-named inputs comes
// first/last in the DOM, and a stale hidden value previously
// caused this field to silently revert to false on every save.
const hidden = document.createElement('input'); const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.type = 'hidden';
hidden.name = inputName; hidden.name = inputName;
hidden.value = 'false'; hidden.value = String(Boolean(colValue));
cell.appendChild(hidden); cell.appendChild(hidden);
const cb = document.createElement('input'); const cb = document.createElement('input');
@@ -187,6 +192,9 @@
cb.checked = Boolean(colValue); cb.checked = Boolean(colValue);
cb.value = 'true'; cb.value = 'true';
cb.className = 'h-4 w-4 text-blue-600'; cb.className = 'h-4 w-4 text-blue-600';
cb.addEventListener('change', () => {
hidden.value = String(cb.checked);
});
cell.appendChild(cb); cell.appendChild(cb);
} else if (colType === 'integer' || colType === 'number') { } else if (colType === 'integer' || colType === 'number') {
@@ -54,15 +54,18 @@
const logoIdInput = row.querySelector('input[name*=".logo.id"]'); const logoIdInput = row.querySelector('input[name*=".logo.id"]');
if (nameInput && urlInput) { if (nameInput && urlInput) {
feeds.push({ const feedObj = {
name: nameInput.value, name: nameInput.value,
url: urlInput.value, url: urlInput.value,
enabled: enabledInput ? enabledInput.checked : true, enabled: enabledInput ? enabledInput.checked : true
logo: logoPathInput || logoIdInput ? { };
if (logoPathInput || logoIdInput) {
feedObj.logo = {
path: logoPathInput ? logoPathInput.value : '', path: logoPathInput ? logoPathInput.value : '',
id: logoIdInput ? logoIdInput.value : '' id: logoIdInput ? logoIdInput.value : ''
} : null };
}); }
feeds.push(feedObj);
} }
}); });
+167
View File
@@ -882,6 +882,25 @@
<!-- Connection status and theme toggle --> <!-- Connection status and theme toggle -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- Global settings search -->
<div class="relative hidden sm:block" id="settings-search-wrap">
<input id="settings-search"
type="text"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="settings-search-results"
aria-label="Search settings"
placeholder="Search settings…"
autocomplete="off"
class="form-control text-sm pl-8 pr-4 py-1.5 w-48 lg:w-64">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
<div id="settings-search-results"
role="listbox"
aria-label="Settings search results"
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div>
</div>
<!-- Theme toggle --> <!-- Theme toggle -->
<button id="theme-toggle" <button id="theme-toggle"
type="button" type="button"
@@ -913,6 +932,10 @@
<i class="fas fa-thermometer-half"></i> <i class="fas fa-thermometer-half"></i>
<span>--°C</span> <span>--°C</span>
</span> </span>
<span id="power-stat" class="hidden items-center space-x-1" title="">
<i class="fas fa-bolt"></i>
<span>Power</span>
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -947,6 +970,27 @@
</div> </div>
</div> </div>
<!-- Under-voltage / throttling warning banner -->
<div id="power-warning-banner" style="display:none"
class="power-warning-banner border-b transition-all duration-300 ease-in-out">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-exclamation-triangle text-lg"></i>
<span class="text-sm font-medium" id="power-warning-banner-text"
aria-live="polite" aria-atomic="true">
A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.
</span>
</div>
<button type="button" onclick="dismissPowerWarningBanner()"
class="power-warning-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss power warning">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
</div>
<!-- Main content --> <!-- Main content -->
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;"> <main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
<!-- Navigation tabs --> <!-- Navigation tabs -->
@@ -1009,6 +1053,11 @@
class="nav-tab"> class="nav-tab">
<i class="fas fa-history"></i>Operation History <i class="fas fa-history"></i>Operation History
</button> </button>
<button @click="activeTab = 'tools'"
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tools"></i>Tools
</button>
</nav> </nav>
</div> </div>
@@ -1290,6 +1339,18 @@
</div> </div>
</div> </div>
<!-- Tools tab -->
<div x-show="activeTab === 'tools'" x-transition>
<div id="tools-content" hx-get="/v3/partials/tools" hx-trigger="loadtab" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading --> <!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
<!-- <!--
Architecture: Server-side rendered plugin configuration forms Architecture: Server-side rendered plugin configuration forms
@@ -1428,6 +1489,89 @@
window.statsSource.addEventListener('error', window._statsErrorHandler); window.statsSource.addEventListener('error', window._statsErrorHandler);
window.displaySource.addEventListener('error', window._displayErrorHandler); window.displaySource.addEventListener('error', window._displayErrorHandler);
// Reset any time the currently-active warning clears, so a future
// (new) occurrence shows the banner again even if this one was dismissed.
window._powerWarningDismissed = false;
window.dismissPowerWarningBanner = function() {
const banner = document.getElementById('power-warning-banner');
if (banner) banner.style.display = 'none';
window._powerWarningDismissed = true;
};
// Labels for whichever flags from _get_power_status() are set (pass
// suffix='_occurred' for the "happened earlier" variant), used to
// build accurate banner/tooltip text instead of hardcoding
// "under-voltage" for what may actually be throttling/freq-capping/
// thermal limiting.
function _activePowerConditionLabels(power, suffix) {
suffix = suffix || '_now';
const labels = [];
if (power['under_voltage' + suffix]) labels.push('under-voltage');
if (power['throttled' + suffix]) labels.push('throttling');
if (power['freq_capped' + suffix]) labels.push('CPU frequency capped');
if (power['soft_temp_limit' + suffix]) labels.push('soft thermal limit');
return labels;
}
function updatePowerStatus(power) {
const statEl = document.getElementById('power-stat');
const banner = document.getElementById('power-warning-banner');
const bannerText = document.getElementById('power-warning-banner-text');
if (!power) {
if (statEl) statEl.classList.add('hidden');
if (banner) {
banner.style.display = 'none';
// Let a future occurrence show the banner again rather
// than leaving stale text/visibility from before this
// (likely transient) missing-data tick.
window._powerWarningDismissed = false;
}
return;
}
const activeNow = power.under_voltage_now || power.throttled_now ||
power.freq_capped_now || power.soft_temp_limit_now;
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
power.freq_capped_occurred || power.soft_temp_limit_occurred;
if (statEl) {
statEl.classList.remove('text-red-600', 'text-yellow-600');
if (activeNow) {
statEl.classList.remove('hidden');
statEl.classList.add('flex', 'text-red-600');
statEl.title = _activePowerConditionLabels(power).join('/') +
' detected right now — check your power supply and cooling';
} else if (occurredEarlier) {
statEl.classList.remove('hidden');
statEl.classList.add('flex', 'text-yellow-600');
const occurredLabels = _activePowerConditionLabels(power, '_occurred');
statEl.title = (occurredLabels.length ? occurredLabels.join('/') : 'An issue') +
' was detected earlier (currently OK)';
} else {
statEl.classList.add('hidden');
}
}
if (banner) {
if (activeNow) {
if (bannerText) {
const labels = _activePowerConditionLabels(power);
bannerText.textContent = (labels.length ? labels.join('/') : 'A power/thermal issue') +
' detected right now — the display may flicker or degrade. Check your power supply and cooling.';
}
if (!window._powerWarningDismissed) {
banner.style.display = '';
}
} else {
banner.style.display = 'none';
// Let a future occurrence show the banner again.
window._powerWarningDismissed = false;
}
}
}
function updateSystemStats(data) { function updateSystemStats(data) {
// Update CPU in header // Update CPU in header
const cpuEl = document.getElementById('cpu-stat'); const cpuEl = document.getElementById('cpu-stat');
@@ -1450,6 +1594,9 @@
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C'; if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
} }
// Update Power (under-voltage / throttling) status in header + banner
updatePowerStatus(data.power);
// Update Overview tab stats (if visible) // Update Overview tab stats (if visible)
const cpuUsageEl = document.getElementById('cpu-usage'); const cpuUsageEl = document.getElementById('cpu-usage');
if (cpuUsageEl && data.cpu_percent !== undefined) { if (cpuUsageEl && data.cpu_percent !== undefined) {
@@ -1905,6 +2052,22 @@
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect(); if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect(); else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect(); else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
else if (tab === 'tools') {
fetch('/v3/partials/tools')
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
contentEl.innerHTML = html;
contentEl.setAttribute('data-loaded', 'true');
if (window.Alpine) window.Alpine.initTree(contentEl);
})
.catch(err => {
console.error('Failed to load tools content:', err);
contentEl.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Tools. Please refresh the page.</p></div>';
});
}
} }
}, 100); }, 100);
}, },
@@ -4604,6 +4767,10 @@
<!-- Custom v3 JavaScript --> <!-- Custom v3 JavaScript -->
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
<!-- Settings tooltips + settings search -->
<script src="{{ url_for('static', filename='v3/js/tooltips.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/settings-search.js') }}" defer></script>
<!-- Modular Plugin Management JavaScript --> <!-- Modular Plugin Management JavaScript -->
<!-- Load utilities first --> <!-- Load utilities first -->
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
@@ -0,0 +1,42 @@
{# ============================================================================ #}
{# Shared UI macros for the v3 web interface. #}
{# #}
{# Import at the top of a partial with: #}
{# {% import 'v3/partials/_macros.html' as ui %} #}
{# #}
{# These power the settings tooltips and the settings search feature: #}
{# - help_tip(text, label): the (i) info icon whose hover/focus tooltip #}
{# explains a setting. This replaces the old always-visible <p> help. #}
{# - fg_id(tab, key): stable anchor id for a .form-group so global search #}
{# can scroll to it (e.g. "setting-display-brightness"). #}
{# - settings_filter(): the per-tab filter box shown under a partial title. #}
{# ============================================================================ #}
{# Info (i) tooltip trigger placed next to a setting label. #}
{# `text` supports "\n" line breaks (rendered via CSS white-space: pre-line). #}
{# Renders nothing when `text` is empty so callers can pass through schema data. #}
{% macro help_tip(text, label='') -%}
{%- if text -%}
<button type="button" class="help-tip" data-tooltip="{{ text }}"
aria-label="{% if label %}Help for {{ label }}{% else %}More information{% endif %}">
<i class="fas fa-circle-info" aria-hidden="true"></i>
</button>
{%- endif -%}
{%- endmacro %}
{# Stable anchor id for a settings field's .form-group wrapper. #}
{% macro fg_id(tab, key) -%}setting-{{ tab }}-{{ key }}{%- endmacro %}
{# Per-tab filter box. Place directly under a partial's <h2> title block. #}
{# The delegated handler in settings-search.js scopes to the enclosing tab. #}
{% macro settings_filter(placeholder='Filter these settings…') -%}
<div class="settings-filter-wrap relative mb-6">
<input type="text"
class="settings-filter form-control text-sm pl-9 pr-4 py-2 w-full"
placeholder="{{ placeholder }}"
aria-label="Filter settings on this tab"
autocomplete="off">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm" aria-hidden="true"></i>
<p class="settings-filter-empty text-sm text-gray-500 mt-3 hidden">No settings match your filter.</p>
</div>
{%- endmacro %}
@@ -1,3 +1,4 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="space-y-6" id="backup-restore-root"> <div class="space-y-6" id="backup-restore-root">
<!-- Security warning --> <!-- Security warning -->
@@ -70,12 +71,12 @@
<h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3> <h3 class="text-sm font-medium text-gray-900 mt-4 mb-2">Choose what to restore</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-700">
<label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-config" checked> <span>Main configuration</span>{{ ui.help_tip('Restore config.json — display settings, schedules, location, and plugin configuration.', 'Main configuration') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-secrets" checked> <span>API keys (secrets)</span>{{ ui.help_tip('Restore config_secrets.json — your weather, sports, and other service API keys.\nLeave off if you prefer to re-enter keys by hand.', 'API keys') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-wifi" checked> <span>WiFi configuration</span>{{ ui.help_tip('Restore saved WiFi network names and credentials.', 'WiFi configuration') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-fonts" checked> <span>User-uploaded fonts</span>{{ ui.help_tip('Restore any custom fonts you uploaded via the Fonts tab.', 'User-uploaded fonts') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-plugin-uploads" checked> <span>Plugin image uploads</span>{{ ui.help_tip('Restore images and files that plugins let you upload (logos, backgrounds, etc.).', 'Plugin image uploads') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span></label> <label class="flex items-center gap-2"><input type="checkbox" id="opt-reinstall" checked> <span>Reinstall missing plugins</span>{{ ui.help_tip('After restoring, re-download any plugins that were installed in the backup but are missing now.', 'Reinstall missing plugins') }}</label>
</div> </div>
<div class="mt-4 flex gap-2"> <div class="mt-4 flex gap-2">
+112 -80
View File
@@ -1,9 +1,12 @@
{% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Display Settings</h2> <h2 class="text-lg font-semibold text-gray-900">Display Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p> <p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
</div> </div>
{{ ui.settings_filter('Filter display settings…') }}
<!-- Hardware status banner: shown when display service is in fallback/simulation mode --> <!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
<div x-data="{ show: false, errorMsg: '' }" <div x-data="{ show: false, errorMsg: '' }"
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => { x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
@@ -37,8 +40,8 @@
<h3 class="text-md font-medium text-gray-900 mb-4">Hardware Configuration</h3> <h3 class="text-md font-medium text-gray-900 mb-4">Hardware Configuration</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4">
<div class="form-group"> <div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows">
<label for="rows" class="block text-sm font-medium text-gray-700">Rows</label> <label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, or 64. Default: 32. Must match your panel.', 'Rows') }}</label>
<input type="number" <input type="number"
id="rows" id="rows"
name="rows" name="rows"
@@ -46,11 +49,10 @@
min="1" min="1"
max="64" max="64"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of LED rows</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-cols" data-setting-key="display.hardware.cols">
<label for="cols" class="block text-sm font-medium text-gray-700">Columns</label> <label for="cols" class="block text-sm font-medium text-gray-700">Columns{{ ui.help_tip('Number of LED columns on a single panel.\nCommon: 32 or 64. Default: 64. Must match your panel.', 'Columns') }}</label>
<input type="number" <input type="number"
id="cols" id="cols"
name="cols" name="cols"
@@ -58,23 +60,21 @@
min="1" min="1"
max="128" max="128"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of LED columns</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length">
<label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length</label> <label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display.', 'Chain Length') }}</label>
<input type="number" <input type="number"
id="chain_length" id="chain_length"
name="chain_length" name="chain_length"
value="{{ main_config.display.hardware.chain_length or 2 }}" value="{{ main_config.display.hardware.chain_length or 2 }}"
min="1" min="1"
max="8" max="24"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of LED panels chained together</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel">
<label for="parallel" class="block text-sm font-medium text-gray-700">Parallel</label> <label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. The Raspberry Pi supports up to 3 (some HATs allow more).', 'Parallel') }}</label>
<input type="number" <input type="number"
id="parallel" id="parallel"
name="parallel" name="parallel"
@@ -82,13 +82,12 @@
min="1" min="1"
max="4" max="4"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Number of parallel chains</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-brightness" data-setting-key="display.hardware.brightness">
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness</label> <label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1100%).\nLower is dimmer, higher is brighter. Recommended: 7090 indoors, 90100 in bright rooms.', 'Brightness') }}</label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="range" <input type="range"
id="brightness" id="brightness"
@@ -99,11 +98,10 @@
class="flex-1"> class="flex-1">
<span id="brightness-value" class="text-sm font-medium w-12">{{ main_config.display.hardware.brightness or 95 }}</span> <span id="brightness-value" class="text-sm font-medium w-12">{{ main_config.display.hardware.brightness or 95 }}</span>
</div> </div>
<p class="mt-1 text-sm text-gray-600">LED brightness: <span id="brightness-display">{{ main_config.display.hardware.brightness or 95 }}</span>%</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping">
<label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping</label> <label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it; "Regular" for direct GPIO wiring.', 'Hardware Mapping') }}</label>
<select id="hardware_mapping" name="hardware_mapping" class="form-control"> <select id="hardware_mapping" name="hardware_mapping" class="form-control">
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option> <option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option> <option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
@@ -114,8 +112,8 @@
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence</label> <label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control"> <select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
<option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option> <option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option>
<option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option> <option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option>
@@ -124,11 +122,10 @@
<option value="BRG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BRG" %}selected{% endif %}>BRG</option> <option value="BRG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BRG" %}selected{% endif %}>BRG</option>
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option> <option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Color channel order for your LED panels</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing</label> <label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', 'Multiplexing') }}</label>
<select id="multiplexing" name="multiplexing" class="form-control"> <select id="multiplexing" name="multiplexing" class="form-control">
<option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option> <option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option>
<option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option> <option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option>
@@ -154,23 +151,32 @@
<option value="21" {% if main_config.display.hardware.get('multiplexing', 0)|int == 21 %}selected{% endif %}>21 - DoubleZMultiplex</option> <option value="21" {% if main_config.display.hardware.get('multiplexing', 0)|int == 21 %}selected{% endif %}>21 - DoubleZMultiplex</option>
<option value="22" {% if main_config.display.hardware.get('multiplexing', 0)|int == 22 %}selected{% endif %}>22 - P4Outdoor-80x40</option> <option value="22" {% if main_config.display.hardware.get('multiplexing', 0)|int == 22 %}selected{% endif %}>22 - P4Outdoor-80x40</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Multiplexing scheme for your LED panels</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-panel_type" data-setting-key="display.hardware.panel_type">
<label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type</label> <label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type{{ ui.help_tip('Special initialization for panels with a specific driver chip (e.g. FM6126A, FM6127).\nLeave on Standard unless your panel stays blank or shows only the first pixel.', 'Panel Type') }}</label>
<select id="panel_type" name="panel_type" class="form-control"> <select id="panel_type" name="panel_type" class="form-control">
<option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option> <option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option>
<option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option> <option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option>
<option value="FM6127" {% if main_config.display.hardware.get('panel_type', '') == "FM6127" %}selected{% endif %}>FM6127</option> <option value="FM6127" {% if main_config.display.hardware.get('panel_type', '') == "FM6127" %}selected{% endif %}>FM6127</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Special panel chipset initialization (use Standard unless your panel requires it)</p> </div>
<div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type">
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.', 'Row Address Type') }}</label>
<select id="row_address_type" name="row_address_type" class="form-control">
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option>
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option>
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
</select>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown">
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown</label> <label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows the GPIO signal so the panel keeps up.\nGuide: Pi 3 → 12, Pi 4 → 24, Pi 5 (PIO) → 13. Increase if the display shows garbage or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label>
<input type="number" <input type="number"
id="gpio_slowdown" id="gpio_slowdown"
name="gpio_slowdown" name="gpio_slowdown"
@@ -178,22 +184,20 @@
min="0" min="0"
max="10" max="10"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Pi 3: 1&ndash;2 &middot; Pi 4: 2&ndash;4 &middot; Pi 5 PIO: 1&ndash;3. Increase if display shows garbage; in RIO mode higher values may improve performance.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-rp1_rio" data-setting-key="display.runtime.rp1_rio">
<label for="rp1_rio" class="block text-sm font-medium text-gray-700"> <label for="rp1_rio" class="block text-sm font-medium text-gray-700">
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span> RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>{{ ui.help_tip('Pi 5 RP1 coprocessor driver mode.\nPIO (0) is the default and uses less CPU. RIO (1) can push a higher refresh rate but inverts the GPIO Slowdown behavior. Ignored on Pi 3/4.', 'RP1 Backend') }}
</label> </label>
<select id="rp1_rio" name="rp1_rio" class="form-control"> <select id="rp1_rio" name="rp1_rio" class="form-control">
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option> <option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option>
<option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 &mdash; RIO (higher throughput; slowdown inverted)</option> <option value="1" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 1 %}selected{% endif %}>1 &mdash; RIO (higher throughput; slowdown inverted)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Pi 5 RP1 coprocessor mode. Ignored on Pi 3/4.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-scan_mode" data-setting-key="display.hardware.scan_mode">
<label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode</label> <label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode{{ ui.help_tip('Order rows are refreshed in.\n0 = progressive (default), 1 = interlaced. Change only if you see banding or flicker on certain panels.', 'Scan Mode') }}</label>
<input type="number" <input type="number"
id="scan_mode" id="scan_mode"
name="scan_mode" name="scan_mode"
@@ -201,13 +205,12 @@
min="0" min="0"
max="1" max="1"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Scan mode for LED matrix (0-1)</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-pwm_bits" data-setting-key="display.hardware.pwm_bits">
<label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits</label> <label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits{{ ui.help_tip('Color depth per channel (111).\nHigher means smoother color but a lower refresh rate; lower means faster refresh with more banding. Default: 11 (this build defaults to 9).', 'PWM Bits') }}</label>
<input type="number" <input type="number"
id="pwm_bits" id="pwm_bits"
name="pwm_bits" name="pwm_bits"
@@ -215,11 +218,10 @@
min="1" min="1"
max="11" max="11"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">PWM bits for brightness control (1-11)</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-pwm_dither_bits" data-setting-key="display.hardware.pwm_dither_bits">
<label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits</label> <label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits{{ ui.help_tip('Time-dithering to gain apparent color depth (04).\nDefault: 0. Raising it can smooth gradients at the cost of a slightly lower refresh rate.', 'PWM Dither Bits') }}</label>
<input type="number" <input type="number"
id="pwm_dither_bits" id="pwm_dither_bits"
name="pwm_dither_bits" name="pwm_dither_bits"
@@ -227,13 +229,12 @@
min="0" min="0"
max="4" max="4"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">PWM dither bits (0-4)</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-pwm_lsb_nanoseconds" data-setting-key="display.hardware.pwm_lsb_nanoseconds">
<label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds</label> <label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds{{ ui.help_tip('Base time for the least-significant color bit (50500 ns).\nDefault: 130. Raising it can reduce flicker on some panels but lowers the maximum refresh rate.', 'PWM LSB Nanoseconds') }}</label>
<input type="number" <input type="number"
id="pwm_lsb_nanoseconds" id="pwm_lsb_nanoseconds"
name="pwm_lsb_nanoseconds" name="pwm_lsb_nanoseconds"
@@ -241,11 +242,10 @@
min="50" min="50"
max="500" max="500"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">PWM LSB nanoseconds (50-500)</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-limit_refresh_rate_hz" data-setting-key="display.hardware.limit_refresh_rate_hz">
<label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz)</label> <label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz){{ ui.help_tip('Caps the panel refresh rate (11000 Hz).\nDefault: 120. A steady cap reduces flicker in camera recordings and keeps timing consistent. Set higher or to the max your panel supports for the smoothest motion.', 'Limit Refresh Rate') }}</label>
<input type="number" <input type="number"
id="limit_refresh_rate_hz" id="limit_refresh_rate_hz"
name="limit_refresh_rate_hz" name="limit_refresh_rate_hz"
@@ -253,7 +253,46 @@
min="1" min="1"
max="1000" max="1000"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Limit refresh rate in Hz (1-1000)</p> </div>
</div>
</div>
<!-- Double-Sided Display -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
<label class="flex items-center gap-2">
<input type="checkbox"
id="double_sided_enabled"
name="double_sided_enabled"
value="true"
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="text-sm font-medium text-gray-700">Enabled</span>
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
</label>
</div>
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
<input type="number"
id="double_sided_copies"
name="double_sided_copies"
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
min="2"
max="8"
class="form-control">
</div>
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal &mdash; chained panels (side by side)</option>
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical &mdash; parallel chains (stacked)</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -263,7 +302,7 @@
<h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3> <h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-disable_hardware_pulsing" data-setting-key="display.hardware.disable_hardware_pulsing">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="disable_hardware_pulsing" name="disable_hardware_pulsing"
@@ -271,10 +310,11 @@
{% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %} {% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span> <span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span>
{{ ui.help_tip('Turn off hardware PWM pulsing.\nEnable this if the Pi audio is in use or you hear buzzing / see instability. Slightly increases CPU usage.', 'Disable Hardware Pulsing') }}
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-inverse_colors" data-setting-key="display.hardware.inverse_colors">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="inverse_colors" name="inverse_colors"
@@ -282,10 +322,11 @@
{% if main_config.display.hardware.inverse_colors %}checked{% endif %} {% if main_config.display.hardware.inverse_colors %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Inverse Colors</span> <span class="ml-2 text-sm font-medium text-gray-900">Inverse Colors</span>
{{ ui.help_tip('Invert every color the panel shows.\nDefault: off. Only needed for panels wired with inverted color logic.', 'Inverse Colors') }}
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-show_refresh_rate" data-setting-key="display.hardware.show_refresh_rate">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="show_refresh_rate" name="show_refresh_rate"
@@ -293,10 +334,11 @@
{% if main_config.display.hardware.show_refresh_rate %}checked{% endif %} {% if main_config.display.hardware.show_refresh_rate %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span> <span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span>
{{ ui.help_tip('Overlay the live panel refresh rate on the display.\nUseful for tuning GPIO Slowdown and PWM settings; turn off for normal use.', 'Show Refresh Rate') }}
</label> </label>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-use_short_date_format" data-setting-key="display.use_short_date_format">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" <input type="checkbox"
name="use_short_date_format" name="use_short_date_format"
@@ -304,6 +346,7 @@
{% if main_config.display.use_short_date_format %}checked{% endif %} {% if main_config.display.use_short_date_format %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Use Short Date Format</span> <span class="ml-2 text-sm font-medium text-gray-900">Use Short Date Format</span>
{{ ui.help_tip('Show dates in a compact form (e.g. 7/8 instead of July 8).\nHandy on narrow displays where space is tight.', 'Use Short Date Format') }}
</label> </label>
</div> </div>
</div> </div>
@@ -312,8 +355,8 @@
<div class="mt-6 pt-4 border-t border-gray-300"> <div class="mt-6 pt-4 border-t border-gray-300">
<h4 class="text-sm font-medium text-gray-900 mb-3">Dynamic Duration</h4> <h4 class="text-sm font-medium text-gray-900 mb-3">Dynamic Duration</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-max_dynamic_duration_seconds" data-setting-key="display.dynamic_duration.max_duration_seconds">
<label for="max_dynamic_duration_seconds" class="block text-sm font-medium text-gray-700">Max Dynamic Duration (seconds)</label> <label for="max_dynamic_duration_seconds" class="block text-sm font-medium text-gray-700">Max Dynamic Duration (seconds){{ ui.help_tip('Ceiling on how long a plugin may extend its own on-screen time (301800s).\nDefault: 180. Plugins with live content (e.g. a game in progress) can request extra time up to this limit.', 'Max Dynamic Duration') }}</label>
<input type="number" <input type="number"
id="max_dynamic_duration_seconds" id="max_dynamic_duration_seconds"
name="max_dynamic_duration_seconds" name="max_dynamic_duration_seconds"
@@ -321,7 +364,6 @@
min="30" min="30"
max="1800" max="1800"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Maximum time plugins can extend display duration (30-1800 seconds)</p>
</div> </div>
</div> </div>
</div> </div>
@@ -351,8 +393,8 @@
<!-- Vegas Settings (shown when enabled) --> <!-- Vegas Settings (shown when enabled) -->
<div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}"> <div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-vegas_scroll_speed" data-setting-key="display.vegas_scroll.scroll_speed">
<label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second)</label> <label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second){{ ui.help_tip('How fast the Vegas ticker scrolls (10200 px/s).\nDefault: 50. Higher is faster but harder to read.', 'Scroll Speed') }}</label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input type="range" <input type="range"
id="vegas_scroll_speed" id="vegas_scroll_speed"
@@ -364,11 +406,10 @@
class="flex-1"> class="flex-1">
<span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span> <span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span>
</div> </div>
<p class="mt-1 text-sm text-gray-600">Speed of the scrolling ticker (10-200 px/s)</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels)</label> <label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
<input type="number" <input type="number"
id="vegas_separator_width" id="vegas_separator_width"
name="vegas_separator_width" name="vegas_separator_width"
@@ -376,29 +417,26 @@
min="0" min="0"
max="128" max="128"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">Gap between plugin content blocks (0-128 px)</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS</label> <label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
<select id="vegas_target_fps" name="vegas_target_fps" class="form-control"> <select id="vegas_target_fps" name="vegas_target_fps" class="form-control">
<option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option> <option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option>
<option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option> <option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option>
<option value="125" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 125 %}selected{% endif %}>125 FPS (Smoothest)</option> <option value="125" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 125 %}selected{% endif %}>125 FPS (Smoothest)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Higher FPS = smoother scroll, more CPU usage</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-vegas_buffer_ahead" data-setting-key="display.vegas_scroll.buffer_ahead">
<label for="vegas_buffer_ahead" class="block text-sm font-medium text-gray-700">Buffer Ahead</label> <label for="vegas_buffer_ahead" class="block text-sm font-medium text-gray-700">Buffer Ahead{{ ui.help_tip('How many upcoming plugins to pre-render so the scroll never stalls.\nDefault: 2 (recommended). More uses extra memory; less saves memory but risks hitches.', 'Buffer Ahead') }}</label>
<select id="vegas_buffer_ahead" name="vegas_buffer_ahead" class="form-control"> <select id="vegas_buffer_ahead" name="vegas_buffer_ahead" class="form-control">
<option value="1" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 1 %}selected{% endif %}>1 Plugin (Less memory)</option> <option value="1" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 1 %}selected{% endif %}>1 Plugin (Less memory)</option>
<option value="2" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 2 %}selected{% endif %}>2 Plugins (Recommended)</option> <option value="2" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 2 %}selected{% endif %}>2 Plugins (Recommended)</option>
<option value="3" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 3 %}selected{% endif %}>3 Plugins (More buffer)</option> <option value="3" {% if main_config.display.get('vegas_scroll', {}).get('buffer_ahead', 2) == 3 %}selected{% endif %}>3 Plugins (More buffer)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">How many plugins to pre-load ahead</p>
</div> </div>
</div> </div>
@@ -431,18 +469,17 @@
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group" id="setting-display-sync_role" data-setting-key="sync.role">
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role</label> <label for="sync_role" class="block text-sm font-medium text-gray-700">Role{{ ui.help_tip('This unit\'s part in a two-display setup.\nStandalone = sync off. Set one Pi to Leader (drives the scroll) and the other to Follower (receives frames). Restart required after changing.', 'Sync Role') }}</label>
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()"> <select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()">
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option> <option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option> <option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option> <option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Set Leader on one Pi, Follower on the other. Restart required after changing.</p>
</div> </div>
<div class="form-group"> <div class="form-group" id="setting-display-sync_port" data-setting-key="sync.port">
<label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port</label> <label for="sync_port" class="block text-sm font-medium text-gray-700">UDP Port{{ ui.help_tip('UDP port the two displays use to exchange frames (102465535).\nDefault: 5765. Must match on both Pis. If the ufw firewall is active, allow it with: sudo ufw allow ' ~ main_config.get('sync', {}).get('port', 5765) ~ '/udp', 'Sync UDP Port') }}</label>
<input type="number" <input type="number"
id="sync_port" id="sync_port"
name="sync_port" name="sync_port"
@@ -450,19 +487,14 @@
min="1024" min="1024"
max="65535" max="65535"
class="form-control"> class="form-control">
<p class="mt-1 text-sm text-gray-600">
Must match on both Pis. If ufw is active:
<code class="text-xs bg-gray-200 px-1 rounded">sudo ufw allow {{ main_config.get('sync', {}).get('port', 5765) }}/udp</code>
</p>
</div> </div>
<div class="form-group" id="sync_position_group" style="display:none"> <div class="form-group" id="setting-display-sync_follower_position" data-setting-key="sync.follower_position" style="display:none">
<label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position</label> <label for="sync_follower_position" class="block text-sm font-medium text-gray-700">Position{{ ui.help_tip('Which side of the leader this follower display sits on.\nSets whether this unit shows the left or right half of the extended scroll.', 'Follower Position') }}</label>
<select id="sync_follower_position" name="sync_follower_position" class="form-control"> <select id="sync_follower_position" name="sync_follower_position" class="form-control">
<option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option> <option value="left" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'left' %}selected{% endif %}>Left of leader</option>
<option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option> <option value="right" {% if main_config.get('sync', {}).get('follower_position', 'left') == 'right' %}selected{% endif %}>Right of leader</option>
</select> </select>
<p class="mt-1 text-sm text-gray-600">Which side of the leader display this unit sits on.</p>
</div> </div>
</div> </div>
@@ -741,7 +773,7 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
function updateSyncUI() { function updateSyncUI() {
const role = document.getElementById('sync_role').value; const role = document.getElementById('sync_role').value;
const bar = document.getElementById('sync_status_bar'); const bar = document.getElementById('sync_status_bar');
const posGroup = document.getElementById('sync_position_group'); const posGroup = document.getElementById('setting-display-sync_follower_position');
if (role === 'standalone') { if (role === 'standalone') {
bar.classList.add('hidden'); bar.classList.add('hidden');
document.getElementById('sync_error_detail').classList.add('hidden'); document.getElementById('sync_error_detail').classList.add('hidden');

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