Compare commits

..
46 Commits
Author SHA1 Message Date
Chuck 853b5aa618 Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation
# Conflicts:
#	CHANGELOG.md
#	src/display_manager.py
2026-09-24 19:39:12 -04:00
ChuckandClaude Opus 5.5 8ad9d191a7 feat(perf): frame timing for every presented frame, a soak tool, a render bench and a stall watchdog (#629)
src/common/frame_timing.py times every frame the display presents, whoever drew it, and writes cumulative counters to /dev/shm. scripts/frame_soak.py grades a running service (late frames, freezes, where the time goes) and scripts/render_bench.py the hardware and render path alone. A stall watchdog logs the stacks behind any scroll held up for 250 ms or more (LEDMATRIX_STALL_WATCHDOG_MS lowers that). See docs/SCROLL_PERFORMANCE.md, "Soaking a rig".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:38:49 -04:00
ChuckandClaude Opus 5.5 7f9c73e9aa fix(vegas): smooth Vegas scroll pacing -- whole pixels per refresh, measured refresh, off-thread preview writes (#628)
Vegas scrolls a whole number of pixels per panel refresh, locked to SwapOnVSync, against the refresh the panel really holds (measured from swap gaps), instead of blending sub-pixel positions against the refresh cap. The web preview PNG is encoded off the render thread while scrolling, with writes ordered and retried. On hdpi, late frames fell from 6.3% to 0.7%. See docs/SCROLL_PERFORMANCE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:38:22 -04:00
Chuck 86c27970ef Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 18:58:49 -04:00
ChuckandClaude Opus 5.5 ab39552cdb refactor(display): stop the frame-timing watchdog at the end of cleanup()
#628 adds its snapshot-writer stop at the top of cleanup(); with this at the
top as well, merging #629 after #628 conflicted. Same behaviour, no
overlap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:58:42 -04:00
Chuck 83fd3491c7 Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 18:34:11 -04:00
ChuckandClaude Opus 5.5 37203e635c fix(perf): recorder review fixes -- period adoption, timed frames, snapshot copy, watchdog stop
From review (CodeRabbit):

- The first refresh period is adopted only once two windows in a row agree.
  One loaded startup window, most frames a refresh late, used to fix a
  period twice the real one for the life of the process.
- totals.timed_frames counts the frames judged against a known period, and
  frame_soak rates late and early frames over it. Before, frames seen before
  any period was known counted as on time, so a short run could PASS
  having judged nothing; now it has no verdict.
- snapshot() copies totals. render_bench kept the snapshot object and
  differenced it against a later one sharing the same live dict, so every
  graded run reported zero frames.
- held_refresh_hz uses the p50 bucket's midpoint, not its upper edge (a
  1-2% low bias, the size of the idle-vs-held gap it exists to show).
- StallWatchdog.stop() and FrameTimingRecorder.close(); DisplayManager's
  cleanup() calls it, so the watchdog no longer outlives its manager.
- A stall that outlasts the 2s scroll-state expiry still gets its end
  reported (tracking is dropped only past GAP_SECONDS).
- test: EMULATOR through monkeypatch; docs: the 1s resume rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:33:34 -04:00
Chuck 695be34a1b Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 18:22:42 -04:00
ChuckandClaude Opus 5.5 ffdd0efd43 style(bench): say when the panel could not be blanked after a run (Codacy B110)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:22:38 -04:00
Chuck cfcdbfb18a Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 17:44:33 -04:00
Chuck 4c17e18786 Merge commit 'b9416ef8' into claude/frame-timing-harness 2026-09-24 17:42:43 -04:00
ChuckandClaude Opus 5.5 b9416ef803 fix(display): Vegas teardown and 240s default; remove dead Vegas buffer code (#637)
* fix(display): tear down Vegas mode on controller cleanup

DisplayController.cleanup() never called VegasModeCoordinator.cleanup(),
so the Vegas teardown (stop, pipeline/stream reset, adapter cache drop)
was unreachable. Call it before the display manager is cleaned up, and
skip it when Vegas was never created.

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

* fix(vegas): default max_cycle_duration to the documented 240s

The template, the web UI help, CONFIG_REFERENCE and the controller all
say 240, but the code defaulted to 600 in two places, so a config
without the key ran Vegas iterations 2.5x longer than documented.

from_config now falls back to the dataclass field defaults instead of
repeating each one, so the two copies can no longer drift, and the
controller's follower scroll-speed default reads VegasModeConfig's.

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

* fix(display): let run.py -d show display_manager's DEBUG output

display_manager pinned its logger to INFO at import, overriding the root
level, so debug mode never showed its DEBUG lines. Use get_logger() from
src.logging_config like the rest of the core and leave the level to the
logging setup.

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

* fix(display): run each startup validation check once

StartupValidator.validate_all() ran twice at boot, before and after the
plugin manager was created, so every config, cache, display and
systemd-unit warning was logged twice. The second pass now runs only the
plugin checks. Drop the commented-out raise_on_errors line.

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

* fix(vegas): one INFO line per plugin-list refresh

StreamManager logged "=" * 60 banners and a line per plugin (INCLUDED,
SKIPPED, FETCHING CONTENT, SEGMENT CREATED) at INFO on every refresh and
fetch, i.e. at each cycle start and every 30s. Log one INFO summary of
the rotation per refresh and move the per-plugin detail, the weighting
breakdown and "no content this cycle" to DEBUG (the adapter still warns
when every content path fails).

Also drop the check/cross marks from the controller's log messages.

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

* fix(vegas): drop the per-iteration static-mode plugin scan

run_iteration() rebuilt _static_mode_plugins on every iteration, asking
every plugin for its display mode and logging the set at INFO, but
nothing ever read it: static pauses are triggered by
_check_static_plugin_trigger() from the next segment. Delete it, the
coordinator's get_ordered_plugins() that only it used, and the
write-only _static_pause_plugin / _static_pause_start.

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

* fix(vegas): remove the staging buffer that was never filled

StreamManager and RenderPipeline carried a double-buffer design that
nothing used: _staging_buffer was only ever cleared or swapped, so
swap_buffers() never did anything and should_recompose()'s
staging_count > 0 branch was dead, and _active_scroll_image,
_staging_scroll_image, _is_rendering, _last_frame_time and
_frame_interval were written but never read. Delete the machinery and
rewrite the docstrings around what actually carries updates:
_pending_updates, consumed by process_updates() in swap mode and
invalidate_pending_updates() in continuous mode.

should_recompose() no longer builds a buffer-status dict every frame.

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

* refactor(display): tidy the display controller without changing behaviour

- Import VegasModeCoordinator locally instead of through module globals
  (there is no circular import to avoid).
- Drop hasattr() checks on attributes PluginManager.__init__ always sets
  (plugin_executor, plugin_last_update, get_plugin_lock,
  run_scheduled_updates*, stop_update_worker) and the dead "older
  manager" fallbacks; keep the health_tracker None checks, now via
  _health_tracker().
- Extract _display_once() for the per-frame display call both render
  loops copied, _advance_on_demand() for the two on-demand rotations,
  _reset_on_demand_fields() for the error and clear paths, and
  _timezone() / _in_window() for the two schedule checks.
- Remove always-true conditions and the unreachable non-plugin else
  branch in run(), and read _was_display_active / _last_published_mode /
  vegas_coordinator directly now that __init__ declares them.
- Declare the follower render state in __init__, name its tuning
  constants, add _follower_sign(), and share the 90/s sync send
  interval with the render pipeline (SYNC_SEND_INTERVAL).
- Delete history narration and the "Opt #N" labels, fix the comment
  that called _scroll_speed constant (hot reload updates it), and drop
  a startup timing log that measured nothing.
- render_pipeline / plugin_adapter: read display_manager.width/height
  as the properties they are, drop an empty TYPE_CHECKING block, an
  aliased threading import and a duplicated `if result and
  self.sync_manager:`.

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

* refactor(display): trim dead code from display_manager

- Add _new_canvas() for the image/draw/fontmode="1" setup that was
  copied six times.
- Call resolve_double_sided() and compose_pixel_mapper_config() directly
  instead of through a module alias and a passthrough method, and replace
  the comment that said the passthrough read class attributes.
- Delete the unused _initialized flag and _ORIENTATION_ROTATE_DEGREES
  alias (no core or monorepo reader; tests stop resetting the flag), the
  test pattern's unreachable no-matrix branch (it only runs once the
  matrix exists), `del old_image  # help GC` (a no-op on a local), a
  duplicated early return in process_deferred_updates, and stale
  comments.

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

* refactor(vegas): remove unread fields and test-only helpers, fix docstrings

- ContentSegment: drop total_width, fetched_at, is_stale, image_count and
  is_static, none of which is read.
- StreamManager: drop _current_index (never advanced) and the test-only
  get_all_content_for_composition() and has_pending_updates();
  VegasModeConfig: drop the test-only is_plugin_included().
- geometry.find_blank_cut() has had no production caller since the crop
  moved to item boundaries; delete it and its tests.
- PluginAdapter: the _finalize docstring described separator_width
  between every image, and _crop_to_budget's said cuts snap to the
  nearest blank column; both now describe what the code does.
- Coordinator: the static-pause interrupt log no longer blames follower
  mode for every interrupt, and set_update_callback names the callback
  the controller actually wires.

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

* refactor(scroll): correct ScrollHelper comments and drop dead branches

- Four comments said the strip always starts with display_width of
  blank; it does only when lead_gap is None (Vegas passes its own).
- Delete the "Width calculation mismatch" warning: the image is created
  at the calculated width, so the two can never differ.
- Remove the two scroll_delay <= 0 fallbacks (which disagreed with each
  other): set_scroll_delay clamps it to at least 0.001 and nothing in
  core or the plugin monorepo assigns it directly.
- Trim the scipy history from the blend docstring.

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

* refactor(run): drop a redundant comment

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

* fix(display): log set_scrolling_state only when it changes

Vegas and scrolling plugins set the scrolling state every frame, so once
display_manager's DEBUG output became visible in debug mode it printed
"Scrolling state set to: True" about 120 times a second. Log only when
the value differs from the previous one; the state, activity timestamp
and frame hold still update on every call.

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

* docs(changelog): display-vegas

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:37:15 -04:00
Chuck 764fc6fcdb Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 17:36:54 -04:00
ChuckandClaude Opus 5.5 f6afbdbb15 fix(web): Cache/Logs error mix-up, store errors, tab fallbacks; remove ~2.5k lines of dead JS (#639)
* fix(web): keep Cache and Logs helpers out of each other's way

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(web): delete the unused handleCredentialsUpload

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

app()'s unused baseComponent lookup is removed.

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

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

Several callers refreshed the installed list with

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

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

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

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

* refactor(web): route debug output through debugLog

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): web-frontend

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:36:53 -04:00
Chuck 99a3608516 Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness 2026-09-24 17:36:48 -04:00
Chuck b1510c209d Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation
# Conflicts:
#	CHANGELOG.md
2026-09-24 17:36:39 -04:00
Chuck 48a433328a Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation
# Conflicts:
#	CHANGELOG.md
2026-09-24 17:36:25 -04:00
Chuck edfcd9e2a1 Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness
# Conflicts:
#	CHANGELOG.md
2026-09-24 17:35:58 -04:00
ChuckandClaude Opus 5.5 3a81f38f09 fix(web): uniqueItems saves, /health count, Vegas order wipe; one list-repair helper (#638)
* fix(web): drop repeats from uniqueItems lists before validating a plugin save

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): web-backend

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The @deprecated methods stay.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): core-common

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): plugin-system

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

---------

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

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

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

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

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

* docs: correct stale setup, service and troubleshooting claims

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behaviour change except the output text noted below.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): docs-scripts

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:31:41 -04:00
Chuck 74ba36a059 Merge remote-tracking branch 'origin/claude/frame-timing-harness' into claude/scan-order-compensation 2026-09-24 17:02:24 -04:00
ChuckandClaude Opus 5.5 d37a3a712a style(perf): say why render_bench fell back; mark the stats path as a safe fixed name (Codacy)
Codacy (Bandit B110, B108). The bench's silent except now prints why it read
config.json directly. The stats file's fixed name in /dev/shm is safe:
write() goes through mkstemp and os.replace, which replaces a planted
symlink instead of following it; the comment says so and marks it nosec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:02:15 -04:00
Chuck dc26baa134 Merge remote-tracking branch 'origin/claude/frame-timing-harness' into claude/scan-order-compensation
# Conflicts:
#	src/display_manager.py
2026-09-24 16:58:46 -04:00
ChuckandClaude Opus 5.5 6aef54598b docs(display): the mid-panel tear can be compensated; say how, and when it cannot
main's new section called the 1px step at mid-height a panel-scan effect with
nothing to fix in the render path. The scan explanation holds, but at one
pixel per refresh the step is one refresh of motion and lagging one half
cancels it, which this branch does. Rewrite that paragraph, list what
compensation covers and what it leaves to the old advice (held frames,
other layouts, the emulator), and add a CHANGELOG entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:52:33 -04:00
Chuck 7b16e953d2 Merge remote-tracking branch 'origin/main' into claude/scan-order-compensation 2026-09-24 16:49:41 -04:00
Chuck 52bc520335 Merge remote-tracking branch 'origin/main' into claude/frame-timing-harness
# Conflicts:
#	CHANGELOG.md
#	docs/SCROLL_PERFORMANCE.md
2026-09-24 16:30:49 -04:00
ChuckandClaude Opus 5.5 4e61d7248a refactor(web): one error-response path for api_v3 (#624)
* refactor(web): answer unhandled api_v3 errors from one blueprint handler

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Nine plugin routes built a structured error by hand:

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

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

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

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

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

---------

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

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

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

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

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

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

* docs(changelog): one plugin-directory resolver

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

---------

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

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

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

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

The duplicate module is deleted; nothing else imported it.

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

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

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

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

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

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

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

* docs(changelog): web logging and TTL cache

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* fix(fonts): one BDF face per thread

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:50:50 -04:00
ChuckandClaude Opus 5.5 5baf983fe0 docs(scroll): explain the tear across the middle on fast scrolls (#620)
* docs(scroll): explain the tear across the middle on fast scrolls

A 1:32-multiplexed 64-row panel lights row 31 almost a whole refresh after
row 32, so fast scrolls show a sideways offset at mid-height of about
speed x refresh period. Documents the cause, how to read the real refresh
rate (show_refresh_rate prints with a carriage return), what was measured on
a single-chain 2x128x64 Pi 4 (pwm_bits, gpio_slowdown and an uncapped
refresh barely help; gpio_slowdown 2 glitches), and the fix that does help:
fewer pixels per output via parallel chains.

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

* docs(scroll): limit the 1:32 row-pair explanation to panels that scan that way

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:50:33 -04:00
ChuckandClaude Opus 5.5 82f3a3a3e4 fix(redaction): make credential redaction linear, not quadratic (#631)
* fix(redaction): make URL-userinfo redaction linear, not quadratic

_REDACT_URL_USERINFO could start a match at every letter of a run of
scheme characters, and each attempt read to the end of the run looking
for `://`. On a long unbroken run of letters or digits (a hex digest, an
ID, part of a response body) that is quadratic: 1.6s for 20k characters.

The display service redacts every message, stack trace and context value
it publishes in the error snapshot, holding the aggregator lock, and
re.sub holds the GIL for the whole call, so one such exception stalled
every thread, render loop included (~0.5s measured for 20k chars of hex).
It also made test_snapshot_stays_small the slowest test in the suite by
far: 142s of a 383s run, 139s of it in this one regex.

A match may now only start where a run of scheme characters starts
(negative lookbehind). Leading digits and `+.-` are captured in group 1
so the substitution restores them, and the scheme still has to start
with a letter, so what gets redacted is unchanged: old and new output
were identical on 300k fuzzed inputs. 20k chars now take ~0.5ms, 200k
~6ms, and test_snapshot_stays_small takes 0.8s.

test/test_redaction.py pins the exact output for schemes that begin after
digits or `+.-`, and bounds 50k-character runs at 1s; against the old
pattern those timing tests fail at 3-11s each.

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

* fix(redaction): make Authorization-header redaction linear too

_REDACT_AUTH_HEADER matched the value's opening as `\s*["\']?\s*`: two
`\s*` separated only by an optional quote. With no quote, a whitespace
run could be split between them in every possible way, and when no
credential followed (end of text, or `,` `"` `<` ...) the engine tried
them all before giving up: quadratic, 8s for `authorization:` and 20k
spaces, 17s with `Proxy-Authorization:` (tried again at the inner
`authorization`). Same stall as the URL pattern: re.sub holds the GIL,
and the display service redacts everything it publishes.

The quote and the whitespace after it are now one optional unit,
`\s*(?:["\']\s*)?`, which matches the same strings with only one way to
split them. Output is identical to the old pattern on 300k fuzzed
inputs; 20k spaces now take ~1.6ms. A scan of all three redaction
patterns over prefix/run/suffix shapes finds none left that scales
superlinearly.

test/test_redaction.py pins exact output for quoted, tabbed, multi-line
and credential-less headers, and bounds header + 20k whitespace at 1s;
against the previous pattern those fail at 8-17s each.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 15:49:51 -04:00
ChuckandClaude Opus 5.5 c1ce0b7b04 fix(web): two api_v3 paths called names that no longer exist (#625)
The Pixlet editor stop route restarts the display after a SIGKILL with
_run_systemctl_command, which starlark.py never imported (since #554). The
Starlark device-location resolver fell back to _ensure_cache_manager, which
#609 deleted; the resolver already accepts no cache manager. Both raised
NameError on the rare path that reaches them. pyflakes finds no other
undefined names in src/ or web_interface/.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:49:29 -04:00
ChuckandClaude Opus 5.5 14c38a3189 fix(perf): count a stall even when the scroll state went missing across it
On hdpi the stall watchdog logged a 1.9s stall during the hourly sports
refresh that the soak report never had: its worst gap was 655ms. The frame
that ended the stall was recorded as static, so its interval was dropped.

"Scrolling" is DisplayManager's scroll state at the moment a frame is
presented, and it goes missing mid-scroll: it expires after 2s without
activity, and any thread can clear it. Plugins call
set_scrolling_state(False) from their own display() (news, stocks, the odds
ticker's fallback), and Vegas captures some of those on the render thread
between two of its own frames. Vegas sets the state again only after its
next frame, so that frame is recorded as static -- along with the capture
or stall it followed.

One static frame between two scrolling frames, with the scroll resuming
within RESUME_SECONDS (1s), is now a frame of the scroll and both of its
intervals count, the first at the scroll's own hold (clearing the state
drops the hold to 1 too). Two static frames in a row still end the scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:23:18 -04:00
ChuckandClaude Opus 5.5 b67818d5c5 feat(perf): LEDMATRIX_STALL_WATCHDOG_MS lowers the stall watchdog's threshold
250ms catches freezes; the hitches left on hdpi are frames 2-5 refreshes
late, which look like the render thread waiting for the GIL. At 30ms the
watchdog dumps those too, naming what the other threads were running when
the frame missed. It polls at a third of the threshold so a stall one poll
long is still seen, which costs some GIL time of its own: a diagnostic
setting, not one to soak with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:26:04 -04:00
ChuckandClaude Opus 5.5 f79618d4f7 refactor(bench): grade render_bench with the shared frame-timing recorder
render_bench.py (from the parallel perf/render-bench work) had its own
grading module, frame_pacing, with its own definition of a missed frame
and its own refresh estimate. The soak already had both in frame_timing,
so the two could have drifted apart on what "late" means.

The bench now gives the display manager a fresh FrameTimingRecorder,
drains it synchronously at the start and end of the graded run, and prints
frame_soak's report with frame_soak's verdict. Its workload is unchanged:
the synthetic strip, --busy load, the shared speed resolver, the
per-frame scrolling announcement. frame_pacing, its tests and its
src.common exports are removed; measure_refresh_hz moves to frame_timing,
where scroll_speeds.py now finds it.

Two ideas from frame_pacing carry over. The bench seeds the recorder with
the idle refresh it measures, so a loop that free-runs (the 827fps bug
the first bench caught) shows as early frames and one stuck at half rate
as late frames, where an estimate taken from their own intervals finds
both self-consistent. And the soak, which has no idle measurement, now
calls a run NOT LOCKED when its refresh estimate beats the configured cap.
The report also gives the rate held while rendering.

Docs: the bench becomes "Without the service" under "Soaking a rig",
keeping its hdpi numbers and the idle-vs-rendering refresh finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:56:44 -04:00
ChuckBuildsandClaude Opus 5 d56ec2ab3a feat(bench): measure a rig against the refresh it actually holds
There was no way to answer "does this hardware present every frame on
time?" other than watching the panel. `scripts/render_bench.py` drives the
production path -- a real DisplayManager and ScrollHelper, configured
through the same `scroll_config` resolver every ticker uses -- and grades
the run with a new `src.common.frame_pacing`, exiting non-zero when more
than 0.1% of frames slipped a refresh. Exit 2 when the run could not be set
up at all, so a rig that was never measured cannot pass by accident.

A missed frame is defined exactly: an interval that rounds up to at least
one more refresh than its frame hold asked for. The half-refresh rounding
boundary keeps a frame that ran 1ms long on a 10ms refresh out of the
count, because it still presented on the refresh it was meant to.

The verdict that matters more is NOT LOCKED. A loop that never blocked on
vsync reports a perfect zero misses while presenting nothing -- 8ms frames
on a 100Hz panel all land in the one-refresh bucket while running 25% too
fast -- so the report also checks the typical frame is not shorter than the
panel could physically present. That is what caught the first version of
this benchmark announcing its scrolling state once instead of per frame:
the state expires on an inactivity threshold, the dirty-tracking skip then
fires mid-scroll, and the loop free-ran at 827fps.

And the refresh is read back out of the frames rather than taken from an
idle measurement. Driving the matrix is bit-banging on the same machine, so
pushing frames slows the refresh: a Pi 4 on 512x64 measures 100.4Hz idle
and holds 96.3Hz while scrolling. Both are real, and grading against the
idle figure reports a locked loop as 4% slow -- or, once the gap passes
half a refresh, as missing every frame. The gap between the two is itself
worth watching: a rise in it is a render-cost regression even when nothing
is missed.

Measured on hdpi (Pi 4, 512x64, pwm_bits 8), two minutes each:

  plain      95.44 fps, 8 missed of 11,449 (0.070%)  PASS
  --busy 2   95.41 fps, 3 missed of 11,445 (0.026%)  PASS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-24 11:08:43 -04:00
ChuckandClaude Opus 5.5 c883a2fd1e feat(perf): a stall watchdog that logs what the render thread is waiting on
The recorder counts freezes; it cannot say why. hdpi showed 1-2s freezes
in both the #628 and offscreen builds, one lining up with hockey's 2s
NHL fetch on the update thread, and nothing in the logs explained it.

StallWatchdog polls every 50ms from its own thread. When a scroll's last
frame is more than 250ms old (and a scroll is still running, so the end
of a scroll is not a stall), it logs the stack of the thread that
presented that frame and the top of every other thread's, then the
stall's length when frames resume. It also measures how late its own
wake-up was: if it was held up as long as the render thread, the whole
interpreter was blocked (C code holding the GIL), not one thread on a
lock. One dump per 30s at most; LEDMATRIX_STALL_WATCHDOG=0 disables it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:58:27 -04:00
ChuckandClaude Opus 5.5 ac841f4583 docs(perf): hdpi soak results, main vs #628
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 08:56:49 -04:00
ChuckandClaude Opus 5.5 98728d3b81 fix(perf): count 1-2s stalls, flag early frames, and keep the refresh estimate honest
Three gaps found by the first hdpi soaks:

- Intervals of 1s or more between two scrolling frames were dropped as
  "gaps between scrolls". But the scrolling state lapses only after 2s, so
  every 1-2s stall inside a scroll vanished from the report. Those are now
  freezes (the gap bound is a 5s sanity limit), with a breakdown by length.
- A frame a whole refresh early means the swap did not wait for the panel.
  Those are counted, and a soak with more than the threshold of them fails
  as NOT LOCKED instead of reporting a flattering late rate.
- The refresh estimate took the lowest window it had seen, so one window of
  non-blocking swaps halved it and made every early frame look on time. A
  window may now lower it by at most 20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 08:49:37 -04:00
ChuckandClaude Opus 5.5 3eb7a2e349 feat(perf): time every presented frame, and a soak script to judge a rig
Each scroller already logs its own stats line, but in different formats,
per source, and Vegas logs a healthy window only at DEBUG. None of it
answers the question a release has to answer on each rig: over a long
run, how often did a moving frame reach the panel late?

Every frame reaches the panel through DisplayManager.update_display, so
it is timed there once, whoever drew it: the blit (SetImage), the vsync
wait, and the interval since the previous frame. The render thread only
appends a tuple. A worker thread aggregates cumulative counters and
histograms and rewrites /dev/shm/ledmatrix_frame_stats.json every 10s
(RAM, so no SD wear).

A frame due after `hold` refreshes that lands one or more refreshes
later is "late": the panel repeated the previous frame, a visible hitch.
Gaps of 250ms+ inside a scroll are "freezes" (recomposes, handovers,
blocking calls), counted separately so one handover does not read as 40
missed refreshes. Static frames, the first frame of a scroll and gaps
between scrolls are not timed. The refresh period is estimated from the
frames themselves.

scripts/frame_soak.py runs next to the service as any user, diffs two
snapshots over a run (default 10 minutes), optionally keeps the web
preview's viewer marker fresh, and exits non-zero above 0.1% late
frames. It also reports whether the loaded rgbmatrix binding releases
the GIL. Documented under "Soaking a rig" in docs/SCROLL_PERFORMANCE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:51:32 -04:00
272 changed files with 17149 additions and 13798 deletions
+1 -15
View File
@@ -3,21 +3,9 @@ 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
@@ -27,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
@@ -45,6 +33,4 @@ jobs:
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
+1 -9
View File
@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
@@ -40,11 +40,3 @@ jobs:
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 *)'
+4 -4
View File
@@ -81,10 +81,10 @@ assets/stocks/crypto_icons/
# Plugin operation state written at runtime.
#
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
# and data/operation_history.json as the web interface runs, into a directory that
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
# opened the web UI -- and every test run that constructs the app -- left three
# web_interface/app.py writes data/plugin_state.json and data/operation_history.json
# (older releases also data/plugin_operations.json) as the web interface runs, into
# a directory that ships tracked (data/.gitkeep). Unignored, every rig that ever
# opened the web UI -- and every test run that constructs the app -- would leave
# untracked files behind and a permanently dirty `git status`. Same reasoning as
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
data/*
+136
View File
@@ -19,6 +19,93 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
- Scripts and installer:
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
- `configure_web_sudo.sh` uses a random temp file and installs its rules with mode 440.
- The installer prints its completion summary before the `-y` reboot, and describes the setup access point as an open network (it was shown with a password it doesn't have).
- `fix_cache_permissions.sh` applies `setup_cache.sh`'s `ledmatrix`-group model instead of setting 777.
- `check_system_compatibility.sh` reports anything but Debian 13 (Trixie) as unsupported, and reaches its summary.
- New `scripts/README.md` lists every script.
- Docs:
- New `docs/ARCHITECTURE.md` (processes, shared state, display loop, plugin system, web UI) and `docs/PERMISSIONS.md` (owners, modes, both sudoers files, repair scripts).
- Deprecated plugin APIs are marked in the plugin docs.
- `src/common/README.md` covers every module.
- Stale setup, service and troubleshooting claims are corrected.
- Plugin store and plugin manager fixes:
- Updating a plugin that was installed from a ZIP no longer tries to reinstall it from the LEDMatrix repository's own URL.
- Repository URLs with `.git` in the middle are no longer mangled. The URL helpers now live in `src/plugin_system/repo_urls.py`.
- Installing from a URL works when the repository's only branch isn't `main` or `master`.
- A missing required config field is reported once, by name.
- A plugin that went over `max_memory_mb` once is no longer refused on every call after that.
- `reload_plugin` reads the manifest from the plugin's discovered directory.
- Removed: `last_display` from plugin state info and `get_last_display()` (nothing recorded them); `PluginOperationQueue`'s `history_file` and `lazy_load` arguments; and `data/plugin_operations.json`, which nothing read.
- Core service fixes:
- `/api/v3/errors` shows each exception's real stack trace instead of `NoneType: None`.
- Wi-Fi disconnect takes the saved connection profile down.
- `wifi_config.json` is written atomically, and a save that fails now gets a 500.
- `plugin://` fonts load from the plugin's own install directory. `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`.
- `APIHelper` keeps cached responses for the `cache_ttl` it was given, instead of always 300 s.
- Logo scales from 0.1 to 10 are honoured everywhere; values outside that range are clamped.
- `LogoHelper` and `logo_downloader`: an empty ESPN logo list counts as a failed download, and the placeholder is written at the requested path.
- Bundled font paths no longer depend on the directory the process was started from.
- Backups record `src.__version__`.
- Removed: `BackgroundDataService`'s `queue_size` stat and `clear_completed_requests()`.
- Web API fixes:
- A plugin save drops repeated entries in lists whose schema says `uniqueItems`, instead of failing validation.
- `/api/v3/health` reports the real plugin count.
- A malformed `vegas_plugin_order` or `vegas_excluded_plugins` is refused with a 400 and nothing is saved. It used to wipe the saved list.
- The per-plugin health and metrics routes return the display service's latest state.
- Resetting a plugin's config takes a backup first and reports a failed save.
- System metrics that can't be read are `null` everywhere: `cpu_temp` off a Pi, and every metric without psutil, where `/system/status` now answers 200 instead of 503.
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
- Web UI:
- Cache tab errors no longer show up in the Logs tab.
- A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
- Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
- The image schedule button works on uploaded images, and the editor stays open while you edit.
- A failed plugin toggle moves the switch back.
- Each save shows one notification; a failed Durations save says it failed.
- Stats the server can't read show `--`.
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
- Display and Vegas:
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
- The display controller stops Vegas mode on shutdown.
- Startup validation warnings are logged once, not twice.
- Vegas logs one INFO line per plugin-list refresh.
- `run.py -d` shows `display_manager` debug output.
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
INFO; 4xx at WARNING, 5xx at ERROR. `LEDMATRIX_DEBUG=true` shows them again.
`web_interface/logging_config.py` is removed. The web cache
(`web_interface/cache.py`) now honours the TTL a value was stored with and is
thread-safe.
- One plugin-directory resolver, `src/plugin_system/plugin_dirs.py`, behind
discovery, `PluginManager.get_plugin_directory`, `PluginLoader`, the store and
state reconciliation. A manifest's `id` wins over a directory merely named for
the id; hidden and `.standalone-backup-` directories are never treated as
plugins (auto-update could previously try to update a backup); ids like
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
named for its manifest id, the installer's layout, behave as before.
- `/api/v3` routes answer an exception they don't handle themselves from one
blueprint error handler, with the same `{status, message, details}` body the
53 removed per-route catch-alls returned. `ErrorCategory` and the
`error_category` key are removed from `src.web_interface.errors` (nothing read
them); `exception_error_response()` replaces the `from_exception` +
`error_response` pairs. A failing plugin action script's error now names the
real failure instead of `UnboundLocalError`.
- `FontManager.get_font()` returns a BDF font at its native size when asked for
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
return PIL's default font, a different typeface, so a plugin that relied on
@@ -33,6 +120,16 @@ accepts both, but the store flags the old spelling as deprecated
its own default (a failed lookup is retried after 30 minutes). Clearing an
app's location in the web UI now actually clears it; the save used to drop
the blank field, so the old value stayed.
- `src.common.bdf_font` — `load_bdf_face(path, size)` (a cached
`freetype.Face` plus the pixel size it really renders at, falling back to
the file's native strike) and `draw_bdf_text(draw, text, x, y, face, color)`.
`DisplayManager`, `FontManager`, `element_style` and the plugin test harness
now all load and draw BDF text through it; the panel's pixels are unchanged
and BDF text draws 10-250x faster. The plugin test harness's
`calendar_font` / `bdf_5x7_font` now has the panel's 7px size set: it used
to be an unsized face, so in golden images and `check_plugin` /
`dev_server` previews its text sat 6px above where the panel draws it (off
the canvas entirely near the top) and `get_font_height()` returned 0.
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
registered each font with `FontManager.register_manager_font()`, published
@@ -81,6 +178,23 @@ floor on the release that ships them):
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
`shared_downloader`.
- `src.common.sports_card.unshare_element_fonts` takes an optional third
argument, `element_for_font` (default: the module's `ELEMENT_FOR_FONT`, so
existing calls are unchanged).
### Sports twins
- The `SportsCoreSharedMixin` helpers that behave identically to their
`sports_card` twins (`_card_option`, `_vs_text`, `_format_game_time`,
`_coerce_rgb`, `_crisp_size`, `_unshare_element_fonts`, the colour/month/
weekday/font-grid tables) are now thin wrappers over the `sports_card`
functions, and `_format_game_date` / `_schema_font_size` share its
formatting body and schema parser. No method was removed or renamed and
nothing renders differently: `test/test_sports_twins.py` checks each pair
against the same inputs, and the old and new mixin agree on every input
there. The pairs that do differ -- favourite-result colours on nested
payloads, the weekday's timezone, the element-name map, per-mode colours --
are left as they are and pinned in that test.
### Logo downloads
@@ -120,6 +234,13 @@ floor on the release that ships them):
when the count is only known to the display service.
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
errors and a Clear button.
- Credential redaction in exception text (`src/redaction.py`) takes time
proportional to the text, not its square. Two patterns were quadratic: URL
`user:password@`, on a long unbroken run of letters or digits (a hex digest,
an ID), and `Authorization:` followed by a long run of whitespace. Either
used to stall every thread of the display service for up to seconds each
time the snapshot was published: about 0.5s for 20k characters of hex, 8s
for 20k spaces. What gets redacted is unchanged.
### Removed
@@ -136,6 +257,21 @@ floor on the release that ships them):
`api_extractors`). No known plugin imports it. A plugin that does must use
`src.common` or its own copy of the code.
- `src.common.frame_timing` -- times every frame the display presents, whoever
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
`scripts/frame_soak.py` judges a running service (late frames, freezes,
where the time goes), and `scripts/render_bench.py` judges the hardware and
render path alone on a synthetic strip. Both fail a run above 0.1% late
frames, and both call a loop that never waited for the panel NOT LOCKED. A
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
- `display.scan_order_compensation` (`"auto"` by default): while something
scrolls at one pixel per refresh, one half of each panel is shown a refresh
behind the other, which removes the 1px step a 1:N-scan panel shows across
its middle. Only for layouts whose row order is known; `"off"` disables it.
See `docs/SCROLL_PERFORMANCE.md`, "A tear across the middle on fast scrolls".
## 3.5.0
New modules a plugin may import via `src.*` (floor on 3.5.0):
+11 -7
View File
@@ -13,14 +13,17 @@
loader does NOT fall back to it — `PluginManager.discover_plugins()`
(`src/plugin_system/plugin_manager.py`) scans only the configured
directory. Fallbacks exist in two narrower places: store operations
(`StoreManager._find_plugin_path()` in `store_manager.py`) and schema
lookup (`SchemaManager.get_schema_path()` in `schema_manager.py`,
which probes `plugins/` *before* `plugin-repos/`).
(`PluginStoreManager._find_plugin_path()` in `store_manager.py`, which
searches `store_search_dirs()` from `plugin_dirs.py`) and schema lookup
(`SchemaManager.get_schema_path()` in `schema_manager.py`, which probes
`plugins/` *before* `plugin-repos/`).
- `src/plugin_system/plugin_dirs.py` — the one resolver for "which directory
holds plugin X" (manifest `id` first, then `<id>` / `ledmatrix-<id>`)
## Plugin System
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
- Required abstract methods: `update()`, `display(force_clear=False)`
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
- Each plugin needs: `manifest.json`, `config_schema.json`, and the entry point (`manager.py` by default); `requirements.txt` if it has dependencies. Required manifest fields: `docs/PLUGIN_API_REFERENCE.md#manifest-required-fields`
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
- Config schemas use JSON Schema Draft-7
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
@@ -34,19 +37,20 @@
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
- Soak a rig for frame timing (on the Pi, service running): `python3 scripts/frame_soak.py --preview` — late-frame rate across every scroller; see `docs/SCROLL_PERFORMANCE.md`
## Plugin Store Architecture
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall
- Monorepo plugins are installed via ZIP extraction (no `.git` directory)
- Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
- Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
- Third-party plugins can use their own repo URL with empty `plugin_path`
## Common Pitfalls
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
- paho-mqtt 2.x requires a `CallbackAPIVersion` argument: `VERSION1` for code written against v1 callback signatures (the MQTT bridge uses `VERSION2`)
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
+19 -10
View File
@@ -328,6 +328,7 @@ This one-shot installer will automatically:
- Install required system packages (git, python3, build tools, etc.)
- Clone or update the LEDMatrix repository
- Run the complete first-time installation script
- Print the web interface address, then **reboot the Pi automatically** (your SSH session will disconnect; give it a few minutes to come back)
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
@@ -689,9 +690,10 @@ Controls how long each installed plugin stays visible in seconds before switchin
### Display Format Settings
- **`use_short_date_format`** (boolean, default: true)
- Use short date format (e.g., "Jan 15") instead of long format (e.g., "January 15th")
- Set to `false` for longer, more readable dates
- Set to `true` to save space and show more information
- Currently has no effect. The web UI still saves it, but no core code
reads it. Scoreboard plugins that offer a short date format read the
setting from their own plugin config instead. See
[CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md#display--other-keys).
### Dynamic Duration Settings (`display.dynamic_duration`)
@@ -779,15 +781,21 @@ Controls how long each installed plugin stays visible in seconds before switchin
<details>
<summary>Manual SSH Commands (for reference)</summary>
The quick actions essentially just execute the following commands on the Pi.
The web interface's quick actions (Start/Stop/Restart Display) call
`sudo systemctl start|stop|restart ledmatrix.service` — see
`execute_system_action()` in
[`web_interface/blueprints/api_v3/system.py`](web_interface/blueprints/api_v3/system.py).
The service runs [`run.py`](run.py) as root.
From the project root directory (ex: /home/ledpi/LEDMatrix):
To run the display in the foreground instead (for debugging), stop the service
first, then from the project root (e.g. `/home/ledpi/LEDMatrix`):
```bash
sudo python3 display_controller.py
sudo systemctl stop ledmatrix.service
sudo python3 run.py # add -d for debug logging
```
This will start the display cycle but only stays active as long as your ssh session is active.
This only runs as long as your SSH session stays open.
### Convenience Scripts
@@ -957,9 +965,10 @@ sudo systemctl enable ledmatrix-web.service
3. Check if another service is using port 5000
**Service Fails to Start:**
1. Check Python dependencies are installed
2. Verify the virtual environment is set up correctly
3. Check file permissions and ownership
1. Check Python dependencies are installed. The installer puts them in the
system Python with `pip install --break-system-packages` (there is no
virtual environment), so `python3 -c "import flask"` should succeed.
2. Check file permissions and ownership
</details>
+55 -74
View File
@@ -185,63 +185,53 @@ their config section to control how oversized content is handled (see
### Plugin Integration (Developer Guide)
All of these have defaults in
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you
need.
**1. Implement Content Method:**
```python
def get_vegas_content(self):
"""
Return PIL Image or list of Images for Vegas mode.
Returns:
PIL.Image or list[PIL.Image]: Content to display
- Single image: fixed-width content
- List of images: multiple segments
- None: skip this cycle
"""
# Example: Return single wide image
img = Image.new('RGB', (256, 32))
# ... render your content ...
return img
# Example: Return multiple segments
return [image1, image2, image3]
# Return a PIL Image, a list of Images, or None.
# A single image is one block; a list becomes one item per image.
return [self._render_game(game) for game in self.games]
```
If it returns `None` (the default), Vegas falls back to the plugin's
`scroll_helper` image, then to capturing `display()` output
(`PluginAdapter.get_content()` in
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
**2. Specify Content Type:**
```python
def get_vegas_content_type(self):
"""
Specify how content should be handled.
Returns:
str: 'multi' | 'static' | 'none'
"""
return 'multi' # Default for most plugins
# 'multi' | 'static' | 'none' -- default is 'static'
return 'multi'
```
`'none'` excludes the plugin from Vegas mode.
**3. Optionally Specify Display Mode:**
```python
def get_vegas_display_mode(self):
"""
Preferred display mode for this plugin.
These return `VegasDisplayMode` members, not strings:
Returns:
str: 'scroll' | 'fixed' | 'static'
"""
return 'scroll'
```python
from src.plugin_system.base_plugin import VegasDisplayMode
def get_vegas_display_mode(self):
return VegasDisplayMode.SCROLL
def get_supported_vegas_modes(self):
"""
List of supported modes.
Returns:
list: ['scroll', 'fixed', 'static']
"""
return ['scroll', 'static']
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
```
`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and
`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the
plugin's `vegas_mode` config value if set, otherwise maps the content type
(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`).
### Content Rendering Guidelines
**Image Dimensions:**
@@ -966,11 +956,16 @@ from src.cache_manager import CacheManager
service = get_background_service(CacheManager())
stats = service.get_statistics()
print(f"Active tasks: {stats['active_tasks']}")
print(f"Completed: {stats['completed']}")
print(f"Failed: {stats['failed']}")
print(f"Active: {stats['active_requests']}")
print(f"Completed: {stats['completed_requests']}")
print(f"Failed: {stats['failed_requests']}")
```
Other keys: `total_requests`, `cached_hits`, `cache_misses`,
`average_fetch_time`, `completed_requests_count` (results currently held in
memory) — see `BackgroundDataService.get_statistics()` in
[`src/background_data_service.py`](../src/background_data_service.py).
**Enable Debug Logging:**
```python
import logging
@@ -981,6 +976,10 @@ logging.getLogger('src.background_data_service').setLevel(logging.DEBUG)
## 5. Permission Management
Ownership, modes, sudo rules and the repair scripts are listed in
[PERMISSIONS.md](PERMISSIONS.md). This section covers the helpers code uses
to keep files shareable.
### Overview
LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files.
@@ -1044,7 +1043,7 @@ ensure_file_permissions(config_path, get_config_file_mode(config_path))
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
| Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read |
| Cache files | `rw-rw-r--` | `0o664` | Owner/group write, all read |
| Cache files | `rw-rw----` | `0o660` | Owner/group write, no world access (`_CACHE_FILE_MODE` in `src/cache/disk_cache.py`) |
**Directory Permissions:**
@@ -1115,40 +1114,22 @@ These core utilities **already handle permissions** - you don't need to call per
### Manual Fixes
If you encounter permission issues:
[PERMISSIONS.md](PERMISSIONS.md) lists who owns what on an installed system,
the expected modes, and which `scripts/fix_perms/` script to run as which
user. In short:
```bash
# Targeted permission fixes (see scripts/fix_perms/README.md)
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts)
sudo ./scripts/fix_perms/fix_cache_permissions.sh # all cache directories
sudo ./scripts/fix_perms/fix_plugin_permissions.sh # plugin directories
sudo ./scripts/fix_perms/fix_web_permissions.sh # web interface files
- `fix_assets_permissions.sh`, `fix_cache_permissions.sh` and
`fix_plugin_permissions.sh` are run with `sudo`.
- `fix_web_permissions.sh` is run as the web interface user, without
`sudo` (it refuses to run as root and calls `sudo` itself where needed).
It resets project file ownership for that user, then makes the two
helper scripts the web user may run as root (`safe_plugin_rm.sh`,
`safe_pip_install.sh`) root-owned again and restores `config_secrets.json`
to its owner, the `ledmatrix` group and mode `640`. It does not write
sudoers rules; `scripts/install/configure_web_sudo.sh` does that.
# Fix specific directory
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
sudo chmod -R 2775 /home/ledpi/LEDMatrix/config
sudo find /home/ledpi/LEDMatrix/config -type f -exec chmod 664 {} \;
# Verify permissions
ls -la config/
ls -la assets/
```
### Verification
```bash
# Check directory has setgid bit
ls -ld assets/
# Should show: drwxrwsr-x (note the 's')
# Check file has correct group
ls -l assets/logo.png
# Should show group 'ledpi'
# Check file permissions
stat -c "%a %n" config/config.json
# Should show: 644 config/config.json
```
Do not `chmod` the whole `config/` directory: `config_secrets.json` must stay
`640`.
---
+17 -79
View File
@@ -13,7 +13,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
- [Using Weather Icons](#using-weather-icons)
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
- [Cache Strategy Patterns](#cache-strategy-patterns)
- [Font Management and Overrides](#font-management-and-overrides)
- [Font Management](#font-management)
- [Error Handling Best Practices](#error-handling-best-practices)
- [Performance Optimization](#performance-optimization)
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
@@ -25,69 +25,12 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
## Using Weather Icons
The Display Manager provides built-in weather icon drawing methods for easy visual representation of weather conditions.
### Basic Weather Icon Usage
```python
def display(self, force_clear=False):
if force_clear:
self.display_manager.clear()
# Draw weather icon based on condition
condition = self.data.get('condition', 'clear')
self.display_manager.draw_weather_icon(condition, x=5, y=5, size=16)
# Draw temperature next to icon
temp = self.data.get('temp', 72)
self.display_manager.draw_text(
f"{temp}°F",
x=25, y=10,
color=(255, 255, 255)
)
self.display_manager.update_display()
```
### Supported Weather Conditions
The `draw_weather_icon()` method automatically maps condition strings to appropriate icons:
- `"clear"`, `"sunny"` → Sun icon
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
- `"thunderstorm"`, `"storm"` → Storm icon
### Custom Weather Icons
For more control, use individual icon methods:
```python
# Draw specific icons
self.display_manager.draw_sun(x=10, y=10, size=16)
self.display_manager.draw_cloud(x=10, y=10, size=16, color=(150, 150, 150))
self.display_manager.draw_rain(x=10, y=10, size=16)
self.display_manager.draw_snow(x=10, y=10, size=16)
```
### Text with Weather Icons
Use `draw_text_with_icons()` to combine text and icons:
```python
icons = [
("sun", 5, 5), # Sun icon at (5, 5)
("cloud", 100, 5) # Cloud icon at (100, 5)
]
self.display_manager.draw_text_with_icons(
"Weather: Sunny, Cloudy",
icons=icons,
x=10, y=20,
color=(255, 255, 255)
)
```
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
are deprecated, removed in 3.7.0. Draw your own icons instead: render them
onto a PIL image and paste it onto `self.display_manager.image`, or ship
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
---
@@ -251,11 +194,8 @@ def update(self):
sport_key = "nhl"
cache_key = f"{self.plugin_id}_{sport_key}_games"
# Uses sport-specific live_update_interval from config
cached = self.cache_manager.get_background_cached_data(
cache_key,
sport_key=sport_key
)
# get_background_cached_data() is deprecated, removed in 3.7.0 — use get()
cached = self.cache_manager.get(cache_key, max_age=60)
if cached:
self.games = cached
@@ -282,9 +222,9 @@ def on_config_change(self, new_config):
---
## Font Management and Overrides
## Font Management
Use the Font Manager for advanced font handling and user customization.
The display manager's built-in fonts and text measurement. For fonts shipped with a plugin, see [FONT_MANAGER.md](FONT_MANAGER.md).
### Using Different Fonts
@@ -656,14 +596,12 @@ def update(self):
```python
def update(self):
# Check if another plugin is enabled
enabled_plugins = self.plugin_manager.get_enabled_plugins()
if "weather" in enabled_plugins:
# Weather plugin is available
weather_plugin = self.plugin_manager.get_plugin("weather")
if weather_plugin:
# Use weather data
pass
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check the
# instance's `enabled` flag instead
weather_plugin = self.plugin_manager.get_plugin("weather")
if weather_plugin is not None and weather_plugin.enabled:
# Use weather data
pass
```
### Sharing Data Between Plugins
+215
View File
@@ -0,0 +1,215 @@
# Architecture
A map of the codebase for a new contributor: which process does what, how
they talk to each other, and where to start reading for common changes.
## Processes
| systemd unit | Runs as | Runs | Installed by |
|---|---|---|---|
| `ledmatrix.service` | root | [`run.py`](../run.py) → `DisplayController` | [`install_service.sh`](../scripts/install/install_service.sh) |
| `ledmatrix-web.service` | the installing user | [`start_web_conditionally.py`](../scripts/utils/start_web_conditionally.py) → [`web_interface/start.py`](../web_interface/start.py) (Flask, port 5000) | `install_service.sh`, [`install_web_service.sh`](../scripts/install/install_web_service.sh) |
| `ledmatrix-update-verify.path` / `.service` | the web user | Health check after an automatic update | the same installers, or [`src/auto_update_setup.py`](../src/auto_update_setup.py) at runtime |
| `ledmatrix-wifi-monitor.service` | root | [`wifi_monitor_daemon.py`](../scripts/utils/wifi_monitor_daemon.py) | [`install_wifi_monitor.sh`](../scripts/install/install_wifi_monitor.sh) |
| `ledmatrix-mqtt-bridge.service` | root | [MQTT bridge](../integrations/mqtt_bridge/README.md) (optional) | [`install_mqtt_bridge.sh`](../scripts/install/install_mqtt_bridge.sh) |
| `ledmatrix-dns-fix.service` | root | DNS workaround (optional) | [`install_dns_fix.sh`](../scripts/install/install_dns_fix.sh) |
Unit templates are in [`systemd/`](../systemd/README.md). The display runs as
root because the LED matrix library needs direct GPIO access. The web
interface runs unprivileged and uses a fixed list of `sudo` rules for the
few privileged things it does; see [PERMISSIONS.md](PERMISSIONS.md).
`start_web_conditionally.py` exits without starting Flask when
`web_display_autostart` is explicitly false in `config.json`.
## How the two main processes share state
The display and the web interface are separate processes that never call
each other. They share three things:
1. **`config/config.json` and `config/config_secrets.json`.** The web
interface writes them through `ConfigManager`
([`src/config_manager.py`](../src/config_manager.py)); the display
notices through `ConfigService` (below).
2. **The disk cache**, `/var/cache/ledmatrix` (owned `root:ledmatrix`,
setgid, files `0660`), read and written through `CacheManager`
([`src/cache_manager.py`](../src/cache_manager.py),
[`src/cache/disk_cache.py`](../src/cache/disk_cache.py)). Readers in the
other process pass `memory_ttl=0` so they do not serve a stale in-memory
copy.
3. **A few files in `/tmp`.**
| State | Where | Written by | Read by |
|---|---|---|---|
| On-demand request | cache `display_on_demand_request` | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py) | display: `_poll_on_demand_requests()` |
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
| Error clear | cache `plugin_error_clear_request` | web | display |
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
The on-demand start route also restarts `ledmatrix.service` by default so the
request takes effect straight away.
## Display loop
[`src/display_controller.py`](../src/display_controller.py), class
`DisplayController`. `__init__` loads config, starts the cache and the
error-snapshot publisher, runs the startup validator, creates the
`DisplayManager` ([`src/display_manager.py`](../src/display_manager.py)),
`FontManager` and `PluginManager`, loads the enabled plugins in parallel,
runs an initial `update()` pass within a 20-second budget
(`_INITIAL_UPDATE_BUDGET_SECONDS`; a plugin that misses it is deferred to
the scheduler), and sets up Vegas mode.
`run()` is the main loop. Each pass, in order: apply a pending plugin
enable/disable, poll on-demand requests, run scheduled plugin updates, check
the on/off schedule and brightness, then show one screen. Priority is
on-demand, then WiFi status messages, then live priority, then Vegas mode,
then normal rotation.
- **Rotation.** `available_modes` is the ordered list of display modes;
`current_mode_index` advances after each screen.
`_apply_plugin_rotation_order()` applies `display.plugin_rotation_order`.
- **Durations.** `_get_display_duration()`: `display.display_durations[mode]`,
else the plugin's `get_display_duration()`, else 30 s. Plugins that
support dynamic duration run until `is_cycle_complete()`, capped by
`display.dynamic_duration.max_duration_seconds` (default 180 s).
- **On-demand.** A request from the web interface pins one plugin (or mode)
for a duration. `_activate_on_demand()` / `_clear_on_demand()`; the
session is saved under `display_on_demand_config` so it survives a
restart. It also keeps the display on during scheduled off hours.
- **Live priority.** `_check_live_priority()` looks for a plugin whose
`has_live_priority()` and `has_live_content()` are both true and switches
to it, rotating between several live games.
- **Schedule and dim schedule.** `_check_schedule()` reads `schedule`;
`_check_dim_schedule()` reads `dim_schedule` and
`display.hardware.brightness`. Both are re-evaluated once a minute.
- **Long screens.** While a screen is showing (a dwell, a scroll, a Vegas
iteration), `_service_pending_changes()` repeats the on-demand, schedule
and brightness checks every 0.25 s, so a change does not wait for the
screen to end.
- **Config hot reload.** `ConfigService`
([`src/config_service.py`](../src/config_service.py)) polls the config and
secrets files' mtimes every 2 s and notifies subscribers when the content
changes. The controller refreshes its cached settings; enabling or
disabling a plugin queues `_reconcile_enabled_plugins()`, which loads or
unloads it on the display thread; each plugin gets `on_config_change()`
for its own section. Set `LEDMATRIX_HOT_RELOAD=false` to turn this off.
Matrix hardware settings are only read at start-up.
- **Vegas mode.** [`src/vegas_mode/`](../src/vegas_mode/): the display loop
calls `VegasModeCoordinator.run_iteration()`
([`coordinator.py`](../src/vegas_mode/coordinator.py)) when
`display.vegas_scroll.enabled` is set. `PluginAdapter` gets each plugin's
content (`get_vegas_content()`, else its `scroll_helper` image, else a
capture of `display()`), `StreamManager` orders it and `RenderPipeline`
scrolls it. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md).
- **Multi-display sync.** `DisplaySyncManager`
([`src/common/sync_manager.py`](../src/common/sync_manager.py)), enabled by
`sync.role`: a leader sends a follower its share of each frame over UDP
(port 5765).
## Plugin system
[`src/plugin_system/`](../src/plugin_system/):
| Area | Where |
|---|---|
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
| Discovery, load, unload, scheduled updates | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
| Resource metrics | [`resource_monitor.py`](../src/plugin_system/resource_monitor.py) |
| Config schemas and defaults | [`schema_manager.py`](../src/plugin_system/schema_manager.py) |
| Install, update, uninstall | [`store_manager.py`](../src/plugin_system/store_manager.py) (`PluginStoreManager`) |
| Core-version gate | [`compatibility.py`](../src/plugin_system/compatibility.py) |
Discovery scans only `plugin_system.plugins_directory` (default
`plugin-repos/`). Scheduled `update()` calls run on one background worker
thread; a per-plugin lock keeps `display()` from running during an update.
**Store flow.** `install_plugin()` renames any existing copy aside
(`<id>.standalone-backup-preinstall`), installs the new one, and puts the old
copy back if the install fails. Monorepo plugins come from the GitHub Trees
API, falling back to the repository ZIP; other plugins by `git clone` or
download. The manifest is checked (see
[required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)), the core
version gate runs, then dependencies are installed as root through
`scripts/fix_perms/safe_pip_install.sh`. `update_plugin()` pulls git
installs, undoing a pull whose new version is incompatible, and reinstalls
everything else through `_reinstall_with_rollback()`.
## Web interface
- **App.** [`web_interface/app.py`](../web_interface/app.py) builds the
Flask `app` at import time, creates the managers, and registers two
blueprints. `web_interface/start.py` runs it on port 5000.
- **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py)
serves the shell `templates/v3/base.html` at `/` and each tab as a
partial at `/partials/<name>` (templates in
`web_interface/templates/v3/partials/`). Plugin configuration tabs are
rendered from the plugin's schema by `plugin_config.html`.
- **API.** [`blueprints/api_v3/`](../web_interface/blueprints/api_v3/) is one
blueprint at `/api/v3`, split by area: `backup.py`, `config.py`,
`display.py`, `fonts.py`, `misc.py` (health, logs, errors, cache, sync),
`plugins.py`, `starlark.py`, `system.py` (service actions, updates, git),
`wifi.py`. `__init__.py` defines the blueprint and shared helpers and
imports the modules so their routes register. Endpoints are listed in
[REST_API_REFERENCE.md](REST_API_REFERENCE.md).
- **Front end.** HTMX loads each tab's partial on first open
(`hx-trigger="loadtab"`); Alpine.js holds page state. Scripts are in
`web_interface/static/v3/js/`; form widgets are bundled from
[`js/widgets/`](../web_interface/static/v3/js/widgets/README.md).
- **Server-sent events** (`app.py`): `/api/v3/stream/stats` (CPU, memory,
temperature, service state, every 10 s), `/api/v3/stream/display` (preview
frames when the PNG changes) and `/api/v3/stream/logs` (journal of both
services). One generator thread per stream is shared by all clients.
## Updates
- **Update Code** on the Overview tab and the automatic updater both call
`perform_core_update()` in
[`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py):
`git pull --rebase`, reinstall changed requirement files, report whether a
restart is needed.
- **Automatic updates** (`auto_update.enabled`, off by default):
`AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py)
runs in the web process, checks every 30 minutes, and updates at most
weekly between 02:00 and 05:00. Before pulling it copies
[`scripts/utils/auto_update_verify.py`](../scripts/utils/auto_update_verify.py)
to `data/auto_update_verifier.py`, then writes
`data/auto_update_verify.request`. That file triggers
`ledmatrix-update-verify.path`, which runs the verifier as a separate unit
(so restarting the web service does not kill it). The verifier restarts
both services, waits for the web API to answer and the display service to
stay up, and on failure resets to the previous commit and restarts again.
Plugin updates run only after a verified core update. State is in
`data/auto_update_state.json` and `data/auto_update_pending.json`.
- **Startup validator.** `StartupValidator`
([`src/startup_validator.py`](../src/startup_validator.py)) runs twice in
`DisplayController.__init__`: config and cache directory first, then
enabled plugins once the plugin manager exists. It also warns when an
installed systemd unit differs from its template in `systemd/`. Results
are logged; startup continues either way.
## Where to start reading
| Task | Start with |
|---|---|
| Change rotation, durations or priorities | `DisplayController.run()` and `_get_display_duration()` in [`display_controller.py`](../src/display_controller.py) |
| Add a config key | [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md), [`config/config.template.json`](../config/config.template.json), the tab's partial and `api_v3/config.py` |
| Change drawing or fonts | [`display_manager.py`](../src/display_manager.py), [`font_manager.py`](../src/font_manager.py), [`src/common/bdf_font.py`](../src/common/bdf_font.py) |
| Add a plugin-facing API | [`base_plugin.py`](../src/plugin_system/base_plugin.py) or [`src/common/`](../src/common/README.md); document it in [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) |
| Plugin install/update bugs | `PluginStoreManager` in [`store_manager.py`](../src/plugin_system/store_manager.py) |
| A plugin that won't load | `PluginManager.load_plugin()` and `PluginLoader.load_plugin()`; `python3 scripts/check_plugin.py --plugin <id>` |
| Add an API endpoint | the matching module in [`api_v3/`](../web_interface/blueprints/api_v3/) |
| Add a web UI tab or control | `templates/v3/base.html`, the tab's partial, `pages_v3.py` |
| Vegas scroll | [`src/vegas_mode/coordinator.py`](../src/vegas_mode/coordinator.py) |
| Installer or permissions | [`first_time_install.sh`](../first_time_install.sh), [`scripts/install/`](../scripts/install/), [PERMISSIONS.md](PERMISSIONS.md) |
| Work without a Pi | [DEV_PREVIEW.md](DEV_PREVIEW.md), [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md), [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) |
+11 -5
View File
@@ -172,10 +172,14 @@ ERROR - Plugin football-scoreboard configuration validation failed: 'api_key' is
### Enable Debug Logging
Set environment variable:
Run the display in the foreground with `-d`, or set `LEDMATRIX_DEBUG=true`
(the value must be `true`; `1` is ignored — see `setup_logging()` in
[`src/logging_config.py`](../src/logging_config.py)):
```bash
export LEDMATRIX_DEBUG=1
python run.py
sudo systemctl stop ledmatrix.service
sudo python3 run.py -d
# or
sudo LEDMATRIX_DEBUG=true python3 run.py
```
### Check Merged Configuration
@@ -321,8 +325,10 @@ cp config/backups/config.json.backup.20240115_120000_000000 config/config.json
## Getting Help
1. Check logs: `tail -f logs/ledmatrix.log`
2. Enable debug: `LEDMATRIX_DEBUG=1`
1. Check logs. Both services log to journald, not to a file:
`sudo journalctl -u ledmatrix.service -f` (display) and
`sudo journalctl -u ledmatrix-web.service -f` (web interface)
2. Enable debug: `LEDMATRIX_DEBUG=true` or `python3 run.py -d`
3. Check error dashboard: `/api/v3/errors/summary`
4. Validate JSON: https://jsonlint.com/
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
+2 -1
View File
@@ -128,7 +128,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `min_content_separation` | int, `24` |
| `min_cut_gap` | int, `6` |
| `continuous_scroll` | bool, `true` |
| `smooth_scroll` | bool, `true` |
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
| `extend_threshold_screens` | float, `2.0` |
| `auto_trim` | bool, `true` |
| `trim_threshold` | int, `10` |
+9 -6
View File
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
# Weather icons
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
# Weather icons: draw_weather_icon() is deprecated, removed in 3.7.0 —
# draw your own icons (the weather plugin ships WeatherIcons)
# Scrolling state
display_manager.set_scrolling_state(True)
@@ -72,20 +72,23 @@ cache_manager.delete("key") # alias for clear_cache(key)
# Advanced caching
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
data = cache_manager.get_background_cached_data("key", sport_key="nhl")
# Strategy
strategy = cache_manager.get_cache_strategy("weather")
interval = cache_manager.get_sport_live_interval("nhl")
```
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
are deprecated, removed in 3.7.0. See
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
## Plugin Manager Quick Methods
```python
# Get plugins
plugin = plugin_manager.get_plugin("plugin-id")
all_plugins = plugin_manager.get_all_plugins()
enabled = plugin_manager.get_enabled_plugins()
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check `enabled`
# on the entries in plugin_manager.plugins
# Get info
info = plugin_manager.get_plugin_info("plugin-id")
@@ -168,7 +171,7 @@ def display(self, force_clear=False):
- [ ] Plugin inherits from `BasePlugin`
- [ ] Implements `update()` and `display()` methods
- [ ] `manifest.json` with required fields
- [ ] `manifest.json` with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
- [ ] `config_schema.json` for web UI (recommended)
- [ ] `README.md` with documentation
- [ ] Error handling implemented
+123 -326
View File
@@ -9,12 +9,14 @@
## Overview
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
- Manager font registration and detection
- Plugin font management
- Programmatic per-element font overrides
- Performance monitoring and caching
- Dynamic font discovery
[`src/font_manager.py`](../src/font_manager.py) loads and caches the TTF and
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
which plugin uses which font so the web UI can show it.
Several methods are deprecated and will be removed in LEDMatrix 3.7.0; they
log a warning on first call. They are listed in
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
[`test/test_deprecation.py`](../test/test_deprecation.py).
## Getting the FontManager
@@ -34,157 +36,60 @@ standalone FontManager when none is available (test harnesses, mocks).
`DisplayManager` has **no** `font_manager` attribute —
`display_manager.font_manager` raises `AttributeError`.
## Architecture
### Manager-Centric Design
Managers define their own fonts, but the FontManager:
1. **Loads and caches fonts** for performance
2. **Detects font usage** for visibility
3. **Allows manual overrides** when needed
4. **Supports plugin fonts** with namespacing
### Font Resolution Flow
```
Manager requests font → Check manual overrides → Apply manager choice → Cache & return
```
## For Manager Developers
### Basic Font Usage
## Resolving a font
```python
from src.font_manager import FontManager
element_key = f"{self.plugin_id}.title"
class MyManager:
def __init__(self, config, display_manager, cache_manager, plugin_manager):
self.display_manager = display_manager
self.font_manager = plugin_manager.font_manager # Shared FontManager
self.manager_id = "my_manager"
def display(self):
# Define your font choices
element_key = "my_manager.title"
font_family = "press_start"
font_size_px = 10
color = (255, 255, 255) # RGB white
# Register your font choice (for detection and future overrides)
self.font_manager.register_manager_font(
manager_id=self.manager_id,
element_key=element_key,
family=font_family,
size_px=font_size_px,
color=color
)
# Get the font (checks for manual overrides automatically)
font = self.font_manager.resolve_font(
element_key=element_key,
family=font_family,
size_px=font_size_px
)
# Use the font for rendering
self.display_manager.draw_text(
"Hello World",
x=10, y=10,
color=color,
font=font
)
```
### Advanced Font Usage
```python
class AdvancedManager:
def __init__(self, config, display_manager, cache_manager, plugin_manager):
self.display_manager = display_manager
self.font_manager = plugin_manager.font_manager
self.manager_id = "advanced_manager"
# Define your font specifications
self.font_specs = {
"title": {"family": "press_start", "size_px": 12, "color": (255, 255, 0)},
"body": {"family": "four_by_six", "size_px": 8, "color": (255, 255, 255)},
"footer": {"family": "five_by_seven", "size_px": 7, "color": (128, 128, 128)}
}
# Register all font specs
for element_type, spec in self.font_specs.items():
element_key = f"{self.manager_id}.{element_type}"
self.font_manager.register_manager_font(
manager_id=self.manager_id,
element_key=element_key,
family=spec["family"],
size_px=spec["size_px"],
color=spec["color"]
)
def get_font(self, element_type: str):
"""Helper method to get fonts with override support."""
spec = self.font_specs[element_type]
element_key = f"{self.manager_id}.{element_type}"
return self.font_manager.resolve_font(
element_key=element_key,
family=spec["family"],
size_px=spec["size_px"]
)
def display(self):
# Get fonts (automatically checks for overrides)
title_font = self.get_font("title")
body_font = self.get_font("body")
footer_font = self.get_font("footer")
# Render with fonts
self.display_manager.draw_text("Title", font=title_font, color=self.font_specs["title"]["color"])
self.display_manager.draw_text("Body Text", font=body_font, color=self.font_specs["body"]["color"])
self.display_manager.draw_text("Footer", font=footer_font, color=self.font_specs["footer"]["color"])
```
### Using Size Tokens
```python
# Get available size tokens
tokens = self.font_manager.get_size_tokens()
# Returns: {'xs': 6, 'sm': 8, 'md': 10, 'lg': 12, 'xl': 14, 'xxl': 16}
# Use token to get size
size_px = tokens.get('md', 10) # 10px
# Then use in font resolution
font = self.font_manager.resolve_font(
element_key="my_manager.text",
# Register the choice so the web UI's Fonts tab can list it.
self.font_manager.register_manager_font(
manager_id=self.plugin_id,
element_key=element_key,
family="press_start",
size_px=size_px
size_px=10,
color=(255, 255, 255),
)
font = self.font_manager.resolve_font(
element_key=element_key,
family="press_start",
size_px=10,
)
self.display_manager.draw_text("Hello", x=10, y=10, font=font)
```
## For Plugin Developers
`resolve_font()` applies any entry for `element_key` in
`config/font_overrides.json`, maps a plugin-local family to its namespaced
name when `plugin_id` is passed, and then calls `get_font(family, size_px)`.
On error it returns a fallback font rather than raising.
> **Note**: plugins that ship their own fonts via a `"fonts"` block
> in `manifest.json` are registered automatically during plugin load
> (`src/plugin_system/plugin_manager.py` calls
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
> URIs documented below are resolved relative to the plugin's
> install directory.
>
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
> font files in `assets/fonts/`. Its **Used by** column shows which
> loaded plugins registered each file through `register_manager_font()`
> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it
> warns before deleting one of them. It has no override editor (the
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
> The programmatic override workflow in
> [Manual Font Overrides](#manual-font-overrides) below still works.
> Let users pick fonts through your plugin's own config schema.
`get_font(family, size_px)` looks the family up in `font_catalog` and loads
it (cached per family and size).
### Plugin Font Registration
## Font families
In your plugin's `manifest.json`:
At start-up the FontManager scans `assets/fonts/` for `.ttf` and `.bdf`
files. Each becomes a family named after the file, lower-cased and without
the extension (`PressStart2P-Regular.ttf` → `pressstart2p-regular`). Four
aliases are added on top:
| Alias | File |
|---|---|
| `press_start` | `assets/fonts/PressStart2P-Regular.ttf` |
| `four_by_six` | `assets/fonts/4x6-font.ttf` |
| `five_by_seven` | `assets/fonts/5x7.bdf` |
| `tom_thumb` | `assets/fonts/tom-thumb.bdf` |
Read the catalog directly: `font_manager.font_catalog` is a dict of family
name to file path. Files added later are picked up on the next start of the
display service.
## Plugin fonts
Plugins that ship their own fonts declare them in a `"fonts"` block in
`manifest.json`. The plugin manager calls
`FontManager.register_plugin_fonts()` during plugin load. `plugin://…`
sources are resolved relative to the plugin's install directory.
```json
{
@@ -195,231 +100,123 @@ In your plugin's `manifest.json`:
{
"family": "custom_font",
"source": "plugin://fonts/custom.ttf",
"metadata": {
"description": "Custom plugin font",
"license": "MIT"
}
"metadata": {"description": "Custom plugin font", "license": "MIT"}
},
{
"family": "web_font",
"source": "https://example.com/fonts/font.ttf",
"metadata": {
"description": "Downloaded font",
"checksum": "sha256:abc123..."
}
"metadata": {"checksum": "sha256:abc123..."}
}
]
}
}
```
### Using Plugin Fonts
Registered families are namespaced as `<plugin_id>::<family>`. Pass
`plugin_id` to `resolve_font()` to use the short name:
```python
class MyPlugin(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.font_manager = self._get_font_manager()
def display(self):
# Use plugin font (automatically namespaced)
font = self.font_manager.resolve_font(
element_key=f"{self.plugin_id}.text",
family="custom_font", # Will be resolved as "my-plugin::custom_font"
size_px=10,
plugin_id=self.plugin_id
)
self.display_manager.draw_text("Plugin Text", font=font)
```
## Manual Font Overrides
Overrides are set in code (there is no web UI or REST endpoint for them).
They are stored in `config/font_overrides.json` and persist across restarts.
### Programmatic Overrides
```python
# Set override
font_manager.set_override(
element_key="nfl.live.score",
family="four_by_six",
size_px=8
font = self.font_manager.resolve_font(
element_key=f"{self.plugin_id}.text",
family="custom_font", # resolved as "my-plugin::custom_font"
size_px=10,
plugin_id=self.plugin_id,
)
# Remove override
font_manager.remove_override("nfl.live.score")
# Get all overrides
overrides = font_manager.get_overrides()
```
## Font Discovery
## Overrides
### Available Fonts
The FontManager automatically scans `assets/fonts/` for TTF and BDF fonts:
```python
# Get all available fonts
fonts = font_manager.get_available_fonts()
# Returns: {'press_start': 'assets/fonts/PressStart2P-Regular.ttf', ...}
# Check if font exists
if "my_font" in fonts:
font = font_manager.get_font("my_font", 10)
```
### Adding Custom Fonts
Place font files in `assets/fonts/` directory:
- Supported formats: `.ttf`, `.bdf`
- Font family name is derived from filename (without extension)
- Will be automatically discovered on next initialization
`resolve_font()` still honours `config/font_overrides.json` (a map of
element key to `family` and/or `size_px`), which is read once at start-up.
The methods that edit it — `set_override()`, `remove_override()`,
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
for overrides (the override editor and `/api/v3/fonts/overrides` were
removed). To let users choose a font, add a field to your plugin's config
schema.
## Font usage in the web UI
The web interface runs in its own process and has no FontManager, so the
display service publishes which plugin uses which font
(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it:
The web UI's **Fonts** tab lists, uploads, previews and deletes the font
files in `assets/fonts/`. The web interface runs in its own process and has
no FontManager, so the display service publishes which plugin uses which
font ([`src/font_usage.py`](../src/font_usage.py)), and the tab's **Used by**
column reads it:
- **Source**: `register_manager_font()` registrations of the loaded
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
and are not counted, and neither is a plugin that opens a font file
directly with PIL — register the fonts your plugin draws with if you want
them listed.
- **Names**: a family, alias (`press_start`, `four_by_six`,
`five_by_seven`, `tom_thumb`) or path is resolved through
`font_catalog` to the file it loads and reported under that file's name
without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`,
`tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside
`assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families
that resolve to nothing are left out.
- **Names**: a family, alias or path is resolved through `font_catalog` to
the file it loads and reported under that file's name without extension
(`PressStart2P-Regular`, `4x6-font`, `5x7`, `tom-thumb`), which is how the
Fonts tab keys its rows. Fonts outside `assets/fonts/` (a plugin's own
`plugin_id::family` fonts) and families that resolve to nothing are left
out.
- **When**: a daemon thread started once plugins have loaded checks every
10 seconds and writes the `font_usage_snapshot` cache key only when the
usage changed (and once a day, so the cache's cleanup never expires it).
Unloading a plugin drops its registrations (`forget_manager_fonts`).
- **Unknown**: until the display service has published, the column reads
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
- The tab warns before deleting a font that a loaded plugin registered.
## Performance Monitoring
## Text measurement
```python
# Get performance stats
stats = font_manager.get_performance_stats()
print(f"Cache hit rate: {stats['cache_hit_rate']*100:.1f}%")
print(f"Total fonts cached: {stats['total_fonts_cached']}")
print(f"Failed loads: {stats['failed_loads']}")
print(f"Manager fonts: {stats['manager_fonts']}")
print(f"Plugin fonts: {stats['plugin_fonts']}")
```
## Text Measurement
```python
# Measure text dimensions
width, height, baseline = font_manager.measure_text("Hello", font)
# Get font height
font_height = font_manager.get_font_height(font)
```
## Best Practices
## Tips
### For Managers
1. **Register all fonts** you use for visibility
2. **Use consistent element keys** (e.g., `{manager_id}.{element_type}`)
3. **Cache font references** if using same font multiple times
4. **Use `resolve_font()`** not `get_font()` directly to support overrides
5. **Define sensible defaults** that work well on LED matrix
### For Plugins
1. **Use plugin-relative paths** (`plugin://fonts/...`)
2. **Include font metadata** (license, description)
3. **Provide fallback** fonts if custom fonts fail to load
4. **Test with different display sizes**
### General
1. **BDF fonts** are often better for small sizes on LED matrices
2. **TTF fonts** work well for larger sizes
3. **Monospace fonts** are easier to align
4. **Test on actual hardware** - what looks good on screen may not work on LED matrix
## Migration from Old System
### Old Way (Direct Font Loading)
```python
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
```
### New Way (FontManager)
```python
element_key = f"{self.manager_id}.text"
self.font_manager.register_manager_font(
manager_id=self.manager_id,
element_key=element_key,
family="pressstart2p-regular",
size_px=8
)
self.font = self.font_manager.resolve_font(
element_key=element_key,
family="pressstart2p-regular",
size_px=8
)
```
- BDF fonts usually look better than TTF at small sizes on LED panels.
- Use `{plugin_id}.{element}` element keys.
- Register the fonts you draw with, so the Fonts tab can warn before one is
deleted.
- Replace direct `ImageFont.truetype("assets/fonts/...", 8)` calls with
`resolve_font()`: it caches, resolves paths against the install directory,
and handles BDF files.
## Troubleshooting
### Font Not Found
- Check font file exists in `assets/fonts/`
- Verify font family name matches filename (without extension, lowercase)
- Check logs for font discovery errors
**Font not found**
- Check the file exists in `assets/fonts/`.
- The family name is the filename without extension, lower-cased.
- Check the display service log for font discovery errors.
### Override Not Working
- Verify element key matches exactly what manager registered
- Check `config/font_overrides.json` for correct syntax
- Restart application to ensure overrides are loaded
**Plugin fonts not loading**
- Check the manifest's `"fonts"` block.
- Check the log for download or registration errors, and that font URLs are
reachable.
### Performance Issues
- Check cache hit rate in performance stats
- Reduce number of unique font/size combinations
- Clear cache if it grows too large: `font_manager.clear_cache()`
## API reference
### Plugin Fonts Not Loading
- Verify plugin manifest syntax
- Check plugin directory structure
- Review logs for download/registration errors
- Ensure font URLs are accessible
Current methods:
## API Reference
| Method | Purpose |
|---|---|
| `register_manager_font(manager_id, element_key, family, size_px, color=None)` | Record a font choice (feeds the Fonts tab) |
| `forget_manager_fonts(manager_id)` | Drop a manager's registrations (core calls it when a plugin unloads) |
| `resolve_font(element_key, family, size_px, plugin_id=None)` | Get a font, applying overrides and plugin namespacing |
| `get_font(family, size_px)` | Get a font directly |
| `get_native_bdf_size(family)` | Native pixel size of a BDF family, or `None` |
| `measure_text(text, font)` | `(width, height, baseline)` |
| `get_font_height(font)` | Line height |
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
| `clear_cache()` | Drop cached fonts and metrics |
| `font_catalog` (attribute) | Family name → file path |
### FontManager Methods
### Deprecated methods
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage
- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads)
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
- `measure_text(text, font)` - Measure text dimensions
- `get_font_height(font)` - Get font height
- `set_override(element_key, family=None, size_px=None)` - Set manual override
- `remove_override(element_key)` - Remove override
- `get_overrides()` - Get all overrides
- `get_detected_fonts()` - Get all detected font usage
- `get_manager_fonts(manager_id=None)` - Get fonts by manager
- `get_available_fonts()` - Get font catalog
- `get_size_tokens()` - Get size token definitions
- `get_performance_stats()` - Get performance metrics
- `clear_cache()` - Clear font cache
- `register_plugin_fonts(plugin_id, font_manifest)` - Register plugin fonts
- `unregister_plugin_fonts(plugin_id)` - Unregister plugin fonts
## Example: Complete Manager Implementation
For a working example of the font manager API in use, see
`src/font_manager.py` itself.
Removed in 3.7.0. Each logs a warning on first call.
| Method | Use instead |
|---|---|
| `get_available_fonts()`, `get_font_catalog()` | read `font_catalog` |
| `get_size_tokens()` | pass a pixel size |
| `get_performance_stats()` | — |
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
| `get_manager_fonts()`, `get_detected_fonts()` | — |
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
+11 -10
View File
@@ -116,8 +116,8 @@ weather and other location-aware plugins.
4. Wait for installation to finish — installed plugins appear in the
**Installed Plugins** section above and get their own tab in the second
nav row
5. Toggle the plugin to enabled
6. From **Overview**, click **Restart Display Service**
5. Toggle the plugin to enabled. The running display loads it within a
few seconds; no restart is needed
You can also install community plugins straight from a GitHub URL using the
**Install from GitHub** section further down the same tab — see
@@ -128,9 +128,9 @@ You can also install community plugins straight from a GitHub URL using the
1. Each installed plugin gets its own tab in the second navigation row
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
update intervals, etc.)
3. Click **Save**
4. Restart the display service from **Overview** so the new settings take
effect
3. Click **Save**. The display service watches `config.json` and hands the
new settings to the running plugin, so no restart is needed. If a plugin
still shows old settings, restart the display service from **Overview**
**Note:** how long each plugin stays on screen is not set in the
plugin's own tab — use the **Rotation** tab's **Screen Durations**
@@ -197,14 +197,15 @@ The fastest way to verify a plugin works without waiting for the rotation:
**Check:**
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
2. Display service was restarted after enabling
3. Plugin's display duration is non-zero
4. No errors in the **Logs** tab for that plugin
2. Plugin's display duration is non-zero
3. No errors in the **Logs** tab for that plugin. A plugin whose
`validate_config()` fails is not loaded until its settings are fixed
**Fix:**
1. Enable the plugin from **Plugin Manager**
2. Click **Restart Display Service** on **Overview**
3. Check the **Logs** tab for plugin-specific errors
2. Check the **Logs** tab for plugin-specific errors
3. If it still does not appear, click **Restart Display Service** on
**Overview**
### Weather Plugin Shows "No Data"
+5 -5
View File
@@ -52,10 +52,10 @@ pytest test/test_display_controller.py test/test_plugin_system.py
```bash
# Run a specific test class
pytest test/test_display_controller.py::TestDisplayControllerModeRotation
pytest test/test_display_controller.py::TestDisplayControllerLivePriority
# Run a specific test function
pytest test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
pytest test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
```
### Run Tests by Marker
@@ -98,7 +98,7 @@ When you run `pytest`, you'll see:
```
test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED
test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation PASSED
test/test_display_controller.py::TestDisplayControllerOnDemand::test_activate_on_demand PASSED
...
```
@@ -174,10 +174,10 @@ pytest
```bash
# Run with maximum verbosity and show print statements
pytest -vv -s test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
pytest -vv -s test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
# Run with Python debugger (pdb)
pytest --pdb test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
pytest --pdb test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
```
### Run Tests in Parallel (Faster)
+154
View File
@@ -0,0 +1,154 @@
# Permissions
Who owns what on an installed system, which privileged commands the web
interface may run, and how to repair ownership when it goes wrong. The
installer, [`first_time_install.sh`](../first_time_install.sh), sets all of
this up; this page describes the result.
## Users and groups
| Account | Used by | Why |
|---|---|---|
| `root` | `ledmatrix.service` (the display) | The LED matrix library needs direct GPIO access |
| The installing user (e.g. `ledpi`) | `ledmatrix-web.service`, `ledmatrix-update-verify.service` | A web server should not run as root |
| `ledmatrix` group | shared files | Members: the installing user, `root`, and `daemon` if it exists. Created by [`setup_cache.sh`](../scripts/install/setup_cache.sh) and the installer |
The installer also adds the web user to `systemd-journal` and `adm` so the
**Logs** tab can read the journal. Group changes apply after the user logs
in again (services pick them up on restart).
## Files and directories
| Path | Owner | Mode | Notes |
|---|---|---|---|
| Project directory | web user | dirs `755`, files `644`, `*.sh` `755` | Set in the installer's "Normalize project file permissions" step |
| `config/` | web user | `2775` | |
| `config/config.json` | web user | `644` | Written by the web interface |
| `config/config_secrets.json` | web user : `ledmatrix` | `640` | Owned by the web user because the web interface writes it; root reads it regardless of mode |
| `plugin-repos/`, `plugins/` | web user | dirs `2775`, files `664` | The web interface installs and removes plugins |
| `assets/` | web user | dirs `755`, files `644` | Root writes downloaded logos regardless |
| `/var/cache/ledmatrix/` | `root:ledmatrix` | `2775` (setgid) | Shared cache: see below |
| Cache files | creator : `ledmatrix` | `660` | |
| `scripts/fix_perms/safe_plugin_rm.sh`, `safe_pip_install.sh` | `root:root` | `755` | Run as root through sudo, so the web user must not be able to edit them |
| `/etc/sudoers.d/ledmatrix_web`, `ledmatrix_wifi` | `root` | `440` | |
What keeps it that way at runtime:
- **Config files.** Saves go through
[`src/config_manager_atomic.py`](../src/config_manager_atomic.py), which
applies `get_config_file_mode()` (`640` for secrets, `644` otherwise) and,
when running as root, moves the file's group to the project directory's
group (`ensure_shared_group_ownership()` in
[`src/common/permission_utils.py`](../src/common/permission_utils.py)).
- **Cache files.** [`src/cache/disk_cache.py`](../src/cache/disk_cache.py)
sets every file it writes to `0660` and gives it the cache directory's
group, without relying on the setgid bit. So a file root writes stays
readable by the web user.
- **Plugin directories.** [`run.py`](../run.py) sets
`sys.dont_write_bytecode`, because root-owned `__pycache__` directories
inside a plugin stop the web user updating or removing it.
`ledmatrix-web.service` deliberately has no `CacheDirectory=`: systemd would
re-own `/var/cache/ledmatrix` to the web user and its primary group, and the
web interface could no longer read what the display writes (see the comment
in [`systemd/ledmatrix-web.service`](../systemd/ledmatrix-web.service)).
If `/var/cache/ledmatrix` is not usable, `CacheManager` falls back to
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache` or a temp directory
([`src/cache_manager.py`](../src/cache_manager.py)). The two services then
may not share a cache, and the web UI shows stale or empty display status,
on-demand state and plugin health. Fix the directory rather than living
with the fallback.
## sudo rules
### `/etc/sudoers.d/ledmatrix_web`
Generated by `web_sudoers_rules()` in
[`scripts/install/lib_sudoers.sh`](../scripts/install/lib_sudoers.sh), the
only place these rules are defined. Installed by the installer and by
[`configure_web_sudo.sh`](../scripts/install/configure_web_sudo.sh), both of
which check them with `visudo -c` first. The web user may run, without a
password:
- `reboot`, `poweroff`
- `systemctl start|stop|restart|enable|disable|status ledmatrix.service`,
`systemctl is-active ledmatrix[.service]`
- `systemctl start|stop|restart ledmatrix-web.service`
- `bash <project>/scripts/fix_perms/safe_plugin_rm.sh *` — removes a
directory only if it resolves to a child of `plugin-repos/` or `plugins/`
- `bash <project>/scripts/fix_perms/safe_pip_install.sh *` — installs a
`requirements.txt` only if it is the project's own or one under
`plugin-repos/` or `plugins/`, so the root display service can import the
packages
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
escape from that pager would be a root shell
### `/etc/sudoers.d/ledmatrix_wifi`
Written by
[`scripts/install/configure_wifi_permissions.sh`](../scripts/install/configure_wifi_permissions.sh)
(run as the web user; the installer calls it). It refuses to grant a binary
that is not root-owned or is group/world-writable. The rules cover:
- `nmcli device wifi connect|disconnect *`, `nmcli device connect|disconnect *`,
`nmcli radio wifi on|off`
- `systemctl start|stop|restart hostapd`, `... dnsmasq`,
`systemctl restart NetworkManager`
- `sysctl -w net.ipv4.ip_forward=0|1`
- `nft add|delete table ip ledmatrix`
- `rfkill unblock wifi`
- `mkdir -p /etc/NetworkManager/dnsmasq-shared.d`
- `cp` of `/tmp/hostapd.conf` and `/tmp/dnsmasq.conf` to their fixed
destinations, and `rm -f /etc/dnsmasq.d/ledmatrix-captive.conf`
- `cp /tmp/ledmatrix-nm-dnsmasq.conf` to
`/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf`, and
`rm -f` of that file
**`iptables` is deliberately not granted.** The captive portal's rules are
built from the interface name and port, so a rule covering them would need a
trailing wildcard, and `iptables --modprobe=<path>` runs `<path>` as root: a
wildcard grant is a root shell for the web user. Doing it safely needs a
wrapper script that builds the rules itself, like `safe_plugin_rm.sh`. On a
stock Raspberry Pi OS image the default user's blanket `NOPASSWD` rule
(`/etc/sudoers.d/010_pi-nopasswd`) hides this gap.
### polkit
The same script installs `/etc/polkit-1/rules.d/10-ledmatrix-wifi.rules`,
which lets the web user perform any `org.freedesktop.NetworkManager.*`
action without authentication.
## Repair scripts
In [`scripts/fix_perms/`](../scripts/fix_perms/). Run them from the project
directory.
| Script | Run as | What it does | Notes |
|---|---|---|---|
| `fix_plugin_permissions.sh` | `sudo` | `plugins/` and `plugin-repos/` to `root:<user>`, dirs `2775`, files `664`; makes a `700` home directory `755` so root can traverse it | Safe. Group-writable, so the web user keeps write access |
| `fix_assets_permissions.sh` | `sudo` | `assets/` to `<user>:<group>`, mode `777` recursively | Works, but looser than the installer's `755`/`644` |
| `fix_cache_permissions.sh` | `sudo` | Runs [`setup_cache.sh`](../scripts/install/setup_cache.sh) for `/var/cache/ledmatrix` (`root:ledmatrix`, `2775`, files `660`), then makes `~/.ledmatrix_cache` (the fallback cache) `<user>:<group>` mode `777` | Safe. The `~/.ledmatrix_cache` mode is still `777` |
| `fix_web_permissions.sh` | the web user, **without** `sudo` | Resets project file ownership for the web user (it calls `sudo` itself), then makes `safe_plugin_rm.sh` and `safe_pip_install.sh` `root:root` `755` again and restores `config_secrets.json` to its owner, group `ledmatrix`, mode `640` | Refuses to run as root. It does not write sudoers rules |
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
To reinstall the sudoers rules, run
`./scripts/install/configure_web_sudo.sh` (web rules) or
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
the web user, not with `sudo`.
After any of these, restart both services:
```bash
sudo systemctl restart ledmatrix.service ledmatrix-web.service
```
## Checking
```bash
ls -ld /var/cache/ledmatrix # drwxrwsr-x root ledmatrix
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
id # web user should list ledmatrix
sudo -l # lists the NOPASSWD rules
```
+71 -78
View File
@@ -9,6 +9,7 @@ Complete API reference for plugin developers. This document describes all method
## Table of Contents
- [Manifest Required Fields](#manifest-required-fields)
- [BasePlugin](#baseplugin)
- [Display Manager](#display-manager)
- [Cache Manager](#cache-manager)
@@ -17,6 +18,50 @@ Complete API reference for plugin developers. This document describes all method
---
## Manifest Required Fields
Three parts of core check `manifest.json`, each for a different set of
fields:
| Check | Fields | What happens when one is missing |
|---|---|---|
| JSON schema, [`schema/manifest_schema.json`](../schema/manifest_schema.json) | `id`, `name`, `version`, `author`, `entry_point`, `class_name`, `compatible_versions` | Install from URL logs a warning (`PluginStoreManager._validate_manifest_schema()`); nothing is refused |
| Plugin Store install, [`src/plugin_system/store_manager.py`](../src/plugin_system/store_manager.py) | `id`, `name`, `class_name`, `display_modes` | Install is refused. A registry install first tries to detect a missing `class_name` from the entry-point file |
| Plugin loader, [`src/plugin_system/plugin_loader.py`](../src/plugin_system/plugin_loader.py) | `class_name` | The plugin fails to load |
Defaults and other uses:
- `entry_point` defaults to `manager.py`; the store writes the default back
into the manifest on install.
- `compatible_versions` (a list of semver ranges such as `">=2.0.0"`) is how
the store decides whether a plugin can run on this core. An install is
refused only when the field excludes the running version
(`compatibility.check()` in
[`src/plugin_system/compatibility.py`](../src/plugin_system/compatibility.py)).
- `version` is compared with the registry's `latest_version` to decide
whether an update is available.
- If `display_modes` is empty at load time, the display controller uses the
plugin id as the only mode.
**Set all eight:** `id`, `name`, `version`, `author`, `entry_point`,
`class_name`, `display_modes`, `compatible_versions`. That satisfies every
check. The schema lists the optional fields.
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "YourName",
"entry_point": "manager.py",
"class_name": "MyPlugin",
"display_modes": ["my-plugin"],
"compatible_versions": [">=2.0.0"]
}
```
---
## BasePlugin
All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality.
@@ -432,82 +477,17 @@ self.display_manager.update_display()
This is the canonical way to render arbitrary images.
### Weather Icons
### Weather Icons (deprecated)
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None`
> Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
Draw a weather icon based on the condition string.
**Parameters**:
- `condition` (str): Weather condition (e.g., "clear", "cloudy", "rain", "snow", "storm")
- `x` (int): X position
- `y` (int): Y position
- `size` (int): Icon size in pixels (default: 16)
**Supported Conditions**:
- `"clear"`, `"sunny"` → Sun icon
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
- `"thunderstorm"`, `"storm"` → Storm icon
**Example**:
```python
self.display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
```
#### `draw_sun(x: int, y: int, size: int = 16) -> None`
Draw a sun icon with rays.
**Parameters**:
- `x` (int): X position
- `y` (int): Y position
- `size` (int): Icon size (default: 16)
#### `draw_cloud(x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)) -> None`
Draw a cloud icon.
**Parameters**:
- `x` (int): X position
- `y` (int): Y position
- `size` (int): Icon size (default: 16)
- `color` (tuple): RGB color (default: light gray)
#### `draw_rain(x: int, y: int, size: int = 16) -> None`
Draw rain icon with cloud and droplets.
#### `draw_snow(x: int, y: int, size: int = 16) -> None`
Draw snow icon with cloud and snowflakes.
#### `draw_text_with_icons(text: str, icons: List[tuple] = None, x: int = None, y: int = None, color: tuple = (255, 255, 255)) -> None`
Draw text with weather icons at specified positions.
**Parameters**:
- `text` (str): Text to display
- `icons` (List[tuple], optional): List of (icon_type, x, y) tuples
- `x` (int, optional): X position for text
- `y` (int, optional): Y position for text
- `color` (tuple): Text color
**Note**: Automatically calls `update_display()` after drawing.
**Example**:
```python
icons = [
("sun", 5, 5),
("cloud", 100, 5)
]
self.display_manager.draw_text_with_icons(
"Weather: Sunny, Cloudy",
icons=icons,
x=10, y=20
)
```
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
- `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
`draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
- `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
— text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
### Scrolling State Management
@@ -601,6 +581,8 @@ Process any deferred updates if not currently scrolling. Called automatically by
#### `get_scrolling_stats() -> dict`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get current scrolling statistics for debugging.
**Returns**: Dictionary with scrolling state information
@@ -742,6 +724,8 @@ data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
> Deprecated, removed in 3.7.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
Get background service cached data with sport-specific intervals.
**Parameters**:
@@ -779,6 +763,8 @@ max_age = strategy['max_age'] # Get configured max age
#### `get_sport_live_interval(sport_key: str) -> int`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get the live_update_interval for a specific sport from config.
**Parameters**:
@@ -803,6 +789,8 @@ Extract data type from cache key to determine appropriate cache strategy.
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Extract sport key from cache key for sport-specific strategies.
**Parameters**:
@@ -847,10 +835,12 @@ for file_info in files:
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
```
### Metrics Methods
### Metrics Methods (deprecated)
#### `get_cache_metrics() -> Dict[str, Any]`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get cache performance metrics.
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
@@ -863,6 +853,8 @@ self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
#### `get_memory_cache_stats() -> Dict[str, Any]`
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
Get memory cache statistics.
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
@@ -907,6 +899,8 @@ for plugin_id, plugin in all_plugins.items():
#### `get_enabled_plugins() -> List[str]`
> Deprecated, removed in 3.7.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
Get list of enabled plugin IDs.
**Returns**: List of plugin identifier strings
@@ -985,9 +979,8 @@ def update(self):
**Example - Checking if another plugin is enabled**:
```python
enabled_plugins = self.plugin_manager.get_enabled_plugins()
if "weather" in enabled_plugins:
# Weather plugin is enabled
weather = self.plugin_manager.plugins.get("weather")
if weather is not None and weather.enabled:
pass
```
+3 -3
View File
@@ -155,9 +155,9 @@ deep-merged back into the plugin's config at load time
### Custom input widgets
Set `"x-widget": "<name>"` on a property. Core widgets are in
`web_interface/static/v3/js/widgets/` (see its README); a plugin can ship its
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`.
See [widget-guide.md](widget-guide.md).
`web_interface/static/v3/js/widgets/`; a plugin can ship its own widget
script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`. See the
[widget guide](../web_interface/static/v3/js/widgets/README.md).
### Custom actions
+2 -1
View File
@@ -157,5 +157,6 @@ For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TR
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`)
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
- Sudo rules: `scripts/install/configure_web_sudo.sh`
- Sudo rules: `scripts/install/lib_sudoers.sh` (written by `first_time_install.sh`
and `scripts/install/configure_web_sudo.sh`)
- Manual installer: `scripts/install_plugin_dependencies.sh`
+9 -4
View File
@@ -520,19 +520,22 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
`display_manager.image` (a PIL Image) and call `update_display()`;
there is no `draw_image()` helper method.
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
(deprecated, removed in 3.7.0 — draw your own icons)
- `get_text_width()`, `get_font_height()` - Text utilities
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
**Cache Manager** (`self.cache_manager`):
- `get()`, `set()`, `delete()` - Basic caching
- `get_cached_data_with_strategy()` - Advanced caching with strategies
- `get_background_cached_data()` - Background service caching
- `get_background_cached_data()` - deprecated, removed in 3.7.0 — use `get()`
**Plugin Manager** (`self.plugin_manager`):
- `get_plugin()`, `get_all_plugins()` - Access other plugins
- `get_plugin_info()` - Get plugin information
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete documentation.
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete
documentation, and its [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis)
table for everything removed in 3.7.0.
## 3rd Party Plugin Development
@@ -577,12 +580,14 @@ Your plugin must:
pass
```
2. **Include manifest.json** with required fields:
2. **Include manifest.json** with the required fields listed in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields):
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "YourName",
"class_name": "MyPlugin",
"entry_point": "manager.py",
"display_modes": ["my_plugin"],
@@ -658,7 +663,7 @@ For your plugin to work well in the plugin store:
with the registry's `latest_version`; releases and tags are not read
- **README.md**: Clear installation and configuration instructions
- **config_schema.json**: Recommended for web UI configuration
- **manifest.json**: Required with all required fields
- **manifest.json**: Required, with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
- **requirements.txt**: If your plugin has Python dependencies
### Distribution Options
+4 -1
View File
@@ -45,7 +45,8 @@ LEDMatrix/
### 1. Minimal Plugin Structure
**manifest.json**:
**manifest.json** (the required fields are explained in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields)):
```json
{
"id": "my-plugin",
@@ -54,6 +55,8 @@ LEDMatrix/
"author": "YourName",
"entry_point": "manager.py",
"class_name": "MyPlugin",
"display_modes": ["my-plugin"],
"compatible_versions": [">=2.0.0"],
"category": "custom"
}
```
+3 -3
View File
@@ -67,9 +67,9 @@ Don't edit `latest_version` or `last_updated` by hand for monorepo plugins:
## Adding or changing an official plugin
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo. The store refuses
a manifest without `id`, `name`, `class_name` and `display_modes`; also
set `version`.
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo, with the
manifest fields listed in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields).
2. Bump `version` in the plugin's `manifest.json` for every change, or users
won't be offered the update.
3. Run `python update_registry.py` in ledmatrix-plugins and commit the
+7 -3
View File
@@ -11,7 +11,7 @@ the one-shot installer. The pages here go deeper.
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) — using the web UI
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) — installing and managing plugins
4. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) — WiFi and AP-mode setup
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes ([PERMISSIONS.md](PERMISSIONS.md) for "Permission denied")
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
@@ -37,7 +37,7 @@ Going deeper:
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
- [widget-guide.md](widget-guide.md) — widget development
- [Widget guide](../web_interface/static/v3/js/widgets/README.md) — built-in `x-widget`s and custom widgets
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
@@ -56,21 +56,25 @@ Going deeper:
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
cache management, background services, permissions
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
- [PERMISSIONS.md](PERMISSIONS.md) — file ownership, sudo rules, repair scripts
- [MQTT bridge](../integrations/mqtt_bridge/README.md) — control the display from Home Assistant over MQTT
## Reference
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
- [src/common/README.md](../src/common/README.md) — shared helper modules plugins can import
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
## Contributing to LEDMatrix itself
- [ARCHITECTURE.md](ARCHITECTURE.md) — processes, display loop, plugin system, web UI; where to start reading
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how the sports scoreboard base classes are organized
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how shared sports scoreboard code moves into `src/common`
## Audits
+7 -2
View File
@@ -2111,13 +2111,18 @@ Errors use one of two shapes. Most endpoints answer:
}
```
Endpoints built on the structured error helper add a code and category:
An exception no route anticipated gets this shape too, with a 500, the
message `An error occurred; see logs for details`, and `details` naming the
exception type and text (credentials redacted). The api_v3 blueprint's
error handler produces it, so it is the same for every `/api/v3` route.
Endpoints built on the structured error helper add a code, and usually
suggested fixes (the web UI's error dialog lists them):
```json
{
"status": "error",
"error_code": "CONFIG_SAVE_FAILED",
"error_category": "configuration",
"message": "Error description",
"details": "optional",
"context": { },
+270 -1
View File
@@ -207,7 +207,10 @@ Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
### 3. Sub-pixel blending was wrong for this display
Enabling it made things worse, not better — see the rule at the top. It is off
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
by default everywhere. Vegas mode used to opt in; it now scrolls in whole
pixels locked to the refresh like the plugin tickers, and keeps the blend only
behind `display.vegas_scroll.sub_pixel_blend` (default `false`). The blend is
also why text looked anti-aliased in the web preview while the panel shimmered.
### 4. Frame-based stepping raced the vsync clock
@@ -231,6 +234,9 @@ advances by elapsed time at `scroll_speed / scroll_delay` px/s.
## Diagnosing a juddery scroller
To check a whole rig rather than one scroller, soak it -- see *Soaking a rig*
below.
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
healthy 100 fps. The stats line reports the tail for that reason — read the
@@ -303,6 +309,269 @@ journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
If a plugin logs its scroll config **twice** with different modes, the second
line is what is running.
## Soaking a rig
The per-scroller lines above tell you *which* scroller misbehaves. The soak
answers the question a release has to answer for each rig: **over a long run,
how often did a moving frame reach the panel late?**
Every frame reaches the panel through `DisplayManager.update_display`, so it is
timed there once, whoever drew it -- Vegas, a ticker plugin, anything. The
render thread only appends a tuple; a worker thread aggregates and rewrites
`/dev/shm/ledmatrix_frame_stats.json` every 10 seconds (RAM, so no SD-card
wear). `src/common/frame_timing.py` has the details.
```bash
python3 scripts/frame_soak.py # 10 minutes, as the display is now
python3 scripts/frame_soak.py --preview # with the web preview open
python3 scripts/frame_soak.py --show # totals since the service started
python3 scripts/frame_soak.py --json a.json # keep the report to compare later
```
It runs as any user next to the display service and stops nothing. It needs
something to *scroll* during the run: a live game holding a static scoreboard
on screen gives no verdict. `--preview` keeps the web preview's viewer marker
fresh, which puts the preview's PNG encoding at full rate -- run it as the web
service's user.
| line | what it tells you |
|---|---|
| **Late frames** | Frames presented one or more refreshes after they were due: the panel showed the previous frame again, a visible hitch. **The pass/fail number**, 0.1% by default (`--max-late-pct`). Only intervals between two scrolling frames count, and a frame held for `frame_hold` refreshes is due `frame_hold` refreshes after the last. |
| **Freezes** | Gaps of 250 ms or more inside a scroll: recomposes, plugin handovers, blocking calls on the render thread. Reported but not failed on, because some are handovers between plugins rather than faults. A gap still counts when the display's scroll state went missing for one frame across it, as long as scrolling resumes within 1 s: both of that frame's intervals count. Two static frames in a row end the scroll. (The state expires after 2 s without scroll activity, and plugins can clear it from their own `display()`.) The late and early rates are over frames judged against a known refresh period, which the recorder adopts once two windows in a row agree on it. |
| **blit** | Copying the frame into the matrix canvas (`SetImage`). It grows with width × height × `pwm_bits`: ~5.5 ms at 512×64 with 8 bits on a Pi 4. It is the biggest fixed cost, and it sets the refresh rates a rig can hold one pixel per refresh at. |
| **wait** | Time blocked in `SwapOnVSync`, i.e. the slack left in each refresh. A p50 near zero means the rig has no headroom and anything extra lands a frame late. |
| **work** | Everything else between two frames: drawing, scrolling, and waiting for the GIL. A wide gap between its p50 and p99 is another thread getting in the way. |
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
The refresh rate is estimated from the frames themselves (swaps that block on
vsync can only land on refresh boundaries). Cross-check it with
`scroll_speeds.py --measure` if it looks wrong. It can read high on a rig where
nothing ever presented at the full refresh rate.
A soak is only meaningful against a fixed workload. Compare runs with the same
content and `--preview` setting, and alternate which build goes first when you
A/B two of them. A live-API workload drifts over time.
The soak says how often; the service's log says why. A scroll that presents no
frame for 250 ms logs `Render stall:` with the stack of the render thread and
the top of every other thread's, and whether the whole interpreter was blocked
(C code holding the GIL) rather than one thread. To see what is behind the
shorter hitches, run the service with `LEDMATRIX_STALL_WATCHDOG_MS=30`, which
dumps at three refreshes late instead: its extra polling costs a little GIL
time of its own, so do that on a diagnostic run, not a soak you are grading.
`LEDMATRIX_STALL_WATCHDOG=0` turns it off.
### Results: hdpi, 2026-09-24
Pi 4, 4×128×64 on one chain (512×64), `gpio_slowdown` 3, cap 120 Hz, the
GIL-releasing binding. Vegas mode with live content, 8-minute soaks with
`--preview`, run in the order shown so each build went both first and last.
| run | build | pacing | pwm_bits | refresh | late | 1 | 2 | 3–5 | 6+ | freezes |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.33% | 2,542 | 74 | 19 | 4 | 0 |
| 2 | #628 | 1 px / refresh | 8 | 100.2 Hz | 0.66% | 238 | 32 | 30 | 5 | 2 |
| 3 | #628 | 1 px / refresh | 8 | 100.3 Hz | 0.70% | 252 | 38 | 26 | 6 | 2 |
| 4 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.46% | 2,659 | 90 | 10 | 4 | 0 |
| 5 | #628 | 1 px / 2 refreshes (53 px/s) | **7** | 107.2 Hz | 0.32% | 68 | 7 | 4 | 2 | 1 |
- Blending cost the panel refresh rate as well as frames: 94.5 Hz against
~100 Hz for the same hardware under whole-pixel pacing.
- The freezes and the 3+ rows in the #628 runs line up with canvas-bound
plugins fetched on the render thread (`drain_deferred`): `news` took ~320 ms
and `hockey-scoreboard` ~660 ms there. Moving those
fetches off the render thread is proposed separately (offscreen rendering).
- Run 5 changed two things at once: the speed, and `pwm_bits` (changed on the
rig between runs). Its lower late rate cannot be credited to either alone.
- These soaks were taken before the recorder counted 1–2 s stalls as freezes,
so a stall of that length would be missing from these rows.
### Without the service: `render_bench.py`
The soak measures the service as it really runs: live content, plugin
updates, the web preview. `scripts/render_bench.py` answers the narrower
question underneath: *with nothing else in the way, can this hardware present
every frame on time?* It scrolls a synthetic strip through the production path
-- a real `DisplayManager`, a real `ScrollHelper`, the same `scroll_config`
resolver every ticker uses -- on content that is identical every run, which
makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT against
another) and for A/B testing a change to the render path.
```bash
sudo systemctl stop ledmatrix # the service owns the GPIO
sudo python3 scripts/render_bench.py # 60s at one pixel per refresh
sudo python3 scripts/render_bench.py --seconds 600 # the shipping gate
sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) speed
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
sudo systemctl start ledmatrix
```
It never starts or stops the service itself, so a crash in it cannot leave
the panel dark. It grades with the same recorder as the soak and prints the
same report, with the same exit status, except that **2** also means the run
could not be set up at all (no root, no panel, a fallback display), so a rig
that was never measured cannot pass by accident.
Two differences from the soak matter:
- **It measures the panel first.** Before scrolling it times bare swaps for a
few seconds to get the idle refresh rate, and seeds the recorder with it.
That is what catches a loop that never locked to the panel at all. The first
version of the bench announced its scrolling state once instead of every
frame; the state expired, the dirty-tracking skip fired mid-scroll, and the
loop free-ran at 827 fps. Graded against its own frames that looks perfectly
steady; graded against the panel's measured rate every frame is early, and
the run fails as NOT LOCKED. (The soak has no idle measurement, so it checks
the rate against `limit_refresh_rate_hz` instead: a "refresh" faster than
the cap cannot have been waiting for the panel.)
- **The stall watchdog prints to the terminal.** A frame held up for more than
250 ms prints the stack of what held it up, in the middle of the run.
Measured with the first version of the bench on hdpi (Pi 4, 512x64,
`pwm_bits` 8), two-minute runs at one pixel per refresh: 8 of 11,449 frames
late (0.070%), and with `--busy 2` 3 of 11,445 (0.026%). The render path and
the hardware pass on their own. Compare the soak results above, from the same
rig with the service running, for how much of the late rate comes from
everything else.
### The panel is slower while you are rendering into it
The bench prints two refresh rates, and they differ:
| | Pi 4, 512x64, `pwm_bits` 8 |
|---|---|
| idle, timing bare swaps | 100.4 Hz |
| while scrolling | 96.3 Hz |
Both are real. Driving an LED matrix is bit-banging on the same machine, so
`SetImage` over a 512x64 chain contends with the refresh itself and slows it.
The recorder therefore reads the rendering rate back from the frames: swaps
that block on vsync can only return on a refresh boundary, so the low end of
`interval / frame_hold` is the period. The idle figure is still printed,
because the gap between the two is itself a measure of how expensive a frame
is: **a rise in that gap is a render-cost regression even when nothing is
late.**
The practical consequence for config: set `limit_refresh_rate_hz` near the rate
the panel holds *while rendering*, not the idle rate and certainly not a cap it
can never reach. A cap well above the real rate makes `scroll_config` solve
speeds against a refresh that does not exist, which is where "3px every 4
refreshes" comes from.
### Bench-only counters
| line | meaning |
|---|---|
| `duplicate` | frames that advanced no pixels. A crisp fixed-step scroll should show none; any at all means the loop is presenting faster than the strip is moving. |
| `blank` | frames with no visible slice to draw: the helper had no content. Should be zero. |
| `restarts` | how many times the strip was scrolled through end to end. Informational: the bench restarts the strip where a plugin would hand over to the next one. |
`--json` writes the full report plus the panel geometry, the solved speed and
these counters, so two rigs (or one rig before and after a change) can be
compared without re-reading a terminal.
---
## A tear across the middle on fast scrolls
**Symptom:** while text scrolls, the top and bottom halves of the panel look
shifted sideways against each other along a horizontal line at mid-height, and
the shift grows with scroll speed. It shows most in Vegas mode at high speed.
**It is the panel's scan, not the software.** The measured panel, like most
64-row panels, is multiplexed 1:32 (some panels of the same size scan
differently, so check yours): it lights two rows at a time, one from each half
(row 0 with row 32, row 1 with row 33, …), stepping down both halves together
once per refresh. So row 31,
the last row of the top half, lights almost a whole refresh period after row 32
right below it. Your eye follows moving text, and moving content that lights at
different times lands in different places, so the two rows meet with an offset
of roughly
```
offset ≈ scroll speed × refresh period
```
Each frame reaches the panel whole (`SwapOnVSync` swaps complete frames between
refreshes); the shift is created inside a single refresh. Other panel heights
show it too, at the point where their two scan halves meet.
On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
(7.7 ms per pass):
| scroll speed | offset at the midline |
|---|---|
| 50 px/s (Vegas default) | ~0.4 px |
| 100 px/s | ~0.8 px |
| 150 px/s | ~1.2 px, plainly visible |
### What the display does about it
At one pixel per refresh, the fastest crisp speed, the step is exactly one
refresh's worth of motion, so it can be cancelled: show one half of the panel
a refresh behind the other -- the half whose row at the seam lights at the
start of each refresh. The two rows either side of the seam then show the same
moment again. What is left is a
lean of one pixel per half from top to bottom, continuous across the panel,
which reads as nothing where the step read as a tear. `DisplayManager` does
this while something scrolls at one frame per refresh
(`display.scan_order_compensation`, `"auto"` by default, `"off"` to disable;
the geometry is in `src/scan_order.py`). The lagging rows come from the
previous frame the display presented, so it works for Vegas and every plugin
ticker without knowing how they scroll.
Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was
written: `scan_mode: 1` (interlaced) made the step vanish but turned moving
edges grainy, and halving the speed halved it, so it is the scan and not a torn
frame. With the compensation the step is gone at 90 px/s.
It is left off where the row order is unknown or the maths does not hold:
- **Slower speeds**, where each frame is held for two or more refreshes. The
offset there is half a pixel or less, and cancelling it would need a lag of
a fraction of a frame.
- **Other layouts:** pixel mappers other than a 0 or 180 degree rotation
(U-mapper, 90/270), non-zero `multiplexing`, interlaced `scan_mode`, and a
canvas remapped to another height (double-sided mode).
- **The emulator,** which has no scan order.
### When it cannot apply
Only a shorter scan period (a faster refresh) or a slower scroll. Measure what
the panel actually achieves first. The library prints the rate with a carriage
return and no newline, so read it from the raw journal:
```bash
# set display.hardware.show_refresh_rate to true (web UI, Display tab), restart, then:
journalctl -u ledmatrix --since "-1min" --no-pager -o cat --all | grep -a -oE "[0-9.]+Hz" | tail -5
```
Turn it off again afterwards. Measured on that panel (Pi 4, single chain),
changing one setting at a time from `pwm_bits: 7`, `gpio_slowdown: 3`:
| change | refresh, uncapped | notes |
|---|---|---|
| none | ~130 Hz | the ceiling for this wiring |
| `pwm_bits: 6` | ~138 Hz | barely faster, and half the colour depth |
| `gpio_slowdown: 2` | ~130 Hz | no faster, **and visible glitching**; keep 3 |
| `limit_refresh_rate_hz: 0` | ~130 Hz | Vegas dropped from 100 to 72–95 fps as the refresh thread took more CPU |
None of these helps much, because the time goes into shifting each row's pixels
out: a 2×128 chain pushes 256 pixels per row down one output. What does help is
**fewer pixels per output**. On a bonnet with more than one output (the
`regular` and `classic` mappings have 3; `adafruit-hat` has 1), put each panel
on its own output and set `parallel` to the number of outputs used and
`chain_length` to the panels per output, for example `parallel: 2`,
`chain_length: 1` for two panels. Each refresh then shifts half the data, which
should roughly double the refresh rate and halve the offset. That is a cable
change, so measure again afterwards.
Short of rewiring, keep fast scrolls moderate on those layouts: at 50 px/s the
offset is under half a pixel.
## Rebuilding the binding
```bash
+11 -9
View File
@@ -73,14 +73,16 @@ B2 below promoted code into it (`SportsCore`, the mode classes,
capabilities sections record that design, but none of it ships in core any
more. Shared sports code lives in `src/common`:
```
src/common/
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
(content building stays in the plugins)
sports_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin
(3.5.0) — the helpers byte-identical in the
plugins' sports.py, and the _favorite_key seam
```
| Module | Since | Holds |
|---|---|---|
| `sports_scroll.py` | 3.2.0 | `SportsScrollDisplay` / `SportsScrollDisplayManager` — scroll orchestration (content building stays in the plugins) |
| `sports_card.py` | 3.3.0 | Free functions for card settings, colours, favourite-team rules, dates and font sizes |
| `sports_game_renderer.py` | 3.3.0 | `SportsGameRendererMixin` — scroll/Vegas card geometry |
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
Each is described in [src/common/README.md](../src/common/README.md).
### Converging on `src/common`
@@ -90,7 +92,7 @@ modules taken from the plugin copies, each a **new module** rather than growth
on an existing one: a plugin that deletes a method copy and relies on an older
module having gained it fails at runtime with an `AttributeError`, while a
missing module fails at load, where the version checks can see it.
`sports_helpers.py` is the first (it holds `_favorite_key`, the override point
`sports_helpers.py` is the newest (it holds `_favorite_key`, the override point
listed below, for later phases); its parity test compares every body against
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
`test/test_common_is_hardware_free.py` keeps `src/common` free of
+11 -6
View File
@@ -102,9 +102,16 @@ cd /path/to/LEDMatrix
bash scripts/download_pixlet.sh
```
The script downloads only the Linux ARM64 build (Raspberry Pi OS 64-bit),
from the `tronbyt/pixlet` releases, to `bin/pixlet/pixlet-linux-arm64`. On
any other platform (32-bit Pi OS, x86_64, macOS), put a `pixlet` binary on
your `PATH`, or set the plugin's `pixlet_path`, or place it in `bin/pixlet/`
under the name `_find_pixlet_binary()` looks for
([`web_interface/blueprints/api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py)).
Verify installation:
```bash
./bin/pixlet/pixlet-linux-amd64 version
./bin/pixlet/pixlet-linux-arm64 version
# Pixlet 0.50.2 (or later)
```
@@ -276,10 +283,8 @@ LEDMatrix/
│ ├── hour_hand.png
│ └── minute_hand.png
│
├── bin/pixlet/ # Pixlet binaries
│ ├── pixlet-linux-amd64
│ ├── pixlet-linux-arm64
│ └── pixlet-darwin-arm64
├── bin/pixlet/ # Pixlet binary
│ └── pixlet-linux-arm64 # the only one download_pixlet.sh fetches
│
└── scripts/
└── download_pixlet.sh # Pixlet installer
@@ -324,7 +329,7 @@ Many apps require API keys for external services:
**Solutions**:
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
2. Verify config: Ensure all required fields are filled
3. Test manually: `./bin/pixlet/pixlet-linux-amd64 render starlark-apps/{app-id}/{app-id}.star`
3. Test manually: `./bin/pixlet/pixlet-linux-arm64 render starlark-apps/{app-id}/{app-id}.star`
4. Missing assets: Some apps need images/fonts that may fail to download
5. API issues: Check API keys and rate limits
+32 -27
View File
@@ -201,10 +201,11 @@ sudo systemctl restart ledmatrix-web
**Solutions:**
1. **Install dependencies:**
1. **Install dependencies** as root, so the root display service can import
them:
```bash
pip3 install --break-system-packages -r requirements.txt
pip3 install --break-system-packages -r web_interface/requirements.txt
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
sudo python3 -m pip install --break-system-packages --no-cache-dir -r web_interface/requirements.txt
```
2. **Test imports step-by-step:**
@@ -250,15 +251,18 @@ sudo systemctl restart ledmatrix-web
**Solutions:**
```bash
# Fix ownership of LEDMatrix directory
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix
[PERMISSIONS.md](PERMISSIONS.md) lists the expected owner and mode of every
file and directory, and which `scripts/fix_perms/` script to run as which
user. Don't `chown -R` the whole project: the two sudo helper scripts in
`scripts/fix_perms/` must stay owned by root.
# Fix config file permissions
```bash
# Config files: web user owns both; secrets must stay 640
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
sudo chmod 644 config/config.json
sudo chmod 640 config/config_secrets.json
# Verify service runs as correct user
# Which user the web interface runs as
sudo systemctl cat ledmatrix-web | grep User
```
@@ -462,15 +466,17 @@ sudo systemctl cat ledmatrix-web | grep User
}
```
2. **Restart display:**
```bash
sudo systemctl restart ledmatrix
```
Or toggle the plugin on in the **Plugin Manager** tab, which writes the
same flag.
3. **Verify in web interface:**
- Open the **Plugin Manager** tab
- Toggle the plugin switch to enable
- From **Overview**, click **Restart Display Service**
2. **Wait a few seconds.** The display service watches `config.json` and
loads a newly enabled plugin without a restart
(`DisplayController._reconcile_enabled_plugins()` in
[`src/display_controller.py`](../src/display_controller.py)). This
needs hot reload, which is on unless `LEDMATRIX_HOT_RELOAD=false` is set.
3. **If it still does not appear**, check the logs for a config validation
error, then restart: `sudo systemctl restart ledmatrix`
#### Plugin Not Loading
@@ -491,10 +497,12 @@ sudo systemctl cat ledmatrix-web | grep User
# Verify all required fields present
```
3. **Check dependencies installed:**
3. **Check dependencies installed.** Install them with `sudo`: the display
service runs as root and does not see packages pip put in your user's
`~/.local` (see [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md)):
```bash
if [ -f plugin-repos/plugin-id/requirements.txt ]; then
pip3 install --break-system-packages -r plugin-repos/plugin-id/requirements.txt
sudo python3 -m pip install --break-system-packages --no-cache-dir -r plugin-repos/plugin-id/requirements.txt
fi
```
@@ -503,14 +511,9 @@ sudo systemctl cat ledmatrix-web | grep User
sudo journalctl -u ledmatrix -f | grep plugin-id
```
5. **Test plugin import:**
5. **Load and render the plugin headlessly:**
```bash
python3 -c "
import sys
sys.path.insert(0, 'plugin-repos/plugin-id')
from manager import PluginClass
print('Plugin imports successfully')
"
python3 scripts/check_plugin.py --plugin plugin-id
```
#### Stale Cache Data
@@ -540,10 +543,12 @@ sudo systemctl cat ledmatrix-web | grep User
sudo systemctl restart ledmatrix
```
2. **Check cache permissions:**
2. **Check cache permissions.** Expected: `root:ledmatrix`, `drwxrwsr-x`.
`setup_cache.sh` restores that layout (see
[PERMISSIONS.md](PERMISSIONS.md#repair-scripts)):
```bash
ls -ld /var/cache/ledmatrix
sudo ./scripts/fix_perms/fix_cache_permissions.sh
sudo bash scripts/install/setup_cache.sh
```
---
+9 -5
View File
@@ -161,7 +161,11 @@ duration, and related settings — so you can configure Vegas mode
entirely from the web UI without hand-editing JSON. See
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
Changes require **Restart Display Service** from the Overview tab.
Brightness and the Vegas Scroll settings apply to the running display
within a few seconds. Matrix hardware settings (rows, columns, chain length,
mapping, GPIO slowdown, PWM and refresh settings) are only read when the
display starts, so those need **Restart Display Service** from the Overview
tab.
### Plugin Manager Tab
@@ -248,16 +252,16 @@ View real-time system logs:
1. Open the **Display** tab
2. Adjust the **Brightness** slider (1–100)
3. Click **Save**
4. Click **Restart Display Service** on the **Overview** tab
3. Click **Save**. The panel picks up the new brightness within a few
seconds; no restart is needed
### Installing a New Plugin
1. Open the **Plugin Manager** tab
2. Scroll to the **Plugin Store** section and browse or search
3. Click **Install** next to the plugin
4. Toggle the plugin on in **Installed Plugins**
5. Click **Restart Display Service** on **Overview**
4. Toggle the plugin on in **Installed Plugins**. The running display
loads it within a few seconds; no restart is needed
### Configuring a Plugin
+5 -588
View File
@@ -1,590 +1,7 @@
# Widget Development Guide
## Overview
The LEDMatrix Widget Registry system allows plugins to use reusable UI components for configuration forms. This enables:
- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code
- **Custom Widgets**: Create plugin-specific widgets without modifying the LEDMatrix codebase
- **Backwards Compatibility**: Existing plugins continue to work without changes
## Available Core Widgets
### Plugin File Manager Widget (`plugin-file-manager`)
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
**Schema Configuration:**
```json
{
"file_manager": {
"type": "null",
"title": "Data Files",
"x-widget": "plugin-file-manager",
"x-widget-config": {
"actions": {
"list": "list-files",
"get": "get-file",
"save": "save-file",
"upload": "upload-file",
"delete": "delete-file",
"create": "create-file",
"toggle": "toggle-category"
},
"upload_hint": "JSON files with day numbers 1–365 as keys",
"directory_label": "my_data/",
"create_fields": [
{ "key": "category_name", "label": "Category Name",
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
"hint": "Lowercase letters, numbers, underscores" },
{ "key": "display_name", "label": "Display Name",
"placeholder": "e.g., My Words", "hint": "Optional" }
]
}
}
}
```
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
**Used by:** of-the-day
---
### Time Picker Widget (`time-picker`)
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
**Schema Configuration:**
```json
{
"target_time": {
"type": "string",
"x-widget": "time-picker",
"default": "00:00",
"x-options": {
"placeholder": "Select time",
"clearable": true
}
}
}
```
**Used by:** countdown
---
### File Upload Single Widget (`file-upload-single`)
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
**Schema Configuration:**
```json
{
"image_path": {
"type": "string",
"x-widget": "file-upload-single",
"x-upload-config": {
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
"max_size_mb": 5
}
}
}
```
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
**Used by:** countdown
---
### File Upload Widget (`file-upload`)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 10,
"max_size_mb": 5,
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
}
}
```
**Used by:** static-image, news plugins
### Checkbox Group Widget (`checkbox-group`)
Multi-select checkboxes for array fields with enum items.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["option1", "option2", "option3"]
},
"x-options": {
"labels": {
"option1": "Option 1 Label",
"option2": "Option 2 Label"
}
}
}
```
**Used by:** odds-ticker, news plugins
### Custom Feeds Widget (`custom-feeds`)
Table-based RSS feed editor with logo uploads.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"enabled": { "type": "boolean" },
"logo": { "type": "object" }
}
},
"maxItems": 50
}
```
**Used by:** news plugin (for custom RSS feeds)
## Using Existing Widgets
To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property:
```json
{
"properties": {
"my_images": {
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 5
}
},
"enabled_leagues": {
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["nfl", "nba", "mlb"]
},
"x-options": {
"labels": {
"nfl": "NFL",
"nba": "NBA",
"mlb": "MLB"
}
}
}
}
}
```
The widget will be automatically rendered when the plugin configuration form is loaded.
## Labelling Enum Options (`x-options.labels`)
A plain `enum` renders as a dropdown whose option text is the value with
underscores replaced and title case applied — `day_first` becomes "Day First".
That is fine for values that read as their own label, and wrong for values that
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
actually produces.
Supply `x-options.labels` to set the visible text. This is the same convention
the `checkbox-group` widget uses:
```json
{
"date_format": {
"type": "string",
"enum": ["abbrev", "numeric", "day_first"],
"default": "abbrev",
"x-options": {
"labels": {
"abbrev": "Sep 19",
"numeric": "9/19",
"day_first": "19 Sep"
}
}
}
}
```
Labels are **display only** — the stored value is still the enum value, so
adding them never changes a saved config. The map may be partial: any value
without a label keeps the humanised fallback. Older cores that predate this
support ignore `x-options` and render the fallback for every option, so a
plugin can ship labels without requiring a core upgrade.
Array-table columns (`x-widget: array-table`) accept the same
`x-options.labels` on a column definition, but their fallback is the **raw
value** rather than the humanised one, because those columns hold values such
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
browser use the labels too (`array-table.js`), so a column reads the same
before and after a page reload.
## Marking Fields as Advanced (`x-advanced`)
Add `"x-advanced": true` to any top-level, non-object property to move it out
of the main form and into a single collapsed **Advanced Settings** section at
the bottom of the plugin's configuration page:
```json
{
"properties": {
"city": {
"type": "string",
"title": "City"
},
"request_timeout": {
"type": "integer",
"default": 10,
"description": "HTTP timeout in seconds",
"x-advanced": true
}
}
}
```
Guidelines:
- Use it for fine-tuning knobs most users never touch (timeouts, retry
behavior, cache TTLs, styling overrides). Anything a first-time user must
set to get the plugin working should stay basic.
- Nothing is hidden permanently — the section expands on click, and the
settings search finds and auto-expands advanced fields like any others.
- The flag is ignored on `object`-type properties (they already render as
their own collapsible sections) and is safely ignored by older cores, so
adding it never breaks compatibility.
## Hiding Fields From the Form (`x-display: "hidden"`)
Add `"x-display": "hidden"` to a property that must stay in the schema but
should not appear as a control: a deprecated key kept so existing configs keep
validating, or an internal value such as an auto-generated row id.
```json
{
"properties": {
"radar_zoom": {
"type": "integer",
"default": 6,
"title": "Radar Zoom Level (deprecated)",
"x-display": "hidden"
}
}
}
```
What the core does with it:
- **Not rendered** at any depth: top-level fields, children of an object
section, and properties of array-of-object items (never a table column, even
if `x-columns` names it, and never in the row editor). A hidden field flagged
`x-advanced` is not listed or counted in Advanced Settings, and an object
whose children are all hidden draws no empty section. Hidden fields don't
show up in the settings search either, since it indexes the rendered form.
- **Stored value preserved on save.** Saving the form never changes a hidden
value. The unchecked-checkbox rule ignores a hidden boolean. Array rows carry
a hidden property's stored value through the form, so the value survives the
row being posted back; a new row gets no value (the plugin fills it in).
- **The API is unaffected.** A JSON save to `POST /api/v3/plugins/config` can
still set a hidden field.
Older cores ignore the flag and render the field as a normal control.
## Creating Custom Widgets
### Step 1: Create Widget File
Create a JavaScript file in your plugin's `widgets/` directory, named
`widgets/[widget-name].js`. The directory is not optional: it is the only
place the core will serve a widget from.
```javascript
// Ensure LEDMatrixWidgets registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('LEDMatrixWidgets registry not found');
return;
}
// Register your widget
window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget',
version: '1.0.0',
/**
* Render the widget HTML
* @param {HTMLElement} container - Container element to render into
* @param {Object} config - Widget configuration from schema
* @param {*} value - Current value
* @param {Object} options - Additional options (fieldId, pluginId, etc.)
*/
render: function(container, config, value, options) {
const fieldId = options.fieldId || container.id;
// Always escape HTML to prevent XSS
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
container.innerHTML = `
<div class="my-custom-widget">
<input type="text"
id="${fieldId}_input"
value="${escapeHtml(value || '')}"
class="w-full px-3 py-2 border border-gray-300 rounded">
</div>
`;
// Attach event listeners
const input = container.querySelector('input');
input.addEventListener('change', (e) => {
this.handlers.onChange(fieldId, e.target.value);
});
},
/**
* Get current value from widget
*/
getValue: function(fieldId) {
const input = document.querySelector(`#${fieldId}_input`);
return input ? input.value : null;
},
/**
* Set value programmatically
*/
setValue: function(fieldId, value) {
const input = document.querySelector(`#${fieldId}_input`);
if (input) {
input.value = value || '';
}
},
/**
* Event handlers
*/
handlers: {
onChange: function(fieldId, value) {
// Trigger form change event
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
```
### Step 2: Declare the Widget in `manifest.json`
The manifest is the allowlist. A widget is served only if the plugin declares
it, so shipping a file under `widgets/` does not by itself publish it:
```json
{
"widgets": [
{
"name": "my-custom-widget",
"script": "my-custom-widget.js",
"description": "What this widget is for"
}
]
}
```
`name` is what you use in `x-widget` and in the URL. `script` is optional and
defaults to `[name].js`; it must be a plain filename directly inside
`widgets/` (no paths). Both are validated against
`schema/manifest_schema.json`.
### Step 3: Reference Widget in Schema
In your plugin's `config_schema.json`:
```json
{
"properties": {
"my_field": {
"type": "string",
"description": "My custom field",
"x-widget": "my-custom-widget",
"default": ""
}
}
}
```
### Step 4: Widget Loading
The widget is loaded on demand when the plugin's configuration form renders a
field that references it. The system will:
1. Check whether the widget is already registered in the core registry.
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
That route serves the declared `script` from your plugin's `widgets/`
directory, as `text/javascript`.
3. Render it by calling the `render` function your script registered.
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
**If the widget fails to load** (not declared, file missing, script throws, or
it never calls `register`), the field falls back to a plain text input holding
the current value. This is deliberate: a broken widget costs the user an
editor, not their configured value.
**Limitation:** the on-demand path applies to `string`-typed fields (the
default branch of the config-form renderer). Fields typed `object`, `array`,
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
dispatched by the server-side template to its own built-in renderers, so a
plugin-supplied `x-widget` on one of those is ignored today.
## Widget API Reference
### Widget Definition Object
```javascript
{
name: string, // Human-readable widget name
version: string, // Widget version
render: function, // Required: Render function
getValue: function, // Optional: Get current value
setValue: function, // Optional: Set value programmatically
handlers: object // Optional: Event handlers
}
```
### Render Function
```javascript
render(container, config, value, options)
```
**Parameters:**
- `container` (HTMLElement): Container element to render into
- `config` (Object): Widget configuration from schema
- `value` (*): Current field value
- `options` (Object): Additional options
- `fieldId` (string): Field ID
- `pluginId` (string): Plugin ID
- `fullKey` (string): Full field key path
### Get Value Function
```javascript
getValue(fieldId)
```
**Returns:** Current widget value
### Set Value Function
```javascript
setValue(fieldId, value)
```
**Parameters:**
- `fieldId` (string): Field ID
- `value` (*): Value to set
## Examples
See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interface/static/v3/js/widgets/example-color-picker.js) for a complete example of a custom color picker widget.
## Best Practices
### Security
1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS
2. **Validate inputs**: Validate user input before processing
3. **Sanitize values**: Clean values before storing
### Performance
1. **Lazy loading**: Load widget scripts only when needed
2. **Event delegation**: Use event delegation for dynamic content
3. **Debounce**: Debounce frequent events (e.g., input changes)
### Accessibility
1. **Labels**: Always associate labels with inputs
2. **ARIA attributes**: Use appropriate ARIA attributes
3. **Keyboard navigation**: Ensure keyboard accessibility
## Troubleshooting
### Widget Not Loading
1. Check browser console for errors
2. Verify widget file path is correct
3. Ensure `LEDMatrixWidgets.register()` is called
4. Check that widget name matches schema `x-widget` value
### Widget Not Rendering
1. Verify `render` function is defined
2. Check container element exists
3. Ensure widget is registered before form loads
4. Check for JavaScript errors in console
### Value Not Saving
1. Ensure widget triggers `widget-change` event
2. Verify form submission includes widget value
3. Check `getValue` function returns correct type
4. Verify field name matches schema property
## Current Implementation Status
**Phase 1 Complete:**
- ✅ Widget registry system created
- ✅ Core widgets extracted to separate files
- ✅ Widget handlers available globally (backwards compatible)
- ✅ Plugin widget loading system implemented
**Current Behavior:**
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
- Widget handlers are registered and available globally
- Custom widgets can be created, declared in `manifest.json`, and are served
and rendered on demand for `string`-typed fields
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
**Backwards Compatibility:**
- All existing plugins using widgets continue to work without changes
- Server-side rendering remains the primary method
- Widget registry provides foundation for future enhancements
## See Also
- [Widget README](../web_interface/static/v3/js/widgets/README.md) - Complete widget development guide with examples
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - General plugin development
- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
The widget guide lives next to the widgets, in
[web_interface/static/v3/js/widgets/README.md](../web_interface/static/v3/js/widgets/README.md).
It lists every built-in `x-widget`, the schema keywords the config form
understands (`x-options.labels`, `x-advanced`, `x-display: "hidden"`), and how
to ship a custom widget with a plugin.
+108 -147
View File
@@ -18,7 +18,7 @@ on_error() {
echo "-- Last 100 lines from log --" >&2
tail -n 100 "$LOG_FILE" >&2 || true
fi
echo "\nCommon fixes:" >&2
printf '\nCommon fixes:\n' >&2
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
echo "- If you saw an APT lock error: wait a minute, close other installers, then run: sudo dpkg --configure -a" >&2
echo "- Re-run this script. It is safe to run multiple times." >&2
@@ -115,7 +115,8 @@ fi
echo "✓ OS requirements met"
echo ""
# Get the actual user who invoked sudo (set after we ensure sudo below)
# The user who ran the installer: SUDO_USER once we are running under sudo
# (the re-exec below guarantees that), otherwise whoever we are now.
if [ -n "${SUDO_USER:-}" ]; then
ACTUAL_USER="$SUDO_USER"
else
@@ -202,7 +203,7 @@ echo ""
# Check if running as root; if not, try to elevate automatically for novices
if [ "$EUID" -ne 0 ]; then
echo "This script needs administrator privileges. Attempting to re-run with sudo..."
exec sudo -E env LEDMATRIX_ELEVATED=1 bash "$0" "$@"
exec sudo -E bash "$0" "$@"
fi
echo "✓ Running as root (required for installation)"
@@ -502,6 +503,44 @@ print_rgbmatrix_build_failure() {
fi
}
# Set WEB_SERVICE_USER to the account ledmatrix-web.service runs as, or "root"
# when it cannot tell. Steps 3.1 and 11 choose plugin-directory ownership from
# it. The logic was pasted three times, identically, and is kept verbatim here.
# Note: install_web_service.sh and install_service.sh no longer contain the
# "User=root" / "User=${ACTUAL_USER}" strings grepped for below (the units come
# from systemd/*.service templates with User=__USER__). So once the unit is
# installed (Step 7.5, by install_service.sh) the first branch reads its real
# User=; before that the second branch is taken whenever
# install_web_service.sh exists, matches neither string, and yields "root" --
# the later branches are reached only if that script is missing.
detect_web_service_user() {
WEB_SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
# Check template file (may have placeholder)
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
# If template has placeholder, check install script
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
# Check install_service.sh to see what user it uses
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
# Web service will be installed by install_service.sh as ACTUAL_USER
WEB_SERVICE_USER="$ACTUAL_USER"
fi
}
echo ""
echo "This script will perform the following steps:"
echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
@@ -634,8 +673,9 @@ else
echo "Setting ownership of assets directory..."
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets"
# Set permissions to allow read/write for owner, group, and others (for root service user)
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
# 777: read/write for owner, group and every other account. Root (the
# display service) does not need it -- root ignores mode bits -- so the
# "other" bits only matter to accounts that are neither the owner nor root.
echo "Setting permissions for assets directory..."
chmod -R 777 "$PROJECT_ROOT_DIR/assets"
@@ -699,32 +739,7 @@ else
fi
# Determine ownership based on web service user
# Check if web service file exists and what user it runs as
WEB_SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
# Check template file (may have placeholder)
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
# If template has placeholder, check install script
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
# Check install_service.sh to see what user it uses
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
# Web service will be installed by install_service.sh as ACTUAL_USER
WEB_SERVICE_USER="$ACTUAL_USER"
fi
detect_web_service_user
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
# so the web service can change permissions. Root service can still access via group (775).
@@ -758,32 +773,7 @@ if [ ! -d "$PLUGIN_REPOS_DIR" ]; then
fi
# Determine ownership based on web service user
# Check if web service file exists and what user it runs as
WEB_SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
# Check template file (may have placeholder)
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
# If template has placeholder, check install script
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
# Check install_service.sh to see what user it uses
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
# Web service will be installed by install_service.sh as ACTUAL_USER
WEB_SERVICE_USER="$ACTUAL_USER"
fi
detect_web_service_user
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
# so the web service can change permissions. Root service can still access via group (775).
@@ -797,8 +787,8 @@ else
chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
fi
# Set directory permissions (775: rwxrwxr-x)
echo "Setting plugin-repos directory permissions to 2775 (sticky bit)..."
# Set directory permissions (2775: rwxrwsr-x, setgid so new entries inherit the group)
echo "Setting plugin-repos directory permissions to 2775 (setgid)..."
find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \;
# Set file permissions (664: rw-rw-r--)
@@ -1005,9 +995,9 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
PACKAGE_NUM=$((PACKAGE_NUM + 1))
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
# Check if package is already installed (basic check - may not catch all cases)
# Try installing with verbose output and timeout (if available)
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
# Install with a timeout where available. --verbose output goes to
# $INSTALL_OUTPUT (filtered below, full copy in the log); --no-cache-dir
# avoids pip cache issues.
INSTALL_OUTPUT=$(mktemp)
INSTALL_SUCCESS=false
@@ -1312,7 +1302,11 @@ else
WEB_DEPS_OK=false
fi
else
echo "Web dependencies already installed from web_interface/requirements.txt in Step 5"
# No marker means Step 5 did not install web_interface/requirements.txt,
# and without the smart installer there is nothing else to try here.
echo "⚠ scripts/install_dependencies_apt.py not found, and Step 5 did not install"
echo " web_interface/requirements.txt, so web interface dependencies may be missing."
WEB_DEPS_OK=false
fi
# Create the marker only when installation actually succeeded, so a
@@ -1516,51 +1510,31 @@ POWEROFF_PATH=$(which poweroff)
BASH_PATH=$(which bash)
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
# Create sudoers content
cat > "$SUDOERS_TMP" << EOF
# LED Matrix Web Interface passwordless sudo configuration
# This allows the web interface user to run specific commands without a password
# Allow $ACTUAL_USER to run specific commands without a password for the LED Matrix web interface
$ACTUAL_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH
$ACTUAL_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
# Install a requirements.txt as root via vetted helper, so packages are visible
# to root-run ledmatrix.service (not just the web interface's own user).
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat >> "$SUDOERS_TMP" << EOF
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
# when its output is a terminal. From that pager (less) a "!sh" is a root
# shell -- the standard journalctl escalation. The web interface always passes
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
# sits in the middle of the command line. NOEXEC stops the command executing
# another program at all, which closes the hole without depending on wildcard
# matching subtleties.
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
EOF
# The rules themselves live in scripts/install/lib_sudoers.sh, shared with
# scripts/install/configure_web_sudo.sh so the two cannot drift apart again.
# If it is missing (a damaged checkout), keep whatever is already installed
# rather than failing the whole install; the gate below skips the install.
SUDOERS_VALID=1
SUDOERS_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_sudoers.sh"
if [ -f "$SUDOERS_LIB" ]; then
# shellcheck source=scripts/install/lib_sudoers.sh
. "$SUDOERS_LIB"
web_sudoers_rules "$ACTUAL_USER" "$PROJECT_ROOT_DIR" "$SYSTEMCTL_PATH" "$BASH_PATH" \
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$SUDOERS_TMP"
else
SUDOERS_VALID=0
echo "⚠ $SUDOERS_LIB not found; cannot generate the sudoers rules." >&2
echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2
echo " the display service until this is fixed." >&2
fi
# Never install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
# headless Pi leaves no way in at all. If the rules do not parse, say so and
# keep whatever is already installed.
SUDOERS_VALID=1
if command -v visudo >/dev/null 2>&1; then
if [ "$SUDOERS_VALID" = "0" ]; then
: # nothing was generated; already reported above
elif command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
SUDOERS_VALID=0
echo "⚠ The generated sudoers rules did not parse:" >&2
@@ -1597,11 +1571,12 @@ echo "-----------------------------------------------------"
if [ -f "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" ]; then
echo "Configuring WiFi management permissions..."
# Run as the actual user (not root) since the script checks for that
sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" || {
if sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh"; then
echo "✓ WiFi management permissions configured"
else
echo "⚠ WiFi permissions configuration failed, but continuing installation"
echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh"
}
echo "✓ WiFi management permissions configured"
fi
else
echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration"
echo " You can configure WiFi permissions later by running:"
@@ -1690,28 +1665,8 @@ fi
# Re-apply plugin directory permissions based on web service user
echo "Re-applying plugin directory permissions..."
# Determine web service user (check installed service, install scripts, or template)
WEB_SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
# Check actual installed service file (most accurate)
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
# Check install_web_service.sh (used by first_time_install.sh)
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="root"
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
fi
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
WEB_SERVICE_USER="$ACTUAL_USER"
fi
# Determine ownership based on web service user
detect_web_service_user
# Set ownership based on web service user
if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then
@@ -1791,10 +1746,11 @@ echo "-------------------------------------"
echo "Removing potential conflicting services (bluetooth and others)..."
if [ "$SKIP_SOUND" = "1" ]; then
echo "Skipping sound module configuration as requested (--skip-sound)."
elif apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio; then
echo "✓ Unnecessary services removed (or not present)"
else
echo "⚠ Some packages could not be removed; continuing"
# apt_remove never fails (it ends in `|| true`); apt itself reports any
# package it could not remove.
apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio
echo "✓ Unnecessary services removed (or not present)"
fi
# Blacklist onboard sound module (idempotent)
@@ -2009,20 +1965,6 @@ if systemctl list-unit-files | grep -q "ledmatrix-wifi-monitor.service"; then
fi
echo ""
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
elif [ "$ASSUME_YES" = "1" ]; then
echo "Non-interactive mode: rebooting now to apply changes..."
reboot
else
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Rebooting now..."
reboot
fi
fi
echo "=========================================="
echo "Installation Complete!"
echo "=========================================="
@@ -2068,7 +2010,7 @@ if command -v nmcli >/dev/null 2>&1; then
if [ -n "$WIFI_STATUS" ]; then
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
if [ "$state" = "connected" ]; then
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1 || true)
if [ -n "$SSID" ]; then
echo " ✓ Connected to: $SSID"
else
@@ -2092,7 +2034,7 @@ echo "AP Mode Status:"
if systemctl is-active --quiet hostapd 2>/dev/null; then
echo " ✓ AP Mode is ACTIVE"
echo " → Connect to WiFi network: LEDMatrix-Setup"
echo " → Password: ledmatrix123"
echo " → Open network, no password"
echo " → Access web UI at: http://192.168.4.1:5000"
AP_MODE_ACTIVE=true
else
@@ -2100,7 +2042,7 @@ else
if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
echo " ✓ AP Mode is ACTIVE (IP detected)"
echo " → Connect to WiFi network: LEDMatrix-Setup"
echo " → Password: ledmatrix123"
echo " → Open network, no password"
echo " → Access web UI at: http://192.168.4.1:5000"
AP_MODE_ACTIVE=true
else
@@ -2202,3 +2144,22 @@ echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
echo ""
echo "Enjoy your LED Matrix display!"
# Reboot last. It used to come before the summary above, so with -y (and
# the one-shot installer, which always passes -y) the reboot was already
# under way while the summary printed, and the SSH session usually dropped
# before any of it -- the web UI address included -- could be read.
echo ""
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
elif [ "$ASSUME_YES" = "1" ]; then
echo "Non-interactive mode: rebooting now to apply changes..."
reboot
else
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Rebooting now..."
reboot
fi
fi
-2
View File
@@ -31,8 +31,6 @@ if args.emulator:
print("Using pygame/RGBMatrixEmulator for display")
print("Press ESC to exit\n")
# Project directory already added above
# Debug output (only in debug mode or emulator mode)
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
if debug_mode:
+59
View File
@@ -0,0 +1,59 @@
# Scripts
Helper scripts for installing, repairing, diagnosing and developing
LEDMatrix. Most users only ever run the one-shot installer (see the project
README); everything else here is for troubleshooting or development.
Status key: **keep** — part of install/runtime or referenced by docs, CI,
tests or code; **dev-only** — for plugin/core development, not needed on a
display; **diagnostic** — run by hand on a Pi when something is wrong.
## Directories
| Directory | Status | What it holds |
|---|---|---|
| [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources |
| [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) |
| [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) |
| [`dev/`](dev/README.md) | dev-only | Plugin linking, emulator runner, Vegas density audit, Pillow smoke test |
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
## Top-level scripts
| Script | Status | What it does |
|---|---|---|
| `build_rgbmatrix_nogil.sh` | keep | Rebuilds the rgbmatrix Python binding so `SwapOnVSync` releases the GIL (docs/SCROLL_PERFORMANCE.md) |
| `check_plugin.py` | dev-only | Renders a plugin across every mode and matrix size and fails on crashes, overflow or golden-image drift |
| `check_release_version.py` | keep | Checks a release tag, CHANGELOG and `src.__version__` agree (release-version-check workflow) |
| `check_system_compatibility.sh` | diagnostic | Pre-install check of hardware, OS (Trixie only), kernel, Python, packages, disk and network |
| `dev_server.py` | dev-only | Browser preview server for plugins without the display loop (http://localhost:5001) |
| `diagnose_dependencies.sh` | diagnostic | Investigates pip installs stuck on "Preparing metadata" |
| `diagnose_web_interface.sh` | diagnostic | Checks why the web interface is not reachable |
| `download_pixlet.sh` | keep | Downloads the bundled Pixlet binaries for Starlark apps (also run from the web UI) |
| `emergency_reconnect.sh` | diagnostic | Reconnects to your WiFi network if captive-portal testing leaves the Pi offline |
| `install_dependencies_apt.py` | keep | Dependency installer that tries apt packages first, then pip (installer Step 7, plugin loader) |
| `install_plugin_dependencies.sh` | diagnostic | Installs plugin requirements by hand when the automatic install fails |
| `prove_security.py` | keep | Security property checks run by pre-commit |
| `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
| `run_plugin_tests.py` | dev-only | Discovers and runs plugin test suites |
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo |
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
## Candidates for removal
Nothing in the repo (docs, CI, tests, other scripts or code) refers to these.
They are kept for now; each one needs an owner decision before it goes.
| Script | What it does |
|---|---|
| `add_defaults_to_schemas.py` | One-off: adds missing `default` values to plugin config schemas |
| `analyze_plugin_schemas.py` | One-off: reports duplicate/inconsistent fields across plugin schemas |
| `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it |
| `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` |
| `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing |
| `test_captive_portal.sh` | Tests the captive portal from a device connected to the AP |
| `verify_wifi_before_testing.sh` | Pre-flight check before unplugging Ethernet to test WiFi |
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
+10 -12
View File
@@ -28,12 +28,12 @@ print_success() {
print_warning() {
echo -e "${YELLOW}⚠${NC} $1"
((WARNINGS++))
WARNINGS=$((WARNINGS + 1))
}
print_error() {
echo -e "${RED}✗${NC} $1"
((COMPATIBILITY_ISSUES++))
COMPATIBILITY_ISSUES=$((COMPATIBILITY_ISSUES + 1))
}
# Check if running on Raspberry Pi
@@ -61,20 +61,18 @@ if [ -f /etc/os-release ]; then
echo "OS: $PRETTY_NAME"
echo "Version ID: ${VERSION_ID:-unknown}"
# first_time_install.sh refuses anything but Raspberry Pi OS / Debian 13
# (Trixie), so anything else is an error here too, not a warning.
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
if [ "${VERSION_ID:-0}" -ge "12" ]; then
print_success "Running compatible Debian/Raspbian version (${VERSION_ID})"
if [ "${VERSION_ID:-0}" -eq "13" ]; then
print_success "Detected Debian 13 Trixie - full compatibility expected"
elif [ "${VERSION_ID:-0}" -eq "12" ]; then
print_success "Detected Debian 12 Bookworm - full compatibility confirmed"
fi
if [ "${VERSION_ID:-0}" = "13" ]; then
print_success "Detected Debian 13 Trixie - supported"
elif [ "${VERSION_ID:-0}" = "12" ]; then
print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
else
print_warning "Old Debian/Raspbian version (${VERSION_ID}) - upgrade recommended"
print_error "Debian/Raspbian ${VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
fi
else
print_warning "Not running Debian/Raspbian - compatibility not guaranteed"
print_error "${ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
fi
else
print_error "Could not detect OS version"
+2
View File
@@ -6,6 +6,8 @@ This directory contains scripts and utilities for development and testing.
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
- **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio)
- **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`)
## Usage
+17 -10
View File
@@ -18,21 +18,26 @@ system user.
permissions on the `assets/` tree so plugins can download and cache
team logos, fonts, and other static content.
- **`fix_cache_permissions.sh`** — Creates (if missing) and fixes
permissions on `/var/cache/ledmatrix/` and `~/.ledmatrix_cache/` of the
user running `sudo`, and creates
`/var/cache/ledmatrix/placeholder_logos/` for the sports plugins. It does
not touch the cache manager's other fallbacks (`/opt/ledmatrix/cache`,
`$TMPDIR/ledmatrix_cache`).
- **`fix_cache_permissions.sh`** — Restores `/var/cache/ledmatrix/` to the
shared `ledmatrix`-group setup by running
`scripts/install/setup_cache.sh` (the same script the installer uses),
and creates/fixes `~/.ledmatrix_cache/` of the user running `sudo`. It
does not touch the cache manager's other fallbacks
(`/opt/ledmatrix/cache`, `$TMPDIR/ledmatrix_cache`).
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
directory so both the root display service and the web service user
can read and write plugin files (manifests, configs, requirements
installs).
- **`fix_web_permissions.sh`** — Fixes permissions on log files,
systemd journal access, and the sudoers entries the web interface
needs to control the display service.
- **`fix_web_permissions.sh`** — Adds you to the `systemd-journal` and
`adm` groups so the web UI can read logs, and makes the project
directory yours again, keeping the root-owned sudo helpers
(`safe_plugin_rm.sh`, `safe_pip_install.sh`) and `config_secrets.json`
the way the installer leaves them. Run it as the web interface's user,
**without** `sudo` (it refuses to run as root and calls `sudo` itself).
It does not write sudoers rules; that is
`scripts/install/configure_web_sudo.sh`.
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
after checking it is the project's own or one under `plugin-repos/` or
@@ -67,7 +72,9 @@ Run these scripts only when:
sudo ./scripts/fix_perms/fix_cache_permissions.sh
sudo ./scripts/fix_perms/fix_assets_permissions.sh
sudo ./scripts/fix_perms/fix_plugin_permissions.sh
sudo ./scripts/fix_perms/fix_web_permissions.sh
# Run as the web interface's user, without sudo (it asks for sudo itself)
./scripts/fix_perms/fix_web_permissions.sh
```
If you're not sure which one you need, run `fix_cache_permissions.sh`
+5 -5
View File
@@ -36,11 +36,12 @@ else
exit 1
fi
# Set permissions to allow read/write for owner, group, and others (for root service user)
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
# 777: read/write for owner, group and every other account. Root (the display
# service) does not need it -- root ignores mode bits -- so the "other" bits
# only matter to accounts that are neither $REAL_USER nor root.
echo "Setting permissions for assets directory..."
if sudo chmod -R 777 "$ASSETS_DIR"; then
echo "✓ Set assets directory permissions to 777 (writable by root service user)"
echo "✓ Set assets directory permissions to 777"
else
echo "✗ Failed to set assets directory permissions"
exit 1
@@ -70,8 +71,7 @@ for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
echo " - Current permissions:"
ls -ld "$FULL_PATH"
# Ensure the directory is writable by both the real user and root (service user)
# Use 777 permissions to allow root (service) to write, or set group ownership
# Owned by the real user; 777 as above (root can write here regardless)
sudo chmod 777 "$FULL_PATH"
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
+35 -59
View File
@@ -1,11 +1,23 @@
#!/bin/bash
# LEDMatrix Cache Permissions Fix Script
# This script fixes permissions on all known cache directories so they're writable by the daemon or current user
# Also sets up placeholder logo directories for sports managers
#
# /var/cache/ledmatrix is shared by the display service (root) and the web
# interface (your user) through the ledmatrix group: root:ledmatrix, 2775,
# files 660. scripts/install/setup_cache.sh is what sets that up (the
# installer's Step 2 runs it, and install_web_service.sh keeps the group), so
# this script runs it rather than applying a model of its own. It used to set
# the directory 777 and re-group it to your own group, replacing the ledmatrix
# group everything else relies on.
#
# It also repairs ~/.ledmatrix_cache, the cache manager's fallback when
# /var/cache/ledmatrix is unusable.
echo "Fixing LEDMatrix cache directory permissions..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SETUP_CACHE="$SCRIPT_DIR/../install/setup_cache.sh"
# Get the real user (not root when running with sudo)
REAL_USER=${SUDO_USER:-$USER}
# Resolve the home directory of the real user robustly
@@ -16,72 +28,36 @@ else
fi
REAL_GROUP=$(id -gn "$REAL_USER")
# Known cache directories for LEDMatrix. Use the actual user's home instead of a hard-coded path.
CACHE_DIRS=(
"/var/cache/ledmatrix"
"$REAL_HOME/.ledmatrix_cache"
)
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
echo ""
echo "Checking cache directory: $CACHE_DIR"
if [ ! -d "$CACHE_DIR" ]; then
echo " - Directory does not exist. Creating it..."
sudo mkdir -p "$CACHE_DIR"
fi
echo " - Current permissions:"
ls -ld "$CACHE_DIR"
echo " - Fixing permissions..."
# Make directory writable by services regardless of user context
sudo chmod 777 "$CACHE_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
echo " - Updated permissions:"
ls -ld "$CACHE_DIR"
echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
else
echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
fi
echo " - Permissions fix complete for $CACHE_DIR."
done
# Set up placeholder logos directory for sports managers
echo ""
echo "Setting up placeholder logos directory for sports managers..."
PLACEHOLDER_DIR="/var/cache/ledmatrix/placeholder_logos"
if [ ! -d "$PLACEHOLDER_DIR" ]; then
echo "Creating placeholder logos directory: $PLACEHOLDER_DIR"
sudo mkdir -p "$PLACEHOLDER_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
sudo chmod 777 "$PLACEHOLDER_DIR"
echo "Checking cache directory: /var/cache/ledmatrix"
if [ -f "$SETUP_CACHE" ]; then
bash "$SETUP_CACHE"
else
echo "Placeholder logos directory already exists: $PLACEHOLDER_DIR"
sudo chmod 777 "$PLACEHOLDER_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
echo " ✗ $SETUP_CACHE not found; /var/cache/ledmatrix left unchanged."
fi
CACHE_DIR="$REAL_HOME/.ledmatrix_cache"
echo ""
echo "Checking cache directory: $CACHE_DIR"
if [ ! -d "$CACHE_DIR" ]; then
echo " - Directory does not exist. Creating it..."
sudo mkdir -p "$CACHE_DIR"
fi
echo " - Current permissions:"
ls -ld "$PLACEHOLDER_DIR"
ls -ld "$CACHE_DIR"
echo " - Fixing permissions..."
sudo chmod 777 "$CACHE_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
echo " - Updated permissions:"
ls -ld "$CACHE_DIR"
echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$PLACEHOLDER_DIR"; then
echo " ✓ Placeholder logos directory is writable by $REAL_USER"
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
else
echo " ✗ Placeholder logos directory is not writable by $REAL_USER"
fi
# Test with daemon user (which the system might run as)
if sudo -u daemon test -w "$PLACEHOLDER_DIR" 2>/dev/null; then
echo " ✓ Placeholder logos directory is writable by daemon user"
else
echo " ✗ Placeholder logos directory is not writable by daemon user"
echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
fi
echo " - Permissions fix complete for $CACHE_DIR."
echo ""
echo "All cache directory permission fixes attempted."
echo "If you still see errors, check which user is running the LEDMatrix service and ensure it matches the owner above."
echo ""
echo "The system will now create placeholder logos in:"
echo " $PLACEHOLDER_DIR"
echo "This should eliminate the permission denied warnings for sports logos."
+6 -5
View File
@@ -51,9 +51,10 @@ fi
echo "Setting ownership to root:$ACTUAL_USER..."
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
# Set directory permissions (775: rwxrwxr-x)
# Root: read/write/execute, Group (ACTUAL_USER): read/write/execute, Others: read/execute
echo "Setting directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
# Set directory permissions (2775: rwxrwsr-x)
# Owner (root) and group (ACTUAL_USER): read/write/execute, others: read/execute.
# The setgid bit makes new entries inherit the ACTUAL_USER group.
echo "Setting directory permissions to 2775 (rwxrwsr-x, setgid)..."
find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \;
# Set file permissions (664: rw-rw-r--)
@@ -71,7 +72,7 @@ fi
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
echo "Setting plugin-repos directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
echo "Setting plugin-repos directory permissions to 2775 (rwxrwsr-x, setgid)..."
find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \;
echo "Setting plugin-repos file permissions to 664..."
@@ -87,7 +88,7 @@ echo "plugin-repos/:"
ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)"
echo ""
echo "Permissions summary:"
echo "- Root service: Can read/write plugins (for PWM hardware access)"
echo "- Root service: Can read/write plugins (as root it needs no permission bits)"
echo "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)"
echo "- Others: Can read plugins"
+51 -5
View File
@@ -25,8 +25,9 @@ echo ""
echo "This script will:"
echo "1. Add the web user to the 'systemd-journal' group for log access"
echo "2. Add the web user to the 'adm' group for additional system access"
echo "3. Configure sudoers for passwordless access to system commands"
echo "4. Set proper file permissions"
echo "3. Make the project directory yours again, keeping the root-owned sudo"
echo " helpers and config_secrets.json as the installer leaves them"
echo " (sudoers rules are configure_web_sudo.sh's job, not this script's)"
echo ""
# Ask for confirmation
@@ -62,6 +63,51 @@ else
echo "✗ Failed to set project ownership"
fi
# The chown above also takes back two kinds of file that first_time_install.sh
# deliberately keeps from the web user. Put them back the way the installer
# leaves them (its Steps 11 and 11.1), whether or not the chown succeeded.
#
# 1. The helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
# (scripts/install/lib_sudoers.sh). A copy the web user owns is a root shell
# for whoever can edit it, so they stay root-owned and writable by root only.
# Keep this list in step with the installer's Step 11.1 loop;
# test/test_web_sudoers_installers_agree.py checks both against the grants.
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
HELPER_PATH="$PROJECT_DIR/scripts/fix_perms/$helper"
if [ -f "$HELPER_PATH" ]; then
if sudo chown root:root "$HELPER_PATH" && sudo chmod 755 "$HELPER_PATH"; then
echo "✓ $helper is root-owned again (sudo runs it as root)"
else
echo "⚠ Could not make $HELPER_PATH root-owned, mode 755."
echo " Fix it by hand: sudo chown root:root $HELPER_PATH && sudo chmod 755 $HELPER_PATH"
fi
fi
done
# 2. config_secrets.json: owned by the account ledmatrix-web.service runs as,
# group ledmatrix, mode 640 -- the same owner, group and mode as the
# installer's Step 11 gives it.
SECRETS_FILE="$PROJECT_DIR/config/config_secrets.json"
if [ -f "$SECRETS_FILE" ]; then
SECRETS_OWNER=""
if [ -f /etc/systemd/system/ledmatrix-web.service ]; then
SECRETS_OWNER=$(grep -m1 "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || true)
fi
SECRETS_OWNER="${SECRETS_OWNER:-$WEB_USER}"
if getent group ledmatrix >/dev/null 2>&1; then
SECRETS_OWNERSHIP="$SECRETS_OWNER:ledmatrix"
else
# No ledmatrix group means the installer never ran; keep the chown's group.
SECRETS_OWNERSHIP="$SECRETS_OWNER"
fi
if sudo chown "$SECRETS_OWNERSHIP" "$SECRETS_FILE" && sudo chmod 640 "$SECRETS_FILE"; then
echo "✓ config_secrets.json restored to $SECRETS_OWNERSHIP, mode 640"
else
echo "⚠ Could not restore $SECRETS_FILE to $SECRETS_OWNERSHIP, mode 640."
echo " Fix it by hand: sudo chown $SECRETS_OWNERSHIP $SECRETS_FILE && sudo chmod 640 $SECRETS_FILE"
fi
fi
# Set proper permissions for config files
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
echo "✓ Set config file permissions"
@@ -86,7 +132,7 @@ echo "Step 5: Testing sudo access..."
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
echo "✓ Sudo access test passed"
else
echo "⚠ Sudo access test failed - you may need to run configure_web_sudo.sh"
echo "⚠ Sudo access test failed - you may need to run scripts/install/configure_web_sudo.sh"
fi
echo ""
@@ -101,5 +147,5 @@ echo ""
echo "After logging back in, test journal access with:"
echo " journalctl --no-pager --lines=5"
echo ""
echo "If you still have sudo issues, run:"
echo " ./configure_web_sudo.sh"
echo "If you still have sudo issues, run (as this user, without sudo):"
echo " $PROJECT_DIR/scripts/install/configure_web_sudo.sh"
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Soak a running display and report how often moving frames reached the panel late.
Runs NEXT TO the display service, as any user: it only reads the stats file the
service writes (src/common/frame_timing.py) at the start and end of the run and
reports the difference. Nothing is stopped, restarted or drawn.
# 10 minutes, as the display is now
python3 scripts/frame_soak.py
# the same with the web preview open (the preview's PNG encodes are one of
# the things that used to make the render loop miss refreshes)
python3 scripts/frame_soak.py --preview
# quick look at the totals since the service started
python3 scripts/frame_soak.py --show
# keep the report for a before/after comparison
python3 scripts/frame_soak.py --duration 600 --json soak-before.json
Exit status: 0 when the late-frame rate is within ``--max-late-pct``, 1 when it
is not, 2 when there was nothing to measure (no stats file, the service
restarted mid-run, or nothing scrolled).
What the numbers mean
---------------------
late frames frames that reached the panel one or more refreshes after they
were due -- the panel showed the previous frame again, which on
a moving strip is a visible hitch. This is the pass/fail number.
freezes gaps of 250ms+ inside a scroll: recomposes, plugin handovers,
blocking calls on the render thread. Reported, not failed on,
since some are handovers between plugins rather than faults.
blit copying the frame into the matrix canvas (rgbmatrix SetImage).
Grows with width x height x pwm_bits.
wait blocked in SwapOnVSync, i.e. slack before the refresh.
work everything else between two frames: drawing, scrolling, and
waiting for the GIL.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.frame_timing import ( # noqa: E402
BUCKET_COUNT,
SCHEMA_VERSION,
default_stats_path,
)
#: Touched by the web UI while someone has the preview open; a fresh marker
#: puts the display service's snapshot writer at full rate. Same path as
#: DisplayManager._viewer_marker_path.
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
#: A stats file not rewritten for this long means nothing is being presented.
STALE_SECONDS = 30.0
def load(path: str) -> Optional[Dict[str, Any]]:
try:
with open(path, encoding="utf-8") as handle:
stats = json.load(handle)
except (OSError, ValueError):
return None
if not isinstance(stats, dict) or stats.get("version") != SCHEMA_VERSION:
return None
return stats
def _histogram(stats: Dict[str, Any], name: str) -> Dict[int, int]:
raw = (stats.get("histograms") or {}).get(name) or {}
return {int(k): int(v) for k, v in raw.items()}
def diff(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
"""What happened between two snapshots of the same process."""
tb, ta = before["totals"], after["totals"]
totals = {}
for key, value in ta.items():
if isinstance(value, dict):
totals[key] = {k: v - tb.get(key, {}).get(k, 0)
for k, v in value.items()}
elif key == "worst_interval_ms":
# A running maximum can't be differenced; it is reported as the
# worst since the service started.
totals[key] = value
else:
totals[key] = value - tb.get(key, 0)
histograms = {}
for name in (after.get("histograms") or {}):
hb, ha = _histogram(before, name), _histogram(after, name)
histograms[name] = {k: v - hb.get(k, 0) for k, v in ha.items()
if v - hb.get(k, 0) > 0}
return {"totals": totals, "histograms": histograms,
"seconds": after["updated"] - before["updated"]}
def percentiles(histogram: Dict[int, int], bucket_ms: float) -> Dict[str, Any]:
"""p50/p95/p99/max from a sparse histogram, as each bucket's upper edge."""
count = sum(histogram.values())
if not count:
return {}
out = {}
targets = {"p50": 0.50, "p95": 0.95, "p99": 0.99}
running = 0
for index in sorted(histogram):
running += histogram[index]
for name, fraction in list(targets.items()):
if running >= fraction * count:
out[name] = _edge(index, bucket_ms)
del targets[name]
out["max"] = _edge(max(histogram), bucket_ms)
return out
def _edge(index: int, bucket_ms: float):
if index >= BUCKET_COUNT - 1:
return f">={index * bucket_ms:g}"
return round((index + 1) * bucket_ms, 2)
def build_report(before, after, preview: bool) -> Dict[str, Any]:
delta = diff(before, after)
totals = delta["totals"]
frames = totals["scroll_frames"]
# The rates are over frames judged against a known refresh period. Stats
# from a recorder that predates the count fall back to every frame.
timed = totals.get("timed_frames", frames) if "timed_frames" in totals else frames
hours = delta["seconds"] / 3600.0 if delta["seconds"] > 0 else 0.0
bucket_ms = after.get("bucket_ms", 0.25)
report = {
"seconds": round(delta["seconds"], 1),
"preview": preview,
"info": after.get("info"),
"binding_releases_gil": after.get("binding_releases_gil"),
"measured_refresh_hz": after.get("measured_refresh_hz"),
"scroll_frames": frames,
"static_frames": totals["static_frames"],
"late_frames": totals["late_frames"],
"timed_frames": timed,
"late_pct": round(100.0 * totals["late_frames"] / timed, 3) if timed else None,
"missed_refreshes": totals["missed_refreshes"],
"late_by": totals["late_by"],
"early_frames": totals.get("early_frames", 0),
"early_pct": (round(100.0 * totals.get("early_frames", 0) / timed, 3)
if timed else None),
"freeze_by": totals.get("freeze_by", {}),
"freezes": totals["freezes"],
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
"freeze_seconds": round(totals["freeze_seconds"], 2),
"worst_interval_ms": (round(totals["worst_interval_ms"], 1)
if totals["worst_interval_ms"] else None),
"timing_ms": {name: percentiles(h, bucket_ms)
for name, h in delta["histograms"].items()},
}
# The rate the panel held while rendering: the typical frame's interval
# per refresh held. A few percent under the idle rate is normal (the Pi is
# bit-banging the panel and pushing frames at once); a widening gap between
# the two is a render-cost regression even when nothing is late.
typical = (report["timing_ms"].get("interval_per_hold") or {}).get("p50")
# percentiles() reports a bucket's upper edge; the midpoint is the better
# estimate, and half a 0.25ms bucket is already ~1% at 100Hz -- the size
# of the idle-vs-held gap this number exists to show.
if isinstance(typical, (int, float)) and typical > bucket_ms / 2:
report["held_refresh_hz"] = round(1000.0 / (typical - bucket_ms / 2), 1)
else:
report["held_refresh_hz"] = None
return report
def print_report(report: Dict[str, Any], limit: float) -> None:
info = report.get("info") or {}
size = "{}x{}".format(
(info.get("cols") or 0) * (info.get("chain_length") or 1),
(info.get("rows") or 0) * (info.get("parallel") or 1))
gil = {True: "releases the GIL", False: "STOCK (holds the GIL in SwapOnVSync)",
None: "unknown"}[report.get("binding_releases_gil")]
print(f"Rig {info.get('pi_model') or 'unknown'}")
print(f"Panel {size} chain {info.get('chain_length')} x parallel "
f"{info.get('parallel')} pwm_bits {info.get('pwm_bits')} "
f"slowdown {info.get('gpio_slowdown')} mapping {info.get('hardware_mapping')}")
print(f"Refresh {report.get('measured_refresh_hz') or '?'} Hz measured, "
f"cap {info.get('limit_refresh_rate_hz')}")
print(f"Binding {gil}")
print(f"Run {report['seconds']:.0f}s, preview "
f"{'open (simulated)' if report['preview'] else 'as-is'}")
print()
frames = report["scroll_frames"]
print(f"Scrolling frames {frames}")
if frames:
late_by = report["late_by"]
print(f"Late frames {report['late_frames']} ({report['late_pct']}%)"
f" missed refreshes {report['missed_refreshes']}"
f" [by 1: {late_by['1']}, 2: {late_by['2']}, "
f"3-5: {late_by['3-5']}, 6+: {late_by['6+']}]")
if report["early_frames"]:
print(f"Early frames {report['early_frames']} "
f"({report['early_pct']}%) swaps returned a refresh early")
print(f"Freezes >=250ms {report['freezes']}"
f" ({report['freezes_per_hour']}/h, {report['freeze_seconds']}s total)"
f" worst gap since start {report['worst_interval_ms'] or '-'} ms")
if report["freezes"]:
print(" by length: " + ", ".join(
f"{k}: {v}" for k, v in report["freeze_by"].items()))
print()
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
for name in ("blit", "wait", "work", "interval_per_hold"):
row = report["timing_ms"].get(name) or {}
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
for k in ("p50", "p95", "p99", "max")))
print()
if report["late_pct"] is None:
print("RESULT nothing scrolled - no verdict")
elif not locked(report, limit):
ceiling = refresh_ceiling(report)
if (report.get("early_pct") or 0.0) > limit:
why = (f"{report['early_pct']}% of frames came a refresh early, so the "
"swaps were not waiting for the panel")
else:
why = (f"frames arrived at {report['measured_refresh_hz']}Hz, faster than "
f"the panel can refresh ({ceiling:g}Hz)")
print(f"RESULT FAIL NOT LOCKED: {why}, and the late count means nothing")
elif report["late_pct"] <= limit:
print(f"RESULT PASS {report['late_pct']}% late <= {limit}%")
else:
print(f"RESULT FAIL {report['late_pct']}% late > {limit}%")
#: How far over the panel's rate frames may arrive before the loop cannot have
#: been waiting for it. The margin covers the refresh wandering a little.
CEILING_MARGIN = 1.05
def refresh_ceiling(report: Dict[str, Any]) -> Optional[float]:
"""The fastest the panel can refresh, as far as this run knows.
The benchmark measures it (``idle_refresh_hz``); the service only knows its
cap. With neither, there is no ceiling to check against.
"""
idle = report.get("idle_refresh_hz")
if idle:
return float(idle)
cap = (report.get("info") or {}).get("limit_refresh_rate_hz")
try:
cap = float(cap)
except (TypeError, ValueError):
return None
return cap if cap > 0 else None
def locked(report: Dict[str, Any], limit: float) -> bool:
"""Whether the loop was paced by the panel at all.
Two ways it is not. Frames a whole refresh early mean some swaps did not
wait. And a loop that never waited at all -- the dirty-tracking skip firing
mid-scroll let one free-run at 827fps -- looks self-consistent to a refresh
estimate taken from its own frames, so nothing registers as early; what
gives it away is a "refresh" faster than the panel can physically do.
"""
if (report.get("early_pct") or 0.0) > limit:
return False
ceiling = refresh_ceiling(report)
measured = report.get("measured_refresh_hz")
return not (ceiling and measured and measured > ceiling * CEILING_MARGIN)
def passed(report: Dict[str, Any], limit: float) -> bool:
return (report["late_pct"] is not None and locked(report, limit)
and report["late_pct"] <= limit)
def touch_marker() -> bool:
try:
with open(VIEWER_MARKER, "a"):
pass
os.utime(VIEWER_MARKER, None)
return True
except OSError:
return False
def wait_for_fresh(path: str, timeout: float) -> Optional[Dict[str, Any]]:
"""The first snapshot written after now, so both ends of the run are exact."""
first = load(path)
deadline = time.time() + timeout
while time.time() < deadline:
current = load(path)
if current and (first is None or current["updated"] != first["updated"]):
return current
time.sleep(0.5)
return None
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--duration", type=float, default=600.0,
help="seconds to soak (default 600)")
parser.add_argument("--preview", action="store_true",
help="keep the web-preview viewer marker fresh, as an "
"open preview tab does")
parser.add_argument("--max-late-pct", type=float, default=0.1,
help="fail above this percentage of late frames (default 0.1)")
parser.add_argument("--stats", default=default_stats_path(),
help="stats file written by the display service")
parser.add_argument("--json", metavar="PATH",
help="also write the report as JSON")
parser.add_argument("--show", action="store_true",
help="print totals since the service started and exit")
args = parser.parse_args(argv)
current = load(args.stats)
if current is None:
print(f"No frame stats at {args.stats}. Is the display service running a "
"build with frame timing, and has anything scrolled for ~10s?",
file=sys.stderr)
return 2
if time.time() - current["updated"] > STALE_SECONDS:
print(f"Frame stats are {time.time() - current['updated']:.0f}s old: nothing "
"has been presented recently (static screen, or the service stopped).",
file=sys.stderr)
if not args.show:
return 2
if args.show:
empty = json.loads(json.dumps(current))
for key, value in empty["totals"].items():
empty["totals"][key] = ({k: 0 for k in value} if isinstance(value, dict)
else 0)
empty["histograms"] = {}
empty["updated"] = current["started"]
report = build_report(empty, current, preview=False)
print_report(report, args.max_late_pct)
return 0
if args.preview and not touch_marker():
print(f"Cannot touch {VIEWER_MARKER}; run as the web service's user to "
"simulate an open preview.", file=sys.stderr)
return 2
print(f"Waiting for a fresh baseline from {args.stats} ...", flush=True)
before = wait_for_fresh(args.stats, timeout=60.0)
if before is None:
print("The stats file stopped updating.", file=sys.stderr)
return 2
end = time.time() + args.duration
next_progress = time.time() + 60.0
while time.time() < end:
if args.preview:
touch_marker()
time.sleep(1.0)
if time.time() >= next_progress:
now = load(args.stats)
if now and now.get("pid") == before["pid"]:
done = now["totals"]["scroll_frames"] - before["totals"]["scroll_frames"]
late = now["totals"]["late_frames"] - before["totals"]["late_frames"]
print(f" {int(end - time.time())}s left: {done} scrolling frames, "
f"{late} late", flush=True)
next_progress += 60.0
after = wait_for_fresh(args.stats, timeout=60.0)
if after is None:
print("The stats file stopped updating during the run.", file=sys.stderr)
return 2
if after.get("pid") != before.get("pid"):
print("The display service restarted during the run; results discarded.",
file=sys.stderr)
return 2
report = build_report(before, after, preview=args.preview)
print()
print_report(report, args.max_late_pct)
if args.json:
with open(args.json, "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2)
if report["late_pct"] is None:
return 2
return 0 if passed(report, args.max_late_pct) else 1
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -19,6 +19,21 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
(the user who runs the script, i.e. the one you installed LEDMatrix as;
there is no `ledmatrix` system user) the passwordless `nmcli` and related
WiFi permissions the web interface needs
- **`install_dns_fix.sh`** - Optional. Installs `ledmatrix-dns-fix.service`,
which adds `options single-request` to the resolver when API calls time
out (see `systemd/README.md`)
- **`install_mqtt_bridge.sh`** - Optional. Installs the Home Assistant MQTT
bridge service (see `integrations/mqtt_bridge/README.md`)
Libraries (sourced, not run):
- **`lib_sudoers.sh`** - The web interface's sudo allow-list
(`/etc/sudoers.d/ledmatrix_web`), shared by `first_time_install.sh` and
`configure_web_sudo.sh`
- **`lib_systemd_render.sh`** - `sed_escape_replacement`, used by every
script that renders a unit from `systemd/*.service`
- **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++
build on low-memory Pis (`first_time_install.sh` Step 6)
## Usage
+44 -64
View File
@@ -26,7 +26,6 @@ fi
# Get the full paths to commands and validate each one
MISSING_CMDS=()
PYTHON_PATH=$(command -v python3) || true
SYSTEMCTL_PATH=$(command -v systemctl) || true
REBOOT_PATH=$(command -v reboot) || true
POWEROFF_PATH=$(command -v poweroff) || true
@@ -35,8 +34,8 @@ JOURNALCTL_PATH=$(command -v journalctl) || true
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)
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
# Validate required commands (systemctl and bash are essential)
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH; do
CMD_VAL="${!CMD_NAME}"
if [ -z "$CMD_VAL" ]; then
MISSING_CMDS+=("$CMD_NAME")
@@ -59,8 +58,17 @@ if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
exit 1
fi
# The rules are shared with first_time_install.sh (Step 10) so the two cannot
# drift apart; add or remove a grant in lib_sudoers.sh, not here.
SUDOERS_LIB="$PROJECT_DIR/lib_sudoers.sh"
if [ ! -f "$SUDOERS_LIB" ]; then
echo "Error: Sudoers rules library not found: $SUDOERS_LIB" >&2
exit 1
fi
# shellcheck source=scripts/install/lib_sudoers.sh
. "$SUDOERS_LIB"
echo "Command paths:"
echo " Python: $PYTHON_PATH"
echo " Systemctl: $SYSTEMCTL_PATH"
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
@@ -69,62 +77,24 @@ echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
echo " Safe plugin rm: $SAFE_RM_PATH"
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
# Create a temporary sudoers file
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
# Create a temporary sudoers file. A predictable name in a world-writable
# directory is a symlink target, and these rules end up in /etc/sudoers.d, so
# let mktemp pick the name; the trap removes it however the script ends.
TEMP_SUDOERS=$(mktemp "${TMPDIR:-/tmp}/ledmatrix_web_sudoers.XXXXXX") || {
echo "Error: could not create a temporary file" >&2
exit 1
}
trap 'rm -f "$TEMP_SUDOERS"' EXIT
{
echo "# LED Matrix Web Interface passwordless sudo configuration"
echo "# This allows the web interface user to run specific commands without a password"
echo ""
echo "# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface"
# Optional: reboot/poweroff (non-critical — skip if not found)
if [ -n "$REBOOT_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
fi
if [ -n "$POWEROFF_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
fi
# Required: systemctl
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
# Optional: journalctl (non-critical — skip if not found)
#
# NOEXEC, matching first_time_install.sh. These rules end in a wildcard and
# journalctl starts a pager, so without it the caller can reach a shell:
# less runs "!command" as the user the pager belongs to, which here is
# root. NOEXEC stops the granted command executing anything of its own.
if [ -n "$JOURNALCTL_PATH" ]; then
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *"
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *"
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *"
fi
echo ""
echo "# Allow web user to remove plugin directories via vetted helper script"
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
echo ""
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
echo "# helper script, so packages are visible to root-run ledmatrix.service"
echo "# (not just the web interface's own user). The helper validates the target"
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
} > "$TEMP_SUDOERS"
web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS"
# Never offer to install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user.
# visudo lives in /usr/sbin, which is not on every user's PATH.
if ! command -v visudo >/dev/null 2>&1 && [ -x /usr/sbin/visudo ]; then
PATH="$PATH:/usr/sbin"
fi
if command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
echo ""
@@ -134,6 +104,8 @@ if command -v visudo >/dev/null 2>&1; then
rm -f "$TEMP_SUDOERS"
exit 1
fi
else
echo "⚠ visudo not found; the rules below have not been validated"
fi
echo ""
@@ -162,7 +134,7 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 0
fi
# Apply the configuration using visudo
# Apply the configuration
echo "Applying sudoers configuration..."
# Harden the helper script: root-owned, not writable by web user
echo "Hardening safe_plugin_rm.sh ownership..."
@@ -181,21 +153,29 @@ if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
fi
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
# sudo reads /etc/sudoers.d files that are root-owned and not writable by
# group or other; 440 is the mode visudo and first_time_install.sh use.
if ! sudo chmod 440 /etc/sudoers.d/ledmatrix_web; then
echo "Warning: could not set mode 440 on /etc/sudoers.d/ledmatrix_web"
fi
echo "Configuration applied successfully!"
echo ""
echo "Testing sudo access..."
# Test a few commands
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
# Ask sudo whether two of the new rules let this user in without a
# password. `sudo -l CMD` answers from the rules without running CMD, so
# this does not depend on whether ledmatrix.service is running, and it
# tests commands the rules actually grant.
if sudo -n -l "$SYSTEMCTL_PATH" status ledmatrix.service > /dev/null 2>&1; then
echo "✓ systemctl status ledmatrix.service - OK"
else
echo "✗ systemctl status ledmatrix.service - Failed"
echo "✗ systemctl status ledmatrix.service - not allowed without a password"
fi
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
echo "✓ File access test - OK"
if sudo -n -l "$BASH_PATH" "$SAFE_RM_PATH" "$PROJECT_ROOT/plugin-repos/example" > /dev/null 2>&1; then
echo "✓ safe_plugin_rm.sh helper - OK"
else
echo "✗ File access test - Failed"
echo "✗ safe_plugin_rm.sh helper - not allowed without a password"
fi
echo ""
+27 -4
View File
@@ -144,6 +144,11 @@ $WEB_USER ALL=(ALL) NOPASSWD: $MKDIR_PATH -p /etc/NetworkManager/dnsmasq-shared.
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
# The same captive-portal DNS drop-in for NetworkManager's shared-mode dnsmasq
# (wifi_manager._write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf),
# exact paths.
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/ledmatrix-nm-dnsmasq.conf /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
EOF
echo "Generated sudoers configuration:"
@@ -151,6 +156,21 @@ echo "--------------------------------"
cat "$TEMP_SUDOERS"
echo "--------------------------------"
# Never install rules we have not parsed. A malformed drop-in in
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
# headless Pi leaves no way in at all. first_time_install.sh and
# configure_web_sudo.sh check their rules the same way.
if command -v visudo >/dev/null 2>&1; then
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
echo "✗ The generated sudoers rules did not parse:" >&2
visudo -c -f "$TEMP_SUDOERS" >&2 || true
echo " Leaving $SUDOERS_FILE unchanged." >&2
exit 1
fi
else
echo "⚠ visudo not found; installing the sudoers rules unvalidated"
fi
# Apply the sudoers configuration
echo ""
echo "Applying sudoers configuration..."
@@ -213,11 +233,14 @@ rm -f "$TEMP_POLKIT"
echo ""
echo "Step 3: Testing permissions..."
# Test sudo access
if sudo -n "$NMCLI_PATH" device status > /dev/null 2>&1; then
echo "✓ nmcli device status - OK"
# Ask sudo whether one of the new rules lets this user in without a password.
# `sudo -l CMD` answers from the rules without running CMD, so the radio is
# left alone. (This used to run `nmcli device status`, which is not granted,
# so it could only ever report a failure.)
if sudo -n -l "$NMCLI_PATH" radio wifi on > /dev/null 2>&1; then
echo "✓ nmcli radio wifi on - OK"
else
echo "✗ nmcli device status - Failed (this is expected if not connected)"
echo "✗ nmcli radio wifi on - not allowed without a password"
fi
echo ""
+6 -1
View File
@@ -10,6 +10,10 @@
set -e
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
# shellcheck source=scripts/install/lib_systemd_render.sh
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
SERVICE_NAME="ledmatrix-dns-fix"
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
@@ -34,7 +38,8 @@ fi
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
echo "Installing $UNIT_DEST..."
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
| $SUDO tee "$UNIT_DEST" > /dev/null
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
+6 -1
View File
@@ -9,6 +9,10 @@
set -e
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
# shellcheck source=scripts/install/lib_systemd_render.sh
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
SERVICE_NAME="ledmatrix-mqtt-bridge"
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
@@ -40,7 +44,8 @@ python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
echo "Installing $UNIT_DEST..."
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
| $SUDO tee "$UNIT_DEST" > /dev/null
$SYSTEMCTL_CMD daemon-reload
+6 -1
View File
@@ -51,20 +51,25 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
# Install packages automatically (no prompt)
# Use apt directly if running as root, otherwise use sudo
PACKAGES_OK=true
if [ "$EUID" -eq 0 ]; then
apt update || echo "⚠ apt update failed, continuing anyway..."
apt install -y "${MISSING_PACKAGES[@]}" || {
PACKAGES_OK=false
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}"
}
else
sudo apt update || echo "⚠ apt update failed, continuing anyway..."
sudo apt install -y "${MISSING_PACKAGES[@]}" || {
PACKAGES_OK=false
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}"
}
fi
echo "✓ Package installation completed"
if [ "$PACKAGES_OK" = true ]; then
echo "✓ Package installation completed"
fi
fi
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
#
# The web interface's passwordless-sudo allow-list, /etc/sudoers.d/ledmatrix_web.
#
# Sourced by first_time_install.sh (Step 10) and
# scripts/install/configure_web_sudo.sh. Both used to carry their own copy of
# these rules, and the copies drifted: one granted safe_pip_install.sh and the
# other did not. Each caller still owns its own validate (visudo -c) / install /
# confirm flow; this file only prints the rules.
#
# Add or remove a grant here and nowhere else.
# web_sudoers_rules WEB_USER PROJECT_ROOT SYSTEMCTL_PATH BASH_PATH REBOOT_PATH POWEROFF_PATH JOURNALCTL_PATH
#
# Print the ledmatrix_web sudoers rules to stdout.
#
# SYSTEMCTL_PATH and BASH_PATH are required, and the caller must make sure they
# are not empty: `visudo -c` does not catch every such rule (with an empty
# BASH_PATH the helper rules still parse, granting the script itself).
# first_time_install.sh stops on a failed `which`; configure_web_sudo.sh checks
# them before calling this.
# REBOOT_PATH, POWEROFF_PATH and JOURNALCTL_PATH are optional: pass "" and
# their rules are left out.
web_sudoers_rules() {
local WEB_USER="${1:-}"
local PROJECT_ROOT="${2:-}"
local SYSTEMCTL_PATH="${3:-}"
local BASH_PATH="${4:-}"
local REBOOT_PATH="${5:-}"
local POWEROFF_PATH="${6:-}"
local JOURNALCTL_PATH="${7:-}"
cat << EOF
# LED Matrix Web Interface passwordless sudo configuration
# This allows the web interface user to run specific commands without a password
# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface
EOF
if [ -n "$REBOOT_PATH" ]; then
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
fi
if [ -n "$POWEROFF_PATH" ]; then
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
fi
cat << EOF
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh *
# Install a requirements.txt as root via vetted helper, so packages are visible
# to root-run ledmatrix.service (not just the web interface's own user).
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *
EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat << EOF
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
# when its output is a terminal. From that pager (less) a "!sh" is a root
# shell -- the standard journalctl escalation. The web interface always passes
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
# sits in the middle of the command line. NOEXEC stops the command executing
# another program at all, which closes the hole without depending on wildcard
# matching subtleties.
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
EOF
fi
}
+4 -3
View File
@@ -2,9 +2,10 @@
#
# Shared helper for rendering systemd unit templates via sed.
#
# Sourced by install_service.sh, install_web_service.sh and
# install_wifi_monitor.sh so all three escape sed replacement text the same
# way instead of carrying three copies of the same fix.
# Sourced by install_service.sh, install_web_service.sh,
# install_wifi_monitor.sh, install_dns_fix.sh and install_mqtt_bridge.sh so
# every unit renderer escapes sed replacement text the same way instead of
# carrying its own copy of the fix.
# sed_escape_replacement VALUE
#
+8 -22
View File
@@ -205,27 +205,6 @@ check_sudo() {
print_success "Sudo access confirmed"
}
# Fix /tmp permissions if needed (common issue when running via curl | bash)
# Note: /tmp permission fixing is now done inline before running first_time_install.sh
# This function is kept for backward compatibility but not actively used
fix_tmp_permissions() {
CURRENT_STEP="TMP directory check"
# Only fix if /tmp is actually not writable (don't preemptively fix)
if [ ! -w /tmp ]; then
print_warning "/tmp is not writable, attempting to fix..."
if [ "$EUID" -eq 0 ]; then
chmod 1777 /tmp 2>/dev/null || true
else
sudo chmod 1777 /tmp 2>/dev/null || true
fi
fi
# Ensure TMPDIR is set correctly
if [ -z "${TMPDIR:-}" ] || [ ! -w "${TMPDIR:-/tmp}" ]; then
export TMPDIR=/tmp
fi
}
# Main installation function
main() {
print_step "LED Matrix One-Shot Installation"
@@ -429,6 +408,13 @@ main() {
print_step "Installation Complete!"
print_success "LED Matrix has been successfully installed!"
echo ""
# first_time_install.sh -y reboots as its last action, so by now the
# reboot is under way (unless LEDMATRIX_SKIP_REBOOT_PROMPT=1 was set).
if [ "${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}" != "1" ]; then
echo "The installer has just started a reboot to finish setup, so this"
echo "session may disconnect now. Give the Pi a few minutes to come back, then:"
echo ""
fi
echo "Next steps:"
echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json"
if command -v hostname >/dev/null 2>&1; then
@@ -449,7 +435,7 @@ main() {
else
echo " 2. Or use the web interface: http://<your-pi-ip>:5000"
fi
echo " 3. Start the service: sudo systemctl start ledmatrix.service"
echo " 3. The display service starts on boot; to start it by hand: sudo systemctl start ledmatrix.service"
echo ""
else
print_error "Main installation script exited with code $INSTALL_EXIT_CODE"
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""Benchmark the render loop against the panel's real refresh rate.
The question this answers is the one that decides whether a rig ships: *does
every frame present on the refresh it was meant to?* It drives the production
path -- a real ``DisplayManager`` and ``ScrollHelper``, the same crisp speed
resolver every ticker uses -- scrolls a synthetic strip for a while, and grades
it with the same frame-timing recorder the display service uses
(``src.common.frame_timing``), printing the same report as
``scripts/frame_soak.py``. A run passes when the loop was genuinely locked to
the panel and no more than ``--max-late-pct`` percent of frames were late.
Where frame_soak.py measures the service as it runs -- live content, plugin
updates, the web preview -- this measures the hardware and the render path
with nothing else in the way, on content that is identical every run. That is
what makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT
against another) and for A/B testing a change to the render path.
# stop the service first; it owns the GPIO
sudo systemctl stop ledmatrix
sudo python3 scripts/render_bench.py # 60s, default speed
sudo python3 scripts/render_bench.py --seconds 600 # the 10-minute gate
sudo python3 scripts/render_bench.py --speed 50 # a slower, held speed
sudo python3 scripts/render_bench.py --busy 2 # with background load
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
sudo systemctl start ledmatrix
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
so a crash here can never leave the panel dark.
Exit status is 0 when the run clears the gate, 1 when it does not, and 2 when
the run could not be set up (no hardware, no root, unusable config) -- so a rig
that cannot be measured is never mistaken for a rig that passed.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import threading
import time
import zlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import frame_timing, scroll_config # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_soak # noqa: E402 (same report, same verdict as the soak)
REPO = Path(__file__).resolve().parent.parent
CONFIG = REPO / "config" / "config.json"
#: Long enough to average out a scheduler hiccup, short enough that nobody
#: skips running it. The shipping gate is --seconds 600.
DEFAULT_SECONDS = 60.0
#: Seconds spent timing bare swaps before the scroll starts. The measurement
#: has to settle, but every second here is a second not scrolling.
MEASURE_SECONDS = 4.0
#: Scrolling discarded before the graded run starts: the first frames carry
#: first-touch costs and the scrolling state settling.
WARMUP_SECONDS = 2.0
def load_config() -> dict:
"""The config the display service would run with."""
try:
from src.config_manager import ConfigManager
config = ConfigManager().config
if isinstance(config, dict) and config:
return config
except Exception as exc: # noqa: BLE001 - any failure means use the plain read
print(f"ConfigManager unavailable ({exc}); reading {CONFIG} directly",
file=sys.stderr)
# ConfigManager pulls in a lot; a plain read is enough to drive the panel
# and keeps the benchmark usable on a half-installed machine.
try:
with open(CONFIG, encoding="utf-8") as handle:
config = json.load(handle)
except (OSError, ValueError) as exc:
sys.exit(f"could not read {CONFIG}: {exc}")
if not isinstance(config, dict):
sys.exit(f"{CONFIG} is not a config object")
return config
def build_strip(width: int, height: int, label: str):
"""A marquee strip a few screens wide, with text and colour.
Deliberately not plain white text on black: how long ``SetImage`` takes
depends on how many subpixels are lit, so a strip that is mostly dark
flatters the panel and hides exactly the regression this benchmark exists
to catch.
"""
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
font = None
for path, size in (
(str(REPO / "assets/fonts/PressStart2P-Regular.ttf"), max(8, height // 4)),
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", max(10, height // 2)),
):
try:
font = load_truetype(path, size)
break
except OSError:
continue
if font is None:
font = ImageFont.load_default()
text = f" {label} *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG *** "
probe = ImageDraw.Draw(Image.new("RGB", (8, 8)))
box = probe.textbbox((0, 0), text, font=font)
text_width = max(1, box[2] - box[0])
text_height = box[3] - box[1]
reps = max(2, (width * 4) // text_width + 1)
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
draw = ImageDraw.Draw(strip)
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
palette = [(255, 210, 60), (80, 200, 255), (255, 90, 90), (140, 255, 140)]
for i in range(reps):
left = i * text_width
# A filled block per repeat, so a meaningful share of the strip is lit.
draw.rectangle(
[left + 4, height - 4, left + text_width - 4, height - 2],
fill=palette[i % len(palette)],
)
draw.text((left, (height - text_height) // 2 - box[1]), text,
font=font, fill=palette[(i + 1) % len(palette)])
return strip
class BackgroundLoad:
"""Threads that imitate plugins updating while the panel scrolls.
Not a simulation of any particular plugin -- it is the shape of the work
that competes with the render loop for the GIL: decoding JSON, resizing an
image, compressing bytes. A render loop that only holds its pacing on an
idle machine is not shippable, and this is how that shows up.
"""
def __init__(self, workers: int) -> None:
self.workers = max(0, workers)
self._stop = threading.Event()
self._threads: list = []
def __enter__(self) -> "BackgroundLoad":
for index in range(self.workers):
thread = threading.Thread(
target=self._run, args=(index,), name=f"bench-load-{index}", daemon=True)
thread.start()
self._threads.append(thread)
return self
def __exit__(self, *exc_info) -> None:
self._stop.set()
for thread in self._threads:
thread.join(timeout=2.0)
def _run(self, index: int) -> None:
from PIL import Image
payload = json.dumps({"games": [{"id": n, "score": [n, n + 1],
"name": f"team {n}"} for n in range(200)]})
image = Image.new("RGB", (256, 64), (12, 34, 56))
while not self._stop.wait(0.25 + 0.05 * index):
json.loads(payload)
image.resize((128, 32), Image.LANCZOS)
zlib.compress(image.tobytes(), 1)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--seconds", type=float, default=DEFAULT_SECONDS,
help=f"how long to scroll for (default {DEFAULT_SECONDS:.0f}; "
"the shipping gate is 600)")
parser.add_argument("--speed", type=float, default=None,
help="requested px/s; snapped to the nearest speed the "
"panel can show in whole pixels (default: one pixel "
"per refresh)")
parser.add_argument("--hz", type=float, default=None,
help="skip the idle measurement and take this as the "
"panel's rate (for reproducing a rig's numbers)")
parser.add_argument("--busy", type=int, default=0, metavar="N",
help="run N background workers imitating plugin updates")
parser.add_argument("--max-late-pct", "--max-missed", dest="max_late_pct",
type=float, default=0.1, metavar="PCT",
help="fail above this percentage of late frames (default 0.1)")
parser.add_argument("--json", dest="json_path", default=None, metavar="PATH",
help="also write the report as JSON, for comparing rigs")
parser.add_argument("--label", default=None,
help="name for this run in the JSON report (default: hostname)")
args = parser.parse_args(argv)
# Everything the display service logs would otherwise land in the middle of
# the report; the benchmark's own output is the point. The stall watchdog
# is the exception: a stack dump naming what held a frame up belongs here.
logging.basicConfig(level=logging.ERROR, stream=sys.stderr)
logging.getLogger("src.common.frame_timing").setLevel(logging.WARNING)
if hasattr(os, "geteuid") and os.geteuid() != 0:
print("this needs root for GPIO access - rerun with sudo", file=sys.stderr)
return 2
config = load_config()
from src.common.scroll_helper import ScrollHelper
from src.display_manager import DisplayManager
try:
display = DisplayManager(config, suppress_test_pattern=True)
except Exception as exc:
print(f"could not open the display ({exc}).\n"
"If the display service is running it owns the GPIO - stop it "
"first:\n sudo systemctl stop ledmatrix", file=sys.stderr)
return 2
if getattr(display, "matrix", None) is None:
print("the display came up in fallback mode - there is no panel here to "
"measure, and a software loop's frame times say nothing about "
"vsync. Run this on a rig.", file=sys.stderr)
return 2
width, height = display.width, display.height
if args.hz is not None:
idle_hz = float(args.hz)
print(f"taking the panel's rate as {idle_hz:.1f}Hz (given, not measured)")
else:
print(f"measuring the panel for {MEASURE_SECONDS:.0f}s...", flush=True)
idle_hz = frame_timing.measure_refresh_hz(display.matrix, MEASURE_SECONDS)
if idle_hz <= 0:
print("the panel did not answer a swap; cannot measure it",
file=sys.stderr)
return 2
cap = scroll_config.refresh_hz_from_config(config)
note = (f" (cap is {cap:.0f}Hz)" if idle_hz < cap * 0.98
else " (at its configured cap)")
print(f"panel refreshes at {idle_hz:.1f}Hz{note}")
requested = args.speed if args.speed else idle_hz
# Configured through the shared resolver rather than by setting the helper
# up by hand, so the benchmark measures the engine every ticker runs on. A
# speed the bench reached some other way would be measuring something no
# plugin does.
helper = ScrollHelper(width, height)
settings = scroll_config.configure(
helper,
plugin_config={"scroll_pixels_per_second": requested},
global_config=config,
refresh_hz=idle_hz,
display_manager=display,
)
choice = settings.crisp
if choice is None:
print("the resolver did not snap to a whole-pixel speed; nothing to "
"grade against", file=sys.stderr)
return 2
print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
helper.set_sub_pixel_scrolling(False)
helper.set_scrolling_image(
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s"))
# The display service's own recorder, owned outright here: never flushed to
# the service's stats file, drained exactly at the start and end of the
# graded run, and seeded with the idle rate so a loop that never locked
# (free-running, or stuck at a fraction of the refresh) shows as early or
# late frames instead of looking self-consistent.
recorder = frame_timing.FrameTimingRecorder(
flush_interval=float("inf"),
info=display._frame_timing_info(), # pylint: disable=protected-access
refresh_hz=idle_hz,
)
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
display.frame_timing = recorder
print(f"scrolling {width}x{height} for {args.seconds:.0f}s"
+ (f" with {args.busy} background worker(s)" if args.busy else "")
+ " ...", flush=True)
frames = 0
duplicates = 0
blanks = 0
restarts = 0
last_column = None
before = None
started = time.perf_counter()
run_started = None
try:
with BackgroundLoad(args.busy):
while True:
now = time.perf_counter()
if run_started is None and now - started >= WARMUP_SECONDS:
recorder.drain()
before = recorder.snapshot()
run_started = now
frames = duplicates = blanks = restarts = 0
if run_started is not None and now - run_started >= args.seconds:
break
helper.update_scroll_position()
if helper.is_scroll_complete():
# The helper parks at the end of the strip and stops
# advancing, exactly as it does under a plugin -- which
# then hands over to the next one. Here there is nothing
# to hand over to, so start the strip again. Without this
# the benchmark measures a still image for the rest of the
# run and reports a smoothness it never demonstrated.
helper.reset_scroll()
restarts += 1
visible = helper.get_visible_portion()
column = int(helper.scroll_position)
if column == last_column:
duplicates += 1
last_column = column
if visible is None:
blanks += 1
else:
display.image.paste(visible, (0, 0))
# Every frame, not once before the loop. The scrolling state
# expires on its own inactivity threshold and takes the frame
# hold with it, so a scroll that announces itself once is
# presented at the wrong rate for all but its first moments --
# and its unchanged frames start taking the dirty-tracking
# skip, which returns without waiting for the panel at all.
# Every ticker re-announces per frame; so does this.
display.set_scrolling_state(True, frame_hold=choice.frame_hold)
display.update_display()
frames += 1
except KeyboardInterrupt:
print("\ninterrupted - reporting what was measured so far")
finally:
display.set_scrolling_state(False)
try:
display.clear()
except Exception as exc: # noqa: BLE001 - a lit panel is harmless; say so and go on
print(f"could not blank the panel: {exc}", file=sys.stderr)
if before is None:
print("interrupted during warm-up; nothing was graded", file=sys.stderr)
return 2
recorder.drain()
report = frame_soak.build_report(before, recorder.snapshot(), preview=False)
report["idle_refresh_hz"] = round(idle_hz, 2)
print()
frame_soak.print_report(report, args.max_late_pct)
held = report.get("held_refresh_hz")
if held:
drop = 100.0 * (idle_hz - held) / idle_hz
print(f"\npanel held ~{held:.1f}Hz while rendering, {drop:.1f}% below its "
f"{idle_hz:.1f}Hz idle rate (a widening gap is a render-cost "
"regression even with nothing late)")
if duplicates:
# A frame that shows the same columns as the one before it is work the
# panel did not need. It is not a miss -- the frame arrived on time --
# but it means the loop is presenting faster than the strip is moving.
print(f"duplicate {duplicates} frames advanced no pixels "
f"({100.0 * duplicates / max(1, frames):.2f}%)")
if blanks:
print(f"blank {blanks} frames had no visible slice to draw")
if restarts:
print(f"restarts {restarts} (the strip was scrolled through "
f"{restarts} time{'s' if restarts != 1 else ''})")
if args.json_path:
report.update({
"label": args.label or os.uname().nodename,
"bench": True,
"requested_pixels_per_second": requested,
"pixels_per_second": choice.pixels_per_second,
"pixels_per_frame": choice.pixels_per_frame,
"frame_hold": choice.frame_hold,
"busy_workers": args.busy,
"duplicate_frames": duplicates,
"blank_frames": blanks,
"strip_restarts": restarts,
"max_late_pct": args.max_late_pct,
"passed": frame_soak.passed(report, args.max_late_pct),
})
Path(args.json_path).write_text(json.dumps(report, indent=2) + "\n",
encoding="utf-8")
print(f"\nwrote {args.json_path}")
if report["late_pct"] is None:
return 2
return 0 if frame_soak.passed(report, args.max_late_pct) else 1
if __name__ == "__main__":
sys.exit(main())
+6 -13
View File
@@ -42,7 +42,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import scroll_config # noqa: E402
from src.common import frame_timing, scroll_config # noqa: E402
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
@@ -99,20 +99,13 @@ def open_matrix(config, refresh_override=None):
def measure_refresh(config, seconds=6.0):
"""Actual refresh rate, by running uncapped and timing the swaps.
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
runs at exactly the panel's rate. This is what an older Pi or a longer
chain will really give you, as opposed to whatever limit_refresh_rate_hz
optimistically asks for.
What an older Pi or a longer chain will really give you, as opposed to
whatever limit_refresh_rate_hz optimistically asks for. The timing loop
itself lives in src.common.frame_timing so the benchmark grades against
the same measurement this ladder is built from.
"""
matrix = open_matrix(config, refresh_override=0)
canvas = matrix.CreateFrameCanvas()
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
frames = 0
started = time.perf_counter()
while time.perf_counter() - started < seconds:
canvas = matrix.SwapOnVSync(canvas)
frames += 1
measured = frames / (time.perf_counter() - started)
measured = frame_timing.measure_refresh_hz(matrix, seconds)
matrix.Clear()
return measured
+1
View File
@@ -9,6 +9,7 @@ This directory contains utility scripts for maintenance and system operations.
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
- **`auto_update_verify.py`** - Health check after an automatic update, rolling back if it fails (the updater copies it to `data/` before pulling and `ledmatrix-update-verify.service` runs that copy)
## Usage
+3 -7
View File
@@ -29,13 +29,9 @@ from typing import Any, Optional, Tuple
from PIL import Image
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies).
try:
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
RESAMPLE_NEAREST = Image.Resampling.NEAREST
except AttributeError: # Pillow < 9.1
RESAMPLE_LANCZOS = Image.LANCZOS
RESAMPLE_NEAREST = Image.NEAREST
# Re-exported by src.common for plugins, which import them from there.
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
RESAMPLE_NEAREST = Image.Resampling.NEAREST
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
+23 -61
View File
@@ -26,6 +26,7 @@ from enum import Enum
from concurrent.futures import ThreadPoolExecutor
import pytz
from src.cache_manager import CacheManager
from src.common.json_body import response_json
from src.common.espn_dates import (
RANGE_RETRY_SECONDS,
_note_range_rejected,
@@ -130,19 +131,14 @@ class BackgroundDataService:
# Thread management
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
# cache_key -> request_id for fetches currently in flight. Submitting
# the same key twice used to start two identical fetches: request_id
# carries a millisecond timestamp, so every submit looked new, and
# active_requests is keyed by it rather than by what is being fetched.
# On a real board the season-schedule key is requested by both the
# Recent and the Upcoming manager, which miss the cache in the same
# millisecond and each download and parse the same payload.
# cache_key -> request_id for fetches currently in flight, so a second
# submit for the same key joins the running fetch instead of starting
# another. It is the normal case: a sport's Recent and Upcoming
# managers miss the cache for the same season schedule together.
self._inflight_by_cache_key: Dict[str, str] = {}
# request_id was sport_year_milliseconds, which is not unique: two
# submits inside the same millisecond produced the SAME id, so one
# silently replaced the other in active_requests and completed_requests.
# Rare before, but dedupe hands this id back to every joiner as their
# handle for get_result(), so it has to be unique. A counter is enough.
# Makes every request_id unique. The id also carries a millisecond
# timestamp, but two submits can share a millisecond, and a joiner
# uses the id as its handle for get_result().
self._request_seq = itertools.count()
self.active_requests: Dict[str, FetchRequest] = {}
self.completed_requests: Dict[str, FetchResult] = {}
@@ -185,9 +181,9 @@ class BackgroundDataService:
This ensures Recent/Upcoming managers and background service
use the same cache keys.
"""
# Same format as CacheManager.generate_sport_cache_key(). This used to
# build a whole CacheManager to call it -- config load, cache-dir
# probing with test writes -- on every submit without a cache_key.
# Same format as CacheManager.generate_sport_cache_key(), built here
# rather than by constructing a CacheManager (config load, cache-dir
# probing) on every submit without a cache_key.
if date_str is None:
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
return f"{sport}_{date_str}"
@@ -330,10 +326,8 @@ class BackgroundDataService:
try:
with self._lock:
# A request cancelled while it sat in the executor queue must
# stay cancelled. Overwriting the status here undid the cancel
# outright: the worker went on to download, cache and call back
# for work the caller had already withdrawn.
# A request cancelled while it sat in the executor queue stays
# cancelled: no download, no cache write, no callback.
if request.status == FetchStatus.CANCELLED:
cancelled_before_start = True
else:
@@ -389,7 +383,7 @@ class BackgroundDataService:
response.raise_for_status()
else:
response.raise_for_status()
data = response.json()
data = response_json(response)
# Validate data structure
if not isinstance(data, dict):
@@ -462,10 +456,9 @@ class BackgroundDataService:
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
with self._lock:
# Don't relabel a cancelled request. The callback gate in the
# finally block only suppresses CANCELLED, so promoting it to
# FAILED here delivered an error callback for a fetch nobody
# was waiting on any more.
# A cancelled request stays CANCELLED even when its fetch
# failed: the finally block skips callbacks only for
# CANCELLED, and nobody is waiting on this fetch any more.
if request.status != FetchStatus.CANCELLED:
request.status = FetchStatus.FAILED
request.error = error_msg
@@ -525,20 +518,13 @@ class BackgroundDataService:
except Exception as e:
logger.error(f"Error in callback for request {request.id}: {e}")
# Released AFTER the loop, not inside it. Every callback here holds
# the same FetchResult, so releasing per-delivery handed the first
# one the data and every joiner `result.data is None` -- which is
# not a quiet degradation: they read `result.data.get('events')` and
# raise AttributeError, which this very loop catches and logs, so
# the symptom was one ERROR line and a manager that silently never
# got its schedule. Deduplication is the normal case, not a corner:
# a sport's recent, upcoming and live managers all ride one season
# fetch.
# Released after the loop, never inside it: every callback holds
# the same FetchResult (a sport's recent, upcoming and live
# managers usually share one fetch), so a release between
# deliveries would hand the later ones `result.data is None`.
#
# Guarded on `callbacks`, because a request submitted without one
# has no other way to collect its payload than polling get_result().
# The old per-delivery release got that right by accident: an empty
# list never entered the loop body.
# Only when there were callbacks: a request submitted without one
# collects its payload by polling get_result().
if callbacks:
self._release_payload(result)
request.result = None
@@ -720,9 +706,6 @@ class BackgroundDataService:
'completed_requests_count': len(self.completed_requests),
'max_completed_requests': self._max_completed_requests,
'completed_requests_usage_percent': (len(self.completed_requests) / self._max_completed_requests * 100) if self._max_completed_requests > 0 else 0,
# Nothing is queued outside the executor; kept for callers
# that read the key.
'queue_size': 0,
'last_cleanup': self._last_completed_requests_cleanup,
'cleanup_interval': self._completed_requests_cleanup_interval
}
@@ -792,27 +775,6 @@ class BackgroundDataService:
return removed_count
def clear_completed_requests(self, older_than_hours: int = 24):
"""
Clear completed requests older than specified time.
Args:
older_than_hours: Clear requests older than this many hours
"""
cutoff_time = time.time() - (older_than_hours * 3600)
with self._lock:
to_remove = []
for request_id, result in self.completed_requests.items():
if result.completed_at < cutoff_time:
to_remove.append(request_id)
for request_id in to_remove:
del self.completed_requests[request_id]
if to_remove:
logger.info(f"Cleared {len(to_remove)} old completed requests")
def shutdown(self, wait: bool = True):
"""
Shutdown the background data service.
+71 -106
View File
@@ -83,14 +83,25 @@ BUNDLED_FONTS: frozenset[str] = frozenset({
_CONFIG_REL = Path("config/config.json")
_SECRETS_REL = Path("config/config_secrets.json")
_WIFI_REL = Path("config/wifi_config.json")
# Sits in config/ next to the three above and is pure user state — a
# YouTube Music session that has to be re-authenticated by hand if lost.
# It was omitted from backups, so a restore silently signed the user out.
# A YouTube Music session: pure user state that has to be re-authenticated by
# hand if lost, so a restore must bring it back.
_YTM_REL = Path("config/ytm_auth.json")
_FONTS_REL = Path("assets/fonts")
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
_STATE_REL = Path("data/plugin_state.json")
#: The sections that are one file each: (section name, path, the
#: RestoreOptions flag that restores it). create, preview, validate and
#: restore all walk this table. ytm_auth follows restore_wifi: it is
#: device-local auth like the Wi-Fi settings, and a toggle of its own for one
#: file would be noise in the restore dialog.
_SINGLE_FILE_SECTIONS: Tuple[Tuple[str, Path, str], ...] = (
("config", _CONFIG_REL, "restore_config"),
("secrets", _SECRETS_REL, "restore_secrets"),
("wifi", _WIFI_REL, "restore_wifi"),
("ytm_auth", _YTM_REL, "restore_wifi"),
)
MANIFEST_NAME = "manifest.json"
PLUGINS_MANIFEST_NAME = "plugins.json"
@@ -140,34 +151,18 @@ class RestoreResult:
# ---------------------------------------------------------------------------
def _ledmatrix_version(project_root: Path) -> str:
"""Best-effort version string for the current install."""
version_file = project_root / "VERSION"
if version_file.exists():
try:
return version_file.read_text(encoding="utf-8").strip() or "unknown"
except OSError:
pass
head_file = project_root / ".git" / "HEAD"
if head_file.exists():
try:
head = head_file.read_text(encoding="utf-8").strip()
if head.startswith("ref: "):
ref = head[5:]
ref_path = project_root / ".git" / ref
if ref_path.exists():
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
return head[:12] or "unknown"
except OSError:
pass
return "unknown"
def _ledmatrix_version() -> str:
"""The release of the running core (``src.__version__``), recorded in the
manifest so a restore can tell which release wrote the backup."""
from src import __version__
return __version__
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
def _build_manifest(contents: List[str]) -> Dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"ledmatrix_version": _ledmatrix_version(project_root),
"ledmatrix_version": _ledmatrix_version(),
"hostname": socket.gethostname(),
"contents": contents,
}
@@ -178,13 +173,34 @@ def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
# ---------------------------------------------------------------------------
def _plugins_directory(project_root: Path) -> Path:
"""The plugin install directory: ``plugin_system.plugins_directory`` from
config/config.json (relative to ``project_root`` unless absolute), or
``plugin-repos`` when the config does not say or cannot be read."""
configured: Any = None
try:
with (project_root / _CONFIG_REL).open("r", encoding="utf-8") as f:
config = json.load(f)
if isinstance(config, dict):
plugin_system = config.get("plugin_system")
if isinstance(plugin_system, dict):
configured = plugin_system.get("plugins_directory")
except (OSError, json.JSONDecodeError):
pass
if not isinstance(configured, str) or not configured.strip():
configured = "plugin-repos"
path = Path(configured)
return path if path.is_absolute() else project_root / path
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
"""
Return a list of currently-installed plugins suitable for the backup
manifest. Each entry has ``plugin_id`` and ``version``.
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
directory and reads each ``manifest.json``.
Reads ``data/plugin_state.json`` if present, then adds any plugin it
does not list from the ``manifest.json`` files in the configured plugin
directory (see :func:`_plugins_directory`).
"""
plugins: Dict[str, Dict[str, Any]] = {}
@@ -206,8 +222,7 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
except (OSError, json.JSONDecodeError) as e:
logger.warning("Could not read plugin_state.json: %s", e)
# Fall back to scanning plugin-repos/ for manifests.
plugins_root = project_root / "plugin-repos"
plugins_root = _plugins_directory(project_root)
if plugins_root.exists():
for entry in sorted(plugins_root.iterdir()):
if not entry.is_dir():
@@ -298,19 +313,10 @@ def create_backup(
tmp_path = zip_path.with_suffix(".zip.tmp")
try:
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# Config files.
if (project_root / _CONFIG_REL).exists():
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
contents.append("config")
if (project_root / _SECRETS_REL).exists():
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
contents.append("secrets")
if (project_root / _WIFI_REL).exists():
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
contents.append("wifi")
if (project_root / _YTM_REL).exists():
zf.write(project_root / _YTM_REL, _YTM_REL.as_posix())
contents.append("ytm_auth")
for section, rel, _flag in _SINGLE_FILE_SECTIONS:
if (project_root / rel).exists():
zf.write(project_root / rel, rel.as_posix())
contents.append(section)
# User-uploaded fonts.
user_fonts = iter_user_fonts(project_root)
@@ -338,7 +344,7 @@ def create_backup(
contents.append("plugins")
# Manifest goes last so that `contents` reflects what we actually wrote.
manifest = _build_manifest(contents, project_root)
manifest = _build_manifest(contents)
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
os.replace(tmp_path, zip_path)
@@ -352,15 +358,16 @@ def create_backup(
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
"""Return a summary of what ``create_backup`` would include."""
project_root = Path(project_root).resolve()
return {
"has_config": (project_root / _CONFIG_REL).exists(),
"has_secrets": (project_root / _SECRETS_REL).exists(),
"has_wifi": (project_root / _WIFI_REL).exists(),
"has_ytm_auth": (project_root / _YTM_REL).exists(),
preview: Dict[str, Any] = {
f"has_{section}": (project_root / rel).exists()
for section, rel, _flag in _SINGLE_FILE_SECTIONS
}
preview.update({
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
"plugin_uploads": len(iter_plugin_uploads(project_root)),
"plugins": list_installed_plugins(project_root),
}
})
return preview
# ---------------------------------------------------------------------------
@@ -431,15 +438,10 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
{},
)
detected: List[str] = []
if _CONFIG_REL.as_posix() in names:
detected.append("config")
if _SECRETS_REL.as_posix() in names:
detected.append("secrets")
if _WIFI_REL.as_posix() in names:
detected.append("wifi")
if _YTM_REL.as_posix() in names:
detected.append("ytm_auth")
detected: List[str] = [
section for section, rel, _flag in _SINGLE_FILE_SECTIONS
if rel.as_posix() in names
]
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
detected.append("fonts")
if any(
@@ -584,55 +586,18 @@ def restore_backup(
result.errors.append("Failed to extract backup")
return result
# Main config.
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
for section, rel, flag in _SINGLE_FILE_SECTIONS:
if not (tmp_dir / rel).exists():
continue
if not getattr(options, flag):
result.skipped.append(section)
continue
try:
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
result.restored.append("config")
_copy_file(tmp_dir / rel, project_root / rel)
result.restored.append(section)
except OSError as e:
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True)
result.errors.append("Failed to restore config.json")
elif (tmp_dir / _CONFIG_REL).exists():
result.skipped.append("config")
# Secrets.
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
try:
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
result.restored.append("secrets")
except OSError as e:
logger.error(
"[Backup] Failed to restore config_secrets.json: %s", e, exc_info=True
)
result.errors.append("Failed to restore config_secrets.json")
elif (tmp_dir / _SECRETS_REL).exists():
result.skipped.append("secrets")
# WiFi.
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
try:
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
result.restored.append("wifi")
except OSError as e:
logger.error(
"[Backup] Failed to restore wifi_config.json: %s", e, exc_info=True
)
result.errors.append("Failed to restore wifi_config.json")
elif (tmp_dir / _WIFI_REL).exists():
result.skipped.append("wifi")
# YouTube Music session. Follows restore_wifi rather than getting its
# own flag: it is device-local auth in the same sense, and a separate
# toggle for one file would be noise in the restore dialog.
if options.restore_wifi and (tmp_dir / _YTM_REL).exists():
try:
_copy_file(tmp_dir / _YTM_REL, project_root / _YTM_REL)
result.restored.append("ytm_auth")
except OSError as e:
logger.error("[Backup] Failed to restore ytm_auth.json: %s", e, exc_info=True)
result.errors.append("Failed to restore ytm_auth.json")
elif (tmp_dir / _YTM_REL).exists():
result.skipped.append("ytm_auth")
logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
result.errors.append(f"Failed to restore {rel.name}")
# User fonts — skip anything that collides with a bundled font.
tmp_fonts = tmp_dir / _FONTS_REL
+10 -21
View File
@@ -18,6 +18,8 @@ import requests
import json
from typing import Dict, Any, Optional, List
from src.common.api_helper import DEFAULT_HTTP_HEADERS
class BaseOddsManager:
"""
@@ -45,22 +47,15 @@ class BaseOddsManager:
self.logger = logging.getLogger(__name__)
self.base_url = "https://sports.core.api.espn.com/v2/sports"
# This path used a bare requests.get, so it identified itself as
# python-requests/x.y -- the one thing ESPN is known to reject. Around
# 2026-08-04 it began 403ing browser strings and bare custom tokens
# alike; what it accepts is a token with a URL that says who is
# calling. Every other ESPN caller in the tree already sends this
# (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost
# the caller its whole update budget.
# Core's shared headers: ESPN rejects requests' default User-Agent
# (see api_helper.USER_AGENT), and a rejected odds request costs the
# calling plugin its update budget.
#
# Deliberately no retry adapter, unlike api_helper: retries multiply
# request_timeout, which is set to 5s precisely to stay inside that
# budget. One try, then the cooldown below.
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'Accept': 'application/json',
})
self.session.headers.update(DEFAULT_HTTP_HEADERS)
# Configuration with defaults
self.update_interval = 3600 # 1 hour default
@@ -72,7 +67,6 @@ class BaseOddsManager:
self.request_timeout = 5
# Set when a request fails; until then, skip the network entirely.
self._skip_network_until = 0.0
self.cache_ttl = 1800 # 30 minutes default
# Load configuration if available
if config_manager:
@@ -89,12 +83,10 @@ class BaseOddsManager:
self.update_interval = odds_config.get('update_interval', self.update_interval)
self.request_timeout = odds_config.get('timeout', self.request_timeout)
self.cache_ttl = odds_config.get('cache_ttl', self.cache_ttl)
self.logger.debug(f"BaseOddsManager configuration loaded: "
f"update_interval={self.update_interval}s, "
f"timeout={self.request_timeout}s, "
f"cache_ttl={self.cache_ttl}s")
f"timeout={self.request_timeout}s")
except Exception as e:
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
@@ -172,15 +164,12 @@ class BaseOddsManager:
odds_data = self._extract_espn_data(raw_data)
if odds_data:
self.logger.info(f"Successfully extracted odds data: {odds_data}")
else:
self.logger.debug("No odds data available for this game")
if odds_data:
self.cache_manager.set(cache_key, odds_data, ttl=interval)
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
else:
self.logger.debug(f"No odds data available for {cache_key}")
# Cache the fact that no odds are available to avoid repeated API calls
# Cache the absence too, so the game is not re-requested
# on every update until the interval passes.
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
return odds_data
+43
View File
@@ -7,6 +7,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
import json
import math
import os
import re
import stat
import time
import tempfile
@@ -98,6 +99,40 @@ def _replace_nonfinite(obj: Any) -> Any:
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
#: Enough of a record to hold its header: ``{"timestamp":<float>,"ttl":<n>,``.
_HEAD_BYTES = 256
#: A record written with its header first (CacheManager.set does). Anything
#: else -- older files with "data" first, records from other writers -- does not
#: match and is parsed in full, as before.
_HEAD_RE = re.compile(
rb'\A\s*\{\s*"timestamp"\s*:\s*(-?[0-9][0-9.eE+-]*)\s*'
rb'(?:,\s*"ttl"\s*:\s*(-?[0-9][0-9.eE+-]*))?\s*[,}]'
)
def _stale_from_head(head: bytes, max_age: Optional[int], now: float) -> bool:
"""True when a record's header alone shows it has expired.
Mirrors the expiry rule in DiskCache.get: a per-entry ttl wins over the
caller's max_age, and no limit at all means never stale. False whenever the
header cannot be read, so the full parse decides as it always did.
"""
match = _HEAD_RE.match(head)
if not match:
return False
try:
timestamp = float(match.group(1))
limit = max_age
if match.group(2) is not None:
ttl = float(match.group(2))
if ttl >= 0:
limit = ttl
except ValueError:
return False
return limit is not None and (now - timestamp) > limit
if orjson is not None:
# Encoding the cache record dominated the background fetch worker: on a
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
@@ -266,6 +301,14 @@ class DiskCache:
try:
with self._lock:
with open(cache_path, 'rb') as f:
# Decide staleness from the header before paying for the
# parse. A stale read is the common case for the biggest
# records (a season schedule is re-fetched when its cache
# expires), and parsing 53MB to throw it away held the GIL
# for ~1.8s -- a visible freeze on the panel.
if _stale_from_head(f.read(_HEAD_BYTES), max_age, time.time()):
return None
f.seek(0)
record = _loads(f.read())
# Determine record timestamp (prefer embedded, else file mtime)
+12 -12
View File
@@ -32,7 +32,6 @@ from typing import Any, Dict, List, Optional
import logging
import threading
import tempfile
from src.exceptions import CacheError
from src.cache.memory_cache import MemoryCache, default_max_size
from src.cache.disk_cache import DiskCache
from src.cache.cache_strategy import CacheStrategy
@@ -272,12 +271,9 @@ class CacheManager:
# Update memory cache first
self._memory_cache_component.set(key, data)
# Save to disk cache
try:
self._disk_cache_component.set(key, data)
except CacheError:
# Disk cache errors are already logged and raised by DiskCache
raise
# DiskCache logs a failed write and raises CacheError, which the
# caller gets as is.
self._disk_cache_component.set(key, data)
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
"""Load data from cache with memory caching."""
@@ -522,8 +518,9 @@ class CacheManager:
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
"""Update cache with new data."""
cache_data = {
# Header first; see DiskCache's stale check.
'timestamp': time.time(),
'data': data,
'timestamp': time.time()
}
return self.save_cache(data_type, cache_data)
@@ -556,12 +553,15 @@ class CacheManager:
from the key and is only a fallback for entries that did not
say. Omit it to keep that inferred behaviour.
"""
cache_data = {
'data': data,
'timestamp': time.time()
}
# timestamp and ttl before data, so they are the first bytes on disk:
# DiskCache.get reads them from the head of the file and can call a
# record stale without parsing it. That matters for the big ones -- a
# whole MLB season is 53MB and ~1.8s of orjson.loads with the GIL held,
# paid in full only to learn the record had expired.
cache_data: Dict[str, Any] = {'timestamp': time.time()}
if ttl is not None:
cache_data['ttl'] = ttl
cache_data['data'] = data
self.save_cache(key, cache_data)
@deprecated("3.7.0")
+219 -30
View File
@@ -1,53 +1,242 @@
# Common Utilities
# src/common
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
Helpers shared by core and plugins. This page lists every module, what it is
for, and whether plugins are expected to import it.
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
Rules for the package:
The recommended way to lay out plugins that render legibly on **any** panel
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
from `src.common` for convenience; canonical import paths are
`src.adaptive_layout` / `src.adaptive_images`.
- Every module must import without display hardware: nothing here may import
`src.display_manager` or `src.plugin_system` at module level
([`test/test_common_is_hardware_free.py`](../../test/test_common_is_hardware_free.py)).
That keeps plugins that use it loadable by the web preview,
`scripts/check_plugin.py` and tests on a laptop.
- A plugin that imports a module added in a given core release must declare
that release as its minimum (`ledmatrix_min_version` in the manifest's
`versions` entry). The "Since" column gives the release; "—" means it
predates 3.1.0, "n/a" that plugins should not import it.
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
`configure_scroll`, `resolve_scroll_settings`, `refresh_hz_from_config`) and
the adaptive layout names below ([`__init__.py`](__init__.py)).
## Summary
| Module | For | Plugins import it? | Since |
|---|---|---|---|
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
The four `sports_*` mixin and card modules hold code the scoreboard plugins
used to carry as identical copies. Each module docstring lists what a host
class must provide. The plan behind them is in
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
## Adaptive layout and images
`src/adaptive_layout.py` and `src/adaptive_images.py` live outside this
package but are re-exported from `src.common`. They are the recommended way
to lay out a plugin that renders legibly on any panel size. Every
`BasePlugin` already has `self.layout`, `self.draw_fit()` and
`self.draw_image()`:
```python
# Every BasePlugin already has self.layout and the draw helpers:
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
crop_to_ink=True, cache_key=f"logo:{abbr}")
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
self.draw_fit(status, regs.status_band)
```
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
Key pieces: `Region`, the font ladders `LADDER_GRID` / `LADDER_ARCADE`,
`LayoutContext` (`fit_text`, `fit_image`, `by_tier`, `px`), and
`scoreboard_regions()` / `media_row()`. Guide:
[docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
## API Helpers (`api_helper.py`)
## Modules
Utilities for making HTTP requests and handling API responses.
### api_helper
## Logo Helpers (`logo_helper.py`)
[`api_helper.py`](api_helper.py). `APIHelper(cache_manager=None, ...)`:
`get()` and `post()` with retries, optional caching through the cache
manager, and a minimum interval between requests (`set_rate_limit()`). Also has
`fetch_espn_scoreboard()`, `fetch_espn_standings()` and
`fetch_espn_rankings()`.
Utilities for loading and managing team logos.
### bdf_font
## Text Helpers (`text_helper.py`)
[`bdf_font.py`](bdf_font.py). The one BDF loader and rasterizer.
`load_bdf_face(path, size)` returns `(face, realised_px)`, falling back to
the file's native strike when it has none at `size`;
`draw_bdf_text(draw, text, x, y, face, color)` draws top-left anchored onto a
PIL `ImageDraw` the same way the panel does. `read_bdf_native_size(path)`
and `clear_face_cache()` round it out. Faces are cached per thread (FreeType
faces are not thread-safe). `DisplayManager`, `FontManager`, `element_style`
and the plugin test harness all use it. Most plugins get BDF text through
`display_manager.draw_text()` or `FontManager` and never import this.
Utilities for text processing and formatting.
### espn_dates
## Scroll Helpers (`scroll_helper.py`)
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
splits a range into month and day requests ESPN accepts and merges the
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
Scoreboard plugins also bundle a copy for older cores.
Utilities for scrolling text on the display.
### font_layout
## Permission Utilities (`permission_utils.py`)
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
`ImageFont.truetype` with PIL's Basic layout engine pinned, so text lays out
the same whether or not the host Pillow has libraqm; use it for anything
drawn to the panel or compared against a golden image. `crisp_size()` gives
the size a bundled face renders on whole pixels at. `resolve_asset_path()`
resolves `assets/fonts/...` against the install root rather than the
working directory.
Helpers for ensuring directory permissions and ownership are correct
when running as a service (used by `CacheManager` to set up its
persistent cache directory).
### logo_helper
## Best Practices
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
display_height, ...)`: `load_logo()`, `load_logo_with_download()`,
`get_logo_variations()`, `normalize_abbreviation()`, with an in-memory cache.
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
2. **Reuse utilities**: Check existing utilities before creating new ones
3. **Document additions**: Add documentation when adding new utilities
### path_safety
[`path_safety.py`](path_safety.py). Core-internal, used by web handlers that
open files named in a request. `safe_path_component(value)` returns the
value if it is one harmless path segment, else `None`;
`resolve_under(base, *parts)` returns the resolved path, or `None` if a part
is unsafe or the result would leave `base`; `safe_relative_parts()` splits a
relative path the same way. Both return the sanitised value rather than a
boolean, so a caller cannot check one string and open another.
### permission_utils
[`permission_utils.py`](permission_utils.py). The modes and ownership that
let the root display service and the web user share files:
`ensure_directory_permissions()`, `ensure_file_permissions()`, the
`get_*_mode()` functions, `ensure_shared_group_ownership()`,
`sudo_remove_directory()` and `install_requirements_file()` (the sudo
`safe_pip_install.sh` path). `ConfigManager`, `CacheManager` and the store
already call these; a plugin needs them only when it creates its own files
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
### scroll_config
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
plugin_config=, global_config=, display_manager=, plugin_logger=)` reads a
plugin's scroll settings, snaps the speed to a whole number of pixels per
panel refresh, puts the helper in fixed-step mode and returns
`ScrollSettings`. Pass `settings.frame_hold` to
`display_manager.set_scrolling_state(True, frame_hold=...)` or the scroll
runs too fast. `resolve()` does the calculation without touching a helper.
See [docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
### scroll_helper
[`scroll_helper.py`](scroll_helper.py). `ScrollHelper(display_width,
display_height, logger=None)`: build a wide image once
(`create_scrolling_image()` or `set_scrolling_image()`), then per frame
`update_scroll_position()` and `get_visible_portion()`;
`is_scroll_complete()`, `calculate_dynamic_duration()` and
`get_dynamic_duration()` for timing. Configure it with `scroll_config`
rather than the `set_*` methods. Vegas mode reads a plugin's
`scroll_helper` image when the plugin has no `get_vegas_content()`.
### snapshot_policy
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
touch its mtime, or skip, based on whether a browser is watching the preview.
The web health check reads the file's age.
### sports_card
[`sports_card.py`](sports_card.py). Free functions taking `config`, `fonts`
and `logger` explicitly: card options (`scroll_card_option()`,
`vs_text()`, `upcoming_center_mode()`), colours (`element_color()`,
`font_color()`, `score_color_for()`, `recent_score_color()`), favourite-team
rules (`favorite_teams_for()`, `side_is_favorite()`, `favorite_result()`),
dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
method and delegates the body.
### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py).
`SportsGameRendererMixin`: the scroll/Vegas card geometry (centre gap, logo
slot, layout offsets, upcoming-card date and time). No `__init__` and no
state; add it as a base class of the plugin's game renderer and override
what differs.
### sports_helpers
[`sports_helpers.py`](sports_helpers.py). Free functions `clamp_window()`,
`clamp_seconds()`, `logo_needs_refresh()`, `spread_weighted_order()`, and
`SportsHelpersMixin` with the scoreboards' `_mode_customization`,
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
Nothing in core uses it.
### sports_scroll
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
`SportsScrollDisplayManager`: the scroll-display orchestration the
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
`SCROLL_LEAGUE_KEYS`; see the module docstring for an example.
### sports_shared
[`sports_shared.py`](sports_shared.py). `SportsCoreSharedMixin`,
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` methods
that were identical in every scoreboard (game selection and rotation,
fonts, colours, dates, the switch-mode upcoming card). The docstring lists
the attributes the host class must have and the three methods deliberately
left out.
### sync_manager
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
links two displays as leader and follower (`sync.role` in config) over UDP
port 5765, plus TCP on the next port for scroll images. The leader drives the
scroll and sends the follower its part of each frame; a follower falls back
to its own plugins when the leader goes quiet. Rows and columns must match.
Created by `DisplayController`; works with any plugin.
### text_helper
[`text_helper.py`](text_helper.py). `TextHelper(font_dir=None, ...)`:
`load_fonts()`, `draw_text_with_outline()`, `get_text_width()`,
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
`draw_multiline_text()`, `create_text_image()`.
## Logging
Modules here create their logger with `logging.getLogger(__name__)`, which is
the same logger `src.logging_config.get_logger(__name__)` returns. The helper
classes (`APIHelper`, `LogoHelper`, `ScrollHelper`, `TextHelper`) and
`espn_dates` take an optional `logger`. In a plugin, pass `self.logger`: it is
created by `get_logger(..., plugin_id=...)` in `BasePlugin`, so messages carry
the plugin id.
## Adding a module
- Keep it importable without hardware (see the test above).
- Give it a module docstring that says what it is for and, if it is a mixin,
what the host class must provide.
- Add it to the table on this page and, if plugins may import it, to the
CHANGELOG with the release to floor on.
+41 -41
View File
@@ -1,8 +1,9 @@
"""
API Helper
Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
Extracted from LEDMatrix core to provide reusable functionality for plugins.
HTTP requests, response caching and ESPN fetch helpers for plugins
(``from src.common import APIHelper``), plus the headers every core request
sends (:data:`USER_AGENT`, :data:`DEFAULT_HTTP_HEADERS`).
"""
import logging
@@ -36,13 +37,20 @@ DEFAULT_HTTP_HEADERS: Mapping[str, str] = MappingProxyType({
class APIHelper:
"""
Helper class for HTTP requests, caching, and ESPN API integration.
Provides functionality for:
- HTTP requests with retry logic and timeouts
- Response caching with TTL support
- ESPN API integration for sports data
- Request rate limiting and throttling
HTTP requests with retries, response caching and ESPN helpers.
- Requests go through one ``requests.Session`` that retries GET, HEAD
and OPTIONS on 429 and 5xx with exponential backoff, and sends
:data:`DEFAULT_HTTP_HEADERS`.
- Consecutive requests from one helper are spaced at least
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
does not count.
- With a ``cache_manager``, :meth:`get` caches the parsed JSON under
``cache_key`` for ``cache_ttl`` seconds. The lifetime is stored with
the entry, so CacheManager honours it on every later read, whatever
max_age that read asks for.
- Failed requests are logged and return None; nothing here raises for a
network or HTTP error.
"""
def __init__(self, cache_manager=None, default_timeout: int = 30,
@@ -73,13 +81,7 @@ class APIHelper:
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
# Default headers
self.session.headers.update({
'User-Agent': USER_AGENT,
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Connection': 'keep-alive'
})
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
# Rate limiting
self._last_request_time = 0
@@ -102,9 +104,8 @@ class APIHelper:
Returns:
Response data as dictionary or None if request fails
"""
# Check cache first
if cache_key and self.cache_manager:
cached = self._get_from_cache(cache_key)
cached = self._get_from_cache(cache_key, cache_ttl)
if cached is not None:
self.logger.debug(f"Using cached response for {cache_key}")
return cached
@@ -268,33 +269,31 @@ class APIHelper:
Args:
key: Cache key
data: Data to cache
ttl: Time-to-live in seconds (ignored - CacheManager doesn't support TTL)
ttl: Seconds the entry stays valid. Stored with the entry, so
it applies to every later read of ``key``.
"""
if self.cache_manager:
self.cache_manager.set(key, data)
self._set_cache(key, data, ttl)
def get_cache(self, key: str) -> Optional[Any]:
"""
Get cached data.
Args:
key: Cache key
Returns:
Cached data or None if not found
Cached data, or None if there is none or it has expired. An
entry written with a ttl (set_cache, get) expires after that ttl;
one written without expires after CacheManager's default max_age.
"""
if self.cache_manager:
return self.cache_manager.get(key)
return None
return self._get_from_cache(key)
def clear_cache(self, pattern: Optional[str] = None) -> None:
"""
Clear cache data.
Uses CacheManager's real surface (clear_cache / delete /
list_cache_files); safely no-ops on managers without it. The old
implementation guarded on a nonexistent ``clear`` method, so it
silently never cleared anything.
Uses CacheManager's clear_cache(), or list_cache_files() and delete()
for a pattern. A cache manager without those methods is left alone.
Args:
pattern: Optional substring to match cache keys; only matching
@@ -315,21 +314,22 @@ class APIHelper:
"cannot clear by pattern")
elif hasattr(self.cache_manager, 'clear_cache'):
self.cache_manager.clear_cache()
elif hasattr(self.cache_manager, 'clear'):
self.cache_manager.clear()
else:
self.logger.debug("Cache manager exposes no clear method; no-op")
def _get_from_cache(self, key: str) -> Optional[Any]:
"""Get data from cache."""
if self.cache_manager:
def _get_from_cache(self, key: str, max_age: Optional[int] = None) -> Optional[Any]:
"""Cached data for ``key``, or None. ``max_age`` only matters for an
entry stored without a ttl; one stored with a ttl uses that."""
if not self.cache_manager:
return None
if max_age is None:
return self.cache_manager.get(key)
return None
def _set_cache(self, key: str, data: Any, ttl: int) -> None:
"""Set data in cache."""
return self.cache_manager.get(key, max_age=max_age)
def _set_cache(self, key: str, data: Any, ttl: Optional[int]) -> None:
"""Store ``data`` under ``key`` for ``ttl`` seconds."""
if self.cache_manager:
self.cache_manager.set(key, data)
self.cache_manager.set(key, data, ttl=ttl)
def _enforce_rate_limit(self) -> None:
"""Enforce rate limiting between requests."""
+261
View File
@@ -0,0 +1,261 @@
"""Loading and drawing BDF bitmap fonts: one loader, one rasterizer.
BDF fonts are fixed-size bitmap strikes. FreeType renders them at the size
baked into the file and rejects any other size, and PIL cannot draw a
``freetype.Face`` at all, so the core draws BDF text itself, glyph by glyph.
This used to be done in several places that drifted apart:
``FontManager``, ``element_style`` and ``DisplayManager`` each loaded faces
their own way, and ``DisplayManager`` and the plugin test harness
(``VisualTestDisplayManager``) each had a copy of the glyph drawing loop. The
harness renders plugin golden images and ``check_plugin`` / ``dev_server``
previews, so a copy that differs from the panel's shows something the panel
never draws. Everything now goes through the two functions here:
* :func:`load_bdf_face` -- a ``freetype.Face`` at the requested pixel size,
or at the file's native strike when the file has no strike at that size.
* :func:`draw_bdf_text` -- draw a string in a ``freetype.Face`` onto a PIL
``ImageDraw``, top-left anchored like ``ImageDraw.text``.
Only PIL and freetype-py are imported, so the module is as cheap to import
from the test harness as from core.
"""
from __future__ import annotations
import ctypes
import logging
import os
import threading
from collections import OrderedDict
from typing import Any, Optional, Sequence, Tuple
from PIL import Image
try:
import freetype
except ImportError: # pragma: no cover - freetype-py is a core requirement
freetype = None
logger = logging.getLogger(__name__)
__all__ = ["read_bdf_native_size", "load_bdf_face", "draw_bdf_text"]
# --------------------------------------------------------------------------
# Loading
# --------------------------------------------------------------------------
def read_bdf_native_size(bdf_path: str) -> Optional[int]:
"""A BDF file's one true pixel size, read from its header, or None.
Prefers the PIXEL_SIZE property, which states the real pixel height
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE is
absent, since point-size only equals pixel height at exactly 100dpi --
several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined at 75dpi, where
the two values genuinely differ. Stops at the first STARTCHAR.
"""
size_line_value = None
try:
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
for line in f:
if line.startswith("PIXEL_SIZE"):
parts = line.split()
if len(parts) >= 2:
return int(float(parts[1]))
elif line.startswith("SIZE") and size_line_value is None:
# Format: "SIZE <point_size> <xres> <yres>"
parts = line.split()
if len(parts) >= 2:
size_line_value = int(float(parts[1]))
elif line.startswith("STARTCHAR"):
break
except (OSError, ValueError):
return None
return size_line_value
#: Loaded faces, keyed on (absolute path, requested size, mtime_ns, file size)
#: so a font file replaced on disk under the same name is loaded afresh.
#: Bounded LRU: the display process runs for weeks and every config save can
#: introduce a new (font, size) pair, but a panel draws from a handful.
_FACE_CACHE_MAX = 256
_face_cache: "OrderedDict[tuple, Tuple[Any, int]]" = OrderedDict()
_face_cache_lock = threading.Lock()
def _face_at(path: str, size_px: int) -> Any:
face = freetype.Face(path)
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
return face
def load_bdf_face(path: str, size_px: int) -> Tuple[Any, int]:
"""``(face, realised_px)`` for the BDF file at ``path``.
``realised_px`` is ``size_px`` when the file has a strike at that size,
otherwise the file's native size: FreeType refuses any other size for a
bitmap font, and answering that with some other typeface (which both
``FontManager`` and ``element_style`` once did) is worse than drawing the
font that was asked for at the size it can do. Callers that lay out by
size need ``realised_px``, not the size they asked for.
Faces are cached per thread. A ``freetype.Face`` holds per-glyph state
(``load_char`` rewrites its glyph slot), and FreeType does not allow two
threads to use one face at once, so the display thread and a plugin's
update thread must never be handed the same object. Within a thread the
face is shared by every caller. Raises if the file can't be loaded at
either size.
"""
if freetype is None:
raise RuntimeError("freetype-py is not installed; BDF fonts need it")
size_px = int(size_px)
abs_path = os.path.abspath(path)
try:
st = os.stat(abs_path)
key = (threading.get_ident(), abs_path, size_px,
st.st_mtime_ns, st.st_size)
except OSError:
key = None # let freetype raise its own error below
if key is not None:
with _face_cache_lock:
cached = _face_cache.get(key)
if cached is not None:
_face_cache.move_to_end(key)
return cached
try:
entry = (_face_at(abs_path, size_px), size_px)
except Exception:
native = read_bdf_native_size(abs_path)
if not native or native == size_px:
raise
# A fresh Face: the first one already took a failed set_char_size.
entry = (_face_at(abs_path, native), native)
logger.debug(
"BDF font %s requested at %spx renders at its native %spx "
"(the file has no strike at the requested size)",
abs_path, size_px, native,
)
if key is not None:
with _face_cache_lock:
_face_cache[key] = entry
_face_cache.move_to_end(key)
while len(_face_cache) > _FACE_CACHE_MAX:
_face_cache.popitem(last=False)
return entry
def clear_face_cache() -> None:
"""Drop every cached face (tests; a font directory swapped wholesale)."""
with _face_cache_lock:
_face_cache.clear()
# --------------------------------------------------------------------------
# Drawing
# --------------------------------------------------------------------------
def _bitmap_bytes(bitmap: Any, nbytes: int) -> bytes:
"""The first ``nbytes`` of a glyph bitmap's buffer, zero-padded.
``bitmap.buffer`` builds a Python list one byte at a time; reading the
underlying FT_Bitmap directly is the same bytes without that cost.
"""
raw = getattr(bitmap, "_FT_Bitmap", None)
if raw is not None and raw.buffer:
return ctypes.string_at(raw.buffer, nbytes)
buf = bytes(bitmap.buffer[:nbytes])
if len(buf) < nbytes:
buf += bytes(nbytes - len(buf))
return buf
def _glyph_points(bitmap: Any, left: int, top: int,
clip_w: int, clip_h: int) -> list:
"""Every lit pixel of a glyph, clipped, as ``(x, y)`` pairs.
The reference definition of which pixels a glyph lights: the MSB-first
bit ``j`` of byte ``i * pitch + j // 8``. Used only where the fast path
below can't express exactly the same thing.
"""
buffer = bitmap.buffer
pitch = bitmap.pitch
points = []
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * pitch + (j // 8)
if byte_index < len(buffer) and buffer[byte_index] & (1 << (7 - (j % 8))):
px = left + j
py = top + i
if 0 <= px < clip_w and 0 <= py < clip_h:
points.append((px, py))
return points
def draw_bdf_text(draw: Any, text: str, x: int, y: int, face: Any,
color: Any = (255, 255, 255),
clip: Optional[Sequence[int]] = None) -> int:
"""Draw ``text`` in a ``freetype.Face`` with ``draw``; return the pen x.
``(x, y)`` is the top-left of the line, as for ``ImageDraw.text``: the
baseline is ``y`` plus the face's ascender. Each glyph's lit bits are set
to ``color`` exactly -- no blending, no anti-aliasing -- and pixels
outside ``[0, clip_w) x [0, clip_h)`` are skipped (``clip`` defaults to
the image size). The pen advances by each glyph's advance width.
Glyphs are drawn as 1-bit masks with ``ImageDraw.bitmap`` rather than a
point at a time, which is pixel-identical and far faster. A ``draw`` that
blends (``ImageDraw.Draw(rgb_image, "RGBA")``) is drawn point by point, so
a translucent colour still blends exactly as it always has.
Errors (a non-BDF ``face``, a bad colour) propagate after any glyphs
before the failing one are drawn; callers decide whether to log them.
"""
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
if clip is None:
clip_w, clip_h = draw.im.size
else:
clip_w, clip_h = int(clip[0]), int(clip[1])
blending = draw.mode != draw.im.mode
for char in text:
face.load_char(char)
glyph = face.glyph
bitmap = glyph.bitmap
rows, width, pitch = bitmap.rows, bitmap.width, bitmap.pitch
left = x + glyph.bitmap_left
top = baseline_y - glyph.bitmap_top
if rows > 0 and width > 0:
if blending or pitch <= 0:
points = _glyph_points(bitmap, left, top, clip_w, clip_h)
if points:
draw.point(points, fill=color)
else:
# The visible part of the glyph box, in glyph coordinates.
x0, y0 = max(0, -left), max(0, -top)
x1, y1 = min(width, clip_w - left), min(rows, clip_h - top)
if x0 < x1 and y0 < y1:
# Raw mode "1" with stride=pitch reads exactly the bits
# _glyph_points does, whatever the glyph's pixel mode.
mask = Image.frombytes(
"1", (width, rows), _bitmap_bytes(bitmap, rows * pitch),
"raw", "1", pitch)
if (x0, y0, x1, y1) != (0, 0, width, rows):
mask = mask.crop((x0, y0, x1, y1))
# An all-blank glyph draws nothing -- and, as before,
# never touches the colour.
if mask.getbbox() is not None:
draw.bitmap((left + x0, top + y0), mask, fill=color)
x += glyph.advance.x >> 6
return x
+10 -2
View File
@@ -39,6 +39,14 @@ from datetime import date, timedelta
from functools import partial
from typing import Any, Dict, List, Optional, Tuple
try:
from src.common.json_body import response_json
except ImportError:
# Plugins bundle copies of this module for older cores, which predate
# json_body; the stdlib parse is what those cores always used.
def response_json(response: Any) -> Any:
return response.json()
# Above this, ESPN returns a truncated list instead of an error. See module
# docstring: 500 is the largest value measured to return complete data.
ESPN_MAX_LIMIT = 500
@@ -194,7 +202,7 @@ def _fetch_one_chunk(
timeout=timeout,
)
response.raise_for_status()
return response.json()
return response_json(response)
except Exception as exc: # noqa: BLE001 - see docstring
if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
@@ -371,4 +379,4 @@ def fetch_espn_scoreboard(
if data is not None:
return data
response.raise_for_status()
return response.json()
return response_json(response)
+5 -4
View File
@@ -67,10 +67,11 @@ _INSTALL_ROOT = Path(__file__).resolve().parents[2]
def resolve_asset_path(relative_path: str) -> str:
"""Resolve a repo-relative asset path independently of the process cwd.
Prefers the path as given — so an absolute path is returned untouched and
behaviour is unchanged wherever the cwd already happened to be the install
root — then the install root derived above, then the original string so a
caller that wants to raise and fall back still can.
In order: an absolute path that exists is returned untouched; otherwise
``relative_path`` under the install root derived above, if that exists;
otherwise ``relative_path`` unchanged, so a caller that wants to raise
and fall back still can. The cwd is never consulted, so a relative path
means the same file whichever directory the process started in.
Without the fallback, any process started outside the install root (the
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
+609
View File
@@ -0,0 +1,609 @@
"""System-wide frame timing: one set of numbers for every presented frame.
Each scroller already logs its own stats line (ScrollHelper.log_frame_rate,
the Vegas coordinator's "Vegas FPS"), but in different formats, per source,
and Vegas only logs a healthy window at DEBUG. None of that answers the
question a release has to answer on each rig: *over a long run, how often did
a moving frame reach the panel late?*
Every frame reaches the panel through ``DisplayManager.update_display``, so it
is recorded there, once, whoever drew it. The render thread only appends a
tuple; a worker thread aggregates, and every ``flush_interval`` seconds writes
cumulative counters and histograms to a small JSON file -- in ``/dev/shm`` where
it exists, so a stats file refreshed all day costs no SD-card writes.
``scripts/frame_soak.py`` reads it twice and reports the difference.
What is counted
---------------
Only intervals between two consecutive *scrolling* frames count: a static
screen that changes once a second has no timing to get wrong, and the first
frame of a scroll has no predecessor worth measuring against.
"Scrolling" is DisplayManager's scroll state when the frame is presented, and
that state can go missing in the middle of a scroll. It expires after 2s
without scroll activity, which a long enough stall outlasts, and any thread can
clear it: plugins call ``set_scrolling_state(False)`` from their own
``display()``, and Vegas captures some of those on the render thread between
two of its frames. The frame after that is recorded as static, and the interval
it ends -- the stall, or the capture -- would vanish from the report. So a
single static frame between two scrolling ones, with the scroll picking up
again within ``RESUME_SECONDS``, is treated as a frame of the scroll: both of
its intervals count. A second static frame in a row means the scroll really
ended. (On hdpi on 2026-09-24 the watchdog logged a 1.9s stall that the soak
report did not have; this is how.)
A frame held for ``hold`` refreshes should arrive ``hold`` refresh periods
after the one before it. One that arrives a whole refresh or more after that is
**late**: the panel showed the previous frame again, which on a moving strip is
a visible hitch. ``missed_refreshes`` sums how many refreshes late.
An interval of ``FREEZE_SECONDS`` or more is a **freeze** instead -- a
recompose, a plugin handover, a blocking call on the render thread. Those are
counted separately, both because they are a different fault and because
folding a single 400ms handover into the late count as "40 missed refreshes"
would drown the jitter the late count exists to measure. ``freeze_by`` splits
them by length. Intervals of ``GAP_SECONDS`` or more are ignored as not being
frames of one scroll at all.
A frame that arrives a whole refresh or more *early* means the swap did not
wait for the panel: the emulator, the fallback display, or a hold that was not
the one in effect. Those are counted as **early**, and a run with more than a
trace of them was not locked to the panel, so its late count means nothing.
The refresh period is estimated from the frames themselves: swaps that block
on vsync can only land on refresh boundaries, so the low end of
interval / hold is the period. It is the smallest per-window 10th percentile
seen so far, over windows with enough frames to trust -- except that a window
cutting it by more than ``MAX_REFRESH_DROP`` is ignored. A panel's refresh does
not jump like that; swaps that stopped blocking do, and adopting their period
would make every early frame look on time.
A caller that has measured the panel independently -- ``scripts/render_bench.py``
times bare swaps first with :func:`measure_refresh_hz` -- passes that rate in
as ``refresh_hz``. The estimate then starts from it instead of from the frames,
which is what catches a loop that never locked at all: one that free-runs
faster than the panel (every frame early) or sits at half its rate (every
frame late), both of which look self-consistent to an estimate taken from
their own intervals.
Stall watchdog
--------------
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
same frames from its own thread and, when a scroll's last frame is more than
``STALL_SECONDS`` old, logs the stack of the thread that presented it and the
top of every other thread's, so the log names what the render thread was
waiting on. It also measures how late its own wake-up was: if the watchdog was
held up as long as the render thread, the whole interpreter was blocked (C
code holding the GIL, or the process not scheduled), not one thread on a lock.
Set ``LEDMATRIX_STALL_WATCHDOG=0`` to turn it off, or
``LEDMATRIX_STALL_WATCHDOG_MS`` to dump at a lower threshold -- 30 catches
frames three refreshes late, which is where GIL contention shows. It polls
three times per threshold, so keep it to diagnostic runs, not soaks.
"""
from __future__ import annotations
import copy
import json
import logging
import os
import queue
import sys
import tempfile
import threading
import time
import traceback
from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
#: Bumped when a field changes meaning, so a reader can refuse stale files.
SCHEMA_VERSION = 1
#: Histogram resolution. 64ms of range covers any frame worth drawing a
#: distribution of; everything beyond lands in the last bucket.
BUCKET_MS = 0.25
BUCKET_COUNT = 256
#: See the module docstring.
FREEZE_SECONDS = 0.25
#: Intervals this long are not frames of one scroll. This used to be 1s,
#: which silently dropped every 1-2s stall inside a scroll. It is now only a
#: sanity bound.
GAP_SECONDS = 5.0
#: A frame recorded as static between two scrolling frames is a frame of the
#: scroll whose state went missing, if the scroll resumes within this long.
#: See "What is counted".
RESUME_SECONDS = 1.0
#: Buckets for freeze length, as cumulative counters a soak can difference.
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
(float("inf"), "2s+"))
#: A window may lower the refresh-period estimate by at most this fraction.
MAX_REFRESH_DROP = 0.2
#: A window needs this many scrolling frames before its refresh estimate is
#: trusted -- about a second of scrolling.
MIN_FRAMES_FOR_REFRESH = 90
FLUSH_INTERVAL = 10.0
#: A scroll's last frame older than this is a stall worth a stack dump.
STALL_SECONDS = 0.25
#: How often the watchdog looks. Also the resolution of its starvation check.
WATCHDOG_POLL_SECONDS = 0.05
#: At most one stack dump per this many seconds: a stall that repeats every
#: extension would otherwise write the same stacks to the SD card all day.
STALL_LOG_INTERVAL = 30.0
#: Written by the display service, read by scripts/frame_soak.py and anything
#: else that wants the numbers. The web UI's viewer marker lives in /tmp; this
#: goes to RAM where there is some, since it is rewritten all day.
STATS_FILENAME = "ledmatrix_frame_stats.json"
def default_stats_path() -> str:
# A fixed name in a shared directory is safe here: write() creates its
# temp file with mkstemp and os.replace()s it over this path, which swaps
# out whatever is there -- a planted symlink included -- without following it.
base = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() # nosec B108
return os.path.join(base, STATS_FILENAME)
def _bucket(seconds: float) -> int:
index = int(seconds * 1000.0 / BUCKET_MS)
return min(max(index, 0), BUCKET_COUNT - 1)
def binding_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
The stock binding blocks in SwapOnVSync holding the GIL, which starves
every other thread for most of each frame (docs/SCROLL_PERFORMANCE.md).
scripts/build_rgbmatrix_nogil.sh rebuilds it, and the rebuilt module links
PyEval_SaveThread where the stock one never does -- a crude test, but the
only one that needs neither a probe on the panel nor the source tree the
module was built from. None when no hardware binding is loaded.
"""
module = sys.modules.get("rgbmatrix.core")
path = getattr(module, "__file__", None)
if not path:
return None
try:
with open(path, "rb") as handle:
return b"PyEval_SaveThread" in handle.read()
except OSError:
return None
def _pi_model() -> Optional[str]:
try:
with open("/proc/device-tree/model", "rb") as handle:
return handle.read().rstrip(b"\0").decode("ascii", "replace").strip()
except OSError:
return None
def measure_refresh_hz(matrix: Any, seconds: float = 4.0) -> float:
"""The panel's refresh rate with nothing else running, by timing bare swaps.
``SwapOnVSync`` blocks until the panel's next refresh, so a loop that does
nothing else runs at exactly the panel's rate. ``limit_refresh_rate_hz`` is
a *cap*, and a long chain, a high ``pwm_bits`` or an older Pi will sit well
under it. Solving scroll speeds against a cap the panel cannot reach is
what produces "3px every 4 refreshes" and the judder that comes with it.
This is the idle rate. The panel refreshes a few percent slower while the
Pi is also pushing frames into it (100.4Hz idle against 96.3Hz scrolling on
a Pi 4 driving 512x64), which is why the recorder reads the rendering rate
back from the frames rather than trusting this.
Pass the matrix the display is already running on rather than opening a
second one: the GPIO has a single owner, and the options in force change
the answer.
:returns: measured Hz, or 0.0 if the matrix cannot be swapped (no
hardware, a stub, a mock).
"""
try:
canvas = matrix.CreateFrameCanvas()
# Discard the first swap: it carries construction and first-touch costs
# that have nothing to do with the steady-state refresh.
canvas = matrix.SwapOnVSync(canvas)
except Exception: # pylint: disable=broad-except
return 0.0
frames = 0
started = time.perf_counter()
while time.perf_counter() - started < seconds:
canvas = matrix.SwapOnVSync(canvas)
frames += 1
elapsed = time.perf_counter() - started
if elapsed <= 0 or frames <= 0:
return 0.0
return frames / elapsed
class FrameTimingRecorder:
"""Collects per-frame timings on the render thread; aggregates elsewhere.
``record`` is the only method the render thread calls, and it does no more
than compare two floats and append a tuple.
"""
def __init__(
self,
path: Optional[str] = None,
flush_interval: float = FLUSH_INTERVAL,
info: Optional[Dict[str, Any]] = None,
refresh_hz: Optional[float] = None,
):
"""
:param refresh_hz: the panel's rate, measured independently (see the
module docstring). Omit it to estimate from the frames alone, as
the display service does.
"""
self.path = path or default_stats_path()
self.flush_interval = flush_interval
self.info = dict(info or {})
# Render-thread state.
self._pending: List[Tuple[float, float, float, int]] = []
self._static_frames = 0
self._previous: Optional[Tuple[float, bool, int]] = None
# The interval ended by a static frame that followed a scrolling one,
# until the next frame shows whether the scroll went on.
self._unsure: Optional[Tuple[float, float, float, int]] = None
self._last_flush: Optional[float] = None
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
self._worker: Optional[threading.Thread] = None
# Worker-thread state. Nothing on the render thread reads these.
self.started = time.time()
self.refresh_period: Optional[float] = (
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
# The first estimate, until a second window agrees with it.
self._refresh_candidate: Optional[float] = None
self.totals: Dict[str, Any] = {
"static_frames": 0,
"scroll_frames": 0,
"late_frames": 0,
"missed_refreshes": 0,
"late_by": {"1": 0, "2": 0, "3-5": 0, "6+": 0},
"early_frames": 0,
# Frames judged against a known refresh period: the denominator
# for the late and early rates. Frames before the period is known
# are neither, and must not dilute them.
"timed_frames": 0,
"freezes": 0,
"freeze_seconds": 0.0,
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
"worst_interval_ms": 0.0,
}
self.histograms: Dict[str, Dict[int, int]] = {
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
}
self._binding_gil: Optional[bool] = None
self._binding_checked = False
# Read by the stall watchdog from its own thread: one tuple assignment,
# so it always sees a consistent (time, scrolling, thread) triple.
self.last_frame: Optional[Tuple[float, bool, int]] = None
#: Whether a scroll is running *now*, supplied by the display manager.
#: The last frame's flag alone would call the end of every scroll a
#: stall.
self.scrolling_now: Optional[Callable[[], bool]] = None
self.watchdog: Optional["StallWatchdog"] = None
def close(self) -> None:
"""Stop the stall watchdog, if one was started."""
watchdog, self.watchdog = self.watchdog, None
if watchdog is not None:
watchdog.stop()
# -- render thread ------------------------------------------------------
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
presented_at: float) -> None:
"""One frame reached the panel.
:param blit: seconds spent copying the frame into the canvas.
:param wait: seconds SwapOnVSync blocked.
:param hold: the refreshes this frame was held for.
:param scrolling: whether a scroll was running when it was presented.
:param presented_at: ``time.perf_counter()`` when the swap returned.
"""
previous = self._previous
self._previous = (presented_at, scrolling, hold)
self.last_frame = (presented_at, scrolling, threading.get_ident())
if not scrolling:
self._static_frames += 1
# The scroll ended, or its state went missing for this frame: the
# next frame says which. Its hold may have been dropped with the
# state, so the interval is due at the scroll's own.
self._unsure = None
if previous is not None and previous[1]:
self._unsure = (presented_at - previous[0], blit, wait, previous[2])
elif self.watchdog is None and self.scrolling_now is not None \
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
self.watchdog = StallWatchdog(self, **watchdog_settings())
self.watchdog.start()
elif previous is not None:
interval = presented_at - previous[0]
unsure, self._unsure = self._unsure, None
if previous[1]:
if interval < GAP_SECONDS:
self._pending.append((interval, blit, wait, hold))
elif unsure is not None and interval < RESUME_SECONDS:
# One static frame between two scrolling ones: the scroll never
# stopped, only its state did. Both intervals were motion.
self._static_frames -= 1
if unsure[0] < GAP_SECONDS:
self._pending.append(unsure)
self._pending.append((interval, blit, wait, hold))
if self._last_flush is None:
self._last_flush = presented_at
elif presented_at - self._last_flush >= self.flush_interval:
self._hand_off()
self._last_flush = presented_at
def _hand_off(self) -> None:
batch, self._pending = self._pending, []
static, self._static_frames = self._static_frames, 0
self._queue.put((batch, static))
if self._worker is None or not self._worker.is_alive():
self._worker = threading.Thread(
target=self._run, daemon=True, name="frame-timing")
self._worker.start()
def drain(self) -> None:
"""Aggregate everything recorded so far, on the calling thread.
For a caller that owns the recorder outright and wants exact numbers at
a moment of its choosing -- the benchmark, between warm-up and run and
at the end. Construct it with ``flush_interval=float('inf')`` so the
worker never runs; the two must not aggregate at once.
"""
batch, self._pending = self._pending, []
static, self._static_frames = self._static_frames, 0
self.aggregate(batch, static)
# -- worker thread ------------------------------------------------------
def _run(self) -> None:
while True:
batch, static = self._queue.get()
try:
self.aggregate(batch, static)
self.write()
except Exception: # never let telemetry take anything down
logger.debug("Frame timing flush failed", exc_info=True)
def aggregate(self, batch: List[Tuple[float, float, float, int]],
static: int) -> None:
"""Fold one window of frames into the running totals."""
totals = self.totals
totals["static_frames"] += static
per_hold = sorted(interval / max(1, hold)
for interval, _, _, hold in batch
if interval < FREEZE_SECONDS)
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
estimate = per_hold[len(per_hold) // 10]
current = self.refresh_period
if estimate <= 0:
pass
elif current is None:
# Adopt the first period only once two windows in a row agree:
# one loaded window at startup, most of its frames a refresh
# late, would otherwise fix a period twice the real one for
# the life of the process, since later windows may only lower
# it by MAX_REFRESH_DROP.
candidate = self._refresh_candidate
if candidate and abs(estimate - candidate) <= candidate * MAX_REFRESH_DROP:
self.refresh_period = min(candidate, estimate)
else:
self._refresh_candidate = estimate
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
self.refresh_period = estimate
period = self.refresh_period
histograms = self.histograms
for interval, blit, wait, hold in batch:
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
interval * 1000.0)
if interval >= FREEZE_SECONDS:
totals["freezes"] += 1
totals["freeze_seconds"] += interval
label = next(name for limit, name in FREEZE_BUCKETS
if interval < limit)
totals["freeze_by"][label] += 1
continue
totals["scroll_frames"] += 1
for name, value in (("blit", blit), ("wait", wait),
("work", max(0.0, interval - blit - wait)),
("interval_per_hold", interval / max(1, hold))):
bucket = _bucket(value)
histogram = histograms[name]
histogram[bucket] = histogram.get(bucket, 0) + 1
if period:
totals["timed_frames"] += 1
missed = round(interval / period) - hold
if missed >= 1:
totals["late_frames"] += 1
totals["missed_refreshes"] += missed
key = ("1" if missed == 1 else "2" if missed == 2
else "3-5" if missed <= 5 else "6+")
totals["late_by"][key] += 1
elif missed <= -1:
totals["early_frames"] += 1
def snapshot(self) -> Dict[str, Any]:
"""The JSON document: cumulative since this process started."""
if not self._binding_checked:
self._binding_gil = binding_releases_gil()
self._binding_checked = True
info = dict(self.info)
info.setdefault("pi_model", _pi_model())
period = self.refresh_period
return {
"version": SCHEMA_VERSION,
"pid": os.getpid(),
"started": self.started,
"updated": time.time(),
"bucket_ms": BUCKET_MS,
"freeze_seconds": FREEZE_SECONDS,
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
"binding_releases_gil": self._binding_gil,
"info": info,
"totals": copy.deepcopy(self.totals),
# JSON keys are strings; readers convert back.
"histograms": {name: {str(k): v for k, v in sorted(h.items())}
for name, h in self.histograms.items()},
}
def write(self) -> None:
"""Replace the stats file atomically with the current snapshot."""
directory = os.path.dirname(self.path) or "."
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".frame_stats.",
suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(self.snapshot(), handle)
os.chmod(tmp, 0o644)
os.replace(tmp, self.path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def watchdog_settings() -> Dict[str, float]:
"""StallWatchdog arguments from ``LEDMATRIX_STALL_WATCHDOG_MS``, if set.
The poll comes down with the threshold, or a stall shorter than one poll
would go unseen.
"""
try:
ms = float(os.environ.get("LEDMATRIX_STALL_WATCHDOG_MS") or 0)
except ValueError:
ms = 0.0
if ms <= 0:
return {}
threshold = ms / 1000.0
return {"threshold": threshold,
"poll": min(WATCHDOG_POLL_SECONDS, threshold / 3)}
class StallWatchdog:
"""Log what the render thread is doing when a scroll stops presenting.
See the module docstring. Polls; never touches the render thread.
"""
def __init__(
self,
recorder: FrameTimingRecorder,
threshold: float = STALL_SECONDS,
poll: float = WATCHDOG_POLL_SECONDS,
log_interval: float = STALL_LOG_INTERVAL,
clock: Callable[[], float] = time.perf_counter,
):
self.recorder = recorder
self.threshold = threshold
self.poll = poll
self.log_interval = log_interval
self.clock = clock
self.stalls = 0
self._last_dump: Optional[float] = None
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, daemon=True, name="stall-watchdog")
self._thread.start()
def stop(self, timeout: float = 1.0) -> None:
"""End the polling thread (DisplayManager.cleanup calls this)."""
self._stop.set()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout)
def _run(self) -> None:
last_wake = self.clock()
stall_from: Optional[float] = None # presented_at of the stalled frame
dumped = False
while not self._stop.wait(self.poll):
now = self.clock()
late = max(0.0, now - last_wake - self.poll)
last_wake = now
try:
stall_from, dumped = self.check(now, late, stall_from, dumped)
except Exception: # never let a diagnostic take anything down
logger.debug("Stall watchdog check failed", exc_info=True)
def check(self, now: float, late: float, stall_from: Optional[float],
dumped: bool) -> Tuple[Optional[float], bool]:
"""One look. Returns the updated (stall_from, dumped) state."""
frame = self.recorder.last_frame
if frame is None:
return None, False
presented_at, scrolling, ident = frame
if stall_from is not None and presented_at != stall_from:
# A frame arrived: the stall is over.
if dumped:
logger.warning(
"Render stall over: no frame for %.0fms",
(presented_at - stall_from) * 1000.0)
return None, False
scrolling_now = self.recorder.scrolling_now
if (stall_from is not None and now - stall_from >= GAP_SECONDS
and (scrolling_now is None or not scrolling_now())):
# The scroll ended without another frame: nothing more to time.
# Only past GAP_SECONDS: the scroll state expires after 2s without
# activity, which a stall outlasts, and its end still wants saying.
return None, False
age = now - presented_at
if (stall_from is None and scrolling and age >= self.threshold
and scrolling_now is not None and scrolling_now()):
self.stalls += 1
if self._last_dump is None or now - self._last_dump >= self.log_interval:
self._last_dump = now
logger.warning(self.describe(ident, age, late))
return presented_at, True
return presented_at, False
return stall_from, dumped
def describe(self, ident: int, age: float, late: float) -> str:
"""The stack dump: the stalled thread in full, the rest in brief."""
names = {t.ident: t.name for t in threading.enumerate()}
frames = sys._current_frames()
lines = [
f"Render stall: no frame for {age * 1000.0:.0f}ms mid-scroll "
f"(watchdog woke {late * 1000.0:.0f}ms late"
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
+ ")",
f"-- {names.get(ident, ident)} (presents frames):",
]
stalled = frames.get(ident)
if stalled is not None:
lines.extend(line.rstrip() for line in
traceback.format_stack(stalled, limit=12))
for other, frame in frames.items():
if other in (ident, threading.get_ident()):
continue
top = traceback.extract_stack(frame, limit=3)
where = " <- ".join(
f"{os.path.basename(f.filename)}:{f.lineno} {f.name}"
for f in reversed(top))
lines.append(f"-- {names.get(other, other)}: {where}")
return "\n".join(lines)
+29
View File
@@ -0,0 +1,29 @@
"""Parse an HTTP response body as JSON, with orjson when it is installed.
``requests``' ``response.json()`` uses the stdlib parser. For the payloads the
sports plugins fetch -- a season schedule is tens of MB -- that runs ~1.7x
slower than orjson on a Pi 4 (3.1s against 1.8s for the 53MB MLB season), and
both hold the GIL for the whole parse, which freezes the display for as long.
Nothing else changes: the result is the same Python objects.
"""
from __future__ import annotations
from typing import Any
try:
import orjson
except ImportError: # optional dependency; see docs/SCROLL_PERFORMANCE.md
orjson = None
def response_json(response: Any) -> Any:
"""``response.json()``, parsed by orjson when available."""
body = getattr(response, "content", None)
if orjson is None or not isinstance(body, (bytes, bytearray)):
return response.json()
try:
return orjson.loads(body)
except orjson.JSONDecodeError:
# Let requests raise its usual error, with its usual message.
return response.json()
+33 -52
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from typing import Dict, List, Optional, Union
import requests
from PIL import Image
from PIL import Image, ImageDraw
from src.common.api_helper import USER_AGENT
from src.common.permission_utils import (
ensure_directory_permissions,
@@ -32,35 +32,14 @@ from src.common.permission_utils import (
# trade for not re-warning about a file nobody is going to add.
MISSING_LOGO_RECHECK_SECONDS = 3600.0
#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed
#: enough that a typo cannot ask for a 4000px image on a 64px panel.
MIN_LOGO_SCALE = 0.05
MAX_LOGO_SCALE = 8.0
def _usable_scale(scale) -> float:
"""A scale that can be applied, or 1.0.
Anything unusable -- None, a string, zero, a negative, NaN, infinity --
means "as shipped", because the alternative is a blank panel from a
mistyped number.
"""
try:
value = float(scale)
except (TypeError, ValueError):
return 1.0
if value != value or value in (float('inf'), float('-inf')):
return 1.0
if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE:
return 1.0
return value
# Well above any real team logo; bounds what a remote URL can write to disk.
# The cap for every logo download: src.logo_downloader.fetch_logo uses it too.
MAX_LOGO_BYTES = 10 * 1024 * 1024
#: A logo's default bounding box, as a multiple of the panel's width and
#: height, when the caller gives no max_width / max_height.
DEFAULT_LOGO_BOX_FACTOR = 1.5
class LogoHelper:
"""
@@ -119,12 +98,16 @@ class LogoHelper:
Args:
team_abbr: Team abbreviation for caching
logo_path: Path to the logo file
max_width: Maximum width (defaults to display_width * 1.5)
max_height: Maximum height (defaults to display_height * 1.5)
max_width: Maximum width (default display_width *
DEFAULT_LOGO_BOX_FACTOR)
max_height: Maximum height (default display_height *
DEFAULT_LOGO_BOX_FACTOR)
scale: User's size multiplier for this image, from
``customization.layout.<element>.scale``. 1.0 is untouched and
takes exactly the path it always did. Callers hold the config,
so they resolve the element name; this only applies the number.
``customization.layout.<element>.scale``; 1.0 leaves the box
as is. Callers hold the config, so they resolve the element
name; this only applies the number, clamped to
src.element_style's MIN_ELEMENT_SCALE..MAX_ELEMENT_SCALE.
A value that is not a finite positive number means 1.0.
Returns:
PIL Image object or None if loading fails
@@ -138,10 +121,13 @@ class LogoHelper:
# key is size-qualified — a panel-size change must not return a
# logo resized for the old dimensions.
if max_width is None:
max_width = int(self.display_width * 1.5)
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None:
max_height = int(self.display_height * 1.5)
scale = _usable_scale(scale)
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Imported here: src.element_style imports src.common (for bdf_font),
# whose __init__ imports this module.
from src.element_style import coerce_scale
scale = coerce_scale(scale, 1.0)
if scale != 1.0:
max_width = max(1, int(round(max_width * scale)))
max_height = max(1, int(round(max_height * scale)))
@@ -374,9 +360,9 @@ class LogoHelper:
nobody asked to grow would change every existing render.
"""
if max_width is None:
max_width = int(self.display_width * 1.5)
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None:
max_height = int(self.display_height * 1.5)
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Only resize if necessary
if logo.width <= max_width and logo.height <= max_height:
@@ -429,31 +415,26 @@ class LogoHelper:
max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Optional[Image.Image]:
"""
Create a placeholder logo with team abbreviation.
A stand-in for a logo that could not be loaded or downloaded: a
translucent grey box with a light outline, filling the logo box.
No text is drawn; ``team_abbr`` is only used in log messages.
Args:
team_abbr: Team abbreviation to display
max_width: Maximum width
max_height: Maximum height
team_abbr: Team the placeholder stands in for
max_width: Width (default display_width * DEFAULT_LOGO_BOX_FACTOR)
max_height: Height (default display_height * DEFAULT_LOGO_BOX_FACTOR)
Returns:
PIL Image with placeholder logo
The RGBA placeholder, or None if it could not be created
"""
try:
if max_width is None:
max_width = int(self.display_width * 1.5)
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
if max_height is None:
max_height = int(self.display_height * 1.5)
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
# Create placeholder image
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
# This would require a font, so we'll create a simple colored rectangle
# In a real implementation, you'd want to add text rendering here
from PIL import ImageDraw
draw = ImageDraw.Draw(placeholder)
# Draw a simple rectangle with team abbreviation
draw.rectangle([0, 0, max_width-1, max_height-1],
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
+8 -4
View File
@@ -241,7 +241,8 @@ def get_assets_dir_mode() -> int:
Return permission mode for asset directories.
Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
@@ -251,7 +252,8 @@ def get_config_dir_mode() -> int:
Return permission mode for config directory.
Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
@@ -271,7 +273,8 @@ def get_plugin_dir_mode() -> int:
Return permission mode for plugin directories.
Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
@@ -281,7 +284,8 @@ def get_cache_dir_mode() -> int:
Return permission mode for cache directories.
Returns:
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
+15 -38
View File
@@ -238,20 +238,9 @@ class ScrollHelper:
self.cached_image = full_image
# Convert to numpy array for fast operations
self.cached_array = np.array(full_image)
# Use actual image width instead of calculated width to ensure accuracy
# This fixes cases where width calculation doesn't match actual positioning
actual_image_width = full_image.width
self.total_scroll_width = actual_image_width
# Log if there's a mismatch (indicating a bug in width calculation)
if actual_image_width != total_width:
self.logger.warning(
"Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). "
"Using actual width for scroll calculations.",
total_width, actual_image_width, abs(actual_image_width - total_width)
)
self.scroll_position = 0.0
self.total_distance_scrolled = 0.0
self.scroll_complete = False
@@ -339,10 +328,8 @@ class ScrollHelper:
# gained. This is what the one visibly smooth scroller on the
# hardware (the stock ticker) was already doing by virtue of never
# enabling frame-based mode.
if self.scroll_delay > 0:
pixels_per_second = self.scroll_speed / self.scroll_delay
else:
pixels_per_second = self.scroll_speed * 100.0
# set_scroll_delay clamps scroll_delay to at least 0.001.
pixels_per_second = self.scroll_speed / self.scroll_delay
pixels_to_move = pixels_per_second * delta_time
self.last_step_time = current_time
else:
@@ -353,11 +340,11 @@ class ScrollHelper:
self.scroll_position += pixels_to_move
self.total_distance_scrolled += pixels_to_move
# Calculate required total distance: total_scroll_width only.
# The image already includes display_width pixels of blank padding at the start
# (added by create_scrolling_image), so once scroll_position reaches
# total_scroll_width the last card has fully scrolled off the left edge.
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
# One pass is total_scroll_width. With the default lead_gap the strip
# starts with display_width of blank, so by then the last item has
# fully left the panel; a caller passing a smaller lead_gap (Vegas)
# decides for itself where its cycle ends. Adding display_width here
# caused 1-2 extra wrap-arounds on wide chains.
required_total_distance = self.total_scroll_width
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip
@@ -414,7 +401,6 @@ class ScrollHelper:
and current_time - self.last_progress_log_time >= self.progress_log_interval
):
elapsed_time = current_time - (self.scroll_start_time or current_time)
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width
# Progress telemetry, emitted every few seconds for the whole of
# every scroll. It says how far along a marquee is, which is what
@@ -461,10 +447,8 @@ class ScrollHelper:
"""
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
installed on the target devices, and the old scipy-based sub-pixel path
was dead code -- get_visible_portion never consulted the flag. The scipy
import was removed with it; installing scipy has no effect.
Implemented with numpy rather than scipy.ndimage.shift, which is not
installed on the target devices.
Args:
start_x: Left column of the earlier of the two frames
@@ -571,22 +555,16 @@ class ScrollHelper:
return self.min_duration
try:
# Calculate total scroll distance needed
# The image already includes display_width padding at the start, so we need
# to scroll total_scroll_width pixels to show all content, plus display_width
# more pixels to ensure the last content scrolls completely off the screen
# The strip's width plus one more screen, so the duration covers
# the last item leaving the panel even when the strip has less
# than display_width of lead-in blank (lead_gap).
total_scroll_distance = self.total_scroll_width + self.display_width
# Calculate effective pixels per second based on scrolling mode
if self.frame_based_scrolling:
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame
# Effective pixels per second = pixels per frame / seconds per frame
if self.scroll_delay > 0:
pixels_per_second = self.scroll_speed / self.scroll_delay
else:
# Fallback if scroll_delay is invalid
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
# Frame-based mode: scroll_speed is pixels per scroll_delay
# seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
pixels_per_second = self.scroll_speed / self.scroll_delay
scroll_mode_str = "frame-based"
else:
# Time-based mode: scroll_speed is already pixels per second
@@ -1072,7 +1050,6 @@ class ScrollHelper:
Returns:
Dictionary with scroll state information
"""
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
return {
'scroll_position': self.scroll_position,
+3 -3
View File
@@ -5,7 +5,7 @@ serves two consumers with different needs:
- The web UI's live preview (SSE reader in web_interface/app.py) wants
fresh frames — but only while a browser is actually watching.
- The health check (web_interface/blueprints/api_v3.py, hardware status)
- The health check (web_interface/blueprints/api_v3/misc.py, hardware status)
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
@@ -27,7 +27,7 @@ Policy:
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
If any constant here changes, re-check the health threshold in
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
"""
from enum import Enum
@@ -37,7 +37,7 @@ VIEWER_INTERVAL = 0.2
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
IDLE_INTERVAL = 30.0
# Max age of the last write/touch before bumping mtime for the health
# check. MUST stay well under api_v3's 60s degraded threshold.
# check. MUST stay well under get_hardware_status's 60s degraded threshold.
TOUCH_INTERVAL = 20.0
# A viewer marker older than this no longer counts as a live viewer.
VIEWER_MARKER_FRESH_SEC = 5.0
+60 -31
View File
@@ -101,11 +101,10 @@ def element_color(config: Optional[Dict[str, Any]], element: str,
mode: Optional[str] = None):
"""Per-element text colour from customization.<element>.text_color.
Delegated rather than reimplemented: there were two copies of this
read and three of the offset read, and the shared one also resolves
the element under the names plugins actually use (the layout block
says `score` where the style block says `score_text`) and honours a
per-mode override. Hex strings are still accepted.
Delegates to src.element_style.element_color, which also resolves the
element under the names plugins actually use (the layout block says
`score` where the style block says `score_text`) and honours a per-mode
override. Hex strings are accepted.
"""
from src.element_style import element_color as _shared
return _shared(config, element, default, mode)
@@ -125,12 +124,11 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
One object can legitimately belong to several elements -- a size resolver
can land two of them on the same face, and a BDF face cannot be un-shared
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
Those draws used to go out white, which is how an element rendered in any
of the 32 shipped bitmap fonts could silently lose a colour the user had
set. So ambiguity is now narrowed before it is given up on: among the
Ambiguity is therefore narrowed before it is given up on: among the
elements sharing a face, a single configured colour is the only thing the
user can have meant, and several that agree mean the same thing. Only a
genuine disagreement falls back to *default*.
genuine disagreement falls back to *default* -- otherwise an element
drawn in any of the shipped bitmap fonts could lose a colour the user set.
The element vocabulary is a parameter because the two callers disagree
about it -- the mixin's map says ``team_text`` where this module's says
@@ -339,6 +337,18 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
if not raw:
return ""
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
The body both date formatters share. They differ in which setting names the
style and in which zone the weekday is taken from (see
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
*weekday* is a zero-argument callable, only called for the "weekday" style.
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
"""
if fmt == "numeric":
return raw
parts = raw.replace("-", "/").split("/")
@@ -347,14 +357,14 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
month, day = int(parts[0]), int(parts[1])
if not 1 <= month <= 12:
return raw
name = MONTH_ABBR[month - 1]
name = months[month - 1]
if fmt == "numeric_day_first":
return f"{day}/{month}"
if fmt == "day_first":
return f"{day} {name}"
if fmt == "weekday":
weekday = weekday_for(config, logger, game)
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
day_name = weekday()
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
return f"{name} {day}"
@@ -388,6 +398,29 @@ def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]:
"""``{element: font_size default}`` from a config_schema.json. Raises.
The parse both schema-default lookups share. Each keeps its own cache --
this function per schema path, ``SportsCoreSharedMixin._schema_font_size``
per class -- because the lifetimes differ: a class is rebuilt when the
display service reloads a plugin, a module-level path cache is not. One
cache would change when a reloaded plugin sees an edited schema.
"""
import json
with open(schema_path) as fh:
schema = json.load(fh)
props = (schema.get('properties', {})
.get('customization', {})
.get('properties', {}))
sizes: Dict[str, int] = {}
for key, spec in props.items():
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
sizes[key] = int(size)
return sizes
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
"""The font_size this plugin's config_schema.json declares, or None.
@@ -399,18 +432,8 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
return None
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
if cache is None:
cache = {}
try:
import json
with open(schema_path) as fh:
schema = json.load(fh)
props = (schema.get('properties', {})
.get('customization', {})
.get('properties', {}))
for key, spec in props.items():
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
cache = _read_schema_font_sizes(schema_path)
except Exception as exc:
# See sports_shared._schema_font_size: an unreadable schema
# silently disables the pixel-grid snap for every element.
@@ -444,7 +467,7 @@ def resolve_font_size(schema_path: str, element_config, element_key,
return crisp_size(font_name, default_size, aliases, grid_table)
def unshare_element_fonts(logger, fonts):
def unshare_element_fonts(logger, fonts, element_for_font=None):
"""Give each colourable element its own face object.
The colour a draw gets is resolved from the face it was handed, and
@@ -458,14 +481,20 @@ def unshare_element_fonts(logger, fonts):
with identical metrics, so nothing about the rendering changes; only
the ability to tell two elements apart does. Faces that cannot be
rebuilt (a BDF loaded through freetype.Face, anything without a usable
path) are left shared, and their draws stay white as before.
path) are left shared; resolve_font_color then picks their colour.
*element_for_font* names the font keys to consider, in order (the first
holder of a face keeps it); it defaults to this module's
:data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map,
which names different keys -- see ``resolve_font_color`` for why the two
vocabularies are kept apart.
"""
try:
from src.common.font_layout import load_truetype as _load
except ImportError: # pragma: no cover
return fonts
# Looked up at call time so tests can spy on the pinned loader.
from src.common.font_layout import load_truetype
if element_for_font is None:
element_for_font = ELEMENT_FOR_FONT
seen = {}
for key in ELEMENT_FOR_FONT:
for key in element_for_font:
font = fonts.get(key)
if font is None:
continue
@@ -476,7 +505,7 @@ def unshare_element_fonts(logger, fonts):
if not path or not size:
continue
try:
fonts[key] = _load(path, size)
fonts[key] = load_truetype(path, size)
except (OSError, ValueError, TypeError):
logger.debug(
"Could not un-share the %s face; it keeps the default colour", key)
+14 -20
View File
@@ -56,21 +56,13 @@ class SportsGameRendererMixin:
# ---- geometry ------------------------------------------------------
# Non-finite settings are rejected before any int()/round(): "inf" reaches
# these from config as a float or a string, passes an `isinstance` plus
# `>= 0` check unharmed, and then raises OverflowError out of int() --
# which the old `except (TypeError, ValueError)` did not catch, so it
# aborted the whole card render. Present in all eight plugins before this
# moved to the core; fixing it here fixes it in all eight.
def _score_reserve_width(self) -> int:
"""Centre strip the score actually needs, measured rather than assumed.
"""Centre strip the score needs: the width of _SCORE_PROBE in the
score font plus a gutter each side, or 0 if it cannot be measured.
The gap was derived from the card width alone (width x
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
comes from config and the element-style resolver. Nothing compared the
two, so any score wider than the clamp was drawn over the logos.
Measuring it keeps the strip wide enough for whatever font is in play.
Measured rather than derived from the card width, because the score's
size comes from config and the element-style resolver: a strip sized
from the width alone lets a large score run over the logos.
"""
try:
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
@@ -87,6 +79,10 @@ class SportsGameRendererMixin:
the card width between the configurable min and max. 0 restores
edge-to-edge logos.
"""
# Non-finite settings are rejected before any int()/round(): "inf"
# arrives from config as a float or a string, passes the isinstance
# and >= 0 checks, and int() then raises OverflowError, which would
# abort the whole card render.
configured = self._scroll_card_option("center_gap")
if (isinstance(configured, (int, float))
and math.isfinite(configured) and configured >= 0):
@@ -110,10 +106,9 @@ class SportsGameRendererMixin:
def _logo_slot_width(self) -> int:
"""Per-side logo slot, leaving the center gap clear.
No longer capped at display_height: the card is sized as two
full-height logos plus the measured gap, so what is left after the gap
is exactly the logo's share. The cap was what froze the logos at 46px
on the old flat 128px card.
Not capped at display_height: the card is sized as two full-height
logos plus the measured gap, so what is left after the gap is exactly
the logo's share. At least 8 px.
"""
available = (self.display_width - self._center_gap_width()) // 2
return max(8, available)
@@ -130,9 +125,8 @@ class SportsGameRendererMixin:
"""X/Y nudge for one element, from customization.layout.
Same block the full-screen scorebug reads (sports.py
_get_layout_offset), so a nudge configured in the web UI now moves
the element on the scroll/Vegas card too -- previously the schema
advertised these offsets but this renderer ignored them.
_get_layout_offset), so a nudge configured in the web UI moves the
element on the scroll/Vegas card as well as on the scorebug.
"""
from src.element_style import layout_offset
return layout_offset(self.config, element, axis, default,
+3 -3
View File
@@ -487,9 +487,9 @@ class SportsScrollDisplayManager:
)
except Exception:
# prepare_scroll_content is subclass-implemented and builds cards
# straight from feed data, which is exactly where this PR's other
# crashes came from. One sport's bad payload must not take down the
# shared orchestration for the others.
# straight from feed data, so it can raise on a malformed payload.
# One sport's bad payload must not take down the shared
# orchestration for the others.
self.logger.exception(
"Error preparing scroll content for game_type=%s", game_type
)
+75 -142
View File
@@ -64,14 +64,27 @@ live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three d
a side and override it, the same two that override ``_SCORE_PROBE`` on
``SportsGameRendererMixin``.
DELIBERATELY NOT MERGED WITH sports_card
----------------------------------------
Fourteen of these have same-named twins in ``src/common/sports_card.py``, which
the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together
here. Only five are provably equivalent by source comparison; the other nine
differ in ways inspection cannot settle, and a wrong guess silently changes what
every scoreboard draws. Merging them needs differential testing against both
implementations, and is left for its own change.
TWINS IN sports_card
--------------------
Many of these have same-named twins in ``src/common/sports_card.py``, which the
scoreboards' ``game_renderer.py`` uses. ``test/test_sports_twins.py`` calls
each pair with the same inputs (the plugins' fixture games in every payload
shape, plus edge cases) and splits them in two:
- Identical: ``_card_option``, ``_vs_text``, ``_format_game_time``,
``_coerce_rgb``, ``_crisp_size``, ``_unshare_element_fonts`` (given the same
element map) and the constant tables. These are now thin wrappers over the
``sports_card`` function; ``_format_game_date`` and ``_schema_font_size``
share its body/parser while keeping their own setting, zone and cache.
``_resolve_font_size`` agrees too but keeps its body, because it dispatches
through the overridable ``_schema_font_size``/``_crisp_size``.
- Different, and pinned as they are: ``_side_is_favorite`` /
``_favorite_result`` / ``_recent_score_color`` (flat keys and the host's
favourites only), ``_weekday_for`` (the plugin's resolved zone, not
``config["timezone"]``), ``_font_color`` / ``_ELEMENT_FOR_FONT`` (another
element vocabulary), ``_element_color`` (passes ``SKIN_MODE``). Each shows
up in one display mode only, so which side is right is a product decision;
the test that pins it names the difference.
"""
from __future__ import annotations
@@ -84,10 +97,11 @@ from datetime import datetime, timedelta, timezone
from typing import Any, ClassVar, Dict, List, Optional, Tuple
import pytz
from src.common.espn_dates import fetch_espn_scoreboard
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
import requests
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from PIL import Image, ImageDraw
from src.common import sports_card as _card
from src.common.font_layout import load_truetype, resolve_asset_path
logger = logging.getLogger(__name__)
@@ -118,36 +132,16 @@ def _resolve_font_path(path: str) -> str:
load raises, the caller falls back, and the scoreboard renders in PIL's
default face instead of the pixel font it was laid out for.
Resolution order matches the core's own resolver: the path as given
first, so behaviour is unchanged wherever it already worked and a
configured absolute path is returned untouched, then the core install
root, then the original string so callers still raise and fall back
exactly as they do today.
Resolution order: the path as given, relative to the cwd, when it
exists -- the order the scoreboards' own sports.py copies used, so a
process running from another checkout keeps that checkout's fonts --
then :func:`src.common.font_layout.resolve_asset_path` (the install
root), which returns the original string when neither exists so callers
still raise and fall back.
"""
if os.path.exists(path):
return path
try:
import src.font_manager as _core_fonts
# The core grew this resolver in ChuckBuilds/LEDMatrix#425. Use it
# when it is there so both repos stay on one definition of "install
# root"; older cores fall through to the equivalent derivation below.
manager = getattr(_core_fonts, "FontManager", None)
resolver = getattr(manager, "_resolve_asset_path", None)
if resolver is not None:
resolved = resolver(path)
if resolved and os.path.exists(resolved):
return resolved
root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__)))
candidate = os.path.join(root, path)
if os.path.exists(candidate):
return candidate
except (ImportError, AttributeError, OSError):
# No core on the path (standalone tooling), a core laid out
# differently, or an unreadable install. Returning the original keeps
# the caller's existing fallback intact.
return path
return path
return resolve_asset_path(path)
class SportsCoreSharedMixin:
@@ -171,27 +165,22 @@ class SportsCoreSharedMixin:
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
"score": "score_text", "time": "period_text", "team": "team_text",
"detail": "detail_text", "status": "status_text"}
# The tables below are sports_card's (and font_layout's) values. The dicts
# are copies, so a caller that mutates one module's table -- or a subclass
# that replaces it -- does not reach into the other.
#: Default tint for a favourite team's finished game.
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = {
"win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)}
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = (
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = (
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = dict(
_card.FAVORITE_RESULT_COLOR_DEFAULTS)
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = _card.MONTH_ABBR
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = _card.WEEKDAY_ABBR
#: Bitmap fonts snap to their native pixel grid.
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = {
"PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7}
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = {
"press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"}
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = dict(_card.FONT_PIXEL_GRID)
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = dict(_card.FONT_NAME_ALIASES)
#: Accepted values for the other-games quality filter.
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
#: How long to stay quiet between ranking-coverage warnings.
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
def _get_season_schedule_dates(self) -> tuple[str, str]:
return "", ""
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Placeholder draw method - subclasses should override."""
# This base method will be simple, subclasses provide specifics
@@ -213,13 +202,11 @@ class SportsCoreSharedMixin:
"""Snap *desired* to the nearest size *font_file* renders crisply at.
A face with no known grid is returned unchanged, so a user-supplied
font is never second-guessed.
font is never second-guessed. The class's own tables are passed, so a
host that declares extra faces keeps them.
"""
font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file)
grid = cls._FONT_PIXEL_GRID.get(font_file)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
return _card.crisp_size(font_file, desired,
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
#: Absolute path of this plugin's directory, declared by the plugin
#: itself. The mixin cannot work it out -- see _plugin_dir.
@@ -281,23 +268,19 @@ class SportsCoreSharedMixin:
"""The font_size this plugin's config_schema.json declares, or None."""
if not element_key:
return None
# Cached per class, not in sports_card's per-path cache: the display
# service rebuilds the class when it reloads a plugin, and that is
# what makes an edited schema take effect. Both caches parse through
# sports_card._read_schema_font_sizes.
cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None)
if cache is None:
cache = {}
try:
import json
directory = self._plugin_dir()
if directory is None:
raise FileNotFoundError("no config_schema.json on the MRO")
with open(os.path.join(directory, 'config_schema.json')) as fh:
schema = json.load(fh)
props = (schema.get('properties', {})
.get('customization', {})
.get('properties', {}))
for key, spec in props.items():
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
cache = _card._read_schema_font_sizes(
os.path.join(directory, 'config_schema.json'))
except Exception as exc:
# Say so. An unreadable schema is not cosmetic: every element's
# configured size then stops matching "the schema default", is
@@ -339,10 +322,7 @@ class SportsCoreSharedMixin:
def _card_option(self, key: str, default: Any = None) -> Any:
"""Read one key from the scroll_card config block."""
block = (self.config or {}).get("scroll_card")
if isinstance(block, dict) and block.get(key) is not None:
return block.get(key)
return default
return _card.scroll_card_option(self.config, key, default)
def _switch_upcoming_center(self) -> str:
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
@@ -354,7 +334,7 @@ class SportsCoreSharedMixin:
def _vs_text(self) -> str:
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
return str(self._card_option("vs_text", "VS"))
return _card.vs_text(self.config)
def _switch_date_format(self) -> str:
"""Date style for the full-screen scorebug.
@@ -374,28 +354,19 @@ class SportsCoreSharedMixin:
return fmt
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
"""Format an upcoming date per scroll_card.switch_date_format."""
"""Format an upcoming date per scroll_card.switch_date_format.
The formatting is sports_card's. What differs from the card's
``format_game_date`` is passed in: the setting (``switch_date_format``,
see :meth:`_switch_date_format`) and the weekday, which comes from
:meth:`_weekday_for` and so from this plugin's resolved timezone.
"""
raw = str(date_text or "").strip()
if not raw:
return raw
fmt = self._switch_date_format()
if fmt == "numeric":
return raw
parts = raw.replace("-", "/").split("/")
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
return raw
month, day = int(parts[0]), int(parts[1])
if not 1 <= month <= 12:
return raw
name = self._MONTH_ABBR[month - 1]
if fmt == "numeric_day_first":
return f"{day}/{month}"
if fmt == "day_first":
return f"{day} {name}"
if fmt == "weekday":
weekday = self._weekday_for(game)
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
return f"{name} {day}"
return _card._format_date_as(self._switch_date_format(), raw,
lambda: self._weekday_for(game),
self._MONTH_ABBR)
def _weekday_for(self, game: Optional[Dict]) -> str:
"""Weekday abbreviation from the game's start time, or ''."""
@@ -413,22 +384,7 @@ class SportsCoreSharedMixin:
def _format_game_time(self, time_text: str) -> str:
"""Return the time as-is (12h) or converted to 24h."""
raw = str(time_text or "").strip()
if not raw or str(self._card_option("time_format", "12h")) != "24h":
return raw
cleaned = raw.upper().replace(" ", "")
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
if not meridiem:
return raw
try:
hh, _, mm = cleaned[:-2].partition(":")
hour, minute = int(hh), int(mm or 0)
except ValueError:
return raw
if not (0 <= hour <= 12 and 0 <= minute <= 59):
return raw
hour = hour % 12 + (12 if meridiem == "PM" else 0)
return f"{hour:02d}:{minute:02d}"
return _card.format_game_time(self.config, time_text)
def _scorebug_font(self, draw, text: str, width: int):
"""The face this scorebug draws its date and time in.
@@ -560,15 +516,7 @@ class SportsCoreSharedMixin:
@staticmethod
def _coerce_rgb(value, fallback):
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
# Checked before unpacking: a 3-character string ("123") would otherwise
# iterate into three digits and yield a colour rather than the fallback.
if not isinstance(value, (list, tuple)) or len(value) != 3:
return fallback
try:
r, g, b = (max(0, min(255, int(channel))) for channel in value)
except (TypeError, ValueError):
return fallback
return (r, g, b)
return _card.coerce_rgb(value, fallback)
@staticmethod
def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool:
@@ -849,28 +797,12 @@ class SportsCoreSharedMixin:
the ability to tell two elements apart does. Faces that cannot be
rebuilt (a BDF loaded through freetype.Face, anything without a usable
path) are left shared, and their draws stay white as before.
The body is sports_card's; this class's own element map is passed, so
the keys considered are the ones this class colours by.
"""
try:
from src.common.font_layout import load_truetype as _load
except ImportError: # pragma: no cover
return fonts
seen = {}
for key in self._ELEMENT_FOR_FONT:
font = fonts.get(key)
if font is None:
continue
if id(font) not in seen:
seen[id(font)] = key
continue
path, size = getattr(font, "path", None), getattr(font, "size", None)
if not path or not size:
continue
try:
fonts[key] = _load(path, size)
except (OSError, ValueError, TypeError):
self.logger.debug(
"Could not un-share the %s face; it keeps the default colour", key)
return fonts
return _card.unshare_element_fonts(self.logger, fonts,
self._ELEMENT_FOR_FONT)
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
"""Colour for whichever element owns this face.
@@ -934,7 +866,10 @@ class SportsCoreSharedMixin:
draw.text((x, y), text, font=font, fill=fill)
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
"""Check if we should log a warning based on cooldown period."""
"""True at most once per ``cooldown`` seconds, for rate-limiting a
warning. The cooldown is shared by every warning on this manager:
``warning_type`` is part of the signature scoreboards inherit, but
does not give each type its own cooldown."""
current_time = time.time()
if current_time - self._last_warning_time > cooldown:
self._last_warning_time = current_time
@@ -949,8 +884,6 @@ class SportsCoreSharedMixin:
try:
# Fetch current week and next few days for immediate display
now = datetime.now(pytz.utc)
immediate_events = []
start_date = now - timedelta(days=self.schedule_lookback_days)
end_date = now + timedelta(days=self.schedule_lookahead_days)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
@@ -958,7 +891,7 @@ class SportsCoreSharedMixin:
data = fetch_espn_scoreboard(
self.session,
url,
params={"dates": date_str, "limit": 1000},
params={"dates": date_str, "limit": ESPN_MAX_LIMIT},
headers=self.headers,
timeout=10,
logger=self.logger,
+29 -27
View File
@@ -32,7 +32,7 @@ from typing import Callable, Optional
import numpy as np
from PIL import Image
from src.display_geometry import DEFAULT_CHAIN_LENGTH
from src.display_geometry import DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_ROWS
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
@@ -75,9 +75,12 @@ class FollowerState(Enum):
class DisplaySyncManager:
"""
Core sync manager. Instantiated by DisplayController based on config['sync'].
Leader sends compressed PNG frames to the follower after each render cycle.
Follower renders received frames; returns to own plugin stack when leader
goes offline.
The leader sends each rendered frame to the follower over UDP as raw RGB
bytes (send_frame), and for Vegas scrolling sends the whole scroll image
once per cycle as a PNG over TCP on port + 1 (send_scroll_image), then
only the scroll position. The follower draws what it receives and goes
back to its own plugins when the leader stops sending.
"""
def __init__(
@@ -192,8 +195,8 @@ class DisplaySyncManager:
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
hw = self._hw_config
local_rows = hw.get("rows", 32)
local_cols = hw.get("cols", 64)
local_rows = hw.get("rows", DEFAULT_ROWS)
local_cols = hw.get("cols", DEFAULT_COLS)
peer_rows = int(msg.get("rows", 0))
peer_cols = int(msg.get("cols", 0))
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
@@ -469,16 +472,23 @@ class DisplaySyncManager:
"""Record a decoded leader frame and enter follower mode if needed."""
with self._frame_lock:
self._latest_frame = img
self._enter_follower_mode(sender_ip)
def _enter_follower_mode(self, sender_ip: str) -> bool:
"""Note that the leader at ``sender_ip`` just sent something, and
switch from standalone to follower mode if not already following.
Returns True if this call made the switch."""
self._last_leader_frame_time = time.time()
self._leader_ip = sender_ip
if self._follower_state == FollowerState.STANDALONE:
self._follower_state = FollowerState.FOLLOWER
self.logger.info(
"Sync: leader active at %s — switching to follower mode",
sender_ip,
)
self.write_status_file()
if self._follower_state != FollowerState.STANDALONE:
return False
self._follower_state = FollowerState.FOLLOWER
self.logger.info(
"Sync: leader active at %s — switching to follower mode",
sender_ip,
)
self.write_status_file()
return True
def _follower_recv_loop(self) -> None:
while self._running:
@@ -559,15 +569,7 @@ class DisplaySyncManager:
# back from. Treat it as malformed.
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
self._latest_scroll_x = scroll_x
self._last_leader_frame_time = time.time()
self._leader_ip = sender_ip
if self._follower_state == FollowerState.STANDALONE:
self._follower_state = FollowerState.FOLLOWER
self.logger.info(
"Sync: leader active at %s — switching to follower mode",
sender_ip,
)
self.write_status_file()
if self._enter_follower_mode(sender_ip):
fire_new_cycle = True # build initial scroll image
elif t == "nc":
# Leader started a new scroll cycle — rebuild local image
@@ -589,8 +591,8 @@ class DisplaySyncManager:
hw = self._hw_config
hello = json.dumps({
"t": "hello",
"rows": hw.get("rows", 32),
"cols": hw.get("cols", 64),
"rows": hw.get("rows", DEFAULT_ROWS),
"cols": hw.get("cols", DEFAULT_COLS),
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
}).encode("utf-8")
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
@@ -660,8 +662,8 @@ class DisplaySyncManager:
base = {
"role": self.role.value,
"port": self.port,
"local_rows": hw.get("rows", 32),
"local_cols": hw.get("cols", 64),
"local_rows": hw.get("rows", DEFAULT_ROWS),
"local_cols": hw.get("cols", DEFAULT_COLS),
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
}
+26 -22
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
from src.common.font_layout import load_truetype, resolve_asset_path
# Shared throwaway draw surface for measuring text without a target canvas.
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
@@ -18,13 +18,14 @@ _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
class TextHelper:
"""
Helper class for text rendering with outlines and font management.
Provides functionality for:
- Loading and managing fonts
- Drawing text with outlines for better readability
- Calculating text dimensions and positioning
- Managing font resources
Font loading, outlined text and text measurement for plugins.
- :meth:`load_fonts` loads TrueType fonts from ``font_dir`` (the install's
assets/fonts by default) with the layout engine pinned
(font_layout.load_truetype). Each (file, size) is loaded once per helper
and reused; a missing or unloadable file becomes PIL's default font.
- :meth:`draw_text_with_outline` and friends draw onto a caller's
``ImageDraw``; the measuring methods need no canvas.
"""
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
@@ -33,22 +34,26 @@ class TextHelper:
Initialize the TextHelper.
Args:
font_dir: Directory containing font files (defaults to assets/fonts)
font_dir: Directory containing font files. Defaults to the
install's assets/fonts, whatever the process cwd is.
logger: Optional logger instance
"""
self.logger = logger or logging.getLogger(__name__)
self.font_dir = Path(font_dir) if font_dir else Path("assets/fonts")
self.font_dir = Path(font_dir) if font_dir else Path(resolve_asset_path("assets/fonts"))
# "<path>:<size>" -> loaded font; see load_fonts.
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
"""
Load fonts for different text elements.
Args:
font_config: Custom font configuration dictionary
font_config: ``{name: {"file": <file in font_dir>, "size": <px>}}``;
defaults to the scoreboard set in _get_default_font_config.
Returns:
Dictionary mapping font names to PIL ImageFont objects
Dictionary mapping font names to PIL ImageFont objects. A font
already loaded by this helper at the same size is reused.
"""
if font_config is None:
font_config = self._get_default_font_config()
@@ -61,9 +66,13 @@ class TextHelper:
size = config['size']
if font_path.exists():
font = load_truetype(str(font_path), size)
cache_key = f"{font_path}:{size}"
font = self._font_cache.get(cache_key)
if font is None:
font = load_truetype(str(font_path), size)
self._font_cache[cache_key] = font
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
fonts[font_name] = font
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
else:
# Fallback to default font
font = ImageFont.load_default()
@@ -115,12 +124,7 @@ class TextHelper:
Returns:
Width in pixels
"""
try:
return int(_measure_draw.textlength(text, font=font))
except AttributeError:
# Fallback for older PIL versions
bbox = _measure_draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
return int(_measure_draw.textlength(text, font=font))
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
"""
+30 -37
View File
@@ -16,8 +16,10 @@ additionally keeps rotating backups in ``config/backups/``.
Plugin configuration
--------------------
Plugin configs are stored inside ``config.json`` under the plugin's ID key
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
to write plugin settings; never write directly to the plugin directory.
and survive plugin reinstalls. Write them by saving the whole config with
:meth:`ConfigManager.save_config_atomic` (or
:meth:`ConfigManager.save_raw_file_content`); never write settings into the
plugin directory, which a reinstall deletes.
Hot-reload
----------
@@ -127,13 +129,10 @@ class ConfigManager:
# Update in-memory config if save was successful
if result.status == SaveResultStatus.SUCCESS:
self.config = new_config_data
# In-memory config now matches what was just written; refresh
# the load signature so the fast path stays valid. NOTE: the
# in-memory copy includes merged secrets; the on-disk file has
# them stripped — the fast path returning self.config preserves
# exactly the pre-cache behavior (load-after-save also returned
# the secret-merged self.config only after re-reading secrets;
# here secrets file is unchanged, so contents are equivalent).
# In-memory config now matches what was just written, so the
# load_config fast path may return it. It still carries the
# merged secrets that were stripped on disk; that matches a full
# reload, because the secrets file was not changed by the save.
self._loaded_sig = self._files_signature()
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
elif result.status == SaveResultStatus.ROLLED_BACK:
@@ -253,11 +252,11 @@ class ConfigManager:
return self.config
except FileNotFoundError as e:
if str(e).find('config_secrets.json') == -1: # Only raise if main config is missing
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
self.logger.error(error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=self.config_path) from e
return self.config
# Only config.json can get here: a missing or unreadable secrets
# file is handled where it is read.
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
self.logger.error(error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=self.config_path) from e
except json.JSONDecodeError as e:
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
self.logger.error(error_msg, exc_info=True)
@@ -320,10 +319,9 @@ class ConfigManager:
A missing secrets file is fine (nothing to strip). But a file that
EXISTS and cannot be read or parsed means stripping is impossible —
and the in-memory config being saved has secrets deep-merged into it,
so proceeding would write them into config.json in plaintext. That
was the historical behavior; it is now a hard refusal. The save
raises so the caller (and user) fixes the secrets file instead of
silently leaking its contents into the world-readable main config.
so proceeding would write them into config.json in plaintext. The
save raises instead, so the caller (and user) fixes the secrets file
rather than leaking its contents into the world-readable main config.
"""
if not os.path.exists(self.secrets_path):
return {}
@@ -465,9 +463,8 @@ class ConfigManager:
# Merge template defaults into current config
self._merge_template_defaults(self.config, template_config)
# Save migrated config using atomic save to preserve permissions
# Use atomic save to preserve file permissions
# Note: save_config_atomic handles secrets internally
# save_config_atomic strips the merged secrets back out and
# keeps the file's owner and mode.
result = self.save_config_atomic(
new_config_data=self.config,
create_backup=False, # Already created backup above
@@ -600,20 +597,16 @@ class ConfigManager:
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
# If we just saved the main config or secrets, the merged self.config might be stale.
# Reload it to reflect the new state.
# Note: We wrap this in try-except because reload failures (e.g., migration errors)
# should not cause the save operation to fail - the file was saved successfully.
if file_type == "main" or file_type == "secrets":
try:
self.load_config()
except Exception as reload_error:
# Log the reload error but don't fail the save operation
# The file was saved successfully, reload is just for in-memory consistency
self.logger.warning(
f"Configuration file saved successfully, but reload failed: {reload_error}. "
f"The file on disk is valid, but in-memory config may be stale."
)
# The merged self.config is now stale; reload it. A reload failure
# (a migration error, say) is logged, not raised: the file itself
# was saved.
try:
self.load_config()
except Exception as reload_error:
self.logger.warning(
f"Configuration file saved successfully, but reload failed: {reload_error}. "
f"The file on disk is valid, but in-memory config may be stale."
)
except PermissionError as e:
# Provide helpful error message with fix instructions
@@ -670,7 +663,7 @@ class ConfigManager:
try:
# Load current configs
main_config = self.get_raw_file_content('main')
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
# Remove plugin from main config
if plugin_id in main_config:
@@ -703,7 +696,7 @@ class ConfigManager:
try:
# Load current configs
main_config = self.get_raw_file_content('main')
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
valid_set = set(valid_plugin_ids)
+628 -699
View File
File diff suppressed because it is too large Load Diff
+235 -175
View File
@@ -34,12 +34,12 @@ else:
from contextlib import contextmanager
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src import scan_order
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
resolve_double_sided,
compose_pixel_mapper_config, physical_size, resolve_double_sided,
)
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5
@@ -47,13 +47,14 @@ import threading
import time
from collections import OrderedDict, deque
from typing import Dict, Any, List, Optional, Tuple
import logging
import math
import zlib
import freetype
from src.common import snapshot_policy
from src.common.frame_timing import FrameTimingRecorder
from src.deprecation import deprecated
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_directory_permissions,
ensure_file_permissions,
@@ -61,9 +62,7 @@ from src.common.permission_utils import (
get_assets_file_mode,
)
# Get logger without configuring
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
logger = get_logger(__name__)
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
#: size regardless, but a Face needs an active size before its metrics --
@@ -134,11 +133,6 @@ class _LogicalMatrix:
setattr(object.__getattribute__(self, "_matrix"), name, value)
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
# sync handshake compute the display size exactly as DisplayManager does
# without importing rgbmatrix. Aliased here for existing callers.
_resolve_double_sided = resolve_double_sided
class DisplayManager:
"""
@@ -160,7 +154,6 @@ class DisplayManager:
"""
_instance = None
_initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
@@ -203,7 +196,19 @@ class DisplayManager:
self._last_snapshot_ts = 0.0
self._last_snapshot_touch_ts = 0.0
self._last_snapshot_digest: Optional[int] = None
# The frame actually on disk. _last_snapshot_digest moves when a frame
# is handed to the writer; this only once it has been saved, so an
# mtime touch never vouches for a frame still waiting to be written.
self._saved_snapshot_digest: Optional[int] = None
self._snapshot_dir_prepared = False
# Background writer used mid-scroll; see _write_snapshot_if_due.
self._snapshot_cond = threading.Condition()
self._snapshot_pending: Optional[Tuple[Image.Image, Optional[int]]] = None
self._snapshot_thread: Optional[threading.Thread] = None
self._snapshot_stop = False
# Held for the whole of each PNG write, by the writer thread and by
# the inline static path, so the two land on disk in order.
self._snapshot_write_lock = threading.Lock()
self._viewer_check_ts = 0.0
self._viewer_fresh = False
self._viewer_was_fresh = False
@@ -233,6 +238,11 @@ class DisplayManager:
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1
# Timing of every presented frame, whoever drew it, for
# scripts/frame_soak.py. See src/common/frame_timing.py.
self.frame_timing = FrameTimingRecorder(info=self._frame_timing_info())
self.frame_timing.scrolling_now = self._scrolling_now
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
@@ -249,20 +259,16 @@ class DisplayManager:
font_time = time.time()
self._load_fonts()
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
# Initialize managers
# Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
"""Compose pixel_mapper_config with the orientation setting.
def _new_canvas(self, width: int, height: int) -> None:
"""Replace ``image``/``draw`` with a black canvas of the given size.
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
web preview shares so it sizes the canvas the same way.
Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
brightness, so anti-aliasing only smears glyphs.
"""
return compose_pixel_mapper_config(hardware_config)
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1"
@staticmethod
def _fallback_advice(cause: str, error: Exception) -> str:
@@ -331,7 +337,7 @@ class DisplayManager:
# 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)
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(
@@ -340,9 +346,7 @@ class DisplayManager:
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.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self._new_canvas(self.matrix.width, self.matrix.height)
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P
@@ -373,7 +377,7 @@ class DisplayManager:
fallback_width, fallback_height = physical_size(self.config)
# 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)
ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds
if ds is not None:
fallback_width = ds['logical_width']
@@ -381,9 +385,7 @@ class DisplayManager:
except Exception:
fallback_width, fallback_height = 128, 32
self.image = Image.new('RGB', (fallback_width, fallback_height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self._new_canvas(fallback_width, fallback_height)
# Simple fallback visualization so web UI shows a realistic canvas
try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -612,18 +614,14 @@ class DisplayManager:
line, font=font, fill=(0, 0, 255))
def _draw_test_pattern(self):
"""Draw a test pattern to verify the display is working."""
"""Draw a test pattern to verify the display is working.
Only called from _setup_matrix once the matrix exists; fallback mode
draws its own "Simulation" canvas there.
"""
try:
self.clear()
if self.matrix is None:
# Fallback mode - just draw on the image
self.draw.rectangle([0, 0, self.image.width-1, self.image.height-1], outline=(255, 0, 0))
self.draw.line([0, 0, self.image.width-1, self.image.height-1], fill=(0, 255, 0))
self.draw.text((10, 10), "Simulation", font=self.font, fill=(0, 0, 255))
logger.info("Drew test pattern in fallback mode")
return
# Draw a red rectangle border
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
@@ -638,7 +636,7 @@ class DisplayManager:
# Update the display once after everything is drawn
self.update_display()
time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation
time.sleep(0.5)
except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@@ -713,9 +711,7 @@ class DisplayManager:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self._new_canvas(target_w, target_h)
yield
finally:
self.matrix = real_matrix
@@ -810,15 +806,21 @@ class DisplayManager:
# Copy the current image to the offscreen canvas. In double-sided
# mode the logical screen is first tiled across the full chain.
blit_started = time.perf_counter()
if self._double_sided is not None:
self.offscreen_canvas.SetImage(self._composite_double_sided())
else:
self.offscreen_canvas.SetImage(self._scan_compensated(self.image))
blit_done = time.perf_counter()
# Swap buffers immediately. framerate_fraction holds the frame
# for N refreshes; SwapOnVSync blocks for all of them, which is
# what paces the render loop to the chosen frame rate.
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
presented_at = time.perf_counter()
self.frame_timing.record(
blit_done - blit_started, presented_at - blit_done,
self._frame_hold, self.is_currently_scrolling(), presented_at)
# Swap our canvas references
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
@@ -877,28 +879,14 @@ class DisplayManager:
try:
if self.matrix is None:
# Fallback mode - just clear the image
# Explicitly clear old image reference to help garbage collection
old_image = getattr(self, 'image', None)
width = old_image.width if old_image else 64
height = old_image.height if old_image else 64
if old_image is not None:
del old_image
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self._new_canvas(width, height)
logger.debug("Cleared display in fallback mode")
return
# Explicitly clear old image reference to help garbage collection
old_image = getattr(self, 'image', None)
if old_image is not None:
del old_image
# Create a new black image
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self._new_canvas(self.matrix.width, self.matrix.height)
if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -928,43 +916,16 @@ class DisplayManager:
logger.error(f"Error clearing display: {e}")
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
"""Draw text using BDF font with proper bitmap handling."""
"""Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left.
Delegates to :func:`src.common.bdf_font.draw_bdf_text`, which the
plugin test harness uses too, so previews and golden images show the
pixels the panel does. Clipped to the logical display size.
"""
try:
# Use the passed font or fall back to calendar_font
face = font if font else self.calendar_font
# Compute baseline from font ascender so caller can pass top-left y
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
for char in text:
face.load_char(char)
bitmap = face.glyph.bitmap
# Get glyph metrics
glyph_left = face.glyph.bitmap_left
glyph_top = face.glyph.bitmap_top
# Draw the character
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * bitmap.pitch + (j // 8)
if byte_index < len(bitmap.buffer):
byte = bitmap.buffer[byte_index]
if byte & (1 << (7 - (j % 8))):
# Calculate actual pixel position
pixel_x = x + glyph_left + j
pixel_y = baseline_y - glyph_top + i
# Only draw if within bounds
if (0 <= pixel_x < self.width and 0 <= pixel_y < self.height):
self.draw.point((pixel_x, pixel_y), fill=color)
# Move to next character
x += face.glyph.advance.x >> 6
draw_bdf_text(self.draw, text, x, y, face, color,
clip=(self.width, self.height))
except Exception as e:
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
@@ -1013,19 +974,13 @@ class DisplayManager:
if not os.path.exists(self.calendar_font_path):
raise FileNotFoundError(f"Font file not found at {self.calendar_font_path}")
# Load with freetype for proper BDF handling
face = freetype.Face(self.calendar_font_path)
# A freshly constructed Face has no active size, so
# face.size.height is 0 until set_char_size is called -- and
# get_font_height() reads exactly that. Without this, every
# caller measuring the 5x7 face got 0 and stacked rows on top
# of one another; the "Calendar font size: 0 pixels" line
# below has been printing the symptom on every start-up.
# font_manager._load_bdf_font already does this; the two paths
# disagreed about whether a Face was usable for measurement.
# 5x7.bdf is a fixed strike, so FreeType renders 7px whatever
# is asked for -- this sets the metrics, not the raster.
face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72)
# load_bdf_face sets the size: a Face built without
# set_char_size reports face.size.height 0, and every caller
# measuring the 5x7 face with get_font_height() got 0 and
# stacked rows on top of one another. 5x7.bdf is a fixed
# strike, so FreeType renders 7px whatever is asked for --
# the size sets the metrics, not the raster.
face, _ = load_bdf_face(self.calendar_font_path, _CALENDAR_FONT_PX)
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
@@ -1383,6 +1338,8 @@ class DisplayManager:
def cleanup(self):
"""Clean up resources."""
if hasattr(self, '_snapshot_cond'):
self._stop_snapshot_writer()
if hasattr(self, 'matrix') and self.matrix is not None:
try:
self.matrix.Clear()
@@ -1391,14 +1348,14 @@ class DisplayManager:
# Ensure image/draw are reset to a blank state
if hasattr(self, 'image') and hasattr(self, 'draw'):
try:
self.image = Image.new('RGB', (self.width, self.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
self._new_canvas(self.width, self.height)
except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True)
# The stall watchdog would otherwise outlive this manager.
if getattr(self, 'frame_timing', None) is not None:
self.frame_timing.close()
# Reset the singleton state when cleaning up
DisplayManager._instance = None
DisplayManager._initialized = False
def format_date_with_ordinal(self, dt):
"""Formats a datetime object into 'Mon Aug 30th' style."""
@@ -1436,9 +1393,9 @@ class DisplayManager:
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
# _build_pixel_mapper_config reads only class attributes, so the class
# stands in for an instance here.
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
# Orientation becomes a "Rotate:<deg>" pixel mapper; the web preview
# composes it the same way so it sizes the canvas identically.
options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '')
@@ -1492,6 +1449,27 @@ class DisplayManager:
value = 0.0
return value if value > 0 else 100.0
def _scrolling_now(self) -> bool:
"""Whether a scroll is running, without is_currently_scrolling()'s
side effect of expiring the state -- safe from the stall watchdog's
thread."""
state = self._scrolling_state
return bool(state['is_scrolling']) and (
time.time() - state['last_scroll_activity']
<= state['scroll_inactivity_threshold'])
def _frame_timing_info(self) -> Dict[str, Any]:
"""What the frame-timing stats were measured on, for the soak report."""
display = self.config.get('display') or {}
hardware = display.get('hardware') or {}
runtime = display.get('runtime') or {}
info = {key: hardware.get(key) for key in (
'rows', 'cols', 'chain_length', 'parallel', 'pwm_bits',
'hardware_mapping', 'limit_refresh_rate_hz', 'pixel_mapper_config')}
info['gpio_slowdown'] = runtime.get('gpio_slowdown')
info['emulator'] = os.environ.get('EMULATOR', 'false') == 'true'
return info
def set_frame_hold(self, refreshes: int) -> None:
"""Hold each pushed frame for this many panel refreshes (>=1).
@@ -1531,13 +1509,16 @@ class DisplayManager:
that does not care gets a new frame every refresh.
"""
current_time = time.time()
# Scrolling callers set this every frame; log transitions only.
changed = self._scrolling_state['is_scrolling'] != is_scrolling
self._scrolling_state['is_scrolling'] = is_scrolling
if is_scrolling:
self._scrolling_state['last_scroll_activity'] = current_time
self.set_frame_hold(frame_hold)
else:
self._frame_hold = 1
logger.debug(f"Scrolling state set to: {is_scrolling}")
if changed:
logger.debug("Scrolling state set to: %s", is_scrolling)
def is_currently_scrolling(self) -> bool:
"""Check if the display is currently in a scrolling state."""
@@ -1606,9 +1587,6 @@ class DisplayManager:
if not self._scrolling_state['deferred_updates']:
return
if not self._scrolling_state['deferred_updates']:
return
# Process only a limited number of updates per call to avoid blocking
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
@@ -1706,67 +1684,149 @@ class DisplayManager:
viewer_fresh, digest != self._last_snapshot_digest)
if action is snapshot_policy.SnapshotAction.SKIP:
return
if action is snapshot_policy.SnapshotAction.TOUCH:
if (action is snapshot_policy.SnapshotAction.TOUCH
and self._saved_snapshot_digest == digest):
# mtime bump only: keeps the health check (snapshot age)
# green without paying for a PNG encode of an unchanged frame
os.utime(self._snapshot_path, None)
self._last_snapshot_touch_ts = now
return
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
# its write failed -- is written instead: touching would make the
# older file on disk look current.)
# WRITE: ensure directory permissions once, not per frame
snapshot_path_obj = Path(self._snapshot_path)
if not self._snapshot_dir_prepared:
# Never modify /tmp permissions - it has special system
# permissions (1777) that must not be changed or it breaks
# apt and other system tools
parent_dir = snapshot_path_obj.parent
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
self._snapshot_dir_prepared = True
# Write atomically: temp then replace. The temp name must be
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
# and this file is written by whichever user the display service
# runs as while tests and tooling run as someone else. A leftover
# fixed-name temp owned by another user is then unopenable even by
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
# sticky dir), which froze the preview and the health check's
# liveness proxy until somebody deleted it by hand. Same pattern as
# the hardware-status write above.
_fd, tmp_path = tempfile.mkstemp(
dir=str(snapshot_path_obj.parent),
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
try:
with os.fdopen(_fd, "wb") as _f:
self.image.save(_f, format='PNG')
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, self._snapshot_path)
except Exception:
# Never leave the temp behind -- that is what made the failure
# permanent rather than transient.
try:
os.unlink(tmp_path)
except OSError:
pass
# Fallback to direct save if replace not supported
self.image.save(self._snapshot_path, format='PNG')
# Set proper file permissions after saving
try:
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
except Exception:
pass
# WRITE. Mid-scroll the PNG encode goes to a background thread: at
# 512x64 it takes 12-14ms on a Pi 4, longer than a 95Hz refresh,
# so on the render thread every preview write made the next swap
# miss its vsync -- five visible hitches a second, but only while
# someone had the web preview open. Pillow releases the GIL while
# it compresses, so the encode no longer holds the loop up. Static
# frames still write inline: nothing is moving to disturb.
if self.is_currently_scrolling():
self._queue_snapshot(self.image.copy(), digest)
else:
# A scroll that just ended can leave its last frame queued or
# mid-write; it must not land on top of this newer one.
with self._snapshot_write_lock:
with self._snapshot_cond:
self._snapshot_pending = None
self._save_snapshot(self.image)
self._saved_snapshot_digest = digest
self._last_snapshot_ts = now
self._last_snapshot_touch_ts = now
self._last_snapshot_digest = digest
except Exception as e:
# 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}")
self._log_snapshot_failure(e)
def _log_snapshot_failure(self, error: Exception) -> None:
# 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.
now = time.time()
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", error)
else:
logger.debug(f"Snapshot write skipped: {error}")
def _save_snapshot(self, image: Image.Image) -> None:
"""Encode ``image`` to the snapshot path atomically. Raises on failure."""
# Ensure directory permissions once, not per frame
snapshot_path_obj = Path(self._snapshot_path)
if not self._snapshot_dir_prepared:
# Never modify /tmp permissions - it has special system
# permissions (1777) that must not be changed or it breaks
# apt and other system tools
parent_dir = snapshot_path_obj.parent
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
self._snapshot_dir_prepared = True
# Write atomically: temp then replace. The temp name must be
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
# and this file is written by whichever user the display service
# runs as while tests and tooling run as someone else. A leftover
# fixed-name temp owned by another user is then unopenable even by
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
# sticky dir), which froze the preview and the health check's
# liveness proxy until somebody deleted it by hand. Same pattern as
# the hardware-status write above.
_fd, tmp_path = tempfile.mkstemp(
dir=str(snapshot_path_obj.parent),
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
try:
with os.fdopen(_fd, "wb") as _f:
image.save(_f, format='PNG')
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, self._snapshot_path)
except Exception:
# Never leave the temp behind -- that is what made the failure
# permanent rather than transient.
try:
os.unlink(tmp_path)
except OSError:
pass
# Fallback to direct save if replace not supported
image.save(self._snapshot_path, format='PNG')
# Set proper file permissions after saving
try:
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
except Exception:
pass
def _queue_snapshot(self, image: Image.Image, digest: Optional[int] = None) -> None:
"""Hand a frame to the snapshot writer thread; the newest frame wins.
One slot, not a queue: if the writer is still encoding when the next
frame is due, the waiting frame is simply replaced. The preview wants
the latest frame, and a backlog would only cost memory and CPU.
"""
with self._snapshot_cond:
self._snapshot_pending = (image, digest)
if self._snapshot_thread is None or not self._snapshot_thread.is_alive():
self._snapshot_thread = threading.Thread(
target=self._snapshot_writer, daemon=True,
name="snapshot-writer")
self._snapshot_thread.start()
self._snapshot_cond.notify()
def _snapshot_writer(self) -> None:
while True:
with self._snapshot_cond:
while self._snapshot_pending is None and not self._snapshot_stop:
self._snapshot_cond.wait()
if self._snapshot_stop:
return # shutting down: a pending frame is dropped
# The write lock before the frame: whichever of this and an inline
# static save gets it first also writes first, and a static save
# clears the slot, so an older frame never lands on a newer one.
with self._snapshot_write_lock:
with self._snapshot_cond:
pending, self._snapshot_pending = self._snapshot_pending, None
if pending is None:
continue
image, digest = pending
try:
self._save_snapshot(image)
self._saved_snapshot_digest = digest
except Exception as e:
# The frame was recorded as written when it was queued.
# Forget that, so an unchanged frame is written again
# rather than only mtime-touching a stale file into
# looking healthy.
self._last_snapshot_digest = None
self._log_snapshot_failure(e)
def _stop_snapshot_writer(self, timeout: float = 1.0) -> None:
"""Stop the writer thread, dropping any frame it has not started."""
with self._snapshot_cond:
self._snapshot_stop = True
self._snapshot_pending = None
self._snapshot_cond.notify_all()
thread = self._snapshot_thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout)
self._snapshot_thread = None
+5 -1
View File
@@ -21,6 +21,8 @@ import time
import requests
from typing import Dict, List
from src.common.api_helper import DEFAULT_HTTP_HEADERS
logger = logging.getLogger(__name__)
class DynamicTeamResolver:
@@ -141,7 +143,9 @@ class DynamicTeamResolver:
self.logger.info("Fetching fresh NCAA Football rankings from ESPN API")
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
response = requests.get(rankings_url, timeout=self.request_timeout)
# ESPN rejects requests' default User-Agent; see api_helper.USER_AGENT.
response = requests.get(rankings_url, headers=dict(DEFAULT_HTTP_HEADERS),
timeout=self.request_timeout)
response.raise_for_status()
data = response.json()
+43 -72
View File
@@ -53,13 +53,9 @@ from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from PIL import ImageFont
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
from src.common.font_layout import load_truetype
try:
import freetype
except ImportError: # pragma: no cover - freetype ships with the core
freetype = None
logger = logging.getLogger(__name__)
# Core install root (the directory that contains src/ and assets/fonts/),
@@ -94,6 +90,17 @@ def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
# Config keys a style element block carries, in schema/UI order.
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
# Title of every generated `layout` (x/y offset) group in the config form.
_LAYOUT_TITLE = 'Layout Offsets'
#: Bounds on a user-set ``customization.layout.<element>.scale``. They are the
#: Scale field's minimum and maximum in the generated schema, and every reader
#: (coerce_scale, element_scale, LogoHelper.load_logo) clamps to them, so the
#: web form and the renderer agree. Below 0.1 a logo is a dot; ten times a
#: panel-sized box is already far off the panel.
MIN_ELEMENT_SCALE = 0.1
MAX_ELEMENT_SCALE = 10.0
@dataclass(frozen=True)
class ElementStyle:
@@ -164,56 +171,25 @@ def native_bdf_size(font_name: str) -> Optional[int]:
def _read_bdf_native_size(path: str) -> Optional[int]:
"""A BDF file's own pixel size, delegated to FontManager.
"""A BDF file's own pixel size (the web UI's fonts API imports this name).
Deliberately not reimplemented: FontManager's reader prefers PIXEL_SIZE
over the SIZE line's point-size (they differ on the several bundled
fonts defined at 75dpi) and stops at the first STARTCHAR. Core always
ships it; the guard is for the plugin test harnesses that stub the
module out.
See :func:`src.common.bdf_font.read_bdf_native_size`: it prefers
PIXEL_SIZE over the SIZE line's point-size, which differ on the several
bundled fonts defined at 75dpi.
"""
try:
from src.font_manager import FontManager
return FontManager._read_bdf_native_size(path)
except Exception: # pragma: no cover - defensive
return None
return read_bdf_native_size(path)
def _load_bdf(path: str, size: int) -> Tuple[Any, int]:
"""A ``freetype.Face`` for a BDF file at the closest size it can do.
BDF fonts are fixed-size bitmap strikes, not scalable outlines:
FreeType accepts only the exact pixel size baked into the file and
raises for anything else. 32 of the 35 shipped fonts are BDF, so a
size the user picked in the web UI usually is not a valid strike.
Retrying at the file's native size is the behaviour SportsCore already
has (``_load_custom_font_from_element_config``). Without it this
function fell through to the generic except below and returned
*PressStart2P* — so choosing 5x7.bdf at size 10 silently rendered a
completely different typeface rather than 5x7 at 7px.
BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel
size baked into the file, and 32 of the 35 shipped fonts are BDF, so a
size the user picked in the web UI usually is not a valid strike. The
shared loader retries at the native size; without that, 5x7.bdf at size
10 used to fall through to *PressStart2P*, a different typeface.
"""
if freetype is None:
raise RuntimeError("freetype not available for BDF fonts")
def _face_at(px: int) -> Any:
face = freetype.Face(path)
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(px * 64, px * 64, 72, 72)
return face
try:
return _face_at(size), size
except Exception:
native = _read_bdf_native_size(path)
if not native or native == size:
raise
# A fresh Face: the first one already took a failed set_char_size.
face = _face_at(native)
logger.debug("BDF font %s loaded at its native size %s "
"(requested %s is not a strike in this file)",
path, native, size)
return face, native
return load_bdf_face(path, size)
def load_font(font_name: str, size: int) -> Any:
@@ -336,7 +312,7 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
if layout_props:
layout = props.setdefault('layout', {
'type': 'object',
'title': 'Layout Offsets',
'title': _LAYOUT_TITLE,
'description': 'Pixel offsets applied to each element '
'(positive x moves right, positive y moves down)',
'x-advanced': True,
@@ -380,16 +356,14 @@ def _element_block_from_spec(element_key: str,
'type': 'string',
'title': 'Font Family',
'x-advanced': True,
# The core already ships this widget and the config form already
# allowlists it; without the hint the field rendered as a bare
# text box the user had to type a filename into.
# The core's font picker; without the hint the form renders a
# bare text box the user has to type a filename into.
'x-widget': 'font-selector',
}
# A bitmap font ignores font_size and renders at its own baked-in
# size, so the size ceiling has to be enforced when picking the
# font, not when setting the size.
max_size = (size_spec or {}).get('max') if isinstance(
spec.get('size'), dict) else None
max_size = size_spec.get('max') if size_spec else None
if isinstance(max_size, (int, float)):
font_prop['x-options'] = {'maxFixedSize': max_size}
if 'default' in font_spec:
@@ -492,8 +466,8 @@ def _offset_block_from_spec(element_key: str,
'title': 'Scale',
'description': 'Size multiplier; 1 is the shipped size.',
'default': 1.0,
'minimum': 0.1,
'maximum': 10.0,
'minimum': MIN_ELEMENT_SCALE,
'maximum': MAX_ELEMENT_SCALE,
'x-advanced': True,
}
if isinstance(scale_spec, dict):
@@ -574,7 +548,7 @@ def _modes_block(declaration: Dict[str, Any],
if layout_props:
element_props['layout'] = {
'type': 'object',
'title': 'Layout Offsets',
'title': _LAYOUT_TITLE,
'x-advanced': True,
'additionalProperties': False,
'properties': layout_props,
@@ -715,7 +689,7 @@ def _modes_block_from_properties(props: Dict[str, Any], element_keys: list,
if layout_props:
element_props['layout'] = {
'type': 'object',
'title': 'Layout Offsets',
'title': _LAYOUT_TITLE,
'x-advanced': True,
'additionalProperties': False,
'properties': layout_props,
@@ -1045,12 +1019,15 @@ def _coerce_align(value: Any) -> Optional[str]:
return None
def _coerce_scale(value: Any, default: float) -> float:
"""A positive size multiplier, or ``default``.
def coerce_scale(value: Any, default: float = 1.0) -> float:
"""A usable size multiplier: ``value`` clamped to
[MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE], or ``default``.
Clamped rather than merely validated: a scale of 0 or a negative one is
a zero-or-inverted image, and the panel is 32 pixels tall -- a typo
should cost a wrong size, not a crash inside PIL.
``default`` is returned for anything that is not a finite positive number
(None, a bool, a string, 0, a negative, NaN, infinity): those are typos,
and a typo should cost the shipped size, not a blank or inverted image or
a crash inside PIL. A positive number outside the range is a real request
for "smaller" or "bigger", so it is clamped rather than ignored.
"""
if isinstance(value, bool) or value is None:
return default
@@ -1058,9 +1035,9 @@ def _coerce_scale(value: Any, default: float) -> float:
scale = float(value)
except (TypeError, ValueError):
return default
if scale <= 0:
if not math.isfinite(scale) or scale <= 0:
return default
return min(scale, 10.0)
return min(max(scale, MIN_ELEMENT_SCALE), MAX_ELEMENT_SCALE)
def _coerce_offset(value: Any, default: int, element_key: str,
@@ -1229,7 +1206,7 @@ def element_scale(config: Any, element_key: str, default: float = 1.0,
try:
value = _element_field(config, element_key, 'scale', mode,
in_layout=True)
return default if value is None else _coerce_scale(value, default)
return default if value is None else coerce_scale(value, default)
except Exception as e:
logger.warning("Error reading scale for %s: %s", element_key, e)
return default
@@ -1406,12 +1383,6 @@ class ElementStyleResolver:
return {}
return _lookup_element(block.get('layout'), element_key)
@classmethod
def _layout_axis(cls, block: Dict[str, Any], element_key: str,
axis: str) -> Any:
"""``block['layout'][element][axis]``, or None if absent anywhere."""
return cls._layout_element(block, element_key).get(axis)
# -- resolution internals -----------------------------------------------
@@ -1532,7 +1503,7 @@ class ElementStyleResolver:
user_forced_color=bool(color_forced),
visible=_coerce_bool(visible, True),
align=_coerce_align(align),
scale=_coerce_scale(scale, 1.0),
scale=coerce_scale(scale, 1.0),
)
def _classic_style(self, classic_font: str, classic_size: int,
+16 -3
View File
@@ -26,6 +26,19 @@ from src.exceptions import LEDMatrixError
from src.redaction import redact_credentials
def _format_trace(error: BaseException) -> str:
"""The traceback carried by ``error`` itself.
Callers often record an exception after its ``except`` block has ended,
or from another thread than the one that raised it (plugin_executor runs
plugins on worker threads), where ``traceback.format_exc()`` has nothing
to report. The exception object keeps its own ``__traceback__``, so the
trace is built from that. An exception that was created but never raised
has no traceback, and the result is just its type and message.
"""
return "".join(traceback.format_exception(type(error), error, error.__traceback__))
@dataclass
class ErrorRecord:
"""Record of a single error occurrence."""
@@ -145,8 +158,8 @@ class ErrorAggregator:
with self._lock:
error_type = type(error).__name__
# Extract additional context from LEDMatrixError subclasses
error_context = context or {}
# A copy, so the caller's dict is not changed behind its back.
error_context = dict(context) if context else {}
if isinstance(error, LEDMatrixError) and error.context:
error_context.update(error.context)
@@ -157,7 +170,7 @@ class ErrorAggregator:
context=error_context,
plugin_id=plugin_id,
operation=operation,
stack_trace=traceback.format_exc()
stack_trace=_format_trace(error)
)
# Add record (with size limit)
+63 -119
View File
@@ -38,7 +38,13 @@ import time
from collections import OrderedDict
from pathlib import Path
from PIL import ImageFont
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
from src.common.font_layout import load_truetype, resolve_asset_path
from src.common.permission_utils import (
ensure_directory_permissions,
get_assets_dir_mode,
get_config_dir_mode,
)
from typing import Dict, Tuple, Optional, Union, Any, List
from src.deprecation import deprecated
@@ -56,7 +62,6 @@ class FontManager:
def __init__(self, config: Dict[str, Any]):
self.config = config
self.fonts_config = config.get("fonts", {})
# Font discovery and catalog
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
@@ -72,10 +77,8 @@ class FontManager:
# Plugin font management
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
self.plugin_font_catalogs: Dict[str, Dict[str, str]] = {} # plugin_id -> {family_name -> file_path}
self.font_metadata: Dict[str, Dict[str, Any]] = {} # family_name -> metadata
self.font_dependencies: Dict[str, List[str]] = {} # family_name -> [required_families]
# Manager font registration - NEW for manager-centric model
# Fonts managers and plugins report using (register_manager_font).
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
# Bumped when a manager's registered families change (not when one
@@ -87,13 +90,10 @@ class FontManager:
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
self.temp_font_dir.mkdir(exist_ok=True)
# Performance monitoring
# Counters behind get_performance_stats().
self.performance_stats = {
"font_load_times": {},
"cache_hits": 0,
"cache_misses": 0,
"render_times": {},
"total_renders": 0,
"failed_loads": 0,
"start_time": time.time()
}
@@ -104,9 +104,6 @@ class FontManager:
"four_by_six": "assets/fonts/4x6-font.ttf",
"five_by_seven": "assets/fonts/5x7.bdf",
"tom_thumb": "assets/fonts/tom-thumb.bdf"
# Note: cozette_bdf removed - font file not available
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
}
# Size tokens for convenience
@@ -115,7 +112,10 @@ class FontManager:
}
# Font overrides storage (for manual overrides)
self.font_overrides_file = "config/font_overrides.json"
# Under the install root's config/ (which always exists), not the
# cwd: the file itself may not exist yet, and resolve_asset_path
# hands back a missing path unchanged.
self.font_overrides_file = os.path.join(resolve_asset_path("config"), "font_overrides.json")
self.font_overrides: Dict[str, Dict[str, Any]] = {}
# Bumped whenever cached font objects are invalidated, so holders of
@@ -127,7 +127,6 @@ class FontManager:
def reload_config(self, new_config: Dict[str, Any]):
"""Reload configuration and refresh font catalog."""
self.config = new_config
self.fonts_config = new_config.get("fonts", {})
self.font_cache.clear() # Clear cache to force reload
self.metrics_cache.clear() # Clear metrics cache
self.cache_generation += 1
@@ -135,7 +134,6 @@ class FontManager:
logger.info("FontManager configuration reloaded successfully")
# ==================== Manager Font Registration ====================
# NEW: Support for managers to register their font choices dynamically
def register_manager_font(self, manager_id: str, element_key: str,
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
@@ -208,16 +206,23 @@ class FontManager:
# ==================== Plugin Font Management ====================
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any]) -> bool:
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
"""
Register fonts for a specific plugin.
Args:
plugin_id: Unique identifier for the plugin
font_manifest: Font manifest from plugin's manifest.json
font_manifest: The ``fonts`` block of the plugin's manifest.json
plugin_dir: The plugin's directory, which ``plugin://`` sources
are relative to. PluginManager passes the directory it loaded
the plugin from. When omitted, the plugin is looked up in the
configured ``plugin_system.plugins_directory`` and then in
``plugins/``.
Returns:
True if registration successful, False otherwise
True if the manifest was valid (individual fonts that fail to load
are logged and skipped), False otherwise
"""
try:
# Validate font manifest structure
@@ -234,7 +239,7 @@ class FontManager:
# Process font definitions
fonts = font_manifest.get("fonts", [])
for font_def in fonts:
if self._register_plugin_font(plugin_id, font_def):
if self._register_plugin_font(plugin_id, font_def, plugin_dir):
logger.info(f"Successfully registered font {font_def.get('family')} for plugin {plugin_id}")
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
@@ -269,7 +274,8 @@ class FontManager:
return True
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any]) -> bool:
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any],
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
"""Register a single font from a plugin."""
try:
family = font_def["family"]
@@ -283,7 +289,7 @@ class FontManager:
elif source.startswith("plugin://"):
# Relative to plugin directory
relative_path = source.replace("plugin://", "")
font_path = self._resolve_plugin_font_path(plugin_id, relative_path)
font_path = self._resolve_plugin_font_path(plugin_id, relative_path, plugin_dir)
else:
# Absolute or relative path
font_path = source
@@ -297,14 +303,6 @@ class FontManager:
self.plugin_font_catalogs[plugin_id][family] = font_path
self.font_catalog[namespaced_family] = font_path
# Store metadata
if "metadata" in font_def:
self.font_metadata[namespaced_family] = font_def["metadata"]
# Store dependencies
if "dependencies" in font_def:
self.font_dependencies[namespaced_family] = font_def["dependencies"]
logger.info(f"Registered plugin font: {namespaced_family} -> {font_path}")
return True
@@ -366,11 +364,15 @@ class FontManager:
return '.zip'
return '.ttf' # default
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str) -> Optional[str]:
"""Resolve a plugin-relative font path."""
# Assume plugins are in a 'plugins' directory
plugin_dir = Path("plugins") / plugin_id
font_path = plugin_dir / relative_path
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str,
plugin_dir: Optional[Union[str, Path]] = None) -> Optional[str]:
"""Resolve a ``plugin://`` font path against the plugin's directory."""
if plugin_dir is None:
plugin_dir = self._find_plugin_dir(plugin_id)
if plugin_dir is None:
logger.error(f"Plugin font {relative_path}: directory for plugin {plugin_id} not found")
return None
font_path = Path(plugin_dir) / relative_path
if font_path.exists():
return str(font_path)
@@ -378,6 +380,18 @@ class FontManager:
logger.error(f"Plugin font not found: {font_path}")
return None
def _find_plugin_dir(self, plugin_id: str) -> Optional[Path]:
"""The installed directory of ``plugin_id``, for callers of
register_plugin_fonts that do not pass one: the configured plugins
directory (relative paths are relative to the install root), then the
legacy ``plugins/`` directory."""
# Imported here: src.plugin_system's package import loads PluginManager.
from src.plugin_system.plugin_dirs import resolve_plugin_dir
configured = (self.config.get("plugin_system") or {}).get("plugins_directory") or "plugin-repos"
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
@deprecated("3.7.0")
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
"""Unregister all fonts for a plugin."""
@@ -389,8 +403,6 @@ class FontManager:
namespaced_family = f"{plugin_id}::{family}"
if namespaced_family in self.font_catalog:
del self.font_catalog[namespaced_family]
if namespaced_family in self.font_metadata:
del self.font_metadata[namespaced_family]
del self.plugin_font_catalogs[plugin_id]
@@ -441,8 +453,6 @@ class FontManager:
Returns:
Resolved font object
"""
start_time = time.time()
try:
# Check for manual overrides first
if element_key in self.font_overrides:
@@ -459,14 +469,7 @@ class FontManager:
if plugin_id in self.plugin_font_catalogs and family in self.plugin_font_catalogs[plugin_id]:
family = f"{plugin_id}::{family}"
# Get the font
font = self.get_font(family, size_px)
# Record performance
duration = time.time() - start_time
self._record_performance_metric("resolve", f"{family}_{size_px}", duration)
return font
return self.get_font(family, size_px)
except Exception as e:
logger.error(f"Error resolving font for {element_key}: {e}", exc_info=True)
@@ -490,7 +493,6 @@ class FontManager:
return self.font_cache[cache_key]
self.performance_stats["cache_misses"] += 1
start_time = time.time()
# Load font
font_path = self.font_catalog.get(family)
@@ -505,44 +507,23 @@ class FontManager:
else:
font = load_truetype(font_path, size_px)
except Exception as e:
# The one log line for a failed load: _load_bdf_font lets
# its error propagate to here.
logger.error(f"Error loading font {font_path}: {e}")
self.performance_stats["failed_loads"] += 1
font = ImageFont.load_default()
# Cache and record performance
self.font_cache[cache_key] = font
duration = time.time() - start_time
self.performance_stats["font_load_times"][cache_key] = duration
return font
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
"""Load a BDF font using FreeType."""
try:
native_size = self._read_bdf_native_size(font_path)
if native_size is not None and native_size != size_px:
# BDF is a fixed-strike bitmap format: FreeType renders the
# native size no matter what set_char_size asks for.
logger.debug(
"BDF font %s requested at %spx but renders at its native "
"%spx", font_path, size_px, native_size
)
face = freetype.Face(font_path)
try:
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
except freetype.FT_Exception:
# FreeType rejects any size but the strike's own, and get_font
# used to answer that with PIL's default font -- a different
# typeface. Use the native strike, as element_style does.
if native_size is None or native_size == size_px:
raise
face = freetype.Face(font_path)
face.set_char_size(native_size * 64, native_size * 64, 72, 72)
return face
except Exception as e:
logger.error(f"Error loading BDF font {font_path}: {e}")
raise
"""Load a BDF font through the shared loader.
A size the file has no strike for comes back at the native strike
rather than failing over to PIL's default font, a different typeface
(see :func:`src.common.bdf_font.load_bdf_face`).
"""
return load_bdf_face(font_path, size_px)[0]
def get_native_bdf_size(self, family: str) -> Optional[int]:
"""The one true pixel size of a BDF family in the catalog, or None
@@ -554,30 +535,9 @@ class FontManager:
@staticmethod
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
"""Read a BDF file's own header to find its one true pixel size.
Prefers the PIXEL_SIZE property, which states the real pixel height
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE
is absent, since point-size only equals pixel height at exactly
100dpi — several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined
at 75dpi, where the two values genuinely differ."""
size_line_value = None
try:
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
for line in f:
if line.startswith("PIXEL_SIZE"):
parts = line.split()
if len(parts) >= 2:
return int(float(parts[1]))
elif line.startswith("SIZE") and size_line_value is None:
# Format: "SIZE <point_size> <xres> <yres>"
parts = line.split()
if len(parts) >= 2:
size_line_value = int(float(parts[1]))
elif line.startswith("STARTCHAR"):
break
except (OSError, ValueError):
return None
return size_line_value
"""A BDF file's one true pixel size; see
:func:`src.common.bdf_font.read_bdf_native_size`."""
return read_bdf_native_size(bdf_path)
def _get_fallback_font(self) -> ImageFont.ImageFont:
"""Get a fallback font when loading fails."""
@@ -758,11 +718,6 @@ class FontManager:
def _save_overrides(self):
"""Save current font overrides to file."""
try:
from pathlib import Path
from src.common.permission_utils import (
ensure_directory_permissions,
get_config_dir_mode
)
font_overrides_path = Path(self.font_overrides_file)
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
with open(self.font_overrides_file, 'w') as f:
@@ -789,12 +744,6 @@ class FontManager:
"""Get available size tokens."""
return self.size_tokens.copy()
def _record_performance_metric(self, operation: str, font_key: str, duration: float):
"""Record a performance metric."""
if operation not in self.performance_stats:
self.performance_stats[operation] = {}
self.performance_stats[operation][font_key] = duration
@deprecated("3.7.0")
def get_performance_stats(self) -> Dict[str, Any]:
"""Get performance statistics."""
@@ -824,7 +773,8 @@ class FontManager:
@deprecated("3.7.0")
def add_font(self, font_file_path: str, family_name: str) -> bool:
"""Add a new font to the catalog."""
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
stays where it is; only assets/fonts is created if it is missing."""
try:
# Validate font file
if not os.path.exists(font_file_path):
@@ -836,13 +786,7 @@ class FontManager:
logger.warning(f"Font family '{family_name}' already exists")
return False
# Copy font to assets/fonts directory
from pathlib import Path
from src.common.permission_utils import (
ensure_directory_permissions,
get_assets_dir_mode
)
fonts_dir = Path("assets/fonts")
fonts_dir = Path(resolve_asset_path("assets/fonts"))
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
# Add to catalog
+50 -73
View File
@@ -16,7 +16,7 @@ import json
from typing import Dict, List, Optional, Tuple
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
from src.common.font_layout import load_truetype
from src.common.font_layout import load_truetype, resolve_asset_path
from PIL.PngImagePlugin import PngInfo
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
@@ -370,22 +370,15 @@ class LogoDownloader:
@staticmethod
def get_logo_filename_variations(abbreviation: str) -> list:
"""Get possible filename variations for a team abbreviation."""
variations = []
"""Filenames a logo for ``abbreviation`` may be stored under: the
upper-cased abbreviation as given, then its normalize_abbreviation()
form (``TA&M.png``, then ``TAANDM.png``)."""
original = abbreviation.upper()
normalized = LogoDownloader.normalize_abbreviation(abbreviation)
# Add original and normalized versions
variations.extend([f"{original}.png", f"{normalized}.png"])
# Special handling for known cases
if original == 'TA&M':
# TA&M has a file named TA&M.png, but normalize creates TAANDM.png
variations = [f"{original}.png", f"{normalized}.png"]
return variations
return [f"{original}.png", f"{normalized}.png"]
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
# Allowlist for a league name or code that goes into a filesystem path or
# an ESPN URL: lower-case alphanumerics, underscores and dashes only.
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
def get_logo_directory(self, league: str) -> str:
@@ -462,15 +455,12 @@ class LogoDownloader:
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
return False
# Allowlist for the league_code segment interpolated into ESPN API URLs
_SAFE_LEAGUE_CODE_RE = re.compile(r'^[a-z0-9_-]+$')
def _resolve_api_url(self, league: str) -> Optional[str]:
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
api_url = self.API_ENDPOINTS.get(league)
if not api_url and league.startswith('soccer_'):
league_code = league[len('soccer_'):]
if not self._SAFE_LEAGUE_CODE_RE.match(league_code):
if not self._SAFE_LEAGUE_RE.match(league_code):
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
return None
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
@@ -501,7 +491,8 @@ class LogoDownloader:
return None
def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
"""Fetch team data from ESPN API for a specific league."""
"""Fetch one team's record (``<teams endpoint>/<team_id>``) from the
ESPN API; None on any request or parse failure."""
api_url = self._resolve_api_url(league)
if not api_url:
logger.error(f"No API endpoint configured for league: {league}")
@@ -520,7 +511,7 @@ class LogoDownloader:
logger.error(f"Error fetching team data for {team_id} in {league}: {e}")
return None
except json.JSONDecodeError as e:
logger.error(f"Error parsing JSON response for{team_id} in {league}: {e}")
logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}")
return None
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
@@ -626,42 +617,6 @@ class LogoDownloader:
# Default to FBS for unknown conferences
return 'FBS'
def _get_team_name_variations(self, abbreviation: str) -> List[str]:
"""Generate common variations of a team abbreviation for matching."""
variations = set()
abbr = abbreviation.upper()
variations.add(abbr)
# Add normalized version
variations.add(self.normalize_abbreviation(abbr))
# Common substitutions
substitutions = {
'&': ['AND', 'A'],
'A&M': ['TAMU', 'TA&M', 'TEXASAM'],
'STATE': ['ST', 'ST.'],
'UNIVERSITY': ['U', 'UNIV'],
'COLLEGE': ['C', 'COL'],
'TECHNICAL': ['TECH', 'T'],
'NORTHERN': ['NORTH', 'N'],
'SOUTHERN': ['SOUTH', 'S'],
'EASTERN': ['EAST', 'E'],
'WESTERN': ['WEST', 'W']
}
# Apply substitutions
for original, replacements in substitutions.items():
if original in abbr:
for replacement in replacements:
variations.add(abbr.replace(original, replacement))
variations.add(abbr.replace(original, '')) # Remove the word entirely
# Add common abbreviations for Texas A&M
if 'A&M' in abbr or 'TAMU' in abbr:
variations.update(['TAMU', 'TA&M', 'TEXASAM', 'TEXAS_A&M', 'TEXAS_AM'])
return list(variations)
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
"""Download missing logos for a specific league."""
logger.info(f"Starting logo download for league: {league}")
@@ -794,7 +749,9 @@ class LogoDownloader:
return False
try:
logo_url = data["team"]["logos"][0]["href"]
except KeyError:
except (KeyError, IndexError, TypeError):
# A team without logos comes back with an empty list.
logger.debug(f"No logo URL for team {team_id} in {league}")
return False
# Download the logo
success = self.download_logo(logo_url, logo_path, team_abbreviation)
@@ -824,24 +781,39 @@ class LogoDownloader:
logger.info(f"Overall logo download results: {total_downloaded} downloaded, {total_failed} failed")
return results
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str) -> bool:
"""Create a placeholder logo when real logo cannot be downloaded."""
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str,
filepath: Optional[Path] = None) -> bool:
"""Write a grey placeholder with the team abbreviation on it, for when
the real logo cannot be downloaded.
Args:
team_abbreviation: Drawn on the placeholder.
logo_dir: Directory for the file when ``filepath`` is not given;
the file is then ``<normalize_abbreviation(abbr)>.png``.
filepath: The exact path to write, which is where the caller will
look for the logo. ``logo_dir`` is ignored when it is given.
Returns:
True if the placeholder was written, False otherwise (logged).
"""
if filepath is not None:
filepath = Path(filepath)
logo_dir = str(filepath.parent)
try:
# Ensure the logo directory exists
if not self.ensure_logo_directory(logo_dir):
logger.error(f"Failed to create logo directory: {logo_dir}")
return False
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
filepath = Path(logo_dir) / filename
# Create a simple placeholder logo
logo = Image.new('RGBA', (64, 64), (100, 100, 100, 255)) # Gray background
if filepath is None:
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
filepath = Path(logo_dir) / filename
logo = Image.new('RGBA', PLACEHOLDER_SIZE, PLACEHOLDER_BG)
draw = ImageDraw.Draw(logo)
# Try to load a font, fallback to default
try:
font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
font = load_truetype(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), 12)
except (OSError, IOError):
try:
font = ImageFont.load_default()
@@ -855,8 +827,8 @@ class LogoDownloader:
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (64 - text_width) // 2
y = (64 - text_height) // 2
x = (PLACEHOLDER_SIZE[0] - text_width) // 2
y = (PLACEHOLDER_SIZE[1] - text_height) // 2
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
else:
# Fallback without font
@@ -963,14 +935,19 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
Convenience function to download a missing team logo.
Args:
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
league: League identifier (e.g., 'ncaa_fb', 'nfl')
logo_path: Full path to where the logo should be saved
team_id: ESPN team id, used to look up the logo URL when
``logo_url`` is not given
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
logo_path: Where the logo (or placeholder) is written; relative paths
are relative to the install root
logo_url: Optional direct URL to the logo
create_placeholder: Whether to create a placeholder if download fails
Returns:
True if logo exists or was successfully downloaded, False otherwise
True if a logo or placeholder is at ``logo_path`` afterwards (it was
already there, was downloaded, or a placeholder was written),
False otherwise.
"""
downloader = shared_downloader()
@@ -1011,7 +988,7 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
time.sleep(0.1) # Small delay
if not success and create_placeholder:
logger.info(f"Creating placeholder logo for {team_abbreviation}")
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, filepath=filepath)
return success
success = downloader.download_missing_logo_for_team(league, team_id, team_abbreviation, logo_path)
@@ -1019,7 +996,7 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
if not success and create_placeholder:
logger.info(f"Creating placeholder logo for {team_abbreviation}")
# Create placeholder as fallback
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, filepath=filepath)
if success:
logger.info(f"Successfully handled logo for {team_abbreviation}")
+51 -104
View File
@@ -11,7 +11,6 @@ Stability: Stable - maintains backward compatibility
from abc import ABC, abstractmethod
from enum import Enum
from typing import Dict, Any, Optional, List
import logging
import os
import sys
from src.logging_config import get_logger
@@ -511,104 +510,53 @@ class BasePlugin(ABC):
"""
Get the display duration for this plugin instance.
Automatically detects duration from:
1. self.display_duration instance variable (if exists)
2. self.config.get("display_duration", 15.0) (fallback)
Uses, in order, the first positive number among:
1. ``self.display_duration`` (a common pattern in scoreboard plugins)
2. ``self.config["display_duration"]``
3. 15.0
Can be overridden by plugins to provide dynamic durations based
on content (e.g., longer duration for more complex displays).
Numeric strings count as numbers. Can be overridden by plugins to
provide dynamic durations based on content (e.g., longer duration for
more complex displays).
Returns:
Duration in seconds to display this plugin's content
"""
# Check for instance variable first (common pattern in scoreboard plugins)
if hasattr(self, 'display_duration'):
try:
duration = getattr(self, 'display_duration')
# Handle None case
if duration is None:
pass # Fall through to config
# Try to convert to float if it's a number or numeric string.
# bool is excluded: it's an int subclass, and True would
# otherwise read as a 1-second duration.
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
if duration > 0:
return float(duration)
else:
self.logger.debug(
"display_duration instance variable is non-positive (%s), using config fallback",
duration
)
# Try converting string representations of numbers
elif isinstance(duration, str):
try:
duration_float = float(duration)
if duration_float > 0:
return duration_float
else:
self.logger.debug(
"display_duration string value is non-positive (%s), using config fallback",
duration
)
except (ValueError, TypeError):
self.logger.warning(
"display_duration instance variable has invalid string value '%s', using config fallback",
duration
)
else:
self.logger.warning(
"display_duration instance variable has unexpected type %s (value: %s), using config fallback",
type(duration).__name__, duration
)
except (TypeError, ValueError, AttributeError) as e:
self.logger.warning(
"Error reading display_duration instance variable: %s, using config fallback",
e
)
# Fall back to config
config_duration = self.config.get("display_duration", 15.0)
try:
# Ensure config value is also a valid float (bool excluded — an
# int subclass that would otherwise read True as 1 second)
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
if config_duration > 0:
return float(config_duration)
else:
self.logger.debug(
"Config display_duration is non-positive (%s), using default 15.0",
config_duration
)
return 15.0
elif isinstance(config_duration, str):
try:
duration_float = float(config_duration)
if duration_float > 0:
return duration_float
else:
self.logger.debug(
"Config display_duration string is non-positive (%s), using default 15.0",
config_duration
)
return 15.0
except ValueError:
self.logger.warning(
"Config display_duration has invalid string value '%s', using default 15.0",
config_duration
)
return 15.0
else:
self.logger.warning(
"Config display_duration has unexpected type %s (value: %s), using default 15.0",
type(config_duration).__name__, config_duration
)
except (ValueError, TypeError) as e:
duration = getattr(self, 'display_duration', None)
except (TypeError, ValueError, AttributeError) as e:
# A plugin may define display_duration as a property that raises.
self.logger.warning(
"Error processing config display_duration: %s, using default 15.0",
e
)
"Error reading display_duration instance variable: %s, using config fallback", e)
duration = None
if duration is not None:
seconds = self._positive_seconds(duration, "display_duration instance variable")
if seconds is not None:
return seconds
return 15.0
seconds = self._positive_seconds(
self.config.get("display_duration", 15.0), "config display_duration")
return seconds if seconds is not None else 15.0
def _positive_seconds(self, value: Any, source: str) -> Optional[float]:
"""``value`` as a positive float, or None (with a log line) if it is not one.
bool is rejected although it is an int subclass: True would otherwise
read as a 1-second duration.
"""
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
self.logger.warning("%s has unexpected type %s (value: %s), ignoring it",
source, type(value).__name__, value)
return None
try:
seconds = float(value)
except ValueError:
self.logger.warning("%s has invalid value %r, ignoring it", source, value)
return None
if seconds > 0:
return seconds
self.logger.debug("%s is non-positive (%s), ignoring it", source, value)
return None
# ---------------------------------------------------------------------
# Dynamic duration support hooks
@@ -926,25 +874,20 @@ class BasePlugin(ABC):
config_mode, self.plugin_id
)
# Fall back to mapping legacy content_type
content_type = self.get_vegas_content_type()
if content_type == 'multi':
# Fall back to mapping legacy content_type. 'none' (excluded from
# Vegas) also maps to FIXED_SEGMENT: exclusion is decided by checking
# get_vegas_content_type() separately.
if self.get_vegas_content_type() == 'multi':
return VegasDisplayMode.SCROLL
elif content_type == 'static':
return VegasDisplayMode.FIXED_SEGMENT
elif content_type == 'none':
# 'none' means excluded - return FIXED_SEGMENT as default
# The exclusion is handled by checking get_vegas_content_type() separately
return VegasDisplayMode.FIXED_SEGMENT
return VegasDisplayMode.FIXED_SEGMENT
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
"""
Return list of Vegas display modes this plugin supports.
Used by the web UI to show available mode options for user configuration.
Override to customize which modes are available for this plugin.
Not currently consulted by core: neither Vegas mode nor the web UI
calls it. It is kept, and plugins override it, as the declared set of
modes a future mode picker would offer.
By default:
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
@@ -972,6 +915,10 @@ class BasePlugin(ABC):
"""
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
Not currently consulted by core: Vegas mode sizes a card from the
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings
(see get_vegas_render_width()). Kept because plugins override it.
Returns the number of panels this plugin should occupy when displayed
as a fixed segment. The actual pixel width is calculated as:
width = panels * single_panel_width
@@ -1025,7 +972,7 @@ class BasePlugin(ABC):
required_fields = ['api_key', 'city']
for field in required_fields:
if field not in self.config:
self.logger.error("Missing required field: %s", field)
self.logger.error("Missing required field: %s", field)
return False
return True
"""
+9 -78
View File
@@ -9,8 +9,6 @@ import threading
import queue
from typing import Dict, Optional, List, Callable, Any
from datetime import datetime
from pathlib import Path
import json
from src.plugin_system.operation_types import (
PluginOperation, OperationType, OperationStatus
@@ -28,28 +26,22 @@ class PluginOperationQueue:
- Prevents concurrent operations on same plugin
- Operation status tracking
- Operation cancellation
- Operation history
- In-memory history of finished operations
The history is not persisted. The web UI's operation history comes from
OperationHistory (operation_history.py), which has its own file; a copy
written here was never read back by anything.
"""
def __init__(
self,
history_file: Optional[str] = None,
max_history: int = 100,
lazy_load: bool = False
):
def __init__(self, max_history: int = 100):
"""
Initialize operation queue.
Args:
history_file: Optional path to file for persisting operation history
max_history: Maximum number of operations to keep in history
lazy_load: If True, defer loading history file until first access
"""
self.logger = get_logger(__name__)
self.history_file = Path(history_file) if history_file else None
self.max_history = max_history
self._lazy_load = lazy_load
self._history_loaded = False
# Operation tracking
self._operations: Dict[str, PluginOperation] = {}
@@ -62,20 +54,8 @@ class PluginOperationQueue:
self._worker_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
# Load history from file if it exists (unless lazy loading)
if not self._lazy_load and self.history_file and self.history_file.exists():
self._load_history()
self._history_loaded = True
# Start worker thread
self._start_worker()
def _ensure_loaded(self) -> None:
"""Ensure history is loaded (for lazy loading)."""
if not self._history_loaded and self.history_file and self.history_file.exists():
self._load_history()
self._history_loaded = True
def enqueue_operation(
self,
operation_type: OperationType,
@@ -139,7 +119,6 @@ class PluginOperationQueue:
Returns:
PluginOperation if found, None otherwise
"""
self._ensure_loaded()
with self._lock:
return self._operations.get(operation_id)
@@ -184,7 +163,6 @@ class PluginOperationQueue:
Returns:
List of operations, sorted by creation time (newest first)
"""
self._ensure_loaded()
with self._lock:
# Sort by creation time (newest first)
history = sorted(
@@ -314,11 +292,7 @@ class PluginOperationQueue:
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
del self._active_operations[operation.plugin_id]
# Add to history
self._add_to_history(operation)
# Save history to file
self._save_history()
def _add_to_history(self, operation: PluginOperation) -> None:
"""Add operation to history, maintaining max_history limit."""
@@ -330,46 +304,6 @@ class PluginOperationQueue:
self._operation_history.sort(key=lambda op: op.created_at)
self._operation_history = self._operation_history[-self.max_history:]
def _save_history(self) -> None:
"""Save operation history to file."""
if not self.history_file:
return
try:
with self._lock:
# Convert operations to dicts
history_data = [op.to_dict() for op in self._operation_history]
# Ensure directory exists
self.history_file.parent.mkdir(parents=True, exist_ok=True)
# Write to file
with open(self.history_file, 'w') as f:
json.dump(history_data, f, indent=2)
except Exception as e:
self.logger.warning(f"Error saving operation history: {e}")
def _load_history(self) -> None:
"""Load operation history from file."""
if not self.history_file or not self.history_file.exists():
return
try:
with open(self.history_file, 'r') as f:
history_data = json.load(f)
with self._lock:
self._operation_history = [
PluginOperation.from_dict(op_data)
for op_data in history_data
]
self.logger.info(f"Loaded {len(self._operation_history)} operations from history")
except Exception as e:
self.logger.warning(f"Error loading operation history: {e}")
def shutdown(self) -> None:
"""Shutdown the operation queue and worker thread."""
self.logger.info("Shutting down plugin operation queue")
@@ -377,7 +311,4 @@ class PluginOperationQueue:
if self._worker_thread and self._worker_thread.is_alive():
self._worker_thread.join(timeout=5.0)
# Save history one last time
self._save_history()
+345
View File
@@ -0,0 +1,345 @@
"""
One answer to "which directory holds plugin X?".
Five places used to answer it, each with its own rules and each re-parsing
every manifest per lookup: ``PluginManager`` discovery and
``get_plugin_directory``, ``PluginLoader.find_plugin_directory``,
``PluginStoreManager._find_plugin_path`` / ``list_installed_plugins`` and
``state_reconciliation.disk_plugin_ids``. The rules now live here once; what
still legitimately differs between callers (which directories to search,
whether a ``ledmatrix-`` prefix or a case difference counts as a match) is a
keyword argument at the call site, so a difference is always a visible choice
rather than an accident of which copy you read.
The rules
---------
* A directory is a *candidate* when it is a directory (a symlink to one counts:
dev plugins are symlinked in) and its name is neither hidden (leading ``.``)
nor carries :data:`BACKUP_MARKER`. store_manager renames a plugin aside with
that marker during install/rollback; the aside still holds a manifest, so
treating it as a plugin would resurrect a ghost.
* A plugin's id is its manifest ``id``. The directory name is only a fallback,
for callers that must still see a plugin whose manifest is missing an id.
* Resolving an id within one directory: a directory whose manifest declares
the id wins; among several, the one named exactly for the id, then
``ledmatrix-<id>``, then by name. Only when no manifest claims the id do
directory names count: ``<id>``, then ``ledmatrix-<id>`` (``prefix=True``),
then either of those ignoring case (``case_insensitive=True``). The name
fallback still returns a directory whose manifest is unreadable -- that is
how a broken plugin gets uninstalled or reinstalled. ``by_manifest=False``
(``PluginManager.get_plugin_directory``, whose discovery map already holds
the manifest answer) skips straight to the names.
* Several directories are searched one at a time, in the order given: the
first directory that resolves the id at all wins, by manifest or by name.
* The id must be one plain path segment (``safe_path_component``); anything
else resolves to nothing rather than being joined or truncated.
* Returned paths are ``search_dir / name`` and are not resolved, so a
symlinked dev plugin keeps the path that lies inside the search directory.
Manifests are read at most once per :class:`PluginDirectoryIndex`; one index is
one scan.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Union
from src.common.path_safety import safe_path_component
__all__ = [
'BACKUP_MARKER',
'PLUGIN_DIR_PREFIX',
'ManifestStatus',
'PluginDirEntry',
'PluginDirectoryIndex',
'is_ignored_dir_name',
'resolve_plugin_dir',
'store_search_dirs',
]
#: Substring store_manager embeds in a plugin directory it has set aside
#: (``<id>.standalone-backup-preinstall`` / ``-migrating``). Existing debris on
#: devices carries exactly this text, so it must never change.
BACKUP_MARKER = '.standalone-backup-'
#: Legacy repository naming (``ledmatrix-<id>``); some installs still use it
#: as the directory name.
PLUGIN_DIR_PREFIX = 'ledmatrix-'
PathLike = Union[str, Path]
class ManifestStatus:
"""What reading ``manifest.json`` in a candidate directory produced."""
OK = 'ok' # a JSON object with a non-empty "id"
MISSING = 'missing' # no manifest.json
UNREADABLE = 'unreadable' # I/O error or invalid JSON
NOT_OBJECT = 'not_object' # valid JSON, but not an object
NO_ID = 'no_id' # an object without a usable "id"
def is_ignored_dir_name(name: str) -> bool:
"""True for names that are never a plugin: hidden, or set aside mid-install."""
return name.startswith('.') or BACKUP_MARKER in name
@dataclass
class PluginDirEntry:
"""One candidate directory and its manifest, read once."""
path: Path
status: str
manifest: Optional[Any] = None
error: Optional[BaseException] = None
@property
def name(self) -> str:
return self.path.name
@property
def manifest_id(self) -> Optional[str]:
"""The manifest's ``id`` when the manifest is usable, else None."""
if self.status != ManifestStatus.OK:
return None
return self.manifest['id']
@property
def manifest_parses(self) -> bool:
"""The manifest exists and is valid JSON (of any shape)."""
return self.status in (ManifestStatus.OK, ManifestStatus.NOT_OBJECT,
ManifestStatus.NO_ID)
@property
def installed_id(self) -> str:
"""The manifest id, falling back to the directory name."""
return self.manifest_id or self.name
def _read_entry(path: Path) -> PluginDirEntry:
manifest_path = path / 'manifest.json'
if not manifest_path.is_file():
return PluginDirEntry(path, ManifestStatus.MISSING)
try:
with open(manifest_path, 'r', encoding='utf-8') as handle:
manifest = json.load(handle)
except (OSError, ValueError) as exc: # ValueError covers JSON + decode errors
return PluginDirEntry(path, ManifestStatus.UNREADABLE, error=exc)
if not isinstance(manifest, dict):
return PluginDirEntry(path, ManifestStatus.NOT_OBJECT, manifest)
plugin_id = manifest.get('id')
if not plugin_id or not isinstance(plugin_id, str):
return PluginDirEntry(path, ManifestStatus.NO_ID, manifest)
return PluginDirEntry(path, ManifestStatus.OK, manifest)
def _preference(plugin_id: str, name: str) -> tuple:
"""Sort key among directories that all claim ``plugin_id``."""
if name == plugin_id:
rank = 0
elif name == PLUGIN_DIR_PREFIX + plugin_id:
rank = 1
else:
rank = 2
return (rank, name)
@dataclass
class PluginDirectoryIndex:
"""Every candidate directory directly under ``root``, manifests read once.
Build one with :meth:`scan`. It is a snapshot: a directory added or
removed afterwards is not seen until the next scan.
"""
root: Path
entries: List[PluginDirEntry] = field(default_factory=list)
#: Set when ``root`` exists but could not be listed.
error: Optional[BaseException] = None
_plugins: Optional[Dict[str, PluginDirEntry]] = field(
default=None, init=False, repr=False, compare=False)
@classmethod
def scan(cls, root: PathLike) -> 'PluginDirectoryIndex':
root = Path(root)
index = cls(root)
try:
children = sorted(root.iterdir(), key=lambda p: p.name)
except FileNotFoundError:
return index
except OSError as exc:
index.error = exc
return index
for child in children:
if is_ignored_dir_name(child.name):
continue
try:
if not child.is_dir():
continue
except OSError:
continue
index.entries.append(_read_entry(child))
return index
# -- listing ----------------------------------------------------------
def plugins(self) -> Dict[str, PluginDirEntry]:
"""Manifest id -> entry, one entry per id.
When several directories declare the same id, the one named for it
wins, then ``ledmatrix-<id>``, then the first by name; see
:meth:`duplicates` for the losers.
"""
if self._plugins is not None:
return self._plugins
chosen: Dict[str, PluginDirEntry] = {}
for entry in self.entries:
plugin_id = entry.manifest_id
if plugin_id is None:
continue
current = chosen.get(plugin_id)
if current is None or (_preference(plugin_id, entry.name)
< _preference(plugin_id, current.name)):
chosen[plugin_id] = entry
self._plugins = chosen
return chosen
def duplicates(self) -> Dict[str, List[PluginDirEntry]]:
"""Ids declared by more than one directory -> every such entry."""
seen: Dict[str, List[PluginDirEntry]] = {}
for entry in self.entries:
if entry.manifest_id is not None:
seen.setdefault(entry.manifest_id, []).append(entry)
return {k: v for k, v in seen.items() if len(v) > 1}
def installed_ids(self, *, require_parseable_manifest: bool) -> Set[str]:
"""Ids of everything that counts as installed.
A directory counts when it has a manifest.json -- which must also be
valid JSON when ``require_parseable_manifest``. Its id is the manifest
id, or the directory name when the manifest does not carry one.
"""
ids: Set[str] = set()
for entry in self.entries:
if entry.status == ManifestStatus.MISSING:
continue
if require_parseable_manifest and not entry.manifest_parses:
continue
ids.add(entry.installed_id)
return ids
def entry_for_installed_id(self, plugin_id: str) -> Optional[PluginDirEntry]:
"""The entry :meth:`installed_ids` reported as ``plugin_id``."""
entry = self.plugins().get(plugin_id)
if entry is not None:
return entry
for entry in self.entries:
if entry.manifest_id is None and entry.name == plugin_id:
return entry
return None
# -- lookup -----------------------------------------------------------
def find(self, plugin_id: str, *, prefix: bool, case_insensitive: bool,
by_manifest: bool = True) -> Optional[Path]:
"""Resolve ``plugin_id`` within this directory (rules in the module doc)."""
plugin_id = _lookup_id(plugin_id)
if plugin_id is None:
return None
if by_manifest:
entry = self.plugins().get(plugin_id)
if entry is not None:
return entry.path
names = _candidate_names(plugin_id, prefix)
by_name = {e.name: e for e in self.entries}
for name in names:
if name in by_name:
return by_name[name].path
if case_insensitive:
for low in (n.lower() for n in names):
for entry in self.entries:
if entry.name.lower() == low:
return entry.path
return None
def _lookup_id(plugin_id: Any) -> Optional[str]:
"""``plugin_id`` if it can name a plugin directory at all, else None."""
plugin_id = safe_path_component(plugin_id)
if plugin_id is None or is_ignored_dir_name(plugin_id):
return None
return plugin_id
def _candidate_names(plugin_id: str, prefix: bool) -> List[str]:
names = [plugin_id]
if prefix:
names.append(PLUGIN_DIR_PREFIX + plugin_id)
return names
def _is_dir(path: Path) -> bool:
try:
return path.is_dir()
except OSError:
return False
def resolve_plugin_dir(plugin_id: Any, search_dirs: Iterable[PathLike], *,
prefix: bool, case_insensitive: bool = False,
by_manifest: bool = True) -> Optional[Path]:
"""The directory holding ``plugin_id``, searching ``search_dirs`` in order.
Each search directory is scanned once and each manifest in it read once.
``by_manifest=False`` skips the manifest pass and matches directory names
only, which reads no manifests at all.
Names are compared against the directory listing, never by probing
``search_dir / name``: on a case-insensitive filesystem that probe says
``Demo`` exists when the directory is ``demo``, which made the answer
depend on the platform.
"""
plugin_id = _lookup_id(plugin_id)
if plugin_id is None:
return None
for search_dir in search_dirs:
search_dir = Path(search_dir)
if by_manifest or case_insensitive:
found = PluginDirectoryIndex.scan(search_dir).find(
plugin_id, prefix=prefix, case_insensitive=case_insensitive,
by_manifest=by_manifest)
else:
found = _find_by_name(search_dir, _candidate_names(plugin_id, prefix))
if found is not None:
return found
return None
def _find_by_name(search_dir: Path, names: List[str]) -> Optional[Path]:
try:
present = {child.name for child in search_dir.iterdir()}
except OSError:
return None
for name in names:
if name in present and _is_dir(search_dir / name):
return search_dir / name
return None
def store_search_dirs(plugins_dir: PathLike) -> List[Path]:
"""Directories the plugin store searches: the configured one, then a
sibling ``plugins/`` (the legacy/dev location) when that is a different
directory. Discovery deliberately does NOT use this -- it scans only the
configured directory (see CLAUDE.md, test_discovery_path_contract.py)."""
plugins_dir = Path(plugins_dir)
dirs = [plugins_dir]
try:
base = plugins_dir if plugins_dir.is_absolute() else plugins_dir.resolve()
sibling = base.parent / 'plugins'
if sibling != base:
dirs.append(sibling)
except (OSError, ValueError):
pass
return dirs
+1 -1
View File
@@ -93,7 +93,7 @@ class PluginExecutor:
if result_container['exception']:
error = result_container['exception']
error_msg = f"{plugin_context} operation failed: {error}"
self.logger.error(error_msg, exc_info=True)
self.logger.error(error_msg, exc_info=error)
record_error(error, plugin_id=plugin_id, operation="execute")
raise PluginError(error_msg, plugin_id=plugin_id) from error
+78 -111
View File
@@ -5,10 +5,10 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns.
"""
import errno
import importlib
import importlib.metadata
import importlib.util
import json
import os
import sys
import subprocess
@@ -21,6 +21,7 @@ from packaging.requirements import InvalidRequirement, Requirement
from src.exceptions import PluginError
from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import resolve_plugin_dir
def requirements_has_real_deps(requirements_file: str) -> bool:
@@ -184,6 +185,46 @@ def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
return None
def contained_plugin_dir(plugin_dir: Path, plugins_dir: Path) -> Optional[str]:
"""``plugin_dir`` rebuilt from an entry enumerated under ``plugins_dir``.
Returns None when ``plugin_dir`` is not a subdirectory of ``plugins_dir``.
Callers derive ``plugin_dir`` from a manifest-declared id, so the path is
rebuilt from :func:`find_trusted_subdir`'s answer rather than trusted: a
name that came out of ``os.scandir()`` on the trusted root carries no
taint, which is a real containment guarantee (and one CodeQL's
path-injection query can follow), not a string sanitiser.
"""
plugin_dir_real = os.path.realpath(str(plugin_dir))
plugins_dir_real = os.path.realpath(str(plugins_dir))
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
if matched_name is None:
return None
return os.path.join(plugins_dir_real, matched_name)
def requirements_to_install(plugin_dir: str, logger: logging.Logger,
label: str) -> Optional[str]:
"""The plugin's requirements.txt if pip has work to do, else None.
None when there is no requirements.txt, when it lists nothing (plugins
whose dependencies ship with core often keep an all-comments file), or
when every requirement is already installed. Shared by the loader and the
store so both skip pip for the same reasons; they differ only in how they
run it.
"""
requirements_file = os.path.join(plugin_dir, "requirements.txt")
if not os.path.isfile(requirements_file):
return None
if not requirements_has_real_deps(requirements_file):
logger.debug("requirements.txt for %s has no real dependencies, skipping pip", label)
return None
if requirements_are_satisfied(requirements_file):
logger.debug("Dependencies for %s already satisfied, skipping pip", label)
return None
return requirements_file
class PluginLoader:
"""Handles plugin module loading and class instantiation."""
@@ -214,85 +255,36 @@ class PluginLoader:
) -> Optional[Path]:
"""
Find the plugin directory for a given plugin ID.
Tries multiple strategies:
1. Use plugin_directories mapping if available
2. Direct path matching
3. Case-insensitive directory matching
4. Manifest-based search
1. The discovery mapping, when it has the id and the path exists.
2. ``plugins_dir`` only, by the shared rules in
``src/plugin_system/plugin_dirs.py``: a directory whose manifest
declares the id wins; otherwise ``<id>`` or ``ledmatrix-<id>``,
matched case-insensitively. Backup and hidden directories are
never matched.
Args:
plugin_id: Plugin identifier
plugins_dir: Base plugins directory
plugin_directories: Optional mapping of plugin_id to directory
Returns:
Path to plugin directory or None if not found
"""
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
return None
Returns:
Path to plugin directory or None if not found. An id that is not
one plain path segment finds nothing.
"""
# Strategy 1: Use mapping from discovery
if plugin_directories and plugin_id in plugin_directories:
plugin_dir = plugin_directories[plugin_id]
if plugin_dir.exists():
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
return plugin_dir
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
plugins_dir_resolved = plugins_dir.resolve()
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
try:
_candidate.relative_to(plugins_dir_resolved)
except ValueError:
continue
if _candidate.exists():
return _candidate
# Strategy 3: Case-insensitive search
normalized_id = plugin_id.lower()
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
item_name = item.name
if item_name.lower() == normalized_id:
return item
if item_name.lower() == f"ledmatrix-{plugin_id}".lower():
return item
# Strategy 4: Manifest-based search
self.logger.debug("Directory name search failed for %s, searching by manifest...", plugin_id)
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
# Skip if already checked
if item.name.lower() == normalized_id or item.name.lower() == f"ledmatrix-{plugin_id}".lower():
continue
manifest_path = item / "manifest.json"
if manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
item_manifest = json.load(f)
item_manifest_id = item_manifest.get('id')
if item_manifest_id == plugin_id:
self.logger.info(
"Found plugin %s in directory %s (manifest ID matches)",
plugin_id,
item.name
)
return item
except (json.JSONDecodeError, Exception) as e:
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
continue
return None
plugin_dir = resolve_plugin_dir(
plugin_id, [plugins_dir], prefix=True, case_insensitive=True)
if plugin_dir is not None and plugin_dir.name != plugin_id:
self.logger.debug("Found plugin %s in directory %s",
plugin_id, plugin_dir.name)
return plugin_dir
def install_dependencies(
self,
@@ -322,43 +314,15 @@ class PluginLoader:
if not plugin_id:
return False
# Resolve to a canonical absolute path (normalises .. and symlinks)
plugin_dir_real = os.path.realpath(str(plugin_dir))
plugins_dir_real = os.path.realpath(str(plugins_dir))
requested_name = os.path.basename(plugin_dir_real)
# Match the requested directory against an entry actually enumerated
# from the trusted plugins_dir, and build the path from that entry --
# not from requested_name. A name that came out of os.scandir() on a
# 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:
safe_plugin_dir = contained_plugin_dir(plugin_dir, plugins_dir)
if safe_plugin_dir is None:
self.logger.error(
"Plugin directory for %s not found inside plugins dir", plugin_id
)
return False
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
if not os.path.isfile(requirements_file):
return True # No dependencies needed
if not requirements_has_real_deps(requirements_file):
self.logger.debug(
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
plugin_id
)
return True
if requirements_are_satisfied(requirements_file):
self.logger.debug(
"Dependencies for %s already satisfied in current environment, skipping pip",
plugin_id
)
requirements_file = requirements_to_install(safe_plugin_dir, self.logger, plugin_id)
if requirements_file is None:
return True
try:
@@ -397,8 +361,8 @@ class PluginLoader:
# below).
try:
# sys.executable is this process's own interpreter (not
# attacker-influenced), and requirements_file is a path
# built internally by find_plugin_directory, never raw
# attacker-influenced), and requirements_file is rebuilt
# by contained_plugin_dir() from a trusted listing, never raw
# external input.
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
@@ -433,10 +397,10 @@ class PluginLoader:
except FileNotFoundError:
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
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:
except OSError as e:
# A broken pipe (EPIPE) happens when pip's output pipe closes
# mid-download, usually a network interruption.
if e.errno == errno.EPIPE:
self.logger.error(
"Broken pipe error during dependency installation for %s. "
"This usually indicates a network interruption or pip output buffer issue. "
@@ -577,7 +541,7 @@ class PluginLoader:
plugin_id: str,
plugin_dir: Path,
entry_point: str
) -> Optional[Any]:
) -> Any:
"""
Load a plugin module from file.
@@ -596,7 +560,12 @@ class PluginLoader:
entry_point: Entry point filename (e.g., 'manager.py')
Returns:
Loaded module or None on error
The loaded module
Raises:
PluginError: If the plugin id, directory or entry point is
invalid. Whatever the module raises while executing
propagates unchanged.
"""
plugin_id = os.path.basename(plugin_id or '')
if not plugin_id:
@@ -831,9 +800,7 @@ class PluginLoader:
# Load module
entry_point = manifest.get('entry_point', 'manager.py')
module = self.load_module(plugin_id, plugin_dir, entry_point)
if module is None:
raise PluginError(f"Failed to load module for plugin {plugin_id}", plugin_id=plugin_id)
# Get plugin class
class_name = manifest.get('class_name')
if not class_name:
+107 -104
View File
@@ -2,7 +2,8 @@
Plugin Manager
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
Handles dynamic plugin loading from the plugins/ directory.
Loads plugins from the configured plugins directory
(``plugin_system.plugins_directory``, ``plugin-repos/`` by default).
API Version: 1.0.0
"""
@@ -25,7 +26,9 @@ from src.plugin_system.plugin_state import PluginStateManager, PluginState
from src.plugin_system.schema_manager import (
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
)
from src.common.path_safety import safe_path_component
from src.plugin_system.plugin_dirs import (
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
)
from src.deprecation import deprecated
from src.common.permission_utils import (
ensure_directory_permissions,
@@ -38,7 +41,7 @@ class PluginManager:
Manages plugin discovery, loading, and lifecycle.
The PluginManager is responsible for:
- Discovering plugins in the plugins/ directory
- Discovering plugins in the configured plugins directory
- Loading plugin modules and instantiating plugin classes
- Managing plugin lifecycle (load, unload, reload)
- Providing access to loaded plugins
@@ -97,10 +100,9 @@ class PluginManager:
self.plugin_directories: Dict[str, Path] = {}
self.plugin_last_update: Dict[str, float] = {}
# Cached data-fetch intervals per plugin_id.
# _get_plugin_update_interval falls back to config_manager.get_config()
# (a full dict copy) when the manifest lacks an interval — caching avoids
# that copy on every 30-fps tick. Cleared on load/unload.
# Cached static data-fetch intervals per plugin_id, so the render
# loop's scheduling tick does not repeat the manifest/config lookup
# for every plugin. Cleared on load/unload.
self._update_interval_cache: Dict[str, Optional[float]] = {}
# Health tracking (optional, set by display_controller if available)
@@ -108,14 +110,12 @@ class PluginManager:
self.resource_monitor = None
# --- Asynchronous plugin updates -------------------------------
# update() used to run inline in the render loop (execute_update's
# internal thread.join(timeout=30) blocked it), so one slow plugin
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
# happens on the render thread (run_scheduled_updates), but
# execution moves to this single background worker. Per-plugin
# locks keep a plugin's update() and display() mutually exclusive —
# today's implicit guarantee, now explicit (and, unlike today,
# also held across the post-timeout window).
# Run inline in the render loop, one slow plugin HTTP fetch in
# update() freezes scrolling for the whole fetch. Scheduling happens
# on the render thread (run_scheduled_updates); execution happens on
# this single background worker. Per-plugin locks keep a plugin's
# update() and display() mutually exclusive, including across the
# post-timeout window.
# Kill switch: plugin_system.synchronous_updates: true restores the
# inline path.
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
@@ -175,89 +175,88 @@ class PluginManager:
self.logger.error("Could not create plugins directory %s: %s", self.plugins_dir, e, exc_info=True)
raise PluginError(f"Could not create plugins directory: {self.plugins_dir}", context={'error': str(e)}) from e
def _report_skip_once(self, key: str, message: str, *args: Any) -> None:
"""Warn about a skipped directory once per process, not per scan.
Discovery runs on every web UI page load and every config reconcile,
so warning unconditionally would put a line in the journal each time
someone opened a page -- the same log-volume problem this is meant to
help diagnose.
"""
reported = self.__dict__.setdefault('_skip_reported', set())
if key in reported:
return
reported.add(key)
self.logger.warning(message, *args)
def _scan_directory_for_plugins(self, directory: Path) -> List[str]:
"""
Scan a directory for plugins.
Which directories count and how an id maps to one is decided by
:class:`PluginDirectoryIndex` (``src/plugin_system/plugin_dirs.py``),
shared with the loader, the store and reconciliation. Only
``directory`` is scanned: discovery has no fallback to ``plugins/``.
Directories set aside mid-install (``BACKUP_MARKER`` in the name) are
skipped so they don't overwrite live entries.
Args:
directory: Directory to scan
Returns:
List of plugin IDs found
"""
plugin_ids = []
if not directory.exists():
return plugin_ids
return []
# Build new state locally before acquiring lock
new_manifests: Dict[str, Dict[str, Any]] = {}
new_directories: Dict[str, Path] = {}
try:
for item in directory.iterdir():
if not item.is_dir():
continue
# Skip backup directories so they don't overwrite live entries
if '.standalone-backup-' in item.name:
continue
manifest_path = item / "manifest.json"
if not manifest_path.exists():
# Once per directory per process. Discovery runs on every
# web UI page load and every config reconcile, so warning
# unconditionally would put a line in the journal each
# time someone opened a page -- the same log-volume
# problem this is meant to help diagnose.
# A directory here that carries no manifest is not a
# plugin. Said once, because the alternative is a plugin
# that is enabled in config, enabled in plugin state,
# present on disk, and simply absent from the running
# process with nothing anywhere to say why. Working that
# out afterwards means reading cache-file mtimes.
if item.name not in self._skip_reported:
self._skip_reported.add(item.name)
self.logger.warning(
"Skipping %s: no manifest.json, so it cannot be "
"loaded as a plugin", item.name)
continue
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (json.JSONDecodeError, PermissionError, OSError) as e:
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
continue
index = PluginDirectoryIndex.scan(directory)
if index.error is not None:
self.logger.error("Error scanning directory %s: %s", directory,
index.error, exc_info=index.error)
for entry in index.entries:
if entry.status == ManifestStatus.MISSING:
# A directory here that carries no manifest is not a plugin.
# Said once, because the alternative is a plugin that is
# enabled in config, enabled in plugin state, present on disk,
# and simply absent from the running process with nothing
# anywhere to say why. Working that out afterwards means
# reading cache-file mtimes.
self._report_skip_once(
entry.name, "Skipping %s: no manifest.json, so it cannot be "
"loaded as a plugin", entry.name)
elif entry.status == ManifestStatus.UNREADABLE:
self.logger.warning("Error reading manifest from %s: %s",
entry.path / "manifest.json", entry.error,
exc_info=entry.error)
elif entry.status == ManifestStatus.NOT_OBJECT:
# json.load accepts any JSON value, so a manifest holding
# null, [] or "text" parses and then raises AttributeError on
# .get(). Nothing here catches that -- the outer handler takes
# OSError/PermissionError only -- so a single malformed
# manifest aborted the whole scan and every other plugin on
# null, [] or "text" parses. It once raised AttributeError on
# .get() and aborted the whole scan, so every other plugin on
# disk, however healthy, silently failed to register.
if not isinstance(manifest, dict):
if item.name not in self._skip_reported:
self._skip_reported.add(item.name)
self.logger.warning(
"Skipping %s: its manifest.json is %s, not a JSON "
"object", item.name, type(manifest).__name__)
continue
self._report_skip_once(
entry.name, "Skipping %s: its manifest.json is %s, not a "
"JSON object", entry.name, type(entry.manifest).__name__)
elif entry.status == ManifestStatus.NO_ID:
# Parsed but unusable. This was the quietest path of all: the
# manifest is read successfully and then dropped.
self._report_skip_once(
entry.name, "Skipping %s: its manifest.json has no \"id\", "
"so there is nothing to register it under", entry.name)
plugin_id = manifest.get('id')
if not plugin_id:
# Parsed but unusable. This was the quietest path of all:
# the manifest is read successfully and then dropped.
if item.name not in self._skip_reported:
self._skip_reported.add(item.name)
self.logger.warning(
"Skipping %s: its manifest.json has no \"id\", so "
"there is nothing to register it under", item.name)
continue
plugins = index.plugins()
for plugin_id, entries in index.duplicates().items():
self._report_skip_once(
"duplicate:" + plugin_id,
"Plugin id %r is declared by %d directories (%s); using %s",
plugin_id, len(entries), ", ".join(e.name for e in entries),
plugins[plugin_id].name)
plugin_ids.append(plugin_id)
new_manifests[plugin_id] = manifest
new_directories[plugin_id] = item
except (OSError, PermissionError) as e:
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
new_manifests: Dict[str, Dict[str, Any]] = {
plugin_id: entry.manifest for plugin_id, entry in plugins.items()}
new_directories: Dict[str, Path] = {
plugin_id: entry.path for plugin_id, entry in plugins.items()}
# Replace shared state under lock so uninstalled plugins don't linger
with self._discovery_lock:
@@ -266,8 +265,8 @@ class PluginManager:
self.plugin_directories.clear()
self.plugin_directories.update(new_directories)
return plugin_ids
return list(plugins)
def discover_plugins(self) -> List[str]:
"""
Discover all plugins in the plugins directory.
@@ -395,7 +394,8 @@ class PluginManager:
self.font_manager, 'register_plugin_fonts'
):
try:
self.font_manager.register_plugin_fonts(plugin_id, font_manifest)
self.font_manager.register_plugin_fonts(
plugin_id, font_manifest, plugin_dir=plugin_dir)
except Exception as e:
self.logger.warning(
"Failed to register fonts for plugin %s: %s", plugin_id, e
@@ -660,9 +660,15 @@ class PluginManager:
if not self.unload_plugin(plugin_id):
return False
# Re-discover to get updated manifest
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
if manifest_path.exists():
# Re-read the manifest so an edit to it takes effect, from the
# directory discovery found the plugin in: a directory's name need not
# be the id its manifest declares.
with self._discovery_lock:
directories = dict(self.plugin_directories)
plugin_dir = self.plugin_loader.find_plugin_directory(
plugin_id, self.plugins_dir, directories)
manifest_path = plugin_dir / "manifest.json" if plugin_dir is not None else None
if manifest_path is not None and manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
@@ -772,24 +778,20 @@ class PluginManager:
not one plain path segment (``..``, ``a/b``, an absolute path) is
refused instead of being joined onto ``plugins_dir``. The join is not
resolved further: dev plugins are symlinks into ``plugins_dir``.
The discovery map is authoritative. For an id discovery has not seen,
only directory names are tried -- ``<id>`` then ``ledmatrix-<id>``,
in ``plugins_dir`` only -- so a miss on a web request never reads
every manifest on disk. Rules: ``src/plugin_system/plugin_dirs.py``.
"""
with self._discovery_lock:
if plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id])
plugin_id = safe_path_component(plugin_id)
if plugin_id is None:
return None
plugin_dir = self.plugins_dir / plugin_id
if plugin_dir.exists():
return str(plugin_dir)
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
if plugin_dir.exists():
return str(plugin_dir)
return None
plugin_dir = resolve_plugin_dir(
plugin_id, [self.plugins_dir], prefix=True, case_insensitive=False,
by_manifest=False)
return str(plugin_dir) if plugin_dir is not None else None
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
"""
@@ -884,11 +886,12 @@ class PluginManager:
updating, since a scheduler that propagates a plugin bug stops every
other plugin too.
The static result is cached per plugin_id after the first lookup to
avoid calling config_manager.get_config() — which returns a full dict
copy — on every tick of the 30-fps display loop. The cache is
invalidated when a plugin is loaded or unloaded. The dynamic hook is
deliberately *not* cached: caching it would defeat its only purpose.
The static result is cached per plugin_id after the first lookup, so
the manifest/config resolution is not repeated on every scheduling
tick of the display loop. A change to ``update_interval`` in
config.json therefore takes effect when the plugin is next loaded or
unloaded, which clears the cache. The dynamic hook is deliberately
*not* cached: caching it would defeat its only purpose.
"""
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
if dynamic is not None:
+1 -8
View File
@@ -41,7 +41,6 @@ class PluginStateManager:
self._state_transition_counts: Dict[str, int] = {}
self._error_info: Dict[str, Dict[str, Any]] = {}
self._last_update: Dict[str, datetime] = {}
self._last_display: Dict[str, datetime] = {}
def _record_transition(self, plugin_id: str) -> None:
"""Count a state transition. Callers must already hold ``_lock``."""
@@ -181,11 +180,7 @@ class PluginStateManager:
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
"""Get timestamp of last update() call."""
return self._last_update.get(plugin_id)
def get_last_display(self, plugin_id: str) -> Optional[datetime]:
"""Get timestamp of last display() call."""
return self._last_display.get(plugin_id)
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
"""
Get comprehensive state information for a plugin.
@@ -212,7 +207,6 @@ class PluginStateManager:
'is_error': self.is_error(plugin_id),
'can_execute': self.can_execute(plugin_id),
'last_update': self.get_last_update(plugin_id),
'last_display': self.get_last_display(plugin_id),
'error_info': self.get_error_info(plugin_id),
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
}
@@ -231,5 +225,4 @@ class PluginStateManager:
self._state_transition_counts.pop(plugin_id, None)
self._error_info.pop(plugin_id, None)
self._last_update.pop(plugin_id, None)
self._last_display.pop(plugin_id, None)
+70
View File
@@ -0,0 +1,70 @@
"""
Repository URL helpers shared by the plugin store and saved repositories.
One definition of "the same repository URL", of how a GitHub URL maps to
``owner/repo``, and of the headers sent to the GitHub API.
"""
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
#: Hosts whose URLs name a GitHub repository. Matched against
#: ``urlparse(url).hostname``, never by substring: a substring test accepts
#: ``https://github.com.example.org/...`` as GitHub.
GITHUB_HOSTS = frozenset({'github.com', 'www.github.com'})
#: Sent on every request the store makes to GitHub.
USER_AGENT = 'LEDMatrix-Plugin-Manager/1.0'
def normalize_repo_url(url: str) -> str:
"""``url`` without surrounding whitespace, trailing slashes or a trailing ``.git``.
Only a *trailing* ``.git`` is removed. The unanchored
``url.replace('.git', '')`` this replaces turned
``https://github.com/user/my.github.io`` into ``.../myhub.io``.
Case is preserved; compare with :func:`same_repo`.
"""
url = url.strip().rstrip('/')
if url.endswith('.git'):
url = url[:-4]
return url
def same_repo(url_a: str, url_b: str) -> bool:
"""Whether two URLs name the same repository.
GitHub owner and repository names are case-insensitive, so
``ChuckBuilds/LEDMatrix-Plugins`` and ``chuckbuilds/ledmatrix-plugins``
are one repository.
"""
return normalize_repo_url(url_a).lower() == normalize_repo_url(url_b).lower()
def github_owner_repo(url: str) -> Optional[Tuple[str, str]]:
"""``(owner, repo)`` for a github.com repository URL, else None.
The first two path segments, so a URL that points inside the repository
(``.../owner/repo/tree/main/plugins/x``) still names ``owner/repo``.
"""
parsed = urlparse(normalize_repo_url(url))
if parsed.hostname not in GITHUB_HOSTS:
return None
parts = [part for part in parsed.path.split('/') if part]
if len(parts) < 2:
return None
return parts[0], normalize_repo_url(parts[1])
def github_api_headers(token: Optional[str] = None) -> Dict[str, str]:
"""Headers for a GitHub REST API request, authenticated when ``token`` is set.
An authenticated request gets 5000 requests an hour instead of 60.
"""
headers = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': USER_AGENT,
}
if token:
headers['Authorization'] = f'token {token}'
return headers
+31 -20
View File
@@ -33,7 +33,14 @@ class ResourceLimits:
@dataclass
class ResourceMetrics:
"""Resource usage metrics for a plugin."""
"""Resource usage metrics for a plugin.
``memory_mb`` is the largest growth in this *process's* resident memory
seen across a single monitored call -- a high-water mark, not current
usage, and not the plugin's own footprint (another thread allocating
during the call counts too). ``cpu_percent`` is the whole process's CPU
use since the previous sample.
"""
memory_mb: float = 0.0
cpu_percent: float = 0.0
execution_time: float = 0.0
@@ -42,11 +49,6 @@ class ResourceMetrics:
max_execution_time: float = 0.0
min_execution_time: float = float('inf')
last_update_time: float = field(default_factory=time.time)
def update_average_execution_time(self):
"""Update average execution time."""
if self.call_count > 0:
self.total_execution_time = self.total_execution_time / self.call_count
#: How often a plugin's metrics are written to the cache, in seconds.
@@ -287,7 +289,8 @@ class PluginResourceMonitor:
# Calculate execution time
execution_time = time.time() - start_time
memory_growth_mb = 0.0
# Update metrics
with self._lock:
metrics.execution_time = execution_time
@@ -302,17 +305,17 @@ class PluginResourceMonitor:
# Update memory and CPU if monitoring enabled
if self.enable_monitoring:
end_memory = self._get_process_memory_mb()
metrics.memory_mb = max(metrics.memory_mb, end_memory - start_memory)
memory_growth_mb = self._get_process_memory_mb() - start_memory
metrics.memory_mb = max(metrics.memory_mb, memory_growth_mb)
# CPU is harder to measure per-call, so we track it separately
metrics.cpu_percent = self._get_process_cpu_percent()
# Persist metrics, at most once per interval per plugin.
self._persist_metrics(plugin_id, metrics)
# Check limits
if limits:
self._check_limits(plugin_id, metrics, limits, execution_time)
self._check_limits(plugin_id, metrics, limits, execution_time,
memory_growth_mb)
return result
@@ -326,9 +329,17 @@ class PluginResourceMonitor:
metrics.last_update_time = time.time()
raise
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
limits: ResourceLimits, execution_time: float) -> None:
"""Check if plugin has exceeded resource limits."""
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
limits: ResourceLimits, execution_time: float,
memory_growth_mb: float) -> None:
"""Raise ResourceLimitExceeded if this call went over a limit.
Execution time and memory growth are this call's own; CPU is the
latest process sample. Judging memory by the stored high-water mark
(``metrics.memory_mb``) instead would fail every call after the first
expensive one, so the health tracker's circuit breaker would reopen
on every recovery probe and the plugin would never update again.
"""
warnings = []
errors = []
@@ -343,13 +354,13 @@ class PluginResourceMonitor:
)
# Check memory
if limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb:
if limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb:
errors.append(
f"Memory usage {metrics.memory_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
f"Memory growth {memory_growth_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
)
elif limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb * limits.warning_threshold:
elif limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb * limits.warning_threshold:
warnings.append(
f"Memory usage {metrics.memory_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
f"Memory growth {memory_growth_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
)
# Check CPU
+5 -15
View File
@@ -10,6 +10,8 @@ import os
from pathlib import Path
from typing import List, Dict, Optional
from src.plugin_system.repo_urls import normalize_repo_url
class SavedRepositoriesManager:
"""Manages saved GitHub repository URLs."""
@@ -71,18 +73,6 @@ class SavedRepositoriesManager:
pass
return False
@staticmethod
def _clean_url(repo_url: str) -> str:
"""Normalize a repo URL: strip whitespace, trailing slashes, and a
trailing ``.git`` suffix ONLY. (The old ``.replace('.git', '')``
was an unanchored substring replace that mangled URLs merely
containing ``.git``, e.g. ``https://github.com/user/my.github.io``.)
"""
repo_url = repo_url.strip().rstrip('/')
if repo_url.endswith('.git'):
repo_url = repo_url[:-4]
return repo_url
def get_all(self) -> List[Dict[str, str]]:
"""Get all saved repositories."""
return self.repositories.copy()
@@ -98,7 +88,7 @@ class SavedRepositoriesManager:
Returns:
True if added successfully
"""
repo_url = self._clean_url(repo_url)
repo_url = normalize_repo_url(repo_url)
# Check if already exists
for repo in self.repositories:
@@ -138,7 +128,7 @@ class SavedRepositoriesManager:
Returns:
True if removed successfully
"""
repo_url = self._clean_url(repo_url)
repo_url = normalize_repo_url(repo_url)
previous = self.repositories
remaining = [r for r in previous if r.get('url') != repo_url]
@@ -156,7 +146,7 @@ class SavedRepositoriesManager:
def has(self, repo_url: str) -> bool:
"""Check if a repository is already saved."""
repo_url = self._clean_url(repo_url)
repo_url = normalize_repo_url(repo_url)
return any(r.get('url') == repo_url for r in self.repositories)
def get_registry_repositories(self) -> List[Dict[str, str]]:
+15 -18
View File
@@ -14,6 +14,7 @@ import jsonschema
from jsonschema import Draft7Validator, ValidationError
from src.core_config_keys import CORE_CONFIG_KEYS
from src.element_style import expand_style_elements
def _renders_as_object(prop: Dict[str, Any]) -> bool:
@@ -452,11 +453,7 @@ class SchemaManager:
# full per-element style blocks (font/size/color + layout
# offsets) the web-UI config form renders. No-op for schemas
# without the declaration; never raises.
try:
from src.element_style import expand_style_elements
schema = expand_style_elements(schema)
except ImportError:
pass
schema = expand_style_elements(schema)
# Cache the schema
self._schema_cache[plugin_id] = schema
@@ -643,20 +640,12 @@ class SchemaManager:
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
)
# Create validator with enhanced schema
# iter_errors reports every violation, including one ``required``
# error per missing field at every depth.
validator = Draft7Validator(enhanced_schema)
# Collect all validation errors
for error in validator.iter_errors(config):
error_msg = self._format_validation_error(error, plugin_id)
errors.append(error_msg)
# Check required fields
required_fields = enhanced_schema.get('required', [])
for field in required_fields:
if field not in config:
errors.append(f"Missing required field: '{field}'")
errors.append(self._format_validation_error(error, plugin_id))
if errors:
return False, errors
@@ -687,7 +676,15 @@ class SchemaManager:
field_path = f"'{path}'" if path else "root"
if error.validator == 'required':
missing = error.validator_value
# validator_value is the schema's whole ``required`` list; the
# error itself is about one field, which jsonschema names only in
# its message ("'api_key' is a required property").
missing = next(
(name for name in error.validator_value
if error.message.startswith(f"{name!r} ")),
None)
if missing is None:
return f"Field {field_path}: {error.message}"
return f"Field {field_path}: Missing required property '{missing}'"
elif error.validator == 'type':
expected = error.validator_value
+5 -2
View File
@@ -34,7 +34,9 @@ class PluginState:
version: Optional[str] = None
installed_at: Optional[datetime] = None
last_updated: Optional[datetime] = None
config_version: int = 1 # For detecting state corruption
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
# plugin_state.json keeps the shape older releases load with cls(**data).
config_version: int = 1
metadata: Dict[str, Any] = None
def __post_init__(self):
@@ -100,6 +102,8 @@ class PluginStateManager:
# State storage
self._states: Dict[str, PluginState] = {}
# The file's top-level "version", written back as read. Nothing
# checks it yet; it is there for a future format change to branch on.
self._state_version = 1
# Threading
@@ -193,7 +197,6 @@ class PluginStateManager:
current_state.metadata = {}
current_state.metadata.update(updates['metadata'])
# Increment config version
current_state.config_version += 1
# Store updated state
+26 -32
View File
@@ -15,6 +15,7 @@ from enum import Enum
from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
from src.plugin_system.state_manager import PluginStateManager
from src.logging_config import get_logger
@@ -102,31 +103,25 @@ def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str
def disk_plugin_ids(plugins_dir) -> Set[str]:
"""Plugin ids actually installed on disk.
A directory counts only when it is not a standalone backup and its
manifest.json parses. A corrupt manifest must not read as installed, or a
live "in config but not on disk" finding gets cleared on the strength of an
unreadable file.
A directory counts only when it is not a standalone backup (or hidden)
and its manifest.json parses. A corrupt manifest must not read as
installed, or a live "in config but not on disk" finding gets cleared on
the strength of an unreadable file.
The id is the manifest's ``id`` -- what discovery registers and what the
config is keyed by -- and the directory name only when the manifest has
none. Directory names alone made a plugin living in ``ledmatrix-stocks/``
with id ``stocks`` read as both "stocks in config but not on disk" and
"ledmatrix-stocks on disk but not in config".
"""
ids: Set[str] = set()
root = Path(plugins_dir)
try:
if not root.exists():
return ids
for entry in root.iterdir():
if not entry.is_dir() or '.standalone-backup-' in entry.name:
continue
manifest = entry / "manifest.json"
if not manifest.exists():
continue
try:
with open(manifest, 'r') as f:
json.load(f)
except (OSError, ValueError):
continue
ids.add(entry.name)
return _disk_index(plugins_dir).installed_ids(require_parseable_manifest=True)
except OSError:
return ids
return ids
return set()
def _disk_index(plugins_dir) -> PluginDirectoryIndex:
return PluginDirectoryIndex.scan(Path(plugins_dir))
def still_unresolved(entries: List[Dict[str, Any]],
@@ -326,16 +321,15 @@ class StateReconciliation:
"""Get plugin state from disk (installed plugins)."""
state = {}
try:
# Membership comes from the shared extractor so the web interface
# re-checks stored findings against this same definition; the
# manifest is then re-read here only for version/name.
for plugin_id in disk_plugin_ids(self.plugins_dir):
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
try:
with open(manifest_path, 'r') as f:
manifest = json.load(f)
except (OSError, ValueError): # nosec B112 - raced or corrupt; skip
continue
# Membership uses the same index and rule as disk_plugin_ids, so
# the web interface re-checks stored findings against this same
# definition; each manifest is read once, by the scan.
index = _disk_index(self.plugins_dir)
for plugin_id in index.installed_ids(require_parseable_manifest=True):
entry = index.entry_for_installed_id(plugin_id)
manifest = entry.manifest if entry is not None else None
if not isinstance(manifest, dict):
manifest = {}
state[plugin_id] = {
'exists_on_disk': True,
'version': manifest.get('version'),
File diff suppressed because it is too large Load Diff
@@ -14,13 +14,16 @@ PIL Image canvas and draws text using the actual project fonts.
MAINTENANCE WARNING: this class is a deliberate fork of
src/display_manager.py so it can run without hardware. It mirrors
these DisplayManager methods by name and behavior: _load_fonts,
_draw_bdf_text, get_font_height, get_text_width, draw_text,
get_font_height, get_text_width, draw_text,
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
capture_mode, set_scrolling_state, is_currently_scrolling,
process_deferred_updates, update_display, render_size. A behavior
change to any of those in DisplayManager must be mirrored here, or
plugin visual tests will pass against stale behavior.
BDF text is not mirrored: both classes load BDF faces and draw BDF glyphs
through src/common/bdf_font.py, so those pixels cannot drift.
"""
import math
@@ -31,6 +34,7 @@ from pathlib import Path
from typing import Any, List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype
from src.logging_config import get_logger
@@ -147,16 +151,18 @@ class VisualTestDisplayManager:
self.small_font = load_truetype(ttf_path, crisp_size(press_start, 8))
self.font = self.regular_font # alias used by some code paths
# 5x7 BDF font via freetype
# 5x7 BDF font, loaded exactly as DisplayManager._load_fonts does
# (same loader, same 7px request as its _CALENDAR_FONT_PX). A bare
# freetype.Face has no active size, so its ascender reads 0 and
# every line drew a baseline too high.
try:
import freetype
bdf_path = str(fonts_dir / '5x7.bdf')
if not os.path.exists(bdf_path):
raise FileNotFoundError(f"BDF font not found: {bdf_path}")
face = freetype.Face(bdf_path)
face, _ = load_bdf_face(bdf_path, 7)
self.calendar_font = face
self.bdf_5x7_font = face
except (ImportError, FileNotFoundError, OSError) as e:
except Exception as e: # freetype missing or the file unloadable
logger.debug("BDF font not available, using small_font as fallback: %s", e)
self.calendar_font = self.small_font
self.bdf_5x7_font = self.small_font
@@ -300,41 +306,18 @@ class VisualTestDisplayManager:
logger.debug(f"Error drawing image: {e}")
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
"""Draw text using BDF font with proper bitmap handling.
"""Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left.
Replicated from DisplayManager._draw_bdf_text().
Not a copy: DisplayManager._draw_bdf_text calls the same
:func:`src.common.bdf_font.draw_bdf_text`, so what this draws is
what the panel draws.
"""
try:
if isinstance(color, list):
color = tuple(color)
face = font if font else self.calendar_font
# Compute baseline from font ascender
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
for char in text:
face.load_char(char)
bitmap = face.glyph.bitmap
glyph_left = face.glyph.bitmap_left
glyph_top = face.glyph.bitmap_top
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * bitmap.pitch + (j // 8)
if byte_index < len(bitmap.buffer):
byte = bitmap.buffer[byte_index]
if byte & (1 << (7 - (j % 8))):
pixel_x = x + glyph_left + j
pixel_y = baseline_y - glyph_top + i
if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height:
self.draw.point((pixel_x, pixel_y), fill=color)
x += face.glyph.advance.x >> 6
draw_bdf_text(self.draw, text, x, y, face, color,
clip=(self.width, self.height))
except Exception as e:
logger.debug(f"Error drawing BDF text: {e}")
+16 -3
View File
@@ -24,8 +24,13 @@ _REDACT_CREDENTIAL = re.compile(
# silently leak the ones nobody thought of. Not covered by the generic pattern
# above, whose value part stops at whitespace and so would keep the credential
# once a space follows the scheme.
#
# The opening quote and the whitespace after it are one optional unit. Written
# `\s*["\']?\s*`, a whitespace run with no quote in it could be split between
# the two `\s*` in every possible way, and a header with no credential after
# it tried them all: quadratic, 8s for 20k spaces.
_REDACT_AUTH_HEADER = re.compile(
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
r'((?:proxy-)?authorization["\']?\s*[=:]\s*(?:["\']\s*)?'
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
r'([^\s,"\'<>}]+)', # the credential, redacted
re.IGNORECASE,
@@ -34,8 +39,16 @@ _REDACT_AUTH_HEADER = re.compile(
# Credentials embedded in a URL: https://user:password@host. requests quotes
# the full URL in its exceptions, so this is a realistic leak. The username is
# kept -- it identifies which account failed without being the secret.
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
re.IGNORECASE)
#
# A match may only start where a run of scheme characters starts. Unanchored,
# `[a-z][a-z0-9+.-]*://` was tried from every letter of a long run (a hex
# digest, an ID, a blob of response body), each attempt reading to the end of
# the run: quadratic, 1.6s for 20k characters, all of it holding the GIL.
# Leading digits and `+.-` sit inside group 1 so the substitution puts them
# back; the scheme proper still has to start with a letter.
_REDACT_URL_USERINFO = re.compile(
r'((?<![a-z0-9+.-])[0-9+.-]*[a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
re.IGNORECASE)
def redact_credentials(text: str) -> str:

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