Compare commits

...
Author SHA1 Message Date
ChuckandClaude Opus 5.5 8136a2d525 fix(ipc): a plugin reload no longer freezes the panel during Vegas (#723)
A plugin.reload no longer freezes the panel during Vegas: the old instance is torn down and the new one loaded off the render thread (frame gap 3017 ms -> 9 ms in the ledpi reproduction). A failed or timed-out teardown stops the reload with a restart hint instead of loading over stale modules or tearing down an instance still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:42:23 -04:00
ChuckandClaude Opus 5.5 5aa7a63127 feat(timing): time garbage-collection pauses in the frame stats (#722)
A GcMonitor in src/common/frame_timing.py, installed once per process from gc.callbacks by the display manager (and render_bench), counts collections and seconds per generation, the longest, and those of 20 ms or more. A long one tags the next presented frame 'gc' in record(), so frame_soak shows its late rate under 'after work'; the stats file gains an additive 'gc' block printed as a 'Garbage collection' line; and a Render stall dump says when a long collection ran inside the stall. Diagnostic only: nothing tunes, freezes or disables the collector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:29:08 -04:00
ChuckandClaude Opus 5.5 85be4bf25d fix(display): end the scroll before the schedule-off blank and WiFi notice (#721)
The schedule-off blank and the WiFi notice are drawn by the display controller, not dispatched to a plugin, so #716's handover never reached them. Drawn while the last scroll's state was still set, the blank went out with the ticker's lagging rows on a scan-compensated panel and stayed up for its 60 s dwell, and the notice's redraws (which #712 now shows over a running scroller or Vegas) were timed as 0.5-1 s freezes and logged as a mid-scroll Render stall. The controller now calls set_scrolling_state(False) before drawing either; a scroller that resumes sets the state again on its next frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:46:01 -04:00
ChuckandClaude Opus 5.5 0e78e06eb9 perf(sports): reuse an unchanged scroll strip at the start of a turn (#719)
A scoreboard in scroll mode no longer redraws every card at the start of a recent/upcoming turn whose games have not changed (~1.4 s for seven football cards at 192x48 on a Pi 4, with the render thread waiting). SportsScrollDisplayManager keeps one display per slate (game type + leagues; up to 4 per game type, at most 6 MB per plugin of strips not on screen) and rewinds the strip it built last time when its games, rankings, config, panel size and date are unchanged and it is under 10 minutes old. First turns, changed slates, live strips and empty turns are drawn as before; get_scroll_display() still answers with the strip on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:39:44 -04:00
ChuckandClaude Opus 5.5 746dcfcadb feat(ipc): control socket stage 2 - wake the render thread, brightness.set, plugin.reload (#720)
Control socket stage 2: the render thread wakes for queued commands (static screens ~1 ms, Vegas within one frame), brightness.set, and plugin.reload after a store update, with mailbox/restart fallbacks. Rig checks listed in the PR body are still to run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:37:54 -04:00
ChuckandClaude Opus 5.5 f72d69c2b0 perf(display): skip preview work nobody reads (lazy checksum, 1 Hz snapshot writer) (#717)
Mid-scroll, update_display() no longer checksums every frame: it asks is_currently_scrolling() once per frame and reuses the answer, hashes only when dirty tracking can skip a static frame, and the preview snapshot asks its policy first and hashes only when a write or touch could follow (decide() is monotone, pinned by a property test). With the preview open the snapshot is written at most once a second (VIEWER_INTERVAL 1.0 s, was 0.2 s; the SSE stream re-read it once a second, so four encodes in five went unread); the stream now polls its mtime every 0.25 s (VIEWER_POLL_INTERVAL), so the preview stays about as fresh. The PNG is written at compress_level=1. --preview soaks are not comparable across this change.

Merged with #716: _scan_segments takes the frame's one scrolling answer and #716's static-handover pass-through, as soaked on ledpi (A B B A, 20 min each: main 0.118% / 0.113% late, with #716 and this 0.107% / 0.104%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:27:20 -04:00
ChuckandClaude Opus 5.5 1ecf3aba03 fix(display): narrow the WiFi status before reading expires_at (#715)
Narrow the WiFi status before reading expires_at (mypy Optional index; behaviour unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:24:11 -04:00
ChuckandClaude Opus 5.5 f5793e2134 perf(common): rasterize outlined text once instead of nine times (#718)
New draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0), offsets=OUTLINE_SQUARE) in src/common/text_helper.py, with OUTLINE_SQUARE and OUTLINE_CROSS. It rasterizes the string once and stamps the mask at each outline offset instead of one draw.text per offset: the same pixels as the nine-draw loop (an equivalence sweep across the bundled fonts, image and font modes, colours and positions pins it, on Windows and Linux), about 8x faster per outlined string. Fractional coordinates, multiline text, fonts other than a plain FreeTypeFont, other image modes and a replaced draw.text take the old loop. SportsCoreSharedMixin._draw_text_with_outline and TextHelper.draw_text_with_outline draw through it; the scoreboards' own game_renderer loops adopt it in a ledmatrix-plugins change after a core release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:19:30 -04:00
ChuckandClaude Opus 5.5 34be83d595 fix(display): end the scroll state at scroller-to-static handovers (#716)
A static plugin screen that follows a scroller no longer starts with the ticker's lagging rows on scan-compensated panels, and the 1 Hz loop's second frame is no longer recorded as a ~1 s mid-scroll freeze / Render stall. The display controller calls DisplayManager.end_scroll_for_static_screen() before a static screen's first display() (clears the scan history; _scan_segments passes its frames through in one swap) and set_scrolling_state(False) after it; the scroller's hold stays until then, so late-frame counts are unchanged. A screen's first frame is tagged 'handover': gaps of 250 ms or more before it go to the additive handover_freezes (frame_soak prints 'Handover gaps'), not freezes. The display thread is named display-<plugin id>. The WiFi notice and the schedule-off blank are not covered yet (docs list them as a follow-up).

ledpi A B B A soak (20 min each, --preview): main 0.118% / 0.113% late with 6 / 3 freezes; with this and #717 0.107% / 0.104% late, 0 freezes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:13:57 -04:00
ChuckandClaude Opus 5.5 41db488c73 fix(display): schedule windows end at the end time; on-demand ending in off hours blanks at once (#714)
Schedule and dim windows are half-open [start, end): on from the start time, off at exactly the end time, whatever second the check runs. An on-demand session that ends in scheduled-off hours (expiry or stop) clears the once-a-minute schedule gate, so the panel blanks within about a second. Golden: schedule; two test_display_pending_changes.py tests now say end_time 23:00.

Merged with #712 and #713: with all three in, docs/RUN_LOOP_REDESIGN.md's 'may be wrong' list is empty, so that section now records that all six items are fixed and by which PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:10:04 -04:00
ChuckandClaude Opus 5.5 77e0ea91ae fix(display): live games take over within a second, and straight from Vegas (#713)
A game that goes live takes over within about a second (_check_live_takeover in the frame loops and the dwell sleep, throttled to 1 s, never during on-demand, scheduled-off, live_in_ticker or an already-live screen); an interrupted Vegas iteration switches straight to the game; has_live_content() is asked once per plugin per scan. Goldens: live_priority, vegas.

Merged with #712: after an interrupted Vegas iteration the WiFi-notice check runs before the live switch (WiFi outranks live). Adds test/test_run_loop_wifi_and_live.py, pinning that a notice and a game arriving during the same screen (1 Hz, 125 Hz, Vegas) show the notice first, then the game, and neither while scheduled off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:59:14 -04:00
ChuckandClaude Opus 5.5 6c6394a1a7 fix(display): a WiFi notice preempts the current screen and Vegas yields to it (#712)
A WiFi notice preempts the current screen within about a second (frame loops, post-loop check, make-up dwell), and an interrupted Vegas iteration that yielded for a notice ends the pass so the notice shows next. Goldens: wifi_notice, vegas. First of three run-loop fixes (#712, #713, #714), pre-tested together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:51:00 -04:00
ChuckandClaude Opus 5.5 4be53d048b feat(fetch): shared fetch service, stage 1 (pooling, merging, host budgets, counters) (#702)
Core's own HTTP fetch paths (APIHelper, fetch_espn_scoreboard and its date chunks, BackgroundDataService, BaseOddsManager.get_odds) go through one service in src/common/fetch_service.py: shared connection pools per retry policy, merged identical in-flight GETs, per-host token-bucket budgets (fetch_service.rate_limits), and per-plugin request counters published to GET /api/v3/plugins/fetch-stats. Return values, exceptions, cache keys, TTLs and retry policies are unchanged. Core-internal in this release; plugins should not import it directly yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:38:11 -04:00
ChuckandClaude Opus 5.5 9edeb6da14 fix(display): a raising display() counts as a circuit-breaker failure (#707)
The first-frame dispatch (_dispatch_first_frame) now asks PluginExecutor.execute_display() to re-raise (raise_errors=True) and records a raise inside the executor as a breaker failure, with the original exception as last_error, instead of a success. The screen is still an empty pass and rotation is unchanged; a hung display() is still recorded once, as a hang. The run-loop golden trace plugin_error.json is regenerated (crashy now records health failures and is skipped by the breaker), and behaviour 7 is dropped from docs/RUN_LOOP_REDESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:51:38 -04:00
ChuckandClaude Opus 5.5 a21650e746 refactor(display): run() stage 1 - golden traces and extracted helpers, no behaviour change (#704)
Adds golden trace tests for DisplayController.run() (test/test_run_loop_golden.py on a fake clock with fake plugins, 15 scenarios, fixtures in test/fixtures/run_loop_golden/) and moves twelve blocks of run() into named helpers (_dispatch_first_frame, _resolve_durations, _resolve_active_mode, _needs_high_fps, _advance_after_screen and others) with the traces identical before and after. docs/RUN_LOOP_REDESIGN.md describes the target structure. Hardware-checked on hdpi: Vegas late-frame rate unchanged in an ABBA A/B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:41:43 -04:00
ChuckandClaude Sonnet 5.5 eb8128a981 feat(display): cancel the half-panel scan offset on slower, held-frame scrolls (#711)
Scan-order compensation only ran at one frame per refresh, so a crisp scroll
like 60 px/s on a 120 Hz panel (1px every 2 refreshes) showed a half-pixel
step across the middle of the panel. A held frame is now presented as a
sequence of swaps (scan_order.refresh_plan): the lagging half shows the
previous frame for its first refresh and the new one for the rest, so it
steps one refresh after the rest. Skipped when a blit takes over half a
refresh, since the second blit has to land before the next vsync.

Soaked on ledpi (60 px/s, 120 Hz): 0.16% late frames, as before the change.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:32:24 -04:00
ChuckandClaude Sonnet 5.5 16b566e14f feat(scroll): show which scroll speeds are smooth on this panel (#710)
* feat(scroll): show which scroll speeds are smooth on this panel

The Vegas Scroll Speed slider now says what the panel will do with the
chosen speed and offers the nearest smooth ones to click. Backed by
scroll_config.speed_advice() and GET /api/v3/config/scroll-speed-advice,
which uses the refresh the display measured rather than the cap.

Also stops the default 50 px/s snapping to a stepped 48 px/s (2px every 5
refreshes, 24fps) on a 120Hz panel: the low-fps penalty in solve_crisp()
now loses to 60 or 40 px/s. 100Hz panels are unchanged.

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

* fix(scroll): hint threw before its timer variables existed; count 25-30fps as stepped

The Vegas speed hint called refreshScrollSpeedHint() before the let
declarations it uses, so it never rendered (found on ledpi). And the
solver's low-fps penalty stopped at 25fps, which let a measured 125.7Hz
panel keep a 25.1fps 2px-every-5-refreshes scroll.

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

* test: add the scroll-speed-advice route to the /api/v3 URL map snapshot

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:15:22 -04:00
ChuckandClaude Opus 5.5 4ddc3a3620 chore: prepare the 3.8.0 release (#709)
* chore(deprecation): remove the 35 APIs deprecated for 3.8.0

The usage scan (docs/DEPRECATIONS_3.8.md, regenerated 2026-10-01 and
committed here) finds no call or override of any of them in the 46
monorepo plugins or the 8 third-party plugins plugins.json lists; the
only core callers were other deprecated methods removed alongside.

- CacheManager: 13 methods, plus the private helpers only
  has_data_changed used (_has_*_changed, _is_market_open).
- DisplayManager: 7 methods, plus WEATHER_COLORS and the private
  _draw_sun/_cloud/_rain/_snow/_storm helpers only the icon methods used.
- FontManager: 14 methods, plus size_tokens, _save_overrides and
  _clear_plugin_font_cache. font_overrides and _load_overrides stay:
  resolve_font() still applies config/font_overrides.json.
  performance_stats stays: get_font() keeps it and tests read it.
- PluginManager.get_enabled_plugins.

test_deprecation.py pins only the two 3.9.0 markers now; the scanner
tests run against a stand-in core instead of the real markers. The
memory-tier tests read stats through log_memory_cache_stats() and the
component, and the test of the removed _clear_plugin_font_cache goes.
Docs drop the removed methods' reference entries; the Deprecated APIs
table becomes "Removed in 3.8.0". CHANGELOG gains a Removed section.

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

* chore(deprecation): drop the test harness's copies of the removed icon methods

VisualTestDisplayManager still drew weather icons that DisplayManager no
longer has, so a plugin's visual tests could pass on calls that raise
AttributeError on the real display. Its draw_sun/draw_cloud/draw_rain/
draw_snow/draw_weather_icon/draw_text_with_icons, WEATHER_COLORS and the
private helpers go, with the tests that exercised them. The CHANGELOG's
Deprecations entries no longer say nothing is removed.

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

* chore: prepare the 3.8.0 release

Bumps src.__version__ to 3.8.0 and turns Unreleased into ## 3.8.0, with a
summary and a New modules list (vegas_elements, testing.vegas, sports_vegas;
display_watchdog, plugin_catalog, plugin_runtime) for plugins flooring on
3.8.0. Adds the CHANGELOG line #701's second commit lacked (blocks laid out
off the render thread).

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

* chore: 3.8.0 also ships what landed on main since the prep

#703, #705, #706 and #693 merged after this branch was cut; their CHANGELOG
entries now sit under 3.8.0. The summary and New modules list name them
(sports consolidation stage 4's four modules, src/ipc, field_model), stage
4's section says to floor on 3.8.0, and src/common/README.md marks its four
modules 3.8.0 instead of Unreleased.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:07:02 -04:00
ChuckandClaude Opus 5.5 2601cb4cbb chore(deprecation): remove the 35 APIs deprecated for 3.8.0 (#708)
* chore(deprecation): remove the 35 APIs deprecated for 3.8.0

The usage scan (docs/DEPRECATIONS_3.8.md, regenerated 2026-10-01 and
committed here) finds no call or override of any of them in the 46
monorepo plugins or the 8 third-party plugins plugins.json lists; the
only core callers were other deprecated methods removed alongside.

- CacheManager: 13 methods, plus the private helpers only
  has_data_changed used (_has_*_changed, _is_market_open).
- DisplayManager: 7 methods, plus WEATHER_COLORS and the private
  _draw_sun/_cloud/_rain/_snow/_storm helpers only the icon methods used.
- FontManager: 14 methods, plus size_tokens, _save_overrides and
  _clear_plugin_font_cache. font_overrides and _load_overrides stay:
  resolve_font() still applies config/font_overrides.json.
  performance_stats stays: get_font() keeps it and tests read it.
- PluginManager.get_enabled_plugins.

test_deprecation.py pins only the two 3.9.0 markers now; the scanner
tests run against a stand-in core instead of the real markers. The
memory-tier tests read stats through log_memory_cache_stats() and the
component, and the test of the removed _clear_plugin_font_cache goes.
Docs drop the removed methods' reference entries; the Deprecated APIs
table becomes "Removed in 3.8.0". CHANGELOG gains a Removed section.

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

* chore(deprecation): drop the test harness's copies of the removed icon methods

VisualTestDisplayManager still drew weather icons that DisplayManager no
longer has, so a plugin's visual tests could pass on calls that raise
AttributeError on the real display. Its draw_sun/draw_cloud/draw_rain/
draw_snow/draw_weather_icon/draw_text_with_icons, WEATHER_COLORS and the
private helpers go, with the tests that exercised them. The CHANGELOG's
Deprecations entries no longer say nothing is removed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:45:28 -04:00
ChuckandClaude Opus 5.5 695ff92009 feat(ipc): display control socket, stage 1 - on-demand with acks (#706)
The display serves a control socket (/run/ledmatrix/control.sock) carrying versioned JSON commands, one per line, each answered. Stage 1 covers on-demand start, stop and status; commands are queued on the socket thread and applied on the render thread through the mailbox's own handler, and the web interface falls back to the file mailbox when the socket is unavailable. Protocol and security model: docs/IPC_CONTROL_SOCKET.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:33:02 -04:00
ChuckandClaude Opus 5.5 74696d2108 fix(display): routine rotation log lines are DEBUG (30% fewer journal lines) (#693)
"Processing mode", "display() returned False" and "No content to display" repeated what "Switching to mode" already logs on every rotation; they are now DEBUG. On ledpi this cut the display's journal lines by about 30%; the measured SD-write saving is small (within noise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:21:22 -04:00
ChuckandClaude Opus 5.5 3ad0438e75 feat(common): sports consolidation stage 4 -- the identical sweep (plugin host, live scroll, display rules, font path) (#705)
Moves the code every scoreboard plugin carries identically into core: src.common.sports_plugin_host, sports_live_scroll, sports_display_rules and sports_font_path, with unit tests and a parity test against the ledmatrix-plugins copies (LEDMATRIX_PLUGINS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:10:22 -04:00
ChuckandClaude Opus 5.5 c6701ac00d feat(web): ES-module page lifecycle and one schema field model (stage 1) (#703)
Adds a native ES-module layer to the web UI (core/boot, registry, api, facade; window.LEDMatrix as the one global), a page lifecycle that the Cache tab is converted to as the reference, text/javascript serving and revalidation for unversioned module requests, and src/plugin_system/field_model.py with a parity test against the render_field macro. Also: the cache page toggles its grey 'Not configured' style instead of only adding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:00:26 -04:00
ChuckandClaude Opus 5.5 795834811f perf(scroll): extend and trim the Vegas strip in place (#701)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

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

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

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

* feat(vegas): live elements update in place while they scroll

One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

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

* feat(sports): live Vegas cards for the scoreboards (shared layer)

One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

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

* feat(vegas): keep live games in the ticker by default

display.vegas_scroll.live_in_ticker now defaults to true: through a live
game the marquee keeps running and the live scoreboard takes extra turns in
it -- its cards updating in place while they scroll -- instead of the ticker
giving way to the full-screen scoreboard.

The new default would reach nobody on its own: every existing config holds
an explicit false copied from the template (there was no control for it),
and the template merge only adds missing keys. ConfigManager therefore turns
a stored false on once, with a backup, and records live_in_ticker_migrated
so a false chosen afterwards stays. The marker is never in the template.

A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests
that pin the full-screen takeover now say live_in_ticker=false.

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

* feat(dev): preview a plugin's Vegas strip in the dev server

The dev server's View selector gains "Vegas strip (live elements)" and
"Vegas strip (plain Vegas content)": the plugin's block of the Vegas ticker,
laid out by the ticker's own code (render_vegas_strip, as render_plugin.py
--vegas uses), with its live elements listed. /api/render takes
"vegas": "live" | "plain"; the display view is unchanged.

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

* perf(scroll): extend and trim the Vegas strip in place

Every strip extension rebuilt the whole strip (np.concatenate: 2-2.6 ms for
a 10-14k px strip at 512x64 on a Pi 4) and every trim copied what was left
(1.2-1.8 ms), on the render thread. With ~4 ms of slack per refresh, every
extension frame on hdpi missed its refresh (5/5 in each soak run).

The strip now lives in a buffer with spare room; cached_array is a view of
its live columns. An append writes only the new columns (~0.2 ms), a trim
only moves the view's start, and the one full copy happens when the buffer
is reallocated (STRIP_SPARE_FACTOR 3: about once every two strip-lengths
scrolled). A cached_array set from outside -- the multi-display follower's
read-only one, create_scrolling_image's -- is never written through, and a
new strip lets the old buffer go. last_copy_bytes says what was copied, and
the Vegas frame-timing attribution reports that instead of the whole strip.

test_scroll_helper_in_place.py: the buffer is reused and only new columns
copied, trims copy nothing, reallocation when the room runs out, outside
arrays untouched, and random appends/trims/patches/scrolling checked frame
by frame against the old copying strip (mutation-checked).

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

* refactor(sports): a default _determine_game_type on SportsScrollDisplay

render_vegas_card looked the method up with getattr and a None default, which
static analysis (Codacy) reports as calling something that may not be
callable. The base class now has the default -- the card type from the game's
state -- and the plugins that define their own override it as before.

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

* refactor(scroll): no assert in _extended_strip

An assert vanishes under python -O (Codacy); a real check says the same.

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

* fix: review follow-ups on the shared live-card layer

- The reused Vegas renderer always gets the current rankings, empty
  included, so ranks cleared since are not kept drawn.
- render_plugin.py: --timeline refuses --no-live (a timeline shows live
  elements changing), --timeline/--no-live need --vegas, and the Vegas
  paths create the output's directory like the display path does.

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

* perf(vegas): lay a group's blocks out before the extension, off the render thread

#701 cut the strip copy, but the frame after an extension was still late:
the render thread also joined each plugin's rows (separation_gap measures
every pair) and pasted the blocks into an addition image, ~37 ms on hdpi
(Pi 4, 512x64) against ~3.75 ms of slack.

The thread that fetched the group now does that as each member arrives:
RenderPipeline.prepare_group_member joins the rows and turns the block into
pixels (the prefetch thread and the live worker, under the render gate).
extend_scroll_content takes those blocks, and ScrollHelper.append_content
writes items -- images or RGB arrays -- straight into the strip's spare room,
blanking only the gaps. A member that was not prepared (an inline fetch) is
joined at the extension as before; the strip is identical either way.

On hdpi the extension's render-thread work goes from 37.5 ms to 3.2 ms p50
in place (6.9 ms when the buffer is reallocated). The helper's per-append
INFO line, a duplicate of the pipeline's, is now DEBUG.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:48:47 -04:00
ChuckandClaude Opus 5.5 dfd67c7c8b feat(dev): preview a plugin's Vegas strip in the dev server (#700)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

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

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

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

* feat(vegas): live elements update in place while they scroll

One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

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

* feat(sports): live Vegas cards for the scoreboards (shared layer)

One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

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

* feat(vegas): keep live games in the ticker by default

display.vegas_scroll.live_in_ticker now defaults to true: through a live
game the marquee keeps running and the live scoreboard takes extra turns in
it -- its cards updating in place while they scroll -- instead of the ticker
giving way to the full-screen scoreboard.

The new default would reach nobody on its own: every existing config holds
an explicit false copied from the template (there was no control for it),
and the template merge only adds missing keys. ConfigManager therefore turns
a stored false on once, with a backup, and records live_in_ticker_migrated
so a false chosen afterwards stays. The marker is never in the template.

A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests
that pin the full-screen takeover now say live_in_ticker=false.

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

* feat(dev): preview a plugin's Vegas strip in the dev server

The dev server's View selector gains "Vegas strip (live elements)" and
"Vegas strip (plain Vegas content)": the plugin's block of the Vegas ticker,
laid out by the ticker's own code (render_vegas_strip, as render_plugin.py
--vegas uses), with its live elements listed. /api/render takes
"vegas": "live" | "plain"; the display view is unchanged.

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

* refactor(sports): a default _determine_game_type on SportsScrollDisplay

render_vegas_card looked the method up with getattr and a None default, which
static analysis (Codacy) reports as calling something that may not be
callable. The base class now has the default -- the card type from the game's
state -- and the plugins that define their own override it as before.

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

* fix: review follow-ups on the shared live-card layer

- The reused Vegas renderer always gets the current rankings, empty
  included, so ranks cleared since are not kept drawn.
- render_plugin.py: --timeline refuses --no-live (a timeline shows live
  elements changing), --timeline/--no-live need --vegas, and the Vegas
  paths create the output's directory like the display path does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:37:15 -04:00
ChuckandClaude Opus 5.5 7f06cc9c3b feat(vegas): keep live games in the ticker by default (#699)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

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

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

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

* feat(vegas): live elements update in place while they scroll

One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

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

* feat(sports): live Vegas cards for the scoreboards (shared layer)

One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

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

* feat(vegas): keep live games in the ticker by default

display.vegas_scroll.live_in_ticker now defaults to true: through a live
game the marquee keeps running and the live scoreboard takes extra turns in
it -- its cards updating in place while they scroll -- instead of the ticker
giving way to the full-screen scoreboard.

The new default would reach nobody on its own: every existing config holds
an explicit false copied from the template (there was no control for it),
and the template merge only adds missing keys. ConfigManager therefore turns
a stored false on once, with a backup, and records live_in_ticker_migrated
so a false chosen afterwards stays. The marker is never in the template.

A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests
that pin the full-screen takeover now say live_in_ticker=false.

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

* refactor(sports): a default _determine_game_type on SportsScrollDisplay

render_vegas_card looked the method up with getattr and a None default, which
static analysis (Codacy) reports as calling something that may not be
callable. The base class now has the default -- the card type from the game's
state -- and the plugins that define their own override it as before.

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

* fix: review follow-ups on the shared live-card layer

- The reused Vegas renderer always gets the current rankings, empty
  included, so ranks cleared since are not kept drawn.
- render_plugin.py: --timeline refuses --no-live (a timeline shows live
  elements changing), --timeline/--no-live need --vegas, and the Vegas
  paths create the output's directory like the display path does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:27:16 -04:00
ChuckandClaude Opus 5.5 a12be7c3c5 feat(sports): live Vegas cards for the scoreboards (shared layer) (#698)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

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

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

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

* feat(vegas): live elements update in place while they scroll

One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

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

* feat(sports): live Vegas cards for the scoreboards (shared layer)

One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

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

* refactor(sports): a default _determine_game_type on SportsScrollDisplay

render_vegas_card looked the method up with getattr and a None default, which
static analysis (Codacy) reports as calling something that may not be
callable. The base class now has the default -- the card type from the game's
state -- and the plugins that define their own override it as before.

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

* fix: review follow-ups on the shared live-card layer

- The reused Vegas renderer always gets the current rankings, empty
  included, so ranks cleared since are not kept drawn.
- render_plugin.py: --timeline refuses --no-live (a timeline shows live
  elements changing), --timeline/--no-live need --vegas, and the Vegas
  paths create the output's directory like the display path does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:32:07 -04:00
ChuckandClaude Opus 5.5 f4bda50710 feat(vegas): live elements update in place while they scroll (#697)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

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

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

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

* feat(vegas): live elements update in place while they scroll

One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:07:31 -04:00
ChuckandClaude Opus 5.5 56947298d6 a plugin API for content that changes while it scrolls (#696)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

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

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:59:16 -04:00
ChuckandClaude Opus 5.5 596809acc3 perf(scroll): build the strip's PIL image only when something reads it (#695)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:34 -04:00
ChuckandClaude Opus 5.5 77862b631b perf(timing): say which render-thread work a late frame followed (#694)
* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

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

* docs(changelog): note the frame-op attribution and bench modes

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:39 -04:00
ChuckandClaude Opus 5.5 7804ea8f69 feat(update): stable/beta update channel; stable follows release tags (#684)
Adds auto_update.channel: stable follows the newest vX.Y.Z release tag
(detached HEAD; pre-releases and other tags ignored), beta follows main as
before. Nothing ever moves a device backwards: a checkout newer than the
newest release keeps following main (or stays put when detached) until a
release contains its commit. Legacy configs migrate to stable when they
reach a release. Update Code, the weekly updater's preflight, and the
verifier's rollback (back to old_ref: branch or detached release) all
honour the channel. General tab Update Channel select, GET/POST
/api/v3/system/update-channel, release-aware Overview banner and Tools git
panel. New installs default to stable.

Rig fix (ledpi): /system/check-update reports update_available: false when
the channel's action is none (a detached HEAD newer than the newest
release), matching Update Code; the Tools panel no longer calls every
detached HEAD "a release".

Merged with main through #687 (heartbeat verifier, #683 login, #688
plugin_catalog, #685 Tailwind build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:35:26 -04:00
ChuckandClaude Opus 5.5 64c7289593 feat(display): systemd watchdog and heartbeat for a frozen render loop (#687)
If the render loop gets stuck inside a plugin's display(), ledmatrix.service
stays active and the panel stays frozen. This adds a way to detect that.

- src/display_watchdog.py (standard library only) sends sd_notify over
  $NOTIFY_SOCKET and writes /run/ledmatrix/display-heartbeat.json. Only the
  render thread counts: beats from other threads are ignored.
- ledmatrix.service: WatchdogSec=120, NotifyAccess=main,
  RuntimeDirectory=ledmatrix (0755), RestartSteps=4 and
  RestartMaxDelaySec=2min. It stays Type=simple. run.py widens the watchdog
  to 15 min for start-up, and load_plugin() does the same on the render
  thread. The loop arms after its first frame.
- /api/v3/health adds checks.display_loop: running, stalled (no heartbeat
  for over 60s, which makes the status degraded) or not_reported. With web
  login on, a caller who is not logged in still gets only healthy/degraded,
  and a stall degrades that answer.
- The update verifier requires a fresh heartbeat from the restarted display
  when the display it replaced was writing one. A frozen panel is rolled
  back.
- Existing installs get the systemd watchdog only after install_service.sh
  is re-run. The heartbeat works right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:15:31 -04:00
ChuckandClaude Opus 5.5 b09434a418 refactor(plugins): the display publishes plugin runtime state; retire plugin_state.json (#690)
Stage 2 of the web plugin catalog, after #688.

- The display publishes a plugin runtime snapshot (plugin_runtime.py) to
  the shared cache: per plugin loaded, lifecycle state, a short redacted
  error summary, the version it loaded and when, plus published_at /
  stale_after / running. Written on change (throttled to 10 s; the
  RUNNING/ENABLED flip of an ordinary update is not a change) and once a
  minute otherwise; cleanup() publishes running: false.
- The web reads it back and restores loaded / state / error_info in
  /api/v3/plugins/installed (plus loaded_version, loaded_at and
  data.runtime). Only a live snapshot counts; stale, stopped or missing
  answers null and says which.
- data/plugin_state.json is retired: every reader and writer moved to
  config + disk (desired) or the snapshot (observed). Nothing in it was
  non-derivable, so nothing is migrated and an existing file is left
  unread. The web-side PluginStateManager (state_manager.py) is removed;
  the display's plugin_state.PluginStateManager is the only state machine.
- StateReconciliation compares config + disk with the snapshot, reporting
  enabled-but-not-loaded and older-version-loaded as no_action findings.
- Backups list installed manifests with enabled from config.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:48:14 -04:00
ChuckandClaude Opus 5.5 7ab6fb1aff refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.

- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
  directories, display modes, installed version, schema and config reads,
  with no way to run a plugin. app.py and both blueprints use it; the
  plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
  reach the display through ConfigService (on_config_change) and the
  enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
  resource_monitor. /plugins/installed reports loaded/state/error_info as
  null (the display does not publish them) and enabled by the display's
  rule.
- Store install, update and uninstall answer restart_required when the
  running display will not pick the change up by itself
  (display_restart_required). The restart banner follows the flag via
  window.noteRestartRequired instead of the /config/main URL heuristic;
  /config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
  modules, oauth_flow action scripts) goes through
  _import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
  user's setting or the manifest, with vegas_participation_source; when
  only the plugin's code decides it, null with source 'runtime', since the
  web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
  fails, and asks for a restart when an enabled plugin's first request got
  no answer and the re-sent one found it up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:39:44 -04:00
ChuckandClaude Opus 5.5 ba6eccb489 build(web): generate the UI's Tailwind CSS with the pinned standalone CLI (#685)
Replaces the hand-written Tailwind subset in app.css with a real, purged
Tailwind build: scripts/build_css.py runs the pinned, SHA-256-checked
standalone Tailwind CLI (no Node), the generated tailwind.css and
plugin-frame.css are committed, and CI fails when they are stale. The Pi
never builds anything. The login page (#683) now links tailwind.css too,
and the load-order test covers every template that links app.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:38:16 -04:00
ChuckandClaude Opus 5.5 5ea0d511dc feat(store): read ledmatrix_min_version, aliases and commit from the registry (#686)
The store reads three optional registry fields: ledmatrix_min_version
(an incompatible install/update is refused before any download, with a
"Needs LEDMatrix X+" card badge), aliases (update/uninstall/reinstall by
registry id find a plugin installed under its manifest id, with registry
proof only), and commit (shown and linked on the store card). An older
plugins.json behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:31:16 -04:00
ChuckandClaude Opus 5.5 1c928b2033 feat(vegas): one declared participation per plugin (scroll | pause | exclude) (#682)
A plugin takes part in Vegas mode in one declared way: 'scroll', 'pause'
or 'exclude', resolved from the user's vegas_participation setting, the
manifest field, then the legacy hooks, so no plugin changes behaviour.
The stream manager decides inclusion and pauses through it; the installed
plugins API and the Vegas plugin-order list report it. Deprecates
get_supported_vegas_modes, get_vegas_segment_width and vegas_panel_count
for removal in 3.9.0, and regenerates docs/DEPRECATIONS_3.8.md to include
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:30:37 -04:00
ChuckandClaude Opus 5.5 b2df0fda1b docs(sports): record the ufc round-break verify outcome (refuted) (#692)
ESPN sends the break between rounds as STATUS_END_OF_ROUND with
displayClock "-", not "0:00", so the shared game-over rule never
drops a five-round fight at the round 4 break. Verified against
recorded payloads in ChuckBuilds/ledmatrix-plugins#580, which pins it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:20:30 -04:00
ChuckandClaude Opus 5.5 15c61def67 fix(web): make the SSE streams' 200/min rate limit actually apply (#691)
app.py called limiter.limit("200 per minute")(stream_x) after the routes
were registered and discarded the result. flask-limiter 3.x enforces a
decorated limit in the wrapper limit() returns, and marks the original
function so the before_request middleware skips it, so the streams had
no limit at all -- not even the 1000/min default. Register the wrapper
as the view instead.

The new test (skipped without flask-limiter) reconnects to each stream
201 times and expects the last to get a 429; it fails on the old code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:12:23 -04:00
ChuckandClaude Opus 5.5 c8a0ddcf7b chore(deprecation): retarget the 35 deprecations to 3.8.0 and add a usage scan (#681)
Moves the 35 @deprecated markers from 3.7.0 (already shipped with them in
place) to 3.8.0, and adds scripts/plugin_api_usage.py plus the generated
docs/DEPRECATIONS_3.8.md: who still calls or overrides each deprecated
method across core, the monorepo and third-party plugins. Removes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:11:36 -04:00
ChuckandClaude Opus 5.5 9fe23af432 docs(sports): reconcile-then-promote roadmap, drift report and report-only CI job (#680)
Rewrites the roadmap in docs/SPORTS_UNIFICATION.md for the
reconcile-then-promote decision (stages 0-3 recorded as done), and adds the
sports drift report script with a report-only CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:11:02 -04:00
ChuckandClaude Opus 5.5 c0d97e4867 fix(plugins): one hung plugin no longer stops every plugin from updating (#677)
The update worker no longer blocks forever on a plugin whose display()
never returns. It waits at most PLUGIN_LOCK_TIMEOUT (5s) for a plugin's
lock, then skips that plugin's update (a report-only "busy skip" in
health) and keeps updating every other plugin. display() frames are timed
(slow calls logged and counted; calls past the executor timeout recorded as
hangs), a hung update() is recorded, and on_config_change() now runs under
the plugin lock or is deferred to the worker. The plugin-facing API is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:41 -04:00
ChuckandClaude Opus 5.5 e3c85cece6 feat(web): optional web login and API tokens, off by default (stacked on #674) (#683)
Optional web login, off by default: a device that sets no password behaves
exactly as before. Set under General > Security; then every page and API
route needs a session login or an API token (Authorization: Bearer).
Loopback, the Wi-Fi setup flow in AP mode, static files, captive-portal
probes and a reduced /api/v3/health stay open. Secrets live in the web_auth
section of config_secrets.json and no API returns them.
scripts/reset_web_password.py turns login off. Stacked on #674.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:09:40 -04:00
ChuckandClaude Opus 5.5 ba38a83c2c fix(web): refuse cross-site state-changing requests (Origin/Referer check) (#674)
The web interface refuses state-changing requests (POST/PUT/PATCH/DELETE)
whose Origin (or, without one, Referer) is not the host they were sent to,
or is null: 403 CROSS_SITE_REQUEST (web_interface/origin_guard.py). Any
website a LAN user visited could otherwise make their browser POST a plain
form to the Pi. /api/v3/system/action also refuses form-encoded and
text/plain bodies (415) unless sent by HTMX. Clients that send no Origin or
Referer (curl, requests, Home Assistant, the MQTT bridge) are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:02:01 -04:00
316 changed files with 45614 additions and 4943 deletions
+4
View File
@@ -6,3 +6,7 @@
# and systemd rejects CRLF unit files.
*.sh text eol=lf
*.service text eol=lf
# Generated by scripts/build_css.py; collapsed in diffs, not hand-edited.
web_interface/static/v3/tailwind.css linguist-generated=true
web_interface/static/v3/plugin-frame.css linguist-generated=true
+55
View File
@@ -113,6 +113,25 @@ jobs:
REQUIRE_DOM: "1"
run: node test/js/run_all.js
css-build:
name: Tailwind CSS is up to date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
# Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no
# Node), rebuilds static/v3/tailwind.css and plugin-frame.css from the
# templates and JS, and fails if the committed files differ. Fix a
# failure by running `python3 scripts/build_css.py` and committing.
- name: Check the committed CSS matches a fresh build
run: python scripts/build_css.py --check
type-check:
name: Type check (mypy ratchet)
runs-on: ubuntu-latest
@@ -140,3 +159,39 @@ jobs:
# them, or if a listed file is missing. See CONTRIBUTING.md.
- name: Run mypy on the ratchet list
run: python scripts/check_types.py
sports-drift-report:
name: Sports drift report (report only)
runs-on: ubuntu-latest
# A progress measure for docs/SPORTS_UNIFICATION.md, never a gate: the
# monorepo's own check_sports_drift.py is the gate. The step summary shows
# how many bodies each scoreboard method family still has.
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Check out ledmatrix-plugins (main)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ChuckBuilds/ledmatrix-plugins
path: ledmatrix-plugins
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
# Stdlib only; exits 0 whatever it finds.
- name: Report method-family drift across the nine scoreboards
run: |
python scripts/sports_drift_report.py --plugins ledmatrix-plugins \
--markdown --json sports-drift.json >> "$GITHUB_STEP_SUMMARY"
python scripts/sports_drift_report.py --plugins ledmatrix-plugins
- name: Upload the full report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sports-drift-report
path: sports-drift.json
+942 -1
View File
@@ -19,8 +19,672 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
### Garbage-collection pauses in the frame stats
- The display now times every Python garbage collection
(`src.common.frame_timing.GcMonitor`, installed once per process from
`gc.callbacks`). The collector stops every thread while it runs, and a
long one looked like any other render stall. A collection of 20 ms or more
tags the next presented frame `gc`, so `scripts/frame_soak.py` shows its
late rate under *after work*; the stats file gains an additive `gc` block
(collections and seconds per generation, the longest, the long ones),
which the soak report prints as a "Garbage collection" line; and a
`Render stall` dump says when a long collection ran inside the stall.
`scripts/render_bench.py` records the same. Diagnostic only: nothing tunes,
freezes or disables the collector.
### Outlined text: one rasterization
- New `draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0,
0), offsets=OUTLINE_SQUARE)` in `src/common/text_helper.py`, with
`OUTLINE_SQUARE` (the eight-sided outline the scoreboards draw) and
`OUTLINE_CROSS` (four sides). Outlined text was one `draw.text` per
outline offset plus one for the text, so FreeType rasterized the same
string nine times. This rasterizes it once and stamps the mask at each
offset: the same pixels, about 8x faster per outlined string (Pillow 12.3,
desktop). `test/test_text_helper.py` compares it with the nine-draw loop
across the bundled fonts, image and font modes, colours and positions,
and fails if it stops rasterizing once. Fractional coordinates, multiline
text, fonts other than a plain `FreeTypeFont`, image modes other than
RGB, RGBA and L, and a subclassed or replaced `draw.text` take the old
loop unchanged. A whole-pixel float such as `52.0`, which the scorebugs'
centring passes, is not fractional.
- `SportsCoreSharedMixin._draw_text_with_outline`, which eight of the nine
scoreboards inherit for their switch-mode scorebug (ufc has its own), and
`TextHelper.draw_text_with_outline` now draw through it. Scroll and Vegas
cards still use each plugin's own `game_renderer.py` loop, so building a
scroll strip costs the same until the plugins adopt `draw_text_outlined`,
importing it with an `ImportError` fallback to their own loop (a separate
ledmatrix-plugins change after a core release ships it).
### Shared fetch service (stage 1)
Core's own HTTP fetch paths now go through one service, so the plugins that
use them get pooling, merging, host budgets and per-plugin request counts
without a code change. Return values, exceptions, cache keys, TTLs and retry
policies are unchanged.
- **What goes through it.** `APIHelper.get`/`post`, `fetch_espn_scoreboard`
and its date chunks (`src/common/espn_dates.py` -- every scoreboard's live,
recent and upcoming fetch, and `SportsFetchMixin._fetch_season_directly`),
`BackgroundDataService` and `BaseOddsManager.get_odds`. Plugins' own
`requests` calls are not covered yet.
- **Shared connection pools.** Core sessions with the same retry policy mount
one shared adapter, so the odds managers (one per scoreboard league
manager), the background service and the APIHelpers reuse one connection
pool per host. Headers, cookies and auth stay per session.
- **Merged requests.** Identical GETs in flight at once (same URL and query,
effective headers, timeout and retry policy) go out once; the others get a
copy of that response or the same exception. `BackgroundDataService`'s own
request opts out (`share_in_flight=False`): it cancels and replaces fetches,
and already merges by cache key.
- **Host budgets.** Per-host token buckets, `fetch_service.rate_limits` in
`config.json` (new optional section in the template). ESPN hosts default to
20 requests/s with a burst of 200, far above normal traffic; no request waits
longer than `max_wait_seconds` (2 s). Other hosts are unthrottled.
- **Conditional GET.** A response with `ETag` or `Last-Modified` is kept in a
small bounded store (64 entries, 4 MB, 1 MB each) and revalidated; a `304`
is returned to the caller as the original `200`. ESPN sends neither
validator today, so on ESPN this is dormant.
- **Counters.** Requests, merged, bytes, 304s, errors, HTTP errors, adapter
retries, throttled requests and seconds waited, per plugin and per host.
Which plugin made a request comes from a context variable the plugin
executor and plugin loader set (carried across the background service's and
`espn_dates`' worker threads), or else from the plugin directory on the
stack, so a plugin's own threads count too. The display publishes them to
the shared cache at most once a minute on change; read them at
`GET /api/v3/plugins/fetch-stats`.
- `fetch_service` is a core config section (`src/core_config_keys.py`).
### Control socket (stage 2: wake-ups, brightness, plugin reload)
- **Socket commands land at once.** Stage 1's socket was no faster than the
mailbox: a command waited for the static screen's 1 s frame sleep, the
dwell's 0.25 s tick, or Vegas's interrupt check every 10 frames (about
0.4 s on a Pi 4). The render thread now waits on the socket's queue
instead of sleeping, and Vegas checks the queue every frame, so an
on-demand start or stop is applied within about a millisecond on a static
screen or in a dwell, and at the next frame in Vegas or on a scrolling
screen. Commands still run only on the render thread. The file mailbox
keeps its old delays. Idle CPU is unchanged in practice: the waits are
timed `Event` waits with the same wake-ups as the sleeps they replace
(about 25 µs more per wait, measured).
- **`brightness.set`.** Saving a brightness (`POST /api/v3/config/main`)
also puts it on the panel at once over the socket, instead of when the
display's config watcher next reads `config.json` (up to about 2 s). The
response says `brightness_transport: "socket"`, or `"config"` with
`brightness_socket_error` when the watcher applies it as before. The
command itself writes nothing; the dim schedule still applies on top.
- **`plugin.reload`.** Updating an enabled plugin from the store no longer
asks for a display restart when the display can reload it: the update
route asks the display over the socket, which reloads the plugin on its
render thread at the start of the next screen (its modes keep their place
in the rotation) and answers once the new code runs. The response then
says `restart_required: false`, `reloaded: true` and `reloaded_version`.
Without the socket, with a display older than this command, or when the
reload fails, the route answers `restart_required: true` as before, with
`reload_error` giving the reason. The route now also uses the manifest's
plugin id (the one the display runs it under) for this decision, so an
update through a registry alias of an enabled plugin no longer reports
that no restart is needed.
- Both commands answer with the render thread's outcome, or `pending` when
it did not get to them in time (2 s and 10 s). Socket protocol version is
still 1: new commands are additive, and an older display answers
`unknown_command`, which the web interface falls back from. The security
model is unchanged: the same `0660` group socket and peer-credential
check. `config.reload` was not added; see
`docs/IPC_CONTROL_SOCKET.md` for why.
### New modules
- `src/common/fetch_service.py` -- the fetch service above. Core-internal in
this release: plugins reach it through `APIHelper` and `espn_dates`, and
should not import it directly until a plugin-facing API ships (stage 3), so
it sets no `ledmatrix_min_version` floor.
### Tooling
- Golden trace tests for the display loop. `test/test_run_loop_golden.py`
runs the real `DisplayController.run()` against fake plugins on a fake
clock (`test/_run_loop_harness.py`), with no hardware and no real sleeps,
and compares which mode was shown, for how long and why it ended with
`test/fixtures/run_loop_golden/`. It has 15 scenarios: rotation,
empty and failing modes, dynamic duration, live priority, on-demand
(including pinned and resumed after a restart), the schedule and dim
schedule, WiFi notices, sync follower and Vegas. The whole file runs in
about a second. This is stage 1 of restructuring `run()`, described in
`docs/RUN_LOOP_REDESIGN.md`. The other part of stage 1 is internal and
changes no behaviour: twelve blocks of `run()` move into named helpers
(`_dispatch_first_frame`, `_resolve_durations`, `_resolve_active_mode`,
`_needs_high_fps`, `_advance_after_screen` and others), and the traces are
identical before and after the move.
### Fixes
- A plugin reload after a store update (`plugin.reload`, #720) no longer
freezes the panel during Vegas. On ledpi a football reload froze it for
3.0 s (`Render stall over: no frame for 3043ms`). The reload ran on the
render thread, and its unload waited for the plugin's lock. The strip's
prefetch thread held that lock while it rebuilt the old instance's Vegas
content. The render thread now only takes the plugin out of the rotation
and out of the plugin manager (`PluginManager.detach_plugin`). A
`plugin-reload-<id>` thread waits for the lock, tears the old instance
down and loads the new one, and the new instance joins the rotation
between two frames. In a test with a 3.0 s render holding the lock, the
longest gap between frames went from 3017 ms to 9 ms. The reply still
reports the real outcome, the modes keep their places in the rotation,
and Vegas fetches the plugin again. While it reloads, an on-demand request
for the plugin is refused (`plugin-reloading`), and a config reconcile
neither loads it twice nor unloads it mid-load. A Vegas fetch that waited
out a reload for the lock skips the old instance.
- The schedule-off blank and the WiFi notice no longer start with a
scroller's leftovers. Both are drawn by the display controller rather than
dispatched to a plugin, so #716's handover never reached them: drawn while
the last scroll's state was still set, the blank went out with the
ticker's lagging rows on a scan-compensated panel and stayed up for its
60 s dwell, and the notice's redraws (which #712 now shows over a running
scroller or Vegas) were counted as 0.5-1 s freezes and logged as a
`Render stall ... mid-scroll`. The controller now ends the scroll state
before drawing either.
- A plugin whose `display()` raises now opens its circuit breaker. The first
frame of each screen goes through the plugin executor, which caught the
exception and returned False. The display read that as "no content" and
recorded a success, which reset the plugin's failure streak, so the breaker
never tripped. The plugin stayed in rotation and logged a traceback on
every screen. The raise now counts as a failure, so after three in a row
the plugin leaves rotation until the cooldown ends, the same as a raising
`update()`. The display still moves straight on to the next mode. A hung
`display()` is still recorded once, as a hang.
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
within about a second of being posted. It was only checked between
screens, so a 5 s notice posted during a 20 s screen expired before that
screen ended and never appeared. The screen it interrupts comes back in
full once the notice ends. When Vegas stops scrolling for a notice, the
notice is what shows next, and Vegas resumes after it; before, a rotation
screen showed instead and the notice expired behind it. An active
on-demand session still holds the panel until it ends.
- A game that goes live now takes over the panel within about a second.
Live priority was only checked between screens, so a game that went live
during a 30 s screen waited for that screen to end. The frame loops and the
dwell sleep now check too, at most once a second, and not while an
on-demand session is running or a live game is already showing. When Vegas
stops for a live game, the game is the next screen. Before, one rotation
screen showed first and the game came after it. Each check also asks each
plugin `has_live_content()` once, where a plugin registered under several
modes used to be asked once per mode.
- The display schedule turns the panel off at exactly the end time. A window
now runs from its start time up to, but not including, its end time: with
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
minute counted as on, and because the schedule is checked once a minute,
the panel went off at 23:00 or at 23:01 depending on when in the minute
that check ran. Windows that cross midnight and per-day schedules follow
the same rule, and so does the dim schedule.
- An on-demand session that ends during scheduled-off hours, by expiring or
being stopped, blanks the panel within about a second. It used to stay on
until the next minute, because the once-a-minute schedule check had
already run that minute and the session had overridden its answer.
### Scrolling
- A scoreboard in scroll mode no longer freezes the panel at the start of a
recent or upcoming turn whose games have not changed.
`SportsScrollDisplayManager.prepare_and_display()` redrew every card on
every turn while the render thread waited (~1.4s for seven football cards
at 192x48 on a Pi 4); it now rewinds the strip it built last time when
nothing it is drawn from has changed (the games, rankings, config, panel
size and date), and redraws it at least every 10 minutes. Each slate (game
type and leagues) keeps its own display, so leagues that take turns
(`nfl_recent`, `ncaa_fb_recent`) each find their strip again: up to 4 per
game type, with at most 6MB per plugin of strips kept for slates not on
screen. The first turn of each slate after a start, a slate whose games
changed, live strips and a turn with no games are drawn as before.
`get_scroll_display()` and `_scroll_displays` still answer with the strip
on screen; a sport's `prepare_scroll_content()` is no longer called on
every turn.
### Web preview: less work per frame
- Mid-scroll, `update_display()` no longer checksums every frame. The
checksum (`tobytes()` plus `adler32` over the whole framebuffer: ~0.17 ms a
frame at 256x64 on a Pi 4, so roughly twice that at 512x64 and well under
0.1 ms at 128x32) fed only the dirty-tracking skip, which never applies
while scrolling, and the preview snapshot's changed-frame check. The
snapshot now asks its policy first and hashes the frame only when a write
or touch could follow. That changes no snapshot decision:
`snapshot_policy.decide()` is monotone in `frame_changed`, and a test holds
it to that. Two small differences on the panel: the first static frame
after a scroll is pushed even when it matches the scroll's last frame (one
extra swap), and the frame on which a scroll that never said it stopped
times out is presented at a hold of 1 rather than the scroll's hold.
- With the web preview open, the display writes the snapshot at most once a
second (`snapshot_policy.VIEWER_INTERVAL`, was 0.2 s). The preview already
showed at most one frame a second: its SSE stream re-read the file once a
second, so four PNG encodes in five were overwritten unread. The stream now
checks the file's mtime every 0.25 s (new `VIEWER_POLL_INTERVAL`) and sends
each frame soon after it is written, so the preview stays about as fresh;
it still touches the viewer marker once a second, and with no snapshot
file it still sends its placeholder once a second. A screen that animates
faster than once a second without marking itself as scrolling (a GIF, say)
was encoded on the render thread up to five times a second while the
preview was open, 12-14 ms each at 512x64 on a Pi 4; now at most once.
- The snapshot PNG is written at `compress_level=1`. On a desktop that
encoded a text-dense 512x64 frame in about half Pillow's default time, into
a larger file (12 KB instead of 7 KB); sparser frames gain less.
- `scripts/frame_soak.py --preview` soaks are not comparable across this
change: an open preview now costs at most one encode a second, not up to
five. Take both sides of an A/B pair on the same side of it.
### Scroller-to-static handovers
- A static plugin screen that follows a scroller no longer starts with the
scroller's leftovers. Nothing ended the scroll state at a handover; it
expired 2 s after the last scroll frame. So on a panel with scan-order
compensation the static screen's first frame went out with the lagging
rows (the bottom half on a 96x48 panel) taken from the ticker's last
frame: for the whole second it stays up after a scroll at one frame per
refresh, and for its first refresh after a slower, held one. The display
controller now calls the new
`DisplayManager.end_scroll_for_static_screen()` just before such a
screen's first `display()`, so the frames that call draws go out as
drawn, in one swap each, and `set_scrolling_state(False)` once it
returns. The scroll state and its frame hold stay until then, so the
handover is still timed, against the scroller's own pacing: late-frame
counts are unchanged.
- The phantom ~1 s freeze when a static plugin screen follows a scroller is
no longer recorded: the 1 Hz loop's second frame was timed as a frame of the old
scroll, in the soak's freezes and as a `Render stall` in the log. On ledpi
that was 17 of 31 `Render stall over` lines (2026-09-15 to 10-01).
- A screen's first frame is tagged `handover` in the frame stats, every
turn's, also when the rotation comes back to the same mode. A gap of
250 ms or more before it is counted in the new `handover_freezes`
(additive; the schema version is unchanged), not in `freezes` /
`freeze_by`, and `frame_soak.py` prints it as "Handover gaps": a
scroller rebuilding its content at the start of a turn shows up there.
**Freeze counts from soaks before and after this change are not
comparable.** A stall dump taken while that first `display()` is still
drawing says `in a handover gap` instead of `mid-scroll`, and the call
runs on a thread named `display-<plugin id>`.
## 3.8.0
Live Vegas elements: plugin content that keeps changing while it scrolls
(scores on the scoreboards' cards, the flight map's gliding aircraft, the
weather radar's loop), with live games kept in the ticker by default. Also
stable/beta update channels, the display control socket, the systemd
display watchdog, optional web login, ES-module web UI pages, sports
consolidation stage 4, and the removal of the 35 plugin APIs deprecated
since 3.5.0 (see Removed).
### New modules
A plugin may import these via `src.*` once it floors on 3.8.0 (and should
guard the import, since the loader's version check is advisory).
- `src/plugin_system/vegas_elements.py` -- `VegasElement`, the unit a
plugin's `get_vegas_elements()` returns (also re-exported from
`base_plugin`). See "Live Vegas elements" below.
- `src/plugin_system/testing/vegas.py` -- the harness for those hooks:
`render_vegas_elements`, `check_vegas_elements`, `render_vegas_timeline`.
- `src/common/sports_vegas.py` -- what a scoreboard needs for live cards:
`game_key`, `game_fingerprint`, `dedupe_games`, `VegasCardCache`,
`StickyOdds`, `finished_games`.
- `src/common/sports_plugin_host.py`, `sports_live_scroll.py`,
`sports_display_rules.py` and `sports_font_path.py` -- sports
consolidation stage 4; see "New modules (sports consolidation stage 4)"
below.
- `src/display_watchdog.py`, `src/plugin_system/plugin_catalog.py`,
`src/plugin_system/plugin_runtime.py`, `src/plugin_system/field_model.py`
and the `src/ipc/` package -- new core modules (described below) that
plugins do not normally import.
### Web UI: ES modules and one form model (stage 1)
- The web UI gains a native ES-module layer, loaded with
`<script type="module">` and served as-is (no bundler, nothing built on
the Pi): `static/v3/js/core/` (`boot.js`, `registry.js`, `api.js`,
`facade.js`) and `static/v3/js/pages/`. `window.LEDMatrix` is its one
global: `api`, `pages`, `notify`, `escape`, `widgets` and `deprecate`, the
last keeping old `window.*` names working as aliases that warn once.
- Tab partials can become page modules: a partial whose root says
`data-page="<name>"` carries no inline script, and the page registry calls
the page's `init` once when htmx swaps it in and `destroy` when it is
swapped out, aborting a signal that removes its listeners and cancels its
requests. The Cache tab is converted as the reference
(`js/pages/cache.js`); `window.deleteCacheFile` remains as an alias.
- Static `.js` files are always served as `text/javascript`, which module
scripts require, and a `.js` request without the `?v=` content version
(how modules import each other) is revalidated instead of cached as
immutable for a year.
- `src/plugin_system/field_model.py`: `build_field_model(schema, config)`
describes a plugin's config form as one JSON field model. Nothing renders
from it yet; `test/test_field_model_parity.py` checks it names exactly the
form controls and starting values the `render_field` macro emits, for every
schema available (all 46 official plugins, when a checkout is present).
- `docs/WEB_FRONTEND_ARCHITECTURE.md`: the target architecture, the
page-by-page migration order, and how forms switch to the model and to
JSON submit behind a flag.
### Control socket (stage 1: on-demand)
- **The display now serves a control socket**,
`/run/ledmatrix/control.sock`. It carries versioned JSON commands, one per
line, and every command gets an answer
([docs/IPC_CONTROL_SOCKET.md](docs/IPC_CONTROL_SOCKET.md)).
- On-demand start, stop and status are the first commands, plus `hello`
(version negotiation) and `ping`.
- Start and stop are acknowledged once the render thread has them queued.
The render thread applies them through the same handler as the file
mailbox, at its next on-demand check. On a scrolling screen that is the
next frame (the mailbox waits up to 0.25 s). On a static screen it is up
to 1 s, the same as the mailbox.
- The server's threads never touch rendering. Garbage, oversize messages
and slow or vanishing clients are answered or dropped without blocking the
display.
- New core modules: `src/ipc/contract.py`, `server.py` and `client.py`.
They are internal, not a plugin API.
- **`POST /api/v3/display/on-demand/start` and `/stop` try the socket
first.** On any failure (the display is stopped or predates the socket, a
timeout, a refusal), they write the `display_on_demand_request` mailbox
exactly as before. The response's new `transport` field says which path
was used (`"socket"` or `"mailbox"`), and `socket_error` gives the reason
for a fallback. Both paths carry the same `request_id`, so a request that
arrives both ways runs once. The mailbox, and the plugins that write it
directly, keep working for at least one more release.
- **Permissions.** The socket is `0660` and owned by the group the two
services already share (the cache directory's group, `ledmatrix` on an
installed device). On Linux the server also checks each connection's
`SO_PEERCRED`: root, the display's own user, or a member of that group.
`/run/ledmatrix` comes from the existing `RuntimeDirectory=` (#687), or the
display creates it as root under an older unit, so no installer or unit
change is needed. `LEDMATRIX_CONTROL_SOCKET` overrides the path for both
processes, or turns the socket off with `off`. A non-root dev run uses a
private per-user path under the temp directory.
### Scroll speed
- The Vegas Scroll Speed slider now says what the panel will do with the speed
it is on, and offers the nearest smooth ones to click. Only speeds that advance
a whole number of pixels per refresh look smooth, and which those are depends
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
which move one pixel at a time. 100 Hz panels are unaffected.
### Update channels
- Devices no longer pick up every merge to `main`. A new setting,
`auto_update.channel`, picks what Update Code and the weekly automatic
update install: `stable` follows the newest release tag (`vX.Y.Z` by
semantic version; pre-releases and other tags are ignored) and checks it
out with a detached HEAD, and `beta` follows `main` as every device did
before. New installs default to `stable` (config template and installer).
- Nobody is moved backwards. A device running code newer than the newest
release, which is any device that pulled `main` since that release, keeps
following `main` until a release contains its commit, then moves to it and
follows releases. A config written before channels existed behaves the
same way and is saved as `stable` when that move happens. Switching from
beta to stable says so instead of installing an older version.
- Switch channels on the General tab (Update Channel, under Automatic
Updates) or with `GET`/`POST /api/v3/system/update-channel`. The Overview
update banner compares release tags on stable ("LEDMatrix v3.8.0 is
available") rather than commits on `main`. A detached checkout newer
than the newest release gets no banner: Update Code leaves it where it
is until a release includes it.
- A move between `main` and a release tag carries local edits across as the
pull's `--autostash` does, and the automatic update's health check rolls
it back to where HEAD was: the branch, or the detached release.
### Frozen-panel detection
A render loop stuck inside a plugin's `display()` left `ledmatrix.service`
"active" with the panel frozen, and nothing noticed: `/api/v3/health` judged
the display by the preview PNG's age, and the automatic update's health check
passed "service active plus one HTTP 200".
- **systemd watchdog.** `ledmatrix.service` now has `WatchdogSec=120` and
`NotifyAccess=main` (still `Type=simple`). The render thread itself pings
systemd over `$NOTIFY_SOCKET` (`src/display_watchdog.py`, standard library
only), so a stuck render thread stops the pings even while the update
worker and Vegas's tick thread carry on. systemd then kills the display with
SIGABRT -- faulthandler writes every thread's stack to the journal, which
names the plugin -- and restarts it. The process widens the limit to 15
minutes while it starts and while it loads a plugin enabled from the web UI
(either can run pip), and sends `READY=1` and narrows it back after its
first frame.
- **Heartbeat.** The render loop writes `/run/ledmatrix/display-heartbeat.json`
every 5 seconds (`RuntimeDirectory=ledmatrix`; tmpfs, so no SD-card
writes). `/api/v3/health` reports it as `checks.display_loop`: `running`,
`stalled` (older than 60s; the overall status turns `degraded`) or
`not_reported` when there is no heartbeat (dev server, emulator, Windows),
which leaves the verdict to the older checks as before.
- **Update health check.** When the display wrote a heartbeat before an
automatic update, the restarted display must keep one fresh (30s) for the
update to pass; a frozen panel is rolled back. Code that never wrote one is
checked as before. The check runs as the copy taken before the update, so
this takes effect from the update after the one that installs it.
- **Crash loops back off.** `RestartSteps=4` and `RestartMaxDelaySec=2min`
stretch the delay between automatic restarts from 10s to two minutes, instead
of retrying every 10s forever. systemd before 254 (Bookworm) ignores the two
lines with a warning. A start limit was ruled out: once tripped it leaves the
panel dark and refuses the web UI's Start button and the update rollback.
- **Existing installs** keep their old unit until `sudo
./scripts/install/install_service.sh` is re-run (an update never rewrites
units; the startup validator warns about the drift). Until then there is no
watchdog, but the display creates `/run/ledmatrix` itself, so the heartbeat,
the health check and the update check work straight away.
### Security
- The web interface refuses state-changing requests (`POST`, `PUT`, `PATCH`,
`DELETE`) sent by another website's page. Any site a LAN user visited could
make their browser submit a plain HTML form to `http://<pi>:5000` -- CORS
does not stop such a request, only hides its answer -- and
`/api/v3/system/action` accepted form bodies, so that page could reboot or
power off the Pi, pull code, or reach any other mutating route. A request
whose `Origin` (or, without one, `Referer`) is not the host it was sent to,
or is `null`, now gets 403 `CROSS_SITE_REQUEST`
(`web_interface/origin_guard.py`). `/api/v3/system/action` also refuses a
form-encoded or `text/plain` body (415) unless it carries HTMX's
`HX-Request` header; every caller in the interface already sends JSON.
- **Behaviour change for API scripts:** clients that send no `Origin` or
`Referer` -- curl, Python `requests`, Home Assistant, the MQTT bridge --
are unaffected. A browser page served from a *different* origin (a
dashboard or userscript on another host) can no longer call the mutating
API; call it server-side instead. Anyone posting a form body to
`system/action` must switch to JSON. Behind a reverse proxy, forward the
original `Host`, port included (`proxy_set_header Host $http_host;`;
nginx's `$host` drops the port); `X-Forwarded-Host` is not trusted. A
TLS-terminating proxy needs nothing more: a portless `Host` matches an
`https://` page.
### Optional web login
- The web interface can require a password, **off by default**: a device that
does not set one behaves exactly as before. Set it under **General >
Security**; from then on every page and API route needs a login (a session
cookie, 30 days, kept across restarts) or an API token. Unauthenticated page
loads go to the new `/login` page, HTMX requests get `HX-Redirect` to it,
and API calls get `401` JSON (`AUTH_REQUIRED` / `INVALID_TOKEN`). Wrong
passwords are rate-limited per address (5 a minute, 30 an hour, through the
existing flask-limiter). Log out from the header. Changing the password
signs every other browser out. (`web_interface/auth.py`)
- **API tokens** for Home Assistant, scripts and the MQTT bridge: create,
list and revoke them in the same section, send them as
`Authorization: Bearer <token>`. A token is shown once; only its SHA-256 is
stored. Tokens cannot change login settings. The MQTT bridge takes one as
`ledmatrix_api_token` (or `LEDMATRIX_MQTT_LEDMATRIX_API_TOKEN`, or the
Tools tab); it needs one only when it runs on another machine.
- Always open, login or not: requests from the Pi itself (loopback, without
proxy headers), the Wi-Fi setup flow (`/setup` and the Wi-Fi status, scan
and connect routes) while the Pi is in access-point mode, static files, the
captive-portal probe URLs, and `/api/v3/health`, which then answers only
`{"status": "healthy" | "degraded"}` to a caller that is not logged in.
- The password hash (werkzeug), the token hashes and the cookie-signing key
live in the `web_auth` section of `config/config_secrets.json`. No API
returns them: `GET /api/v3/config/main`, `GET /api/v3/config/secrets` and
the raw JSON editor leave the section out, the raw secrets save keeps the
stored one, a `/config/main` save drops a `web_auth` key, and orphaned-plugin
cleanup no longer treats it as a plugin (`CORE_SECRETS_KEYS`).
- **Lost password:** `sudo python3 scripts/reset_web_password.py` on the Pi
turns login off (`--revoke-tokens` also deletes the tokens), or open the
interface from the Pi itself.
- New routes: `/login`, `/logout`, `GET /api/v3/auth/status`,
`POST /api/v3/auth/password`, `POST /api/v3/auth/disable`,
`GET|POST /api/v3/auth/tokens`, `DELETE /api/v3/auth/tokens/<id>`.
### Vegas participation
A plugin now takes part in Vegas mode in one declared way: `'scroll'` (its
content scrolls by), `'pause'` (the scroll stops for its turn and its
`display()` draws it full screen) or `'exclude'`. No plugin changes
behaviour: one that declares nothing gets exactly what the old hooks gave
it, checked against every official plugin.
- `BasePlugin.get_vegas_participation()` resolves, in order: the user's
`vegas_participation` config value, the manifest's `vegas_participation`,
then the legacy hooks (`get_vegas_display_mode()` returning `STATIC` →
pause, else `get_vegas_content_type()` returning `'none'` → exclude, else
scroll). `resolve_vegas_participation()` in `src.plugin_system.base_plugin`
is what the core calls; the user's setting wins even over a plugin that
overrides the method.
- The Vegas stream manager decides inclusion and pauses through it, and
`PluginAdapter.get_content_type()` is removed (core-internal, now unused).
Swap mode no longer drops a plugin's segment for a cycle when its
`get_vegas_display_mode()` raises something other than
`AttributeError`/`TypeError`: like every other decision point it now
treats that as "not paused".
- `vegas_participation` is a core-owned per-plugin property (an enum with no
default) and a manifest field in `schema/manifest_schema.json`.
- `GET /api/v3/plugins/installed` reports each plugin's
`vegas_participation`, and the Vegas plugin-order list badges it (Scroll /
Pause / Excluded) instead of the old Scroll / Fixed / Static.
- `src.deprecation.warn_deprecated()` warns once per process for what
`@deprecated` cannot decorate, such as a config key.
Deprecated, removed in 3.9.0 (each logs a warning on first use). Vegas never
read any of them:
- `BasePlugin.get_supported_vegas_modes()` and
`BasePlugin.get_vegas_segment_width()`.
- The `vegas_panel_count` per-plugin setting (warns once per plugin that sets
it).
- The SCROLL / FIXED_SEGMENT distinction (`vegas_mode` `"scroll"` vs
`"fixed"`): both always scrolled. Documented only; no warning, because
official plugins' schemas still offer `"fixed"`.
### Plugin store
- The store reads three optional registry fields that ledmatrix-plugins'
`update_registry.py` now publishes (ChuckBuilds/ledmatrix-plugins#579). An
older `plugins.json` without them behaves as before.
- `ledmatrix_min_version`: an install or update this core cannot run is
refused before anything is downloaded, pulled or moved aside, and the web
UI says why ("requires LEDMatrix X or newer…", HTTP 409) instead of "check
logs for details". The store card shows a "Needs LEDMatrix X+" badge. The
check on the downloaded manifest stays as the fallback (older registries,
an explicitly requested other branch, `compatible_versions`).
- `aliases`: the entry's other ids. Update, uninstall and reinstall by the
registry id now find a plugin installed under its manifest id
(`weather` → `ledmatrix-weather/`; likewise leaderboard, music, stocks).
Only registry proof counts: the entry's `aliases` or its `plugin_path`
name, or a folder whose manifest declares one of those ids. A
`ledmatrix-<id>/` folder with no such proof is never replaced or removed;
uninstall and update report "not installed" and log the folder's path.
Install and update fetch the registry first when such a folder exists
and none is loaded; uninstall stays offline.
- `commit`: the monorepo commit that introduced the listed version, shown
on the store card and linked to the plugin's source at that commit.
Informational only; installs still come from the branch head.
### Changes
- The web interface no longer loads or runs plugins (web plugin catalog,
stage 1). It built its own `PluginManager` and loaded plugins into the web
process: store installs and updates loaded or reloaded a web-side copy, and
config saves and enable/disable called `on_config_change`, `on_enable` and
`on_disable` on it. None of that reached the panel. The web process now
reads plugins as files through the new `PluginCatalog`
(`src/plugin_system/plugin_catalog.py`); only the display runs them, and
config changes reach them through its config watcher, as they already did.
- A plugin update, an install of a plugin that is already enabled, or an
uninstall that keeps an enabled plugin's config now answers
`restart_required: true` and shows the restart banner, because the
running display keeps the code it loaded until it restarts. Before, the
update looked applied and the panel kept the old version.
- The restart banner follows `restart_required` in any response
(`POST /api/v3/config/main` sends it) rather than the URL that was
called.
- `/api/v3/plugins/installed` reports `loaded`, `state` and `error_info`
as `null`: the display does not publish them, and the old values
described web-side copies. `enabled` follows the display's rule, so a
plugin whose config has no `enabled` flag shows as disabled (it never
ran). `vegas_mode` is the configured value only.
- `vegas_participation` there is the user's setting, else the manifest's
declaration, with a new `vegas_participation_source` (`config` or
`manifest`). When only the plugin's code decides it (a
`get_vegas_participation()` override or the legacy Vegas hooks) it is
`null` with source `runtime`: the display derives it, and the web no
longer asks a web-side plugin instance.
- Starlark routes always use their on-disk path. The one place the web
process still imports plugin code -- the Starlark helper modules and an
`oauth_flow` action script -- is `_import_plugin_code_in_web_process()`,
until a plugin web-entry contract replaces it.
- The display publishes its plugin runtime state, and the web interface
reads it (web plugin catalog, stage 2). A new snapshot in the shared cache
(`plugin_runtime_snapshot`, `src/plugin_system/plugin_runtime.py`) lists,
per plugin, whether the display has it loaded, its lifecycle state, a
short redacted summary of its last error, the version it loaded and when.
It is written when something changes (at most every 10 s; an ordinary
plugin update is not a change) and otherwise once a minute, carries its
publish time, and says `running: false` when the display stops.
- `/api/v3/plugins/installed` fills `loaded`, `state` and `error_info`
again, from that snapshot, and adds `loaded_version` and `loaded_at`.
Only a live snapshot counts: when the display is stopped, has not
published, or has not refreshed for 3 minutes, those fields are `null`
and the new `data.runtime.status` says `stopped`, `unknown` or `stale`.
- `data/plugin_state.json` is retired: nothing reads or writes it. It held
copies of config.json's enabled flags and the manifests' versions, plus
install timestamps only `GET /api/v3/plugins/state` returned, so nothing
in it is migrated; an existing file is left in place and can be deleted.
The web-side `PluginStateManager` (`src/plugin_system/state_manager.py`)
that wrote it is removed; the display's state machine in
`plugin_state.py` is now the only `PluginStateManager`.
- `GET /api/v3/plugins/state` is built per request from config.json, the
plugins on disk and the display's snapshot (`installed`, `in_config`,
`enabled`, `version`, `status`, the runtime fields, and `installed_at` /
`last_updated` from the operation history), with a top-level `runtime`.
It no longer returns `config_version` or `metadata`.
- State reconciliation compares desired state (config.json plus disk) with
the display's snapshot. New findings -- enabled but not loaded (with the
load error), and loaded at an older version than is installed -- are
reported with `fix_action: no_action`; the unresolved-issues banner is
unchanged. `StateReconciliation` takes `config_manager`, `plugins_dir`,
`store_manager` and `runtime_source` as keywords.
- Backups list the installed plugins from disk, with `enabled` from
config.json, instead of merging in `plugin_state.json`. A plugin that
only that file still named (not installed, not configured) is no longer
listed. Restores are unchanged.
### Fixes
- Quieter routine logging. Every rotation logged each mode twice
("Switching to mode", then "Processing mode"), and a mode with nothing to
show added "display() returned False" and "No content to display". Those
three repeats are now DEBUG; "Switching to mode" stays INFO, and `--debug`
shows the rest. On ledpi this cut the display's journal lines by about 30%
(~105 to ~75 per 5 minutes). Each stored line costs roughly 9 KB of SD-card
writes through the persistent journal (display at INFO vs WARNING: about
190 KiB/min apart), so the saving is real but small.
- Reinstalling a plugin by its registry id when it is installed under its
manifest id (`weather` in `ledmatrix-weather/`) no longer deletes it when
the install then fails. The safety copy was taken of `weather/`, which did
not exist, and the real install was removed to make room for the download,
so a refusal by the compatibility gate left no plugin at all. Uninstalling
by the registry id reported success and removed nothing; updating by it
said "not installed". All three now find the install.
- On-demand no longer restarts a running display. `POST
/display/on-demand/start` treated `start_service` (on by default, and what
"Preview on display", the on-demand dialog and the MQTT bridge all send) as
@@ -42,6 +706,282 @@ accepts both, but the store flags the old spelling as deprecated
- A stop request now clears an on-demand error. After a failed request,
`/display/on-demand/status` kept reporting `status: error` for up to two
minutes even after a stop.
- One hung plugin no longer stops every plugin from updating. The single
update worker waited on each plugin's lock with no time limit, and the
render thread holds that lock while it runs the plugin's display(); a
display() that never returned (or a first frame still running after the
executor's 30s timeout) parked the worker for good, so scores, weather and
clocks all froze while the panel kept scrolling. The worker now waits at
most 5s (the bound `unload_plugin()` already uses) and skips that update;
the other plugins keep updating. The skip is logged (at most once a minute
per plugin) and counted in plugin health as a busy skip (`busy_skip_count`,
`last_busy_skip`), but it is not a failure and never opens the circuit
breaker: Vegas mode holds a plugin's lock for its whole content render,
which on a slow Pi can outlast 5s, and a healthy plugin must not be pulled
from rotation for that.
- display() calls are timed on every frame. One taking 2s or more is logged
(at most once a minute per plugin) and counted in plugin health
(`slow_call_count`, `last_slow_call`); one that runs past the executor's
timeout counts as a hang (`hang_count`, `last_hang`) and as a failure to
the circuit breaker. A first frame that times out is no longer recorded as
a success, and an update() still running after its timeout is recorded as
a hang instead of leaving the plugin silently stuck. Only these real hangs
count toward the breaker.
- A plugin's `on_config_change()` no longer runs while its update() is
running on the worker thread. It now runs under the plugin's lock; if the
lock stays busy past the same 5s bound the change is handed to the update
worker, which applies the latest one as soon as the lock frees, and before
the plugin's next update() at the latest. The plugin API is unchanged.
- With a Vegas width budget set (`max_plugin_width_ratio` or a plugin's
`vegas_max_width_screens`), a single image over the budget with no gaps
between items -- a map, one long headline -- no longer takes a pass of its
own showing four blank columns. The cut landed in the middle of the blank
margin trimming leaves at the image's edge; margins are no longer cut
points, so such an image is cropped to the budget as intended.
### Live Vegas elements (plugin API)
- New plugin hooks for content that can change while it scrolls:
`BasePlugin.get_vegas_elements()` returns `VegasElement`s -- named,
fixed-width pieces of Vegas content -- instead of pictures;
`redraw_vegas_element(key, width, height, at)` redraws one without the
plugin lock for content that changes with time; and
`notify_vegas_data_changed()` reports data that arrived outside
`update()`. New module `src/plugin_system/vegas_elements.py`
(`VegasElement`, also re-exported from `base_plugin`). See "Live Vegas
elements" in `docs/PLUGIN_API_REFERENCE.md`.
- The ticker asks a plugin that implements the hook for elements on its
background fetch (under the plugin's lock, on a canvas of its own) and
records where each one lands in the strip, in absolute columns a trim does
not move (`src/vegas_mode/elements.py`). Live elements are never trimmed to
their ink: each is padded with `content_padding` black columns either side.
Every other path -- the first strip, the render-thread fallback, plugins
without the hook -- is unchanged. Swapping redraws into the strip builds
on this.
- `PluginManager.add_update_listener()` / `remove_update_listener()` /
`notify_data_changed()`: a listener hears a plugin id the moment its
`update()` completes, rather than at the next ~4s Vegas poll.
- New `display.vegas_scroll` settings: `live_refresh` (default `true`; the
kill switch), `live_max_hz`, `live_min_interval`, `live_lead_screens`, and
a per-plugin core-owned `vegas_live`. Live elements are off whatever these
say under multi-display sync, in swap mode and with `offscreen_prefetch`
off.
- `scripts/check_plugin.py` checks the element contract for any plugin that
implements it (`src/plugin_system/testing/vegas.py`), and
`test/fixtures/plugins/vegas-live-stub` is a working example.
- **Live elements update in place.** When a plugin's `update()` completes,
one background worker (`src/vegas_mode/live_worker.py`) redraws its live
elements that are on or ahead of the screen, nearest first, and hands the
ones whose pixels changed to the render thread, which copies them into the
strip between two frames (`RenderPipeline.apply_live_patches`,
`ScrollHelper.patch_columns`): at most four patches or two screens of bytes
a frame, no drawing and no locks on the render thread. Elements with
`refresh_hz` are redrawn that often while near the screen, through the
plugin's lock-free `redraw_vegas_element()`. The worker also takes over
group prefetching once the strip holds a live element, so one thread
still does all the drawing; it runs inside the render gate, starts only
when a live element is placed, and is restarted if it dies (three times in
ten minutes turns live updates off for the run). While live elements exist,
the Vegas update tick runs every second instead of every four.
- Web UI: "Update live content while it scrolls" under Vegas mode's Cycle
Pacing (`display.vegas_scroll.live_refresh`).
- **Live cards for the scoreboards (shared code).** New module
`src/common/sports_vegas.py`: `game_key()`, `dedupe_games()`,
`VegasCardCache` (draws a card only when its fingerprint changes) and
`StickyOdds` (keeps a card's odds through a live poll that left them out),
`finished_games()` and `with_finished_games()` (a game that just went final
keeps its card, showing FINAL, where its live card was).
`SportsScrollDisplay` gains `make_vegas_renderer()` (the override point; a
sport that does not implement it keeps its ordinary Vegas content),
`render_vegas_card()`, `vegas_separator()` and `build_vegas_elements()`,
and `SportsScrollDisplayManager` gains `get_vegas_elements_for()`.
`SportsLiveSharedMixin` gains `_record_finished_game()` /
`finished_games_snapshot()`, so a game that goes final keeps a card to show
FINAL on until the hourly recent list takes it over.
- `scripts/render_plugin.py --vegas` renders a plugin's block of the Vegas
strip as the ticker lays it out (live elements, or with `--no-live` its
ordinary content) and writes the live elements' keys and columns beside
it. `--timeline ROWS` stacks the block at successive moments as the
ticker would update it in place (`--timeline-step`, and
`--timeline-update` to run `update()` between rows).
`render_vegas_strip()` and `render_vegas_timeline()` in
`src/plugin_system/testing/vegas.py`; the join is now
`render_pipeline.join_plugin_rows()`.
- **Behaviour change: live games stay in the Vegas ticker by default.**
`display.vegas_scroll.live_in_ticker` now defaults to `true`: the marquee
keeps running through a live game, which takes extra turns in it, instead
of giving way to the full-screen scoreboard. Existing configs all held the
old `false`, copied from the template, so the first start turns it on once
(`ConfigManager._migrate_live_in_ticker_default`; the previous config is
kept as `config.json.backup` and `live_in_ticker_migrated` records that it
ran). To keep the full-screen scoreboard, untick the new **Keep live games
in the ticker** under Vegas mode; a `false` set after the migration stays.
### Scrolling
- A Vegas strip extension no longer costs a late frame. Appending the next
group rebuilt the whole strip (`np.concatenate`, 2-2.6ms for a 10-14k px
strip at 512x64 on a Pi 4) and trimming copied what was left (1.2-1.8ms),
so on hdpi every extension frame missed its refresh. The strip now lives in
a buffer with spare room (`ScrollHelper.STRIP_SPARE_FACTOR`): an append
writes only the new columns (~0.2ms), a trim only moves the start, and the
one full copy happens when the buffer is reallocated, about once every two
strip-lengths scrolled. A strip set from outside (the multi-display
follower's) is never written through.
- A Vegas strip extension costs the render thread about a third of what it
did. Appending the next group and trimming what has scrolled past each
rebuilt the strip's PIL image from its numpy array in full
(`Image.fromarray`: 1.7ms for an 8,000px strip, 3.8ms for 20,000px, on a
Pi 4 -- twice per extension), though every frame is cut from the array and
nothing on the frame path reads the image's pixels. `ScrollHelper` now
builds `cached_image` only when something reads it, which in Vegas means
only a multi-display sync push, and the strip is no longer held in memory
twice. Assigning `cached_image` still stores exactly what was assigned.
New `ScrollHelper.has_strip()` says whether there is a strip without
building its image; the frame path and Vegas use it.
- The frame after a Vegas strip extension is no longer late on a Pi 4. The
render thread also laid out every plugin block of the new group (joining
its rows, measuring the separation between each pair) and pasted the
blocks into one image, about 37ms on hdpi against ~3.75ms of slack. The
thread that fetches the group now does that as each plugin arrives, and
the extension only writes the prepared pixels into the strip
(`ScrollHelper.append_content` takes RGB arrays): 3.2ms. In a 4 x 8 minute
A/B soak, extension frames went from 10 of 10 late to 3 of 10.
### Tooling
- The frame-timing recorder says which render-thread work a late frame
followed. Work done between two frames calls
`FrameTimingRecorder.note_op(kind, nbytes)` and the next presented frame
carries the tag; the stats gain `op_frames`, `late_op_frames`, `op_freezes`
and `op_bytes` per kind (additive; the file's schema version is unchanged).
Vegas tags every strip `compose` and `extend`, and `frame_soak.py` prints an
"after work" table with each kind's own late rate.
`scripts/render_bench.py` can drive the same work on a panel with nothing
else running: `--strip-screens` for a Vegas-sized strip, `--patch-bytes /
--patch-every / --patch-where` for in-place column writes, and
`--extend-every-screens` for appending and trimming on a fixed cadence.
See "Soaking a rig" in `docs/SCROLL_PERFORMANCE.md`.
- `scripts/sports_drift_report.py`: for a ledmatrix-plugins checkout, counts
how many different bodies each method family has across the nine
scoreboards' `sports.py`, `manager.py` and `game_renderer.py`, lists the
families still identical everywhere and those with one outlier, and with
`--family ... --diff` shows the variants. It is the progress measure for
the reconcile-then-promote roadmap in `docs/SPORTS_UNIFICATION.md`, which
this release rewrites. CI runs it against the monorepo's main as a
report-only job ("Sports drift report"; never fails the build).
### Deprecations
- The 35 plugin-facing methods deprecated in 3.5.0 are now removed in 3.8.0,
not 3.7.0: 3.7.0 shipped with all of them still in place, still warning
"will be removed in LEDMatrix 3.7.0". The warning, the docs and
`test/test_deprecation.py` now say 3.8.0. They are removed in this
release (see Removed, below).
- New `scripts/plugin_api_usage.py` lists every `@deprecated` core method and
scans core, the plugin monorepo and the registry's third-party plugins for
calls and overrides, telling real uses from unrelated methods of the same
name. Its output is `docs/DEPRECATIONS_3.8.md` (linked from
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis`); no plugin uses any of
the 35.
- `test/test_deprecation.py` fails while any `@deprecated` marker names a
release at or below `src.__version__`, so a release can no longer ship
warning about a removal it has already passed.
### Removed
The 35 plugin-facing methods deprecated in 3.5.0 (each has logged a warning
on first call since, announced for 3.7.0 and then moved to 3.8.0) are gone.
The usage scan (`docs/DEPRECATIONS_3.8.md`, re-run 2026-10-01) found no call
or override of any of them in the 46 monorepo plugins or the 8 third-party
plugins `plugins.json` lists, and core's own last callers went with them. A
plugin that still calls one gets an `AttributeError`;
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` lists what to use instead.
- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`,
`get_sport_live_interval`, `get_sport_key_from_cache_key`,
`get_background_cached_data`, `is_background_data_available`,
`record_cache_hit`, `record_cache_miss`, `record_fetch_time`,
`get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`. The
private change-detection helpers behind `has_data_changed`
(`_has_weather_changed` and friends, `_is_market_open`) went with it.
- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`,
`draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`, and with them
the `WEATHER_COLORS` table and the private `_draw_sun`/`_draw_cloud`/
`_draw_rain`/`_draw_snow`/`_draw_storm` helpers.
`VisualTestDisplayManager` (the plugin test harness) drops its copies of
the icon methods too, so a plugin's visual tests fail the way the real
display would instead of passing against methods that no longer exist.
- `FontManager`: `set_override`, `remove_override`, `get_overrides`,
`add_font`, `remove_font`, `validate_font`, `get_font_catalog`,
`get_available_fonts`, `get_size_tokens`, `get_performance_stats`,
`get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`,
`unregister_plugin_fonts`, plus the `size_tokens` attribute and the private
`_save_overrides` and `_clear_plugin_font_cache`. `resolve_font()` still
applies `config/font_overrides.json`.
- `PluginManager.get_enabled_plugins` (check `enabled` on the entries in
`plugin_manager.plugins`).
### Web UI styling: a real Tailwind build
- The web UI's utility classes now come from a generated
`static/v3/tailwind.css` (Tailwind v3.4.19 standalone CLI, no Node)
instead of ~500 hand-written rules in `app.css`. The CSS is built on a
dev machine with `python3 scripts/build_css.py` and committed; the Pi
never builds anything. CI's new "Tailwind CSS is up to date" job rebuilds
it and fails when the committed file is stale. `app.css` keeps the theme
tokens, components and dark theme, and loads after `tailwind.css`. The
values `app.css` had customised (darker gray text, emerald/amber button
fills, token shadows, font line-heights, keyboard-only focus rings) are
kept in `web_interface/tailwind/tailwind.config.js`.
- Border utilities now draw. `border-b`, `border-t` and `divide-y` set only
a width, and nothing gave them a style, so the tab-row underlines and
section dividers the markup asks for never showed. They do now.
- `2xl:` classes now apply (the hand-written `.2xl\:…` selectors were
invalid CSS): at 1536px and wider the plugin grids show five columns and
the page gutters widen, as the markup intended.
- Classes the hand-written file never defined now work, e.g. the teal
"configure" badge in Operation History, the button of a purple
`web_ui_actions` card (it had white text on no background), the
toggle-switch knob offsets, the slider accent colours and the password
strength colours.
- A scrollable container with its own background (the live preview stage,
command output in Tools) keeps it. The scroll-hint rule's `background`
shorthand wiped it, so the preview stage rendered white instead of dark.
- Plugin `web_ui/` pages no longer load Tailwind from a CDN, which failed
in AP mode with no internet. They get a local `static/v3/plugin-frame.css`
with the v2 palette they were written against.
### New modules (sports consolidation stage 4)
A plugin may import these via `src.*` once it floors on 3.8.0. All four hold code the
scoreboard plugins carry as identical copies (checked at ledmatrix-plugins
`56c4f15`), moved without behaviour change under the plugins' own names;
each docstring lists what the host class must provide. Nothing in core uses
them yet. The plugins delete their copies when they floor on 3.8.0.
- `src/common/sports_plugin_host.py` — `SportsPluginHostMixin`, ten helpers
of the scoreboard plugin class (`manager.py`) identical in all nine:
`_dispatch_switch_refresh` (with `_SWITCH_REFRESH_MIN_GAP_SECONDS`),
`get_vegas_priority_weight`, `_favorite_team_is_live`,
`_favorite_scan_targets`, `_favorite_scan_games`, `_game_involves`,
`get_vegas_content_type`, `_dynamic_feature_enabled`,
`_get_total_games_for_manager` and `_build_manager_key`. List it before
`BasePlugin`: two of these override its defaults.
- `src/common/sports_live_scroll.py` — `SportsLiveScrollMixin`, the eight
`manager.py` methods that rebuild a live scroll strip mid-cycle without
moving the marquee (`_live_scroll_needs_rebuild`,
`_preserving_scroll_position`, ...), with `LIVE_SCROLL_REBUILD_MIN_SECONDS`
and `LIVE_SCROLL_REBUILD_DUTY_DIVISOR`; identical in the eight scoreboards
with a strip (not ufc). `LIVE_VOLATILE_FIELDS` stays in each plugin.
- `src/common/sports_display_rules.py` — `SportsCardOptionsMixin`
(`_card_option`, `_recent_date_text`; the eight team scoreboards; list it
before `SportsCoreSharedMixin`) and `SportsGameRulesMixin`
(`_filtered_or_all`, `_effective_live_duration`; all nine).
- `src/common/sports_font_path.py` — `resolve_font_path`, what every
scoreboard's `_resolve_font_path` (nine `sports.py`, eight
`game_renderer.py`) returns on a core that ships it: the path as given when
it exists, else `font_layout.resolve_asset_path`.
## 3.7.0
@@ -190,7 +1130,8 @@ New names in existing modules (a plugin using these must floor on 3.5.0):
- `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`, and
`FontManager.forget_manager_fonts()` is new (see Fonts).
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
Deprecated for removal in 3.7.0, later moved to 3.8.0 (each logs a warning
on first use; see
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
core, the monorepo or the registry's third-party plugins calls them:
+1
View File
@@ -46,6 +46,7 @@
- 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)
- Optional registry entry fields (`store_registry.py`): `ledmatrix_min_version` refuses an incompatible install/update before the download (the post-download manifest gate stays as the fallback); `aliases` are the entry's other ids (manifest id `ledmatrix-weather` for `weather`), used with the `plugin_path` name by update/uninstall/reinstall to find the install — only this registry proof counts, never a bare `ledmatrix-<id>` folder (owner decision, #686; such a folder is only logged); `commit` is informational. An older plugins.json has none of them
- 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`
+5 -1
View File
@@ -71,7 +71,11 @@ integration tests.
annotation-only where you can -- widen a hint rather than delete a
defensive runtime check mypy calls unreachable. HTML/JS in
`web_interface/` follows the patterns already in `templates/v3/`
and `static/v3/`.
and `static/v3/`. If you change a template or a static JS file,
run `python3 scripts/build_css.py` and commit the regenerated
`static/v3/tailwind.css` with it -- CI fails when the committed CSS
is out of date. It needs no Node; see
[`web_interface/README.md`](web_interface/README.md#styling-tailwind-css).
5. **Update documentation** alongside code changes. If you add a
config key, document it in the relevant `*.md` file (or, for
plugins, in `config_schema.json` so the form is auto-generated).
+25 -2
View File
@@ -61,8 +61,31 @@ Out of scope (please report upstream):
LEDMatrix is designed for trusted local networks. Several limitations
are intentional rather than vulnerabilities:
- **No web UI authentication.** The web interface assumes the network
it's running on is trusted. Don't expose port 5000 to the internet.
- **Web UI authentication is optional and off by default.** Out of the
box the web interface assumes the network it's running on is trusted.
Setting a password under **General > Security** makes every page and
API route require a login or an API token (`Authorization: Bearer`),
with wrong passwords rate-limited per address
(`web_interface/auth.py`). Deliberately left open even then: requests
from the Pi itself (loopback without proxy headers; a reverse proxy on
the Pi must add `X-Forwarded-For`, or every request it relays counts as
local), the Wi-Fi setup flow while the Pi is in access-point mode,
static files, and a status-only `/api/v3/health`. The password is a
werkzeug hash and tokens are stored as SHA-256, in
`config/config_secrets.json`, which no API returns. There is no TLS:
over plain HTTP the password and tokens cross the LAN in the clear, so
still don't expose port 5000 to the internet; put a TLS reverse proxy
or a VPN in front for remote access. Anyone with shell access to the Pi
can turn login off (`scripts/reset_web_password.py`), which is the
documented recovery path.
"Trusted network" does not mean "trusted websites", though: any page
a LAN user opens could make their browser POST to the Pi. So the
interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or
`Referer`) header names another site (`web_interface/origin_guard.py`),
and `/api/v3/system/action` only accepts JSON or HTMX requests. Tools
that send neither header (curl, Home Assistant, the MQTT bridge) are
unaffected. Not covered: DNS rebinding, and anyone who can reach the
port directly.
- **Plugins run unsandboxed.** Installed plugins execute in the same
Python process as the display loop with full file-system and
network access. Review plugin code (especially third-party plugins
+13 -2
View File
@@ -1,7 +1,8 @@
{
"web_display_autostart": true,
"auto_update": {
"enabled": false
"enabled": false,
"channel": "stable"
},
"schedule": {
"enabled": false,
@@ -133,7 +134,7 @@
"plugin_rotation_order": [],
"use_short_date_format": true,
"vegas_scroll": {
"live_in_ticker": false,
"live_in_ticker": true,
"live_weight": 3,
"favorite_live_weight": 5,
"enabled": false,
@@ -173,6 +174,16 @@
"plugin_system": {
"plugins_directory": "plugin-repos"
},
"fetch_service": {
"enabled": true,
"max_wait_seconds": 2,
"rate_limits": {
"*.espn.com": {
"per_second": 20,
"burst": 200
}
}
},
"web-ui-info": {
"enabled": true,
"display_duration": 10
+91 -75
View File
@@ -10,22 +10,27 @@ This guide covers advanced LEDMatrix features for users and developers, includin
Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to news tickers seen in Las Vegas casinos. Plugins contribute content segments that flow across the display in a seamless ticker-style presentation.
### Display Modes
### How a Plugin Takes Part
**SCROLL (Continuous Scrolling):**
- Content scrolls continuously left
- Smooth, fluid motion
- Best for news-ticker style displays
Each plugin has a *Vegas participation*:
**FIXED_SEGMENT (Fixed-Width Block):**
- Plugin gets fixed-width block on display
- Content doesn't scroll out of its segment
- Multiple plugins can share the display simultaneously
**`scroll` (the default):**
- The plugin's content scrolls by with everyone else's
- Best for news-ticker style content: scores, headlines, prices, the time
**STATIC (Scroll Pauses):**
- Scrolling pauses when content is fully visible
- Displays for specified duration, then resumes scrolling
- Best for content that needs to be fully read
**`pause`:**
- The scroll stops when the plugin's turn comes round
- The plugin draws the whole panel for its display duration, then the
scroll resumes
- Best for content that needs to be read in full, or alerts
**`exclude`:**
- The plugin is left out of Vegas mode
A plugin declares its default; set `vegas_participation` in a plugin's
config to override it (see [Per-Plugin Configuration](#per-plugin-configuration)).
Older documentation also describes a *fixed segment* mode; Vegas never
implemented one, and it has always behaved exactly like `scroll`.
### Configuration
@@ -70,17 +75,31 @@ total. See the full list in
### Live Content in the Ticker
By default, live content **preempts** Vegas mode: while any plugin reports
live priority, the display controller refuses to run the ticker and shows
that plugin's full-screen display instead. You get a big readable scoreboard,
but the marquee stops entirely for the duration of the game.
By default (since 3.8.0) live content **stays in the ticker** and takes
**extra turns inside it**, and a scoreboard that supports live cards updates
the score on a card already crossing the screen (`live_refresh`, "Update live
content while it scrolls").
Set `live_in_ticker` to keep the ticker running and let live content take
**extra turns inside it** instead:
To get the old behaviour back -- live content **preempts** Vegas mode: while
any plugin reports live priority the ticker stops and that plugin's
full-screen display is shown instead -- untick **Keep live games in the
ticker** under Vegas mode, or set `live_in_ticker` to `false`:
```json
"vegas_scroll": {
"live_in_ticker": false
}
```
Until 3.8.0 `false` was the default and every config held it, copied from
the template. The first start on 3.8.0 turns it on once (a backup of the
config is kept as `config.json.backup`, and `live_in_ticker_migrated` records
that it ran); a `false` set after that is left alone.
The weights below apply while live content is in the ticker:
```json
"vegas_scroll": {
"live_in_ticker": true,
"live_weight": 3,
"favorite_live_weight": 5
}
@@ -164,8 +183,7 @@ Override Vegas behavior for specific plugins:
{
"my_plugin": {
"enabled": true,
"vegas_mode": "scroll",
"vegas_panel_count": 2,
"vegas_participation": "pause",
"display_duration": 10
}
}
@@ -175,19 +193,30 @@ Override Vegas behavior for specific plugins:
| Setting | Values | Description |
|---------|--------|-------------|
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) |
| `display_duration` | seconds | Pause duration for STATIC mode |
| `vegas_participation` | `scroll`, `pause`, `exclude` | How this plugin takes part: its content scrolls by, the scroll pauses for its turn and shows it full screen, or it is left out. Unset uses the plugin's own default |
| `display_duration` | seconds | How long a `pause` plugin holds the screen |
| `vegas_width_pct` | 10–100 | Width of this plugin's card, as a percentage of the panel |
| `vegas_overflow` | `rotate`, `truncate` | What to do when its content is wider than its allowance |
| `vegas_max_width_screens` | number of screens | The widest its card may be |
Plugins may also set `vegas_overflow` and `vegas_max_width_screens` in
their config section to control how oversized content is handled (see
`PluginManager` in `src/plugin_system/plugin_manager.py`).
These are core-owned settings (see
[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)): every
plugin accepts them whether or not its own schema lists them. Set them in
the plugin's section of config.json, in the web UI's **Config Editor**
tab.
Some plugins also offer a `vegas_mode` setting of their own (`scroll`,
`fixed` or `static`). It still works — `static` pauses, the other two scroll
— but `vegas_participation` takes precedence, and `fixed` has never done
anything different from `scroll`. The old `vegas_panel_count` setting never
had an effect and is deprecated (removed in 3.9.0).
### Plugin Integration (Developer Guide)
All of these have defaults in
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you
need.
need. The reference is
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-scroll-hooks).
**1. Implement Content Method:**
@@ -203,43 +232,41 @@ If it returns `None` (the default), Vegas falls back to the plugin's
(`PluginAdapter.get_content()` in
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
**2. Specify Content Type:**
**2. Declare how the plugin takes part:**
```python
def get_vegas_content_type(self):
# 'multi' | 'static' | 'none' -- default is 'static'
return 'multi'
Most plugins need nothing: the default is `scroll`. A plugin that should
pause the scroll, or stay out of Vegas, says so in `manifest.json`:
```json
{
"vegas_participation": "pause"
}
```
`'none'` excludes the plugin from Vegas mode.
**3. Optionally Specify Display Mode:**
These return `VegasDisplayMode` members, not strings:
The user's own `vegas_participation` setting overrides the manifest. When
the answer depends on state, override the method instead:
```python
from src.plugin_system.base_plugin import VegasDisplayMode
def get_vegas_display_mode(self):
return VegasDisplayMode.SCROLL
def get_supported_vegas_modes(self):
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
def get_vegas_participation(self):
# 'scroll' | 'pause' | 'exclude'
return 'pause' if self._alert_is_live() else 'scroll'
```
`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`).
A plugin written for an older core that declares nothing keeps its
behaviour: `get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`
pauses, `get_vegas_content_type()` returning `'none'` excludes, and
everything else scrolls. `get_supported_vegas_modes()`,
`get_vegas_segment_width()` and the SCROLL / FIXED_SEGMENT distinction are
deprecated (removed in 3.9.0): Vegas never read them.
### Content Rendering Guidelines
**Image Dimensions:**
- **Height:** Must match display height (typically 32 pixels)
- **Width:** Varies by mode:
- SCROLL: Any width (recommended 64-512 pixels)
- FIXED_SEGMENT: `panel_count * display_width`
- STATIC: Any width, optimized for readability
- **Width:** Any width for `scroll` (recommended 64-512 pixels);
`get_vegas_render_width()` is the width Vegas would like, and it narrows
`display_manager` to match while it asks. A `pause` plugin draws the
whole panel in `display()`.
**Color Mode:**
- Use RGB color mode
@@ -289,17 +316,10 @@ class WeatherPlugin(BasePlugin):
def get_vegas_content(self):
"""Return cached Vegas image"""
return self.vegas_image
def get_vegas_content_type(self):
return 'multi'
def get_vegas_display_mode(self):
return 'scroll'
def get_supported_vegas_modes(self):
return ['scroll', 'static']
```
It scrolls, the default participation, so it declares nothing else.
### System Architecture
Vegas mode consists of four core components working together to provide smooth 125 FPS continuous scrolling:
@@ -382,7 +402,8 @@ Vegas mode consists of four core components working together to provide smooth 1
**Responsibilities:**
- Convert plugin content to scrollable images
- Handle different Vegas display modes (SCROLL, FIXED, STATIC)
- Fetch the content of `scroll` plugins (a `pause` plugin is drawn by
its own `display()` when the scroll pauses; see StreamManager)
- Manage fallback for plugins without Vegas support
- Cache plugin content for performance
@@ -391,21 +412,16 @@ Vegas mode consists of four core components working together to provide smooth 1
- Calls `get_vegas_content()` if available
- Falls back to `display()` method if not
2. **Handle display mode:**
- SCROLL: Returns image as-is for continuous scrolling
- FIXED_SEGMENT: Creates fixed-width block (panel_count * display_width)
- STATIC: Marks content for pause-when-visible behavior
3. **Content type handling:**
- `multi`: Multiple segments (list of images)
- `static`: Single static image
- `none`: Skip this plugin in current cycle
2. **Participation** is decided by the StreamManager, not here
(`resolve_vegas_participation()` in
[`base_plugin.py`](../src/plugin_system/base_plugin.py)): `exclude`
plugins never reach the adapter, and `pause` plugins are not fetched.
**Fallback Behavior:**
- If plugin doesn't implement Vegas methods:
- Calls plugin's `display()` method
- Captures rendered display as static image
- Treats as fixed segment
- Scrolls it by as one block
- Ensures all plugins work in Vegas mode without explicit support
#### 4. RenderPipeline
@@ -508,7 +524,7 @@ All components use thread-safe patterns:
If a plugin doesn't implement Vegas methods:
- System calls the plugin's `display()` method
- Captures the rendered display as a static image
- Treats it as a fixed segment
- Scrolls it by as one block
This ensures all plugins work in Vegas mode, even without explicit support.
+4 -4
View File
@@ -27,7 +27,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
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
were removed in 3.8.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).
@@ -194,7 +194,7 @@ def update(self):
sport_key = "nhl"
cache_key = f"{self.plugin_id}_{sport_key}_games"
# get_background_cached_data() is deprecated, removed in 3.7.0 — use get()
# get_background_cached_data() was removed in 3.8.0 — use get()
cached = self.cache_manager.get(cache_key, max_age=60)
if cached:
@@ -596,8 +596,8 @@ def update(self):
```python
def update(self):
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check the
# instance's `enabled` flag instead
# get_enabled_plugins() was removed in 3.8.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
+196 -16
View File
@@ -41,21 +41,140 @@ each other. They share three things:
| 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 command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
| On-demand request (fallback) | cache `display_on_demand_request` | web, when the socket fails; four plugins write it directly | 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 |
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
| 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 |
| Plugin runtime (loaded, state, last error, version) | cache `plugin_runtime_snapshot` | display: `PluginRuntimePublisher` ([`src/plugin_system/plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)) | web: `read_plugin_runtime()` for `/api/v3/plugins/installed`, `/plugins/state`, reconciliation |
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py): a changed frame at most once a second with a viewer, every 30 s without | web: display SSE stream (checks the mtime every 0.25 s), `/api/v3/health` (file age) |
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, about once a second while a preview is open | display: writes viewer-rate snapshots only while it is fresh (5 s) |
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
| Render-loop heartbeat | `/run/ledmatrix/display-heartbeat.json` (tmpfs) | display: the render thread, via [`display_watchdog`](../src/display_watchdog.py) | web: `/api/v3/health` (`checks.display_loop`); the update health check |
The on-demand start route starts `ledmatrix.service` when it is not running
(`start_service`, on by default) but never restarts a running one: the display
reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell
sleep, its render loops and Vegas's interrupt check as well as the main loop.
(`start_service`, on by default) but never restarts a running one. The routes
send the command over the display's control socket and get an ack; when that
fails (a stopped display, one older than the socket) they write the mailbox
instead, which the display reads every `ON_DEMAND_POLL_INTERVAL` (0.25s), from
its dwell sleep, its render loops and Vegas's interrupt check as well as the
main loop. Both ways end in the same handler, `_handle_on_demand_request()`.
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
for the protocol, the permission model and the plan to retire the mailboxes.
### Web and display processes: who runs plugins
Only the display process imports plugin code, instantiates plugins and calls
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
`on_disable`). The web process is metadata-only: it reads plugins as files
through `PluginCatalog`
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py))
-- manifests, config schemas (through `SchemaManager`), each plugin's
section of `config.json`, and installed versions. The catalog keeps the
read-only method names of `PluginManager` and has nothing that can run a
plugin (no `load_plugin`, `get_plugin` or `plugins`).
How a web-side change reaches the running plugins:
| Change | How the display picks it up |
|---|---|
| Plugin settings saved, config reset | `ConfigService` sees the new `config.json` and calls the plugin's `on_config_change` with the prepared section |
| Plugin enabled or disabled | `ConfigService` → `_controller_config_change` flags a reconcile; `_reconcile_enabled_plugins` loads it (fresh from disk) or unloads it on the render thread |
| Plugin uninstalled (config removed) | the removed section flips its `enabled` flag, and the reconcile unloads it |
| Plugin installed, not enabled | nothing to do until it is enabled, which loads it |
| Plugin updated while enabled | the update route asks the display over the control socket (`plugin.reload`) to reload it on the render thread, and answers `restart_required: false` once the new code runs. Without the socket, as the next row |
| Plugin installed while already enabled, updated while enabled and not reloaded, or uninstalled with its config kept | **not picked up**: the display keeps running what it loaded. The route answers `restart_required: true` and the UI shows its restart banner |
`display_restart_required()` in `plugin_catalog.py` holds that last rule;
routes return it as `restart_required` (with the banner's wording in
`restart_message`), and `window.noteRestartRequired()` in
`static/v3/app.js` raises the banner for any response that carries it,
`POST /api/v3/config/main` included.
Runtime state shown in the UI comes from what the display publishes to the
shared cache: health and metrics (`/api/v3/plugins/health`,
`/plugins/metrics`), errors (`/api/v3/errors/*`), the current mode, and the
plugin runtime snapshot described below. `enabled` is read from
`config.json` by the display's rule (a missing flag is disabled).
Plugin code still runs in the web process in one place,
`_import_plugin_code_in_web_process()` in
[`api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py): the
Starlark routes import the starlark-apps plugin's `tronbyte_repository` and
`pixlet_renderer` helper modules (never the plugin class), and a web-UI
action with `oauth_flow` imports its script for `get_auth_url()`. Every
other web-UI action runs its script as a subprocess. A later, explicit
**plugin web-entry contract** -- a declared entry point for plugin web code
-- replaces that function.
The **control socket** from the web process to the display
([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) carries on-demand
commands and reloads an updated plugin; its next stages stream the
display's state and retire the cache-key mailboxes. The plugin web-entry
contract above is still to come.
### Plugin state: desired, observed, and who owns it
There is one plugin state machine, and the display owns it:
`PluginStateManager` in
[`plugin_state.py`](../src/plugin_system/plugin_state.py) (unloaded →
loaded → enabled ⇄ running, error, disabled), held by the display's
`PluginManager`. It also records, per loaded plugin, the manifest version it
loaded and when. Nothing else keeps plugin state:
| Question | Answered by |
|---|---|
| Is it installed, at which version? | the plugins directory (`manifest.json`) |
| Should it run? | `config.json` (`<id>.enabled`, missing = disabled) |
| Has the user uninstalled it for good? | the store's uninstalled-plugins record |
| Is the display running it, at which version, and why not? | the display's runtime snapshot |
**The runtime snapshot.** `PluginRuntimePublisher`
([`plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)), started by
`DisplayController` right after it creates the `PluginManager`, writes the
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
(type, a redacted message of at most 200 characters, when, recoverable),
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
The cache is on disk, usually the SD card, so it writes when something a
reader sees changes -- throttled to once per 10 s -- and otherwise once a
minute as a heartbeat. RUNNING, which every `update()` passes through, is
published as ENABLED, so plugin updates alone never cause a write.
`cleanup()` publishes `running: false`.
**Reading it.** `read_plugin_runtime()` judges the snapshot before anyone
uses it: `live` (fresh, from a running display), `stale` (older than
`stale_after`, 3 minutes: a hung or crashed display), `stopped` or
`unknown` (none, unreadable, or another schema). Only a live view reports
per-plugin facts; every other status answers `null` for them, so stale
truth cannot leak into a response. `/api/v3/plugins/installed` returns
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
`/api/v3/plugins/state` returns the same beside the desired state.
**Reconciliation**
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
compares desired state (config + disk) with observed state (the snapshot).
It fixes desired-state gaps -- a plugin on disk with no config section gets
`{"enabled": false}`, a configured plugin missing from disk is reinstalled
unless the user uninstalled it -- and only reports observed-state gaps
(enabled but not loaded, loaded at an older version): the display loads and
unloads by config on its own, and a version gap needs a restart.
**`data/plugin_state.json` is retired.** The web process used to keep a
second `PluginStateManager` (`state_manager.py`) persisted to that file:
per plugin an enabled flag copied from config, a version copied from the
manifest (when set at all), a status derived from those, and install/update
timestamps. Reconciliation mostly synced it back to config and backups
merged it into their plugin list. Every field is derivable (the timestamps
from the operation history), so nothing is migrated: no code reads or
writes the file, and a copy left on a device is inert and safe to delete.
The two classes shared a name but not a concern -- a persisted install
record versus the live lifecycle -- so they were not merged; the persisted
one had nothing left to hold and was removed.
## Display loop
@@ -72,7 +191,8 @@ the scheduler), and sets up Vegas mode.
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.
then normal rotation. [RUN_LOOP_REDESIGN.md](RUN_LOOP_REDESIGN.md) is the
plan for restructuring this loop and lists its golden trace tests.
- **Rotation.** `available_modes` is the ordered list of display modes;
`current_mode_index` advances after each screen.
@@ -94,7 +214,10 @@ then normal rotation.
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.
`display.hardware.brightness`. Both are re-evaluated once a minute, and
both windows are half-open: on (or dimmed) from the start time, off at
the end time. When an on-demand session ends, the on/off schedule is
re-checked at once rather than at the next 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
@@ -105,7 +228,8 @@ then normal rotation.
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.
for its own section, under its plugin lock
(`PluginManager.apply_config_change()`). 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()`
@@ -119,6 +243,43 @@ then normal rotation.
`sync.role`: a leader sends a follower its share of each frame over UDP
(port 5765).
### Liveness
A render thread stuck inside a plugin leaves the service "active" and the
panel frozen, so liveness is reported by the render thread itself
([`src/display_watchdog.py`](../src/display_watchdog.py), standard library
only). `beat()` from any other thread is ignored: the update worker, Vegas's
tick thread and the prefetcher keep running while the render thread is stuck,
and must not vouch for it.
- **Check-in points.** The top of `run()`'s loop (`loop_pass()`), every
dwell second (`_sleep_with_plugin_updates`), every frame of the per-screen
loops (`_display_once`), every frame of Vegas's own loop and static pause
(`coordinator.run_iteration`), each plugin fetched for a Vegas cycle
(`StreamManager._fetch_plugin_content`), each update on the
`synchronous_updates` path, and every frame pushed
(`DisplayManager.update_display` -> `note_frame()`). Beats are
rate-limited to one ping and one heartbeat write every 5 s.
- **systemd watchdog.** `ledmatrix.service` is `Type=simple` with
`WatchdogSec=120` and `NotifyAccess=main`. `run.py` sends
`WATCHDOG_USEC` = 15 minutes before importing anything heavy (start-up loads
plugins and runs the 20 s update budget, and the watchdog clock starts with
the process). After the first frame -- or the first full pass, when there is
nothing to draw -- the loop sends `READY=1`, restores the unit's 120 s and
pings. `PluginManager.load_plugin()` on the render thread (a plugin enabled
from the web UI, or loaded for on-demand) gets 15 minutes again, since it
can run pip. A missed deadline is a SIGABRT; faulthandler, enabled on
arming, dumps every thread's stack to the journal.
- **Heartbeat.** `/run/ledmatrix/display-heartbeat.json`
(`{"pid", "mono", "wall"}`; `RuntimeDirectory=ledmatrix`, 0755, file 0644 so
the web user can read it). Readers compare `mono` with their own
`time.monotonic()` -- CLOCK_MONOTONIC is shared by every process and does not
jump when NTP first sets an RTC-less Pi's clock. `/api/v3/health` calls it
`stalled` past 60 s; no file is `not_reported` and changes nothing. A clean
stop removes it. Without `RuntimeDirectory=` (an older unit) the display,
as root, creates the directory itself; off Linux, or without root, there
is no heartbeat.
## Plugin system
[`src/plugin_system/`](../src/plugin_system/):
@@ -127,7 +288,8 @@ then normal rotation.
|---|---|
| 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`) |
| Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
| Manifest, schema, config and version reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
| 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) |
@@ -154,8 +316,9 @@ 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.
Flask `app` at import time, creates the managers -- a `PluginCatalog`,
never a `PluginManager` -- 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
@@ -187,8 +350,19 @@ everything else through `_reinstall_with_rollback()`.
- **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.
fetch branches and tags, move the checkout for the update channel, reinstall
changed requirement files, report whether a restart is needed.
- **Update channels** (`auto_update.channel`):
[`web_interface/update_channel.py`](../web_interface/update_channel.py)
decides the move. `stable` checks out the newest `vX.Y.Z` tag (detached
HEAD) when it contains the current commit; `beta` is
`git pull --rebase --autostash` on the current branch, and leaves a
detached release for `main` first. A stable device newer than the newest
release keeps pulling `main` until a release contains its commit, so no
update ever moves backwards; a config without the key is written as
`stable` once the device reaches a release. Checkouts carry uncommitted
edits across with `git stash create`/`apply`, and keep them in the stash
list if they no longer apply.
- **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
@@ -199,7 +373,11 @@ everything else through `_reinstall_with_rollback()`.
`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.
stay up -- and, when the display wrote a heartbeat before the update, to
keep one fresh from the restarted process (see Liveness) -- and on failure
returns to where HEAD was (the branch, or detached on the previous
release; `old_ref` in the pending file), 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`
@@ -207,7 +385,9 @@ everything else through `_reinstall_with_rollback()`.
`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.
are logged; startup continues either way. Nothing rewrites installed units
on update: a unit change such as the watchdog reaches an existing install
only when `install_service.sh` is re-run.
## Where to start reading
+16 -2
View File
@@ -17,6 +17,7 @@ tooling against it.
|---|---|---|---|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
| `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) |
| `auto_update.channel` | `"stable"` or `"beta"`, `"stable"` (template) | What Update Code and the weekly update install. `stable`: the newest `vX.Y.Z` release tag (pre-releases ignored), checked out with a detached HEAD. `beta`: `main`. Never moves a device backwards: one newer than the newest release keeps following `main` until a release contains its commit. Missing (configs from before channels) behaves like `stable` and is saved as `stable` once the device is on a release. General tab, Update Channel | `web_interface/update_channel.py` (`resolve()`) |
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps |
@@ -30,6 +31,13 @@ tooling against it.
| `start_time` / `end_time` | `"HH:MM"`, `07:00`–`23:00` | Global-mode on/off times |
| `days.<weekday>.{enabled,start_time,end_time}` | per-day objects | Per-day-mode overrides |
The display is on from `start_time` up to, but not including, `end_time`:
with `07:00`–`23:00` it turns on at 07:00 and off at 23:00. An end earlier
than the start crosses midnight (`22:00`–`07:00` is on overnight). In
per-day mode, the entry for the current day decides. An on-demand session
keeps the display on during off hours; once it ends or is stopped, the
display blanks within about a second.
Read by `DisplayController._check_schedule()` (`src/display_controller.py`).
Managed in the web UI under Schedule.
@@ -43,7 +51,9 @@ Same shape as `schedule` (the template sets its `mode` to `"global"`), plus:
Read by `DisplayController._check_dim_schedule()` (`src/display_controller.py`;
saved via `POST /api/v3/config/dim-schedule`). The display returns to
`display.hardware.brightness` outside the window.
`display.hardware.brightness` outside the window. The window has the same
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
`end_time`.
## `display.hardware` — matrix panel hardware
@@ -131,6 +141,10 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
| `live_refresh` | bool, `true` — live elements: a plugin that supports them (scores, the flight map) has what is already scrolling updated when its data changes, instead of freezing each card as it was drawn. Always off under multi-display sync, in swap mode and with `offscreen_prefetch` off. `false` restores the frozen behaviour exactly. Per plugin: `vegas_live` in the plugin's section |
| `live_max_hz` | float, `5` (0–10) — ceiling on how often an animated live element (a moving aircraft) is redrawn; `0` keeps data updates and turns animation off. Capped at 1 Hz without the rebuilt rgbmatrix binding |
| `live_min_interval` | float, `2` (0.5–60) — shortest time between two data redraws of one plugin; a faster plugin is redrawn at this rate, never skipped |
| `live_lead_screens` | float, `1` (0–5) — how far ahead of the screen, in screen widths, an animated element starts being redrawn |
| `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` |
@@ -147,7 +161,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `max_cycle_duration` | int, `240` |
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
| `live_in_ticker` | bool, `true` — keep scrolling during live games instead of handing the display to a full-screen scoreboard. `false` was the default before 3.8.0; the first start on 3.8.0 turns a stored `false` on once and sets `live_in_ticker_migrated` |
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
+175
View File
@@ -0,0 +1,175 @@
# Deprecated plugin APIs: usage scan
Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)).
- Scanned: 2026-10-01, core 3.7.0
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4de1d134), 46 plugins
- Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar)
**37 deprecated methods: 36 unused, 1 still used, 0 need review.**
Counted per plugin: a *call* is `<receiver>.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files.
| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |
|---|---|---|---|---|---|
| `CacheManager.has_data_changed` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 |
| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 |
| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 |
| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | blackjack (1 call, 1 override); calendar (1 override); olympics (1 override) | — | still used by blackjack, calendar, olympics — keep or migrate first |
| `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.9.0 |
| `PluginManager.get_enabled_plugins` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
## Unused — safe to remove (36)
`CacheManager.has_data_changed`, `CacheManager.update_cache`, `CacheManager.setup_persistent_cache`, `CacheManager.get_sport_live_interval`, `CacheManager.get_sport_key_from_cache_key`, `CacheManager.get_background_cached_data`, `CacheManager.is_background_data_available`, `CacheManager.record_cache_hit`, `CacheManager.record_cache_miss`, `CacheManager.record_fetch_time`, `CacheManager.get_cache_metrics`, `CacheManager.log_cache_metrics`, `CacheManager.get_memory_cache_stats`, `DisplayManager.draw_sun`, `DisplayManager.draw_cloud`, `DisplayManager.draw_rain`, `DisplayManager.draw_snow`, `DisplayManager.draw_weather_icon`, `DisplayManager.draw_text_with_icons`, `DisplayManager.get_scrolling_stats`, `FontManager.get_manager_fonts`, `FontManager.get_detected_fonts`, `FontManager.unregister_plugin_fonts`, `FontManager.get_plugin_fonts`, `FontManager.set_override`, `FontManager.remove_override`, `FontManager.get_overrides`, `FontManager.get_available_fonts`, `FontManager.get_size_tokens`, `FontManager.get_performance_stats`, `FontManager.get_font_catalog`, `FontManager.add_font`, `FontManager.remove_font`, `FontManager.validate_font`, `BasePlugin.get_vegas_segment_width`, `PluginManager.get_enabled_plugins`
## Still used — keep or migrate first (1)
`BasePlugin.get_supported_vegas_modes`
## Every hit
File paths are relative to the plugin's directory (core: the repo root).
| Method | Where | File:line | Kind | Code |
|---|---|---|---|---|
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:28 | unrelated | `def get_sport_live_interval(self, sport_key: str) -> int:` |
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` |
| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` |
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache/cache_strategy.py:214 | unrelated | `def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:` |
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:806 | unrelated | `return self._strategy_component.get_sport_key_from_cache_key(key)` |
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:816 | unrelated | `sport_key = self._strategy_component.get_sport_key_from_cache_key(key)` |
| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` |
| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` |
| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` |
| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` |
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` |
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` |
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` |
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1359 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1374 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` |
| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` |
| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` |
| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` |
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1518 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` |
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:84 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1280 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1559 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1650 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` |
| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` |
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` |
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` |
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:732 | override | `def get_supported_vegas_modes(self):` |
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:695 | call | `if mode in self.get_supported_vegas_modes():` |
| `BasePlugin.get_supported_vegas_modes` | calendar | manager.py:875 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
| `BasePlugin.get_supported_vegas_modes` | olympics | manager.py:624 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
| `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` |
## Sources scanned
| Source | Group | Python files | Hits |
|---|---|---|---|
| core | core | 172 | 20 |
| core tests | core-tests | 347 | 17 |
| 7-segment-clock | monorepo | 3 | 0 |
| afl-scoreboard | monorepo | 35 | 0 |
| baseball-scoreboard | monorepo | 61 | 0 |
| basketball-scoreboard | monorepo | 49 | 0 |
| birdnet-go | monorepo | 2 | 0 |
| blackjack | monorepo | 7 | 2 |
| calendar | monorepo | 5 | 1 |
| christmas-countdown | monorepo | 3 | 0 |
| clock-simple | monorepo | 2 | 0 |
| countdown | monorepo | 5 | 0 |
| cricket-scoreboard | monorepo | 8 | 0 |
| f1-scoreboard | monorepo | 15 | 0 |
| fantasy-blitz | monorepo | 13 | 0 |
| football-scoreboard | monorepo | 74 | 0 |
| geochron | monorepo | 10 | 0 |
| hello-world | monorepo | 2 | 0 |
| hockey-scoreboard | monorepo | 52 | 0 |
| incoming-packages | monorepo | 8 | 0 |
| jellyfin-now-playing | monorepo | 4 | 0 |
| lacrosse-scoreboard | monorepo | 40 | 0 |
| ledmatrix-elections | monorepo | 12 | 0 |
| ledmatrix-flights | monorepo | 48 | 0 |
| ledmatrix-leaderboard | monorepo | 9 | 0 |
| ledmatrix-music | monorepo | 11 | 0 |
| ledmatrix-stocks | monorepo | 7 | 0 |
| ledmatrix-weather | monorepo | 15 | 6 |
| march-madness | monorepo | 4 | 0 |
| masters-tournament | monorepo | 10 | 0 |
| mqtt-notifications | monorepo | 4 | 0 |
| news | monorepo | 6 | 0 |
| nfl-draft | monorepo | 3 | 0 |
| nfl-stat-leaders | monorepo | 8 | 0 |
| nrl-scoreboard | monorepo | 30 | 0 |
| odds-ticker | monorepo | 9 | 0 |
| of-the-day | monorepo | 14 | 0 |
| olympics | monorepo | 16 | 1 |
| on-air | monorepo | 2 | 0 |
| pomodoro-timer | monorepo | 3 | 0 |
| soccer-scoreboard | monorepo | 47 | 0 |
| static-image | monorepo | 3 | 0 |
| stock-news | monorepo | 3 | 0 |
| text-display | monorepo | 4 | 0 |
| tide-display | monorepo | 3 | 0 |
| ufc-scoreboard | monorepo | 38 | 0 |
| web-ui-info | monorepo | 2 | 0 |
| youtube-stats | monorepo | 5 | 0 |
| f1-live | third-party | 10 | 0 |
| gif-player | third-party | 1 | 0 |
| pga-tour-leaderboard | third-party | 2 | 0 |
| plex-marquee | third-party | 1 | 0 |
| ledmatrix-dresden-departures | third-party | 1 | 0 |
| tidbyt-baseball-scoreboard | third-party | 2 | 0 |
| sleeper-fantasy | third-party | 1 | 0 |
| ledmatrix-nascar | third-party | 1 | 0 |
## How to re-run
```bash
# Clones the monorepo and each third-party plugin (depth 1) into a temp cache:
python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md
# Or scan a local monorepo checkout (read only) instead of cloning it:
python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins
```
Before removing a method in its release, re-run the scan against the current monorepo and registry: a plugin added since this file was generated may have started calling it. Remove only methods the fresh scan reports unused; move the rest to a later release (the test in `test/test_deprecation.py` fails while a marker names a release at or below `src.__version__`).
+5 -5
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: draw_weather_icon() is deprecated, removed in 3.7.0 —
# draw your own icons (the weather plugin ships WeatherIcons)
# Weather icons: draw_weather_icon() was removed in 3.8.0 — draw your
# own icons (the weather plugin ships WeatherIcons)
# Scrolling state
display_manager.set_scrolling_state(True)
@@ -78,7 +78,7 @@ strategy = cache_manager.get_cache_strategy("weather")
```
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
are deprecated, removed in 3.7.0. See
were removed in 3.8.0. See
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
## Plugin Manager Quick Methods
@@ -87,8 +87,8 @@ are deprecated, removed in 3.7.0. See
# Get plugins
plugin = plugin_manager.get_plugin("plugin-id")
all_plugins = plugin_manager.get_all_plugins()
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check `enabled`
# on the entries in plugin_manager.plugins
# get_enabled_plugins() was removed in 3.8.0 — check `enabled` on the
# entries in plugin_manager.plugins
# Get info
info = plugin_manager.get_plugin_info("plugin-id")
+8 -8
View File
@@ -13,10 +13,9 @@
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).
Several methods were removed in LEDMatrix 3.8.0 after a release of
deprecation warnings; [Removed methods](#removed-methods) below lists them
with what to use instead.
## Getting the FontManager
@@ -128,8 +127,8 @@ font = self.font_manager.resolve_font(
`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
The methods that edited it — `set_override()`, `remove_override()`,
`get_overrides()` — were removed in 3.8.0, 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.
@@ -207,9 +206,10 @@ Current methods:
| `clear_cache()` | Drop cached fonts and metrics |
| `font_catalog` (attribute) | Family name → file path |
### Deprecated methods
### Removed methods
Removed in 3.7.0. Each logs a warning on first call.
Removed in 3.8.0, after logging a deprecation warning on first call since
3.5.0.
| Method | Use instead |
|---|---|
+16
View File
@@ -240,6 +240,22 @@ The fastest way to verify a plugin works without waiting for the rotation:
- Install community plugins straight from a GitHub URL via
**Install from GitHub** on the same tab.
### Keep LEDMatrix Up to Date
- **Update Code** on the **Overview** tab installs the newest version, and a
banner at the top of the page says when one is available.
- **General → Automatic Updates** does it once a week, overnight, with a
health check that undoes an update that breaks the device.
- **General → Update Channel** picks which version that is. **Stable** (the
default) installs releases, which have been tested and have release
notes. **Beta** installs the newest code as soon as it is written, before
it is released: fixes arrive sooner, and so do new problems.
- Switching to Stable never installs an older version than the one you
have. If your device is already newer than the latest release (which is
normal if it was set up or updated from the newest code), it keeps
getting the newest code until the next release includes it, then follows
releases from there. The General tab says when this is the case.
### Enable Advanced Features
**Vegas Scroll Mode:**
+25
View File
@@ -170,6 +170,31 @@ pytest test/test_config_manager.py
pytest
```
### Web UI JavaScript Tests
The suites in `test/js` need node; the DOM ones also need jsdom and a running
web interface (details in [`test/js/README.md`](../test/js/README.md)):
```bash
npm install --no-audit --no-fund --prefix test/js # jsdom; node_modules is gitignored
EMULATOR=true python3 web_interface/app.py # in another shell
BASE=http://localhost:5000 REQUIRE_DOM=1 node test/js/run_all.js
```
`pytest test/test_js_unit_suites.py` runs just the unit suites.
### Plugin Config Form Parity
`test/test_field_model_parity.py` checks `build_field_model` against the
`render_field` macro for every plugin schema it finds
([WEB_FRONTEND_ARCHITECTURE.md](WEB_FRONTEND_ARCHITECTURE.md)). It always
covers `plugin-repos/` and the test fixtures; point it at a checkout of the
official plugins to cover those too:
```bash
LEDMATRIX_MONOREPO_PLUGINS=../ledmatrix-plugins/plugins pytest test/test_field_model_parity.py
```
### Debug a Failing Test
```bash
+407
View File
@@ -0,0 +1,407 @@
# Control socket (web → display)
The display process serves a Unix socket that the web interface uses to send
it commands and get an answer back. It replaces the cache-file "mailboxes" on
the SD card one command at a time. Stage 1 carries on-demand start, stop and
status. Stage 2 makes those commands land within a frame on every kind of
screen, and adds `brightness.set` and `plugin.reload`. The file mailbox stays
as a fallback for one release.
| | |
|---|---|
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
| Served by | the display process ([`src/ipc/server.py`](../src/ipc/server.py)), started by `DisplayController.run()` |
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness) |
| Contract | [`src/ipc/contract.py`](../src/ipc/contract.py): messages, versions, framing and the socket path; both sides import it |
| Override | `LEDMATRIX_CONTROL_SOCKET=/some/path.sock` for both processes, or `=off` to disable it |
## Why
Before the socket, the web interface sent commands by writing a cache key
(`display_on_demand_request`) that the display read every 0.25 s.
- **No acknowledgement.** The route answered "success" once the file was
written, whether or not a display was running to read it.
- **Lost requests.** The display had to read the request and then delete it.
A request written between those two steps could be thrown away (see
`_consume_on_demand_request`). The cache has no atomic claim to prevent it.
- **Fragile.** Each channel repeated its own permission, atomic-write,
staleness and in-memory-cache rules. Two of them caused bugs: a `memory_ttl`
bug ignored every on-demand request after the first for an hour, and a
stopped display was still reported as "active" for two minutes.
The socket answers every command, carries one request per message (so nothing
can overwrite it), and belongs to the display process. If the display is not
running, the socket does not exist, and the web interface knows right away.
## Protocol (version 1)
**Framing.** One JSON object per line (newline-delimited JSON), UTF-8, at
most 64 KiB per line (`MAX_MESSAGE_BYTES`). Senders encode with
`ensure_ascii`, so a newline never appears inside a message. A connection
can carry several requests. Each request gets exactly one response, in order.
**Request**
```json
{"v": 1, "id": "5f0c…", "cmd": "on_demand.start",
"args": {"plugin_id": "clock", "mode": null, "duration": 30, "pinned": false}}
```
- `v` is the protocol version.
- `id` is a printable string of 1-128 characters. It is echoed back in the
response, and for on-demand commands it is also the on-demand `request_id`.
- `cmd` is a command name.
- `args` is an object. It may be omitted when a command takes no arguments.
**Response**
```json
{"v": 1, "id": "5f0c…", "ok": true, "result": {"accepted": true, "request_id": "5f0c…", "queued": 1}}
{"v": 1, "id": "5f0c…", "ok": false, "error": {"code": "busy", "message": "…"}}
```
`id` is `null` only when the request could not be parsed far enough to have
one. Clients branch on `error.code`, never on the message text.
**Commands**
| `cmd` | `args` | `result` | Kind |
|---|---|---|---|
| `hello` | `{versions: [int], client?: str}` | `{version, versions, commands, max_message_bytes, server}` | answered directly |
| `ping` | — | `{pong: true}` | answered directly |
| `on_demand.start` | `{plugin_id?, mode?, duration?, pinned?}` (at least one of `plugin_id` and `mode`) | ack | queued |
| `on_demand.stop` | — | ack | queued |
| `on_demand.status` | — | `{on_demand: {...}, current_mode, display_active}` | answered directly |
| `brightness.set` | `{brightness: int 0-100}` | `{brightness, panel_brightness, dimmed, display_active}` | queued, awaited (2 s) |
| `plugin.reload` | `{plugin_id}` | `{plugin_id, reloaded: true, version, modes}` | queued, awaited (10 s) |
`duration` is a number of seconds, or a numeric string. `0`, `null` or `""`
mean "until stopped". `pinned` must be a real boolean: the REST route has
already converted strings like `"false"` before it sends the command. The
`on_demand` object in `on_demand.status` is the same dict the display
publishes to `display_on_demand_state`.
`brightness.set` sets the panel's normal brightness. It is transient: it
writes nothing to `config.json`, and the next config the display's watcher
loads (or a restart) puts the configured value back. The web interface
sends it after it has saved the setting, so the two agree. The dim schedule
still applies on top, so `panel_brightness` is the dim level while the
schedule dims. While the schedule has the display off, the new level is
kept for when it comes back on.
`plugin.reload` loads a plugin the display is running again from disk,
manifest included: the steps of disabling it live and enabling it again,
with its modes kept in their place in the rotation. Only a running plugin
can be reloaded (`not_loaded` otherwise), so the id never makes the display
import anything new. A plugin loaded only for an on-demand session gets
`busy`. A new version that fails to load gets `failed` and stays out of the
rotation, as it would after a restart. The load runs off the render thread,
so the panel keeps scrolling while it happens (see below).
**Acknowledgements.** A queued on-demand command is *accepted*, not *done*.
`{"accepted": true, "request_id": …}` means the command is waiting in the
render thread's queue. Since stage 2 the render thread waits on that queue
instead of sleeping, so it applies the command within one frame on every
kind of screen (see below). Any outcome is published as before
(`display_on_demand_state`, and `status`/`error` for a bad plugin or mode),
and it can be read with `on_demand.status`.
**Awaited commands.** `brightness.set` and `plugin.reload` are answered only
once the render thread has applied them, with their result or their error.
The connection thread waits for that (2 s and 10 s, `AWAIT_SECONDS` in the
contract); the render thread never waits for a client. When the render thread
has not got to the command in time, the answer is `pending`: the command
stays queued and is still applied, so a client treats `pending` as "not known
to be done", not as a refusal. The client's own timeout is one second longer
than the display's wait, so `pending` arrives before the client gives up.
**Versions.** Every request carries `v`. For any command except `hello`, a
`v` the display does not speak gets `unsupported_version`. `hello` is checked
by its `versions` list instead, and its result names the highest version both
sides share, so a client can find out what a display supports before it
relies on anything newer. The client sends `v: 1` and falls back to the
mailbox when the display refuses it. It does not send `hello` first, which
saves a round trip.
New commands are added within a version, so stage 2 is still version 1. A
display that does not know a command answers `unknown_command`, which the
web interface treats like any other socket failure and falls back from, and
`hello` lists the commands a display knows. The version changes only when the
envelope or the meaning of an existing command changes.
**Error codes:** `bad_json`, `bad_request`, `message_too_large`,
`unsupported_version`, `unknown_command`, `invalid_args`, `busy` (queue full,
or too many connections), `forbidden` (peer credentials refused), `internal`.
Stage 2 adds `pending` (accepted, not applied in time, still queued),
`not_loaded` (`plugin.reload` of a plugin the display is not running) and
`failed` (the render thread tried, and it did not work).
Try it on a device:
```bash
python3 - <<'EOF'
from src.ipc import client # run from the project directory
print(client.on_demand_status())
print(client.brightness_set(60))
EOF
```
## How the display applies a command
The server's threads never touch rendering. A connection thread parses the
request, validates it against the contract, and then does one of two things:
- For a command that changes the panel, it puts a `QueuedCommand` on a
bounded queue (16 entries) and answers with the ack, or, for an awaited
command, with the outcome the render thread reports back through the
command's `CommandOutcome`.
- For a query, it answers from a status snapshot the display provides
(`DisplayController._control_status`). The snapshot only reads attributes.
The render thread drains the queue in `_poll_on_demand_requests()`, the same
place it reads the mailbox:
- An on-demand command goes to `_handle_on_demand_request()`, which is the
mailbox's own handler. The two paths share all of their code: activation,
the processed-id guard, error publishing, and resuming the rotation
afterwards.
- `brightness.set` is applied there and then (`_apply_control_brightness`),
and the current frame is pushed again so the panel shows it.
- `plugin.reload` starts at the top of the next loop pass, the place where
plugins are enabled and disabled live, because there no `display()` and no
Vegas iteration is on the stack (`_apply_pending_plugin_reloads`). Until
then the current screen ends early, as it does for a WiFi notice: the
frame loops, the dwell and Vegas's interrupt check all treat a pending
reload as a reason to stop (`_screen_preempted`).
- Only the quick half of the reload runs on the render thread
(`_start_plugin_reload`): the plugin's modes leave the rotation, its
config subscription is dropped, and `PluginManager.detach_plugin` takes
the instance out of `plugins`. After that nothing new calls the old
instance: no `update()`, and no Vegas fetch. The rotation then advances
(Vegas resumes its strip), and frames keep coming.
- The slow half runs on a `plugin-reload-<id>` thread (`_PluginReloadJob`).
It waits for the plugin's lock, then tears the old instance down
(`unload_detached_plugin`) and loads the new one (`reload_plugin`). The
lock can be held for seconds by a Vegas render of the old instance. On
ledpi the render thread used to wait for it here, and a football reload
froze the panel for 3.0 s.
- The new instance joins the rotation between two frames
(`_finish_plugin_reloads`, from `_service_pending_changes` or the top of
the loop). Its modes go back to their old places, Vegas is told to fetch
it again, and the command is answered.
- While the plugin reloads, it is out of the rotation. Vegas scrolls what
its strip already holds of it. An on-demand request for it gets
`plugin-reloading`. A config reconcile neither loads it a second time nor
unloads it mid-load; a disable saved meanwhile is applied once the
reload is done. A second reload of the same plugin runs after the first.
The 0.25 s floor on the mailbox read does not apply to the queue, because
draining it costs no disk read. A queued command also lets
`_service_pending_changes()` skip its own floor.
### Waking the render thread (stage 2)
Stage 1 made the socket answer, but not land sooner: a queued command waited
for the same polls the mailbox does. Measured on ledpi (Pi 4, 24 fps Vegas),
a start took 1.02 s on a static screen and about 0.4 s in Vegas either way.
Now the queue wakes the render thread:
- **The waits.** The server sets a `threading.Event` whenever it queues a
command. The render thread waits on it (`ControlServer.wait_for_command`)
where it used to sleep: the static screen's 1 s frame sleep
(`_wait_frame_interval`) and the dwell's 0.25 s ticks
(`_sleep_with_plugin_updates`, which also covers scheduled-off and the
empty-rotation pause). On a wake it applies the command at once. A command
that does not end the screen, such as a brightness, does not cut the frame
short: the wait carries on to the end of the interval, so the plugin is
still drawn once a second.
- **Vegas.** The coordinator still runs its interrupt check every 10 frames,
and now also at any frame where `urgent()` is true. The display passes
"a control socket command is queued", which is one `Event.is_set()` per
frame.
- **Scrolling screens** already service pending changes every frame.
So a command lands within a millisecond or so on a static screen and in a
dwell, and within one frame in Vegas and on a scrolling screen. The mailbox
keeps its old delays. Commands still run only on the render thread: the
connection threads only queue them and set the event. The one exception is
the slow half of `plugin.reload` (tearing down and loading the plugin),
which runs on its own thread. Every change to the display's state still
happens on the render thread.
The waits are timed `Event.wait()` calls: no polling, and no more wake-ups
than the sleeps they replace when nothing arrives. Measured under WSL
(Python 3.12, 20 s runs in the order before, after, after, before, with the
socket's accept thread up), the idle process used 0.015–0.018% of a core
before and 0.019–0.021% after on a static screen, and 0.035–0.037% before and
0.047% after in a dwell: about 25 µs more per wait, from `Event.wait`'s own
bookkeeping. A client's send to the render thread waking took 0.72 ms median
(1.04 ms max), and a whole `brightness.set` round trip 0.64 ms median.
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
plain sleeps they were.
**Exactly once.** A command and a mailbox write for the same request share
one `request_id`. If the client times out after the display queued the
command and then also writes the mailbox, the display processes the request
once. The existing `on_demand_request_id` and processed-id checks drop the
second copy.
## Robustness
All of this runs inside the display process, so nothing a client does may
block the render loop or crash it:
- **Bounded connections.** Each connection gets its own daemon thread, with
at most 8 at once. One more is answered `busy` and closed.
- **Timeouts.** Each read and write times out after 2 s. A message must
arrive whole within 5 s of its first byte. An idle connection is closed
after 10 s. A slow or stuck client costs one thread for a few seconds.
- **Malformed input.** A line that is not JSON gets `bad_json`, and the
connection carries on. A line longer than 64 KiB gets `message_too_large`,
and the connection is closed, because the next message boundary cannot be
found. A client that disconnects mid-message is dropped silently. No
exception from a handler leaves the connection thread.
- **Full queue.** When the queue is full, the client gets `busy` and falls
back to the mailbox. A full queue means the render thread is stuck, and the
systemd watchdog deals with that.
- **Awaited commands.** The wait for an awaited command's outcome happens on
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
thread costs that client `pending` and one connection slot for at most
10 s. The render thread settles an outcome without blocking; one nobody is
waiting for any more is simply dropped.
- **Startup.** The server binds under a temporary name, sets the mode and the
group, then renames the socket into place, so it never appears with the
umask's permissions. It removes a stale socket (a file that nothing is
listening on). It never removes a live socket or a file that is not a
socket. `close()` removes the socket only if it is still the one this
process created.
- **Never fatal.** If the server cannot start (Windows, no `AF_UNIX`, a bind
failure, `LEDMATRIX_CONTROL_SOCKET=off`), it logs that and the display runs
as before. The web interface then uses the mailbox.
## Security model
The display runs as root and the web interface as the installing user (see
[PERMISSIONS.md](PERMISSIONS.md)). The socket admits exactly those two, plus
anything else in the group they share:
1. **The directory.** `/run/ledmatrix` is created by `RuntimeDirectory=ledmatrix`
in `ledmatrix.service` (#687): root-owned, `0755`, on tmpfs, and removed
when the display stops. Under an older unit, the display creates the
directory itself as root, as it does for the heartbeat. No installer
change is needed.
2. **The socket file.** The file is `root:<shared group>` with mode `0660`,
and the kernel refuses `connect()` to anyone without write permission on
it. The shared group is the cache directory's group whenever that
directory is group-writable. That is `ledmatrix` on an installed device
(`/var/cache/ledmatrix` is `root:ledmatrix 2775`), and it is the same rule
DiskCache uses for every file the two services share. Otherwise the group
is the project directory's (`get_shared_group_gid()`, which config files
use). With neither, the mode is `0600` and only root can connect.
3. **Peer credentials.** Where the kernel reports them (`SO_PEERCRED`, on
Linux), the server checks every connection again. It accepts root, the
display's own user, or a member of the shared group: the peer's primary
gid, or a supplementary group read from `/proc/<pid>/status`. If `/proc`
is unreadable, it uses the group database. Any other peer gets `forbidden`
and is disconnected. This covers a socket mode that someone loosened by
hand.
The commands are deliberately narrow. They start or stop on-demand display,
read its state, set the brightness, and reload a plugin the display is
already running, all of which anyone who can reach the web UI can already do
(the last by restarting the display). Nothing on the socket runs a shell,
writes a file, or names a path, and `plugin.reload` cannot make the display
import a plugin it was not running. Stage 2 changed none of the access rules
above.
**Development.** A display that is not root and cannot write to
`/run/ledmatrix`, such as `python3 run.py -e` from a checkout, serves the
socket at `$TMPDIR/ledmatrix-<uid>/control.sock`. That directory is private
(`0700`), and the server refuses it if another user owns it. The web
interface, run by the same user, looks there after `/run/ledmatrix`. The test
suite sets `LEDMATRIX_CONTROL_SOCKET=off` (`test/conftest.py`), so a run on a
device never touches the live display.
## Stage plan
1. **On-demand, with acks (done, #706).** Contract, server, client.
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes try the
socket first and report `transport: "socket" | "mailbox"` (plus
`socket_error` on fallback). The mailbox is unchanged, and the plugins that
write it directly (birdnet-go, mqtt-notifications, on-air, pomodoro-timer)
keep working.
2. **Commands that were restarts or polls (done).**
- The render thread waits on the queue instead of sleeping, and Vegas
checks it every frame, so a command lands within a frame on every kind
of screen (see "Waking the render thread").
- `brightness.set`, transient and with no `config.json` write. `POST
/api/v3/config/main` sends it after saving a brightness and reports
`brightness_transport`; without the socket the config watcher applies
the saved value, as before.
- `plugin.reload`, which replaces the `restart_required` answer from #688
for a store update of an enabled plugin. `POST /api/v3/plugins/update`
answers `restart_required: false, reloaded: true` once the new code
runs, and falls back to the restart banner (with `reload_error`)
otherwise.
- `config.reload` was left out. Its only gain over the config watcher
would be skipping the watcher's 2 s mtime poll, and the one setting
where those seconds show, brightness, now has its own command. Plugin
settings already reach the running plugin through the watcher, and the
"which sections changed" ack had no reader: the web interface knows
what it saved. A reload from the socket thread would also run every
config subscriber on a second thread beside the watcher's.
3. **A state stream.** A `subscribe` command that keeps the connection open
and pushes events: mode changes, on-demand state (including the outcome of
an acked on-demand command, which today is only published), plugin
runtime state, the outcome of a reload that answered `pending`, and the
heartbeat. It replaces the polled `display_current_state`,
`plugin_runtime_snapshot` (#690) and `display-heartbeat.json` (#687) for
readers that hold a connection. The web interface relays it to its
existing SSE stream. The files remain for one release for older readers.
- The server's per-connection threads (8 at most) do not suit long-lived
subscribers. A subscriber needs its own bound and a writer that drops
events for a slow reader rather than blocking the display.
- Events are produced on the render thread, so publishing must be a
non-blocking hand-off, like the queue in the other direction.
- The store's install of an already-enabled plugin, and an uninstall that
keeps its config, still answer `restart_required`. With the stream they
can use a load/unload command and report the result the same way the
update route does now.
4. **Retire the mailboxes.** After a release in which every device has had the
socket, the web interface stops writing `display_on_demand_request`, and
the display stops polling it, logging the plugins that still write it so
they can move to an in-process `request_display()`. The other cache keys
used as messages (`plugin_error_clear_request` and the remaining
`display_*` keys) move to the socket or to tmpfs.
## Checking it on a device
```bash
ls -l /run/ledmatrix/control.sock # srw-rw---- root ledmatrix
sudo journalctl -u ledmatrix | grep "Control socket"
curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
-H 'Content-Type: application/json' -d '{"plugin_id":"clock","duration":20}'
# ... "transport": "socket"
```
If the response says `"transport": "mailbox"`, `socket_error` gives the
reason. `no_socket` means the display is stopped or predates the socket.
`refused` usually means the web user is not in the socket's group, which
takes effect when the web service restarts after the user is added.
Brightness and a plugin reload:
```bash
curl -s -X POST localhost:5000/api/v3/config/main \
-H 'Content-Type: application/json' -d '{"brightness":40}'
# ... "brightness_transport": "socket"
curl -s -X POST localhost:5000/api/v3/plugins/update \
-H 'Content-Type: application/json' -d '{"plugin_id":"clock-simple"}'
# after a real update of an enabled plugin: "restart_required": false, "reloaded": true
sudo journalctl -u ledmatrix | grep -E "Brightness set|Reload(ing|ed) plugin"
```
`unknown_command` in `brightness_socket_error` or `reload_error` means the
display runs a stage-1 build: restart it once to pick up this one.
+116 -150
View File
@@ -1,9 +1,10 @@
# Offscreen Rendering
**Status (2026-09-24):** step 1, offscreen rendering, is implemented
**Status (2026-09-30):** offscreen rendering is implemented
(`DisplayManager.offscreen()`, the adapter on the prefetch thread, the plugin
lock). Steps 2 and 3 are proposed. When all three land, this file becomes the
reference for how plugin content is rendered off the render thread.
lock), and so are live elements, which grew out of steps 2 and 3 below: see
*Live elements*. The segment strip proposed as step 2 was not needed; *Why not
a SegmentStrip* says why.
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
runs, A/B/B/A):
@@ -153,136 +154,101 @@ particular keeps presenting while a plugin draws elsewhere.
fetch left is the inline fallback when no prepared group is ready, which in
practice is the first extension. Prefetching at start removes that too.
## Keeping live content fresh
## Live elements: content that changes while it scrolls
Offscreen rendering is also what makes fresh sports scores possible. Today a
plugin's segment is drawn when its group is prefetched, and the strip carries
7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
70–100 seconds later. When a plugin reports new data, Vegas only drops its
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
*next* turn, several minutes later. A segment already in the strip scrolls by
with the data it was drawn with.
Offscreen rendering is also what makes fresh content possible. A plugin's
segment is drawn when its group is prefetched, and the strip carries
7,000-10,000 px of content ahead of the viewport, so at ~100 px/s a score drawn
then reaches the screen 70-100 seconds later -- and once in the strip it never
changed: when a plugin reported new data, Vegas only dropped its caches, so the
change appeared on the plugin's *next* turn, minutes later.
That was the right trade while every redraw of a canvas-bound plugin stalled
the scroll. Off the render thread a redraw costs the scroll nothing, so the
strip can afford three things.
A plugin can now hand Vegas **live elements** instead of pictures
(`BasePlugin.get_vegas_elements()`, see "Live Vegas elements" in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)):
named, fixed-width pieces of content -- one per game card, one for a map. Vegas
records where each lands in the strip and, when the plugin's data changes,
redraws just the changed ones off the render thread and copies their pixels
over the old ones between two frames. A card already crossing the panel
changes; nothing next to it moves.
### 1. Refresh at the gate
### Why not redraw every frame
Before a segment enters the viewport, check whether its plugin has updated
since the segment was drawn. If it has, redraw it offscreen and replace it
while it is still out of sight. Width changes are fine here, because
everything from that segment onward is still invisible.
On a Pi the render thread has about 4 ms of slack per refresh at 512x64 after
the ~6 ms blit, and a scoreboard card is ~29 ms of Pillow work that holds the
GIL. Drawing on the render thread is out of the question at any rate, so the
render thread only ever *copies* pixels that are already drawn. Measured on a
Pi 4 (ledpi): writing a 35 KB card into a 20,000 px strip takes 8.5 µs, a
101 KB map 17 µs, four cards (the per-frame cap) 34 µs -- against 124 µs for
the viewport slice every frame already does.
The gate sits `lead` pixels ahead of the viewport's right edge:
`lead = max(one screen, speed × (render time + margin))`. The render time is
the plugin's own, measured on each render (sports cards take the longest,
hundreds of ms up to seconds per the prefetch notes). A plugin whose render
does not finish before its segment reaches the viewport keeps the old segment.
The scroll never waits for it.
### How an update reaches the screen
Content is then at most `lead / speed` seconds old when it appears, a few
seconds instead of minutes, without changing how far ahead the rotation
fetches.
1. A plugin's `update()` completes. The update worker calls
`PluginManager._note_update_completed`, which calls the update listeners
(`add_update_listener`) there and then, with the plugin's lock still held.
Vegas's listener moves the plugin's **epoch** on
(`src/vegas_mode/elements.py`, `LiveEpochs`) and wakes the live worker.
2. The **live worker** (`src/vegas_mode/live_worker.py`), the one background
thread that draws for the strip once it holds a live element, finds the
plugin's elements whose recorded epoch is older than its current one,
nearest the screen first, and calls `get_vegas_elements()` under the
plugin's lock (0.25 s wait, then a 1 s backoff). Elements whose `version`
is unchanged cost nothing; the rest are pinned and checksummed, and each
whose pixels changed becomes a patch in a one-per-element slot (the latest
wins).
3. Between two frames the render thread
(`RenderPipeline.apply_live_patches`, from `coordinator.run_frame`) pops at
most four patches or two screens of bytes and copies each into the strip
with `ScrollHelper.patch_columns`. It takes no lock and draws nothing. A
patch made for an older strip, for an element trimmed away or already
behind the screen, or from older data than the strip shows, is dropped.
### 2. Replace ahead of the screen
End to end, a new score reaches a card already on screen within one poll of
the data source (30 s for live games) plus about a second: the listener is
immediate, and while live elements exist the update tick that schedules
plugins runs every second instead of every four.
When a plugin reports new data (the Vegas update tick already names them), any
of its segments that are **anywhere ahead of the viewport** are redrawn and
replaced straight away, not only at the gate. That covers the long stretch of
strip between prefetch and the gate.
Elements that change with **time** rather than data (an aircraft moving
between position reports) ask for `refresh_hz`; the worker calls
`redraw_vegas_element()` -- without the plugin's lock, from state the plugin
publishes in one assignment -- that often while the element is on or within
`live_lead_screens` of the screen, capped by `live_max_hz` (5), at 1 Hz
without the render gate, and halved for an element whose redraws average over
50 ms.
### 3. Update on screen
### Geometry
A segment that is already **visible** is patched in place when the redrawn
version has the same geometry: the same total width, and the same width for
each card (a sports plugin returns one image per game, joined with
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
patches in and the digits update as the card scrolls past. The patch is a
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
between frames, so a frame never shows half of a patch.
A live element is never trimmed to its ink: the adapter pads it with
`content_padding` black columns either side and pins its width, and a redraw
at any other width is refused (it shows the next time the plugin comes round).
Records keep **absolute** strip columns -- the strip column plus everything
trimmed off the front since the strip was composed -- so a trim moves one
origin rather than every record. Nothing on screen is ever moved, inserted or
resized; a game added to a slate appears on the plugin's next turn.
When the geometry differs (a game added or dropped, a card that grew), the
visible part cannot change without a jump. Only the cards not yet on screen
are replaced, and only if the geometry up to that point is unchanged. Otherwise
the segment keeps its snapshot until it has scrolled off.
### Why not a SegmentStrip
### Avoiding wasted work
The proposal here was to replace the single strip with a list of segments.
In-place patching of the single strip meets every goal without that: the
patch is O(element) and the strip layout never changes. What a segment list
would still buy is cheaper extensions, and most of that came from making the
strip's PIL copy lazy instead (`ScrollHelper.cached_image`: an extension used
to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of
`frame_soak.py`'s "after work" table says whether the rest is worth it.
- **Change detection.** `run_scheduled_updates_with_changes()` names a plugin
whenever its `update()` ran, not when its data changed. On hdpi
`clock-simple` and `ledmatrix-music` are named on every 4-second tick. A
redraw whose pixels hash the same as the segment's is discarded without a
swap.
- **Redraw on real updates only.** Vegas makes no API calls. Each plugin
fetches on its own schedule, and a redraw is triggered only when the
plugin's `update()` has run since its segment was drawn. On hdpi live
football, baseball and hockey poll every 30 s (live odds every 60 s,
everything else hourly), so a live sports card is redrawn once per poll.
- **Floor.** A plugin is redrawn at most once per
`vegas_scroll.refresh_min_interval` (proposed 10 s), and never while its
previous redraw is still running. The floor never holds back a sports card
polling every 30 s. It exists for chatty plugins: `clock-simple` updates
every second and `ledmatrix-music` polls every 2 s.
- **One worker.** Redraws go through the same background worker as prefetch,
one plugin at a time at `nice 10`, under the plugin's lock.
### When it is off
Data freshness is still bounded by each plugin's own fetch interval (how often
it polls live scores). Drawing faster cannot beat the data source.
### The strip becomes a list of segments
All three need the strip to be replaceable by segment. Today it is one
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
`append_content()` rebuilds the whole thing on the render thread for every
appended block. That is also a pause source.
Proposed `SegmentStrip`, used by Vegas in place of the single image:
- an ordered list of segments: plugin id, card boundaries, a pixel array, the
render time, and the plugin data version it was drawn from, plus its
x-offset in the strip;
- `visible(x, width)` assembles the viewport by slicing across at most a few
segments: the same ~100 KB copy per frame that slicing the single image
costs today;
- append and trim become O(block) list operations, not a copy of the strip;
- replace swaps one list entry and shifts the offsets of the segments after it
(dozens at most). A same-geometry patch copies pixels into the existing array.
Every mutation is prepared off the render thread and applied by the render
thread at a frame boundary, so the strip the render loop reads is never
half-changed.
### Multi-display sync
The follower renders from its own copy of the strip, offset from the leader's
scroll position. Today the leader sends that copy whole, and only in
`start_new_cycle()` (`send_scroll_image`), plus the scroll position every
frame. Continuous scroll, the default, extends and trims the strip without
starting a new cycle, and nothing sends those changes. From reading the code,
the follower therefore probably falls out of step after the first extension
already, before any of this design. That is untested; it needs a two-Pi rig.
With a segment strip, keeping the follower identical becomes **replaying the
leader's operations**:
- Every strip mutation (append, trim, replace, patch) is one operation in
strip coordinates. The leader applies it and sends the same operation to the
follower over the existing TCP channel. Segments are small: a card is ~29 KB
raw and compresses well.
- Operations on off-screen segments apply on arrival. A patch to a segment
that is on either panel carries an *apply at scroll position X* stamp a
couple of hundred milliseconds ahead. Both sides apply it when their scroll
position passes X, so both panels change on the same frame, within the
existing position-sync jitter.
- Each operation carries a sequence number. A follower that sees a gap (a
reconnect, a dropped message) asks for a full snapshot, which is today's
`send_scroll_image` path.
That also fixes the probable continuous-mode gap as a side effect, since
appends and trims become operations too. Until it is in place, fresh-content
updates are disabled while sync is active.
- `display.vegas_scroll.live_refresh: false` (the kill switch; also in the
web UI), or `vegas_live: false` in one plugin's section.
- Always under multi-display sync: the follower mirrors whole strips only, so
a patch would never reach it. (Continuous-mode sync has a separate problem:
the follower is not sent extensions or trims at all.)
- In swap mode (`continuous_scroll: false`) and with `offscreen_prefetch:
false`.
- For plugins without the hook, which are drawn and placed exactly as before,
and on the paths that fetch without the plugin's lock (the first strip of a
run, the render thread's fallback fetch), which use `get_vegas_content()`.
## Risks, and what was checked
@@ -328,11 +294,10 @@ updates are disabled while sync is active.
source of the single-refresh late frames. Holding frames for two refreshes
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
step.
- **Live refreshes pushed from `update()`.** Some sports plugins call
`display()` and `update_display()` from inside `update()`, which runs on the
update worker and can push to the panel mid-Vegas. That is a separate
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
while Vegas owns the panel), but it is out of scope here.
- **Multi-display sync in continuous mode.** The follower is sent the whole
strip only at a new cycle and on connect, never the extensions and trims of
continuous mode, so it drifts from the leader after the first extension.
Live elements stay off under sync for that reason.
## Test plan
@@ -348,14 +313,15 @@ updates are disabled while sync is active.
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
300 ms. The Vegas render loop never goes a frame without presenting (frame
timing recorder: zero freezes).
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
matches slicing one concatenated image, pixel for pixel. Append, trim,
replace-ahead and same-geometry patch each leave every other column
unchanged. A geometry-changing patch of a visible segment is refused.
- **Freshness:** a stub sports plugin whose score changes every second. The
score on screen is never older than `lead / speed` plus the plugin's fetch
interval. A visible card's digits change without the frame-timing recorder
seeing a late frame. An unchanged redraw is discarded.
- **Live elements** (`test/test_vegas_live_*.py`,
`test/test_vegas_elements_*.py`, `test/test_scroll_helper_patch.py`): every
record points at exactly its element's pixels through any sequence of
compose, extend and trim; a patch changes only its element's columns (a
property test against a twin strip that is never patched); the render
thread's apply takes no lock and draws nothing; the worker's priorities,
floors, backoff and hand-over; and, end to end on the emulator with the stub
plugin (`test/fixtures/plugins/vegas-live-stub`), an update changes a card
already in the strip and an animated element moves with no update at all.
- **Hardware:** an hdpi soak, A/B against the #628 build, alternating order.
Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late
rate below 0.66%. Plus, for freshness: log each segment's age when it enters
@@ -363,26 +329,26 @@ updates are disabled while sync is active.
## Rollout
Three changes, each soaked on hdpi before the next:
1. **Offscreen rendering** (shipped): `offscreen()`, the adapter on the
prefetch thread, and the plugin lock. Removed the render-thread pauses.
2. **Measurement and the lazy strip image:** late frames attributed to the
render-thread work before them (`FrameTimingRecorder.note_op`, the "after
work" table), and extensions no longer rebuilding the strip's PIL copy.
3. **Live elements:** the plugin API, the records, the worker and in-place
patches, with the sports scoreboards and the flight map adopting it.
4. **Live games in the ticker by default:** `live_in_ticker` true, so a live
game's cards update in the marquee instead of the full-screen scoreboard
replacing it; existing configs are switched once (`ConfigManager`).
1. **Offscreen rendering:** `offscreen()`, the adapter on the prefetch thread,
and the plugin lock. Removes the render-thread pauses.
2. **`SegmentStrip`:** Vegas's strip becomes a list of segments. Removes the
whole-strip copy on append. No visible behaviour change.
3. **Fresh content:** refresh at the gate, replace ahead, patch on screen,
with change detection and the rate limit.
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores today's
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
`true`) turns off step 3. Keep both for one release, then delete the old paths.
`true`) turns live elements off. Keep both for one release, then delete the
old paths.
## Open questions
1. Keep the kill switch, or ship without one?
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
or wait longer?
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
live sports are redrawn once per 30 s poll regardless.
4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is
proposed as part of the segment strip (step 2), with fresh content
disabled under sync until it has been verified on real hardware.
1. Multi-display sync: is there a two-Pi rig to test on? Replaying strip
operations to the follower (append, trim, patch, in absolute columns) would
fix continuous-mode sync and let live elements run under it.
2. Is the `extend` cost worth a segment list after the lazy image? The soak's
"after work" table answers it per rig.
+2
View File
@@ -29,6 +29,8 @@ in again (services pick them up on restart).
| `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` | |
| `/run/ledmatrix/` | `root` | `755` | tmpfs; `RuntimeDirectory=` in `ledmatrix.service`, removed when the display stops |
| `/run/ledmatrix/control.sock` | `root` : cache directory's group (`ledmatrix`) | `660` | The display's control socket; only root and that group can connect. See [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md#security-model) |
| `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` | |
+255 -130
View File
@@ -14,6 +14,7 @@ Complete API reference for plugin developers. This document describes all method
- [Display Manager](#display-manager)
- [Cache Manager](#cache-manager)
- [Plugin Manager](#plugin-manager)
- [Fetching data](#fetching-data)
- [Deprecated APIs](#deprecated-apis)
---
@@ -149,15 +150,27 @@ Clean up resources when plugin is unloaded. Override to close connections, stop
#### `on_config_change(new_config: Dict[str, Any]) -> None`
Called after plugin configuration is updated via web API.
Called after the plugin's section of `config.json` changes -- a save in the
web UI, say. Every lifecycle hook runs in the display process, which is the
only process that runs plugins: the web interface writes `config.json`, and
the display's config watcher calls this with the prepared section. See
[ARCHITECTURE.md](ARCHITECTURE.md#web-and-display-processes-who-runs-plugins).
In the display service it runs on the config watcher thread while holding
the plugin's lock, so it never overlaps your `update()` or `display()`. If
the plugin stays busy for more than 5 seconds, the change is applied later
from the update thread: as soon as the plugin is free, and before its next
`update()` at the latest.
#### `on_enable() -> None`
Called when plugin is enabled.
Called when the display loads the plugin enabled: at startup, or when it is
switched on in the web UI.
#### `on_disable() -> None`
Called when plugin is disabled.
Called when the display unloads the plugin, e.g. when it is switched off in
the web UI.
#### `get_update_interval() -> Optional[float]`
@@ -296,9 +309,9 @@ the core then falls back to its own live-content check — so a plugin whose
weight calculation is broken still gets `live_weight` for a game that really
is live, rather than being demoted to 1.
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
default (`false`) live content preempts Vegas entirely and there is no ticker
to be weighted within. See
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
3.8.0). With it off live content preempts Vegas entirely and there is no
ticker to be weighted within. See
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
### Vegas scroll hooks
@@ -308,6 +321,58 @@ rotating one at a time. Plugins control how their content appears via
these hooks. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for the user
side of Vegas mode.
#### Vegas participation
Each plugin takes part in Vegas mode in one of three ways:
| Participation | What Vegas does |
|---|---|
| `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else |
| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes |
| `'exclude'` | The plugin is left out of Vegas mode |
Declare the plugin's default in `manifest.json`:
```json
{
"id": "my-alerts",
"vegas_participation": "pause"
}
```
The user can override it per plugin with `vegas_participation` in that
plugin's config section (it is one of the core-owned properties, see
[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)).
Vegas resolves it in this order:
1. the user's `vegas_participation` config value;
2. the plugin's `get_vegas_participation()` — the default implementation
reads the manifest's `vegas_participation`, then derives a value from
the legacy hooks below;
3. derived from the legacy hooks: `get_vegas_display_mode()` returning
`VegasDisplayMode.STATIC` → `'pause'`; otherwise
`get_vegas_content_type()` returning `'none'` → `'exclude'`; everything
else → `'scroll'`.
Step 3 is exactly what Vegas did before participation existed, so a plugin
that declares nothing behaves as it always has. Manifest
`vegas_participation` is new in core 3.8.0; older cores ignore it and use
the legacy hooks.
#### `get_vegas_participation() -> str`
Returns `'scroll'`, `'pause'` or `'exclude'`. Override it only when the
answer depends on state — pause only while an alert is live, exclude while
there is nothing to show; for a fixed answer use the manifest. Vegas applies
the user's config value before calling an override, so an override does not
need to check it. A value that is not one of the three is ignored with a log
line and the legacy hooks decide.
```python
def get_vegas_participation(self):
return 'pause' if self._alert_is_live() else 'scroll'
```
#### `get_vegas_content() -> Optional[PIL.Image | List[PIL.Image] | None]`
Return content to inject into the scroll. Multi-item plugins (sports,
@@ -315,26 +380,113 @@ odds, news) should return a *list* of PIL Images so each item scrolls
independently. Static plugins (clock, weather) can return a single image.
Returning `None` falls back to capturing whatever `display()` produces.
#### `get_vegas_content_type() -> str`
#### `get_vegas_render_width() -> int`
`'multi'`, `'static'`, or `'none'`. Affects how Vegas mode treats the
plugin. Default `'static'`.
The width Vegas wants this plugin's content to occupy, from the plugin's
`vegas_width_pct` config value or the global
`display.vegas_scroll.render_width_pct`. Vegas also narrows
`display_manager` while it asks for content, so a plugin that sizes itself
from `display_manager.width` does not need to read this.
#### `get_vegas_display_mode() -> VegasDisplayMode`
#### Live Vegas elements
Returns one of `VegasDisplayMode.SCROLL`, `FIXED_SEGMENT`, or `STATIC`.
Read from `config["vegas_mode"]` or override directly.
*New in core 3.8.0.* Content from `get_vegas_content()` is baked into the
ticker's strip when the plugin's turn is prefetched, so a score drawn then
scrolls past with that score however many goals are scored while it crosses
the panel. A plugin that returns **live elements** instead gets them updated
in place: after its `update()` the ticker asks again, compares each element
with what the strip holds, and swaps the changed ones in between two frames
-- on screen included -- without anything next to them moving.
#### `get_supported_vegas_modes() -> List[VegasDisplayMode]`
```python
try:
from src.plugin_system.vegas_elements import VegasElement
except ImportError: # core older than 3.8.0: the hook is never called
VegasElement = None
The set of Vegas modes this plugin can render. Used by the UI to populate
the mode selector for this plugin.
class MyScoreboard(BasePlugin):
def get_vegas_elements(self):
if VegasElement is None:
return None
return [VegasElement(key=f"game:{g['id']}",
image=self._card(g), # cache by fingerprint
version=self._fingerprint(g)) # changes iff pixels would
for g in self.games]
```
#### `get_vegas_segment_width() -> Optional[int]`
`VegasElement(key, image, version=None, live=True, refresh_hz=0.0)`:
For `FIXED_SEGMENT` plugins, the number of *panels* the segment
occupies in the scroll (pixel width = panels × `single_panel_width`,
from `display.hardware.cols`). `None` uses the default of 1 panel.
| Field | Meaning |
|---|---|
| `key` | Names the element across redraws; unique in the list, stable for the same logical item (`"game:nfl:401547417"`, `"map"`). |
| `image` | The element now, at the display's height. A live element's **width must not depend on its data**: a redraw at another width is never swapped in (it appears the next time the plugin comes round), because nothing on screen may move. |
| `version` | Anything hashable that changes exactly when the pixels would. Handed back with the **same image object** as last time, it lets the ticker skip converting the element; a new image is always converted and compared by its pixels, so a redraw for new settings is never missed. `None` means "compare pixels". |
| `live` | `False` places it as plain content (trimmed, never refreshed): separators, decoration. |
| `refresh_hz` | For content that changes with **time** rather than data (an aircraft moving between position reports): the ticker calls `redraw_vegas_element()` about this often while the element is on or near the screen, capped by `vegas_scroll.live_max_hz` and at 1 Hz without the rebuilt rgbmatrix binding. |
**`get_vegas_elements() -> Optional[List[VegasElement]]`** — called on the
ticker's background thread under the plugin's lock (never while `update()`
runs), on a canvas of its own and told its render width, exactly like
`get_vegas_content()`. It is called after every `update()` while any of the
plugin's elements is on or ahead of the screen, so it must be cheap when
nothing changed (cache images by version), idempotent, and must not fetch.
Return `None` to use `get_vegas_content()`, which a plugin must keep working
for older cores and for the paths that do not ask for elements (the ticker's
first strip, multi-display sync, the `live_refresh` switch).
**`redraw_vegas_element(key, width, height, at) -> Optional[PIL.Image]`** —
only for elements with `refresh_hz`. Called **without** the plugin's lock,
possibly while `update()` runs, so read only state `update()` replaces in one
assignment (an immutable snapshot), never state it mutates in place. `at` is
the `time.monotonic()` the pixels are expected on the panel: draw the element
as it should look then. Return exactly `width` x `height`, or `None` to skip
the tick.
**`notify_vegas_data_changed()`** — data that arrives outside `update()` (a
background thread, a push callback) calls this so the ticker redraws without
waiting for the next `update()`. Safe from any thread.
Live elements are never trimmed to their ink: the ticker pads each with
`content_padding` black columns either side, the margin trimming would have
left. A single element wider than the plugin's width budget
(`vegas_max_width_screens`, not counting that padding) is cropped like any
other content and scrolls by as plain, no longer live. The user can turn them off per plugin with `vegas_live: false` (a
core-owned property) or for the whole ticker with
`display.vegas_scroll.live_refresh: false`; they are always off under
multi-display sync.
`scripts/check_plugin.py` checks the contract for any plugin that implements
the hook (unique keys, height, width stable with no new data, redraw size,
slow calls) and prints a `vegas elements` row; the checks are in
`src/plugin_system/testing/vegas.py`. `test/fixtures/plugins/vegas-live-stub`
is a small working example.
#### Legacy: `get_vegas_content_type()` and `get_vegas_display_mode()`
Superseded by participation, and still read to derive it when neither the
user nor the manifest declares one (step 3 above). Only two answers ever
mattered: `get_vegas_content_type()` returning `'none'`, and
`get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`.
- `get_vegas_content_type()` returns `'multi'`, `'static'` or `'none'`
(default `'static'`).
- `get_vegas_display_mode()` returns a `VegasDisplayMode` member (not a
string — the string `'static'` never paused anything). The default reads
the plugin's `vegas_mode` config value (`"scroll"`, `"fixed"` or
`"static"`), else maps content type `'multi'` to `SCROLL` and anything
else to `FIXED_SEGMENT`.
`SCROLL` and `FIXED_SEGMENT` (and `vegas_mode` `"scroll"` and `"fixed"`)
have always behaved identically: both scroll. The distinction is deprecated
and goes away in 3.9.0 — see [Deprecated APIs](#deprecated-apis).
#### Deprecated: `get_supported_vegas_modes()` and `get_vegas_segment_width()`
Never read by core, and removed in 3.9.0: calling the `BasePlugin`
implementation logs a deprecation warning. A plugin's own override keeps
working for the plugin itself. `get_vegas_segment_width()` read the
`vegas_panel_count` config value, which has never affected Vegas — a card's
width comes from `get_vegas_content()` and `vegas_width_pct`.
> The full source for `BasePlugin` lives in
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
@@ -477,18 +629,6 @@ self.display_manager.update_display()
This is the canonical way to render arbitrary images.
### Weather Icons (deprecated)
> Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
- `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
For plugins that implement scrolling content, use these methods to coordinate with the display system.
@@ -579,20 +719,6 @@ Process any deferred updates if not currently scrolling. Called automatically by
**Note**: Plugins typically don't need to call this directly.
#### `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
**Example**:
```python
stats = self.display_manager.get_scrolling_stats()
self.logger.debug(f"Scrolling: {stats['is_scrolling']}, Deferred: {stats['deferred_count']}")
```
### Available Fonts
The Display Manager provides several pre-loaded fonts:
@@ -722,27 +848,6 @@ Get data with automatic strategy detection from cache key.
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**:
- `key` (str): Cache key
- `sport_key` (str, optional): Sport identifier (e.g., 'nhl', 'nba') for live interval lookup
**Returns**: Cached data, or `None` if not found or stale
**Example**:
```python
# Uses sport-specific live_update_interval from config
games = self.cache_manager.get_background_cached_data(
"nhl_games",
sport_key="nhl"
)
```
### Strategy Methods
#### `get_cache_strategy(data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]`
@@ -761,23 +866,6 @@ strategy = self.cache_manager.get_cache_strategy("sports_live", sport_key="nhl")
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**:
- `sport_key` (str): Sport identifier (e.g., 'nhl', 'nba')
**Returns**: Live update interval in seconds
**Example**:
```python
interval = self.cache_manager.get_sport_live_interval("nhl")
# Returns configured live_update_interval for NHL
```
#### `get_data_type_from_key(key: str) -> str`
Extract data type from cache key to determine appropriate cache strategy.
@@ -787,17 +875,6 @@ Extract data type from cache key to determine appropriate cache strategy.
**Returns**: Inferred data type string
#### `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**:
- `key` (str): Cache key
**Returns**: Sport identifier, or `None` if not found
### Utility Methods
#### `clear_cache(key: Optional[str] = None) -> None`
@@ -835,30 +912,6 @@ for file_info in files:
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
```
### 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.)
**Example**:
```python
metrics = self.cache_manager.get_cache_metrics()
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.)
---
## Plugin Manager
@@ -897,14 +950,6 @@ for plugin_id, plugin in all_plugins.items():
self.logger.info(f"Plugin {plugin_id} is loaded")
```
#### `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
#### `get_plugin_info(plugin_id: str) -> Optional[Dict[str, Any]]`
Get plugin information including manifest and runtime info.
@@ -986,6 +1031,63 @@ if weather is not None and weather.enabled:
---
## Fetching data
Use the core helpers for HTTP rather than a `requests.Session` of your own:
`APIHelper` (`from src.common import APIHelper`) for JSON APIs, and
`fetch_espn_scoreboard()` (`src.common.espn_dates`) or
`BackgroundDataService` for ESPN scoreboards. Since the release after 3.7.0
these go through the core **fetch service** (`src/common/fetch_service.py`),
so a plugin that uses them gets the following with no code change. Return
values, exceptions and retries are what they were.
- **Shared connections.** Core sessions with the same retry policy share one
connection pool per host, instead of one pool per helper.
- **Merged requests.** Identical GETs in flight at the same time (same URL
and query, headers, timeout and retry policy) go to the network once, and
every caller gets its own copy of the response, or the same exception.
- **Host budgets.** A host can have a token-bucket budget. A request past it
waits for a token, but never longer than `max_wait_seconds` (2 s by
default). Only ESPN hosts have one by default (20 requests a second, burst
200), which normal use never reaches.
- **Conditional GET.** When a server sends `ETag` or `Last-Modified`, the
next identical request revalidates, and a `304 Not Modified` comes back to
your code as the original `200` with its body. ESPN currently sends
neither, so this does nothing there.
- **Counters.** Requests, merged requests, bytes, 304s, errors and time spent
waiting are counted per plugin and per host, and published for the web UI
at `GET /api/v3/plugins/fetch-stats` (see
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#get-fetch-statistics)). A
request is counted against your plugin when it runs inside your
`update()`/`display()`, your constructor or `on_enable()`, or anywhere in
code under your plugin's directory, including threads you start.
What is not covered yet: requests a plugin makes with its own `requests.get()`
or `Session.get()` calls. They work as before but are invisible to the
budgets and counters.
The settings live in `config.json` under `fetch_service`, read when the
display starts and on a config reload:
```json
"fetch_service": {
"enabled": true,
"max_wait_seconds": 2,
"rate_limits": {
"*.espn.com": {"per_second": 20, "burst": 200},
"api.example.com": {"per_second": 1, "burst": 5}
}
}
```
`rate_limits` keys are a host or a `*.domain` pattern (which also matches
the bare domain); `"per_second": 0` removes a budget. `"enabled": false`
turns the whole service into a plain `session.get()`. Two further switches,
`"single_flight": false` and `"conditional_get": false`, turn off merging and
revalidation.
---
## Best Practices
### Caching
@@ -1070,10 +1172,19 @@ if weather is not None and weather.enabled:
## Deprecated APIs
These still work in 3.6 but log a warning the first time they are called
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.7.0**.
Nothing in core, the official plugins or the third-party plugins in the
registry calls them.
A deprecated method still works but logs a warning the first time it is
called (`journalctl -u ledmatrix` shows which one), until the release that
removes it. [DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan
behind each removal: which of the deprecated methods the official plugins,
the registry's third-party plugins and core still call or override. Only
methods that scan reports unused are removed; the rest stay until their
callers migrate.
### Removed in 3.8.0
Deprecated in 3.5.0 with a warning on first call, and gone in 3.8.0:
the scan found no caller in any official or third-party plugin. Calling one
now raises `AttributeError`.
| Object | Methods | Instead |
|---|---|---|
@@ -1086,3 +1197,17 @@ registry calls them.
| `font_manager` | `set_override`, `remove_override`, `get_overrides`, `add_font`, `remove_font`, `validate_font`, `get_size_tokens`, `get_performance_stats`, `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, `unregister_plugin_fonts` | no replacement |
| `plugin_manager` | `get_enabled_plugins` | check `enabled` on the entries in `plugin_manager.plugins` |
### Removed in 3.9.0
The Vegas APIs that described a fixed-width segment, which Vegas never
implemented. Vegas participation (`'scroll'`, `'pause'`, `'exclude'`, see
[Vegas scroll hooks](#vegas-scroll-hooks)) replaces them. Calling one of the
methods, or setting `vegas_panel_count`, logs a warning once per process.
No official plugin calls them; calendar, olympics and blackjack override
`get_supported_vegas_modes()`, which keeps working for the plugin itself.
| What | Instead |
|---|---|
| `BasePlugin.get_supported_vegas_modes()` | declare `vegas_participation` in the manifest |
| `BasePlugin.get_vegas_segment_width()` and the `vegas_panel_count` config key | nothing: a card's width comes from `get_vegas_content()` and `vegas_width_pct` |
| `VegasDisplayMode.SCROLL` vs `FIXED_SEGMENT` (`vegas_mode` `"scroll"` vs `"fixed"`) | `'scroll'` participation; the two always behaved the same |
+25
View File
@@ -33,6 +33,31 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`):
- Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which
validate the values themselves and ignore a bad one with a log line
5. **`vegas_participation`** (string enum: `"scroll"`, `"pause"`,
`"exclude"`; no default)
- Description: how this plugin takes part in Vegas mode — its content
scrolls by, the scroll pauses for its turn and shows it full screen, or
it is left out
- Overrides the plugin's own default (its manifest's
`vegas_participation`, else what its legacy Vegas hooks say); unset
means "use the plugin's default"
- Deliberately has no default: one would be written into every plugin's
config and override what each plugin declares
- Read by `resolve_vegas_participation()` in
`src/plugin_system/base_plugin.py`; see
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)
6. **`vegas_live`** (boolean; no default, unset means on)
- Description: for a plugin with live Vegas elements (it implements
`get_vegas_elements()`), whether the ticker changes what is already
scrolling when the plugin's data changes. `false` shows each card as it
was when drawn, as before live elements existed
- Ignored by plugins without live elements, and whenever live elements
are off for the whole ticker (`display.vegas_scroll.live_refresh`)
- Read by `PluginAdapter.is_live_capable()` in
`src/vegas_mode/plugin_adapter.py`; see
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)
`skin` and `skin_options` were core properties until the skin system was
removed. A plugin config saved with them still loads and saves; the keys are
dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`).
+1 -4
View File
@@ -519,15 +519,12 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
- `draw_text()` - Text rendering. For images, paste directly onto
`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()` - deprecated, removed in 3.7.0 — use `get()`
**Plugin Manager** (`self.plugin_manager`):
- `get_plugin()`, `get_all_plugins()` - Access other plugins
@@ -535,7 +532,7 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
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.
table for everything removed in 3.8.0.
## 3rd Party Plugin Development
+2
View File
@@ -72,6 +72,8 @@ Going deeper:
## Contributing to LEDMatrix itself
- [ARCHITECTURE.md](ARCHITECTURE.md) — processes, display loop, plugin system, web UI; where to start reading
- [WEB_FRONTEND_ARCHITECTURE.md](WEB_FRONTEND_ARCHITECTURE.md) — the web UI's ES modules, page lifecycle and form model, and the page-by-page migration to them
- [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md) — the display's control socket: protocol, security model, stage plan
- [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
+415 -30
View File
@@ -18,6 +18,39 @@ top level instead of under `data` (install-from-url, registry-from-url, the
auth endpoints, upload endpoints, `system/git-info`, `system/check-update`),
the entry below says so.
**Cross-site requests are refused.** A `POST`, `PUT`, `PATCH` or `DELETE`
carrying an `Origin` header (or, without one, a `Referer`) that is not the
host the request was sent to gets `403` with `"error_code":
"CROSS_SITE_REQUEST"`; so does `Origin: null`. This stops other websites from
driving the Pi through a LAN user's browser. Scripts, curl, Home Assistant and
the MQTT bridge send neither header and are unaffected. A browser page on
another origin (a dashboard you host elsewhere, say) can no longer call the
API; call it server-side instead. Behind a reverse proxy, pass the original
`Host` through, port included (nginx: `proxy_set_header Host $http_host;`;
`$host` drops the port) -- `X-Forwarded-Host` is not read.
**Authentication (optional, off by default).** With no web password set,
nothing below needs credentials. Once one is set (General > Security, or
[`POST /auth/password`](#web-login-and-api-tokens)), every route needs a login
session or an API token:
```bash
curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current
```
Without either, an API route answers `401` with
`{"status": "error", "error_code": "AUTH_REQUIRED", "message": ...}`, a
`WWW-Authenticate: Bearer realm="LEDMatrix"` header and the login page's URL
in `X-LEDMatrix-Login`; an unknown or revoked token gets `"error_code":
"INVALID_TOKEN"`. Browser page loads are redirected to `/login` instead, and
HTMX requests get `401` with `HX-Redirect: /login?...`. Never asked for
credentials: requests from the Pi itself (loopback, with no `X-Forwarded-For`,
`X-Real-IP`, `Forwarded` or `X-Forwarded-Host` header), `/static/*`, the
captive-portal probe URLs, `/login`, `/api/v3/health` (status only, see
[Health Check](#health-check)), and -- only while the Pi is in access-point
mode -- `/setup`, `GET /wifi/status`, `GET /wifi/scan` and
`POST /wifi/connect`.
## Table of Contents
- [Configuration](#configuration)
@@ -35,6 +68,7 @@ the entry below says so.
- [Health and Status](#health-and-status)
- [Schedule (dim/power)](#schedule-dimpower)
- [Integrations](#integrations)
- [Web login and API tokens](#web-login-and-api-tokens)
- [Plugin-specific endpoints](#plugin-specific-endpoints)
- [Starlark Apps](#starlark-apps)
@@ -120,10 +154,25 @@ there an unchecked checkbox — which the browser omits — is saved as
```json
{
"status": "success",
"message": "Configuration saved successfully"
"message": "Configuration saved successfully",
"restart_required": true
}
```
`restart_required` is always true here: display hardware, rotation,
durations and general settings take effect when the display restarts, and
the web UI shows its restart banner on the flag. (Plugin sections saved
through this route reach the running plugin live, like
`POST /plugins/config`.)
A saved `brightness` is the exception: it reaches the panel without a
restart. The route also sends it to the running display over the control
socket (`brightness.set`), which puts it on the panel at once, and the
response adds `"brightness_transport": "socket"`. Otherwise it is
`"config"`, with `brightness_socket_error` giving the reason, and the
display's config watcher applies the saved value within a few seconds, as
before.
Invalid values (e.g. an out-of-range `target_fps`, a hardware option the
Raspberry Pi 5 driver cannot use) are rejected with `400` and nothing is
saved.
@@ -213,7 +262,10 @@ times `07:00`-`23:00`. At least one day must be enabled.
Retrieve `config/config_secrets.json` with every set value replaced by eight
bullet characters (`"••••••••"`). Empty values and `YOUR_*` placeholders
are returned as-is, so a client can tell "set" from "not set".
are returned as-is, so a client can tell "set" from "not set". The
`web_auth` section (the web login's password hash, API-token hashes and
cookie key) is left out entirely; [Get Main Configuration](#get-main-configuration)
leaves it out too.
**Response**:
```json
@@ -238,7 +290,8 @@ Replace `config/config.json` with the JSON body (advanced use only).
Save the secrets file (advanced use only). Masked values (`"••••••••"`)
and blank strings in the body are dropped, and the rest is merged onto the
stored secrets, so posting back the GET response unchanged changes nothing.
A secret cannot be cleared by blanking it here.
A secret cannot be cleared by blanking it here. A `web_auth` key in the body
is ignored; the stored login settings are kept.
---
@@ -402,13 +455,24 @@ Request a specific plugin to display on-demand.
"mode": "nfl_live",
"duration": 45,
"pinned": true,
"service": { "active": true, "returncode": 0, "stdout": "", "stderr": "" }
"service": { "active": true, "returncode": 0, "stdout": "", "stderr": "" },
"transport": "socket"
}
}
```
`service` is `null` when `start_service` is false.
`transport` says how the request reached the display: `"socket"` means the
display's control socket acknowledged it (it is queued for the render thread,
which wakes for it and applies it within a frame; see
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
written to the cache mailbox the display polls, as before the socket existed.
With `"mailbox"`, `socket_error` gives the reason the socket was not used
(`no_socket` when the display is stopped or predates the socket, `timeout`,
`refused`, `busy`, ...). Either way the request is applied the same way;
`request_id` is the same id in both.
### Stop On-Demand Display
**POST** `/api/v3/display/on-demand/stop`
@@ -431,11 +495,14 @@ Stop the current on-demand display.
"status": "success",
"data": {
"request_id": "uuid-here",
"service": null
"service": null,
"transport": "socket"
}
}
```
`transport` and `socket_error` are as for start.
---
## Plugins
@@ -465,21 +532,65 @@ List all installed plugins with their status and metadata.
"enabled": true,
"verified": true,
"loaded": true,
"state": "loaded",
"state": "enabled",
"error_info": null,
"loaded_version": "1.2.3",
"loaded_at": 1790000000.0,
"last_updated": "2025-01-15T10:30:00Z",
"last_commit": "abc1234",
"last_commit_message": "feat: Add live game updates",
"branch": "main",
"web_ui_actions": [],
"vegas_mode": null,
"vegas_content_type": null
"vegas_content_type": null,
"vegas_participation": "scroll",
"vegas_participation_source": "manifest"
}
]
],
"runtime": {
"status": "live",
"published_at": 1790000030.0,
"age_seconds": 12.4,
"stale_after": 180.0
}
}
}
```
Metadata comes from each plugin's files on disk; `enabled` is the plugin's
`enabled` flag in `config.json` (missing means disabled, as the display
reads it). `vegas_mode` is the plugin's configured `vegas_mode`, or `null`.
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` come from
the runtime snapshot the display publishes (the web process runs no plugin
code). `state` is the display's lifecycle state (`loaded` while loading,
`enabled`, `disabled`, `error`, `unloaded`); `error_info` is `null` or
`{"type", "message", "at", "recoverable"}`, with the message redacted and at
most 200 characters (the full error is at `/errors/*`). `loaded_version` is
the version the display loaded, which differs from `version` after an update
until the display restarts. A plugin a live snapshot does not list is
`loaded: false`, `state: "unloaded"`.
`runtime.status` says whether to believe them: `live` (fresh snapshot from
a running display), `stale` (not refreshed within `stale_after` seconds: the
display is hung or died), `stopped` (the display shut down) or `unknown`
(nothing published yet). Unless it is `live`, every one of those fields is
`null`. Health and metrics are at [`/plugins/health`](#get-plugin-health)
and `/plugins/metrics`.
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
`"pause"` or `"exclude"` (see
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)),
and `vegas_participation_source` says where it came from. The web reads it
the way the display resolves it, as far as files can tell: the user's own
`vegas_participation` setting (`"config"`), else the manifest's declared
`vegas_participation` (`"manifest"`). Past those the display derives it from
the plugin's code -- a `get_vegas_participation()` override or the legacy
Vegas hooks -- which the web process never runs, so `vegas_participation`
is `null` and the source is `"runtime"`. A plugin that overrides
`get_vegas_participation()` decides at run time and can differ from its
manifest's declaration. `vegas_content_type` is always `null`.
### Get Plugin Configuration
**GET** `/api/v3/plugins/config?plugin_id=<plugin_id>`
@@ -639,7 +750,19 @@ Install a plugin from the plugin store.
```
When the operation queue is unavailable the install runs synchronously and
the response has only a `message`.
the response has only a `message` and the restart fields below.
The finished operation's `result` (from `/plugins/operation/<operation_id>`)
carries `restart_required`: true when the plugin is already enabled in
`config.json`, because the running display does not load newly installed
files by itself; `restart_message` then holds the restart banner's wording.
A plugin that is not enabled needs no restart: enabling it loads it.
A plugin whose registry entry (or downloaded manifest) needs a newer
LEDMatrix is refused: the synchronous install answers `409` with a message
such as `Failed to install plugin x: X requires LEDMatrix 3.8.0 or newer…`,
and a queued one fails with that message. Nothing already installed is
changed.
### Uninstall Plugin
@@ -664,6 +787,11 @@ Remove an installed plugin.
}
```
The finished operation's `result` carries `restart_required`. Removing the
plugin's config (the default) lets the display unload it by itself, so it is
false; with `preserve_config: true` an enabled plugin keeps running until
the display restarts, and it is true.
### Update Plugin
**POST** `/api/v3/plugins/update`
@@ -684,11 +812,42 @@ Update a plugin to the latest version. Runs synchronously.
"message": "Plugin football-scoreboard updated ...",
"data": {
"last_updated": "2025-01-15T10:30:00Z",
"commit": "abc1234..."
}
"commit": "abc1234...",
"update_status": "updated"
},
"restart_required": true,
"restart_message": "Plugin updated — restart the display to run the new version"
}
```
`update_status` is `updated`, `up_to_date` or `local_only`.
When the plugin changed and is enabled, the route asks the running display
to reload it over the control socket (`plugin.reload`, see
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)). Once the new code is
running, the answer is:
```json
{
"status": "success",
"message": "Plugin football-scoreboard updated to version 2.1.0; the display is running the new version",
"restart_required": false,
"reloaded": true,
"reloaded_version": "2.1.0"
}
```
If the display could not reload it, `restart_required` is true (the running
display keeps the code it loaded until it restarts) and `reload_error` says
why: `no_socket` (the display is stopped or predates the socket),
`unknown_command` (a display older than this command), `not_loaded`,
`failed` (the new version did not load; it is out of the rotation),
`pending` (not done within 10 s; it will still be reloaded), or another
transport reason.
An update this core cannot run answers `409` with `Plugin update refused:`
and the reason; the installed version is left as it was.
### Install Plugin from URL
**POST** `/api/v3/plugins/install-from-url`
@@ -717,10 +876,13 @@ Install a plugin directly from a GitHub repository URL. Runs synchronously.
"message": "Plugin my-plugin installed successfully",
"plugin_id": "my-plugin",
"name": "My Plugin",
"branch": "main"
"branch": "main",
"restart_required": false
}
```
`restart_required` follows the same rule as `/plugins/install`.
### Load Registry from URL
**POST** `/api/v3/plugins/registry-from-url`
@@ -848,6 +1010,63 @@ Metrics for one plugin; `data` has the same fields as one entry above.
Reset metrics for a plugin.
### Get Fetch Statistics
**GET** `/api/v3/plugins/fetch-stats`
Network requests made through the core fetch service
(`src/common/fetch_service.py`), per plugin and per host, cumulative since
the display started. Read-only. The display publishes the counters at most
once a minute when they change (every 10 minutes otherwise), so they can be
up to a minute old. Requests a plugin makes with its own `requests` calls,
outside `APIHelper`, `espn_dates`, `BackgroundDataService` and
`BaseOddsManager`, are not counted yet.
`data.status` is `live`, `stale` (no publish for longer than
`stale_after`), `stopped` (the display exited; the last counters are kept)
or `unknown` (nothing published; `data.data` is `null`).
**Response**:
```json
{
"status": "success",
"data": {
"status": "live",
"age_seconds": 12.4,
"data": {
"schema": 1,
"running": true,
"published_at": 1790000000.0,
"stale_after": 720.0,
"since": 1789990000.0,
"totals": {"requests": 412, "merged": 3, "not_modified": 0,
"errors": 1, "http_errors": 2, "retries": 0,
"throttled": 0, "overruns": 0, "bytes": 18234011,
"wait_seconds": 0.0},
"plugins": {
"football-scoreboard": {"requests": 240, "merged": 2, "bytes": 9120330,
"hosts": {"site.api.espn.com": 180,
"sports.core.api.espn.com": 62},
"...": "the other counters, as in totals"}
},
"hosts": {
"site.api.espn.com": {"requests": 301, "...": "as in totals"}
},
"validators": {"entries": 0, "bytes": 0},
"config": {"enabled": true, "single_flight": true,
"conditional_get": true, "max_wait_seconds": 2.0,
"rate_limits": {"*.espn.com": {"per_second": 20.0, "burst": 200.0}}}
}
}
}
```
`requests` counts round trips sent (retries inside the HTTP adapter are in
`retries`), `merged` requests answered by an identical one already in
flight, `not_modified` 304s served from the stored body, `errors` transport
failures and `http_errors` responses with status 400 or above. `bytes` is the
decoded body size. `core` is everything no plugin made.
### Get/Set Plugin Limits
**GET** `/api/v3/plugins/limits/<plugin_id>`
@@ -892,8 +1111,11 @@ copy, not the display service's in-memory state.
**GET** `/api/v3/plugins/state`
Get the state manager's record for every plugin, keyed by plugin id. Pass
`?plugin_id=<id>` for one plugin (`data` is then that record).
Every plugin that is installed or configured, keyed by plugin id: desired
state from `config.json` and the plugins directory, observed state from the
display's runtime snapshot. Built per request; there is no state file.
Pass `?plugin_id=<id>` for one plugin (`data` is then that record; 404 if
it is neither installed nor configured).
**Response**:
```json
@@ -902,23 +1124,41 @@ Get the state manager's record for every plugin, keyed by plugin id. Pass
"data": {
"football-scoreboard": {
"plugin_id": "football-scoreboard",
"status": "loaded",
"status": "enabled",
"installed": true,
"in_config": true,
"enabled": true,
"version": "1.2.3",
"loaded": true,
"state": "enabled",
"error_info": null,
"loaded_version": "1.2.3",
"loaded_at": 1790000000.0,
"installed_at": "2025-01-15T10:30:00",
"last_updated": "2025-01-15T10:30:00",
"config_version": 1,
"metadata": {}
"last_updated": "2025-01-15T10:30:00"
}
}
},
"runtime": {"status": "live", "published_at": 1790000030.0, "age_seconds": 12.4, "stale_after": 180.0}
}
```
`status` is `enabled` / `disabled` for an installed plugin, `unknown` for
one that is configured but not installed, and `error` when the display
reports its state as `error`. `installed_at` and `last_updated` are the
newest successful install, and install or update, in the operation history
(`null` when it has none). The runtime fields follow the same rule as
[`/plugins/installed`](#get-installed-plugins): `null` unless
`runtime.status` is `live`.
### Reconcile Plugin State
**POST** `/api/v3/plugins/state/reconcile`
Reconcile plugin state across config, disk and the state manager.
Reconcile desired state (`config.json` plus the plugins on disk) with the
display's runtime snapshot. Desired-state gaps are fixed (a plugin on disk
with no config section is added disabled); observed-state gaps -- enabled
but not loaded, loaded at an older version than is installed -- are
reported with `fix_action: "no_action"`.
**Request Body** (optional):
```json
@@ -1189,13 +1429,22 @@ searches.
"version": "1.2.3",
"branch": "main",
"default_branch": "main",
"plugin_path": "plugins/football-scoreboard"
"plugin_path": "plugins/football-scoreboard",
"commit": "843588025a81197056f8d96779ccb2be19337ab8",
"ledmatrix_min_version": "3.7.0",
"aliases": [],
"incompatible_reason": null
}
]
}
}
```
`commit` (the monorepo commit that introduced `version`),
`ledmatrix_min_version` and `aliases` come from the registry entry and are
`null` / `[]` when an older registry lacks them. `incompatible_reason` is the
message an install would be refused with on this core, or `null`.
### Get GitHub Status
**GET** `/api/v3/plugins/store/github-status`
@@ -1336,20 +1585,59 @@ Get LEDMatrix repository version.
**GET** `/api/v3/system/check-update`
Whether `origin/main` has commits the checkout lacks. Cached briefly.
Fields at the top level (no envelope):
Whether newer code is available on this device's update channel. On
`stable` that is a newer release tag than the checkout (`target_version`
names it); on `beta`, and on `stable` while it waits on a branch for a
release that contains the current commit, it is commits on `origin/main`
the checkout lacks. A detached checkout newer than the newest release is
never offered an update: Update Code leaves it where it is until a release
includes it, and `channel_message` says so in the General tab's words.
Cached briefly. Fields at the top level (no envelope):
```json
{
"update_available": true,
"remote_sha": "abc123...",
"commits_behind": 3
"commits_behind": 3,
"target_version": "v3.8.0",
"channel": "stable",
"configured_channel": "stable",
"waiting": false,
"newest_release": "v3.8.0",
"current_release": null,
"channel_message": "Stable: release v3.8.0 is available."
}
```
When git cannot run the check, the response also carries
`"check_failed": true` and an `error` explaining why.
### Update Channel
**GET** `/api/v3/system/update-channel`
The update channel and what the next Update Code or weekly update would do
(in `data`): `configured` (`"stable"`, `"beta"` or `null` for a config from
before channels), `channel` (the one in effect), `waiting` (stable, but the
device is newer than the newest release, so it follows `main` for now),
`action` (`none`, `checkout_tag`, `pull` or `switch_to_beta`),
`newest_release`, `current_release`, `branch` (`""` when on a release tag),
`message`. Reads local refs; `?fetch=1` fetches from origin first.
**POST** `/api/v3/system/update-channel`
```json
{
"channel": "beta"
}
```
Saves `auto_update.channel`. The next update applies it; switching to
`stable` never installs an older version than the one running, and the
`message` says when the device keeps following `main` until a newer release.
400 for anything but `stable` or `beta`. The General tab form also accepts
`auto_update_channel` on `POST /api/v3/config/main`.
### Automatic Update Status
**GET** `/api/v3/system/auto-update`
@@ -1375,7 +1663,9 @@ Hide the current automatic-update alert until a new one replaces it.
Branch, dirty state, recent commits and remote for the Tools tab. Fields at
the top level: `branch`, `dirty`, `status`, `recent_commits`, `remote_url`
(credentials scrubbed), `upstream`, `can_pull`.
(credentials scrubbed), `upstream`, `can_pull`, and for the update channel
`detached`, `version` (`git describe`), `current_release` (the release tag
HEAD is exactly on, else `null`) and `channel_message` (detached only).
### Git Branches
@@ -1388,7 +1678,10 @@ Fetches `origin` and lists branches to switch to: `current`, `upstream`,
**POST** `/api/v3/system/action`
Execute system-level actions. JSON or form data.
Execute system-level actions. Send JSON (`Content-Type: application/json`).
A form-encoded or `text/plain` body is accepted only with an `HX-Request`
header (HTMX sends it; a cross-site HTML form cannot) and is otherwise
refused with `415`.
**Request Body**:
```json
@@ -1949,6 +2242,18 @@ Health of the web interface, display service, config file, plugin system and
display snapshot. `data.status` is `healthy` or `degraded`, with
`data.services` and `data.checks`.
`data.checks.display_loop` is the display's render-loop heartbeat: `running`
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
frozen even if the service is active; the status turns `degraded`), or
`not_reported` when the display writes none (not started yet, the dev server,
Windows), which does not affect the status.
Open even when the web login is on, for uptime monitors; a caller that is not
logged in (and has no token) then gets only `{"status": "success", "data":
{"status": "healthy" | "degraded"}}`. A stalled render loop still shows there
as `degraded`; the `checks` detail is only for logged-in callers, tokens and
requests from the Pi itself.
### Hardware Status
**GET** `/api/v3/hardware/status`
@@ -2014,20 +2319,100 @@ enabled.
Home Assistant MQTT bridge service state and settings: `data.service`,
`data.config_exists`, `data.config_path`, `data.config` (password
omitted), `data.password_set`, `data.env_override_prefix`.
omitted), `data.password_set`, `data.api_token_set`, `data.env_override_prefix`.
**PUT** `/api/v3/integrations/mqtt-bridge/config`
Write `integrations/mqtt_bridge/bridge_config.json`. Only the keys you send
change. The password is write-only: omit `mqtt_password` to keep it, send a
value to replace it, or send `"clear_password": true`. A password with
value to replace it, or send `"clear_password": true`. The web-login API token
the bridge sends (`ledmatrix_api_token`, needed only when login is on and the
bridge runs on another machine) is write-only the same way, cleared with
`"clear_api_token": true`. A password with
`mqtt_tls` off is refused unless `allow_insecure_mqtt` is true. Returns
`data.password_set` and `data.restart_required` (the bridge must be
restarted to pick up changes). See
`data.password_set`, `data.api_token_set` and `data.restart_required` (the
bridge must be restarted to pick up changes). See
[integrations/mqtt_bridge/README.md](../integrations/mqtt_bridge/README.md).
---
## Web login and API tokens
The optional password and API tokens (`web_auth` in
`config/config_secrets.json`, `web_interface/auth.py`). No route here returns
the password hash, a token hash or the cookie key. A request authenticated by
an API token gets `403` `TOKEN_NOT_ALLOWED` from every route in this section:
tokens are for integrations, not for changing who can log in. Wrong current
passwords (`403` `WRONG_PASSWORD`) count against the same per-address limit as
the login page: 5 a minute, 30 an hour, then `429`.
Lost password: run `sudo python3 scripts/reset_web_password.py` on the Pi.
### Login status
**GET** `/api/v3/auth/status`
```json
{
"status": "success",
"data": {
"enabled": true,
"signed_in": true,
"access": "session",
"min_password_length": 8,
"tokens": [
{"id": "3f9c1a2b4d5e6f70", "name": "Home Assistant", "prefix": "lmx_Ab3d",
"created_at": "2026-09-29T20:14:03+00:00"}
]
}
}
```
`access` is how this request got in: `open` (login off), `session`,
`localhost`, `ap-setup` or `token`.
### Set or change the password
**POST** `/api/v3/auth/password`
Body: `{"new_password": "...", "current_password": "..."}`.
`current_password` is required once login is on. At least 8 characters, no
leading or trailing space (`400` `WEAK_PASSWORD`). Setting the first password
turns login on. Every existing login session ends; the caller's own browser
is signed in again with the answer.
### Turn login off
**POST** `/api/v3/auth/disable`
Body: `{"current_password": "..."}`. Removes the password; API tokens are
kept (and are needed again if login is turned back on).
### API tokens
**GET** `/api/v3/auth/tokens` — `data.tokens`, as in the status answer.
**POST** `/api/v3/auth/tokens` — body `{"name": "Home Assistant"}` (1-60
characters). Answers `201` with `data.token`, the token itself (`lmx_` plus 43
characters), and `data.record`. **The token is never shown again**; only its
SHA-256 is stored. At most 50 tokens.
**DELETE** `/api/v3/auth/tokens/<id>` — revoke; it stops working on the next
request. `404` for an unknown id.
Send a token as `Authorization: Bearer <token>`.
### Login page
`GET /login` shows the login form (and redirects home when login is off or
this browser is already signed in); `POST /login` with a form field `password`
(and optional `next`, a path on this server) signs in and redirects to `next`,
or answers `401` with the form again. `POST /logout` ends the session. Both
are outside `/api/v3` and go through the cross-site check like every other
`POST`.
---
## Plugin-specific endpoints
A handful of endpoints belong to individual plugins. The music plugin's
+287
View File
@@ -0,0 +1,287 @@
# Restructuring `DisplayController.run()`
`run()` in [`src/display_controller.py`](../src/display_controller.py) decides
what the panel shows and runs it. This document is the plan for turning it
from one long loop into three parts with clear jobs: an **Arbiter** that
decides, a **ScreenRunner** that runs one screen, and **Sources** that each
know about one kind of content. It covers the target design, the stages that
get there, and how each stage is checked.
The goal is to change how the control flow is organised, not to move code
into more files. Each stage ships as its own PR, and none of them changes
what the panel shows unless that PR says so and updates the golden traces
on purpose.
## Why
- **The priority order is written in branch order, twice.** It is
Follower, on-demand, WiFi notice, live priority, Vegas, rotation. In
`run()` that order exists only as the order of `if` blocks. Vegas
repeats part of it in its interrupt callback (`_check_vegas_interrupt`).
- **Preemption is found by re-checking.** A screen ends early when
something else changed `current_display_mode` or `is_display_active`
underneath it. `run()` notices with five separate
`current_display_mode != active_mode` checks: after an empty pass, in each
of the two frame loops, after the frame loops, and before rotating.
- **Most recent fixes were ordering bugs** between these branches (#618,
#644, #649, #652): a lost mode switch, rotating past an on-demand request,
spinning when every mode is empty.
- **It could not be tested** without threads, real sleeps and stopping the
loop by raising from a patched method.
## What `run()` does today
Each pass, in order:
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable, then
any plugin reloads the control socket asked for
(`_apply_pending_plugin_reloads`; a pending reload ends the screen
before it, like a WiFi notice, through `_screen_preempted`). The static
screen's frame sleep and the dwell wait on the socket's queue instead of
sleeping (`_wait_frame_interval`, `_sleep_with_plugin_updates`); without
a socket, as in the golden traces, they are the plain sleeps.
2. With no modes: dwell 1 s, next pass.
3. Poll on-demand requests and expiry, release plugins loaded only for
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
the schedule (an on-demand session overrides scheduled-off), apply the
brightness target.
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
5. **Follower:** render one frame from the leader. `_run_follower_frame`
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
It is also polled mid-screen (`_wifi_notice_pending`): the frame loops,
the dwell sleep and an interrupted Vegas iteration end within about a
second when one arrives, and a screen cut short resumes after it.
7. **Live priority** (unless on-demand, or Vegas keeps live content in the
ticker): switch to the next live mode, or resume the rotation. A game
that goes live during a screen is caught sooner, by
`_check_live_takeover` in the frame loops and the dwell sleep (at most
once a second, and not while a live mode is showing).
8. **Vegas** (unless on-demand, or live content preempts it): run one
iteration of up to `max_cycle_duration`. A completed iteration ends the
pass, and so does one that yielded for a WiFi notice or the schedule.
Any other interrupted one falls through to step 9 in the same pass.
9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
(`_plugin_for_mode`), draw the first frame through the executor
(`_dispatch_first_frame`). On no content, rotate at once
(`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the
bounds (`_track_dynamic_cycle`, `_resolve_durations`,
`_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the
125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the
next mode (`_advance_after_screen`).
The helpers named above were extracted in stage 1 without changing
behaviour. The frame loops, the Vegas branch and every early exit are still
inline in `run()`.
## Target design
```python
def run(self):
while True:
inputs = self._drain_inputs() # requests, schedule, config, sync
plan = self.arbiter.decide(self.state, inputs, clock.now())
outcome = self.runner.run(plan) # ExitReason + elapsed
self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume
```
### Sources
Each kind of content is a Source. A Source looks at the state and the
inputs and either offers a screen or passes. The Arbiter asks them in this
order:
| Order | Source | Offers a screen when | Today |
|---|---|---|---|
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 |
| 1 | Follower | a sync leader is driving this panel | step 5 |
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
| 3 | Wifi | a status message is pending and on-demand is not active | step 6 |
| 4 | Live | a live-priority plugin has live content (round-robin across several) | step 7 |
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | step 8 |
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
ScheduledOff is a gate in front of the Sources because that is how it works
today: a scheduled-off panel stays blank even for a follower, and only an
on-demand session overrides it.
### Arbiter
```python
Arbiter.decide(state, inputs, now) -> ScreenPlan
```
`decide` is a pure function: it does no I/O, takes no locks and does not
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
an expected plan. It returns a `ScreenPlan`:
| Field | Meaning |
|---|---|
| `source` | which Source won |
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
| `min_duration`, `max_duration` | from `_resolve_durations` and `_clamp_to_on_demand` |
| `dynamic` | run until the plugin's cycle completes, between min and max |
| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 |
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
### ScreenRunner
```python
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
```
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
frame loop that the plan's frame policy selects, services pending changes
between frames, and returns one `ExitReason`:
| ExitReason | Today's equivalent (golden-trace exit) |
|---|---|
| `DURATION` | target duration reached (`duration`) |
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) |
| `ERROR` | the dispatch itself raised (`error`) |
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) |
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
The runner asks the Arbiter, at the throttled service points it already has,
whether a Source in `plan.preemptible_by` now wants the panel.
`FrameClock` provides `now()` and `sleep()`. In production it is
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
that the harness patches in today.
## Stages
| Stage | Change | Behaviour change | Verified by |
|---|---|---|---|
| 1 | Golden traces; extract helpers from `run()` | none | traces generated on main pass unchanged; mutation check |
| 2 | Arbiter with Follower and Wifi Sources | none | traces unchanged; Arbiter unit tables; ledpi smoke |
| 3 | ScreenRunner, FrameClock, ExitReason, `PREEMPTED`; OnDemand, Live, Rotation Sources | none | traces unchanged; ledpi frame soak A/B |
| 4 | Vegas as a Source driven by `run_frame()` | none intended | traces against the real coordinator; ledpi Vegas soak A/B |
| 5 | Plugins declare `frame_policy` | DEBUG instead of INFO for the FPS line | traces; soak on a static-heavy rotation |
### Stage 1 (this PR)
- `test/_run_loop_harness.py` builds a real `DisplayController` through
`__init__` on in-memory fakes (plugins, cache, config service, plugin
manager, sync manager, display manager). It swaps the module's `time` and
`datetime` for one fake clock and runs the real `run()` until a horizon.
The first frame of each screen still goes through the real
`PluginExecutor` and the per-plugin locks.
- `test/test_run_loop_golden.py` has 15 scenarios, each compared with
`test/fixtures/run_loop_golden/<scenario>.json`:
- plain rotation (display_durations override, a high-FPS scroller, a
plugin whose `display()` takes no `display_mode`)
- empty modes and a mode with no plugin; an all-empty rotation (the 1 s
pause)
- plugin errors and the circuit breaker
- dynamic duration (cycle complete, plugin cap, global cap)
- live priority taking over and handing back; live round-robin
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
a restart
- schedule off and dim, with an on-demand override during downtime
- WiFi notice; sync follower
- Vegas, with and without `live_in_ticker`
- Each trace row is `[start, mode, duration, exit_reason, frames,
force_clear]`. The exit reason is the event that decided what came next.
- All 16 tests run in under a second. The goldens were generated from
main's `run()` before any code moved.
- Vegas uses `FakeVegas`, which implements only the contract the controller
depends on: `run_iteration()` returns True after its duration and False
when the interrupt or live check asks it to yield, checking at the real
coordinator's cadence. Running the real coordinator on the fake clock
belongs to stage 4.
- Twelve helpers were extracted from `run()` (listed under "What `run()`
does today"). Breaking any one of them fails at least one golden trace.
### Stage 2: Arbiter, starting with Follower and Wifi
1. Add `ScreenPlan` and an `Arbiter` with the ScheduledOff gate, Follower
and Wifi. Every other case returns a `LEGACY` plan, which means "carry on
with the existing code" (steps 7-9).
2. `run()` calls `decide()` after the bookkeeping in step 3 and dispatches
on `plan.source`: blank, `_run_follower_frame()`, the WiFi notice, or the
existing path. Inputs that Sources read (follower active, the pending
WiFi message, schedule state) are collected first, so `decide()` stays
pure.
3. Unit-test `decide()` with tables. The golden traces must not change.
The Wifi Source must keep the mid-screen preemption described in step 6
of "What `run()` does today".
Follower and Wifi go first because each is one self-contained branch that
ends the pass. They prove the plumbing without touching the frame loops.
### Stage 3: ScreenRunner and `PREEMPTED`
Move the two frame loops, the make-up dwell and the dynamic-duration exit
into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the
five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources
so `LEGACY` is left meaning only Vegas.
This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield),
so it needs a frame soak on ledpi, A/B against main. Coordinate with
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
### Stage 4: Vegas as a Source
The controller calls `coordinator.run_frame()` once per frame from the
ScreenRunner instead of handing over to `run_iteration()` for up to
`max_cycle_duration`. The interrupt callback and the second copy of the
priority order go away, because preemption becomes `PREEMPTED`. The
`vegas-plugin-tick` thread that is spawned every 4 s becomes the
controller's normal update tick. Extend the harness to drive the real
coordinator on the fake clock, which means patching its `time` and running
its prefetch inline. Verify with a Vegas soak on ledpi, A/B.
### Stage 5: `frame_policy`
Plugins declare `frame_policy` (STATIC, PERIODIC(hz), ANIMATED(fps),
SCROLL). `_needs_high_fps` becomes the mapping for legacy plugins
(`needs_high_fps`, the `static-image` special case, `enable_scrolling`),
and its per-screen INFO line drops to DEBUG. It is already read twice per
screen: once quietly before the first frame, so `_dispatch_first_frame` can
end the previous scroll for a screen that runs the 1 Hz loop
(`_start_screen_handover`), and once after it to pick the loop. A declared
policy answers both.
## How each stage is verified
- **Golden traces.** Run `python -m pytest test/test_run_loop_golden.py`;
it takes about a second. A refactoring stage must leave every trace
unchanged. A deliberate behaviour change regenerates them with
`LEDMATRIX_REGEN_GOLDEN=1` in its own commit, and the commit message
explains each changed row. A new scenario's golden is generated against
main's `run()` first, then checked against the branch.
- **Mutation check.** Break each moved or new piece once, for example take
`max` of the caps instead of `min`, or skip the live hold. At least one
trace must fail each time. Stage 1 did this for all twelve helpers.
- **Full suite.** Diff the FAILED/ERROR ids against a baseline run of main
in a separate worktree. The Windows host has a stable set of
pre-existing failures, so never compare against zero.
- **ledpi soak** (stages 2-5). With the service running the branch:
`python3 scripts/frame_soak.py --preview` for 10 minutes on a scrolling
rotation, and on Vegas for stages 3-4. Alternate which build goes first.
Compare late-frame rate and freezes with main. Also check by hand that
on-demand start, stop and expiry, a live game taking over and handing
back, and the schedule turning the panel off and on all behave as before.
## Behaviour the traces pin down that may be wrong
Stage 1 recorded six behaviours as they were, each to be fixed in its own
PR that updates the affected trace and explains why. All six are fixed:
- A WiFi notice was only checked between screens, and Vegas yielded to one
and then showed a rotation screen instead. Notices now preempt within
about a second, and Vegas yields straight to them (#712; `wifi_notice`,
`vegas`).
- A live game only took over between screens, and Vegas yielded to one and
then showed a rotation screen first. Games now take over within about a
second, and Vegas yields straight to them (#713; `live_priority`,
`vegas`).
- An on-demand session that ended during scheduled-off kept the panel on
until the next minute, and a schedule window's end minute counted as on
only sometimes. Windows are now half-open `[start, end)`, and the panel
blanks as soon as on-demand ends in off hours (#714; `schedule`).
A new one found later goes the same way: record it here with the trace that
shows it, then fix it in its own PR, not inside a restructure stage.
+54 -13
View File
@@ -73,6 +73,10 @@ Sample ladder for a 100 Hz panel:
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
```
The Vegas **Scroll Speed** slider in the web UI shows the same thing live: a
line under it says what your speed will run as on this panel, and links to the
nearest smooth speeds.
### How a slow speed stays crisp
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
@@ -331,17 +335,24 @@ 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.
fresh, which puts the preview's PNG encoding at the viewer rate, as an open
preview does -- run it as the web service's user. That rate is at most one
frame a second. Through 3.8.0 it was up to five, so a `--preview` soak taken
before that change is not comparable with one taken after it (the hdpi
results below are from before it): take both sides of an A/B pair on
the same side of it.
| 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. |
| **Freezes** | Gaps of 250 ms or more inside a scroll: recomposes, plugin handovers the display controller does not tag (see *Handover gaps*), 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. Handovers to a static screen no longer show up here: the display controller ends the scroll state after a static screen's first frame, where it used to linger for 2 s and turn the 1 Hz loop's second frame into a ~1 s "freeze" (17 of 31 `Render stall over` lines on ledpi, 2026-09-15 to 10-01). **Freeze counts from before and after that change are not comparable.** |
| **Handover gaps** | Gaps of 250 ms or more from a scroll's last frame to the next screen's first: the next plugin drawing, not a scroll stalling. The display controller tags that first frame `handover`, and these gaps are counted here instead of under Freezes (`handover_freezes` in the stats; the `handover` row under *after work* counts the same ones in its freezes column). Every turn's first frame is tagged, also when the rotation comes back to the same mode (a one-mode rotation, a pinned on-demand mode, live priority holding a screen), so a scroller rebuilding its content at the start of a turn is counted here; measure work on that rebuild with this line, not Freezes. Missing from stats written by an older service, whose freezes include them. |
| **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. |
| **Garbage collection** | Python's cyclic collector stops every thread while it runs. Collections per generation in the run and the time they took, how many took 20 ms or more, and the longest since the service started. A long one tags the next frame `gc` (see *after work*), and a `Render stall` dump says when one ran inside the stall. Diagnostic only: nothing tunes the collector. Missing from stats written by an older service. |
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
| **after work** | Frames presented straight after tagged render-thread work, with their own late rate: `extend` and `compose` (Vegas building its strip), `patch` (live elements, once they land), `handover` (a new screen's first frame), `gc` (a garbage collection of 20 ms or more ran since the frame before). A kind whose late rate sits well above the overall one is the work making frames late. Shown only when something tagged its work. |
The refresh rate is estimated from the frames themselves (swaps that block on
vsync can only land on refresh boundaries). Cross-check it with
@@ -355,10 +366,13 @@ 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.
(C code holding the GIL) rather than one thread. A stall while the next
screen's first `display()` is still drawing says `in a handover gap` instead of
`mid-scroll`; that call runs on a thread named `display-<plugin id>`. 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
@@ -406,6 +420,10 @@ sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) spe
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
# render-thread strip work, each tagged so the report gives it a late rate:
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25 # a live map patch
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6 # Vegas extensions
sudo systemctl start ledmatrix
```
@@ -468,6 +486,8 @@ refreshes" comes from.
| `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. |
| `patches` | `--patch-bytes N --patch-every K`: N bytes of columns written into the strip in place every K frames, on screen or (`--patch-where ahead`) just past it -- what a live element update costs the render thread. Their frames are the `patch` row under *after work*. |
| `extensions` | `--extend-every-screens N`: a block appended and the scrolled-past columns trimmed every N screens, as continuous Vegas does. The cost is a copy of the whole strip, so size it like Vegas's with `--strip-screens` (8,000-20,000px). Their frames are the `extend` row. |
`--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
@@ -510,19 +530,43 @@ On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
### 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
The step is the motion of one refresh, 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
this while something scrolls
(`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.
A frame held for several refreshes (any crisp speed below the panel's full
refresh rate, e.g. 60 px/s at 120 Hz) is presented as two swaps instead of one:
the lagging half shows the previous frame for the first refresh and the new one
for the rest, so it steps one refresh after the rest rather than one frame.
That costs a second blit inside the refresh after the first swap, so it is
skipped when a blit takes more than half a refresh.
A plugin screen that runs the 1 Hz loop after a scroll is not composed: the
display controller calls `DisplayManager.end_scroll_for_static_screen()` before
its first `display()`, so the frames that call presents go out as drawn, in one
swap each, instead of with the lagging half taken from the scroller's last
frame.
The controller's own screens -- the blank shown when the schedule turns the
panel off, and the WiFi status message -- end the scroll state before they are
drawn, so they go out as drawn and are timed as static frames, not as freezes
of the old scroll. A scroller that resumes after a WiFi notice sets the state
again on its next frame.
One screen that follows a scroll is still composed while the scroll state lasts
(it expires 2 s after the scroller's last frame): a screen that runs the
high-FPS loop without scrolling (an older `static-image`, which is forced into
it). Its first frame takes its lagging half from the scroller's last frame, for
one refresh after a held scroll and otherwise until its next frame.
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
@@ -530,9 +574,6 @@ 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).
+311 -52
View File
@@ -35,10 +35,11 @@ defaults, or as capabilities they opt into.
### Reusability — write once, nine plugins benefit
Only code that is **identical in intent across all nine** moves into the base
class. That set is small and knowable — it is exactly the methods present in every
copy today (phase B1 below). Everything else stays where it is until it earns
promotion.
Only code that is **identical across every plugin that carries it** moves into
core. Stages 0–3 moved the copies that already were; what is left has drifted,
and earns promotion by being reconciled first — made identical in all nine
plugins, one method family per release, with every visible difference decided
rather than averaged away. See [Roadmap](#roadmap).
### Modularity — a change to one feature cannot reach a plugin that doesn't use it
@@ -86,6 +87,10 @@ more. Shared sports code lives in `src/common`:
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
| `sports_plugin_host.py` | next release | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh |
| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
Each is described in [src/common/README.md](../src/common/README.md).
@@ -97,9 +102,10 @@ 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 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
`sports_helpers.py` holds `_favorite_key`, the override point listed below.
Each promoted module has a parity test that compares its bodies against the
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
`test/test_common_is_hardware_free.py` keeps `src/common` free of
`rgbmatrix`, `src.display_manager` and `src.plugin_system`. How a plugin adopts a
module and drops its copy is documented in the plugins repo's
@@ -235,12 +241,284 @@ legacy compatibility rather than the mechanism.
> (`display_manager.refresh_hz`), and speed comes from
> `scroll_settings.scroll_speed` alone. See `docs/SCROLL_PERFORMANCE.md`.
## Phases
## Roadmap
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
**rollout**, and it splits into three phases with very different risk profiles.
The original plan folded the last two together; they are separated here because
one of them cannot break a user on an old core and the other can.
### Done: stages 0–3
The second project, after the B phases below: move what the nine `sports.py`
copies (and their support files) carried byte-identically into `src/common`,
one new module per stage, and delete the copies once the plugins floor on the
release that ships it.
| Stage | Core | Plugins (ledmatrix-plugins) | What moved |
|---|---|---|---|
| 0 | none needed; found #662 (odds `no_odds` marker) and #663 (a reloaded plugin's dir goes first on `sys.path`) | #562 | Deleted the bundled copies nothing could reach (`base_odds_manager`, `logo_downloader`, three unused data sources, ~4.2k lines); three UFC fixes |
| 1 | 3.5.0: `sports_helpers` (#583), `espn_dates`, `json_body` | #563 (1a, the eight team scoreboards), #564 (1b, ufc); floor 3.5.0 | The identical helpers and ESPN date-range handling; ufc also adopted the `sports_shared` mixins |
| 2 | 3.6.0: `favorite_team_check`, `sports_timezone`; fixes in 3.6.1 (#667) and 3.6.2 (#670) | #565 (guarded adoption), #567 (f1), #570 (sunset, floor 3.6.1), #571 (soccer, 3.6.2) | The favourite-team check (seven copies) and the timezone resolver (ten); each plugin keeps a thin timezone binding |
| 3 | 3.7.0 (#672): `sports_celebration`, `sports_fetch`, `sports_card_wrappers` | #572 (goldens first), #574 (floor 3.7.0, copies deleted) | Celebration drawing (five plugins), four fetch methods (nine), seventeen card delegations (eight renderers) |
Stage 3 was re-checked independently when this roadmap was written: #574's
parent and #574 itself, rendered through the core harness against core 3.7.0,
gave pixel-identical output for all 399 frames (192 harness screens across the
nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
celebration frames), with a parent-vs-parent rerun as the determinism control.
### Stage 4: the identical sweep (core done; adoption waits for a release)
Re-measured on ledmatrix-plugins `56c4f15` (2026-09-30) the report still
lists 58 families identical in every copy. Stage 4 moves the ones that are
identical across the nine, or across eight with the ninth lacking the
method, into four new modules: `sports_plugin_host` (ten `manager.py`
helpers, all nine), `sports_live_scroll` (eight `manager.py` methods, every
plugin with a live strip, so not ufc), `sports_display_rules` (four
`sports.py` methods, in two mixins because their carriers differ) and
`sports_font_path`. The parity test (`test/test_sports_stage4_parity.py`)
compares each with every plugin copy using this report's own normalisation,
plus decorators and constant values, which the normalisation drops.
`_resolve_font_path` was meant to be replaced by
`font_layout.resolve_asset_path`, but that never looks in the cwd, and the
plugins' copy does first, so the swap would change which font a process
started from another checkout loads. `resolve_font_path` is the copy's
behaviour on a core that ships it, checked path for path against all 17
copies (`test/test_sports_font_path.py`).
Left in the plugins, though identical:
- `_get_timezone`, `_extract_game_details`, `_fetch_data` (nine): a
per-plugin import and the abstract contract, as in stage 3.
- `_schema_font_size`, `_resolve_font_size` (eight renderers): they read the
plugin's own `_SCHEMA_PATH`, as in stage 3.
- The 29 families carried by seven plugins or fewer: the afl/nrl/soccer
lineage's own helpers (`_swrr_advance`, `_refresh_switch_mode_managers`,
`_initialize_logo_dir`, ...), the multi-league helpers
(`_resolve_managers_for_mode`, `_extract_mode_type`, ...), and eleven
two-plugin helpers. Each is one lineage's code; most go when
family 13 or 14 reconciles the code around them. `_odds_color` (seven
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
wants it can inherit that.
### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
`4327c2e` (2026-09-29, after stage 3) with `scripts/sports_drift_report.py`:
| File | Method families | In all nine | Method lines | Identical copies beyond the first | Drifted families |
|---|---:|---:|---:|---:|---:|
| `sports.py` | 94 | 30 | 30,976 | 1,479 lines | 19 |
| `manager.py` | 114 | 36 | 27,137 | 3,198 lines | 38 |
| `game_renderer.py` (8 plugins) | 89 | — | 6,830 | 546 lines | 11 |
"Drifted" means in at least seven plugins with at least three different
bodies. Everything still identical adds up to about 5,200 duplicated lines;
the rest of the ~65,000 method lines is drifted, one outlier away from
identical, or unique to one plugin. Drifted code cannot move unchanged, so
consolidation stalls unless the copies are made identical first.
`manager.py`, the largest copy of all and the layer the display controller and
Vegas talk to, was in no plan before this one.
### The method: reconcile, then promote
**Owner decision (2026-09-29):** each release, pick one drifted method family,
make all nine copies identical, then promote it to core. A *family* here is a
set of methods that share state and ship together (the rankings methods, the
game-over check); the report measures each method in it. The procedure:
1. **Measure.** `python scripts/sports_drift_report.py --family sports.py::<name> --diff`
lists which plugins share each body and diffs every variant against the
most common one. Put the grouping in the PR.
2. **Classify every difference**, and say which class in the PR:
- *A fix one copy has and the others lack* (a lock, a guard, a correct
season year). Port it. It is a behaviour change, so it gets a CHANGELOG
line in each plugin.
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
Make it a declared class constant or override point with a default, as
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
`_favorite_key` are, and add it to the tables above. Never a sport-name
branch: core must not learn sport names.
- *A product difference*: anything a user can see (which games show, a
colour, a date, a badge, how long a screen stays). The owner picks the
behaviour before the code changes; the decision goes in the PR and in a
test that pins it (as `test/test_sports_twins.py` pins the twins).
- *Noise*: comments, log wording, dead branches. Pick one.
3. **Pin the output first.** Before touching the family, its output must be
covered: the harness goldens (`test/golden`), the scroll cards
(`golden-cards`) and celebrations (`golden-celebration`) for drawing
families; for logic families, a table-driven test over the nine plugins'
fixture games. Missing coverage lands in its own PR first, as #572 did for
stage 3.
4. **Reconcile in the plugins** (a monorepo PR). The report must show one
variant per class for the family. Render every touched plugin before and
after through the harness and diff pixels, not hashes. Every differing
frame must match a recorded product decision; any other difference is a
bug. Bump each plugin's `version`, add a `versions[]` entry and a CHANGELOG
entry, and run `update_registry.py`.
5. **Promote in core**: a new `src/common` module per family (a new module, not
growth on an old one, for the reason under Converging on `src/common`), a
parity test against the plugin copies, and a CHANGELOG module entry naming
the release that ships it.
6. **Adopt** once that release is out: each plugin floors on it, inherits the
mixin, deletes its copy, gains a sunset guard (like the monorepo's
`scripts/test_stage3_mixin_copies.py`), and is pixel-diffed again; the
expected difference is zero.
7. **Re-measure** and update the numbers here.
A family is only reconciled when *all nine* agree. Leaving one plugin behind
recreates the drift the report exists to measure.
Soaks: pixel diffs prove the drawing, not the timing. A family that changes
when data arrives or which games are live (5, 7, 9, 13 and 14 below) needs a
live-game soak on a rig, and out-of-season sports wait for their season.
Before a soak, check the rig's `*_display_mode`: a board in `switch` mode tells
you nothing about the scroll path.
### Order
One family per release, in this order. Variant counts are from the report
above (per method: distinct bodies across the plugins that carry it, counted
per class role). Stage 4 needs no reconciliation and can ride along with any
release.
| # | Family | Methods (variants) | Why here |
|---|---|---|---|
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
| 14 | `manager.py` host | Nine different bodies in nine plugins: `__init__`, `display`, `update`, `has_live_content`, `get_live_modes`, `get_vegas_content`, `on_config_change`, `_initialize_managers`, `_get_available_modes`, `_get_current_manager`, `_adapt_config_for_manager`. Dynamic duration: `_evaluate_dynamic_cycle_completion` (9), `_record_dynamic_progress` (8), `get_cycle_duration` (8), `get_dynamic_duration_cap` (6), `supports_dynamic_duration`, `is_cycle_complete` (5 each), `reset_cycle_state` (4). Scroll: `_display_scroll_mode` (8), `_ensure_scroll_content_for_vegas` (8), `_collect_games_for_scroll` (7), `_should_use_scroll_mode`, `_has_any_scroll_mode` (6 each) | See below |
**`manager.py`.** Reconciling it body by body would take a release per
method. The plan is a host class in core, `SportsScoreboardPlugin(BasePlugin)`,
that takes the plugin's leagues as data (key, label, ESPN path, Live, Recent
and Upcoming classes: basketball's `manager.py` already describes its leagues
as such a table) and a typed mode key instead of the mode-name string parsing
(`endswith('_live')`, `split('_')`) every copy repeats. Order within it:
stage 4's identical helpers first; then dynamic duration, then live priority (`has_live_content`,
`get_live_modes`, `has_live_priority`), then Vegas content, then mode
resolution, then the lifecycle methods. Pilot the whole host on nrl or afl,
the smallest copies (about 1,850 lines each), with a frame soak and a
live-game soak before a second plugin moves. `get_vegas_content` is also being
changed by the scroll-performance work: coordinate before touching it.
**Held:** `data_sources.py` (nine copies; soccer's matches core's) and
`dynamic_team_resolver.py` (eight true forks, a different constructor from
core's). Effort on data fetching is better spent on the shared poller that
family 9 prepares.
### Product decisions each family needs
Owner calls to make before (or while) reconciling. Items marked *verify* are
suspected behaviour that needs a payload or a rig to confirm first.
- **5, game-over check.** Which rule each sport gets: the clock never ends a
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
0:00 from period 3, basketball, football and lacrosse from period 4.
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
in baseball (its games carry no `period`), and not triggered by ufc's round
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
this also closes a ~1 s window at the horn when the ticking clock reads
`0:00`), or its own final period.
- **6, favourite matching.** NRL keeps matching favourites by team id
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
own copies of the selection methods. Six plugins log the recent-games
selection at INFO; baseball, football and ufc do not.
- **7, other-games rotation.** football advances the rotation window under
`_games_lock` (update() and display() both advance it; interleaved, a
window of games is skipped) and fixes a favourites-only pool that recomposed
the list on every frame. Port both. ufc does not attach odds to fights
rotated in: decide whether rotated fights show odds.
- **8, rankings.** (a) afl, basketball, nrl and soccer turn a *standings*
payload into ranks (a pro league's standings position becomes the rank
badge); baseball, hockey, lacrosse, ufc and football do not. Which is
right is visible on every pro-league card with "show ranking" on.
(b) football also keys ranks by team id, so two schools sharing an
abbreviation across divisions cannot be confused: adopt for all.
(c) football asks for the division roster of the *season* year (July
onward is this year's season), which is right for football and wrong for
college basketball, hockey and lacrosse, whose ESPN season is the year it
ends: a per-sport seam, not football's constant. (d) baseball's
`_choose_poll` calls `dynamic_team_resolver.choose_top_division_poll`, the
others inline it: one home, in core.
- **9, live fetch and odds.** (a) afl, basketball, nrl and soccer cache the
live scoreboard for 30 s under `<sport_key>_scoreboard_current`; the others
do not (the harness fixtures seed that key, so the change shows up there).
(b) basketball fetches college games with no `dates` parameter, citing a
404 (*verify* now that `espn_dates` handles ranges). (c) odds are fetched
three ways: blocking (hockey, lacrosse), a thread waited on for 1.5–2 s
(six plugins), or fire-and-forget for upcoming games (basketball). This
sets how long `update()` takes and when an odds line appears. (d) nrl
guards on a missing odds manager; port it.
- **10, view model.** Per key, whether every sport emits it. Additive only:
no key is renamed or removed.
- **11 and 12, the scorebug and the card.** The pinned divergences in
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
same game differently:
- weekday timezone: the card reads only `config["timezone"]` and falls back
to UTC, so a board with only the global zone labels an evening kickoff
with the next day. **Decided 2026-09-24: use the plugin's timezone
(fix); not yet implemented;**
- an out-of-range start time: the scorebug drops the weekday, the card
raises;
- favourite result on a nested payload, which score wins when flat and
nested disagree, and where the favourites come from (the manager's list
vs the game's stamped list plus config);
- the element vocabulary (`team_text` vs `team_name`; rank and odds in one
map, not the other), and its consequences: a `team_name` colour reaching
one team face and not the other, and the odds face shared with the score
face in scroll mode only;
- per-mode colour overrides, which apply in switch mode only;
- by design, kept unless the owner says otherwise: the date format
(`switch_date_format` "numeric" vs the card's "abbrev") and the upcoming
centre (`switch_upcoming_center` "date_time" vs "vs"), both with an
"inherit" opt-in; and the two schema-font caches (per class vs per path).
Also: football's `_fit_score_font` swaps to the narrow score face at any
panel height when the score overflows, where the other seven keep the
design face at or below the design height (a 64x32 board shows the
difference); and whether switch mode and the card become one renderer drawn
at two sizes.
- **13, mode lifecycle.** The live-rotation dialect per sport (incremental
SWRR in afl, nrl and soccer; a precomputed schedule elsewhere); which sports
arm celebrations and on what (stays in each plugin, as in stage 3).
- **14, `manager.py`.** Dynamic-duration semantics (what completes a cycle,
the floor and cap per mode), what counts as live content for live priority
(favourites only or any live game), and the order of Vegas content.
### Measuring progress
`scripts/sports_drift_report.py` prints the numbers above for any
ledmatrix-plugins checkout (`--plugins <path>` or `LEDMATRIX_PLUGINS`). CI runs
it on every push and PR against the monorepo's main (the "Sports drift report"
job in `.github/workflows/test.yml`): report only, never failing, with the
tables in the job summary and the full JSON as an artifact. The monorepo's
`scripts/check_sports_drift.py` is the gate: it fails when a function that
agrees across the plugins starts to differ. A stage is done when its family
shows one variant per class here and its copies are gone.
```
python scripts/sports_drift_report.py --plugins ../ledmatrix-plugins
python scripts/sports_drift_report.py --family sports.py::_is_game_really_over --diff
```
## Phases B0–B6 (history)
The first project: it moved the scroll orchestration into core and proved the
upgrade path (floors, the store's compatibility gate, the sunset). All seven
phases are done. They are kept because the reasoning in B4–B6 is what every
later stage relies on; the plan from here is [Roadmap](#roadmap).
B0–B3 shipped in core 3.2.0. The rollout after them split into three phases
with very different risk profiles, because one of them cannot break a user on
an old core and the other can.
| Phase | Scope | Status | Gate |
|---|---|---|---|
@@ -275,8 +553,12 @@ a floor can be trusted against, and today it is not:
the update path that re-downloads.
`update_plugin`'s git branch pulls in place and re-downloads nothing, so it
stayed ungated until `_gate_pulled_commit` closed it — checked after the pull
(the registry carries no floor field, so the incoming floor is unknowable
before it) and undone with `git reset --hard` to the pre-pull commit. That
(the registry then carried no floor field, so the incoming floor was
unknowable before it) and undone with `git reset --hard` to the pre-pull
commit. The registry now publishes `ledmatrix_min_version`, and install and
update refuse on it before downloading or pulling; both post-download gates
remain as the fallback for older registries, other branches and
`compatible_versions`. That
route is rare in practice, since monorepo plugins install as archives; it was
closed because the sunset rule in the plugins repo's
`08-shared-sports-code.md` states as **condition 3** that the core enforces
@@ -439,10 +721,13 @@ deprecated `ledmatrix_min`). See
order any floor-raising tool must reproduce — and note the name is **inverted**
between the top level and `versions[]`.
**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and
`base_odds_manager.py`. The standing decision held them until B6 closed; it now
has, so they can be reconsidered — with B5's lesson applied, which is to build
the object and diff rendered output rather than trust a static check.
**The modules held back then** (`data_sources.py`, `game_renderer.py`,
`base_odds_manager.py`) have since gone different ways: the eight team
scoreboards import core's `base_odds_manager` (ufc keeps an MMA fork), the
game renderers inherit core's `SportsCardWrappersMixin` (3.7.0) but keep their
drawing, and `data_sources.py` is still copied. Their status is under
[Roadmap](#roadmap). B5's lesson applies to all of them: build the object and
diff rendered output rather than trust a static check.
### B5 retrospective — what the adoption actually cost
@@ -485,40 +770,12 @@ and its one delivered user-visible gain was that adopted plugins honoured the
global `target_fps` instead of hardcoding ~100 FPS (since withdrawn: see the
note under the B3 design above).
### Decision: stop adopting further modules until B6 closes
### Decision: stop adopting further modules until B6 closes (lifted)
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
carrying cost — a second copy to keep in step — against a payoff that is
contingent on B6, and B6 is gated on an installed base we cannot currently
measure. Consolidate what is already committed; revisit when B6 does.
## What's next
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
a version number CI now asserts (#428), the compatibility gate is in
`install_plugin` and reads `compatible_versions` as well as the floor
(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version`
(plugins #244), and all eight plugins have adopted the scroll orchestration
(plugins #245–#249, repaired in #251, tidied in #252).
What actually remains, smallest first:
1. **Soak the adoptions on hardware.** football and hockey have been run on a
live rig through real games; baseball was watched through one earlier. The
rest are proven by harness, unit tests and pixel comparison. Out-of-season
sports cannot be soaked until their season starts. When you do, **check the
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
sunset plugin and tell you nothing about the scroll code the sunset changed.
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
that landed after 3.2.0, so it is un-installable until the release exists.
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
the largest single duplication left: ~11,500 lines across eight plugins, with
~36,500 more in the eight `sports.py`. The `src/base_classes/sports/`
package promoted in B1/B2 was never imported by a plugin and has been
removed, so the plugin copies are the only starting point.
Held from B5 until B6 ran on 2026-09-01: each adoption added a second copy to
keep in step against a payoff that depended on the sunset. Once the store
refused a too-new plugin on every route, adopting and sunsetting in one stage
became safe, and stages 0–3 under [Roadmap](#roadmap) did exactly that.
## How to keep this project healthy
@@ -545,7 +802,9 @@ Lessons this migration paid for, worth applying beyond it:
## Rules for contributors
- **Promote on evidence, not intuition.** A method moves to core when every copy
has it and they agree on intent. Otherwise it stays in the plugins.
that has it is identical. Drifted copies are reconciled first, one family
per release, with each visible difference an owner decision (see
[Roadmap](#roadmap)); until then they stay in the plugins.
- **Never add a sport name to core.** If core needs to know which sport it is,
the design is wrong — add an override point instead.
- **A capability that is not opted into must not execute.** If you find yourself
+99
View File
@@ -295,6 +295,42 @@ sudo systemctl cat ledmatrix-web | grep User
---
#### Issue: Updates and the update channel
**Symptoms:**
- The General tab says "Stable: this device runs code newer than the newest
release ... keeps following main"
- Tools shows a version such as `v3.8.0` instead of a branch name, or `git
status` over SSH says `HEAD detached at v3.8.0`
- Update Code says "already up to date" while GitHub's `main` has newer commits
**Explanation:** these are the Stable update channel working as intended
(`auto_update.channel`, General → Update Channel). Stable installs the
newest release tag, which git checks out without a branch ("detached
HEAD"); that is normal and every update path handles it. Stable never
installs an older version than the one running, so a device that is ahead of
the newest release keeps following `main` until a release includes its
commit, then switches to releases on its own.
**Solutions:**
1. **Want the newest code instead?** Set Update Channel to **Beta** and click
Update Code. The device leaves the release for `main` and pulls it.
Or from SSH:
```bash
curl -X POST http://localhost:5000/api/v3/system/update-channel \
-H 'Content-Type: application/json' -d '{"channel": "beta"}'
```
2. **See what the next update will do:**
```bash
curl 'http://localhost:5000/api/v3/system/update-channel?fetch=1'
```
3. **Local changes after a channel switch:** edits that no longer fit the new
version are kept in the git stash rather than lost; `git stash list`
shows them as "LEDMatrix autostash before update".
---
### WiFi & AP Mode Issues
#### AP Mode Not Activating
@@ -516,6 +552,64 @@ sudo systemctl cat ledmatrix-web | grep User
python3 scripts/check_plugin.py --plugin plugin-id
```
#### Panel Frozen, or the Display Restarts Every Few Minutes
**Symptoms:**
- The panel stops changing while `systemctl status ledmatrix` says `active`
- The display restarts on its own, a couple of minutes after it froze
- `/api/v3/health` shows `checks.display_loop.status` as `stalled`
The display's render loop checks in with systemd every few seconds
(`WatchdogSec=120` in `ledmatrix.service`) and writes a heartbeat to
`/run/ledmatrix/display-heartbeat.json`. When the loop gets stuck -- almost
always inside one plugin's `display()` -- the check-ins stop, and after two
minutes systemd kills and restarts the display. The kill dumps every thread's
stack into the log, so it says which plugin was stuck.
**Solutions:**
1. **Find the stuck plugin.** Look for the watchdog kill and the stack dump
after it. The render loop is the thread whose stack runs through
`display_controller.py` in `run` (usually the `Current thread` block);
the first `plugin-repos/...` file in it is the plugin:
```bash
sudo journalctl -u ledmatrix --since "1 hour ago" | grep -A40 "Watchdog timeout"
```
2. **Check the heartbeat by hand.** Its age should stay under about ten
seconds while the display runs:
```bash
cat /run/ledmatrix/display-heartbeat.json
curl -s http://localhost:5000/api/v3/health | python3 -m json.tool | grep -A3 display_loop
```
`not_reported` means the display writes no heartbeat: it has not drawn
its first frame yet, or it runs an older version.
3. **Disable the plugin** in the web UI and report it to its author with the
stack dump. Restarts that repeat back off from 10 seconds to two minutes
apart, so a plugin that hangs on every start does not restart the display
hundreds of times an hour.
4. **Is the watchdog installed?** Installs from before it keep their old unit
until the installer is re-run (a startup warning says the unit differs
from its template):
```bash
systemctl show -p WatchdogUSec ledmatrix # 2min once running; 0 = not installed
sudo ./scripts/install/install_service.sh
```
`WatchdogUSec` reads `15min` for the first minutes after a start: that is
the start-up allowance, narrowed to two minutes once the first frame is on
the panel.
5. **A plugin that legitimately blocks longer** than two minutes (it should
not; `display()` runs on the render thread) can be given more time with a
drop-in, `sudo systemctl edit ledmatrix`:
```ini
[Service]
WatchdogSec=300
```
`WatchdogSec=0` turns the watchdog off.
#### Stale Cache Data
**Symptoms:**
@@ -952,6 +1046,11 @@ git reset --hard HEAD~1
# Or rollback to specific commit
git reset --hard <commit-hash>
# On the Stable update channel HEAD is a release tag, not a branch:
# go back to an earlier release instead (the next update moves forward again)
git tag --list 'v*' --sort=-v:refname | head
git checkout --detach v3.7.0
# Restart all services
sudo systemctl restart ledmatrix
sudo systemctl restart ledmatrix-web
+284
View File
@@ -0,0 +1,284 @@
# Web frontend architecture
This page covers where the web UI's JavaScript is going and how it gets
there one page at a time. The UI is Flask + HTMX + Alpine.js. Templates
live in `web_interface/templates/v3/` and static files in
`web_interface/static/v3/`.
Two rules hold at every step:
- **The Pi never builds anything.** It serves the files that are committed.
CI builds the generated CSS (`scripts/build_css.py`, see #685) and checks
it. The JavaScript needs no build at all: it is native ES modules that the
browser loads as they are.
- **Every page keeps working, and so does every plugin.** Third-party plugin
forms, `x-widget` scripts and plugin web UIs use the existing `window.*`
names. Each name keeps working as an alias until a release announces that
it will be removed.
## Where it started
- About 195 `window.*` globals. Their load order is held together by comments
repeated in the headers of `app-early.js`, `app-shell.js` and
`plugins_manager.js`.
- About 5,700 lines of inline `<script>` in the tab partials.
`js/htmx-config.js` re-runs every one of them after every htmx swap, so
each partial's code had to cope with running twice.
- The installed-plugin list is kept in four places.
- Plugin config forms are drawn by the `render_field` macro in
`partials/plugin_config.html`, which is about 1,100 lines of Jinja. It
duplicates the JS widgets. The server then needs about 430 lines to
rebuild JSON from the flat dotted keys the form posts. The soccer form
renders to 1.2 MB of HTML.
- `plugins_manager.js` is 3,800 lines. The owner decided that it needs a
namespace refactor before it is split, which is what this plan provides.
## Target
```
static/v3/js/
core/ ES modules ("type": "module" in core/package.json)
boot.js entry point; base.html loads it with <script type="module">
registry.js page lifecycle: init/destroy on htmx swaps
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
facade.js window.LEDMatrix and deprecated aliases
(later) escape.js, notify.js, dialog.js, streams.js, visibility.js,
store.js (the one installed-plugin store), form/renderer.js
pages/ one module per tab partial
cache.js export init(root, ctx), destroy(root, ctx)
...
```
### The page lifecycle
A converted partial has no `<script>`. Its root element names its page:
```html
<div class="..." data-page="cache"> ... </div>
```
`core/boot.js` registers each page with a loader:
`registry.register('cache', () => import('../pages/cache.js'))`. A page's
module is fetched only when its partial first appears.
`core/registry.js` handles the rest:
| Event | What the registry does |
|---|---|
| `htmx:beforeSwap` (on `document`, so it runs after the body-level handlers that can veto a swap) | If `detail.shouldSwap` is still true, destroys every mounted page inside the swap target |
| `htmx:afterSwap` | Destroys any mounted page whose root has left the document, then mounts every `data-page` root not mounted yet |
| `LEDMatrix.pages.refresh()` | Same as afterSwap. `loadPartialDirect` (the no-htmx fallback in `base.html`) calls it |
| `start()` | Mounts whatever is already on the page. Module scripts run deferred, so a partial may arrive first |
Mounting is idempotent: a root is never initialised twice.
Each mount gets a `ctx` object:
| Field | Contents |
|---|---|
| `ctx.root` | The page's root element |
| `ctx.name` | The page name |
| `ctx.signal` | An `AbortSignal` that is aborted after `destroy()` |
| `ctx.state` | A per-mount object for the page's own state |
| `ctx.api` | Shared service from `boot.js` |
| `ctx.notify` | Shared service from `boot.js` |
A page that passes `{ signal: ctx.signal }` to `addEventListener` and
`fetch` needs no teardown code. Its listeners and in-flight requests go
away when the partial is swapped out. `pages/cache.js` is the worked
example: its delete buttons use one delegated listener, rows are built with
`textContent` rather than markup strings, and a newer load supersedes an
older one.
### One facade
`window.LEDMatrix` is the only global the module code adds:
| Member | What it is |
|---|---|
| `api` | `core/api.js`: `get`/`post`/`put`/`del`. Resolves to the JSON body, rejects with an `ApiError` |
| `pages` | `register`, `refresh`, `list` |
| `notify(message, type)` | Calls `window.showNotification`, looked up at call time |
| `escape` | Read-through to `window.LEDEscape` |
| `widgets` | Read-through to `window.LEDMatrixWidgets` |
| `deprecate(name, target, replacement)` | Keeps an old `window.*` name working. It warns once in the console, then forwards |
`ApiError` carries `status`, `body`, `network` and `loginRequired`.
`escape`, `widgets` and `notify` are read at call time. The classic scripts
that define them are deferred, and a plugin may replace them.
Login: `base.html` wraps `window.fetch` before any other script runs, and
the wrapper sends a 401 with `X-LEDMatrix-Login` to the login page (#683).
`api.js` calls `window.fetch` at call time, so its requests get the same
redirect. It also rejects that answer quietly with `loginRequired`, so no
error message flashes up while the page navigates away.
### Serving modules from the Pi
- **MIME type.** A browser runs a module only when it is served with a
JavaScript MIME type. `app.py` pins `.js` and `.mjs` to `text/javascript`
rather than trusting the host's mimetypes table, and
`test/web_interface/test_es_modules.py` checks it.
- **Caching.** `url_for` adds `?v=<mtime>` to the entry script, but modules
import each other by plain relative URL, without the version. A static
`.js` request without `v` is therefore served `Cache-Control: no-cache`
(revalidated, so 304 when unchanged) instead of being cached as immutable
for a year. Versioned URLs keep the long cache. A later optimisation is an
import map that maps each module to its versioned URL.
- **Load order.** `boot.js` loads after every classic script. Modules are
deferred and run in document order with the deferred classic scripts.
Nothing classic may depend on a module at load time. A classic script that
needs a module service calls `window.LEDMatrix` at run time.
### One form model
`src/plugin_system/field_model.py` provides
`build_field_model(schema, config, plugin_id)`. It walks a plugin's schema
once and returns a JSON tree with these keys for each field:
- path, label, help, widget
- starting value, default
- constraints, options, secret flag
- the exact form controls the macro posts today (`inputs`)
- the JS widget it mounts (`mount`)
`test/test_field_model_parity.py` renders the real macro for every schema it
can find and checks that the model names the same controls, with the same
starting values, in the same order, and the same widget mounts. The schemas
come from `plugin-repos/`, `test/fixtures/plugins/`, the ledmatrix-plugins
monorepo when a checkout is present, and a synthetic schema that reaches
every branch of the macro. The test was mutation-checked when it was
written. Each of these deliberate model bugs makes it fail:
- dropping the checkbox-group sentinel
- dropping a table's `00:00` time default
- picking the first matching `<select>` option instead of the last
- dropping the `None` quirk
- missing all-hidden objects
The model mirrors the macro's quirks on purpose. The parity run surfaced
these:
- 83 number fields whose schema default is `null` render `value="None"`.
- Four array fields name an `x-widget` the core does not ship (`color`,
`tag-input`) and fall back to a comma-separated text box.
- A list-typed `type` uses its first entry, so `["null", "string"]` draws a
text box.
- Eleven objects with no properties and no widget render nothing.
These get fixed once, in the renderer, after the switch below.
## Switching forms to the model, behind a flag
Stage 1 (this change) only proves the model is complete. Rendering does not
change. The switch is staged so either path can be turned back on at any
point:
1. **Model endpoint.** `GET /api/v3/plugins/config/model?plugin_id=<id>`
returns `build_field_model(schema, prepared_masked_config)`. It uses the
same preparation as the partial: defaults merged, secrets masked.
2. **Renderer module.** `core/form/renderer.js` walks the model. It draws
plain fields itself and hands every widget to `LEDMatrixWidgets` through
one `mount(el, field)` adapter. The adapter keeps plugin widgets' existing
`render(container, config, value, options)` signature (the hard
constraint in PRODUCT.md). `getValue()` results are assembled into one
JSON object.
3. **Flag.** `plugin_config.html` renders the macro unless the form-model
flag is on. The flag is a `web_interface.form_model` setting in
`config.json` (default off), plus a per-browser override
(`localStorage.ledmatrixFormModel`) so a tester can compare both paths on
one device. With the flag on, the partial renders only a
`<div data-page="plugin-config" data-plugin-id="...">` root, and
`pages/plugin-config.js` fetches the model and renders it.
4. **JSON submit.** With the flag on, Save posts
`Content-Type: application/json` to the existing
`POST /api/v3/plugins/config` JSON path (`plugin_config.py`, `is_json`).
That path already validates against the schema and keeps secrets. No
dotted keys, no `__rendered_section`, no checkbox reconstruction.
5. **Save parity test.** This gates turning the flag on by default. For every
schema, posting the macro form's data and posting the renderer's JSON
must store the same config.
6. **Retire.** Once the flag has been on by default for a release with no
regressions, the macro shrinks to a no-JS fallback for plain fields, and
the form-encoded reconstruction (`_parse_form_value_with_schema`,
`_set_nested_value`, `_set_missing_booleans_to_false` and friends in
`api_v3/__init__.py`) is deleted. The settings search index is then built
from the model instead of from rendered HTML.
## Migration order
Smallest and most isolated first. `plugins_manager.js` goes last. Line counts
are the inline script in each partial today.
| # | Page | Inline JS | Why it is here |
|---|---|---|---|
| 1 | Cache (`cache.html`) | 163 lines, now 0 | **Done in stage 1.** One endpoint pair, no globals other pages use. The reference conversion |
| 2 | Rotation (`durations.html`) | 29 | Tiny. One htmx form |
| 3 | Operation History | 293 | No globals, read-only list |
| 4 | Config Editor (`raw_json.html`) | 212 | No globals. CodeMirror is set up and torn down in init/destroy |
| 5 | Backup & Restore | 232 | 5 globals used only by its own `onclick`s; these become delegated listeners plus deprecated aliases |
| 6 | Schedule | 193 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
| 7 | General | 147 | `webLogin` global and the security section. The first page that touches login |
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
| 11 | Fonts | 681 | Large, but self-contained (6 globals) |
| 12 | Logs | 801 | 14 globals, a stream and timers. Uses the visibility service from step 8 |
| 13 | Tools | 1,022 | 21 globals, MQTT bridge, Pixlet editor, diagnostics polling |
| 14 | Starlark app config, plugin config (`plugin_config.html`) | 123 + 294 | Plugin panels sit inside an Alpine `x-if` that removes them without an htmx swap. The registry's sweep covers that on the next swap; this step adds a MutationObserver or an `x-if` hook. Then the form-model flag (above) |
| 15 | Plugin Manager (`plugins.html` + `plugins_manager.js`) | 3,836-line file | Last. Split along the seams that already exist (installed grid, store, registries, Starlark section, on-demand) into `pages/plugins/*.js`. Its 42 globals become aliases. The four installed-plugin stores merge into one `core/store.js`, and `window.installedPlugins` becomes a getter over it |
The shell moves in parallel, a service at a time, with no page depending on
the order:
| Service | Current home | New module |
|---|---|---|
| `showNotification` | 4 versions | `core/notify.js` |
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
| SSE streams | `app-shell.js` | `core/streams.js` |
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` |
Each move leaves the old global as an alias. When the last inline script is
gone, the script re-execution in `htmx-config.js` and the "HTMX never
loaded" fallbacks in `base.html` can go too (keep the captive-page path).
## How the tests cover each step
The JS suites live in `test/js` (see `test/js/README.md` and
`docs/HOW_TO_RUN_TESTS.md`). In CI, the **Web UI JS tests** job installs
jsdom, starts the web interface and runs `node test/js/run_all.js` with
`REQUIRE_DOM=1`, so a skipped DOM suite fails the job.
`test/test_js_unit_suites.py` also runs every unit suite under pytest.
Unit suites need only node. They import the shipped modules directly:
`core/package.json` and `pages/package.json` mark those directories
`"type": "module"`.
| Suite | Kind | What it covers |
|---|---|---|
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment |
| `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) |
| `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states |
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; every registered page has its module and exactly one partial root; a converted partial has no `<script>` |
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
What each future step adds:
- **A page conversion** adds `dom/test_<page>_page.js`, built like the cache
suite: the real partial from the server, the real API's payload shape, N
swaps followed by one action that must make exactly one request, the
destroy and cancel behaviour, and escaping. `test_es_modules.py` picks up
the new page automatically. A unit suite that today slices a function out
of a template or `plugins_manager.js` and `eval`s it is rewritten to
import the module once that code moves (stage C of the plan).
- **A shell service move** adds a unit suite for the module and an alias
test showing the old global still works.
- **The form switch** adds the save parity test (macro form data and
renderer JSON store the same config, for every schema) and a DOM suite
for `pages/plugin-config.js`. Both run with the flag on and off.
- **`plugins_manager.js`.** The existing DOM suites (`test_installed_dom.js`,
`test_store_dom.js`, `test_no_double_fetch.js`) already test the real
Plugin Manager in jsdom. They stay green throughout the split and are the
gate for it, alongside the unit suites that pin its card rendering and
escaping.
+68 -5
View File
@@ -78,7 +78,9 @@ The Overview tab provides at-a-glance information and quick actions:
- **Start Display** / **Stop Display** — control the display service
- **Restart Display Service** — apply configuration changes
- **Restart Web Service** — restart the web UI itself
- **Update Code** — `git pull` the latest version (stashes local changes)
- **Update Code** — update to the newest version on the update channel (the
newest release on Stable, the newest code on `main` on Beta; stashes local
changes). The channel is set on the General tab.
- **Reboot System** / **Shutdown System** — confirm-gated power controls
**Display Preview:**
@@ -90,6 +92,11 @@ The Overview tab provides at-a-glance information and quick actions:
Configure basic system settings:
- **Automatic Updates** — weekly updates with a health check and rollback
- **Update Channel** — **Stable** (default) installs releases; **Beta**
installs the newest code on `main` before it is released. Switching to
Stable never installs an older version: a device ahead of the newest
release keeps following `main` until a release includes it
- **Timezone** — used by all time/date displays
- **Location** — city/state/country for weather and other location-aware
plugins
@@ -130,6 +137,34 @@ Configure basic system settings:
Click **Save** to write changes to `config/config.json`. Most changes
require a display service restart from **Overview**.
Below the settings, the **Security** section (its own buttons, not the Save
button) controls the optional login:
- **Web interface password** — off by default. Setting one turns login on:
browsers on your network then see a login page, and stay logged in for 30
days (across restarts). The browser you set it from stays logged in.
Changing the password logs every other browser out. **Turn login off**
needs the current password. A **Log out** button appears in the header
while you are logged in. Five wrong passwords in a minute (or 30 in an
hour) from one address make it wait.
- **API tokens** — for Home Assistant, scripts, or the MQTT bridge on another
machine. Give it a name, click **Create token**, and copy the token right
away: it is shown once. Revoke it here when it is no longer needed.
- Never asked for a password: a browser on the Pi itself, and the Wi-Fi setup
page while the Pi is in access-point mode (so you can always get it back on
a network).
**Forgot the password?** SSH into the Pi and run:
```bash
sudo python3 ~/LEDMatrix/scripts/reset_web_password.py
```
(use the folder LEDMatrix is installed in). Login is off again right away,
no restart needed, and you can set a new password. API tokens are kept; add
`--revoke-tokens` to delete them too. Alternatively, open
`http://localhost:5000` in a browser on the Pi itself.
### Display Tab
Configure your LED matrix hardware:
@@ -346,6 +381,14 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
- `POST /api/v3/plugins/install` — Install a plugin from the store
- `POST /api/v3/plugins/install-from-url` — Install a plugin from a GitHub URL
If the optional login is on, send an API token (General > Security):
```bash
curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current
```
Scripts running on the Pi itself need no token.
**Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation.
---
@@ -408,9 +451,27 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
## Security Considerations
**Network Access:**
- The interface is accessible to anyone on your local network
- No authentication is currently implemented
- Recommended for trusted networks only
- By default the interface is accessible to anyone on your local network
- An optional password (General > Security) makes every page and API call
need a login or an API token; see [General Tab](#general-tab). Requests
from the Pi itself and the Wi-Fi setup flow in access-point mode stay open,
and `/api/v3/health` answers only its overall status without a login
- The interface speaks plain HTTP, so the password and tokens cross your
network unencrypted: still recommended for trusted networks only
- Behind a reverse proxy **on the Pi**, make it send `X-Forwarded-For`
(nginx: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`).
Without it every proxied request looks like it comes from the Pi itself,
which is never asked to log in
**Other websites:**
- A web page you open elsewhere could otherwise make your browser send
commands to the Pi (reboot, update, config changes). The interface refuses
any change request whose `Origin`/`Referer` header names a different site
(403 `CROSS_SITE_REQUEST`), so use the interface from its own address.
- Scripts, curl, Home Assistant and the MQTT bridge send no such header and
keep working. Behind a reverse proxy, forward the original `Host` header
with its port (nginx: `proxy_set_header Host $http_host;` -- `$host`
drops the port).
**Best Practices:**
1. Run on a private network (not exposed to internet)
@@ -428,7 +489,9 @@ The web interface uses modern web technologies:
- **Backend:** Flask with Blueprint-based modular design
- **Frontend:** HTMX for dynamic content, Alpine.js for reactive components
- **Styling:** Tailwind CSS for responsive design
- **Styling:** Tailwind CSS utilities, generated at development time and
committed (the Pi never builds CSS; see
[`web_interface/README.md`](../web_interface/README.md#styling-tailwind-css))
- **Real-Time:** Server-Sent Events (SSE) for live updates
### File Locations
+4
View File
@@ -835,6 +835,10 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config.json" ]; then
cat > "$PROJECT_ROOT_DIR/config/config.json" <<'EOF'
{
"web_display_autostart": true,
"auto_update": {
"enabled": false,
"channel": "stable"
},
"timezone": "America/Chicago",
"display": {
"hardware": {
+7
View File
@@ -96,6 +96,13 @@ environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
which keeps a broker password out of a file on disk — put it in a systemd
drop-in with `Environment=` or `EnvironmentFile=` instead.
**Web login.** If the web interface's optional login is on (General >
Security), a bridge running on the Pi itself still needs nothing: requests from
the Pi are never asked to log in. A bridge on another machine needs an API
token: create one under General > Security and set `"ledmatrix_api_token"`
(or `LEDMATRIX_MQTT_LEDMATRIX_API_TOKEN`, or the token field in the Tools tab's
bridge settings). It is sent as `Authorization: Bearer <token>`.
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips
certificate verification and exists only for a self-signed broker on a
trusted LAN; it logs a warning when used.
@@ -66,6 +66,10 @@ DEFAULTS = {
"mqtt_tls": False,
"mqtt_tls_insecure": False,
"ledmatrix_api_base": "http://localhost:5000",
# Only needed when the web interface's optional login is on AND the bridge
# reaches it from another machine: requests from the Pi itself never need
# one. Create it under General > Security; sent as a Bearer token.
"ledmatrix_api_token": None,
"request_timeout": 15,
"on_demand_duration": None,
"log_level": "INFO",
@@ -125,10 +129,13 @@ class LEDMatrixClient:
"""
def __init__(self, api_base: str, timeout: int = 15,
session: Optional[requests.Session] = None):
session: Optional[requests.Session] = None,
api_token: Optional[str] = None):
self.api_base = api_base.rstrip("/")
self.timeout = timeout
self.session = session or requests.Session()
if api_token:
self.session.headers["Authorization"] = f"Bearer {api_token}"
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
url = f"{self.api_base}/api/v3{path}"
@@ -403,7 +410,8 @@ class Bridge:
self.status_topic = f"{self.command_topic}/status"
self.state_topic = f"{self.command_topic}/state"
self.availability_topic = f"{self.command_topic}/availability"
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"],
api_token=config.get("ledmatrix_api_token") or None)
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
self._stop = threading.Event()
self._mqtt = None
+17
View File
@@ -22,6 +22,7 @@ src/common/api_helper.py
src/common/bdf_font.py
src/common/espn_dates.py
src/common/favorite_team_check.py
src/common/fetch_service.py
src/common/font_layout.py
src/common/frame_timing.py
src/common/json_body.py
@@ -34,9 +35,14 @@ src/common/snapshot_policy.py
src/common/sports_card.py
src/common/sports_card_wrappers.py
src/common/sports_celebration.py
src/common/sports_display_rules.py
src/common/sports_fetch.py
src/common/sports_font_path.py
src/common/sports_live_scroll.py
src/common/sports_plugin_host.py
src/common/sports_scroll.py
src/common/sports_timezone.py
src/common/sports_vegas.py
src/config_service.py
src/core_config_keys.py
src/deprecation.py
@@ -45,19 +51,26 @@ src/display_geometry.py
src/dynamic_team_resolver.py
src/exceptions.py
src/font_usage.py
src/ipc/__init__.py
src/ipc/client.py
src/ipc/contract.py
src/ipc/server.py
src/logging_config.py
src/logo_downloader.py
src/matrix_support.py
src/pi5_matrix_support.py
src/plugin_system/__init__.py
src/plugin_system/compatibility.py
src/plugin_system/field_model.py
src/plugin_system/operation_history.py
src/plugin_system/operation_queue.py
src/plugin_system/operation_types.py
src/plugin_system/plugin_catalog.py
src/plugin_system/plugin_dirs.py
src/plugin_system/plugin_executor.py
src/plugin_system/plugin_health.py
src/plugin_system/plugin_loader.py
src/plugin_system/plugin_runtime.py
src/plugin_system/plugin_state.py
src/plugin_system/repo_urls.py
src/plugin_system/resource_monitor.py
@@ -70,13 +83,17 @@ src/plugin_system/testing/loading.py
src/plugin_system/testing/mocks.py
src/plugin_system/testing/plugin_test_base.py
src/plugin_system/testing/sizes.py
src/plugin_system/testing/vegas.py
src/plugin_system/vegas_elements.py
src/redaction.py
src/scan_order.py
src/startup_validator.py
src/vegas_mode/__init__.py
src/vegas_mode/config.py
src/vegas_mode/coordinator.py
src/vegas_mode/elements.py
src/vegas_mode/geometry.py
src/vegas_mode/live_worker.py
src/vegas_mode/stream_manager.py
src/web_interface/api_helpers.py
src/web_interface/config_arrays.py
+8
View File
@@ -14,6 +14,14 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path:
sys.path.insert(0, project_dir)
# Under systemd the watchdog clock is already running, and start-up (plugin
# loads, initial updates) takes far longer than the render loop's limit. Widen
# it before anything slow is imported; the render loop narrows it again once
# its first frame is on the panel. A no-op outside systemd. Standard library
# only -- see src/display_watchdog.py.
from src import display_watchdog
display_watchdog.watchdog.begin_startup()
# Parse command-line arguments BEFORE any imports
parser = argparse.ArgumentParser(description='LEDMatrix Display Controller')
parser.add_argument('-e', '--emulator', action='store_true',
+5
View File
@@ -149,6 +149,11 @@
},
"description": "Array of display mode names this plugin provides"
},
"vegas_participation": {
"type": "string",
"enum": ["scroll", "pause", "exclude"],
"description": "How this plugin takes part in Vegas mode by default: 'scroll' (its content scrolls by), 'pause' (the scroll stops for its turn and display() draws it full screen) or 'exclude' (left out). A user's per-plugin vegas_participation setting overrides it. Omit it to derive the participation from get_vegas_display_mode() / get_vegas_content_type(). Cores before 3.8.0 ignore it."
},
"api_requirements": {
"type": "array",
"items": {
+3
View File
@@ -34,11 +34,14 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
| `frame_soak.py` | diagnostic | Soaks a running display and reports how often frames reached the panel late (docs/SCROLL_PERFORMANCE.md) |
| `install_dependencies_apt.py` | keep | Dependency installer that tries apt packages first, then pip (installer Step 7, plugin loader) |
| `install_plugin_dependencies.sh` | diagnostic | Installs plugin requirements by hand when the automatic install fails |
| `plugin_api_usage.py` | dev-only | Scans core, the plugin monorepo and the registry's third-party plugins for callers of every `@deprecated` core method; its output is [docs/DEPRECATIONS_3.8.md](../docs/DEPRECATIONS_3.8.md) |
| `prove_security.py` | keep | Security property checks run by pre-commit |
| `render_bench.py` | diagnostic | Benchmarks the render loop against the panel's real refresh rate on a synthetic strip |
| `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
| `reset_web_password.py` | keep | Turns the optional web login off when the password is lost (`sudo python3 scripts/reset_web_password.py`; docs/WEB_INTERFACE_GUIDE.md) |
| `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 |
| `sports_drift_report.py` | keep | Counts the different bodies of each method across the nine scoreboards in a `ledmatrix-plugins` checkout (report-only CI job; docs/SPORTS_UNIFICATION.md) |
| `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 |
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Build the web UI's Tailwind CSS with the pinned standalone Tailwind CLI.
The generated files are committed, so the Pi never builds anything. Run this
on a dev machine (or let CI run it) after changing a template, a static JS
file, or anything under ``web_interface/tailwind/``:
python3 scripts/build_css.py # rebuild the committed CSS
python3 scripts/build_css.py --check # exit 1 if the committed CSS is stale
No Node or npm: the script downloads Tailwind's standalone CLI (a single
executable) for this OS and CPU from the Tailwind GitHub release, checks it
against the SHA-256 pinned below, and caches it outside the repo
(``$LEDMATRIX_TAILWIND_CACHE``, else the per-user cache directory).
Outputs (see ``BUILDS``):
- ``web_interface/static/v3/tailwind.css``: the utilities the templates and
static JS use. Linked before ``app.css`` in ``base.html``.
- ``web_interface/static/v3/plugin-frame.css``: preflight plus a broad set of
common utilities, for plugin ``web_ui/`` fragments served in an iframe.
Their markup lives in plugin repos, so it can't be scanned; the safelist in
``plugin-frame.config.js`` stands in for it.
To move to a new Tailwind v3 release, change ``TAILWIND_VERSION`` and every
hash in ``TAILWIND_ASSETS`` (the release's ``sha256sums.txt``, or the digests
from ``gh api repos/tailwindlabs/tailwindcss/releases/tags/<tag>``), rebuild,
and review the diff of the generated CSS.
"""
from __future__ import annotations
import argparse
import hashlib
import os
import platform
import shutil
import stat
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys
import tempfile
import urllib.request
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TAILWIND_DIR = PROJECT_ROOT / "web_interface" / "tailwind"
STATIC_V3 = PROJECT_ROOT / "web_interface" / "static" / "v3"
TAILWIND_VERSION = "3.4.19"
# asset name -> SHA-256, from the v3.4.19 release.
TAILWIND_ASSETS = {
"tailwindcss-linux-arm64": "e5b2d27694daa80cc52ec29553ba2c6bd43d86bd51a9d633ed24058b9c05a676",
"tailwindcss-linux-armv7": "e3610b109a64720295e1c00a18dd2d6d79d3cddc618219aa0830de97a55429a4",
"tailwindcss-linux-x64": "4af3198c015616ea7d6617974ec3d70d987ecc00c1ca8463b0a30fd65cc7c06e",
"tailwindcss-macos-arm64": "7fdeb00818b6214a337383063282b2361ecb08bbc08f8c8a7ba97ee1e2eaa4fe",
"tailwindcss-macos-x64": "a597f407e0f1f03535731f5b42f1576a8152cb5fffc2f38e754722bc0c280045",
"tailwindcss-windows-arm64.exe": "f2b6b999747aa0ae31999d59db117b1ba1e4e15e17675d7108e30aac4b680686",
"tailwindcss-windows-x64.exe": "a15158c4c5e0e7a75f7229bfe4986fe7710d2edc468b6f96c8981f78ab211347",
}
DOWNLOAD_URL = (
"https://github.com/tailwindlabs/tailwindcss/releases/download/v{version}/{asset}"
)
# (input CSS, config, output) -- all relative to the project root.
BUILDS = (
(
"web_interface/tailwind/app.input.css",
"web_interface/tailwind/tailwind.config.js",
"web_interface/static/v3/tailwind.css",
),
(
"web_interface/tailwind/plugin-frame.input.css",
"web_interface/tailwind/plugin-frame.config.js",
"web_interface/static/v3/plugin-frame.css",
),
)
def asset_name() -> str:
"""The release asset for this OS and CPU."""
system = platform.system()
machine = platform.machine().lower()
if machine in ("x86_64", "amd64"):
arch = "x64"
elif machine in ("aarch64", "arm64"):
arch = "arm64"
elif machine.startswith("armv7") or machine == "armv8l":
arch = "armv7"
else:
raise SystemExit(f"No standalone Tailwind CLI for CPU {machine!r}.")
if system == "Linux":
name = f"tailwindcss-linux-{arch}"
elif system == "Darwin":
name = f"tailwindcss-macos-{arch}"
elif system == "Windows":
name = f"tailwindcss-windows-{arch}.exe"
else:
raise SystemExit(f"No standalone Tailwind CLI for {system!r}.")
if name not in TAILWIND_ASSETS:
raise SystemExit(f"No standalone Tailwind CLI for {system} {machine}.")
return name
def cache_dir() -> Path:
override = os.environ.get("LEDMATRIX_TAILWIND_CACHE")
if override:
return Path(override)
if platform.system() == "Windows":
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
elif platform.system() == "Darwin":
base = Path.home() / "Library" / "Caches"
else:
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
return base / "ledmatrix" / "tailwindcss"
def sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def ensure_cli() -> Path:
"""Path to the verified CLI, downloading it on first use."""
name = asset_name()
expected = TAILWIND_ASSETS[name]
target = cache_dir() / f"v{TAILWIND_VERSION}" / name
if target.is_file():
if sha256_of(target) == expected:
return target
print(f"Cached {target} fails its SHA-256 check; downloading it again.")
target.unlink()
target.parent.mkdir(parents=True, exist_ok=True)
url = DOWNLOAD_URL.format(version=TAILWIND_VERSION, asset=name)
if not url.startswith("https://"):
raise SystemExit(f"Refusing to download the Tailwind CLI over a non-https URL: {url}")
print(f"Downloading Tailwind CLI v{TAILWIND_VERSION} ({name})...")
fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=".download-")
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "wb") as out, urllib.request.urlopen(url, timeout=120) as resp: # nosec B310 - https only, checked above
shutil.copyfileobj(resp, out)
actual = sha256_of(tmp)
if actual != expected:
raise SystemExit(
f"SHA-256 mismatch for {url}\n expected {expected}\n got {actual}"
)
tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.replace(tmp, target)
finally:
if tmp.exists():
tmp.unlink()
return target
def run_build(
cli: Path, input_css: str, config: str, output: Path, work_dir: Path
) -> None:
# The minifier's rule merging depends on the input file's line endings,
# so a Windows checkout (core.autocrlf, CRLF) would build different bytes
# than CI's Linux one and --check would fail. Feed the CLI an LF copy.
# (Content files' line endings don't matter; @import isn't used, so the
# copy's location doesn't either.)
lf_input = work_dir / (Path(input_css).name)
lf_input.write_bytes(
(PROJECT_ROOT / input_css).read_bytes().replace(b"\r\n", b"\n")
)
cmd = [
str(cli),
"--input", str(lf_input),
"--config", str(PROJECT_ROOT / config),
"--output", str(output),
"--minify",
]
# NODE_ENV=production and no browserslist lookup keep the output the
# same on every machine.
env = dict(os.environ, NODE_ENV="production", BROWSERSLIST_IGNORE_OLD_DATA="1")
# The CLI path is computed here (cache dir + pinned asset name) and the
# binary was SHA-256-verified by ensure_cli(); env is os.environ plus two
# fixed values.
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env, capture_output=True, text=True) # nosec B603 - list-form argv, no shell # nosemgrep
if result.returncode != 0:
sys.stderr.write(result.stdout + result.stderr)
raise SystemExit(f"Tailwind build failed for {input_css}")
# The CLI writes without a trailing newline; add one so the committed
# file is a well-formed text file and editors leave it alone.
text = output.read_text(encoding="utf-8").replace("\r\n", "\n")
if not text.endswith("\n"):
text += "\n"
output.write_bytes(text.encode("utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"--check",
action="store_true",
help="build to a temp dir and fail if the committed CSS differs",
)
args = parser.parse_args(argv)
cli = ensure_cli()
stale = []
with tempfile.TemporaryDirectory(prefix="ledmatrix-css-") as tmp:
for input_css, config, output in BUILDS:
committed = PROJECT_ROOT / output
built = Path(tmp) / Path(output).name if args.check else committed
run_build(cli, input_css, config, built, Path(tmp))
if args.check:
old = (
committed.read_bytes().replace(b"\r\n", b"\n")
if committed.is_file()
else None
)
if old != built.read_bytes():
stale.append(output)
else:
print(f"Wrote {output} ({committed.stat().st_size:,} bytes)")
if stale:
print(
"The committed CSS is out of date: " + ", ".join(stale) + "\n"
"Run `python3 scripts/build_css.py` and commit the result."
)
return 1
if args.check:
print("Committed CSS is up to date.")
return 0
if __name__ == "__main__":
sys.exit(main())
+21
View File
@@ -72,6 +72,7 @@ from src.plugin_system.testing.harness import ( # noqa: E402
from src.plugin_system.testing.sizes import ( # noqa: E402
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
)
from src.plugin_system.testing.vegas import check_plugin_vegas_elements # noqa: E402
logger = get_logger("[Check Plugin]")
@@ -193,6 +194,24 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
all_run_results.extend(results)
# Live Vegas elements, for a plugin that has them: checked once, at the
# first size, with the base config.
width, height = effective_sizes[0]
try:
vegas = check_plugin_vegas_elements(
plugin_id, plugin_dir, full_config, effective_mock_data, width, height,
run_update=effective_run_update)
except Exception as exc: # noqa: BLE001 - one plugin must not end an --all run
all_run_results.append(RenderResult(
plugin_id, width, height, "vegas elements",
error=f"the element check itself failed: {exc!r}"))
return all_run_results
if vegas.implemented:
all_run_results.append(RenderResult(
plugin_id, width, height, "vegas elements",
error="; ".join(vegas.errors) or None,
notes=[f"{vegas.elements} element(s), {vegas.live} live"] + vegas.warnings))
return all_run_results
@@ -236,6 +255,8 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
f" controller skips the mode")
else:
status, detail = "FAIL", ""
if r.notes:
detail += f" ({'; '.join(r.notes)})"
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
print()
return everything_ok
+54 -3
View File
@@ -200,12 +200,21 @@ def api_plugin_defaults(plugin_id):
return jsonify({'defaults': defaults})
#: /api/render "vegas" values: the plugin's block of the Vegas strip, built
#: from its live elements (falling back to its Vegas content, as the ticker
#: does) or from its ordinary Vegas content only.
VEGAS_VIEWS = ('live', 'plain')
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
skip_update):
skip_update, vegas=None):
"""Render one plugin at one size. Returns the /api/render response dict.
A fresh plugin instance per call, mirroring the safety harness, so sizes
never share state.
never share state. With ``vegas`` set ('live' or 'plain') the image is the
plugin's block of the Vegas strip instead of its display(), laid out by
the ticker's own code (src/plugin_system/testing/vegas.py), and the
response lists where each live element sits in it.
"""
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
from src.plugin_system.plugin_loader import PluginLoader
@@ -243,6 +252,10 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
warnings.append(f"update() raised: {type(e).__name__} — see server log")
if vegas:
return _vegas_response(plugin_id, plugin_instance, display_manager, vegas,
start_time, errors, warnings)
# Run display()
try:
plugin_instance.display(force_clear=True)
@@ -262,6 +275,40 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
}
def _vegas_response(plugin_id, plugin_instance, display_manager, vegas, start_time,
errors, warnings):
"""The /api/render response for the Vegas strip view."""
import base64
import io
from src.plugin_system.testing.vegas import render_vegas_strip
block, layout = None, []
try:
block, layout = render_vegas_strip(plugin_instance, plugin_id, display_manager,
live=(vegas == 'live'))
except Exception as e:
logger.warning("Vegas render raised for plugin %s", plugin_id, exc_info=True)
errors.append(f"Vegas render raised: {type(e).__name__} — see server log")
if block is None:
if not errors:
errors.append("The plugin has no Vegas content")
block = display_manager.image
elif vegas == 'live' and not layout:
warnings.append("No live elements: this is the plugin's ordinary Vegas content")
buffer = io.BytesIO()
block.convert('RGB').save(buffer, format='PNG')
return {
'image': 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode('ascii'),
'width': block.width,
'height': block.height,
'render_time_ms': round((time.time() - start_time) * 1000, 1),
'errors': errors,
'warnings': warnings,
'live_elements': [{'key': key, 'x': x, 'width': width} for x, key, width in layout],
}
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
"""Re-derive a plugin directory from the search dirs' own listings.
@@ -333,6 +380,10 @@ def api_render():
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
vegas = data.get('vegas') or None
if vegas is not None and vegas not in VEGAS_VIEWS:
return jsonify({'error': f'vegas must be one of {", ".join(VEGAS_VIEWS)}'}), 400
try:
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
except LookupError:
@@ -345,7 +396,7 @@ def api_render():
try:
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
mock_data, width, height, skip_update)
mock_data, width, height, skip_update, vegas=vegas)
except Exception:
app.logger.exception('plugin load failed during render')
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
+93 -3
View File
@@ -9,7 +9,10 @@ reports the difference. Nothing is stopped, restarted or drawn.
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)
# the things that used to make the render loop miss refreshes). An open
# preview is encoded at most once a second; through 3.8.0 it was up to
# five times, so a --preview run from before that change is not comparable
# with one from after it
python3 scripts/frame_soak.py --preview
# quick look at the totals since the service started
@@ -27,14 +30,24 @@ 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,
freezes gaps of 250ms+ inside a scroll: recomposes, plugin handovers
the display controller does not tag (see handover gaps),
blocking calls on the render thread. Reported, not failed on,
since some are handovers between plugins rather than faults.
handover gaps the same length of gap where the display controller had just
started a screen's turn (also the same mode's again): its
first display() drawing. Counted here instead of under
freezes. Stats from a service older than this count have no
such line, and their freezes include these, so do not
compare freeze counts across that change.
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.
after work frames presented straight after tagged render-thread work
(Vegas strip extensions, live-element patches), with their own
late rate. Shown only when something tagged its work.
"""
from __future__ import annotations
@@ -55,7 +68,8 @@ from src.common.frame_timing import ( # noqa: E402
)
#: 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
#: puts the display service's snapshot writer at the viewer rate
#: (snapshot_policy.VIEWER_INTERVAL). Same path as
#: DisplayManager._viewer_marker_path.
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
@@ -126,6 +140,56 @@ def _edge(index: int, bucket_ms: float):
return round((index + 1) * bucket_ms, 2)
def op_rows(totals: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""Per kind of noted render-thread work: how often its frame was late.
A kind's frames are the ones presented straight after that work ran (see
"Operations" in src/common/frame_timing.py). Stats from a recorder that
predates the counters have none, and give an empty table.
"""
frames = totals.get("op_frames") or {}
late = totals.get("late_op_frames") or {}
freezes = totals.get("op_freezes") or {}
moved = totals.get("op_bytes") or {}
rows = {}
for kind in sorted(set(frames) | set(freezes)):
count = frames.get(kind, 0)
if not count and not freezes.get(kind, 0):
continue
rows[kind] = {
"frames": count,
"late": late.get(kind, 0),
"late_pct": (round(100.0 * late.get(kind, 0) / count, 3)
if count else None),
"freezes": freezes.get(kind, 0),
"bytes": moved.get(kind, 0),
}
return rows
def gc_window(before: Dict[str, Any], after: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Garbage collection over the run, or None from a service without the
monitor. The counters are cumulative since the service started, so they
are differenced like the totals; the longest is since the start."""
ga = after.get("gc")
if not ga:
return None
gb = before.get("gc") or {}
def minus(key):
return [a - b for a, b in zip(ga.get(key, []),
gb.get(key) or [0] * len(ga.get(key, [])))]
seconds = minus("seconds")
return {
"collections": minus("collections"),
"ms": [round(x * 1000.0, 1) for x in seconds],
"long_pauses": ga.get("long_pauses", 0) - gb.get("long_pauses", 0),
"long_ms": round((ga.get("long_seconds", 0.0)
- gb.get("long_seconds", 0.0)) * 1000.0, 1),
"threshold_ms": ga.get("threshold_ms"),
"max_ms_since_start": ga.get("max_ms"),
}
def build_report(before, after, preview: bool) -> Dict[str, Any]:
delta = diff(before, after)
totals = delta["totals"]
@@ -155,10 +219,15 @@ def build_report(before, after, preview: bool) -> Dict[str, Any]:
"freezes": totals["freezes"],
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
"freeze_seconds": round(totals["freeze_seconds"], 2),
# None from a service that predates the count: its handovers are
# among the freezes above.
"handover_freezes": totals.get("handover_freezes"),
"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()},
"ops": op_rows(totals),
"gc": gc_window(before, after),
}
# 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
@@ -209,6 +278,16 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
if report["freezes"]:
print(" by length: " + ", ".join(
f"{k}: {v}" for k, v in report["freeze_by"].items()))
if report.get("handover_freezes") is not None:
print(f"Handover gaps {report['handover_freezes']}"
" >=250ms before a new screen's first frame; not in the freezes")
gc_stats = report.get("gc")
if gc_stats:
counts, ms = gc_stats["collections"], gc_stats["ms"]
print(f"Garbage collection gen0/1/2 {counts[0]}/{counts[1]}/{counts[2]}"
f" ({ms[0]}/{ms[1]}/{ms[2]} ms) >={gc_stats['threshold_ms']:g}ms: "
f"{gc_stats['long_pauses']} ({gc_stats['long_ms']} ms)"
f" longest since start {gc_stats['max_ms_since_start']} ms")
print()
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
for name in ("blit", "wait", "work", "interval_per_hold"):
@@ -216,6 +295,17 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
for k in ("p50", "p95", "p99", "max")))
print()
ops = report.get("ops") or {}
if ops:
# Frames presented straight after render-thread work of each kind. A
# late rate well above the overall one points at that work.
print(f"{'after work':<18}{'frames':>8}{'late':>8}{'late %':>8}"
f"{'freezes':>9}{'MB moved':>10}")
for kind, row in ops.items():
pct = "-" if row["late_pct"] is None else f"{row['late_pct']:g}"
print(f"{kind:<18}{row['frames']:>8}{row['late']:>8}{pct:>8}"
f"{row['freezes']:>9}{row['bytes'] / 1e6:>10.2f}")
print()
if report["late_pct"] is None:
print("RESULT nothing scrolled - no verdict")
elif not locked(report, limit):
+778
View File
@@ -0,0 +1,778 @@
#!/usr/bin/env python3
"""Who still calls or overrides the core methods marked ``@deprecated``?
A deprecated plugin-facing method may only be removed once nothing uses it,
and plugins live in other repositories. This script answers the question for
every method ``src/deprecation.py``'s decorator marks in core:
1. it lists the markers by parsing ``src/`` (so the list can never drift from
the code);
2. it scans, with the ``ast`` module, core itself (``src/``,
``web_interface/``, ``scripts/``, the top-level ``*.py``; ``test/``
separately), the official monorepo's ``plugins/`` directory, and every
third-party plugin the monorepo's ``plugins.json`` lists with its own repo
URL (shallow-cloned read-only into a cache directory);
3. it reports, per method and per plugin, the calls and overrides it found,
and a verdict: unused (safe to remove in the marker's release), still used
(keep or migrate those plugins first), or needs review.
Matching is by method name, so it has to separate real uses from unrelated
methods that happen to share the name (the weather plugin's own ``draw_sun``,
say). Each hit is classified by what it is attached to:
* **call** -- ``<receiver>.name`` where the receiver is named like the owning
object (``self.cache_manager``, ``display_manager``, ``plugin_manager`` ...,
or a local alias assigned from one), or ``self``/``super()`` inside a class
that subclasses the owner. Attribute references that are not called
(``callback=cm.get_cache_metrics``) count too.
* **override** -- ``def name`` in a class that subclasses the owner.
* **review** -- ``<receiver>.name`` where the receiver says nothing about its
type, or ``getattr(obj, "name")``. Possibly a real use; read the listed line.
* **unrelated** -- ``self.name`` inside a class that defines ``name`` itself
and does not subclass the owner, ``Klass.name`` where the same tree defines
``Klass.name``, or ``def name`` in such a class: a name collision, not a use.
* **internal** -- a hit inside the body of another deprecated core method
(``draw_rain`` calling ``draw_cloud``): it keeps the method only as long as
that caller is kept.
Only calls and overrides make a method "still used"; review hits make it
"needs review"; hits in test files are listed but never block removal (a test
that mocks a method does not need it to exist).
python3 scripts/plugin_api_usage.py # clone everything, print Markdown
python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins
python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md
python3 scripts/plugin_api_usage.py --format json
Nothing is ever written to the repositories it scans: the monorepo path is only
read, and clones live in ``--cache-dir``.
"""
from __future__ import annotations
import argparse
import ast
import json
import os
import re
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys
import tempfile
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple
REPO_ROOT = Path(__file__).resolve().parent.parent
MONOREPO_URL = "https://github.com/ChuckBuilds/ledmatrix-plugins"
MONOREPO_SLUG = "chuckbuilds/ledmatrix-plugins"
#: Receiver names that mean "this is the owning core object". Compared against
#: the last name in the receiver (``self.plugin_manager.cache_manager`` ->
#: ``cache_manager``), lower-cased with leading underscores stripped.
OWNER_RECEIVERS: Dict[str, Tuple[str, ...]] = {
"CacheManager": ("cache_manager", "cache_mgr", "cachemanager", "cache", "cm"),
"DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"),
"FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"),
"PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"),
}
#: Directories never scanned (vendored environments, VCS metadata, caches).
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "env",
"site-packages", ".tox", ".mypy_cache", ".pytest_cache"}
CORE_DIRS = ("src", "web_interface", "scripts")
CORE_TEST_DIRS = ("test",)
# --------------------------------------------------------------------------
# Markers
@dataclass
class Marker:
owner: str # class name, e.g. "CacheManager"
method: str
removal: str
alternative: Optional[str]
module: str # e.g. "src.cache_manager"
line: int
@property
def key(self) -> str:
return f"{self.owner}.{self.method}"
def _decorator_name(node: ast.expr) -> Optional[str]:
target = node.func if isinstance(node, ast.Call) else node
if isinstance(target, ast.Name):
return target.id
if isinstance(target, ast.Attribute):
return target.attr
return None
def find_markers(core_root: Path) -> List[Marker]:
"""Every ``@deprecated(...)`` method under ``core_root/src``."""
markers: List[Marker] = []
for path in sorted((core_root / "src").rglob("*.py")):
if path.name == "deprecation.py":
continue
tree = _parse(path)
if tree is None:
continue
module = ".".join(path.relative_to(core_root).with_suffix("").parts)
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
for fn in cls.body:
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for dec in fn.decorator_list:
if _decorator_name(dec) != "deprecated" or not isinstance(dec, ast.Call):
continue
args = [a.value if isinstance(a, ast.Constant) else None for a in dec.args]
kw = {k.arg: k.value.value for k in dec.keywords
if isinstance(k.value, ast.Constant)}
removal = args[0] if args else kw.get("removal")
alternative = args[1] if len(args) > 1 else kw.get("alternative")
markers.append(Marker(cls.name, fn.name, str(removal), alternative,
module, fn.lineno))
return markers
# --------------------------------------------------------------------------
# Scanning
@dataclass
class Hit:
kind: str # call | override | review | unrelated | internal
path: str
line: int
code: str
test: bool
via: Optional[str] = None # internal: the deprecated core method it sits in
@dataclass
class Source:
"""One plugin (or core) tree to scan."""
name: str
group: str # core | core-tests | monorepo | third-party
root: Optional[Path]
error: Optional[str] = None
hits: Dict[str, List[Hit]] = field(default_factory=lambda: defaultdict(list))
files: int = 0 # Python files scanned
def _parse(path: Path) -> Optional[ast.AST]:
try:
return ast.parse(path.read_text(encoding="utf-8", errors="replace"), str(path))
except (SyntaxError, ValueError):
return None
def _iter_py(root: Path) -> Iterator[Path]:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in filenames:
if name.endswith(".py"):
yield Path(dirpath) / name
def _is_test_path(rel: Path) -> bool:
parts = [p.lower() for p in rel.parts]
return (any(p in ("test", "tests") for p in parts[:-1])
or parts[-1].startswith("test_") or parts[-1].endswith("_test.py")
or parts[-1] == "conftest.py")
def _terminal(node: ast.expr) -> Optional[str]:
"""The last name in a receiver expression, or None if it has none."""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
if isinstance(node, ast.Call):
return _terminal(node.func)
if isinstance(node, ast.Subscript):
return _terminal(node.value)
return None
def _norm(name: Optional[str]) -> str:
return (name or "").lstrip("_").lower()
def _base_names(cls: ast.ClassDef) -> List[str]:
return [t for t in (_terminal(b) for b in cls.bases) if t]
class _Scanner(ast.NodeVisitor):
"""Collect hits for every marked method name in one file."""
def __init__(self, markers: Dict[str, List[Marker]], lines: List[str],
rel: str, test: bool, core_modules: Dict[str, str], module: Optional[str],
local_definers: Dict[str, Set[str]], built: Dict[str, str]):
self.markers = markers # method name -> markers with that name
self.local_definers = local_definers # method name -> this tree's own classes/modules defining it
self.built = built # ``x``/``self.x`` -> class it was built from in this file
self.lines = lines
self.rel = rel
self.test = test
self.core_modules = core_modules # owner class -> defining module (core only)
self.module = module # this file's module when scanning core
self.classes: List[ast.ClassDef] = []
self.scope: List[ast.AST] = [] # enclosing classes and functions
self.aliases: List[Dict[str, str]] = [{}] # local name -> owner class
self.out: Dict[str, List[Hit]] = defaultdict(list)
# -- helpers
def _code(self, node: ast.AST) -> str:
line = self.lines[node.lineno - 1] if 0 < node.lineno <= len(self.lines) else ""
return line.strip()[:160]
def _add(self, marker: Marker, kind: str, node: ast.AST) -> None:
via = self._inside_deprecated()
if via and kind != "unrelated":
# Only reached through another deprecated method: goes when that does.
kind = "internal"
self.out[marker.key].append(Hit(kind, self.rel, node.lineno, self._code(node),
self.test, via if kind == "internal" else None))
def _inside_deprecated(self) -> Optional[str]:
"""``Owner.method`` when this node sits in a deprecated core method's body."""
for i in range(len(self.scope) - 2, -1, -1):
cls, fn = self.scope[i], self.scope[i + 1]
if isinstance(cls, ast.ClassDef):
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
for m in self.markers.get(fn.name, ()):
if self._is_owner_class(cls, m.owner):
return m.key
return None
return None
def _owner_for_receiver(self, name: Optional[str]) -> Optional[str]:
n = _norm(name)
for scope in reversed(self.aliases):
if name in scope:
return scope[name]
for owner, receivers in OWNER_RECEIVERS.items():
if n in receivers:
return owner
return None
def _is_owner_class(self, cls: ast.ClassDef, owner: str) -> bool:
"""True for the real core class (only when scanning its own module)."""
return (self.module is not None and cls.name == owner
and self.core_modules.get(owner) == self.module)
def _subclasses(self, cls: ast.ClassDef, owner: str) -> bool:
return owner in _base_names(cls)
def _class_defines(self, cls: ast.ClassDef, name: str) -> bool:
return any(isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name
for n in cls.body)
# -- scopes
def visit_ClassDef(self, node: ast.ClassDef) -> None:
for fn in node.body:
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name in self.markers:
for m in self.markers[fn.name]:
if self._is_owner_class(node, m.owner):
continue # the definition itself
kind = "override" if self._subclasses(node, m.owner) else "unrelated"
self._add(m, kind, fn)
self.classes.append(node)
self.scope.append(node)
self.generic_visit(node)
self.scope.pop()
self.classes.pop()
def _visit_function(self, node) -> None:
self.aliases.append({})
self.scope.append(node)
self.generic_visit(node)
self.scope.pop()
self.aliases.pop()
visit_FunctionDef = _visit_function
visit_AsyncFunctionDef = _visit_function
def visit_Assign(self, node: ast.Assign) -> None:
# ``dm = self.display_manager`` makes ``dm.draw_sun()`` a call.
owner = self._owner_for_receiver(_terminal(node.value))
for target in node.targets:
if isinstance(target, ast.Name) and owner:
self.aliases[-1][target.id] = owner
self.generic_visit(node)
# -- uses
def visit_Attribute(self, node: ast.Attribute) -> None:
if node.attr in self.markers:
for m in self.markers[node.attr]:
self._add(m, self._classify(node, m), node)
self.generic_visit(node)
def _classify(self, node: ast.Attribute, m: Marker) -> str:
recv = node.value
cls = self.classes[-1] if self.classes else None
is_self = isinstance(recv, ast.Name) and recv.id in ("self", "cls")
is_super = (isinstance(recv, ast.Call) and isinstance(recv.func, ast.Name)
and recv.func.id == "super")
if is_self or is_super:
if cls is not None and (self._is_owner_class(cls, m.owner) or self._subclasses(cls, m.owner)):
return "call"
if cls is not None and self._class_defines(cls, m.method):
return "unrelated"
return "review"
name = _terminal(recv)
if self._owner_for_receiver(name) == m.owner:
return "call"
definers = self.local_definers.get(m.method, ())
if name in definers or self.built.get(name or "") in definers:
# e.g. the weather plugin's WeatherIcons.draw_sun, or
# self._strategy_component = CacheStrategy(); ...get_sport_live_interval()
return "unrelated"
return "review"
def visit_Call(self, node: ast.Call) -> None:
func = node.func
if (isinstance(func, ast.Name) and func.id in ("getattr", "hasattr", "setattr", "delattr")
and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant)
and node.args[1].value in self.markers):
for m in self.markers[node.args[1].value]:
owner = self._owner_for_receiver(_terminal(node.args[0]))
self._add(m, "call" if owner == m.owner else "review", node)
self.generic_visit(node)
def _built_from(tree: ast.AST) -> Dict[str, str]:
"""``{name: Class}`` for every ``name = Class(...)`` / ``self.name = Class(...)``."""
built: Dict[str, str] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
cls = _terminal(node.value.func)
for target in node.targets:
name = _terminal(target) if isinstance(target, (ast.Name, ast.Attribute)) else None
if name and cls:
built[name] = cls
return built
def scan_tree(source: Source, roots: Iterable[Path], base: Path, markers: List[Marker],
core: bool, test_override: Optional[bool] = None,
definer_roots: Iterable[Path] = ()) -> None:
by_name: Dict[str, List[Marker]] = defaultdict(list)
for m in markers:
by_name[m.method].append(m)
core_modules = {m.owner: m.module for m in markers}
owners = {m.owner for m in markers}
files: List[Tuple[Path, str, Optional[ast.AST]]] = []
for root in roots:
if not root.exists():
continue
for path in ([root] if root.is_file() else sorted(_iter_py(root))):
source.files += 1
text = path.read_text(encoding="utf-8", errors="replace")
if any(name in text for name in by_name):
files.append((path, text, _parse(path)))
# Classes (and modules) in this tree with their own method of a marked
# name, so ``WeatherIcons.draw_sun()`` is recognised as theirs.
local_definers: Dict[str, Set[str]] = defaultdict(set)
definer_files = list(files)
for root in definer_roots:
for path in (sorted(_iter_py(root)) if root.is_dir() else ()):
text = path.read_text(encoding="utf-8", errors="replace")
if any(name in text for name in by_name):
definer_files.append((path, text, _parse(path)))
for path, _, tree in definer_files:
for node in (tree.body if tree is not None else ()):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in by_name:
local_definers[node.name].add(path.stem)
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)) if tree else ():
if cls.name in owners and core:
continue
if owners & set(_base_names(cls)):
continue
for fn in cls.body:
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name in by_name:
local_definers[fn.name].add(cls.name)
for path, text, tree in files:
rel = path.relative_to(base)
test = _is_test_path(rel) if test_override is None else test_override
if tree is None:
# Unparseable (Python 2, a template ...): fall back to text, as review.
for no, line in enumerate(text.splitlines(), 1):
for name in by_name:
if re.search(rf"{re.escape(name)}", line):
for m in by_name[name]:
source.hits[m.key].append(
Hit("review", rel.as_posix(), no, line.strip()[:160], test))
continue
module = ".".join(rel.with_suffix("").parts) if core else None
scanner = _Scanner(by_name, text.splitlines(), rel.as_posix(), test,
core_modules, module, local_definers, _built_from(tree))
scanner.visit(tree)
for key, hits in scanner.out.items():
source.hits[key].extend(hits)
# --------------------------------------------------------------------------
# Fetching plugin trees (read-only)
def _git(*args: str, cwd: Optional[Path] = None) -> subprocess.CompletedProcess:
# Never stop to ask for credentials: a deleted or private plugin repo
# should be reported as not scanned, not hang the scan.
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
return subprocess.run( # nosec B603 B607 - list-form git argv, no shell; URLs follow "--" # nosemgrep
["git", *args], cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=300, env=env)
def _rmtree(path: Path) -> None:
"""Delete a clone; git marks pack files read-only, which Windows refuses to delete."""
import shutil
import stat
def retry(func, target, _exc):
os.chmod(target, stat.S_IWRITE)
func(target)
if sys.version_info >= (3, 12):
shutil.rmtree(path, onexc=retry)
else:
shutil.rmtree(path, onerror=retry)
def shallow_clone(url: str, branch: Optional[str], dest: Path, reuse: bool) -> Optional[str]:
"""Clone ``url`` into ``dest`` (depth 1), replacing any earlier clone.
Returns an error string, or None on success.
"""
if reuse and (dest / ".git").exists():
return None
if dest.exists():
_rmtree(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
args = ["-c", "core.longpaths=true", "clone", "--quiet", "--depth", "1"]
if branch:
args += ["--branch", branch]
# "--" ends option parsing: a registry URL starting with "-" (for example
# "--upload-pack=...") is then only ever a repository argument.
result = _git(*args, "--", url, str(dest))
if result.returncode != 0 and branch:
result = _git("-c", "core.longpaths=true", "clone", "--quiet", "--depth", "1",
"--", url, str(dest))
if result.returncode != 0:
lines = (result.stderr or result.stdout).strip().splitlines()
return lines[-1] if lines else "git clone failed"
return None
def _head(path: Path, branch: bool = True) -> str:
"""Commit (and branch) of a checkout, via read-only git calls."""
rev = _git("--no-optional-locks", "rev-parse", "--short=8", "HEAD", cwd=path)
if rev.returncode != 0:
return "unknown revision"
if not branch:
return rev.stdout.strip()
ref = _git("--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD", cwd=path)
return f"{ref.stdout.strip()} @ {rev.stdout.strip()}"
def _plugin_id(plugin_dir: Path) -> str:
try:
return json.loads((plugin_dir / "manifest.json").read_text(encoding="utf-8"))["id"]
except (OSError, ValueError, KeyError, TypeError):
return plugin_dir.name
def _is_monorepo(url: str) -> bool:
return MONOREPO_SLUG in url.lower().rstrip("/").removesuffix(".git")
# --------------------------------------------------------------------------
# Report
def verdicts(markers: List[Marker], sources: List[Source]) -> Dict[str, Tuple[str, str]]:
"""``{Owner.method: (status, text)}`` across every source.
An *internal* hit (a call from inside another deprecated method) keeps a
method only while that caller is itself kept, so statuses are resolved
until they stop changing.
"""
failed = [s.name for s in sources if s.error]
status: Dict[str, Tuple[str, str]] = {}
for _ in range(len(markers) + 1):
changed = False
for m in markers:
used, review = [], []
for s in sources:
live = [h for h in s.hits.get(m.key, []) if not h.test]
if any(h.kind in ("call", "override") for h in live) or any(
h.kind == "internal" and status.get(h.via, ("",))[0] == "used"
for h in live):
used.append(s.name)
elif any(h.kind == "review" for h in live) or any(
h.kind == "internal" and status.get(h.via, ("",))[0] == "review"
for h in live):
review.append(s.name)
if used:
new = ("used", f"still used by {', '.join(used)} — keep or migrate first")
elif review:
new = ("review", f"needs review: possible use in {', '.join(review)}")
elif failed:
new = ("unknown", f"not proven unused: {len(failed)} plugin(s) could not be scanned")
else:
new = ("unused", f"unused — safe to remove in {m.removal}")
if status.get(m.key) != new:
status[m.key] = new
changed = True
if not changed:
break
return status
def _counts(hits: List[Hit]) -> Dict[str, int]:
c: Dict[str, int] = defaultdict(int)
for h in hits:
c[("test " if h.test else "") + h.kind] += 1
return c
def _usage_cell(marker: Marker, sources: List[Source], kinds: Tuple[str, ...]) -> str:
parts = []
for s in sources:
c = _counts(s.hits.get(marker.key, []))
bits = [f"{c[k]} {k}{'s' if c[k] != 1 else ''}" for k in kinds if c[k]]
if bits:
parts.append(f"{s.name} ({', '.join(bits)})")
return "; ".join(parts) or "—"
def render_markdown(markers: List[Marker], sources: List[Source], meta: Dict[str, str]) -> str:
out: List[str] = []
w = out.append
w("# Deprecated plugin APIs: usage scan")
w("")
w("Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it "
"(see [How to re-run](#how-to-re-run)).")
w("")
w(f"- Scanned: {meta['date']}, core {meta['core_version']}")
w(f"- Monorepo: {meta['monorepo']}")
w(f"- Third-party plugins: {meta['third_party']}")
failed = [s for s in sources if s.error]
if failed:
w("- **Not scanned:** " + "; ".join(f"{s.name} ({s.error})" for s in failed))
w("")
status = verdicts(markers, sources)
tally: Dict[str, int] = defaultdict(int)
for st, _ in status.values():
tally[st] += 1
w(f"**{len(markers)} deprecated methods: {tally['unused']} unused, "
f"{tally['used']} still used, {tally['review']} need review"
+ (f", {tally['unknown']} not proven" if tally["unknown"] else "") + ".**")
w("")
w("Counted per plugin: a *call* is `<receiver>.method` on an object named like "
"the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), "
"or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass "
"of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot "
"tell. *Internal* hits sit inside another deprecated core method and go with it. "
"*Unrelated* hits are a different class's own method with the same name "
"(a name collision), and never block removal; neither do hits in test files.")
w("")
w("| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |")
w("|---|---|---|---|---|---|")
core_sources = [s for s in sources if s.group in ("core", "core-tests")]
plugin_sources = [s for s in sources if s.group not in ("core", "core-tests")]
for m in markers:
core = _usage_cell(m, core_sources, ("call", "override", "review", "internal",
"test call", "test override", "test review",
"test internal"))
plugins = _usage_cell(m, plugin_sources, ("call", "override", "review"))
other = _usage_cell(m, plugin_sources, ("unrelated", "test call", "test override",
"test review", "test unrelated"))
w(f"| `{m.key}` | {m.removal} | {core} | {plugins} | {other} | {status[m.key][1]} |")
w("")
groups = [("unused", "Unused — safe to remove"), ("used", "Still used — keep or migrate first"),
("review", "Needs review"), ("unknown", "Not proven unused")]
for st, title in groups:
names = [m.key for m in markers if status[m.key][0] == st]
if names:
w(f"## {title} ({len(names)})")
w("")
w(", ".join(f"`{n}`" for n in names))
w("")
detail = [(m, s, h) for m in markers for s in sources
for h in s.hits.get(m.key, []) if h.kind != "unrelated" or not h.test]
if detail:
w("## Every hit")
w("")
w("File paths are relative to the plugin's directory (core: the repo root).")
w("")
w("| Method | Where | File:line | Kind | Code |")
w("|---|---|---|---|---|")
for m, s, h in detail:
kind = ("test " if h.test else "") + h.kind
if h.via:
kind += f" (in `{h.via}`)"
code = h.code.replace("|", "\\|").replace("`", "'")
w(f"| `{m.key}` | {s.name} | {h.path}:{h.line} | {kind} | `{code}` |")
w("")
w("## Sources scanned")
w("")
w("| Source | Group | Python files | Hits |")
w("|---|---|---|---|")
for s in sources:
n = sum(len(v) for v in s.hits.values())
files = f"not scanned: {s.error}" if s.error else str(s.files)
w(f"| {s.name} | {s.group} | {files} | {n} |")
w("")
w("## How to re-run")
w("")
w("```bash")
w("# Clones the monorepo and each third-party plugin (depth 1) into a temp cache:")
w("python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md")
w("# Or scan a local monorepo checkout (read only) instead of cloning it:")
w("python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins")
w("```")
w("")
w("Before removing a method in its release, re-run the scan against the current "
"monorepo and registry: a plugin added since this file was generated may have "
"started calling it. Remove only methods the fresh scan reports unused; move "
"the rest to a later release (the test in `test/test_deprecation.py` fails "
"while a marker names a release at or below `src.__version__`).")
w("")
return "\n".join(out)
def render_json(markers: List[Marker], sources: List[Source], meta: Dict[str, str]) -> str:
data = {"meta": meta, "sources": [{"name": s.name, "group": s.group, "error": s.error}
for s in sources], "methods": []}
status = verdicts(markers, sources)
for m in markers:
st, text = status[m.key]
data["methods"].append({
"method": m.key, "module": m.module, "removal": m.removal,
"alternative": m.alternative, "status": st, "verdict": text,
"hits": [{"source": s.name, **h.__dict__} for s in sources
for h in s.hits.get(m.key, [])],
})
return json.dumps(data, indent=2)
# --------------------------------------------------------------------------
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--monorepo", type=Path,
help="local ledmatrix-plugins checkout to scan (read only); "
"default: shallow-clone its main branch")
parser.add_argument("--registry", type=Path,
help="plugins.json to read third-party plugins from "
"(default: the monorepo's)")
parser.add_argument("--cache-dir", type=Path,
default=Path(tempfile.gettempdir()) / "ledmatrix-plugin-api-usage",
help="where clones go (default: %(default)s)")
parser.add_argument("--reuse-cache", action="store_true",
help="scan clones already in --cache-dir instead of re-cloning "
"(offline re-runs; the report may then be stale)")
parser.add_argument("--no-third-party", action="store_true",
help="skip third-party plugins (the report then cannot prove anything unused)")
parser.add_argument("--format", choices=("md", "json"), default="md")
parser.add_argument("--output", type=Path, help="write the report here instead of stdout")
args = parser.parse_args(argv)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
markers = find_markers(REPO_ROOT)
if not markers:
print("No @deprecated markers found in src/.", file=sys.stderr)
return 0
sys.path.insert(0, str(REPO_ROOT))
try:
from src import __version__ as core_version
except Exception: # noqa: BLE001 -- reporting only
core_version = "unknown"
sources: List[Source] = []
core = Source("core", "core", REPO_ROOT)
scan_tree(core, [REPO_ROOT / d for d in CORE_DIRS] + sorted(REPO_ROOT.glob("*.py")),
REPO_ROOT, markers, core=True)
core_tests = Source("core tests", "core-tests", REPO_ROOT)
scan_tree(core_tests, [REPO_ROOT / d for d in CORE_TEST_DIRS], REPO_ROOT, markers,
core=True, test_override=True,
definer_roots=[REPO_ROOT / d for d in CORE_DIRS])
sources += [core, core_tests]
# Monorepo
if args.monorepo:
mono = args.monorepo.resolve()
mono_desc = f"local checkout `{mono.name}` ({_head(mono)})"
else:
mono = args.cache_dir / "ledmatrix-plugins"
err = shallow_clone(MONOREPO_URL, "main", mono, args.reuse_cache)
if err:
print(f"Could not clone the monorepo: {err}", file=sys.stderr)
return 1
mono_desc = f"[ChuckBuilds/ledmatrix-plugins]({MONOREPO_URL}) ({_head(mono)})"
plugins_dir = mono / "plugins"
mono_dirs = sorted(p for p in plugins_dir.iterdir() if p.is_dir()) if plugins_dir.is_dir() else []
for d in mono_dirs:
s = Source(_plugin_id(d), "monorepo", d)
scan_tree(s, [d], d, markers, core=False)
sources.append(s)
mono_desc += f", {len(mono_dirs)} plugins"
# Third-party plugins from the registry
registry = args.registry or (mono / "plugins.json")
third: List[dict] = []
try:
reg = json.loads(registry.read_text(encoding="utf-8"))
entries = reg["plugins"] if isinstance(reg, dict) else reg
third = [e for e in entries if e.get("repo") and not _is_monorepo(e["repo"])]
except (OSError, ValueError, KeyError) as exc:
print(f"Could not read {registry}: {exc}", file=sys.stderr)
return 1
if args.no_third_party:
tp_desc = "skipped (--no-third-party)"
else:
for e in third:
dest = args.cache_dir / "third-party" / re.sub(r"[^\w.-]", "_", e["id"])
err = shallow_clone(e["repo"], e.get("branch") or None, dest, args.reuse_cache)
root = dest / e["plugin_path"] if e.get("plugin_path") else dest
s = Source(e["id"], "third-party", root, error=err)
if not err:
scan_tree(s, [root], root, markers, core=False)
sources.append(s)
tp_desc = (f"{len(third)} with their own repo in `plugins.json` "
f"({', '.join(e['id'] for e in third)})")
meta = {
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"core_version": core_version,
"core_rev": _head(REPO_ROOT, branch=False),
"monorepo": mono_desc,
"third_party": tp_desc,
}
report = (render_json if args.format == "json" else render_markdown)(markers, sources, meta)
if args.output:
args.output.write_text(report, encoding="utf-8", newline="\n")
print(f"Wrote {args.output}", file=sys.stderr)
else:
sys.stdout.write(report)
return 0
if __name__ == "__main__":
sys.exit(main())
+193 -7
View File
@@ -25,6 +25,14 @@ against another) and for A/B testing a change to the render path.
sudo python3 scripts/render_bench.py --busy 2 # with background load
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
# the cost of changing pixels under a moving strip (Vegas live elements):
# a 101KB write into the visible columns every 25 frames
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25
# the cost of extending a Vegas-sized strip on the render thread: a 30-screen
# strip, extended by 6 screens (and trimmed) every 6 screens scrolled
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6
sudo systemctl start ledmatrix
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
@@ -92,13 +100,17 @@ def load_config() -> dict:
return config
def build_strip(width: int, height: int, label: str):
"""A marquee strip a few screens wide, with text and colour.
def build_strip(width: int, height: int, label: str, screens: float = 4.0):
"""A marquee strip about ``screens`` 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.
The width matters to the extension mode, whose cost is a copy of the whole
strip: Vegas carries 8,000-20,000 columns, so measure extension against a
strip that wide (``--strip-screens``), not the four-screen default.
"""
from PIL import Image, ImageDraw, ImageFont
@@ -123,7 +135,7 @@ def build_strip(width: int, height: int, label: str):
text_width = max(1, box[2] - box[0])
text_height = box[3] - box[1]
reps = max(2, (width * 4) // text_width + 1)
reps = max(2, int(width * screens) // 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
@@ -179,6 +191,119 @@ class BackgroundLoad:
zlib.compress(image.tobytes(), 1)
class StripWork:
"""Render-thread work a Vegas strip does between frames, on a schedule.
Patching writes a block of columns into the strip in place, as a live
element update does. Extending appends a block and trims what has scrolled
past, as continuous Vegas does (render_pipeline.extend_scroll_content).
Both run where Vegas runs them -- on the frame loop, before the next frame
is drawn -- and are tagged with ``FrameTimingRecorder.note_op``, so the
report shows how often the frame straight after each one was late.
The content comes from the benchmark's own strip, prepared before the run:
in Vegas it is drawn off the render thread, so drawing it here would time
work the render thread never does.
"""
def __init__(self, helper, recorder, source, *, patch_bytes: int = 0,
patch_every: int = 25, patch_where: str = "visible",
extend_every_screens: float = 0.0, extend_width: int = 0,
separator: int = 32) -> None:
import numpy as np
from PIL import Image
self.helper = helper
self.recorder = recorder
self.width = helper.display_width
self.height = helper.display_height
self.patch_every = max(1, int(patch_every))
self.patch_where = patch_where
self.separator = max(0, int(separator))
self.patches = 0
self.patched_bytes = 0
self.extensions = 0
self._frames = 0
self._position_at_extend = 0.0
pixels = np.asarray(source.convert("RGB"))
source_width = pixels.shape[1]
def columns(count: int, offset: int):
# Wraps around the source, so any width can be cut from it.
return np.ascontiguousarray(
pixels[:, (np.arange(count) + offset) % source_width])
# Two versions to alternate between, so every patch changes pixels.
self._patches = []
if patch_bytes > 0:
count = max(1, int(patch_bytes) // (self.height * 3))
self._patches = [columns(count, 0), columns(count, count)]
self.extend_every = (int(extend_every_screens * self.width)
if extend_every_screens > 0 else 0)
self._blocks = []
if self.extend_every:
# By default each append (block plus its separator) replaces
# exactly what scrolled past since the last one, so the strip
# holds its width, as Vegas's does in the steady state.
count = int(extend_width) or max(1, self.extend_every - self.separator)
self._blocks = [Image.fromarray(columns(count, 0)),
Image.fromarray(columns(count, count))]
def reset(self) -> None:
"""The strip was restarted from the beginning."""
self._position_at_extend = self.helper.scroll_position
def before_frame(self) -> None:
"""Do whatever work is due before the next frame is drawn."""
if self._blocks:
self._extend_if_due()
if self._patches:
self._frames += 1
if self._frames % self.patch_every == 0:
self._patch()
def _extend_if_due(self) -> None:
helper = self.helper
if helper.scroll_position < self._position_at_extend:
self._position_at_extend = helper.scroll_position
# Due on a fixed cadence rather than N screens after the last one ran,
# which would drift by the overshoot of a multi-pixel step each time.
due_at = self._position_at_extend + self.extend_every
if helper.scroll_position < due_at:
return
block = self._blocks[self.extensions % 2]
helper.append_content([block], item_gap=self.separator, element_gap=0)
moved = helper.cached_array.nbytes
# One screen kept behind the viewport, as Vegas does. The trim shifts
# every strip coordinate, the cadence's included.
cut = helper.drop_scrolled_prefix(keep_before=self.width)
if cut:
moved += helper.cached_array.nbytes
self._position_at_extend = due_at - cut
self.extensions += 1
self.recorder.note_op("extend", moved)
def _patch(self) -> None:
patch = self._patches[self.patches % 2]
strip = self.helper.cached_array
count = patch.shape[1]
if strip is None or count > strip.shape[1]:
return
start = int(self.helper.scroll_position)
if self.patch_where == "visible":
x = start + max(0, (self.width - count) // 2)
else:
# Just past the right edge: a change to content not yet on screen.
x = start + self.width + 16
x = max(0, min(x, strip.shape[1] - count))
strip[:, x:x + count] = patch
self.patches += 1
self.patched_bytes += patch.nbytes
self.recorder.note_op("patch", patch.nbytes)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
@@ -202,8 +327,36 @@ def main(argv=None) -> int:
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)")
parser.add_argument("--strip-screens", type=float, default=4.0, metavar="S",
help="strip width in screens (default 4). Vegas strips are "
"8,000-20,000px; the extension cost scales with it")
parser.add_argument("--patch-bytes", type=int, default=0, metavar="N",
help="write N bytes of columns into the strip in place "
"every --patch-every frames, as a Vegas live element "
"update does (a 150x64 card is ~29KB, a 512x64 map "
"~100KB)")
parser.add_argument("--patch-every", type=int, default=25, metavar="K",
help="frames between patches (default 25; 1 = every frame)")
parser.add_argument("--patch-where", choices=("visible", "ahead"),
default="visible",
help="patch the columns on screen, or just past its right "
"edge (default visible)")
parser.add_argument("--extend-every-screens", type=float, default=0.0,
metavar="N",
help="append a block and trim the strip every N screens "
"scrolled, as continuous Vegas does")
parser.add_argument("--extend-width", type=int, default=0, metavar="W",
help="width of each appended block in px (default: N "
"screens less the separator, so the strip holds its "
"width)")
args = parser.parse_args(argv)
if args.extend_every_screens > 0 and args.strip_screens < args.extend_every_screens + 3:
# The strip must stay ahead of the viewport between extensions.
args.strip_screens = args.extend_every_screens + 3
print(f"strip widened to {args.strip_screens:g} screens so extensions "
"keep ahead of the viewport")
# 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.
@@ -272,8 +425,9 @@ def main(argv=None) -> int:
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"))
strip = build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s",
screens=args.strip_screens)
helper.set_scrolling_image(strip)
# 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
@@ -284,12 +438,28 @@ def main(argv=None) -> int:
flush_interval=float("inf"),
info=display._frame_timing_info(), # pylint: disable=protected-access
refresh_hz=idle_hz,
gc_monitor=frame_timing.install_gc_monitor(),
)
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 "")
work = StripWork(helper, recorder, strip,
patch_bytes=args.patch_bytes, patch_every=args.patch_every,
patch_where=args.patch_where,
extend_every_screens=args.extend_every_screens,
extend_width=args.extend_width)
doing = []
if args.busy:
doing.append(f"{args.busy} background worker(s)")
if args.patch_bytes > 0:
doing.append(f"a {args.patch_bytes}B {args.patch_where} patch every "
f"{args.patch_every} frame(s)")
if work.extend_every:
doing.append(f"an extension every {args.extend_every_screens:g} screens")
print(f"scrolling {width}x{height} ({helper.cached_array.shape[1]}px strip) "
f"for {args.seconds:.0f}s"
+ (" with " + ", ".join(doing) if doing else "")
+ " ...", flush=True)
frames = 0
@@ -309,8 +479,11 @@ def main(argv=None) -> int:
before = recorder.snapshot()
run_started = now
frames = duplicates = blanks = restarts = 0
work.patches = work.patched_bytes = work.extensions = 0
if run_started is not None and now - run_started >= args.seconds:
break
# Where Vegas does its strip work: before the frame is drawn.
work.before_frame()
helper.update_scroll_position()
if helper.is_scroll_complete():
# The helper parks at the end of the strip and stops
@@ -320,6 +493,7 @@ def main(argv=None) -> int:
# the benchmark measures a still image for the rest of the
# run and reports a smoothness it never demonstrated.
helper.reset_scroll()
work.reset()
restarts += 1
visible = helper.get_visible_portion()
column = int(helper.scroll_position)
@@ -375,6 +549,11 @@ def main(argv=None) -> int:
if restarts:
print(f"restarts {restarts} (the strip was scrolled through "
f"{restarts} time{'s' if restarts != 1 else ''})")
if work.patches:
print(f"patches {work.patches} ({work.patched_bytes / 1e6:.1f} MB "
"written into the strip)")
if work.extensions:
print(f"extensions {work.extensions}")
if args.json_path:
report.update({
@@ -388,6 +567,13 @@ def main(argv=None) -> int:
"duplicate_frames": duplicates,
"blank_frames": blanks,
"strip_restarts": restarts,
"strip_screens": args.strip_screens,
"patch_bytes": args.patch_bytes,
"patch_every": args.patch_every,
"patch_where": args.patch_where,
"patches": work.patches,
"extend_every_screens": args.extend_every_screens,
"extensions": work.extensions,
"max_late_pct": args.max_late_pct,
"passed": frame_soak.passed(report, args.max_late_pct),
})
+57
View File
@@ -56,9 +56,33 @@ def main() -> int:
help='Display mode to render, for plugins that declare '
'more than one in their manifest (e.g. nrl_live). '
'Omitted, the plugin picks its own default.')
parser.add_argument('--vegas', action='store_true',
help="Render the plugin's block of the Vegas ticker strip "
"instead of display(): its live elements if it has "
"them, else its Vegas content, laid out as the "
"ticker lays them out. Also writes the live "
"elements' keys and columns to <output>.json")
parser.add_argument('--no-live', action='store_true',
help="With --vegas: ignore live elements and render the "
"plugin's ordinary Vegas content (for before/after)")
parser.add_argument('--timeline', type=int, default=0, metavar='ROWS',
help="With --vegas: render ROWS rows, each the block a "
"--timeline-step later as the ticker would update it "
"in place (animated elements redrawn for that moment)")
parser.add_argument('--timeline-step', type=float, default=0.25, metavar='SECONDS',
help="Seconds between --timeline rows (default 0.25)")
parser.add_argument('--timeline-update', action='store_true',
help="With --timeline: run update() before each row and "
"redraw every live element from the new data")
args = parser.parse_args()
if args.timeline > 1 and args.no_live:
# A timeline shows live elements changing; plain content never does.
parser.error("--timeline shows live elements; it cannot be combined with --no-live")
if (args.timeline or args.no_live) and not args.vegas:
parser.error("--timeline and --no-live need --vegas")
if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION):
print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})")
raise SystemExit(1)
@@ -145,6 +169,39 @@ def main() -> int:
except Exception as e:
logger.warning("update() raised: %s — continuing to display()", e)
if args.vegas:
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
if args.vegas and args.timeline > 1:
from src.plugin_system.testing.vegas import render_vegas_timeline
image, rows = render_vegas_timeline(
plugin_instance, args.plugin, display_manager, steps=args.timeline,
step_seconds=args.timeline_step, run_update=args.timeline_update)
if image is None:
logger.error("Plugin '%s' has no Vegas content", args.plugin)
return 1
image.save(args.output)
logger.info("Saved a %d-row Vegas timeline (%dx%d) to %s",
rows, image.width, image.height, args.output)
return 0
if args.vegas:
from src.plugin_system.testing.vegas import render_vegas_strip
block, layout = render_vegas_strip(
plugin_instance, args.plugin, display_manager, live=not args.no_live)
if block is None:
logger.error("Plugin '%s' has no Vegas content", args.plugin)
return 1
block.save(args.output)
sidecar = Path(args.output).with_suffix('.json')
sidecar.write_text(json.dumps(
{"width": block.width, "height": block.height,
"live_elements": [{"key": k, "x": x, "width": w} for x, k, w in layout]},
indent=2) + "\n", encoding="utf-8")
logger.info("Saved Vegas strip %dx%d (%d live element(s)) to %s and %s",
block.width, block.height, len(layout), args.output, sidecar)
return 0
# A plugin that declares several display modes usually renders nothing
# useful without being told which one to draw: the scoreboards keep their
# state on per-mode sub-managers and their no-argument path returns False.
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Turn the web interface's optional login off, for when the password is lost.
Removes the password (and the key that signs login cookies) from the
``web_auth`` section of ``config/config_secrets.json``. The interface is then
open again, as it is before a password is ever set, and a new password can be
set under General > Security. API tokens are kept unless ``--revoke-tokens``
is given. Nothing else in the secrets file is touched, and the web service
does not need a restart: it notices the change on the next request.
Run it on the Pi, from any directory:
sudo python3 ~/LEDMatrix/scripts/reset_web_password.py
``sudo`` because the secrets file is not readable by every user. The file
keeps its owner and permissions.
Another way in without the password: open the interface from the Pi itself
(http://localhost:5000). Requests from the Pi are never asked to log in.
"""
import argparse
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config_manager_atomic import atomic_write_json # noqa: E402
SECTION = 'web_auth' # web_interface/auth.py; not imported to keep Flask out
LOGIN_KEYS = ('password_hash', 'session_secret', 'password_set_at')
def reset(settings_file: Path, revoke_tokens: bool = False) -> str:
"""Clear the login from ``settings_file`` (config_secrets.json).
Returns what was done. The message names the file and counts tokens; it
never includes anything read from the file.
"""
if not settings_file.exists():
return f'{settings_file} does not exist, so no password is set. Nothing to do.'
with open(settings_file, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if not isinstance(data, dict):
raise ValueError(f'{settings_file} does not hold a JSON object')
section = data.get(SECTION)
if not isinstance(section, dict):
return 'No web login password is set. Nothing to do.'
had_password = bool(section.get('password_hash'))
token_count = len(section.get('tokens') or [])
for key in LOGIN_KEYS:
section.pop(key, None)
if revoke_tokens:
section.pop('tokens', None)
if section:
data[SECTION] = section
else:
data.pop(SECTION, None)
if not had_password and not (revoke_tokens and token_count):
return 'No web login password is set. Nothing to do.'
atomic_write_json(settings_file, data)
done = []
if had_password:
done.append('Web login is off: the interface opens without a password. '
'Set a new one under General > Security.')
if revoke_tokens and token_count:
done.append(f'Revoked {token_count} API token(s).')
elif token_count:
done.append(f'{token_count} API token(s) kept (use --revoke-tokens to remove them).')
return ' '.join(done)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description='Turn the LEDMatrix web login off (lost password recovery).')
parser.add_argument('--secrets', dest='settings_file', type=Path,
default=PROJECT_ROOT / 'config' / 'config_secrets.json',
help='secrets file (default: config/config_secrets.json '
'in this LEDMatrix checkout)')
parser.add_argument('--revoke-tokens', action='store_true',
help='also delete every API token')
args = parser.parse_args(argv)
settings_file = args.settings_file
try:
outcome = reset(settings_file, revoke_tokens=args.revoke_tokens)
except PermissionError:
print(f'Permission denied reading or writing {settings_file}. Run it with sudo.',
file=sys.stderr)
return 1
except (OSError, ValueError) as err:
print(f'Could not reset the web login: {err}', file=sys.stderr)
return 1
print(outcome)
return 0
if __name__ == '__main__':
sys.exit(main())
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""Report how far apart the nine scoreboards' copies of each method are.
The sports consolidation (docs/SPORTS_UNIFICATION.md) moves shared code from
the scoreboard plugins into ``src/common``. Byte-identical copies have mostly
been moved; what is left has drifted, and is promoted one *method family* at a
time by first making every copy identical ("reconcile, then promote"). This
report is the progress measure for that: for every method in the tracked
files it counts the copies and the distinct bodies among them, so a stage can
say "``_is_game_really_over``: 5 variants -> 1" instead of remembering it.
It reads a ledmatrix-plugins checkout and never fails a build: it is a report,
not a gate. The monorepo's own ``scripts/check_sports_drift.py`` is the gate
(it fails when a function that agrees across the plugins starts to differ).
Definitions
-----------
family
One method name in one tracked file, across every class that defines it
and every plugin. ``sports.py::update`` covers ``SportsLive.update``,
``SportsRecent.update`` and ``SportsUpcoming.update`` in all nine plugins.
Module-level functions are families too.
copies
How many definitions the family has (plugin x class).
plugins
How many of the nine plugins define it at least once.
variants
Distinct bodies among the copies, compared as ASTs with docstrings,
comments, formatting, decorators and annotations ignored. A family is
reconciled when every class in it is down to one variant.
per-class variants
The same count within one class role (``SportsLive.update`` across the
plugins). Class names are folded the way the plugins name them
(``SoccerScoreboardPlugin`` and ``UFCScoreboardPlugin`` are both
``SScoreboardPlugin``), so manager.py lines up across sports.
folded
Variants left after sport and league names are folded to a placeholder
(``self.nfl_live`` == ``self.nhl_live``, ``"NFL"`` == ``"NHL"``). The gap
between ``variants`` and ``folded`` is drift that is only naming.
Usage
-----
python scripts/sports_drift_report.py --plugins ../ledmatrix-plugins
python scripts/sports_drift_report.py --markdown # for a CI summary
python scripts/sports_drift_report.py --json out.json # machine-readable
python scripts/sports_drift_report.py --family sports.py::update
``--plugins`` defaults to ``$LEDMATRIX_PLUGINS`` (a checkout root or its
``plugins/`` directory, the same variable the core parity tests read). With no
checkout it says so and exits 0.
"""
from __future__ import annotations
import argparse
import ast
import collections
import difflib
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
#: The nine scoreboards the consolidation covers, by directory prefix.
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
#: Files every scoreboard carries a copy of. sports.py and game_renderer.py
#: are the consolidation's subject; manager.py (the BasePlugin host, the
#: largest copy of all) joined the plan with the reconcile-then-promote
#: method. ufc has no game_renderer.py (it draws fights in fight_renderer.py).
DEFAULT_FILES = ("sports.py", "manager.py", "game_renderer.py")
#: A family is "drifted" when it is widespread and has several bodies. The
#: defaults match the review that introduced this report (at least 7 plugins,
#: at least 3 variants).
DEFAULT_MIN_PLUGINS = 7
DEFAULT_MIN_VARIANTS = 3
#: Sport, league and competition names that legitimately differ between the
#: plugins. Only used for the ``folded`` column.
SPORT_TOKENS = (
"afl", "nrl", "baseball", "basketball", "football", "hockey", "soccer",
"lacrosse", "ufc", "mma", "mlb", "milb", "nhl", "nfl", "nba", "wnba",
"ncaa", "ncaafb", "ncaam", "ncaaw", "ncaa_fb", "ncaa_baseball",
"ncaa_basketball", "ncaam_hockey", "ncaaw_hockey", "ncaam_lacrosse",
"ncaaw_lacrosse", "ncaam_basketball", "ncaaw_basketball", "epl",
"uefa", "mls", "laliga", "bundesliga", "seriea", "ligue1",
)
_TOKEN_RE = re.compile(
r"(?<![A-Za-z0-9])(" + "|".join(sorted(SPORT_TOKENS, key=len, reverse=True))
+ r")(?![A-Za-z0-9])", re.IGNORECASE)
_TOKEN_SET = {t.lower() for t in SPORT_TOKENS}
_CAMEL_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+|_")
def fold(name: str) -> str:
"""Replace sport and league names in an identifier or string with ``S``.
Both spellings the plugins use: snake_case (``nfl_live`` -> ``S_live``)
and CamelCase (``UFCScoreboardPlugin`` -> ``SScoreboardPlugin``).
"""
name = _TOKEN_RE.sub("S", name)
# sub, not findall + join: characters between words (spaces, dots,
# braces in a log string) must survive, or distinct text folds together.
return _CAMEL_RE.sub(
lambda m: "S" if m.group(0).lower() in _TOKEN_SET else m.group(0), name)
def _strip_docstring(body: List[ast.stmt]) -> List[ast.stmt]:
if (body and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and isinstance(body[0].value.value, str)):
return body[1:] or [ast.Pass()]
return body
class _Canonical(ast.NodeTransformer):
"""Drop what is not behaviour: docstrings, decorators, annotations."""
def _func(self, node):
self.generic_visit(node)
node.body = _strip_docstring(node.body)
node.decorator_list = []
node.returns = None
return node
visit_FunctionDef = _func
visit_AsyncFunctionDef = _func
def visit_ClassDef(self, node):
self.generic_visit(node)
node.body = _strip_docstring(node.body)
return node
def visit_arg(self, node):
node.annotation = None
return node
def visit_AnnAssign(self, node):
# ``x: T = v`` is ``x = v``; a bare ``x: T`` does nothing at runtime.
self.generic_visit(node)
if node.value is None:
return None
return ast.copy_location(
ast.Assign(targets=[node.target], value=node.value), node)
class _Folded(_Canonical):
"""Canonical, plus sport names folded out of identifiers and strings."""
def visit_Name(self, node):
node.id = fold(node.id)
return node
def visit_Attribute(self, node):
self.generic_visit(node)
node.attr = fold(node.attr)
return node
def visit_arg(self, node):
node = super().visit_arg(node)
node.arg = fold(node.arg)
return node
def visit_keyword(self, node):
self.generic_visit(node)
if node.arg:
node.arg = fold(node.arg)
return node
def visit_Constant(self, node):
if isinstance(node.value, str):
node.value = fold(node.value)
return node
def _func(self, node):
node = super()._func(node)
node.name = fold(node.name)
return node
visit_FunctionDef = _func
visit_AsyncFunctionDef = _func
def _digest(node: ast.AST, transformer: ast.NodeTransformer) -> str:
# Re-parse a copy so the transformers never mutate the tree being walked.
clone = ast.parse(ast.unparse(node)).body[0]
clone = transformer.visit(clone)
# The function's own name is the family key, not part of its body.
if isinstance(clone, (ast.FunctionDef, ast.AsyncFunctionDef)):
clone.name = "_"
return hashlib.sha256(ast.dump(clone).encode()).hexdigest()[:12]
class Copy:
"""One definition of a method (or module-level function) in one plugin."""
__slots__ = ("plugin", "cls", "name", "lines", "exact", "folded", "source")
def __init__(self, plugin, cls, name, lines, exact, folded, source=""):
self.plugin = plugin
self.cls = cls
self.name = name
self.lines = lines
self.exact = exact
self.folded = folded
self.source = source
def collect_file(path: Path, plugin: str) -> List[Copy]:
"""Every top-level function and class method in one file."""
text = path.read_text(encoding="utf-8", errors="replace")
try:
tree = ast.parse(text)
except SyntaxError as exc:
print(f" ! {path}: {exc}", file=sys.stderr)
return []
out = []
def add(node, cls):
out.append(Copy(plugin, cls, node.name,
node.end_lineno - node.lineno + 1,
_digest(node, _Canonical()), _digest(node, _Folded()),
ast.get_source_segment(text, node) or ""))
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
add(node, "<module>")
elif isinstance(node, ast.ClassDef):
for child in node.body:
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
add(child, fold(node.name))
return out
def resolve_plugins_dir(raw: Optional[str]) -> Optional[Path]:
"""A checkout root or its plugins/ directory; None when there is neither."""
if not raw:
return None
root = Path(raw)
if (root / "plugins").is_dir():
root = root / "plugins"
if not any((root / f"{s}-scoreboard").is_dir() for s in SPORTS):
return None
return root
def build(plugins_dir: Path, files: Iterable[str]) -> Dict[Tuple[str, str], List[Copy]]:
"""{(file, method name): [Copy, ...]} across the nine scoreboards."""
families: Dict[Tuple[str, str], List[Copy]] = collections.defaultdict(list)
for fname in files:
for sport in SPORTS:
path = plugins_dir / f"{sport}-scoreboard" / fname
if path.is_file():
for copy in collect_file(path, sport):
families[(fname, copy.name)].append(copy)
return families
def summarise(key: Tuple[str, str], copies: List[Copy]) -> dict:
"""The numbers for one family."""
per_class = collections.defaultdict(list)
for c in copies:
per_class[c.cls].append(c)
classes = []
for cls, members in sorted(per_class.items()):
groups = collections.defaultdict(list)
for c in members:
groups[c.exact].append(c.plugin)
classes.append({
"class": cls,
"copies": len(members),
"variants": len(groups),
"folded": len({c.folded for c in members}),
"groups": sorted((sorted(p) for p in groups.values()),
key=lambda g: (-len(g), g)),
})
total_lines = sum(c.lines for c in copies)
# What promotion would remove: every copy but one per class role.
one_each = sum(max(c.lines for c in members) for members in per_class.values())
return {
"file": key[0],
"family": key[1],
"plugins": len({c.plugin for c in copies}),
"copies": len(copies),
"variants": len({(c.cls, c.exact) for c in copies}),
"folded": len({(c.cls, c.folded) for c in copies}),
"worst_class_variants": max(k["variants"] for k in classes),
"lines": total_lines,
"duplicated_lines": total_lines - one_each,
"classes": classes,
}
def report(families, min_plugins: int, min_variants: int) -> dict:
rows = [summarise(k, v) for k, v in families.items()]
by_file = collections.defaultdict(list)
for r in rows:
by_file[r["file"]].append(r)
files = {}
for fname, frows in sorted(by_file.items()):
files[fname] = {
"families": len(frows),
"in_all_plugins": sum(1 for r in frows if r["plugins"] == len(SPORTS)),
"lines": sum(r["lines"] for r in frows),
"identical_duplicated_lines": sum(
r["duplicated_lines"] for r in frows if r["worst_class_variants"] == 1),
}
drifted = sorted(
(r for r in rows
if r["plugins"] >= min_plugins and r["variants"] >= min_variants),
key=lambda r: (-r["variants"], -r["lines"], r["file"], r["family"]))
identical = sorted(
(r for r in rows if r["copies"] >= 2 and r["worst_class_variants"] == 1),
key=lambda r: (-r["duplicated_lines"], r["file"], r["family"]))
# One body shared by every plugin but one: the cheapest reconciliations.
# "One" across the whole family: a class role whose odd one out is a
# different plugin from another role's is two outliers, not one.
one_outlier = sorted(
(r for r in rows
if r["plugins"] >= min_plugins and r["worst_class_variants"] == 2
and all(len(k["groups"]) < 2 or len(k["groups"][1]) == 1
for k in r["classes"])
and len(_minorities(r)) == 1),
key=lambda r: (-r["duplicated_lines"], r["file"], r["family"]))
return {"files": files, "drifted": drifted, "identical": identical,
"one_outlier": one_outlier,
"rows": rows, "thresholds": {"min_plugins": min_plugins,
"min_variants": min_variants}}
def _minorities(r) -> set:
"""Every plugin in a minority body, across the family's class roles."""
return {p for k in r["classes"] for g in k["groups"][1:] for p in g}
def _outlier(r) -> str:
"""The plugin whose body differs, for a one-outlier family."""
return ", ".join(sorted(_minorities(r)))
def _text(rep, top_identical: int) -> str:
out = []
out.append("Per file (all methods and module functions):")
for fname, f in rep["files"].items():
out.append(f" {fname:<18} {f['families']:>4} families, "
f"{f['in_all_plugins']:>3} in all {len(SPORTS)} plugins, "
f"{f['lines']:>6} lines; identical copies beyond the first: "
f"{f['identical_duplicated_lines']} lines")
t = rep["thresholds"]
out.append("")
out.append(f"Drifted families (in >= {t['min_plugins']} plugins, "
f">= {t['min_variants']} variants): {len(rep['drifted'])}")
out.append(f" {'file::family':<58} {'plug':>4} {'copies':>6} {'var':>4} "
f"{'fold':>4} {'worst':>5} {'lines':>6}")
for r in rep["drifted"]:
name = f"{r['file']}::{r['family']}"
out.append(f" {name:<58} {r['plugins']:>4} {r['copies']:>6} "
f"{r['variants']:>4} {r['folded']:>4} "
f"{r['worst_class_variants']:>5} {r['lines']:>6}")
out.append("")
out.append(f"One outlier (in >= {t['min_plugins']} plugins, every plugin but "
f"one agrees): {len(rep['one_outlier'])}")
for r in rep["one_outlier"]:
name = f"{r['file']}::{r['family']}"
out.append(f" {name:<58} {r['plugins']:>4} plugins, differs in "
f"{_outlier(r)}; {r['lines']} lines")
out.append("")
out.append(f"Identical in every copy (promote as-is), top {top_identical} "
f"by duplicated lines, of {len(rep['identical'])}:")
for r in rep["identical"][:top_identical]:
name = f"{r['file']}::{r['family']}"
out.append(f" {name:<58} {r['plugins']:>4} plugins "
f"{r['duplicated_lines']:>5} duplicated lines")
return "\n".join(out)
def _markdown(rep, top_identical: int, source: str) -> str:
t = rep["thresholds"]
out = ["## Sports drift report", "",
f"Scoreboard copies read from `{source}`. Report only: this never fails "
"the build. See docs/SPORTS_UNIFICATION.md.", "",
"| File | Families | In all 9 | Lines | Identical duplicated lines |",
"|---|---:|---:|---:|---:|"]
for fname, f in rep["files"].items():
out.append(f"| `{fname}` | {f['families']} | {f['in_all_plugins']} | "
f"{f['lines']} | {f['identical_duplicated_lines']} |")
out += ["", f"### Drifted families (in >= {t['min_plugins']} plugins, "
f">= {t['min_variants']} variants): {len(rep['drifted'])}", "",
"| Family | Plugins | Copies | Variants | Folded | Worst class | Lines |",
"|---|---:|---:|---:|---:|---:|---:|"]
for r in rep["drifted"]:
out.append(f"| `{r['file']}::{r['family']}` | {r['plugins']} | {r['copies']} | "
f"{r['variants']} | {r['folded']} | {r['worst_class_variants']} | "
f"{r['lines']} |")
out += ["", f"### One outlier (every plugin but one agrees): "
f"{len(rep['one_outlier'])}", "",
"| Family | Plugins | Differs in | Lines |", "|---|---:|---|---:|"]
for r in rep["one_outlier"]:
out.append(f"| `{r['file']}::{r['family']}` | {r['plugins']} | "
f"{_outlier(r)} | {r['lines']} |")
out += ["", f"### Identical in every copy: {len(rep['identical'])} "
f"(top {top_identical} by duplicated lines)", "",
"| Family | Plugins | Duplicated lines |", "|---|---:|---:|"]
for r in rep["identical"][:top_identical]:
out.append(f"| `{r['file']}::{r['family']}` | {r['plugins']} | "
f"{r['duplicated_lines']} |")
return "\n".join(out) + "\n"
def _family_detail(rep, families, wanted: str, show_diff: bool) -> str:
"""Which plugins share each body of one family; optionally the diffs.
The diff is against the body most plugins share (the first group), which
is where a reconciliation usually starts.
"""
fname, _, family = wanted.partition("::")
for r in rep["rows"]:
if r["file"] == fname and r["family"] == family:
out = [f"{wanted}: {r['plugins']} plugins, {r['copies']} copies, "
f"{r['variants']} variants ({r['folded']} after folding sport "
f"names), {r['lines']} lines"]
copies = families[(fname, family)]
for k in r["classes"]:
out.append(f" {k['class']}: {k['variants']} variant(s) "
f"({k['folded']} folded)")
for g in k["groups"]:
out.append(f" {', '.join(g)}")
if not show_diff or len(k["groups"]) < 2:
continue
by_plugin = {c.plugin: c for c in copies if c.cls == k["class"]}
base = by_plugin[k["groups"][0][0]]
for g in k["groups"][1:]:
other = by_plugin[g[0]]
out.extend(difflib.unified_diff(
base.source.splitlines(), other.source.splitlines(),
f"{base.plugin}-scoreboard/{fname}",
f"{other.plugin}-scoreboard/{fname}", lineterm="", n=2))
return "\n".join(out)
return f"{wanted}: no such family"
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--plugins", default=os.environ.get("LEDMATRIX_PLUGINS"),
help="ledmatrix-plugins checkout (default: $LEDMATRIX_PLUGINS)")
ap.add_argument("--files", default=",".join(DEFAULT_FILES),
help="comma-separated files to compare (default: %(default)s)")
ap.add_argument("--min-plugins", type=int, default=DEFAULT_MIN_PLUGINS)
ap.add_argument("--min-variants", type=int, default=DEFAULT_MIN_VARIANTS)
ap.add_argument("--top-identical", type=int, default=15)
ap.add_argument("--markdown", action="store_true",
help="print a Markdown summary (for $GITHUB_STEP_SUMMARY)")
ap.add_argument("--json", metavar="PATH",
help="also write the full report as JSON")
ap.add_argument("--family", action="append", default=[],
help="show which plugins share each body, e.g. sports.py::update")
ap.add_argument("--diff", action="store_true",
help="with --family, also diff each variant against the most common one")
args = ap.parse_args(argv)
plugins_dir = resolve_plugins_dir(args.plugins)
if plugins_dir is None:
msg = ("No ledmatrix-plugins checkout: pass --plugins or set "
"LEDMATRIX_PLUGINS. Nothing to report.")
print(f"_{msg}_\n" if args.markdown else msg)
return 0
files = [f.strip() for f in args.files.split(",") if f.strip()]
families = build(plugins_dir, files)
rep = report(families, args.min_plugins, args.min_variants)
if args.json:
with open(args.json, "w", encoding="utf-8") as fh:
json.dump(rep, fh, indent=2)
fh.write("\n")
if args.markdown:
print(_markdown(rep, args.top_identical, str(plugins_dir)), end="")
else:
print(_text(rep, args.top_identical))
for wanted in args.family:
print()
print(_family_detail(rep, families, wanted, args.diff))
return 0
if __name__ == "__main__":
sys.exit(main())
+18 -1
View File
@@ -247,6 +247,19 @@
</div>
</details>
<!-- What to render: the plugin's screen, or its block of the Vegas strip -->
<div class="flex items-center gap-2">
<label for="viewSelect" class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">View</label>
<select id="viewSelect" onchange="onConfigChange()"
class="flex-1 px-2 py-1.5 rounded-lg text-xs"
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
title="Vegas strip: the plugin's block of the Vegas ticker, laid out as the ticker lays it out">
<option value="">Display</option>
<option value="live">Vegas strip (live elements)</option>
<option value="plain">Vegas strip (plain Vegas content)</option>
</select>
</div>
<!-- Render buttons -->
<div class="flex gap-2">
<button onclick="renderPlugin()" id="renderBtn"
@@ -489,6 +502,7 @@
width: width,
height: height,
mock_data: mockData,
vegas: document.getElementById('viewSelect').value || null,
}),
});
@@ -510,8 +524,11 @@
updateZoom();
// Show render time
const live = data.live_elements;
document.getElementById('renderTimeText').textContent =
`${data.render_time_ms}ms`;
`${data.render_time_ms}ms` + (live ? ` · ${data.width}px strip, ` +
`${live.length} live element(s)` +
(live.length ? `: ${live.map(e => e.key).join(', ')}` : '') : '');
// Show warnings/errors
showMessages(data.errors || [], data.warnings || []);
+73 -3
View File
@@ -14,12 +14,20 @@ the same reason: the rollback cannot depend on packages the update changed.
The updater leaves data/auto_update_pending.json:
{"status": "pending", "old_head": ..., "new_head": ...,
"old_ref": "main" | "" (detached) | absent (older updaters),
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
This moves its status to "verifying" and then to one of "success",
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
The web interface reports that outcome and raises a banner for anything but
success.
"The display service is active" does not mean the panel is drawing: a render
loop stuck inside a plugin leaves the service active and the panel frozen.
Where the display writes a heartbeat (/run/ledmatrix, see
src/display_watchdog.py), the display also has to keep it fresh, from the
restarted process, to count as healthy. Where it never wrote one -- the code
being updated predates it -- the check is what it always was.
"""
import json
import os
@@ -36,6 +44,14 @@ from pathlib import Path
PENDING_NAME = 'auto_update_pending.json'
REQUIREMENT_FILES = ('requirements.txt', 'web_interface/requirements.txt')
WEB_HEALTH_URL = 'http://127.0.0.1:5000/api/v3/system/version'
#: Written by the display's render loop every few seconds. A copy of
#: src/display_watchdog.HEARTBEAT_PATH, not an import: this file runs as a
#: copy made before the update and must not depend on the code it checks.
HEARTBEAT_PATH = '/run/ledmatrix/display-heartbeat.json'
#: How old the heartbeat may be. Well under STABLE_SECONDS: a display that
#: draws its first frame and then freezes must go stale inside the window it
#: has to stay healthy for, or the check would pass it.
HEARTBEAT_FRESH_SECONDS = 30
#: How long the services get to come up after a restart...
HEALTH_TIMEOUT_SECONDS = 180
#: ...and how long they must then stay up. Restart=on-failure makes a crash
@@ -113,16 +129,35 @@ def _short(sha):
return (sha or 'unknown')[:7]
def _read_heartbeat(path=HEARTBEAT_PATH):
"""The display's heartbeat, or None when there is none (or it is unreadable)."""
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
class Verifier:
def __init__(self, project_root, run=subprocess.run, sleep=time.sleep,
clock=time.monotonic, web_responds=_web_responds, log=None):
clock=time.monotonic, web_responds=_web_responds, log=None,
read_heartbeat=_read_heartbeat):
self.project_root = Path(project_root)
self.pending_file = pending_path(project_root)
self.run = run
self.sleep = sleep
# Monotonic, and compared with the heartbeat's own monotonic stamp:
# CLOCK_MONOTONIC is one clock for every process on the machine.
self.clock = clock
self.web_responds = web_responds
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
self.read_heartbeat = read_heartbeat
#: Whether the display was writing a heartbeat before the update.
self.expect_heartbeat = False
#: When the display was last restarted; an older heartbeat is the
#: previous process's, not proof the new one draws.
self.display_restarted_at = None
def _run(self, args, timeout=GIT_TIMEOUT_SECONDS):
try:
@@ -154,18 +189,32 @@ class Verifier:
ok = True
# A display the user had stopped stays stopped.
if display:
self.display_restarted_at = self.clock()
ok = self.restart('ledmatrix') and ok
return self.restart('ledmatrix-web') and ok
def display_drawing(self):
"""True while the restarted display keeps its heartbeat fresh."""
data = self.read_heartbeat()
mono = data.get('mono') if data else None
if not isinstance(mono, (int, float)) or isinstance(mono, bool):
return False
if self.display_restarted_at is not None and mono < self.display_restarted_at:
return False # still the process from before the restart
return self.clock() - mono <= HEARTBEAT_FRESH_SECONDS
def wait_healthy(self, display):
"""None once the services are up and stay up, else what went wrong."""
deadline = self.clock() + HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS
healthy_since = baseline = None
web = disp = False
active = drawing = True
count_known = True
while self.clock() < deadline:
web = self.web_responds()
disp = self.service_active('ledmatrix') if display else True
active = self.service_active('ledmatrix') if display else True
drawing = self.display_drawing() if (display and self.expect_heartbeat) else True
disp = active and drawing
restarts = self.restart_count('ledmatrix') if display else None
# Without a restart count a crash loop looks healthy between
# attempts, so an unreadable count never counts as stable.
@@ -181,8 +230,11 @@ class Verifier:
problems = []
if not web:
problems.append('the web interface did not respond')
if not disp:
if not active:
problems.append('the display service did not stay running')
elif not drawing:
problems.append('the display service is running but its panel is not '
'being drawn (no fresh heartbeat)')
if web and disp and not count_known:
problems.append("the display service's restart count could not be read")
return '; '.join(problems) or 'the display service kept restarting'
@@ -225,6 +277,20 @@ class Verifier:
if not old:
return False, 'the commit to roll back to is unknown'
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
# An update may have moved HEAD between main and a detached release
# tag (the stable/beta channels). Go back to where HEAD was -- the
# branch, or detached -- before resetting, or resetting would drag
# the wrong ref: main onto a release commit, or leave a device that
# was following main stuck on a detached one. No old_ref (an older
# updater wrote this file) means HEAD never moved between refs.
old_ref = pending.get('old_ref')
if old_ref is not None:
move = (['git', 'checkout', '--quiet', '--force', old_ref] if old_ref
else ['git', 'checkout', '--quiet', '--force', '--detach', old])
result = self._run(move, timeout=GIT_RESET_TIMEOUT_SECONDS)
if result.returncode != 0:
return False, (f'"{" ".join(move)}" failed: '
f'{(result.stderr or result.stdout or "").strip()}')
# --hard: the updater refuses to run with local edits to tracked core
# files (web_interface/auto_update.local_changes), so outside the
# plugin folders the only thing this discards is the update. Edits
@@ -258,6 +324,10 @@ class Verifier:
write_pending(self.pending_file, pending)
display = bool(pending.get('display_was_active'))
# Read before anything restarts: the display still running is the
# pre-update code, and whether it writes a heartbeat decides whether
# the updated one must.
self.expect_heartbeat = display and self.read_heartbeat() is not None
dependency_failures = pending.get('dependency_failures') or []
if dependency_failures:
# Never restart onto code whose packages did not install.
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.7.0"
__version__ = "3.8.0"
+40 -4
View File
@@ -27,6 +27,13 @@ from concurrent.futures import ThreadPoolExecutor
import pytz
from src.cache_manager import CacheManager
from src.common.json_body import response_json
from src.common.fetch_service import (
current_plugin_id,
fetch_get,
get_fetch_service,
plugin_scope,
share_connection_pool,
)
from src.common.espn_dates import (
RANGE_RETRY_SECONDS,
_note_range_rejected,
@@ -78,6 +85,9 @@ class FetchRequest:
commit_claimed: bool = False
result: Optional[Any] = None
error: Optional[str] = None
# The plugin that submitted the request, so the fetch service counts the
# worker's requests against it (fetch_service, caller identity).
owner: Optional[str] = None
@dataclass
class FetchResult:
@@ -119,6 +129,12 @@ class _ConnectionRetryingSession:
def __init__(self, session):
self._session = session
@property
def fetch_identity_session(self):
"""The wrapped Session, whose headers and adapter the fetch service
reads to key this request (src/common/fetch_service.py)."""
return self._session
def get(self, *args, **kwargs):
for attempt in range(self.ATTEMPTS):
try:
@@ -196,9 +212,12 @@ class BackgroundDataService:
# connection errors three times, a dead network cost up to 16
# connection attempts per request and held one of the few worker
# threads for all of them.
#
# The adapter is the fetch service's shared no-retry one: the same
# max_retries=0, with the connection pool shared with the other core
# sessions that do not retry (the odds managers).
self.session = requests.Session()
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=0))
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=0))
share_connection_pool(self.session, max_retries=0)
# Default headers: core's shared set (real User-Agent, no hand-set
# Accept-Encoding) -- see src/common/api_helper.py.
@@ -299,6 +318,10 @@ class BackgroundDataService:
if url.split('?', 1)[0].rstrip('/').endswith('/scoreboard'):
params = clamp_espn_limit(params)
# Who asked, resolved on the submitting thread: the worker thread
# runs no plugin code, so it could not tell (fetch_service).
owner = current_plugin_id()
# Create fetch request
request = FetchRequest(
id=request_id,
@@ -311,7 +334,8 @@ class BackgroundDataService:
timeout=timeout or self.request_timeout,
max_retries=max_retries,
priority=priority,
callback=callback
callback=callback,
owner=owner,
)
with self._lock:
@@ -330,6 +354,7 @@ class BackgroundDataService:
self.stats['deduplicated_requests'] = (
self.stats.get('deduplicated_requests', 0) + 1
)
get_fetch_service().note_merged(url, owner)
logger.info(
"Joined in-flight fetch %s for %s (cache_key=%s) instead of "
"starting a duplicate", existing_id, sport, cache_key
@@ -357,6 +382,11 @@ class BackgroundDataService:
Returns:
Fetch result with data or error information
"""
with plugin_scope(request.owner):
return self._fetch_data_worker_scoped(request)
def _fetch_data_worker_scoped(self, request: FetchRequest) -> FetchResult:
"""_fetch_data_worker's body, run with the submitter as the caller."""
start_time = time.time()
result = FetchResult(request_id=request.id, success=False, retry_count=request.retry_count)
@@ -621,8 +651,14 @@ class BackgroundDataService:
for attempt in range(request.max_retries + 1):
try:
response = self.session.get(
# Not shared with an identical request in flight: this
# service cancels and replaces fetches, and a replacement
# must not join the one it replaced. Its own cache_key
# dedup already merges what should be merged.
response = fetch_get(
self.session,
request.url,
share_in_flight=False,
params=request.params,
headers=request.headers,
timeout=request.timeout
+32 -35
View File
@@ -88,7 +88,6 @@ _WIFI_REL = Path("config/wifi_config.json")
_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
@@ -179,20 +178,27 @@ def _build_manifest(contents: List[str]) -> 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
def _read_config(project_root: Path) -> Dict[str, Any]:
"""config/config.json as a dict; empty when missing or unreadable."""
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
return {}
return config if isinstance(config, dict) else {}
def _plugins_directory(project_root: Path,
config: Optional[Dict[str, Any]] = None) -> 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."""
if config is None:
config = _read_config(project_root)
configured: Any = None
plugin_system = config.get("plugin_system")
if isinstance(plugin_system, dict):
configured = plugin_system.get("plugins_directory")
if not isinstance(configured, str) or not configured.strip():
configured = "plugin-repos"
path = Path(configured)
@@ -202,33 +208,23 @@ def _plugins_directory(project_root: Path) -> 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``.
manifest. Each entry has ``plugin_id``, ``version`` and ``enabled``.
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`).
The plugins are the ``manifest.json`` files in the configured plugin
directory (see :func:`_plugins_directory`), with the manifest's version;
``enabled`` is config.json's flag by the display's rule (a missing flag
is disabled). A restore reinstalls every listed plugin and takes enabled
state from the restored config.json, so ``enabled`` is informational.
``data/plugin_state.json`` is not read: it only ever repeated config's
enabled flags and the manifests' versions, and is retired (nothing
writes it any more). An old backup that listed a plugin only from that
file still restores it, since restore reads ``plugins.json`` as written.
"""
plugins: Dict[str, Dict[str, Any]] = {}
config = _read_config(project_root)
state_file = project_root / _STATE_REL
if state_file.exists():
try:
with state_file.open("r", encoding="utf-8") as f:
state = json.load(f)
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
if isinstance(raw_plugins, dict):
for plugin_id, info in raw_plugins.items():
if not isinstance(info, dict):
continue
plugins[plugin_id] = {
"plugin_id": plugin_id,
"version": info.get("version") or "",
"enabled": bool(info.get("enabled", True)),
}
except (OSError, json.JSONDecodeError) as e:
logger.warning("Could not read plugin_state.json: %s", e)
plugins_root = _plugins_directory(project_root)
plugins_root = _plugins_directory(project_root, config)
if plugins_root.exists():
for entry in sorted(plugins_root.iterdir()):
if not entry.is_dir():
@@ -247,10 +243,11 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
continue
plugin_id = data.get("id") or entry.name
if plugin_id not in plugins:
section = config.get(plugin_id)
plugins[plugin_id] = {
"plugin_id": plugin_id,
"version": data.get("version", ""),
"enabled": True,
"enabled": isinstance(section, dict) and bool(section.get("enabled", False)),
}
return sorted(plugins.values(), key=lambda p: p["plugin_id"])
+8 -1
View File
@@ -19,6 +19,7 @@ import json
from typing import Dict, Any, Optional, List, cast
from src.common.api_helper import DEFAULT_HTTP_HEADERS
from src.common.fetch_service import fetch_get, share_connection_pool
@@ -59,7 +60,13 @@ class BaseOddsManager:
# 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.
#
# Every scoreboard league manager builds one of these, so the session
# mounts the fetch service's shared no-retry adapter: the same single
# try, over one connection pool per host for all of them instead of
# one pool per instance.
self.session = requests.Session()
share_connection_pool(self.session, max_retries=0)
self.session.headers.update(DEFAULT_HTTP_HEADERS)
# Configuration with defaults
@@ -168,7 +175,7 @@ class BaseOddsManager:
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
self.logger.debug(f"Requesting odds from URL: {url}")
response = self.session.get(url, timeout=self.request_timeout)
response = fetch_get(self.session, url, timeout=self.request_timeout)
response.raise_for_status()
raw_data = response.json()
+1 -256
View File
@@ -37,7 +37,6 @@ from src.cache.disk_cache import DiskCache
from src.cache.cache_strategy import CacheStrategy
from src.cache.cache_metrics import CacheMetrics
from src.logging_config import get_logger
from src.deprecation import deprecated
# Canonical implementation lives in src.cache.disk_cache; re-exported here
# because this module's docstring documents it and external code may import
@@ -408,122 +407,6 @@ class CacheManager:
"""Get the cache directory path."""
return self.cache_dir
@deprecated("3.7.0")
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
"""Check if data has changed from cached version."""
cached_data = self.load_cache(data_type)
if not cached_data:
return True
if data_type == 'weather':
return self._has_weather_changed(cached_data, new_data)
elif data_type == 'stocks':
return self._has_stocks_changed(cached_data, new_data)
elif data_type == 'stock_news':
return self._has_news_changed(cached_data, new_data)
elif data_type == 'nhl':
return self._has_nhl_changed(cached_data, new_data)
elif data_type == 'mlb':
return self._has_mlb_changed(cached_data, new_data)
return True
def _has_weather_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if weather data has changed."""
# Handle new cache structure where data is nested under 'data' key
if 'data' in cached:
cached = cached['data']
# Handle case where cached data might be the weather data directly
if 'current' in cached:
# This is the new structure with 'current' and 'forecast' keys
current_weather = cached.get('current', {})
if current_weather and 'main' in current_weather and 'weather' in current_weather:
cached_temp = round(current_weather['main']['temp'])
cached_condition = current_weather['weather'][0]['main']
return (cached_temp != new.get('temp') or
cached_condition != new.get('condition'))
# Handle old structure where temp and condition are directly accessible
return (cached.get('temp') != new.get('temp') or
cached.get('condition') != new.get('condition'))
def _has_stocks_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if stock data has changed."""
if not self._is_market_open():
return False
return cached.get('price') != new.get('price')
def _has_news_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if news data has changed."""
# Handle both dictionary and list formats
if isinstance(new, list):
# If new data is a list, cached data should also be a list
if not isinstance(cached, list):
return True
# Compare lengths and content
if len(cached) != len(new):
return True
# Compare titles since they're unique enough for our purposes
cached_titles = set(item.get('title', '') for item in cached)
new_titles = set(item.get('title', '') for item in new)
return cached_titles != new_titles
else:
# Original dictionary format handling
cached_headlines = set(h.get('id') for h in cached.get('headlines', []))
new_headlines = set(h.get('id') for h in new.get('headlines', []))
return not cached_headlines.issuperset(new_headlines)
def _has_nhl_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if NHL data has changed."""
return (cached.get('game_status') != new.get('game_status') or
cached.get('score') != new.get('score'))
def _has_mlb_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if MLB game data has changed."""
if not cached or not new:
return True
# Check if any games have changed status or score
for game_id, new_game in new.items():
cached_game = cached.get(game_id)
if not cached_game:
return True
# Check for score changes
if (new_game['away_score'] != cached_game['away_score'] or
new_game['home_score'] != cached_game['home_score']):
return True
# Check for status changes
if new_game['status'] != cached_game['status']:
return True
# For live games, check inning and count
if new_game['status'] == 'in':
if (new_game['inning'] != cached_game['inning'] or
new_game['inning_half'] != cached_game['inning_half'] or
new_game['balls'] != cached_game['balls'] or
new_game['strikes'] != cached_game['strikes'] or
new_game['bases_occupied'] != cached_game['bases_occupied']):
return True
return False
def _is_market_open(self) -> bool:
"""Check if the US stock market is currently open."""
return self._strategy_component.is_market_open()
@deprecated("3.7.0", "use set()")
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,
}
return self.save_cache(data_type, cache_data)
def get(self, key: str, max_age: Optional[int] = 300,
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Get data from cache if it exists and is not stale.
@@ -564,42 +447,6 @@ class CacheManager:
cache_data['data'] = data
self.save_cache(key, cache_data)
@deprecated("3.7.0")
def setup_persistent_cache(self) -> bool:
"""
Set up a persistent cache directory with proper permissions.
This should be run once with sudo to create the directory.
"""
try:
# Try to create /var/cache/ledmatrix with proper permissions
from pathlib import Path
from src.common.permission_utils import (
ensure_directory_permissions,
get_cache_dir_mode
)
cache_dir = '/var/cache/ledmatrix'
cache_dir_path = Path(cache_dir)
ensure_directory_permissions(cache_dir_path, get_cache_dir_mode())
# Set ownership to the real user (not root)
real_user = os.environ.get('SUDO_USER')
if real_user:
import pwd
try:
uid = pwd.getpwnam(real_user).pw_uid
gid = pwd.getpwnam(real_user).pw_gid
os.chown(cache_dir, uid, gid)
self.logger.info(f"Set ownership of {cache_dir} to {real_user}")
except (OSError, KeyError) as e:
self.logger.warning(f"Could not set ownership for {cache_dir}: {e}", exc_info=True)
self.logger.info(f"Successfully set up persistent cache directory: {cache_dir}")
return True
except (OSError, IOError, PermissionError) as e:
self.logger.error(f"Failed to set up persistent cache directory {cache_dir}: {e}", exc_info=True)
return False
def cleanup_disk_cache(self, force: bool = False) -> Dict[str, Any]:
"""
Clean up expired disk cache files based on retention policies.
@@ -776,14 +623,6 @@ class CacheManager:
else:
self.logger.info("Disk cache cleanup thread stopped successfully")
@deprecated("3.7.0")
def get_sport_live_interval(self, sport_key: str) -> int:
"""
Get the live_update_interval for a specific sport from config.
Falls back to default values if config is not available.
"""
return self._strategy_component.get_sport_live_interval(sport_key)
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
"""
Get cache strategy for different data types.
@@ -798,13 +637,6 @@ class CacheManager:
"""
return self._strategy_component.get_data_type_from_key(key)
@deprecated("3.7.0")
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
"""
Extract sport key from cache key to determine appropriate live_update_interval.
"""
return self._strategy_component.get_sport_key_from_cache_key(key)
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Dict[str, Any]]:
"""
Get data from cache using data-type-specific strategy.
@@ -838,58 +670,6 @@ class CacheManager:
data_type = self.get_data_type_from_key(key)
return self.get_cached_data_with_strategy(key, data_type)
@deprecated("3.7.0", "use get()")
def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Get data from background service cache with appropriate strategy.
This method is specifically designed for Recent/Upcoming managers
to use data cached by the background service.
Args:
key: Cache key to retrieve
sport_key: Sport key for determining appropriate cache strategy
Returns:
Cached data if available and fresh, None otherwise
"""
# Determine the appropriate cache strategy
data_type = self.get_data_type_from_key(key)
strategy = self.get_cache_strategy(data_type, sport_key)
# For Recent/Upcoming managers, we want to use the background service cache
# which should have longer TTLs than the individual manager caches
max_age = strategy['max_age']
memory_ttl = strategy.get('memory_ttl', max_age)
# Get the cached data
cached_data = self.get_cached_data(key, max_age, memory_ttl)
if cached_data:
# Record cache hit for performance monitoring
self.record_cache_hit('background')
# Unwrap if stored in { 'data': ..., 'timestamp': ... } format
if isinstance(cached_data, dict) and 'data' in cached_data:
return cached_data['data']
return cached_data
# Record cache miss for performance monitoring
self.record_cache_miss('background')
return None
@deprecated("3.7.0", "use get()")
def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
"""
Check if background service has fresh data available.
This helps Recent/Upcoming managers determine if they should
wait for background data or fetch immediately.
"""
data_type = self.get_data_type_from_key(key)
strategy = self.get_cache_strategy(data_type, sport_key)
# Check if we have data that's still fresh according to background service TTL
cached_data = self.get_cached_data(key, strategy['max_age'])
return cached_data is not None
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
"""
Centralized cache key generation for sports data.
@@ -906,44 +686,9 @@ class CacheManager:
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
return f"{sport}_{date_str}"
@deprecated("3.7.0")
def record_cache_hit(self, cache_type: str = 'regular') -> None:
"""Record a cache hit for performance monitoring."""
self._metrics_component.record_hit(cache_type)
@deprecated("3.7.0")
def record_cache_miss(self, cache_type: str = 'regular') -> None:
"""Record a cache miss for performance monitoring."""
self._metrics_component.record_miss(cache_type)
@deprecated("3.7.0")
def record_fetch_time(self, duration: float) -> None:
"""Record fetch operation duration for performance monitoring."""
self._metrics_component.record_fetch_time(duration)
@deprecated("3.7.0")
def get_cache_metrics(self) -> Dict[str, Any]:
"""Get current cache performance metrics."""
return self._metrics_component.get_metrics()
@deprecated("3.7.0")
def log_cache_metrics(self) -> None:
"""Log current cache performance metrics."""
self._metrics_component.log_metrics()
@deprecated("3.7.0")
def get_memory_cache_stats(self) -> Dict[str, Any]:
"""
Get statistics about the memory cache.
Returns:
Dictionary with memory cache statistics
"""
return self._memory_cache_component.get_stats()
def log_memory_cache_stats(self) -> None:
"""Log current memory cache statistics."""
stats = self.get_memory_cache_stats()
stats = self._memory_cache_component.get_stats()
self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} "
f"({stats['usage_percent']:.1f}%), "
f"Last cleanup: {time.time() - stats['last_cleanup']:.1f}s ago")
+92 -2
View File
@@ -27,6 +27,7 @@ Rules for the package:
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
@@ -40,11 +41,16 @@ Rules for the package:
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 |
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
| [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | 3.8.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_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
@@ -103,7 +109,9 @@ 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.
Every request goes through [`fetch_service`](#fetch_service), the chunks
counted against the plugin that asked. Scoreboard plugins also bundle a copy
for older cores.
### favorite_team_check
@@ -116,6 +124,23 @@ says the league has nothing on yet; `reset()` re-arms it after a config edit.
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
a copy for older cores.
### fetch_service
[`fetch_service.py`](fetch_service.py). Core-internal for now. Every core
fetch path -- `APIHelper.get`/`post`, `espn_dates` (so every scoreboard's
ESPN scoreboard fetch and `SportsFetchMixin`), `BackgroundDataService` and
`BaseOddsManager` -- calls `fetch_get(session, url, ...)` instead of
`session.get(url, ...)`. Same arguments, return value and exceptions; on top
it shares one connection pool per host per retry policy
(`share_connection_pool`), merges identical GETs in flight, applies per-host
token buckets (`fetch_service.rate_limits` in config.json; ESPN gets 20/s,
burst 200), revalidates with server-sent `ETag`/`Last-Modified` and counts
requests per plugin and per host. The display publishes the counters
(`FetchStatsPublisher`) for `GET /api/v3/plugins/fetch-stats`. Which plugin
made a request comes from `plugin_scope()`, set by the plugin executor, or
else from the plugin directory on the stack. See
[docs/PLUGIN_API_REFERENCE.md](../../docs/PLUGIN_API_REFERENCE.md#fetching-data).
### font_layout
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
@@ -206,7 +231,8 @@ rather than the `set_*` methods. Vegas mode reads a plugin's
[`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.
The web health check reads the file's age, and the web preview stream checks
its mtime every `VIEWER_POLL_INTERVAL`.
### sports_card
@@ -238,6 +264,16 @@ The colour helpers are free functions (`logo_palette()`, `lift_color()`,
`mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which
builds the celebration dict the docstring describes.
### sports_display_rules
[`sports_display_rules.py`](sports_display_rules.py). Two `SportsCore`
mixins: `SportsCardOptionsMixin` (`_card_option()`, which never lets the
upcoming scorebug lose both its date and time, and `_recent_date_text()`;
list it before `SportsCoreSharedMixin`) and `SportsGameRulesMixin`
(`_filtered_or_all()`, the no-favourites quality filter that fails open, and
`_effective_live_duration()`, the shorter dwell for a non-favourite live
game).
### sports_fetch
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
@@ -246,6 +282,13 @@ methods that decide which requests a scoreboard makes --
`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live
lookback) and `_wants_live_odds()` (odds only for games near the screen).
### sports_font_path
[`sports_font_path.py`](sports_font_path.py). `resolve_font_path(path)`: the
path as given when it exists (relative to the cwd), else
`font_layout.resolve_asset_path(path)`. What the scoreboards'
`_resolve_font_path` copies return on a core that ships it.
### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py).
@@ -263,6 +306,25 @@ what differs.
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
Nothing in core uses it.
### sports_live_scroll
[`sports_live_scroll.py`](sports_live_scroll.py). `SportsLiveScrollMixin`:
keeps a live scroll strip current. It fingerprints the live games (the clock
and the display pipeline's own keys excluded, via the host's
`LIVE_VOLATILE_FIELDS`), rebuilds when they change, rate-limited by what a
rebuild costs, and `_preserving_scroll_position()` keeps the marquee where
it was. Pairs with `SportsPluginHostMixin`, whose `_dispatch_switch_refresh()`
it uses.
### sports_plugin_host
[`sports_plugin_host.py`](sports_plugin_host.py). `SportsPluginHostMixin`:
helpers of a scoreboard's `BasePlugin` subclass. `get_vegas_priority_weight()`
(more Vegas slots while a favourite plays, found across every plugin's data
shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
`display()` never waits on the network), `get_vegas_content_type()` and small
dynamic-duration helpers. List it before `BasePlugin`.
### sports_scroll
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
@@ -270,6 +332,10 @@ Nothing in core uses it.
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.
`prepare_and_display()` rewinds a recent or upcoming strip whose games,
rankings, config, panel size and date are unchanged instead of calling
`prepare_scroll_content()` again, with one display per slate (game type and
leagues).
### sports_shared
@@ -280,6 +346,17 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
the attributes the host class must have and the three methods deliberately
left out.
### sports_vegas
[`sports_vegas.py`](sports_vegas.py). What a scoreboard needs for live Vegas
cards (one element per game, swapped in place while it scrolls):
`game_key()`, `game_fingerprint()`, `dedupe_games()`, `VegasCardCache` (draws
a card only when its fingerprint changes), `StickyOdds` (keeps a card's odds
through a live poll that left them out), and `finished_games()` /
`with_finished_games()` (a game that just went final keeps its card, showing
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
them together; a scoreboard not built on it (UFC) uses them directly.
### sports_timezone
[`sports_timezone.py`](sports_timezone.py).
@@ -308,6 +385,19 @@ Created by `DisplayController`; works with any plugin.
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
`draw_multiline_text()`, `create_text_image()`.
`draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0),
offsets=OUTLINE_SQUARE)` (Unreleased) draws the text in `outline_color` at
each offset, then in `fill` on top: the same pixels as one `draw.text` per
offset, but the string is rasterized once. `OUTLINE_SQUARE` is the
eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the
four-sided one. Fractional coordinates (a whole-pixel float such as `52.0`
is fine), multiline text, fonts other than a plain `FreeTypeFont`, image modes
other than RGB, RGBA and L, and a subclassed or replaced `draw.text` take
the `draw.text` loop unchanged. `TextHelper.draw_text_with_outline()` and
the scoreboards' `SportsCoreSharedMixin._draw_text_with_outline()` use it.
A plugin that also runs on older cores should guard the import and keep its
own loop as the fallback.
## Logging
Modules here create their logger with `logging.getLogger(__name__)`, which is
+14 -7
View File
@@ -11,10 +11,10 @@ import time
from datetime import datetime
from types import MappingProxyType
from src.common.espn_dates import ESPN_MAX_LIMIT
from src.common.fetch_service import fetch_get, fetch_post, share_connection_pool
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
if TYPE_CHECKING:
@@ -45,7 +45,11 @@ class APIHelper:
- 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`.
:data:`DEFAULT_HTTP_HEADERS`. Its connection pool is shared with every
other helper using the same retry policy, and requests go through the
core fetch service (``src/common/fetch_service.py``): identical GETs in
flight are merged, hosts with a budget are paced, and requests are
counted per plugin. Return values and errors are unchanged.
- Consecutive requests from one helper are spaced at least
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
does not count.
@@ -81,9 +85,10 @@ class APIHelper:
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD", "OPTIONS"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
# The shared adapter for this retry policy: the same retries as a
# private HTTPAdapter(max_retries=retry_strategy), with the connection
# pool shared by every helper (fetch_service).
share_connection_pool(self.session, retry_strategy)
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
@@ -128,7 +133,8 @@ class APIHelper:
request_headers.update(headers)
# Make request
response = self.session.get(
response = fetch_get(
self.session,
url,
params=params,
headers=request_headers,
@@ -255,7 +261,8 @@ class APIHelper:
if headers:
request_headers.update(headers)
response = self.session.post(
response = fetch_post(
self.session,
url,
data=data,
json=json_data,
+29 -3
View File
@@ -32,6 +32,7 @@ scoreboards ask every 30 seconds. After that the range is tried again, so the
workaround retires itself if ESPN reverts.
"""
import contextvars
import threading
import time
from concurrent.futures import ThreadPoolExecutor
@@ -47,6 +48,21 @@ except ImportError:
def response_json(response: Any) -> Any:
return response.json()
try:
# The core fetch service: counts, per-host budget, merging of identical
# requests. Same call, same result and errors as ``session.get``.
from src.common.fetch_service import fetch_get, pinned_caller
except ImportError:
# Bundled copies on cores without it call the session directly.
import contextlib
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
**kwargs: Any) -> Any:
return session.get(url, **kwargs)
def pinned_caller() -> Any:
return contextlib.nullcontext()
# 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
@@ -195,7 +211,8 @@ def _fetch_one_chunk(
logged and swallowed here rather than raised to the gather below.
"""
try:
response = session.get(
response = fetch_get(
session,
url,
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
headers=headers,
@@ -220,6 +237,10 @@ def _fetch_chunks(
callers keep ``chunks`` order from the returned list -- but it does mean
the session is shared across threads, which is why this only ever issues
GETs and never touches session state.
Each chunk runs in a copy of the caller's context, with the caller pinned
into it, so the fetch service counts the chunks against the plugin that
asked for the range rather than against the core.
"""
if not chunks:
return []
@@ -229,10 +250,15 @@ def _fetch_chunks(
if len(chunks) == 1:
return [fetch(chunks[0])]
workers = min(ESPN_CHUNK_WORKERS, len(chunks))
with pinned_caller():
# One copy per chunk: a Context cannot be entered by two threads.
contexts = [contextvars.copy_context() for _ in chunks]
with ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="espn-chunk",
) as pool:
return list(pool.map(fetch, chunks))
futures = [pool.submit(context.run, fetch, chunk)
for context, chunk in zip(contexts, chunks)]
return [future.result() for future in futures]
def fetch_espn_date_chunks(
@@ -363,7 +389,7 @@ def fetch_espn_scoreboard(
# real error to log, without spending the chunks a second time.
chunks_tried = True
response = session.get(url, params=params, headers=headers, timeout=timeout)
response = fetch_get(session, url, params=params, headers=headers, timeout=timeout)
if is_range and response.status_code == 400 and not chunks_tried:
_note_range_rejected()
if logger:
File diff suppressed because it is too large Load Diff
+253 -24
View File
@@ -19,8 +19,9 @@ 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
"Scrolling" is the scroll state ``DisplayManager.update_display`` acted on for
the frame, sampled once before the blit and swap, 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
@@ -38,12 +39,20 @@ after the one before it. One that arrives a whole refresh or more after that 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.
recompose, a plugin handover nobody tagged (see below), 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.
One kind of freeze is not a scroll stalling at all: the gap from one screen's
last frame to the next screen's first, while the next screen draws. The
display controller tags that frame ``handover`` (see "Operations") at the
start of every turn, the same mode's again included, and a tagged freeze is
counted in ``handover_freezes`` instead of ``freezes`` and ``freeze_by``.
Stats written before that field existed have handovers among their freezes,
so freeze counts from before and after it are not comparable.
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
@@ -66,6 +75,35 @@ 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.
Operations
----------
The late count says how often, not which work did it. Render-thread work that
happens between two frames -- extending the Vegas strip, patching a live
element into it -- calls :meth:`FrameTimingRecorder.note_op` first, and the
next presented frame carries the tag: the interval that frame ends is the one
the work landed in. ``op_frames`` counts timed frames per kind,
``late_op_frames`` the late ones among them, ``op_freezes`` those that were a
freeze instead, and ``op_bytes`` what the work moved. A kind whose late rate
sits well above the overall one is the work to look at.
``handover`` (:data:`HANDOVER_OP`) is noted off the render thread: the display
controller notes it just before it starts a screen's first ``display()``,
which presents from a thread of its own, and drops the note again with
:meth:`FrameTimingRecorder.drop_op` once that call returns, so a first
``display()`` that drew nothing cannot leave the tag for an unrelated frame.
Garbage collection
------------------
Python's cyclic collector stops every thread while it runs. :class:`GcMonitor`
times each collection from ``gc.callbacks``; the display manager installs one
per process. A collection of ``GC_PAUSE_SECONDS`` or more tags the next
presented frame ``gc`` (:data:`GC_OP`), so it shows in ``op_frames``,
``late_op_frames`` and ``op_freezes`` like noted work, and the snapshot carries
a ``gc`` block of cumulative counters: collections and seconds per generation,
the longest, and the long ones. A stall dump says when a long collection ran
inside the stall. Diagnostic only: nothing tunes or freezes the collector.
Stall watchdog
--------------
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
@@ -84,6 +122,7 @@ three times per threshold, so keep it to diagnostic runs, not soaks.
from __future__ import annotations
import copy
import gc
import json
import logging
import os
@@ -122,6 +161,16 @@ RESUME_SECONDS = 1.0
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
(float("inf"), "2s+"))
#: The op the display controller notes before a screen's first frame. A
#: freeze it ends is a handover, counted apart from the freezes; see
#: "What is counted".
HANDOVER_OP = "handover"
#: The op a garbage collection of ``GC_PAUSE_SECONDS`` or more tags the next
#: frame with; see "Garbage collection".
GC_OP = "gc"
GC_PAUSE_SECONDS = 0.020
#: A window may lower the refresh-period estimate by at most this fraction.
MAX_REFRESH_DROP = 0.2
@@ -153,11 +202,94 @@ def default_stats_path() -> str:
return os.path.join(base, STATS_FILENAME)
class GcMonitor:
"""Times every garbage collection, from ``gc.callbacks``.
Python's cyclic collector stops every thread for as long as a collection
takes, and a full one over a large heap (a season of game dicts) can take
longer than a frame. Nothing measured that, so a stall it caused looked
like any other. The callback runs inside the collection, with the GIL
held, and collections never overlap, so these plain counters need no
lock: the render thread and the stats writer only read them.
Install it once per process with :func:`install_gc_monitor`.
"""
def __init__(self, threshold: float = GC_PAUSE_SECONDS):
self.threshold = threshold
self._started: Optional[float] = None
#: Per generation (0, 1, 2), since the monitor was installed.
self.collections = [0, 0, 0]
self.seconds = [0.0, 0.0, 0.0]
self.max_seconds = 0.0
#: Collections of ``threshold`` or more, and their total length. The
#: recorder compares ``long_pauses`` with the count it last saw to tag
#: the next frame.
self.long_pauses = 0
self.long_seconds = 0.0
#: ``time.perf_counter()`` at the end of the last long collection,
#: and its length, for the stall watchdog.
self.last_long: Optional[Tuple[float, float]] = None
def __call__(self, phase: str, info: Dict[str, Any]) -> None:
now = time.perf_counter()
if phase == "start":
self._started = now
return
started, self._started = self._started, None
if started is None:
return
took = now - started
generation = min(max(int(info.get("generation", 0)), 0), 2)
self.collections[generation] += 1
self.seconds[generation] += took
if took > self.max_seconds:
self.max_seconds = took
if took >= self.threshold:
self.long_seconds += took
self.last_long = (now, took)
self.long_pauses += 1
def snapshot(self) -> Dict[str, Any]:
"""Cumulative counters for the stats file (all since installation)."""
return {
"threshold_ms": round(self.threshold * 1000.0, 3),
"collections": list(self.collections),
"seconds": [round(x, 6) for x in self.seconds],
"max_ms": round(self.max_seconds * 1000.0, 3),
"long_pauses": self.long_pauses,
"long_seconds": round(self.long_seconds, 6),
}
_gc_monitor: Optional[GcMonitor] = None
_gc_monitor_lock = threading.Lock()
def install_gc_monitor() -> GcMonitor:
"""The process's GcMonitor, installed in ``gc.callbacks`` on first call."""
global _gc_monitor
with _gc_monitor_lock:
if _gc_monitor is None:
_gc_monitor = GcMonitor()
gc.callbacks.append(_gc_monitor)
return _gc_monitor
#: One presented frame's interval: (interval, blit, wait, hold, ops), where
#: ops is the work noted before it (kind -> bytes) or None.
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
def _bucket(seconds: float) -> int:
index = int(seconds * 1000.0 / BUCKET_MS)
return min(max(index, 0), BUCKET_COUNT - 1)
def _bump(counter: Dict[str, int], key: str, by: int = 1) -> None:
counter[key] = counter.get(key, 0) + by
def binding_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
@@ -239,23 +371,31 @@ class FrameTimingRecorder:
flush_interval: float = FLUSH_INTERVAL,
info: Optional[Dict[str, Any]] = None,
refresh_hz: Optional[float] = None,
gc_monitor: Optional[GcMonitor] = 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.
:param gc_monitor: tags frames after a long garbage collection and
adds its counters to the stats (see "Garbage collection"). The
display manager passes the process's :func:`install_gc_monitor`.
"""
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._pending: List[_Frame] = []
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._unsure: Optional[_Frame] = None
# Work noted since the last frame (kind -> bytes), for the next one.
self._ops: Optional[Dict[str, int]] = None
self.gc_monitor = gc_monitor
self._gc_seen = gc_monitor.long_pauses if gc_monitor is not None else 0
self._last_flush: Optional[float] = None
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
self._worker: Optional[threading.Thread] = None
@@ -280,7 +420,16 @@ class FrameTimingRecorder:
"freezes": 0,
"freeze_seconds": 0.0,
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
# Freezes that ended a screen handover rather than stalled a
# scroll: in neither of the two above. Additive; see "What is
# counted".
"handover_freezes": 0,
"worst_interval_ms": 0.0,
# Per kind of noted render-thread work; see "Operations".
"op_frames": {},
"late_op_frames": {},
"op_freezes": {},
"op_bytes": {},
}
self.histograms: Dict[str, Dict[int, int]] = {
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
@@ -305,6 +454,37 @@ class FrameTimingRecorder:
# -- render thread ------------------------------------------------------
def note_op(self, kind: str, nbytes: int = 0) -> None:
"""Tag the next presented frame with work done before it.
Render thread only, like :meth:`record`, which consumes the tag: the
interval the next frame ends is the one this work landed in. Several
notes before one frame accumulate, per kind. See "Operations" (and
:data:`HANDOVER_OP`, the one note made from another thread).
:param kind: a short name for the work, e.g. ``"extend"``, ``"patch"``.
:param nbytes: how much the work moved, summed into ``op_bytes``.
"""
ops = self._ops
if ops is None:
ops = self._ops = {}
ops[kind] = ops.get(kind, 0) + int(nbytes)
def drop_op(self, kind: str) -> None:
"""Forget a note of ``kind`` that no frame has carried yet.
For work that may present nothing: the display controller notes a
handover before a screen's first ``display()`` and drops it once that
returns. When the call drew a frame, the frame already took the tag
and this does nothing; when it drew nothing (no content), the tag
would otherwise land on whatever frame came next -- seconds or minutes
later, and nothing to do with the handover. Other kinds noted for the
same frame are kept.
"""
ops = self._ops
if ops is not None:
ops.pop(kind, None)
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
presented_at: float) -> None:
"""One frame reached the panel.
@@ -312,12 +492,24 @@ class FrameTimingRecorder:
: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 scrolling: whether a scroll was running for this frame: the
scroll state ``update_display`` acted on, sampled once before the
blit and swap.
: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())
ops, self._ops = self._ops, None
monitor = self.gc_monitor
if monitor is not None and monitor.long_pauses != self._gc_seen:
# A long collection ran since the last frame: the interval this
# frame ends is the one it landed in. Read here rather than
# noted, since note_op is the render thread's and a collection
# runs on whichever thread triggered it.
self._gc_seen = monitor.long_pauses
ops = dict(ops) if ops else {}
ops[GC_OP] = ops.get(GC_OP, 0)
if not scrolling:
self._static_frames += 1
# The scroll ended, or its state went missing for this frame: the
@@ -325,7 +517,8 @@ class FrameTimingRecorder:
# 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])
self._unsure = (presented_at - previous[0], blit, wait,
previous[2], ops)
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())
@@ -335,14 +528,14 @@ class FrameTimingRecorder:
unsure, self._unsure = self._unsure, None
if previous[1]:
if interval < GAP_SECONDS:
self._pending.append((interval, blit, wait, hold))
self._pending.append((interval, blit, wait, hold, ops))
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))
self._pending.append((interval, blit, wait, hold, ops))
if self._last_flush is None:
self._last_flush = presented_at
@@ -382,15 +575,17 @@ class FrameTimingRecorder:
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."""
def aggregate(self, batch: List[_Frame], static: int) -> None:
"""Fold one window of frames into the running totals.
Each frame is ``(interval, blit, wait, hold, ops)``; ``ops`` (the work
noted before it, or None) may be left off.
"""
totals = self.totals
totals["static_frames"] += static
per_hold = sorted(interval / max(1, hold)
for interval, _, _, hold in batch
if interval < FREEZE_SECONDS)
per_hold = sorted(frame[0] / max(1, frame[3]) for frame in batch
if frame[0] < FREEZE_SECONDS)
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
estimate = per_hold[len(per_hold) // 10]
current = self.refresh_period
@@ -412,10 +607,23 @@ class FrameTimingRecorder:
period = self.refresh_period
histograms = self.histograms
for interval, blit, wait, hold in batch:
for frame in batch:
interval, blit, wait, hold = frame[:4]
ops = frame[4] if len(frame) > 4 else None
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
interval * 1000.0)
if ops:
for kind, nbytes in ops.items():
_bump(totals["op_bytes"], kind, nbytes)
if interval >= FREEZE_SECONDS:
for kind in ops or ():
_bump(totals["op_freezes"], kind)
if ops and HANDOVER_OP in ops:
# The next screen drawing its first frame, not a scroll
# that stalled: counted apart, so the freezes keep
# meaning the second. See "What is counted".
totals["handover_freezes"] += 1
continue
totals["freezes"] += 1
totals["freeze_seconds"] += interval
label = next(name for limit, name in FREEZE_BUCKETS
@@ -432,6 +640,10 @@ class FrameTimingRecorder:
if period:
totals["timed_frames"] += 1
missed = round(interval / period) - hold
for kind in ops or ():
_bump(totals["op_frames"], kind)
if missed >= 1:
_bump(totals["late_op_frames"], kind)
if missed >= 1:
totals["late_frames"] += 1
totals["missed_refreshes"] += missed
@@ -460,6 +672,9 @@ class FrameTimingRecorder:
"binding_releases_gil": self._binding_gil,
"info": info,
"totals": copy.deepcopy(self.totals),
# Additive: absent from older files and when no monitor is set.
**({"gc": self.gc_monitor.snapshot()}
if self.gc_monitor is not None else {}),
# 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()},
@@ -590,14 +805,28 @@ class StallWatchdog:
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."""
"""The stack dump: the stalled thread in full, the rest in brief.
A stall while a ``handover`` note is still waiting for its frame is
the next screen's first ``display()`` taking its time, not a scroll
that stopped, and is labelled a handover gap.
"""
names = {t.ident: t.name for t in threading.enumerate()}
frames = sys._current_frames()
pending = getattr(self.recorder, "_ops", None)
where = ("in a handover gap" if pending and HANDOVER_OP in pending
else "mid-scroll")
monitor = getattr(self.recorder, "gc_monitor", None)
last_long = getattr(monitor, "last_long", None)
gc_note = ""
if last_long is not None and time.perf_counter() - last_long[0] <= age:
gc_note = (f"; a {last_long[1] * 1000.0:.0f}ms garbage collection "
"ran inside it")
lines = [
f"Render stall: no frame for {age * 1000.0:.0f}ms mid-scroll "
f"Render stall: no frame for {age * 1000.0:.0f}ms {where} "
f"(watchdog woke {late * 1000.0:.0f}ms late"
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
+ ")",
+ gc_note + ")",
f"-- {names.get(ident, ident)} (presents frames):",
]
stalled = frames.get(ident)
+66 -2
View File
@@ -157,7 +157,13 @@ def crisp_ladder(
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
_STEP_PENALTY = 0.05
_SLOW_FPS_PENALTY = 0.25 # below 20fps
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
_LOWISH_FPS_PENALTY = 0.16 # below 30fps, i.e. "slightly stepped"
# Up to 30fps, matching CrispSpeed.steppiness: a measured 125.7Hz panel makes
# 50.3px/s (2px every 5 refreshes) 25.1fps, which a 25fps cutoff let through.
# 0.16, not less: asked for 50px/s on a 120Hz panel, 48px/s (2px every 5
# refreshes, 24fps) costs 0.04 + 0.05 + this, and has to lose to both 60px/s
# and 40px/s (1px, smooth, 20% off = 0.20). At 0.10 it won and shipped a
# visibly stepped scroll to anyone asking for the default.
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
@@ -173,7 +179,7 @@ def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
fps = candidate.frames_per_second
if fps < 20:
cost += _SLOW_FPS_PENALTY
elif fps < 25:
elif fps < 30:
cost += _LOWISH_FPS_PENALTY
return cost
@@ -474,3 +480,61 @@ def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
if not isinstance(hardware, dict):
return DEFAULT_REFRESH_HZ
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
#: Smooth options offered next to a speed that is not one itself.
_ADVICE_ALTERNATIVES = 2
def speed_advice(
requested_pixels_per_second: float,
refresh_hz: float,
min_pixels_per_second: float = MIN_PIXELS_PER_SECOND,
max_pixels_per_second: float = MAX_PIXELS_PER_SECOND,
) -> Dict[str, Any]:
"""What the panel will do with a requested speed, for showing in a UI.
``applied`` is what :func:`solve_crisp` picks, i.e. what really runs.
``smooth`` is true when that is single-pixel-ish, 30fps-or-better motion.
``alternatives`` are the smooth ladder entries nearest the request inside
the given range, for a click-to-apply suggestion; empty when the request
already is one.
"""
hz = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
requested = max(MIN_PIXELS_PER_SECOND,
min(MAX_PIXELS_PER_SECOND, _coerce(requested_pixels_per_second) or 0.0))
applied = solve_crisp(requested, hz)
def as_dict(c: CrispSpeed) -> Dict[str, Any]:
return {
"pixels_per_second": round(c.pixels_per_second, 1),
"pixels_per_frame": c.pixels_per_frame,
"frame_hold": c.frame_hold,
"frames_per_second": round(c.frames_per_second, 1),
"steppiness": c.steppiness,
}
smooth_ladder = [
c for c in crisp_ladder(hz)
if c.steppiness == "smooth"
and min_pixels_per_second <= c.pixels_per_second <= max_pixels_per_second
]
# 2%: a UI hands over whole numbers, and 63 asked of a 62.9 px/s panel is
# as good as exact.
exact = abs(applied.pixels_per_second - requested) <= max(0.05, 0.02 * requested)
smooth = applied.steppiness == "smooth"
alternatives: List[CrispSpeed] = []
if not (exact and smooth):
alternatives = sorted(
smooth_ladder,
key=lambda c: abs(c.pixels_per_second - requested),
)[:_ADVICE_ALTERNATIVES]
alternatives.sort(key=lambda c: c.pixels_per_second)
return {
"requested": round(requested, 1),
"refresh_hz": round(hz, 1),
"applied": as_dict(applied),
"exact": exact,
"smooth": smooth,
"alternatives": [as_dict(c) for c in alternatives],
}
+222 -37
View File
@@ -18,7 +18,7 @@ Features:
import logging
import math
import time
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List, Tuple
from PIL import Image
import numpy as np
@@ -29,6 +29,15 @@ import numpy as np
FPS_LOG_INTERVAL = 5.0
def _rgb_pixels(item) -> np.ndarray:
"""An appended item's pixels as an RGB array, as pasting it would draw them."""
if isinstance(item, np.ndarray):
return item
if item.mode != 'RGB':
item = item.convert('RGB')
return np.asarray(item)
def frame_stats(frame_times: list) -> Dict[str, Any]:
"""Summary statistics over one window of frame durations (seconds).
@@ -111,9 +120,21 @@ class ScrollHelper:
self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds
self.scroll_speed = 1.0
self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms)
self.cached_image: Optional[Image.Image] = None
self.cached_image = None # see the property below
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
self.total_scroll_width = 0
# An extended strip lives in a buffer with spare room after it, and
# cached_array is a view of the buffer's live columns: an append writes
# only the new columns, and a trim only moves the view's start. See
# append_content. _strip_view is the view this helper last made; a
# cached_array that is anything else was set from outside and is not
# written through.
self._strip_buffer: Optional[np.ndarray] = None
self._strip_view: Optional[np.ndarray] = None
self._strip_start = 0
#: Bytes the last append_content / drop_scrolled_prefix copied, for
#: frame-timing attribution (src/common/frame_timing.py note_op).
self.last_copy_bytes = 0
# Pre-allocated buffer for output frame (reused to avoid allocations)
self._frame_buffer: Optional[np.ndarray] = None
@@ -172,7 +193,53 @@ class ScrollHelper:
# Scrolling state management
self.is_scrolling = False
self.scroll_complete = False
# -- the strip as a PIL image ---------------------------------------------
#
# Every frame is cut from cached_array; nothing on the frame path reads the
# PIL image's pixels. Extending and trimming a strip (append_content,
# drop_scrolled_prefix) used to rebuild that image in full each time
# anyway: Image.fromarray of a Vegas-sized strip is 1.7-3.8ms on a Pi 4,
# twice per extension, on the render thread. Those two now leave it to be
# built from the array on first read, which in Vegas means only by a
# multi-display sync push -- and the strip is not held twice in memory.
#
# Assigning cached_image still stores exactly what was assigned; a lazy
# image is only ever one the helper derived from its own array.
@property
def cached_image(self) -> Optional[Image.Image]:
"""The strip as a PIL image, built from ``cached_array`` if deferred."""
image = self.__dict__.get('_cached_image')
if image is not None:
return image
source = self.__dict__.get('_image_source')
if source is None:
return None
# Built from the array this read started with. Another thread (the
# sync push) may read while the render thread extends the strip; it
# then gets the strip as it was, as it did when the image was built
# eagerly, and the stale build is not kept.
image = Image.fromarray(source)
if self.__dict__.get('_image_source') is source:
self._cached_image = image
return image
@cached_image.setter
def cached_image(self, image: Optional[Image.Image]) -> None:
self._cached_image = image
self._image_source = None
def _defer_image(self) -> None:
"""The array just changed under the image: rebuild it only if read."""
self._cached_image = None
self._image_source = self.cached_array
def has_strip(self) -> bool:
"""Whether there is a strip (an image, or one deferred), not reading it."""
return (self.__dict__.get('_cached_image') is not None
or self.__dict__.get('_image_source') is not None)
def create_scrolling_image(self, content_items: list,
item_gap: int = 32,
element_gap: int = 16,
@@ -203,6 +270,7 @@ class ScrollHelper:
self.total_scroll_width = 0
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
self.cached_array = np.array(self.cached_image)
self._forget_strip_buffer()
self.scroll_position = 0.0
self.total_distance_scrolled = 0.0
self.scroll_complete = False
@@ -238,6 +306,7 @@ class ScrollHelper:
self.cached_image = full_image
# Convert to numpy array for fast operations
self.cached_array = np.array(full_image)
self._forget_strip_buffer()
actual_image_width = full_image.width
self.total_scroll_width = actual_image_width
@@ -283,7 +352,7 @@ class ScrollHelper:
Otherwise the position advances by elapsed time at the configured
speed.
"""
if not self.cached_image:
if not self.has_strip():
return
# Calculate frame time for consistent scroll speed regardless of FPS
@@ -427,7 +496,7 @@ class ScrollHelper:
Returns:
PIL Image showing the visible portion, or None if no cached image
"""
if not self.cached_image or self.cached_array is None:
if self.cached_array is None or not self.has_strip():
return None
start_x_int = int(self.scroll_position)
@@ -501,7 +570,7 @@ class ScrollHelper:
slices (128×32 = 12 KB) used here.
"""
_size = (self.display_width, self.display_height)
img_w = self.cached_image.width
img_w = self.cached_array.shape[1]
if end_x <= img_w:
# Normal case: single contiguous slice (fastest path)
@@ -634,7 +703,10 @@ class ScrollHelper:
strip also defers completion, which is the intent.
Args:
content_items: Images to append, in order
content_items: Images to append, in order. An item may instead be
its pixels already as an RGB array (``np.asarray`` of an RGB
image), so a caller can do that conversion off the render
thread (Vegas prepares its blocks with the group).
item_gap: Gap between appended items, and between the existing
content and the first appended item
element_gap: Extra gap after each item, mirroring
@@ -646,42 +718,103 @@ class ScrollHelper:
if not content_items:
return False
if self.cached_image is None or self.cached_array is None:
if self.cached_array is None or not self.has_strip():
# Nothing to extend yet — this is just the first build.
self.create_scrolling_image(
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
[Image.fromarray(item) if isinstance(item, np.ndarray) else item
for item in content_items],
item_gap=item_gap, element_gap=element_gap, lead_gap=0)
return True
gap = max(0, item_gap)
addition_width = (
sum(img.width for img in content_items)
+ gap * len(content_items) # one leading gap per item
+ element_gap * len(content_items)
)
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
pieces = []
x = 0
for img in content_items:
for item in content_items:
x += gap # separate from whatever precedes
addition.paste(img, (x, 0))
x += img.width + element_gap
pixels = _rgb_pixels(item)
pieces.append((x, pixels))
x += pixels.shape[1] + element_gap
addition_width = x
# numpy concatenate then one conversion back, rather than allocating a
# full-width PIL image and pasting twice: the strip can be tens of
# thousands of columns wide and this runs on the render path.
self.cached_array = np.concatenate(
(self.cached_array, np.array(addition)), axis=1)
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
# Each item is written straight into the spare room after the strip,
# when there is some: the strip can be tens of thousands of columns
# wide and this runs on the render thread, where copying all of it
# (2-3 ms at 512x64 on a Pi 4) -- or even laying the items out in an
# image of their own first (another 4-5 ms) -- cost the frame after
# every extension. The PIL image is built from the array only if
# something reads it (see cached_image).
self.cached_array = self._extended_strip(pieces, addition_width)
self._defer_image()
self.total_scroll_width = self.cached_array.shape[1]
self.scroll_complete = False
self.logger.info(
# Debug: this runs on the render thread, and the caller (Vegas) logs
# each extension itself.
self.logger.debug(
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
len(content_items), addition_width, self.total_scroll_width,
self.scroll_position
)
return True
#: Room an extended strip's buffer is given, as a multiple of what it
#: holds when (re)allocated. Trims free columns at the front and appends
#: use them at the back, so with 3x the buffer is reallocated -- the one
#: full copy -- about once every two strip-lengths scrolled.
STRIP_SPARE_FACTOR = 3.0
def _extended_strip(self, pieces: List[Tuple[int, np.ndarray]], added: int) -> np.ndarray:
"""The strip with ``added`` black columns after it, ``pieces`` drawn in.
Each piece is ``(x, pixels)``, x counted from the old strip's end;
written in place when the buffer has the room.
"""
live = self.cached_array
if live is None:
# append_content builds a first strip itself and never comes here.
raise RuntimeError("no strip to extend")
width = live.shape[1]
buffer = self._strip_buffer
if (live is self._strip_view and buffer is not None
and self._strip_start + width + added <= buffer.shape[1]):
end = self._strip_start + width
self.last_copy_bytes = 0
else:
total = width + added
buffer = np.empty((live.shape[0], max(total + 1, int(total * self.STRIP_SPARE_FACTOR)))
+ live.shape[2:], dtype=live.dtype)
buffer[:, :width] = live
self._strip_buffer = buffer
self._strip_start = 0
end = width
self.last_copy_bytes = live.nbytes
rows = buffer.shape[0]
region = buffer[:, end:end + added]
# Black only where no piece lands -- the gaps, and below a short
# piece: blanking the whole region first cost as much again as
# writing the pieces (1.8 ms at 512x64 on a Pi 4).
covered = 0
for x, pixels in pieces:
pixels = pixels[:rows]
cols = pixels.shape[1]
if x > covered:
region[:, covered:x] = 0
region[:pixels.shape[0], x:x + cols] = pixels
if pixels.shape[0] < rows:
region[pixels.shape[0]:, x:x + cols] = 0
covered = max(covered, x + cols)
if covered < added:
region[:, covered:] = 0
self.last_copy_bytes += region.nbytes
self._strip_view = buffer[:, self._strip_start:self._strip_start + width + added]
return self._strip_view
def _forget_strip_buffer(self) -> None:
"""A new strip replaces the extended one: let its buffer go."""
self._strip_buffer = None
self._strip_view = None
self._strip_start = 0
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
"""
Discard columns that have already scrolled past, to bound memory.
@@ -699,14 +832,15 @@ class ScrollHelper:
Returns:
Number of columns actually removed
"""
if self.cached_image is None or self.cached_array is None:
if self.cached_array is None or not self.has_strip():
return 0
strip_width = self.cached_array.shape[1]
# While the viewport wraps, get_visible_portion fills its right-hand side
# from the *head* of the strip, so trimming the head would change what
# is on screen. Continuous mode extends before ever reaching that state;
# refusing here keeps "trimming is invisible" true unconditionally.
if self.scroll_position + self.display_width > self.cached_image.width:
if self.scroll_position + self.display_width > strip_width:
return 0
cut = int(self.scroll_position) - max(0, keep_before)
@@ -714,15 +848,24 @@ class ScrollHelper:
return 0
# Never trim so far that the remaining strip is narrower than the
# viewport, or get_visible_portion has nothing to slice.
cut = min(cut, max(0, self.cached_image.width - self.display_width))
cut = min(cut, max(0, strip_width - self.display_width))
if cut <= 0:
return 0
# .copy() so the original buffer is released rather than kept alive by
# a numpy view.
self.cached_array = self.cached_array[:, cut:].copy()
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
if self.cached_array is self._strip_view:
# Only the view's start moves; the columns behind it are reused
# when the buffer is next reallocated (append_content).
self._strip_view = self.cached_array[:, cut:]
self._strip_start += cut
self.cached_array = self._strip_view
self.last_copy_bytes = 0
else:
# Not a strip this helper extended: .copy() so the original
# buffer is released rather than kept alive by a view.
self.cached_array = self.cached_array[:, cut:].copy()
self.last_copy_bytes = self.cached_array.nbytes
self._defer_image()
self.total_scroll_width = self.cached_array.shape[1]
self.scroll_position -= cut
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
@@ -732,9 +875,46 @@ class ScrollHelper:
)
return cut
def patch_columns(self, x: int, pixels: np.ndarray) -> int:
"""Overwrite the strip's columns from ``x`` with ``pixels``, in place.
What a live Vegas element update is (src/vegas_mode/elements.py): the
strip keeps its width, the scroll keeps its position, and only these
columns change. Call it between frames on the thread that draws them;
every frame copies its slice out of the strip (get_visible_portion),
so no frame already handed on can see half a patch.
Clipped to the strip at both ends. Refused (0) for an array this
helper may not write -- the multi-display follower adopts a read-only
one -- or for pixels of another height. The PIL image is deferred, so
a later read of cached_image shows the patch.
Args:
x: Strip column of the first column of ``pixels``
pixels: uint8 array (height, width, 3)
Returns:
Bytes written.
"""
strip = self.cached_array
if strip is None or not strip.flags.writeable:
return 0
if pixels.ndim != 3 or pixels.shape[0] != strip.shape[0] \
or pixels.shape[2] != strip.shape[2]:
return 0
width = pixels.shape[1]
lo, hi = max(0, int(x)), min(strip.shape[1], int(x) + width)
if hi <= lo:
return 0
strip[:, lo:hi] = pixels[:, lo - int(x):hi - int(x)]
if self.__dict__.get('_cached_image') is not None \
or self.__dict__.get('_image_source') is not None:
self._defer_image()
return (hi - lo) * strip.shape[0] * strip.shape[2]
def remaining_unscrolled(self) -> int:
"""Columns of strip still to the right of the viewport."""
if self.cached_image is None:
if not self.has_strip():
return 0
return max(0, self.total_scroll_width - int(self.scroll_position)
- self.display_width)
@@ -796,6 +976,7 @@ class ScrollHelper:
# Convert to numpy array for fast operations (required for get_visible_portion)
self.cached_array = np.array(image)
self._forget_strip_buffer()
# Update scroll width
self.total_scroll_width = image.width
@@ -1053,6 +1234,7 @@ class ScrollHelper:
"""
self.cached_image = None
self.cached_array = None
self._forget_strip_buffer()
self.total_scroll_width = 0
self.scroll_position = 0.0
self.total_distance_scrolled = 0.0
@@ -1082,5 +1264,8 @@ class ScrollHelper:
'elapsed_time': (time.time() - self.scroll_start_time)
if self.scroll_start_time
else None,
'cached_image_size': (self.cached_image.width, self.cached_image.height) if self.cached_image else None
# From the array: reading cached_image would build a deferred one.
'cached_image_size': ((self.cached_array.shape[1], self.cached_array.shape[0])
if self.cached_array is not None and self.has_strip()
else None)
}
+21 -2
View File
@@ -26,14 +26,33 @@ Policy:
- Unchanged frames are never re-encoded; the mtime is touched every
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
The writer and the SSE reader (web_interface/app.py) have two periods, not
one shared value. The reader sends each write it sees, so the preview shows
at most one frame per VIEWER_INTERVAL. (That was 0.2 s while the reader
slept 1 s between reads, so four encodes in five were overwritten unread.)
The reader checks the file's mtime every VIEWER_POLL_INTERVAL, which is only
a stat, and so sends each write within that long of it landing. Equal
periods would alias: two unsynchronised 1 s clocks leave the preview up to a
second stale, and now and then 2 s between frames.
decide() is monotone in frame_changed: SKIP for a changed frame means SKIP
for an unchanged one. DisplayManager relies on that to skip hashing the
frame when even a changed one would be skipped; test_snapshot_policy.py
checks it.
If any constant here changes, re-check the health threshold in
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
"""
from enum import Enum
# Snapshot cadence with a browser preview open (seconds).
VIEWER_INTERVAL = 0.2
# Snapshot cadence with a browser preview open (seconds): the shortest gap
# between two preview frames.
VIEWER_INTERVAL = 1.0
# How often the web SSE reader checks the snapshot's mtime (seconds). Must
# stay well under VIEWER_INTERVAL -- half of it at most -- or the two clocks
# alias (see above).
VIEWER_POLL_INTERVAL = 0.25
# 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
+164
View File
@@ -0,0 +1,164 @@
"""Which games a scoreboard shows, for how long, and what its scorebug dates say.
Four ``sports.py`` methods are identical (executable AST, docstrings
stripped, decorators compared) in every scoreboard that carries them, and
were copied here from ledmatrix-plugins ``56c4f15`` (origin/main,
2026-09-30) under their existing names. They split into two mixins because
their carriers differ, and a plugin should not gain an override it did not
have:
``SportsCardOptionsMixin`` -- afl, baseball, basketball, football, hockey,
lacrosse, nrl and soccer (ufc draws no team scorebug):
- ``_card_option`` -- reads one ``scroll_card`` key through
``SportsCoreSharedMixin._card_option``, but never lets the upcoming
scorebug lose both its date and its time (the combination a settings-form
bug saved for a whole cohort of boards);
- ``_recent_date_text`` -- the date line of the full-screen recent scorebug.
``SportsGameRulesMixin`` -- all nine:
- ``_filtered_or_all`` (all but football, which has no such method) -- the
quality and division filters on a board with no favourites, failing open
to every game rather than a blank panel;
- ``_effective_live_duration`` (all but ufc, which has none) -- how long a
live game stays up: ``non_favorite_live_game_duration`` for a
non-favourite when favourites are set, else ``game_display_duration``.
afl, nrl and soccer carry it on ``SportsCore``, the other five on
``SportsLive``; the bodies are the same.
The plugin missing a method gains one it never calls, which changes nothing:
nothing in that plugin, nor in core, calls it.
A new module rather than more methods on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixins read; the host-contract
test in ``test/test_sports_display_rules.py`` fails if a read is added
without being listed here.
``SportsCardOptionsMixin``:
- ``SportsCoreSharedMixin`` (``src.common.sports_shared``) in the MRO
**after** this mixin: ``_card_option`` calls that mixin's ``_card_option``
and ``_switch_upcoming_center`` by name, and ``_recent_date_text`` its
``_format_game_date``. List this mixin first --
``class SportsCore(SportsCardOptionsMixin, SportsGameRulesMixin,
SportsFetchMixin, SportsCoreSharedMixin, SportsHelpersMixin, ABC)`` --
or ``SportsCoreSharedMixin._card_option`` wins and the rescue is lost.
Through it: ``config`` (the ``scroll_card`` block it reads).
``SportsGameRulesMixin``:
- ``_passes_other_filters(game)`` -- the plugin's own quality/division
filter (``_filtered_or_all``).
- ``_check_ranking_coverage(games)`` -- from ``SportsCoreSharedMixin``.
- ``favorite_teams``, ``game_display_duration`` and
``_is_favorite_game(game)``; ``non_favorite_live_game_duration`` read with
``getattr`` (``_effective_live_duration``).
Neither mixin has an ``__init__`` or state. A method on the plugin's own
class still wins over either.
"""
from typing import Any, Callable, Dict, List, Optional
from src.common.sports_shared import SportsCoreSharedMixin
class SportsCardOptionsMixin:
"""The scorebug's ``scroll_card`` reads. See module docstring."""
# The host contract, declared for type checking only.
_format_game_date: Callable[..., str]
def _card_option(self, key: str, default: Any = None) -> Any:
"""Read one scroll_card key, never blanking the upcoming scorebug.
With the middle set to "date and time" and both of those lines
switched off, the full-screen upcoming scorebug is two logos and
"Next Game" with nothing to say when the game is. Nobody picks that
on purpose -- "vs" and "none" are the settings for a card without the
stack -- yet a whole cohort of boards has it: switch_show_date/_time
shipped while the core's settings form still drew keys missing from
the saved config as unchecked boxes, so the next Save wrote both as
false (fixed in LEDMatrix #597). That one combination therefore reads
as both on. Hiding either line alone, or both under "vs" or "none",
is still honoured.
"""
# The mixin named outright, not super(): tests lift this method onto
# stand-in classes that are not SportsCore subclasses.
base = SportsCoreSharedMixin._card_option
keys = ("switch_show_date", "switch_show_time")
value = base(self, key, default) # type: ignore[arg-type]
if (key in keys and not value
and not any(base(self, k, True) for k in keys) # type: ignore[arg-type]
and SportsCoreSharedMixin._switch_upcoming_center(self) == "date_time"): # type: ignore[arg-type]
return True
return value
def _recent_date_text(self, game: Optional[Dict]) -> str:
"""When a finished game was played, for the full-screen scorebug.
Formatted by switch_date_format, like the upcoming scorebug, so the
two dates on this display agree; its "numeric" default returns the
extractor's "9/23" unchanged. ``switch_recent_show_date`` (default
true) is the off switch.
"""
if not self._card_option("switch_recent_show_date", True):
return ""
return self._format_game_date(str((game or {}).get("game_date") or ""), game)
class SportsGameRulesMixin:
"""Which games are worth showing, and for how long. See module docstring."""
# The host contract, declared for type checking only.
favorite_teams: List[str]
game_display_duration: float
_passes_other_filters: Callable[[Dict], bool]
_check_ranking_coverage: Callable[[List[Dict]], None]
_is_favorite_game: Callable[[Dict], bool]
def _filtered_or_all(self, games: List[Dict]) -> List[Dict]:
"""The games worth watching, or all of them if that leaves none.
With no favourites configured every game selected is a non-favourite
game, so the quality and division settings have to apply here too. They
governed only the top-up slice, which this branch never uses, so a
board with an empty favourites list had both settings silently inert --
it could ask for ranked games only and still get the next N kickoffs.
Fails open as a whole, not just per check. `_passes_other_filters`
allows a game whose data could not be resolved, but a filter working
exactly as asked can still match nothing on a given day, and here there
is no favourite left to carry the mode -- an empty list is a blank
panel rather than a short one.
"""
kept = [g for g in games if self._passes_other_filters(g)]
self._check_ranking_coverage(games)
return kept or games
def _effective_live_duration(self, game) -> float:
"""How long the given live game should stay on screen before rotating.
Non-favorite live games use non_favorite_live_game_duration, but only
when it is set (> 0) AND favorite teams are configured. With no favorites
(or the knob at 0) every live game uses game_display_duration - identical
to the prior single-duration behavior. When show_favorite_teams_only is
on, non-favorite games are never shown, so this naturally never fires."""
non_fav = getattr(self, "non_favorite_live_game_duration", 0) or 0
if (
non_fav > 0
and self.favorite_teams
and game is not None
and not self._is_favorite_game(game)
):
return non_fav
return self.game_display_duration
__all__ = ["SportsCardOptionsMixin", "SportsGameRulesMixin"]
+41
View File
@@ -0,0 +1,41 @@
"""Where a scoreboard's bundled font file is, whatever the working directory.
Every scoreboard's ``sports.py`` (nine) and ``game_renderer.py`` (eight)
carries the same module-level ``_resolve_font_path``. It predates
:func:`src.common.font_layout.resolve_asset_path`, and probes the core for
it: the path as given when it exists (relative to the cwd), else the core's
resolver (``FontManager._resolve_asset_path``, which delegates to
``resolve_asset_path``), else the path joined to the install root, else the
path unchanged so the caller's ``ImageFont.truetype`` raises and falls back
as before.
On every core this module ships in, the probe always finds the resolver, and
the install-root join repeats what the resolver already tried. What is left
is two steps, and :func:`resolve_font_path` is exactly those: the cwd first,
then ``resolve_asset_path``. ``test/test_sports_font_path.py`` checks that
against the plugins' own copies, path for path. It is the same rule as
``sports_shared._resolve_font_path``, made public so a plugin can import it.
Why not ``resolve_asset_path`` alone: it never consults the cwd, so a
process started from another checkout would switch to the install root's
fonts. Keeping the cwd first keeps that behaviour exactly.
"""
import os
from src.common.font_layout import resolve_asset_path
def resolve_font_path(path: str) -> str:
"""``path`` if it exists, else :func:`resolve_asset_path` of it.
Absolute paths that exist come back untouched; a relative path is tried
against the cwd, then the install root; a path found nowhere comes back
unchanged, so the caller still raises and falls back.
"""
if os.path.exists(path):
return path
return resolve_asset_path(path)
__all__ = ["resolve_font_path"]
+277
View File
@@ -0,0 +1,277 @@
"""Keep a live scoreboard's scrolling strip current without restarting it.
In scroll mode a scoreboard renders its games into one wide image and
scrolls it past the panel. The strip used to be rebuilt only when a cycle
completed, so a score changed mid-cycle stayed frozen in the pixels until the
marquee finished. Eight scoreboards -- afl, baseball, basketball, football,
hockey, lacrosse, nrl and soccer (ufc has no live strip) -- carry the same
fix in their ``manager.py``: fingerprint the live games, rebuild when the
fingerprint changes (rate-limited, and never for the clock alone), and keep
the marquee's position across the rebuild. Its eight methods and two class
constants are identical (executable AST, docstrings stripped, decorators
compared) in all eight and were copied here from ledmatrix-plugins
``56c4f15`` (origin/main, 2026-09-30) under their existing names:
- ``_live_scroll_managers`` -- the live managers whose games are on the strip;
- ``_refresh_live_scroll_managers`` -- let them refresh before they are
fingerprinted, off the render thread;
- ``_live_scroll_fields``, ``_fingerprint_games`` and
``_live_scroll_fingerprint`` -- what the strip was drawn from;
- ``_live_scroll_needs_rebuild`` (with ``LIVE_SCROLL_REBUILD_MIN_SECONDS``
and ``LIVE_SCROLL_REBUILD_DUTY_DIVISOR``) and ``_note_live_scroll_built``
-- when to rebuild;
- ``_preserving_scroll_position`` -- a context manager that keeps the marquee
where it was across a rebuild.
``LIVE_VOLATILE_FIELDS`` stays in each plugin: afl, nrl and soccer also
exclude ``period_text``, which embeds the clock in those sports.
A separate module from ``sports_plugin_host`` because ufc has no live strip:
it inherits that mixin and not this one, so none of this is in its MRO.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` / ``cls.<attr>`` the mixin reads;
the host-contract test in ``test/test_sports_live_scroll.py`` fails if a read
is added without being listed here.
- ``LIVE_VOLATILE_FIELDS`` -- a class constant: the game-dict keys a rebuild
ignores (the clock, and what the display pipeline adds).
- ``_live_scroll_fingerprints``, ``_live_scroll_rebuilt_at`` and
``_live_scroll_rebuild_cost`` -- empty dicts the host creates in
``__init__``, keyed by scroll key.
- ``logger``.
- ``_dispatch_switch_refresh(manager)`` -- from ``SportsPluginHostMixin``.
- ``_league_registry`` (``{league: {"enabled": bool, "managers": {"live":
manager}}}``) or a ``_get_manager(mode_type)`` accessor, both read with
``getattr`` -- ``_live_scroll_managers``. A host with neither gets no
managers, which leaves the feature inert rather than wrong.
- ``_scroll_manager``, read with ``getattr`` --
``_preserving_scroll_position`` asks it for the mode's scroll helper.
Add it as a base of the plugin class beside ``SportsPluginHostMixin``, before
``BasePlugin``: ``class SoccerScoreboardPlugin(SportsPluginHostMixin,
SportsLiveScrollMixin, BasePlugin)``. The two define no name in common. No
``__init__``; a method on the plugin's own class still wins over the mixin's.
"""
import logging
import time
from contextlib import contextmanager
from typing import Any, Callable, ClassVar, Dict, FrozenSet, Iterator, List
class SportsLiveScrollMixin:
"""Mid-cycle rebuilds of a live scroll strip. See module docstring."""
# The host contract, declared for type checking only: these create no
# attributes, so the host's own values are what the methods read.
logger: logging.Logger
LIVE_VOLATILE_FIELDS: ClassVar[FrozenSet[str]]
_live_scroll_fingerprints: Dict[Any, Any]
_live_scroll_rebuilt_at: Dict[Any, float]
_live_scroll_rebuild_cost: Dict[Any, float]
_dispatch_switch_refresh: Callable[[Any], None]
#: Floor between mid-cycle strip rebuilds, and the duty-cycle cap that can
#: raise it.
#:
#: A rebuild re-renders every card into one wide image, on the render
#: thread, so the marquee is frozen for however long it takes. Measured on a
#: Pi 4: 28ms for one game, 139ms for five, 435ms for fifteen. A fixed 5s
#: floor is fine for one game and wrong for a full slate -- with fifteen
#: live games a pitch lands somewhere every second or so, the fingerprint
#: changes continuously, and 435ms every 5s is nearly a tenth of the time
#: spent not scrolling.
#:
#: So the floor also scales with what the last rebuild actually cost: never
#: spend more than 1/LIVE_SCROLL_REBUILD_DUTY_DIVISOR of wall time
#: rebuilding. Fifteen games self-limits to a rebuild every ~8.7s; one game
#: stays on the 5s floor. No per-sport tuning, and it adapts to slate size
#: and panel width on its own.
LIVE_SCROLL_REBUILD_MIN_SECONDS: ClassVar[float] = 5.0
LIVE_SCROLL_REBUILD_DUTY_DIVISOR: ClassVar[float] = 20.0
def _live_scroll_managers(self, league=None):
"""The live managers whose games are on the strip.
Two shapes across the scoreboard lineage: a _league_registry (baseball,
basketball, hockey, lacrosse, soccer, football) and a _get_manager
accessor on the single-league plugins (afl, nrl). Anything else returns
nothing, which leaves this feature inert rather than wrong.
"""
registry = getattr(self, "_league_registry", None)
if isinstance(registry, dict) and registry:
managers = []
for league_id, entry in registry.items():
if league is not None and league_id != league:
continue
entry = entry or {}
if not entry.get("enabled", False):
continue
manager = (entry.get("managers") or {}).get("live")
if manager is not None:
managers.append(manager)
return managers
getter = getattr(self, "_get_manager", None)
if callable(getter):
try:
# pylint: disable=not-callable
# The lineages that lack _get_manager infer this as None, so a
# static checker calls it uncallable. callable() above is the
# runtime guard; the branch is simply dead in those plugins.
manager = getter("live")
except (AttributeError, KeyError, TypeError, ValueError, OSError):
return []
return [manager] if manager is not None else []
return []
def _refresh_live_scroll_managers(self, league=None) -> None:
"""Let the live managers refresh before their games are fingerprinted.
Switch mode stays current because _try_manager_display() calls
_ensure_manager_updated() on every pass. Scroll mode had no equivalent:
its only refresh sat inside the block gated by the rebuild decision, and
that decision is computed from the data the refresh would replace. So
once the first strip was built nothing could change it, and the score on
the marquee stayed frozen until the process restarted.
The refresh runs off the render thread -- see _dispatch_switch_refresh().
This is called on every scroll frame, and a due manager.update() is a
network round trip: run inline, it froze the marquee for the length of
the ESPN request. The refreshed games land a few frames later, and the
fingerprint check that follows this call picks them up on the next frame
after they do. Dispatches for a manager are rate-limited, so the frames
where nothing is due cost a dict lookup and a clock read.
Deliberately NOT gated on mode_type == "live". A recent/upcoming strip
never rebuilds from the fingerprint (_live_scroll_needs_rebuild returns
early for those), so refreshing here looks like wasted work -- but with
live_priority the plugin only switches TO live mode once it knows live
games exist, and it learns that from these same managers. Refreshing
only while live mode is on screen would rebuild the same circularity one
level up, and a game that went live would wait for the background
plugin update -- an hour, on a rig that sets update_interval: 3600.
"""
for manager in self._live_scroll_managers(league) or []:
try:
self._dispatch_switch_refresh(manager)
except (AttributeError, KeyError, TypeError, ValueError, OSError,
RuntimeError) as exc:
# Narrow on purpose: the update itself runs on another thread,
# and _ensure_manager_updated() swallows whatever it raises, so
# anything arriving here is a lookup error or a thread that
# could not be started, not a fetch failure.
self.logger.debug("Live scroll refresh skipped: %s", exc)
@classmethod
def _live_scroll_fields(cls, game) -> tuple:
"""One game as sorted ``(key, value)`` strings, minus the volatile keys."""
try:
items = list(game.items())
except AttributeError:
return (("<not-a-dict>", str(game)),)
return tuple(sorted((str(k), str(v)) for k, v in items
if k not in cls.LIVE_VOLATILE_FIELDS))
@classmethod
def _fingerprint_games(cls, games) -> tuple:
"""Order-independent fingerprint of a list of games."""
return tuple(sorted(cls._live_scroll_fields(g) for g in (games or [])))
def _live_scroll_fingerprint(self, league=None) -> tuple:
"""Fingerprint of every live game the strip's managers hold now."""
games: List[Any] = []
for manager in self._live_scroll_managers(league):
games.extend(getattr(manager, "live_games", None) or [])
return self._fingerprint_games(games)
def _live_scroll_needs_rebuild(self, scroll_key, mode_type, league=None) -> bool:
"""True when the live card would draw differently than the strip does.
_scroll_prepared is cleared only when the cycle *completes*, so a score
scored mid-cycle stayed frozen in the rendered strip until the marquee
finished -- minutes, for a long game list. Restarting the display forces
a rebuild, which is the workaround users find.
"""
if mode_type != "live":
return False
known = self._live_scroll_fingerprints.get(scroll_key)
if known is None:
return False # nothing built yet; normal path
if self._live_scroll_fingerprint(league) == known:
return False
last = self._live_scroll_rebuilt_at.get(scroll_key, 0.0)
cost = self._live_scroll_rebuild_cost.get(scroll_key, 0.0)
floor = max(self.LIVE_SCROLL_REBUILD_MIN_SECONDS,
cost * self.LIVE_SCROLL_REBUILD_DUTY_DIVISOR)
if time.time() - last < floor:
return False # deferred, not dropped
return True
def _note_live_scroll_built(self, scroll_key, mode_type, fingerprint=None,
league=None) -> None:
"""Record what the strip was built from.
Takes a fingerprint captured from the *managers* immediately before the
render, not one computed from the games handed to the renderer. Those
two are not comparable: _collect_games_for_scroll() decorates each game
with extra keys ("league", "status"), so a fingerprint taken from its
output can never equal one taken from the managers -- every check past
the rate limiter would rebuild, defeating the clock exclusion entirely.
That is not hypothetical; it is what the first version of this did, and
an end-to-end simulation caught it rebuilding on a bare clock tick.
Capturing before the render also closes the race a plain re-read would
open: a background update landing mid-render would otherwise be recorded
as though the strip already contained it.
"""
if mode_type != "live":
return
self._live_scroll_fingerprints[scroll_key] = (
fingerprint if fingerprint is not None
else self._live_scroll_fingerprint(league))
self._live_scroll_rebuilt_at[scroll_key] = time.time()
@contextmanager
def _preserving_scroll_position(self, mode_type, active, scroll_key=None) -> Iterator[None]:
"""Keep the marquee where it is across a mid-cycle rebuild.
ScrollHelper.set_scrolling_image() resets two counters and both matter:
scroll_position (without it the marquee snaps back to the start, which
looks worse than the stale score being fixed) and total_distance_scrolled
(without it the cycle restarts, so a game that keeps scoring could stop
the strip ever completing). Restored clamped to the new strip, since a
score gaining a digit changes its card's width by a few pixels.
A no-op unless `active` -- a first build should start at zero.
"""
helper = None
if active and getattr(self, "_scroll_manager", None):
try:
helper = self._scroll_manager.get_scroll_display(mode_type).scroll_helper # type: ignore[attr-defined]
except Exception: # pragma: no cover - defensive
helper = None
position = getattr(helper, "scroll_position", None) if helper else None
distance = getattr(helper, "total_distance_scrolled", None) if helper else None
started = time.time()
try:
yield
finally:
# What this render cost, so the next floor can scale with it. Keyed by
# scroll_key, which is what _live_scroll_needs_rebuild() reads --
# they are only the same string in some of these plugins, and keying
# by mode_type made the duty cap silently inert in the rest.
self._live_scroll_rebuild_cost[scroll_key or mode_type] = time.time() - started
if helper is not None and position is not None:
width = max(getattr(helper, "total_scroll_width", 0) - 1, 0)
helper.scroll_position = min(position, width)
if distance is not None:
helper.total_distance_scrolled = distance
helper.scroll_complete = False
self.logger.info(
"[Scroll] Live card changed; rebuilt the %s strip in place "
"at position %d", mode_type, int(helper.scroll_position))
__all__ = ["SportsLiveScrollMixin"]
+270
View File
@@ -0,0 +1,270 @@
"""The scoreboard plugin class's helpers every ``manager.py`` copies.
Each scoreboard's ``manager.py`` holds its ``BasePlugin`` subclass (the
"host": ``SoccerScoreboardPlugin``, ``UFCScoreboardPlugin``, ...). Ten of its
methods, and the class constant one of them reads, are identical
(executable AST, docstrings stripped, decorators compared) in all nine
scoreboards -- afl, baseball, basketball, football, hockey, lacrosse, nrl,
soccer and ufc -- and were copied here from ledmatrix-plugins ``56c4f15``
(origin/main, 2026-09-30) under their existing names:
- ``_dispatch_switch_refresh`` (with ``_SWITCH_REFRESH_MIN_GAP_SECONDS``) --
run a manager's refresh on a daemon thread so ``display()`` never blocks
on the network;
- ``get_vegas_priority_weight``, ``_favorite_team_is_live``,
``_favorite_scan_targets``, ``_favorite_scan_games`` and
``_game_involves`` -- how many Vegas slots the plugin asks for, and
whether a configured favourite is playing live;
- ``get_vegas_content_type`` -- ``'multi'``: a scoreboard is a list of games;
- ``_dynamic_feature_enabled``, ``_get_total_games_for_manager`` and
``_build_manager_key`` -- small dynamic-duration helpers.
This is stage 4 of the consolidation (docs/SPORTS_UNIFICATION.md): the
families that needed no reconciling. The rest of ``manager.py`` has drifted
and is reconciled one family per release before it moves.
A new module rather than more methods on an existing mixin, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-frame.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_plugin_host.py`` fails if a read is added without
being listed here.
- ``_ensure_manager_updated(manager)`` -- ``_dispatch_switch_refresh`` runs
it on the thread it starts. It must swallow its own errors: nothing joins
the thread.
- ``global_config``, ``has_live_priority()``, ``has_live_content()`` and
``supports_dynamic_duration()`` -- all on ``BasePlugin``; the scoreboards
override the last three.
- ``is_enabled`` -- set by each scoreboard's ``__init__`` (``BasePlugin``
calls its flag ``enabled``).
- ``_switch_refresh_threads`` and ``_switch_refresh_at``, read with
``getattr`` -- ``_dispatch_switch_refresh`` creates both on first use, so
a host need not.
- The live managers it scans for favourites are found through ``vars(self)``
(``_favorite_scan_targets``): any attribute, or value of a dict
attribute, with ``favorite_teams`` (or ``favorite_fighters``) and
``live_games`` (or ``live_matches``, or an ``active_celebration`` dict
holding a ``game``).
Add it as a base of the plugin class, **before** ``BasePlugin``, e.g.
``class SoccerScoreboardPlugin(SportsPluginHostMixin, BasePlugin)``:
``get_vegas_priority_weight`` and ``get_vegas_content_type`` override
``BasePlugin``'s defaults. A method on the plugin's own class still wins over
the mixin's. The mixin has no ``__init__`` and creates no class attributes
beyond its one constant.
"""
import logging
import threading
import time
from typing import Any, Callable, ClassVar, Dict, Iterator, Optional
class SportsPluginHostMixin:
"""The scoreboard plugin class's identical helpers. See module docstring."""
# The host contract, declared for type checking only: these create no
# attributes, so the host's own values are what the methods read.
logger: logging.Logger
is_enabled: bool
global_config: Dict[str, Any]
_ensure_manager_updated: Callable[[Any], Any]
has_live_priority: Callable[[], bool]
has_live_content: Callable[[], bool]
supports_dynamic_duration: Callable[[], bool]
# Created on first use by _dispatch_switch_refresh, per instance.
_switch_refresh_threads: Dict[int, threading.Thread]
_switch_refresh_at: Dict[int, float]
#: Floor between two draw-time refresh dispatches for one manager. The
#: manager's own update() still decides whether anything is fetched; this
#: only stops display() starting a thread on every frame just to be told
#: the interval has not elapsed.
_SWITCH_REFRESH_MIN_GAP_SECONDS: ClassVar[float] = 5.0
def _dispatch_switch_refresh(self, manager) -> None:
"""Run _ensure_manager_updated(manager) on a daemon thread.
Called from display(), so it must not block: when an update is due,
manager.update() fetches rankings and the schedule over the network,
and doing that inline stalled the frame for the length of the round
trip. The refreshed games land in the manager a few frames later --
still within the manager's own interval, which is the freshness the
switch path was missing.
At most one refresh per manager runs at a time, and dispatches for the
same manager are at least _SWITCH_REFRESH_MIN_GAP_SECONDS apart. Only
the render thread touches the two bookkeeping dicts, so they need no
lock; manager.update() stamps last_update before it fetches, so a
concurrent background plugin.update() for the same manager returns
early rather than fetching twice.
"""
threads: Optional[Dict[int, threading.Thread]] = getattr(self, "_switch_refresh_threads", None)
if threads is None:
threads = self._switch_refresh_threads = {}
stamps: Optional[Dict[int, float]] = getattr(self, "_switch_refresh_at", None)
if stamps is None:
stamps = self._switch_refresh_at = {}
key = id(manager)
running = threads.get(key)
if running is not None and running.is_alive():
return
now = time.monotonic()
last = stamps.get(key)
if last is not None and now - last < self._SWITCH_REFRESH_MIN_GAP_SECONDS:
return
stamps[key] = now
thread = threading.Thread(
target=self._ensure_manager_updated,
args=(manager,),
daemon=True,
name="SwitchRefresh-%s" % type(manager).__name__,
)
threads[key] = thread
thread.start()
# ---- Vegas weighting: is a favourite playing? -----------------------
#
# With display.vegas_scroll.live_in_ticker set, the marquee keeps running
# through a live game and plugins can claim more than one slot per cycle.
# The core already gives any plugin with live content `live_weight`; this
# exists for the one thing the core cannot work out for itself, which is
# *whose* game is live. See PLUGIN_API_REFERENCE, "Vegas scroll hooks",
# and ADVANCED_FEATURES, "Live content in the ticker".
def get_vegas_priority_weight(self):
"""Slots per Vegas cycle: more when a favorite team is playing.
Returns None when nothing is live, which leaves the decision to the
core rather than asserting a weight of 1 -- the core may have its own
reason to boost this plugin later.
"""
try:
if not (self.has_live_priority() and self.has_live_content()):
return None
vegas = (self.global_config or {}).get('display', {}).get(
'vegas_scroll', {})
if self._favorite_team_is_live():
return vegas.get('favorite_live_weight', 5)
return vegas.get('live_weight', 3)
except Exception:
# Never let a weighting question break the rotation; the core
# treats an exception as weight 1 anyway, and None says the same
# thing more cheaply.
return None
def _favorite_team_is_live(self):
"""Whether any live game or fight involves a configured favorite.
The sports plugins do not share one data shape, so this enumerates the
real ones rather than assuming. An earlier version looked only for an
attribute holding `live_games` alongside `favorite_teams`, which was
true of five plugins and quietly false for four others -- they simply
never reported a favorite, and no test noticed because the tests used
the assumed shape rather than each plugin's own.
Handled:
* managers held directly on the plugin *and* inside a dict such as
``self._managers`` (nrl, afl)
* ``live_games`` (most) and ``live_matches`` (cricket)
* ``favorite_teams`` (most) and ``favorite_fighters`` (ufc)
* identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``,
``fighter1_name``/``fighter2_name``, and cricket's nested
``teams: [{name, abbr, short_name}]``
* ``active_celebration["game"]``, a snapshot the live manager keeps
precisely because the game leaves ``live_games`` while the
celebration is still on screen
"""
for holder in self._favorite_scan_targets():
favorites = (getattr(holder, 'favorite_teams', None)
or getattr(holder, 'favorite_fighters', None))
if not favorites:
continue
wanted = {str(f).strip().lower() for f in favorites if f}
if not wanted:
continue
for game in self._favorite_scan_games(holder):
if self._game_involves(game, wanted):
return True
return False
def _favorite_scan_targets(self) -> Iterator[Any]:
"""Objects that might carry live content: attributes, and dict values.
nrl and afl keep their per-league managers in a ``self._managers``
dict, so walking attribute values alone finds the dict and stops.
"""
for value in list(vars(self).values()):
yield value
if isinstance(value, dict):
for nested in list(value.values()):
yield nested
@staticmethod
def _favorite_scan_games(holder) -> Iterator[Dict[str, Any]]:
"""Every game/fight on a holder that a favorite could be playing in."""
for attr in ('live_games', 'live_matches'):
for game in (getattr(holder, attr, None) or []):
if isinstance(game, dict):
yield game
celebration = getattr(holder, 'active_celebration', None)
if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict):
yield celebration['game']
@staticmethod
def _game_involves(game, wanted) -> bool:
"""Whether a game/fight involves one of the wanted names."""
for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id',
'fighter1_name', 'fighter2_name'):
value = game.get(field)
if value is not None and str(value).strip().lower() in wanted:
return True
# Cricket nests its sides and matches on any of three names, by
# substring -- "india" should match "India Women". Mirrors that
# plugin's own _match_has_team rather than inventing a second rule.
for team in (game.get('teams') or []):
if not isinstance(team, dict):
continue
hay = " ".join(str(team.get(k) or '') for k in
('name', 'abbr', 'short_name')).lower()
if any(name in hay for name in wanted):
return True
return False
def get_vegas_content_type(self) -> str:
"""Plugin provides multiple scrollable items (games)."""
return 'multi'
# ---- dynamic duration ------------------------------------------------
def _dynamic_feature_enabled(self) -> bool:
"""Dynamic duration applies: the plugin is enabled and supports it."""
if not self.is_enabled:
return False
return self.supports_dynamic_duration()
@staticmethod
def _get_total_games_for_manager(manager) -> int:
"""How many games a manager holds, from the first list it carries."""
if manager is None:
return 0
for attr in ("live_games", "games_list", "recent_games", "upcoming_games"):
value = getattr(manager, attr, None)
if isinstance(value, list):
return len(value)
return 0
@staticmethod
def _build_manager_key(mode_name: str, manager) -> str:
"""``"<mode>:<manager class>"``, the key progress is tracked under."""
manager_name = manager.__class__.__name__ if manager else "None"
return f"{mode_name}:{manager_name}"
__all__ = ["SportsPluginHostMixin"]
+540 -13
View File
@@ -42,17 +42,22 @@ Usage::
from __future__ import annotations
import functools
import logging
import threading
import time
from typing import Any, Dict, List, Optional
import weakref
from collections import OrderedDict
from typing import Any, Callable, Dict, List, Optional, Tuple
from PIL import Image
from src.common import scroll_config
from src.common import scroll_config, sports_vegas
from src.common.scroll_helper import ScrollHelper
logger = logging.getLogger(__name__)
#: Defaults every copy agreed on. A subclass overrides
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
@@ -413,6 +418,160 @@ class SportsScrollDisplay:
"""Whether content is prepared and ready to scroll."""
return bool(self.scroll_helper.cached_image)
# ------------------------------------------------------------------
# Live Vegas cards
# ------------------------------------------------------------------
#
# One live element per game (src/plugin_system/vegas_elements.py): the
# ticker swaps a card in place when its game changes. A sport opts in by
# implementing make_vegas_renderer(); everything else is here.
def make_vegas_renderer(self, card_width: int,
rankings_cache: Optional[Dict[str, int]] = None) -> Any:
"""The renderer this sport draws one game card with, at ``card_width``.
**Override point.** Return the object whose ``render_game_card(game,
game_type)`` draws one card exactly ``card_width`` wide at the display's
height -- the one prepare_scroll_content already builds -- without the
black padding prepare_scroll_content adds around each card (the ticker
adds its own). Raising NotImplementedError, the default, keeps the
plugin on its ordinary Vegas content.
"""
raise NotImplementedError(
f"{type(self).__name__} has no live Vegas cards (make_vegas_renderer)")
def _determine_game_type(self, game: Dict[str, Any]) -> str:
"""The card a game is drawn as: 'live', 'recent' or 'upcoming'.
From the game's state; a sport whose scroll display decides it
differently (most define their own) overrides this.
"""
return {'in': 'live', 'post': 'recent'}.get(sports_vegas._state(game), 'upcoming')
def render_vegas_card(self, renderer: Any, game: Dict[str, Any]) -> Image.Image:
"""Draw one game's card. Override only if the renderer is called differently."""
card: Image.Image = renderer.render_game_card(game, self._determine_game_type(game))
return card
def vegas_separator(self, league: str) -> Optional[Image.Image]:
"""The league separator shown before a league's cards, if there is an icon."""
icon = self._separator_icons.get(league)
if icon is None:
return None
gap = self._vegas_settings(league).get("gap_between_games", 48)
pad = max(4, int(gap) // 2)
image = Image.new('RGB', (icon.width + pad * 2, self.display_height), (0, 0, 0))
mask = icon if icon.mode == 'RGBA' else None
image.paste(icon, (pad, (self.display_height - icon.height) // 2), mask)
return image
def _vegas_memo(self) -> Dict[Any, Any]:
"""Per-size, per-config memo for the live path; emptied when either changes."""
stamp = (self.display_width, self.display_height, id(self.config))
memo: Optional[Tuple[Any, Dict[Any, Any]]] = getattr(self, '_vegas_memo_store', None)
if memo is None or memo[0] != stamp:
memo = (stamp, {})
self._vegas_memo_store = memo
store: Dict[Any, Any] = memo[1]
return store
def _vegas_settings(self, league: Optional[str]) -> Dict[str, Any]:
"""A league's scroll settings, looked up once per size and config.
The live path asks after every update; a sport's settings lookup can
be expensive (sizing the default card width builds probe renderers).
"""
memo = self._vegas_memo()
key = ('settings', league)
if key not in memo:
memo[key] = dict(self._get_scroll_settings(league))
settings: Dict[str, Any] = memo[key]
return settings
def _vegas_renderer(self, card_width: int,
rankings_cache: Optional[Dict[str, int]]) -> Any:
"""The sport's renderer for one card width, built once rather than per slate.
Building one loads fonts and, for the default card width, probes the
layout; the scroll path pays that on every prepare, which the live
path would repeat on every update.
"""
memo = self._vegas_memo()
key = ('renderer', card_width)
if key not in memo:
memo[key] = self.make_vegas_renderer(card_width, rankings_cache)
renderer = memo[key]
if hasattr(renderer, 'set_rankings_cache'):
# Every time, empty included: the renderer is reused across
# slates, and ranks cleared since must not stay drawn.
renderer.set_rankings_cache(rankings_cache or {})
return renderer
def build_vegas_elements(
self,
games: List[Dict[str, Any]],
leagues: List[str],
rankings_cache: Optional[Dict[str, int]] = None,
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
now: Optional[float] = None,
) -> Optional[List[Any]]:
"""The slate as live Vegas elements: one card per game, separators between leagues.
Only cards whose fingerprint changed are drawn; the rest come from the
cache. ``fingerprint(game)`` should return what the card draws (the
plugin's own signature fields, the clock included for live games); by
default the whole game dict is used, which redraws on any change. The
teams' ranks from ``rankings_cache`` count too: the renderer draws
them from there, not from the game.
Raises NotImplementedError when the sport has no make_vegas_renderer.
"""
from src.plugin_system.vegas_elements import VegasElement
games = sports_vegas.dedupe_games(games)
if not games:
return None
# Settings follow each game's own league, not the slate's first one:
# a card's width must not change because another league has no games
# today (the ticker refuses a redraw of another width).
first = self._vegas_settings(leagues[0] if leagues else None)
cards = getattr(self, '_vegas_cards', None)
if cards is None:
cards = self._vegas_cards = sports_vegas.VegasCardCache()
odds = getattr(self, '_vegas_odds', None)
if odds is None:
odds = self._vegas_odds = sports_vegas.StickyOdds()
fingerprint = fingerprint or sports_vegas.game_fingerprint
elements: List[Any] = []
keys: List[str] = []
current_league = None
separators = 0
for game in games:
league = game.get("league")
settings = self._vegas_settings(league) if league else first
card_width = int(settings.get("game_card_width", self.display_width))
if settings.get("show_league_separators", True) and league != current_league:
separator = self.vegas_separator(league) if league else None
if separator is not None:
elements.append(VegasElement(
key=f"sep:{separators}:{league}", image=separator, live=False))
separators += 1
current_league = league
key = sports_vegas.game_key(game)
drawn = odds.apply(key, game, now)
ranks = (rankings_cache.get(str(drawn.get("home_abbr"))),
rankings_cache.get(str(drawn.get("away_abbr")))) if rankings_cache else None
renderer = self._vegas_renderer(card_width, rankings_cache)
elements.append(cards.element(
key, (fingerprint(drawn), ranks, card_width, self.display_height),
functools.partial(self.render_vegas_card, renderer, drawn)))
keys.append(key)
cards.retain(keys)
odds.retain(keys)
return elements
def get_current_game_count(self) -> int:
return len(self._current_games)
@@ -433,16 +592,80 @@ class SportsScrollDisplay:
return info
class _StripSlot:
"""One slate's display in a game type's pool, and what its strip shows.
``key`` is None when the strip must not be reused: never built, built
from something that could not be fingerprinted, a build that failed, or
released to stay inside the memory budget.
"""
__slots__ = ("display", "key", "built_at", "strip", "last_used", "epoch")
def __init__(self, display: SportsScrollDisplay) -> None:
self.display = display
self.key: Optional[Tuple[Any, ...]] = None
self.built_at = 0.0
#: The helper's strip array when it was built, held weakly: a strip
#: replaced or cleared by anything since (a Vegas build on the same
#: display, a plugin calling clear()) no longer matches it.
self.strip: Optional[Callable[[], Any]] = None
self.last_used = 0.0
#: Bumped by every forget(). A build records its key only if this is
#: what it was when the build started: anything that forgot the slot
#: meanwhile (another build on this display, which may finish first
#: and leave its strip in the helper) means the strip in the helper
#: is not known to be this build's.
self.epoch = 0
def forget(self) -> None:
self.key = None
self.strip = None
self.epoch += 1
class SportsScrollDisplayManager:
"""One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming').
Subclasses set :attr:`display_class`; everything else was near-identical
across the eight plugin copies.
A recent or upcoming strip that has not changed is reused rather than
redrawn when its turn comes round again -- see :meth:`prepare_and_display`.
"""
#: The SportsScrollDisplay subclass to instantiate per game type.
display_class = SportsScrollDisplay
#: Game types whose strip is reused while nothing it is drawn from has
#: changed. Live is left out: its games change every poll, so the check
#: would never pay, and sports_live_scroll rebuilds a live strip in place
#: mid-cycle around get_scroll_display('live'), which has to stay the one
#: display it always was. Vegas's 'mixed' never comes through
#: prepare_and_display.
STRIP_MEMO_GAME_TYPES = frozenset({"recent", "upcoming"})
#: Oldest a reused strip may be. Not everything a card draws is in the
#: game dicts -- a team logo that was missing at the first build appears
#: only when the card is drawn again -- so an unchanged slate is still
#: redrawn this often.
STRIP_MEMO_MAX_AGE_S = 600.0
#: Displays kept per game type, one per slate (its leagues): the one on
#: screen plus the most recently shown others. When all are taken, the
#: least recently shown one draws the new slate, as the one shared display
#: always did, so a rotation with more slates than this costs no more
#: than before.
STRIP_MEMO_SLATES_PER_TYPE = 4
#: Ceiling, per plugin, on the strips kept for displays not on screen, in
#: bytes (the strip's array and image, and its Vegas items). Seven
#: football games at 192x48 come to ~0.65MB, thirty at 512x64 to ~4MB.
#: It bounds strip pixels only: each extra display also keeps its own
#: logo and separator-icon caches and frame buffer, and each slot a frozen
#: copy of the config in its key (~50KB), none of which is counted here.
STRIP_MEMO_MAX_PARKED_BYTES = 6 * 1024 * 1024
def __init__(
self,
display_manager,
@@ -460,16 +683,34 @@ class SportsScrollDisplayManager:
# either way, but two spellings of "nothing active" across two classes
# is a trap for anyone comparing state between them.
self._current_game_type: str = ""
# Per game type, its displays by slate, least recently shown first.
# _scroll_displays[game_type] is always the one on screen, so every
# reader of it (display_frame, is_complete, the plugins' own
# get_dynamic_duration and has_cached_content) sees what it did when
# there was only one display per game type.
self._strip_pools: Dict[str, "OrderedDict[Tuple[str, Tuple[Any, ...]], _StripSlot]"] = {}
# Only the bookkeeping is under it (and creating a slate's display,
# the first time that slate is drawn), never a build: a display()
# call that outlived its timeout can still be building when the next
# one starts.
self._strip_lock = threading.RLock()
def _new_scroll_display(self) -> SportsScrollDisplay:
return self.display_class(
self.display_manager,
self.config,
self.logger,
global_config=self.global_config,
)
def get_scroll_display(self, game_type: str) -> SportsScrollDisplay:
"""The display for ``game_type``, created on first use."""
"""The display for ``game_type``, created on first use.
For a recent or upcoming game type, the display of the slate prepared
last -- the strip display_frame() draws.
"""
if game_type not in self._scroll_displays:
self._scroll_displays[game_type] = self.display_class(
self.display_manager,
self.config,
self.logger,
global_config=self.global_config,
)
self._scroll_displays[game_type] = self._new_scroll_display()
return self._scroll_displays[game_type]
def prepare_and_display(
@@ -479,8 +720,63 @@ class SportsScrollDisplayManager:
leagues: List[str],
rankings_cache: Optional[Dict[str, int]] = None,
) -> bool:
"""Build content for ``game_type`` and make it the active strip."""
scroll_display = self.get_scroll_display(game_type)
"""Build content for ``game_type`` and make it the active strip.
Building a strip draws every card while the render thread waits for
it, with the panel frozen on its last frame: ~1.4s for seven football
cards at 192x48 on a Pi 4, at the start of every turn and again each
time the cycle completes. A recent or upcoming turn usually draws
exactly the strip its slate drew last time. So when nothing the strip
is drawn from has changed -- the games, the rankings, the config, the
panel size and the date -- that strip is rewound and shown again
instead, which leaves the plugin's prepare_scroll_content() uncalled.
Two leagues usually take turns on one game type (nfl_recent, then
ncaa_fb_recent), so each slate keeps its own display rather than
sharing one; see STRIP_MEMO_SLATES_PER_TYPE. Anything in doubt is
drawn again: a live strip, a turn with no games, inputs that cannot
be fingerprinted, a strip older than STRIP_MEMO_MAX_AGE_S, or one
changed since it was built.
"""
keyed = self._strip_memo_key(games, game_type, leagues, rankings_cache)
restore: Optional[SportsScrollDisplay] = None
epoch = 0
with self._strip_lock:
if keyed is None:
self._forget_shown_strip(game_type)
scroll_display = self.get_scroll_display(game_type)
else:
reused = self._reuse_strip(game_type, *keyed)
if reused is not None:
# What a fresh build leaves: the strip at its start, a new
# cycle not yet complete, this game type active.
reused.reset_scroll()
self._current_game_type = game_type
self.logger.debug(
"Reusing the unchanged %s strip for %s",
game_type, ", ".join(map(str, keyed[0][1])))
return True
scroll_display, restore, epoch = self._display_to_build(
game_type, keyed[0])
# Before the build, so data that changes during it reads as changed.
started = time.monotonic()
success = self._prepare_on(
scroll_display, games, game_type, leagues, rankings_cache)
if keyed is not None:
with self._strip_lock:
self._note_strip_built(
game_type, keyed, scroll_display, restore, success, started,
epoch)
return success
def _prepare_on(
self,
scroll_display: SportsScrollDisplay,
games: List[Dict],
game_type: str,
leagues: List[str],
rankings_cache: Optional[Dict[str, int]],
) -> bool:
try:
success = scroll_display.prepare_scroll_content(
games, game_type, leagues, rankings_cache
@@ -498,6 +794,200 @@ class SportsScrollDisplayManager:
self._current_game_type = game_type
return success
# ------------------------------------------------------------------
# Reusing an unchanged strip
# ------------------------------------------------------------------
def _strip_memo_key(
self,
games: Any,
game_type: str,
leagues: Any,
rankings_cache: Any,
) -> Optional[Tuple[Tuple[str, Tuple[Any, ...]], Tuple[Any, ...]]]:
"""``(slate, key)``: which display, and everything its strip is drawn
from. None when this strip must be drawn regardless.
The games go through sports_vegas.game_fingerprint, the same "has
this card changed" the live Vegas cards are redrawn on: the whole
game dict, so no field a card draws can be missed. The config is
fingerprinted by value, not by identity, so a config edited in place
counts as changed.
"""
if game_type not in self.STRIP_MEMO_GAME_TYPES or not games:
return None
# Iterated twice (here and by the build), so a one-shot iterable
# would reach the build empty.
if not isinstance(games, (list, tuple)) or not isinstance(leagues, (list, tuple)):
return None
try:
slate = (game_type, tuple(leagues))
hash(slate)
key = (
tuple(sports_vegas.game_fingerprint(game) for game in games),
sports_vegas._freeze(rankings_cache),
sports_vegas._freeze(self.config),
(getattr(self.display_manager, "width", None),
getattr(self.display_manager, "height", None)),
# A backstop: no card reads the clock today (game dates come
# in the game dicts), but a strip must not outlive its day.
time.localtime()[:3],
)
except Exception:
# A game dict changing size under a background update, say. The
# build reads it anyway; only the reuse is given up.
self.logger.debug("Strip for %s not reusable this turn", game_type,
exc_info=True)
return None
return slate, key
def _strip_reusable(self, slot: _StripSlot, key: Tuple[Any, ...]) -> bool:
if slot.key is None or slot.strip is None:
return False
if time.monotonic() - slot.built_at >= self.STRIP_MEMO_MAX_AGE_S:
return False
helper = getattr(slot.display, "scroll_helper", None)
array = getattr(helper, "cached_array", None)
if array is None or slot.strip() is not array:
return False
has_strip = getattr(helper, "has_strip", None)
if callable(has_strip) and not has_strip():
return False
return bool(slot.key == key)
def _reuse_strip(
self, game_type: str, slate: Tuple[str, Tuple[Any, ...]], key: Tuple[Any, ...],
) -> Optional[SportsScrollDisplay]:
"""The display already showing this exact strip, made the active one."""
pool = self._strip_pools.get(game_type)
slot = pool.get(slate) if pool else None
if pool is None or slot is None or not self._strip_reusable(slot, key):
return None
pool.move_to_end(slate)
slot.last_used = time.monotonic()
# The display this replaces keeps its slot and strip for its own next
# turn, within the parked-strip budget.
self._scroll_displays[game_type] = slot.display
self._trim_parked_strips()
return slot.display
def _display_to_build(
self, game_type: str, slate: Tuple[str, Tuple[Any, ...]],
) -> Tuple[SportsScrollDisplay, Optional[SportsScrollDisplay], int]:
"""The display to draw ``slate`` on, made the active one.
Returns it; when it replaced another as the active display, that
one, to put back if the build fails -- a failed build left the
previous strip showing when the game type had one display; and the
slot's epoch the build must still find to record its key.
"""
pool = self._strip_pools.setdefault(game_type, OrderedDict())
active = self._scroll_displays.get(game_type)
slot = pool.get(slate)
if slot is None:
if active is not None and not any(s.display is active for s in pool.values()):
# The game type's display, holding nothing reusable: this
# slate is drawn on it, exactly as before slates had their own.
slot = _StripSlot(active)
elif len(pool) < max(1, self.STRIP_MEMO_SLATES_PER_TYPE):
slot = _StripSlot(self._new_scroll_display())
else:
# The least recently shown slate's display draws this one.
_, slot = pool.popitem(last=False)
pool[slate] = slot
# Whatever was reusable on it is about to be drawn over.
slot.forget()
pool.move_to_end(slate)
slot.last_used = time.monotonic()
self._scroll_displays[game_type] = slot.display
replaced = active if active is not None and active is not slot.display else None
return slot.display, replaced, slot.epoch
def _note_strip_built(
self,
game_type: str,
keyed: Tuple[Tuple[str, Tuple[Any, ...]], Tuple[Any, ...]],
scroll_display: SportsScrollDisplay,
restore: Optional[SportsScrollDisplay],
success: bool,
started: float,
epoch: int,
) -> None:
slate, key = keyed
pool = self._strip_pools.get(game_type)
slot = pool.get(slate) if pool else None
if (slot is None or slot.display is not scroll_display or slot.epoch != epoch
or self._scroll_displays.get(game_type) is not scroll_display):
# Another prepare moved on while this one built, or drew on this
# display too. Record nothing; the strip is drawn again next time.
return
array = getattr(scroll_display.scroll_helper, "cached_array", None)
if success and array is not None:
slot.key = key
slot.built_at = started
slot.strip = weakref.ref(array)
elif not success and restore is not None:
self._scroll_displays[game_type] = restore
self._trim_parked_strips()
def _forget_shown_strip(self, game_type: str) -> None:
"""A build this memo cannot key is about to draw on the active display."""
active = self._scroll_displays.get(game_type)
for slot in (self._strip_pools.get(game_type) or {}).values():
if slot.display is active:
slot.forget()
@staticmethod
def _strip_bytes(scroll_display: SportsScrollDisplay) -> int:
"""What keeping this display's strip costs: the strip's array, the
image beside it, and its Vegas items."""
array = getattr(scroll_display.scroll_helper, "cached_array", None)
total = int(array.nbytes) * 2 if array is not None else 0
for item in getattr(scroll_display, "_vegas_content_items", None) or ():
total += item.width * item.height * len(item.getbands())
return total
def _trim_parked_strips(self) -> None:
"""Release the strips of displays not on screen until they fit
STRIP_MEMO_MAX_PARKED_BYTES: those that can never be reused first (a
failed build left an older slate's strip behind), then the least
recently shown. The display itself is kept, so its slate's next turn
draws on it again."""
# list() first: get_scroll_display() can add a game type from another
# thread (Vegas's 'mixed'), and a dict must not grow mid-iteration.
on_screen = {id(display) for display in list(self._scroll_displays.values())}
parked = []
total = 0
for pool in self._strip_pools.values():
for slot in pool.values():
if id(slot.display) in on_screen:
continue
try:
size = self._strip_bytes(slot.display)
except Exception:
# Unmeasurable: assume it does not fit.
size = self.STRIP_MEMO_MAX_PARKED_BYTES + 1
if size:
parked.append((slot.last_used, size, slot))
total += size
parked.sort(key=lambda entry: (entry[2].key is not None, entry[0]))
for _, size, slot in parked:
if total <= self.STRIP_MEMO_MAX_PARKED_BYTES:
break
slot.forget()
self._release_strip(slot.display)
total -= size
def _release_strip(self, scroll_display: SportsScrollDisplay) -> None:
"""Drop a display's strip without SportsScrollDisplay.clear(), which
also tells the display manager nothing is scrolling -- not this
display's to say while another one is on screen."""
try:
scroll_display.scroll_helper.clear_cache()
scroll_display._vegas_content_items = []
except Exception:
self.logger.debug("Could not release a parked strip", exc_info=True)
def display_frame(self, game_type: Optional[str] = None) -> bool:
"""Advance the active strip (or a named one) by one frame."""
game_type = game_type or self._current_game_type
@@ -520,11 +1010,48 @@ class SportsScrollDisplayManager:
return scroll_display.is_scroll_complete()
def clear_all(self) -> None:
"""Clear every display and forget which one was active."""
for scroll_display in self._scroll_displays.values():
"""Clear every display and forget which one was active.
The displays of slates not on screen too, and nothing cleared is
reused: the next prepare draws its strip again.
"""
displays = list(self._scroll_displays.values())
with self._strip_lock:
for pool in self._strip_pools.values():
for slot in pool.values():
slot.forget()
if not any(slot.display is shown for shown in displays):
displays.append(slot.display)
for scroll_display in displays:
scroll_display.clear()
self._current_game_type = ""
def get_vegas_elements_for(
self,
game_type: str,
games: List[Dict[str, Any]],
leagues: List[str],
rankings_cache: Optional[Dict[str, int]] = None,
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
) -> Optional[List[Any]]:
"""Live Vegas cards for a slate, built on the ``game_type`` display.
None when the sport has no live cards (it does not implement
make_vegas_renderer) or building them failed, so the plugin's
get_vegas_elements() can return it and the ticker falls back to the
plugin's ordinary Vegas content.
"""
scroll_display = self.get_scroll_display(game_type)
try:
return scroll_display.build_vegas_elements(
games, leagues, rankings_cache, fingerprint)
except NotImplementedError:
return None
except Exception:
# Built straight from feed data, like prepare_scroll_content.
self.logger.exception("Error building live Vegas cards")
return None
def get_all_vegas_content_items(self) -> List[Image.Image]:
"""Every display's Vegas items, for splicing into the marquee."""
items: List[Image.Image] = []
+56 -13
View File
@@ -102,6 +102,7 @@ import requests
from PIL import Image, ImageDraw
from src.common import sports_card as _card
from src.common.font_layout import load_truetype, resolve_asset_path
from src.common.text_helper import OUTLINE_SQUARE, draw_text_outlined
logger = logging.getLogger(__name__)
@@ -851,19 +852,12 @@ class SportsCoreSharedMixin:
elif fill is None:
fill = self._font_color(font)
draw.fontmode = "1"
x, y = position
for dx, dy in [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
]:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
draw.text((x, y), text, font=font, fill=fill)
# The eight-neighbour outline, then the text on top. Rasterized once
# and stamped nine times rather than drawn nine times; the pixels are
# the same (draw_text_outlined falls back to the nine draws wherever
# that is not proven).
draw_text_outlined(draw, position, text, font, fill, outline_color,
OUTLINE_SQUARE)
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
"""True at most once per ``cooldown`` seconds, for rate-limiting a
@@ -1348,6 +1342,55 @@ class SportsLiveSharedMixin:
or candidate < current):
self._next_scheduled_start_ts = candidate
#: How long a game that finished live is still reported by
#: finished_games_snapshot(): long enough for the recent-games list, which
#: refreshes about hourly, to take it over well before most slates would.
FINISHED_GAME_TTL = 900.0
def _record_finished_game(self, details: Dict) -> None:
"""Remember a game that was live and has just gone final (or looks over).
A finished game leaves ``live_games`` at the next poll, and the recent
list that will show it refreshes about hourly, so in between nothing
holds the game's final score -- and a live Vegas card for it would keep
its last live score. Call this wherever a poll drops a game as final
or over. Only a game this manager had as live is taken; one already
held takes the newer details (a game dropped by an "is it over"
heuristic, then marked final by the feed) but keeps its expiry, so a
feed that lists finals all day cannot keep one here all day.
"""
game_id = details.get("id") if isinstance(details, dict) else None
if not game_id:
return
finished = self.__dict__.setdefault("_finished_games", {})
held = finished.get(game_id)
if held is not None:
finished[game_id] = (held[0], dict(details))
return
if not any(g.get("id") == game_id for g in getattr(self, "live_games", ()) or ()):
return
finished[game_id] = (time.monotonic(), dict(details))
def finished_games_snapshot(self) -> List[Dict]:
"""Games that went final here within FINISHED_GAME_TTL, newest data first.
Copies, safe to decorate. The caller dedupes them against its other
lists (src/common/sports_vegas.dedupe_games keeps the liveliest copy,
and a final beats nothing but a live one).
"""
finished = self.__dict__.get("_finished_games")
if not finished:
return []
now = time.monotonic()
# A copy first: a manager finishing its update in the background (off
# the plugin's lock) may record a game while the ticker reads these.
held = list(finished.items())
for game_id, (seen, _game) in held:
if now - seen > self.FINISHED_GAME_TTL:
finished.pop(game_id, None)
return [dict(game) for _id, (seen, game) in held
if now - seen <= self.FINISHED_GAME_TTL]
def _note_live_fetch(self, found_live: bool) -> None:
"""Record whether a look for live games found any."""
if found_live:
+250
View File
@@ -0,0 +1,250 @@
"""Live Vegas cards for the sports scoreboards.
A scoreboard hands the Vegas ticker one card per game. As live elements
(src/plugin_system/vegas_elements.py) those cards change on the panel while
they scroll: a goal redraws its game's card and the ticker swaps it in place.
This module is what every scoreboard needs for that and would otherwise write
nine times:
- :func:`game_key` -- a stable key per game, so the ticker can tell which card
a redraw belongs to however the slate is re-sorted.
- :class:`VegasCardCache` -- draws a card only when what it shows changed
(its fingerprint), so an unchanged slate costs a dictionary lookup per game
and a changed one only the cards that changed.
- :class:`StickyOdds` -- live odds are fetched only for games near the front
of the rotation, so a card's odds come and go between polls; this keeps the
last odds for a while instead of redrawing the card without them.
- :func:`dedupe_games` -- a game present in two managers' lists (live and
recent, around the final whistle) appears once, its liveliest copy.
- :func:`finished_games` / :func:`with_finished_games` -- a game that has just
gone final keeps its card, now showing FINAL, where its live card was,
until the recent list (refreshed about hourly) takes it over.
- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole
game dict, frozen hashable.
SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts
them together; a plugin adopts it by implementing make_vegas_renderer().
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple
from PIL import Image
#: Which copy of a duplicated game wins: the liveliest.
_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1}
def _state(game: Dict[str, Any]) -> str:
status = game.get('status')
state = status.get('state') if isinstance(status, dict) else status
if isinstance(state, str):
return state
if game.get('is_live'):
return 'in'
if game.get('is_final'):
return 'post'
return 'pre'
def _freeze(value: Any) -> Any:
"""A hashable, order-stable copy of feed data."""
if isinstance(value, dict):
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
if isinstance(value, (list, tuple)):
return tuple(_freeze(v) for v in value)
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return repr(value)
def game_fingerprint(game: Dict[str, Any]) -> Hashable:
"""Everything in a game dict, hashable: a card drawn from it changes only if this does.
The default card version. Nothing a card could draw is left out, so no
field is ever frozen on the panel; the cost is a redraw when a field the
card does not draw changes too, which feed data rarely does between polls.
"""
frozen: Hashable = _freeze(game)
return frozen
def game_key(game: Dict[str, Any]) -> str:
"""A key that names this game and nothing else, across polls.
``game:<league>:<id>`` from the feed's own id. A game without one falls
back to its teams and start time, which is stable for the life of a game.
"""
league = game.get('league') or 'game'
game_id = game.get('id') or game.get('game_id')
if game_id not in (None, ''):
return f"game:{league}:{game_id}"
away = game.get('away_abbr') or game.get('away_team') or '?'
home = game.get('home_abbr') or game.get('home_team') or '?'
start = game.get('start_time_utc') or game.get('start_time') or ''
return f"game:{league}:{away}@{home}:{start}"
def dedupe_games(games: Iterable[Dict[str, Any]],
key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]:
"""Each game once, in first-seen order, keeping its liveliest copy.
Around a final whistle a game can be in the live list (last poll) and the
recent list (next poll) at once; two cards with one key would be refused
by the ticker, and showing the game twice is wrong anyway.
"""
chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
for game in games:
key = key_fn(game)
current = chosen.get(key)
if current is None or _STATE_PRIORITY.get(_state(game), 0) > \
_STATE_PRIORITY.get(_state(current), 0):
chosen[key] = game
return list(chosen.values())
def finished_games(
live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]:
"""Games that just left these live managers' lists, final ones as recent games.
``live_managers`` pairs each league with its live manager (None is
skipped). Each manager reports what SportsLiveSharedMixin recorded
(finished_games_snapshot, copies), with its league. A final game is
drawn as a recent card. One a poll only judged over -- a tied end of
regulation looks like that too -- keeps its last live state, so its card
never says FINAL early; if play resumes the live list has it again, and
dedupe_games keeps that copy.
"""
finished: List[Dict[str, Any]] = []
for league, manager in live_managers:
snapshot = getattr(manager, 'finished_games_snapshot', None)
if not callable(snapshot):
continue
for game in snapshot():
game['league'] = league
if game.get('is_final'):
status = game.get('status')
status = dict(status) if isinstance(status, dict) else {}
status['state'] = 'post'
game.update(status=status, is_live=False)
finished.append(game)
return finished
def with_finished_games(
games: List[Dict[str, Any]], leagues: List[str],
finished: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], List[str]]:
"""The slate with games that just went final where their live cards were.
A slate lists each league's games together, live ones first. Each
finished game goes after its league's live games, ahead of the rest; a
league with no games left in the slate is added at the end. A finished
game the slate also has (the recent list caught up) is left for
dedupe_games, which keeps one copy.
"""
if not finished:
return list(games), list(leagues)
pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict()
for game in finished:
pending.setdefault(game.get('league'), []).append(game)
merged: List[Dict[str, Any]] = []
for index, game in enumerate(games):
league = game.get('league')
if league in pending and _state(game) != 'in':
merged.extend(pending.pop(league))
merged.append(game)
following = games[index + 1] if index + 1 < len(games) else None
if league in pending and (following is None or following.get('league') != league):
merged.extend(pending.pop(league)) # the league's games were all live
leagues = list(leagues)
for league, rest in pending.items():
merged.extend(rest)
if league not in leagues:
leagues.append(league)
return merged, leagues
class VegasCardCache:
"""Cards drawn once per fingerprint, kept for as long as their game is.
``element(key, fingerprint, render)`` returns a VegasElement whose image is
``render()``'s -- called only when the fingerprint differs from the one the
cached card was drawn for. The fingerprint is also the element's version,
so the ticker skips unchanged cards without comparing pixels.
Bounded: keys not passed to :meth:`retain` after a slate are dropped, and
at most ``max_entries`` are ever held (oldest first).
"""
def __init__(self, max_entries: int = 96) -> None:
self.max_entries = max(1, int(max_entries))
self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict()
self.renders = 0
def element(self, key: str, fingerprint: Hashable,
render: Callable[[], Image.Image], live: bool = True) -> Any:
from src.plugin_system.vegas_elements import VegasElement
cached = self._cards.get(key)
if cached is not None and cached[0] == fingerprint:
self._cards.move_to_end(key)
image = cached[1]
else:
image = render()
self.renders += 1
self._cards[key] = (fingerprint, image)
self._cards.move_to_end(key)
while len(self._cards) > self.max_entries:
self._cards.popitem(last=False)
return VegasElement(key=key, image=image, version=fingerprint, live=live)
def retain(self, keys: Iterable[str]) -> None:
"""Forget every card whose key is not in ``keys``."""
keep = set(keys)
for key in [k for k in self._cards if k not in keep]:
self._cards.pop(key, None)
def clear(self) -> None:
self._cards.clear()
def __len__(self) -> int:
return len(self._cards)
class StickyOdds:
"""Keep a game's last odds on its card while a live poll leaves them out.
Live odds are fetched only for games near the front of the rotation
(src/common/sports_fetch.py), so the same game's dict has odds on one poll
and none on the next. Drawn as-is that redraws the card every poll with
the odds flickering in and out. ``apply`` returns the game with its last
non-empty odds put back, for up to ``ttl_s`` seconds after they were seen.
"""
def __init__(self, ttl_s: float = 600.0) -> None:
self.ttl_s = float(ttl_s)
self._seen: Dict[str, Tuple[float, Any]] = {}
def apply(self, key: str, game: Dict[str, Any],
now: Optional[float] = None) -> Dict[str, Any]:
now = time.monotonic() if now is None else now
odds = game.get('odds')
if odds:
self._seen[key] = (now, odds)
return game
seen = self._seen.get(key)
if seen is None or now - seen[0] > self.ttl_s:
self._seen.pop(key, None)
return game
refilled = dict(game)
refilled['odds'] = seen[1]
return refilled
def retain(self, keys: Iterable[str]) -> None:
keep = set(keys)
for key in [k for k in self._seen if k not in keep]:
self._seen.pop(key, None)
+178 -10
View File
@@ -7,7 +7,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype, resolve_asset_path
@@ -15,6 +15,174 @@ 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)))
#: A one-pixel outline on all eight sides, in the order the scoreboards have
#: always drawn it (dx outer, dy inner). The order can change pixels only
#: where anti-aliased (fontmode "L") edges overlap; it is kept anyway.
OUTLINE_SQUARE: Tuple[Tuple[int, int], ...] = (
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1))
#: A one-pixel outline on the four edge sides only, leaving the diagonal
#: corners open: the thinner outline ufc's fight card draws.
OUTLINE_CROSS: Tuple[Tuple[int, int], ...] = ((-1, 0), (1, 0), (0, -1), (0, 1))
# What the stamping path in draw_text_outlined is proven pixel-identical for
# (test/test_text_helper.py compares it with the draw.text loop across every
# combination). Anything else takes the loop. Compared with ``in`` on tuples
# rather than sets so an unhashable fontmode falls back instead of raising.
_STAMP_DRAW_MODES = ("RGB", "RGBA", "L")
_STAMP_FONT_MODES = ("1", "L")
# ImageDraw.text as Pillow defines it, which the stamping path stands in for.
# A draw whose text has been replaced since -- on the class or the instance,
# as a test recording the strings drawn does -- takes the loop, so the
# replacement still sees every call.
_PILLOW_DRAW_TEXT = ImageDraw.ImageDraw.text
def draw_text_outlined(draw: ImageDraw.ImageDraw, xy: Sequence[Any], text: Any,
font: Any, fill: Any,
outline_color: Any = (0, 0, 0),
offsets: Iterable[Sequence[Any]] = OUTLINE_SQUARE) -> None:
"""Draw ``text`` in ``outline_color`` at each of ``offsets``, then in ``fill`` on top.
The result is pixel-identical to the loop every outlined draw used to be::
x, y = xy
for dx, dy in offsets:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
draw.text((x, y), text, font=font, fill=fill)
but each ``draw.text`` rasterizes the whole string through FreeType again,
so the default nine draws did the same glyph work nine times, and on a
scoreboard card that text work is much of the render. Here the string is
rasterized once and the one mask is stamped at every offset, which is
what ``draw.text`` itself does with the mask, so the pixels are the same.
That holds only where it has been checked: a plain ``ImageDraw`` whose
``text`` is Pillow's, a ``FreeTypeFont``, one line of ``str``, whole-pixel
``xy`` (an int, or a float with nothing after the point, which is what
centring on a measured ``textlength`` with ``// 2`` gives) and int
offsets, and the image and font modes in ``_STAMP_DRAW_MODES`` /
``_STAMP_FONT_MODES``. Fractional coordinates change the raster itself
(Pillow rasterizes at the sub-pixel start), and multiline text is laid
out line by line. Every other case, and anything
the stamping path cannot prepare, runs the loop above unchanged, so it
behaves exactly as before, errors included.
Args:
draw: The ``ImageDraw`` to draw on.
xy: Top-left (x, y) of the text, as for ``draw.text``.
text: The text.
font: The font, as for ``draw.text``.
fill: Colour of the text itself, drawn last.
outline_color: Colour of the outline.
offsets: (dx, dy) of each outline draw, in drawing order.
:data:`OUTLINE_SQUARE` (the default) or :data:`OUTLINE_CROSS`.
"""
x, y = xy
# Read once: the loop below may have to start over after the stamping
# path looked at them.
offsets = tuple(offsets)
if _can_stamp(draw, x, y, text, font, offsets):
if _stamp_outlined(draw, int(x), int(y), text, font, fill,
outline_color, offsets):
return
for dx, dy in offsets:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
draw.text((x, y), text, font=font, fill=fill)
def _can_stamp(draw: Any, x: Any, y: Any, text: Any, font: Any,
offsets: Tuple[Any, ...]) -> bool:
"""Whether draw_text_outlined may stamp one mask instead of drawing N times.
Exact types for the draw and the font, and Pillow's own ``draw.text``: a
subclass may override ``text`` or ``getmask2``, or a test may replace
``draw.text`` to record what is drawn, and stamping would skip either.
"""
return (
type(draw) is ImageDraw.ImageDraw
and ImageDraw.ImageDraw.text is _PILLOW_DRAW_TEXT
and "text" not in vars(draw)
and type(font) is ImageFont.FreeTypeFont
and isinstance(text, str)
and "\n" not in text
and "\r" not in text
and _whole_pixel(x)
and _whole_pixel(y)
and all(isinstance(o, (tuple, list)) and len(o) == 2
and isinstance(o[0], int) and isinstance(o[1], int)
for o in offsets)
and draw.mode in _STAMP_DRAW_MODES
and draw.fontmode in _STAMP_FONT_MODES
)
def _whole_pixel(v: Any) -> bool:
"""An int, or a float on a whole pixel, as a draw.text coordinate.
For those, draw.text's ``int(x + dx)`` is ``int(x) + dx`` and its
sub-pixel start is 0 (or -0.0, which renders the same), so one mask fits
every offset. Floats are held well inside the range where ``x + dx`` is
exact; nothing that far out is on any canvas, so the loop the rest take
costs nothing that matters.
"""
if isinstance(v, int):
return True
return isinstance(v, float) and v.is_integer() and -2**31 < v < 2**31
def _text_ink(draw: ImageDraw.ImageDraw, color: Any) -> Any:
"""The ink ``ImageDraw.text`` resolves ``color`` to (its inner getink)."""
ink, fill_ink = draw._getink(color)
return fill_ink if ink is None else ink
def _stamp_outlined(draw: ImageDraw.ImageDraw, x: int, y: int, text: str,
font: ImageFont.FreeTypeFont, fill: Any, outline_color: Any,
offsets: Tuple[Sequence[Any], ...]) -> bool:
"""Rasterize once and stamp; False, with nothing drawn, to take the loop.
Replays what ``ImageDraw.text`` does for one line at an integer position
(Pillow 11 and 12): ``font.getmask2`` with these arguments, then
``draw.draw.draw_bitmap`` at the position plus the mask's offset.
``draw.draw`` and ``draw._getink`` are Pillow internals, so everything up
to the first pixel is guarded: if anything fails before then, nothing has
been drawn and the loop runs instead, which then fails (or not) exactly
as it always did -- a bad fill colour still raises after the outline is
drawn, as it did from the last ``draw.text``.
"""
try:
outline_ink = _text_ink(draw, outline_color)
text_ink = _text_ink(draw, fill)
# What draw.text passes for a single line with no anchor at a whole
# pixel position, by keyword so a getmask2 with another parameter
# order cannot shift them. ink only matters to an RGBA (colour-glyph)
# mask, which the font modes allowed here never produce.
mask, (ox, oy) = font.getmask2(
text, draw.fontmode, direction=None, features=None,
language=None, stroke_width=0, anchor="la", ink=text_ink,
start=(0.0, 0.0), stroke_filled=True)
draw_bitmap = draw.draw.draw_bitmap
except Exception:
return False
stamped = False
try:
# draw.text returns without drawing when its ink resolves to None.
if outline_ink is not None:
for dx, dy in offsets:
draw_bitmap((x + dx + ox, y + dy + oy), mask, outline_ink)
stamped = True
if text_ink is not None:
draw_bitmap((x + ox, y + oy), mask, text_ink)
except Exception:
# A rejected call draws nothing, but one that got through has: never
# draw the outline twice (anti-aliased edges would be blended twice).
if stamped:
raise
return False
return True
class TextHelper:
"""
@@ -103,15 +271,15 @@ class TextHelper:
outline_width: Width of outline in pixels
"""
x, y = position
# Draw outline by drawing text in outline color at offset positions
for dx in range(-outline_width, outline_width + 1):
for dy in range(-outline_width, outline_width + 1):
if dx != 0 or dy != 0: # Skip center position
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
# Draw main text
draw.text((x, y), text, font=font, fill=fill)
# Outline: every offset up to outline_width away on each axis, centre
# skipped, in the order this has always drawn them (OUTLINE_SQUARE at
# width 1). The main text is drawn last, on top.
offsets = [(dx, dy)
for dx in range(-outline_width, outline_width + 1)
for dy in range(-outline_width, outline_width + 1)
if dx != 0 or dy != 0]
draw_text_outlined(draw, (x, y), text, font, fill, outline_color, offsets)
def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int:
"""
+45 -3
View File
@@ -451,8 +451,10 @@ class ConfigManager:
template_config = json.load(f)
# Check if migration is needed
if self._config_needs_migration(self.config, template_config):
self.logger.info("Config migration needed - adding new configuration items with defaults")
needs_merge = self._config_needs_migration(self.config, template_config)
if needs_merge or self._live_in_ticker_needs_migration():
if needs_merge:
self.logger.info("Config migration needed - adding new configuration items with defaults")
# Create backup of current config
backup_path = f"{self.config_path}.backup"
@@ -461,7 +463,9 @@ class ConfigManager:
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
# Merge template defaults into current config
self._merge_template_defaults(self.config, template_config)
if needs_merge:
self._merge_template_defaults(self.config, template_config)
self._migrate_live_in_ticker_default()
# save_config_atomic strips the merged secrets back out and
# keeps the file's owner and mode.
@@ -482,6 +486,44 @@ class ConfigManager:
self.logger.error(f"Error during config migration: {e}")
# Don't raise - continue with current config
#: Set in display.vegas_scroll once _migrate_live_in_ticker_default() has
#: run. Never in the template: the template merge would add it first, and
#: the flip would then never run.
LIVE_IN_TICKER_MARKER = 'live_in_ticker_migrated'
def _vegas_scroll_section(self) -> Optional[Dict[str, Any]]:
display = self.config.get('display')
vegas = display.get('vegas_scroll') if isinstance(display, dict) else None
return vegas if isinstance(vegas, dict) else None
def _live_in_ticker_needs_migration(self) -> bool:
vegas = self._vegas_scroll_section()
return vegas is not None and not vegas.get(self.LIVE_IN_TICKER_MARKER)
def _migrate_live_in_ticker_default(self) -> None:
"""Turn on live_in_ticker for a config that only ever had the old default. Once.
LEDMatrix 3.8.0 makes ``display.vegas_scroll.live_in_ticker`` true:
live games stay in the Vegas ticker, their cards updating while they
scroll, instead of the ticker giving way to the full-screen
scoreboard. Every existing config holds an explicit ``false`` copied
from the template -- there was no control for it -- and the template
merge only adds missing keys, so the new default would reach nobody.
This rewrites that ``false`` once and marks the config, so a
``false`` chosen afterwards (the Vegas checkbox, or by hand) stays.
"""
vegas = self._vegas_scroll_section()
if vegas is None or vegas.get(self.LIVE_IN_TICKER_MARKER):
return
vegas[self.LIVE_IN_TICKER_MARKER] = True
if vegas.get('live_in_ticker') is False:
vegas['live_in_ticker'] = True
self.logger.info(
"Vegas mode now keeps live games in the ticker (the new default): "
"display.vegas_scroll.live_in_ticker turned on, once. Untick "
"\"Keep live games in the ticker\" under Vegas mode for the "
"full-screen scoreboard.")
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
"""Check if config needs migration by comparing with template."""
return self._has_new_keys(current_config, template_config)
+8 -4
View File
@@ -29,6 +29,7 @@ CORE_CONFIG_KEYS = frozenset({
'display',
'sync',
'plugin_system',
'fetch_service',
# Older or optional core sections still found in existing config files.
'logging',
'network',
@@ -43,11 +44,14 @@ CORE_CONFIG_KEYS = frozenset({
})
#: Top-level keys of ``config_secrets.json`` that belong to the core rather than
#: to a plugin: the GitHub token the Plugin Store reads, and the historical
#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything
#: deciding whether a secrets section is a plugin's needs this as well as
#: ``CORE_CONFIG_KEYS``.
#: to a plugin: the GitHub token the Plugin Store reads, the historical
#: ``youtube`` section, and ``web_auth`` (the optional web login's password
#: hash and API-token hashes, web_interface/auth.py) -- which orphan-plugin
#: cleanup would otherwise delete, logging everyone out. Plugin secrets are
#: namespaced by plugin id, so anything deciding whether a secrets section is a
#: plugin's needs this as well as ``CORE_CONFIG_KEYS``.
CORE_SECRETS_KEYS = frozenset({
'github',
'youtube',
'web_auth',
})
+32
View File
@@ -6,6 +6,14 @@ still be called by a plugin nobody has checked. Such methods get
process logs a warning naming the method and the release that removes it
(visible in ``journalctl -u ledmatrix``), and emits a DeprecationWarning for
tooling.
Before a release removes anything, ``scripts/plugin_api_usage.py`` scans core,
the official plugin monorepo and the registry's third-party plugins for callers
and overriders of every marked method. Its latest output is
``docs/DEPRECATIONS_3.8.md``; remove only what it reports unused, and move the
rest to a later release. ``test/test_deprecation.py`` fails while any marker
names a release at or below ``src.__version__``, so a release cannot ship with
a removal date it has already passed.
"""
import functools
@@ -45,3 +53,27 @@ def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F],
return wrapper # type: ignore[return-value]
return decorate
def warn_deprecated(what: str, removal: str, alternative: Optional[str] = None,
once_key: Optional[str] = None) -> bool:
"""Warn that ``what`` will be removed in ``removal``, once per process.
For what ``@deprecated`` cannot decorate: a config key, a manifest field,
a value a hook returns. Same message, log line and DeprecationWarning as
the decorator. ``once_key`` (default: ``what``) is what "once" counts
against, so one deprecated key can warn once for each plugin that sets it.
Returns whether this call warned.
"""
message = f"{what} is deprecated and will be removed in LEDMatrix {removal}"
if alternative:
message += f"; {alternative}"
key = once_key or what
with _warned_lock:
if key in _warned:
return False
_warned.add(key)
logger.warning(message)
warnings.warn(message, DeprecationWarning, stacklevel=2)
return True
+1402 -481
View File
File diff suppressed because it is too large Load Diff
+183 -256
View File
@@ -52,16 +52,15 @@ import threading
import time
from collections import OrderedDict, deque
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
import math
import zlib
import freetype
from src.common import snapshot_policy
from src.common.frame_timing import FrameTimingRecorder
from src import display_watchdog
from src.common.frame_timing import FrameTimingRecorder, install_gc_monitor
if TYPE_CHECKING:
from src.common.render_gate import RenderGate
from src.deprecation import deprecated
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_directory_permissions,
@@ -81,6 +80,15 @@ _CALENDAR_FONT_PX = 7
#: frame, so a fault that persists would otherwise log ~100 lines a second.
_UPDATE_ERROR_LOG_INTERVAL = 60.0
#: zlib level for the preview snapshot PNG. The fastest level: each file is
#: read by the web UI and soon replaced by the next, so encode time (paid on
#: the render thread for a static screen) matters more than its size.
#: Lossless at any level. Against Pillow's default (6), on a desktop with
#: Pillow 12.3, a text-dense 512x64 frame encoded in about half the time,
#: into 12 KB instead of 7 KB; sparser frames saved less time (10-20%) and
#: stayed under 2 KB.
_SNAPSHOT_PNG_COMPRESS_LEVEL = 1
def _bdf_native_size(face) -> int:
"""The pixel height a BDF Face declares, or 0 if it does not say.
@@ -224,6 +232,11 @@ def _per_thread_canvas_attr(name: str) -> property:
#: A held frame is only split when its blit takes less than this share of a
#: refresh: the second blit has to land before the next vsync.
_SPLIT_BLIT_FRACTION = 0.5
class DisplayManager:
"""
Singleton hardware abstraction layer for the RGB LED matrix.
@@ -292,8 +305,8 @@ class DisplayManager:
self._TEXT_WIDTH_CACHE_MAX = 1024
# Snapshot mirror for web preview + health check (service writes, web
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
# full rate only while the web SSE broadcaster keeps the viewer marker
# fresh; unchanged frames are never re-encoded, only mtime-touched.
# the viewer rate only while the web SSE broadcaster keeps the viewer
# marker fresh; unchanged frames are never re-encoded, only mtime-touched.
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
self._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
self._last_snapshot_ts = 0.0
@@ -340,6 +353,10 @@ class DisplayManager:
# advances a whole pixel every Nth refresh instead of every one.
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1
# True while a static screen draws its first frame after a scroll,
# whose state is left set until then: those frames go out without
# scan-order compensation. See end_scroll_for_static_screen().
self._static_handover = False
# A src.common.render_gate.RenderGate while Vegas runs with
# vegas_scroll.prefetch_gate on: opened around each swap so the
@@ -348,7 +365,8 @@ class DisplayManager:
# 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 = FrameTimingRecorder(
info=self._frame_timing_info(), gc_monitor=install_gc_monitor())
self.frame_timing.scrolling_now = self._scrolling_now
self._scrolling_state = {
@@ -914,8 +932,7 @@ class DisplayManager:
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
Serialized via ``_update_lock``: plugins can call this directly from
background threads (e.g. sports base classes push an immediate
"live" refresh from inside update()), so without a lock two callers
background threads of their own, so without a lock two callers
could both pass the digest check before either writes it back,
double-pushing a frame, or interleave the offscreen/current canvas
swap below. The lock is scoped to this method, so callers never
@@ -928,6 +945,9 @@ class DisplayManager:
# the fallback branch, so captured content never reaches the
# web preview either.
return
# The render loop's watchdog arms on the first frame to reach
# the panel (or the emulator/fallback path standing in for it).
display_watchdog.note_frame()
with self._update_lock:
if self.matrix is None:
# Fallback mode - no actual hardware to update
@@ -936,16 +956,32 @@ class DisplayManager:
self._write_snapshot_if_due()
return
# Asked once per frame and the answer reused below: the call
# has side effects (it expires a stale scroll and drops its
# frame hold), so asking again further down could disagree
# with what this frame was already treated as. Asked first,
# so the dirty check, the scan-order segments, the pacing gate,
# the swaps and frame timing all see one answer and the frame
# hold it leaves.
scrolling = self.is_currently_scrolling()
digest = None
frame_checksum = None
if self._dirty_tracking_enabled:
# No digest mid-scroll. The skip it feeds is never taken while
# scrolling (see below), so all it bought there was the
# snapshot's changed-frame check -- a tobytes() plus adler32
# over the whole framebuffer every frame (~0.17ms at 256x64
# on a Pi 4, twice that at 512x64) for a decision acted on at
# most once a second. _write_snapshot_if_due hashes for
# itself when a write or touch is actually due. The cost: the
# first static frame after a scroll is always pushed, once.
if self._dirty_tracking_enabled and not scrolling:
try:
brightness = getattr(self.matrix, 'brightness', None)
except AttributeError:
brightness = None
frame_checksum = zlib.adler32(self.image.tobytes())
digest = (frame_checksum, brightness)
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
if digest == self._last_pushed_digest:
# Nothing changed since the last push — the panel is
# already showing exactly this frame.
#
@@ -970,27 +1006,35 @@ class DisplayManager:
# 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())
segments = [(self._composite_double_sided(), self._frame_hold)]
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.
segments = self._scan_segments(self.image, scrolling)
gate = self.render_gate
blit_time = swap_time = 0.0
# Usually one segment: the frame, held for _frame_hold
# refreshes. SwapOnVSync blocks for all of them, which is what
# paces the render loop to the chosen frame rate. Scan-order
# compensation on a held frame splits it, so the lagging rows
# change one refresh after the rest.
if gate is not None:
gate.before_swap(self._frame_hold)
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
for index, (shown, hold) in enumerate(segments):
if index:
blit_started = time.perf_counter()
self.offscreen_canvas.SetImage(shown)
blit_done = time.perf_counter()
blit_time += blit_done - blit_started
self.matrix.SwapOnVSync(self.offscreen_canvas, hold)
swap_time += time.perf_counter() - blit_done
# Swap our canvas references
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
if gate is not None:
gate.after_swap(self._frame_hold)
presented_at = time.perf_counter()
self._last_blit_seconds = blit_time / len(segments)
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
blit_time, swap_time,
self._frame_hold, scrolling, presented_at)
self._last_pushed_digest = digest
@@ -1037,23 +1081,48 @@ class DisplayManager:
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
for top, bottom, lag in bands))
def _scan_compensated(self, image: Image.Image) -> Image.Image:
"""The frame to present, with lagging rows taken from earlier frames.
def _scan_segments(self, image: Image.Image,
scrolling: Optional[bool] = None
) -> List[Tuple[Image.Image, int]]:
"""What to present for this frame: ``[(image, refreshes), ...]``.
Only mid-scroll at one frame per refresh: that is when consecutive
frames are consecutive refreshes. At a longer hold, or on a static
screen, the history is dropped and the frame goes out as it is.
Mid-scroll with compensation on, lagging rows are taken from earlier
refreshes (see src/scan_order.py). At one refresh per frame that is one
image. A frame held longer is split at the refresh where the lagging
rows catch up, so those rows step a refresh after the rest. The split
needs a second blit inside the refresh that follows the first swap, so
it is skipped when a blit is too slow to fit. A static screen goes out
as it is, and drops the history. So does a static screen's first frame
after a scroll, while the scroll state is still set (see
end_scroll_for_static_screen): one segment, held for the scroll's
hold, with no rows from the scroller's frames.
``scrolling`` is the caller's is_currently_scrolling() answer for this
frame. update_display() asks once, before calling this, so the frame
hold read here is the one that answer left (an expired scroll's hold
is already dropped). None asks here.
"""
hold = self._frame_hold
bands = getattr(self, '_scan_lag_bands', None)
if not bands:
return image
if self._frame_hold != 1 or not self.is_currently_scrolling():
self._scan_history.clear()
return image
presented = scan_order.compose(image, self._scan_history, bands)
if (not bands
or not (scrolling if scrolling is not None
else self.is_currently_scrolling())
or self._static_handover):
if bands:
self._scan_history.clear()
return [(image, hold)]
if hold > 1:
blit = getattr(self, '_last_blit_seconds', 0.0)
if blit > _SPLIT_BLIT_FRACTION / max(1.0, self.refresh_hz):
self._scan_history.clear()
return [(image, hold)]
segments = [
(scan_order.compose(image, self._scan_history, bands, backs), count)
for backs, count in scan_order.refresh_plan(bands, hold)
]
# A copy: plugins draw into the same image object frame after frame.
self._scan_history.appendleft(image.copy())
return presented
return segments
def clear(self):
"""Clear the display completely."""
@@ -1320,203 +1389,6 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error drawing text: {e}", exc_info=True)
@deprecated("3.7.0")
def draw_sun(self, x: int, y: int, size: int = 16):
"""Draw a sun icon using yellow circles and lines."""
center = (x + size//2, y + size//2)
radius = size//3
# Draw the center circle
self.draw.ellipse([center[0]-radius, center[1]-radius,
center[0]+radius, center[1]+radius],
fill=(255, 255, 0)) # Yellow
# Draw the rays
ray_length = size//4
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center[0] + (radius * math.cos(rad))
start_y = center[1] + (radius * math.sin(rad))
end_x = center[0] + ((radius + ray_length) * math.cos(rad))
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
@deprecated("3.7.0")
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
"""Draw a cloud icon."""
# Draw multiple circles to form a cloud shape
self.draw.ellipse([x+size//4, y+size//3, x+size//4+size//2, y+size//3+size//2], fill=color)
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
@deprecated("3.7.0")
def draw_rain(self, x: int, y: int, size: int = 16):
"""Draw rain icon with cloud and droplets."""
# Draw cloud
self.draw_cloud(x, y, size)
# Draw rain drops
drop_color = (0, 0, 255) # Blue
drop_size = size//6
for i in range(3):
drop_x = x + size//4 + (i * size//3)
drop_y = y + size//2
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
fill=drop_color, width=2)
@deprecated("3.7.0")
def draw_snow(self, x: int, y: int, size: int = 16):
"""Draw snow icon with cloud and snowflakes."""
# Draw cloud
self.draw_cloud(x, y, size)
# Draw snowflakes
snow_color = (200, 200, 255) # Light blue
for i in range(3):
center_x = x + size//4 + (i * size//3)
center_y = y + size//2 + size//4
# Draw a small star shape
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = center_x + (size//8 * math.cos(rad))
end_y = center_y + (size//8 * math.sin(rad))
self.draw.line([center_x, center_y, end_x, end_y],
fill=snow_color, width=1)
# Weather icon color constants
WEATHER_COLORS = {
'sun': (255, 200, 0), # Bright yellow
'cloud': (200, 200, 200), # Light gray
'rain': (0, 100, 255), # Light blue
'snow': (220, 220, 255), # Ice blue
'storm': (255, 255, 0) # Lightning yellow
}
def _draw_sun(self, x: int, y: int, size: int) -> None:
"""Draw a sun icon with rays."""
center_x, center_y = x + size//2, y + size//2
radius = size//4
ray_length = size//3
# Draw the main sun circle
self.draw.ellipse([center_x - radius, center_y - radius,
center_x + radius, center_y + radius],
fill=self.WEATHER_COLORS['sun'])
# Draw sun rays
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center_x + int((radius + 2) * math.cos(rad))
start_y = center_y + int((radius + 2) * math.sin(rad))
end_x = center_x + int((radius + ray_length) * math.cos(rad))
end_y = center_y + int((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y],
fill=self.WEATHER_COLORS['sun'], width=2)
def _draw_cloud(self, x: int, y: int, size: int) -> None:
"""Draw a cloud using multiple circles."""
cloud_color = self.WEATHER_COLORS['cloud']
base_y = y + size//2
# Draw main cloud body (3 overlapping circles)
circle_radius = size//4
positions = [
(x + size//3, base_y), # Left circle
(x + size//2, base_y - size//6), # Top circle
(x + 2*size//3, base_y) # Right circle
]
for cx, cy in positions:
self.draw.ellipse([cx - circle_radius, cy - circle_radius,
cx + circle_radius, cy + circle_radius],
fill=cloud_color)
def _draw_rain(self, x: int, y: int, size: int) -> None:
"""Draw rain drops falling from a cloud."""
self._draw_cloud(x, y, size)
rain_color = self.WEATHER_COLORS['rain']
# Draw rain drops at an angle
drop_size = size//8
drops = [
(x + size//4, y + 2*size//3),
(x + size//2, y + 3*size//4),
(x + 3*size//4, y + 2*size//3)
]
for dx, dy in drops:
# Draw angled rain drops
self.draw.line([dx, dy, dx - drop_size//2, dy + drop_size],
fill=rain_color, width=2)
def _draw_snow(self, x: int, y: int, size: int) -> None:
"""Draw snowflakes falling from a cloud."""
self._draw_cloud(x, y, size)
snow_color = self.WEATHER_COLORS['snow']
# Draw snowflakes
flake_size = size//6
flakes = [
(x + size//4, y + 2*size//3),
(x + size//2, y + 3*size//4),
(x + 3*size//4, y + 2*size//3)
]
for fx, fy in flakes:
# Draw a snowflake (six-pointed star)
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = fx + int(flake_size * math.cos(rad))
end_y = fy + int(flake_size * math.sin(rad))
self.draw.line([fx, fy, end_x, end_y],
fill=snow_color, width=1)
def _draw_storm(self, x: int, y: int, size: int) -> None:
"""Draw a storm cloud with lightning bolt."""
self._draw_cloud(x, y, size)
# Draw lightning bolt
bolt_color = self.WEATHER_COLORS['storm']
bolt_points = [
(x + size//2, y + size//2), # Top
(x + 3*size//5, y + 2*size//3), # Middle right
(x + 2*size//5, y + 2*size//3), # Middle left
(x + size//2, y + 5*size//6) # Bottom
]
self.draw.polygon(bolt_points, fill=bolt_color)
@deprecated("3.7.0")
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
"""Draw a weather icon based on the condition."""
if condition.lower() in ['clear', 'sunny']:
self._draw_sun(x, y, size)
elif condition.lower() in ['clouds', 'cloudy', 'partly cloudy']:
self._draw_cloud(x, y, size)
elif condition.lower() in ['rain', 'drizzle', 'shower']:
self._draw_rain(x, y, size)
elif condition.lower() in ['snow', 'sleet', 'hail']:
self._draw_snow(x, y, size)
elif condition.lower() in ['thunderstorm', 'storm']:
self._draw_storm(x, y, size)
else:
self._draw_sun(x, y, size)
# Note: No update_display() here - let the caller handle the update
@deprecated("3.7.0")
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
color: tuple = (255, 255, 255)):
"""Draw text with weather icons at specified positions."""
# Draw the text
self.draw_text(text, x, y, color)
# Draw any icons
if icons:
for icon_type, icon_x, icon_y in icons:
self.draw_weather_icon(icon_type, icon_x, icon_y)
# Update the display once after everything is drawn
self.update_display()
def cleanup(self):
"""Clean up resources."""
if hasattr(self, '_snapshot_cond'):
@@ -1695,6 +1567,9 @@ class DisplayManager:
# A plugin captured for Vegas calls this from its own display();
# it must not change the live scroll's state or frame hold.
return
# A scroll starting or ending also ends a static screen's handover;
# see end_scroll_for_static_screen.
self._static_handover = False
current_time = time.time()
# Scrolling callers set this every frame; log transitions only.
changed = self._scrolling_state['is_scrolling'] != is_scrolling
@@ -1707,6 +1582,47 @@ class DisplayManager:
if changed:
logger.debug("Scrolling state set to: %s", is_scrolling)
def end_scroll_for_static_screen(self) -> None:
"""Ready the panel for a static screen's first frame after a scroll.
The display controller calls this just before it dispatches the first
frame of a screen that runs its 1 Hz loop, and
``set_scrolling_state(False)`` once that dispatch returns. Nothing
else ends a scroll at a handover: the state belongs to the screen
before, and would only expire 2 s after its last frame.
Until then, the frames that dispatch presents go out as drawn, not
scan-order composed: each as one segment, held for the scroll's hold.
With the state still "scrolling", ``_scan_segments`` would take their
lagging rows from the frame before: for the first, the scroller's last
frame -- the bottom half of the old ticker under the new screen on a
96x48 panel. At hold 1 that frame stays up for a whole second; at a
longer hold its first refresh flashes the old rows. For a second frame
in the same call, the rows would come from the first, shown for as long
as the first's would be. Dirty tracking does not keep such a frame up
past the screen's next redraw: a frame pushed while the scroll state
is set leaves it no digest to match, so that redraw is pushed.
The rest of that scroll is left on purpose, until the controller ends
it:
* the scroll state, so the gap from the scroller's last frame to this
screen's first is still timed by the frame-timing recorder and
watched by the stall watchdog, which is where a slow first
``display()`` shows up;
* its frame hold. On a frame that stays up for a second it only moves
the swap to the scroll's next hold boundary, and it is the pacing
that gap is due at: judged at hold 1, a handover that kept the
scroller's own schedule would count as frames late.
The next ``set_scrolling_state()`` call, whoever makes it, ends this.
One attribute store, so no lock: ``update_display`` reads it once per
frame, under its own, and the history is dropped there.
"""
if self._writes_suppressed():
return # a thread drawing off-screen cannot end the live scroll
self._static_handover = True
def is_currently_scrolling(self) -> bool:
"""Check if the display is currently in a scrolling state."""
current_time = time.time()
@@ -1828,18 +1744,6 @@ class DisplayManager:
if removed_count > 0:
logger.debug(f"Cleaned up {removed_count} expired deferred updates")
@deprecated("3.7.0")
def get_scrolling_stats(self) -> dict:
"""Get current scrolling statistics for debugging."""
return {
'is_scrolling': self._scrolling_state['is_scrolling'],
'last_activity': self._scrolling_state['last_scroll_activity'],
'deferred_count': len(self._scrolling_state['deferred_updates']),
'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'],
'max_deferred_updates': self._scrolling_state['max_deferred_updates'],
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
}
def _viewer_is_fresh(self, now: float) -> bool:
"""True when a browser preview is watching (marker file touched by
the web SSE broadcaster). The marker is stat'd at most once per
@@ -1861,11 +1765,14 @@ class DisplayManager:
Args:
frame_checksum: adler32 of the current frame, when the caller has
already computed one. Dirty tracking checksums every frame a
few lines above the call site, and re-deriving it here meant a
second tobytes() plus a second pass over the whole framebuffer
on every single frame — ~0.17ms per frame of the two combined
at 256x64, paid 100 times a second to reach the same number.
already computed one. Dirty tracking checksums every static
frame a few lines above the call site, and re-deriving it here
meant a second tobytes() plus a second pass over the whole
framebuffer on every single frame — ~0.17ms per frame of the
two combined at 256x64, paid 100 times a second to reach the
same number. None (mid-scroll, dirty tracking off, no
hardware): the frame is hashed here, and only when the policy
could act on it.
"""
try:
now = time.time()
@@ -1876,11 +1783,29 @@ class DisplayManager:
self._last_snapshot_ts = 0.0
self._viewer_was_fresh = viewer_fresh
digest = (frame_checksum if frame_checksum is not None
else zlib.adler32(self.image.tobytes()))
action = snapshot_policy.decide(
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
viewer_fresh, digest != self._last_snapshot_digest)
if frame_checksum is not None:
digest = frame_checksum
action = snapshot_policy.decide(
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
viewer_fresh, digest != self._last_snapshot_digest)
else:
# Ask as if the frame had changed before paying to find out.
# decide() is monotone in frame_changed -- a SKIP for a
# changed frame is a SKIP for an unchanged one too (its touch
# branch ignores frame_changed) -- so returning here gives the
# same answer the hash would have, and on most frames the hash
# is never taken. test_snapshot_policy.py holds decide() to it.
action = snapshot_policy.decide(
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
viewer_fresh, True)
if action is snapshot_policy.SnapshotAction.SKIP:
return
digest = zlib.adler32(self.image.tobytes())
if digest == self._last_snapshot_digest:
# Unchanged after all: the decision an unchanged frame gets.
action = snapshot_policy.decide(
now, self._last_snapshot_ts,
self._last_snapshot_touch_ts, viewer_fresh, False)
if action is snapshot_policy.SnapshotAction.SKIP:
return
if (action is snapshot_policy.SnapshotAction.TOUCH
@@ -1958,7 +1883,8 @@ class DisplayManager:
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
try:
with os.fdopen(_fd, "wb") as _f:
image.save(_f, format='PNG')
image.save(_f, format='PNG',
compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL)
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, self._snapshot_path)
except Exception:
@@ -1969,7 +1895,8 @@ class DisplayManager:
except OSError:
pass
# Fallback to direct save if replace not supported
image.save(self._snapshot_path, format='PNG')
image.save(self._snapshot_path, format='PNG',
compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL)
# Set proper file permissions after saving
try:
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
+414
View File
@@ -0,0 +1,414 @@
"""Render-loop liveness: systemd watchdog pings and a heartbeat file.
A panel can freeze while ``ledmatrix.service`` stays "active": a plugin's
``display()`` that never returns, a deadlock, a stuck hardware swap. Nothing
outside the process could tell, so nothing restarted it. This module lets the
render loop prove it is still going round, in two ways:
* **systemd watchdog.** The unit sets ``WatchdogSec=`` and
``NotifyAccess=main``; this sends ``WATCHDOG=1`` over ``$NOTIFY_SOCKET``.
When the pings stop, systemd kills the process (SIGABRT, so faulthandler
prints every thread's stack to the journal first) and ``Restart=`` brings it
back.
* **Heartbeat file**, ``/run/ledmatrix/display-heartbeat.json``, for the web
interface's ``/api/v3/health`` and the automatic update's health check.
``/run`` is tmpfs, so the writes never reach the SD card.
Both are driven only from the render thread -- ``beat()`` from any other
thread is ignored -- so a render thread stuck inside a plugin stops them even
while every other thread carries on. The unit's ``WatchdogSec=`` is the
steady-state limit; start-up (plugin loads, the 20s initial update budget,
dependency installs) is far longer and happens before the render loop exists,
so ``begin_startup()`` widens the limit for it and the render loop narrows it
back, sends ``READY=1`` and starts pinging once its first frame is on the
panel. See docs/ARCHITECTURE.md ("Liveness") for the unit settings.
Standard library only, and no import of the rest of ``src``: ``run.py`` loads
this before anything heavy so the start-up allowance is in place long before
the unit's own ``WatchdogSec`` could expire. Without ``$NOTIFY_SOCKET`` (dev
server, emulator, Windows, an older unit) every call is a cheap no-op, and the
heartbeat is written only where ``/run/ledmatrix`` exists or can be created.
"""
import contextlib
import json
import logging
import os
import socket
import tempfile
import threading
import time
from typing import Any, Callable, Dict, Iterator, Mapping, Optional
logger = logging.getLogger(__name__)
#: Where the display writes its heartbeat. ``RuntimeDirectory=ledmatrix`` in
#: the unit creates the directory; a display running under an older unit
#: creates it itself (it runs as root). The web interface, which is not root,
#: only reads it: the directory is 0755 and the file 0644.
HEARTBEAT_DIR = '/run/ledmatrix'
HEARTBEAT_NAME = 'display-heartbeat.json'
HEARTBEAT_PATH = HEARTBEAT_DIR + '/' + HEARTBEAT_NAME
#: How often the render loop pings systemd and rewrites the heartbeat. Beats
#: come many times a second; this is the rate limit on the side effects.
BEAT_INTERVAL_SECONDS = 5.0
#: A heartbeat older than this means the render loop has stopped. Above the
#: longest gap a healthy loop has (the executor's 30s display() timeout), so a
#: slow plugin does not read as a frozen panel.
HEARTBEAT_STALE_SECONDS = 60.0
#: The watchdog limit while the process starts, before the render loop runs.
#: Start-up loads every plugin (pip included, when a dependency is missing:
#: up to 300s a try), then spends up to 20s on initial updates. A hang in
#: there is still caught, just later.
STARTUP_ALLOWANCE_SECONDS = 15 * 60
#: The watchdog limit while the render thread loads a plugin that was just
#: enabled from the web UI: loading can run pip, on this thread.
PLUGIN_LOAD_ALLOWANCE_SECONDS = 15 * 60
# -- sd_notify -------------------------------------------------------------
def notify(message: str, environ: Optional[Mapping[str, str]] = None,
socket_factory: Optional[Callable[..., Any]] = None) -> bool:
"""Send ``message`` to systemd over ``$NOTIFY_SOCKET``; True if it was sent.
The same protocol as libsystemd's ``sd_notify()``: one datagram of
newline-separated ``KEY=VALUE`` lines to an AF_UNIX socket. An address
starting with ``@`` is in the abstract namespace (a leading NUL byte).
Never raises: a missing socket or a failed send is simply False, so a
display run outside systemd behaves exactly as before.
"""
env = os.environ if environ is None else environ
address = env.get('NOTIFY_SOCKET') or ''
if address.startswith('@'):
address = '\0' + address[1:]
elif not address.startswith('/'):
# Unset, or a vsock: address (systemd 253+, VMs only).
return False
family = getattr(socket, 'AF_UNIX', None)
if family is None:
return False
factory = socket_factory or socket.socket
try:
sock = factory(family, socket.SOCK_DGRAM | getattr(socket, 'SOCK_CLOEXEC', 0))
try:
sock.connect(address)
sock.sendall(message.encode('utf-8'))
finally:
sock.close()
return True
except OSError as e:
logger.debug("sd_notify(%r) failed: %s", message, e)
return False
def watchdog_usec(environ: Optional[Mapping[str, str]] = None) -> Optional[int]:
"""The unit's ``WatchdogSec`` in microseconds, or None when it has none.
systemd passes it as ``$WATCHDOG_USEC``, with ``$WATCHDOG_PID`` naming the
process it is meant for (a child that inherited the environment must not
think the watchdog is its own).
"""
env = os.environ if environ is None else environ
pid = env.get('WATCHDOG_PID')
if pid and pid != str(os.getpid()):
return None
try:
usec = int(env.get('WATCHDOG_USEC', ''))
except ValueError:
return None
return usec if usec > 0 else None
# -- heartbeat reading (web interface) ---------------------------------------
def read_heartbeat(path: str = HEARTBEAT_PATH) -> Optional[Dict[str, Any]]:
"""The heartbeat the display last wrote, or None when there is none.
None covers a display that does not write one -- dev server, emulator,
Windows, a display that has not drawn its first frame yet -- as well as an
unreadable file, so callers fall back to whatever they did before.
"""
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def heartbeat_age(data: Mapping[str, Any], now_mono: Optional[float] = None,
now_wall: Optional[float] = None) -> Optional[float]:
"""Seconds since the heartbeat in ``data`` was written, or None if it has no time.
Measured on the monotonic clock when it can be: on Linux that is
CLOCK_MONOTONIC, shared by every process, and it does not jump when NTP
first corrects the clock of a Pi with no RTC. /run is emptied at boot, so
a heartbeat always comes from this boot. Falls back to the wall clock.
"""
now_mono = time.monotonic() if now_mono is None else now_mono
now_wall = time.time() if now_wall is None else now_wall
mono = data.get('mono')
if isinstance(mono, (int, float)) and not isinstance(mono, bool):
age = now_mono - mono
if age >= -1.0: # a clock this far behind is not the same clock
return max(age, 0.0)
wall = data.get('wall')
if isinstance(wall, (int, float)) and not isinstance(wall, bool):
return max(now_wall - wall, 0.0)
return None
# -- the render loop's side ----------------------------------------------------
_DEFAULT_DIR = object()
class RenderWatchdog:
"""Pings systemd and writes the heartbeat, from the render thread only.
Lifecycle: ``begin_startup()`` as the process starts, ``bind_render_thread()``
when ``DisplayController.run()`` starts, then ``note_frame()`` for every
frame pushed to the panel and ``beat()`` / ``loop_pass()`` from every place
the render loop reliably comes back to. The first beat after the first
frame (or after the loop's first full pass, when there is nothing to draw)
arms it: ``READY=1``, the unit's own ``WatchdogSec``, and the heartbeat.
"""
def __init__(self, environ: Optional[Mapping[str, str]] = None,
send: Optional[Callable[[str], bool]] = None,
clock: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
heartbeat_dir: Any = _DEFAULT_DIR,
enable_faulthandler: bool = True):
env = dict(os.environ if environ is None else environ)
self._send = send or (lambda message: notify(message, env))
self._clock = clock
self._wall_clock = wall_clock
self._usec = watchdog_usec(env)
if heartbeat_dir is _DEFAULT_DIR:
# /run exists only on Linux; elsewhere (Windows dev) there is no
# heartbeat rather than a C:\run folder.
heartbeat_dir = HEARTBEAT_DIR if os.name == 'posix' else None
self._heartbeat_dir: Optional[str] = heartbeat_dir
# None until the first write; False for good if that one failed
# (nowhere to write: not root, no /run); True once one landed.
self._heartbeat_ok: Optional[bool] = None
self._heartbeat_warned = False
self._enable_faulthandler = enable_faulthandler
self._render_thread: Optional[int] = None
self._frame_pushed = False
self._passes = 0
self._armed = False
self._last_beat: Optional[float] = None
self._extend_depth = 0
interval = BEAT_INTERVAL_SECONDS
if self._usec:
# systemd's advice is to ping at half the limit; a third leaves
# room for one late beat even if someone sets a very short one.
interval = min(interval, self._usec / 1e6 / 3)
self._interval = interval
@property
def armed(self) -> bool:
return self._armed
def _on_render_thread(self) -> bool:
return self._render_thread is not None and threading.get_ident() == self._render_thread
def begin_startup(self) -> None:
"""Widen the watchdog to cover start-up. Call as early as possible.
systemd starts the watchdog clock when a Type=simple service starts,
and start-up routinely takes longer than the render loop's limit.
Only widens: an operator who set a longer ``WatchdogSec`` keeps it.
"""
if not self._usec:
return
allowance = max(self._usec, int(STARTUP_ALLOWANCE_SECONDS * 1e6))
self._send(f'WATCHDOG_USEC={allowance}\nSTATUS=Starting: loading plugins')
def bind_render_thread(self) -> None:
"""Mark the calling thread as the render thread; beats from others are ignored."""
self._render_thread = threading.get_ident()
self._frame_pushed = False
self._passes = 0
def note_frame(self) -> None:
"""A frame was pushed to the panel (DisplayManager.update_display).
Any thread may push the first one -- the first dispatch of a screen
runs on PluginExecutor's thread -- so this only records it; the
render thread's next beat arms the watchdog.
"""
if self._render_thread is None:
return # start-up screens, before the render loop exists
self._frame_pushed = True
if self._on_render_thread():
self.beat()
def loop_pass(self) -> None:
"""The top of the render loop's ``while True``.
A second arrival here means a whole pass finished. That counts as the
first frame when there was nothing to draw (no plugins enabled, every
screen empty): the loop is plainly alive, and a watchdog that never
armed would leave a later hang uncaught.
"""
if not self._on_render_thread():
return
self._passes += 1
if self._passes > 1:
self._frame_pushed = True
self.beat()
def beat(self) -> None:
"""The render loop is still going round. Cheap; call it freely."""
if not self._on_render_thread():
return
if not self._armed:
if not self._frame_pushed:
return
self._arm()
return
now = self._clock()
if self._last_beat is not None and now - self._last_beat < self._interval:
return
self._last_beat = now
if self._usec:
self._send('WATCHDOG=1')
self._write_heartbeat(now)
def _arm(self) -> None:
self._armed = True
self._last_beat = self._clock()
if self._usec:
# Back from the start-up allowance to the unit's own limit.
self._send(f'READY=1\nWATCHDOG_USEC={self._usec}\nWATCHDOG=1\nSTATUS=Rendering')
self._install_faulthandler()
logger.info("systemd watchdog armed: the render loop must check in every %.0fs",
self._usec / 1e6)
else:
self._send('READY=1\nSTATUS=Rendering')
self._write_heartbeat(self._last_beat)
def _install_faulthandler(self) -> None:
"""Dump every thread's stack when the watchdog's SIGABRT arrives.
That trace, in the journal, is what says which plugin the render
thread was stuck in.
"""
if not self._enable_faulthandler:
return
try:
import faulthandler
import sys
if not faulthandler.is_enabled() and sys.stderr is not None:
faulthandler.enable(all_threads=True)
except (ImportError, RuntimeError, ValueError, OSError, AttributeError) as e:
logger.debug("faulthandler not enabled: %s", e)
@contextlib.contextmanager
def extended(self, seconds: float, reason: str = '') -> Iterator[None]:
"""Allow the render thread ``seconds`` for one blocking job.
For the few legitimate jobs that can outlast the watchdog, such as
loading a newly enabled plugin, which can run pip on this thread.
Nests; the unit's limit comes back when the outermost one ends.
"""
if not (self._armed and self._usec and self._on_render_thread()):
yield
return
usec = max(self._usec, int(seconds * 1e6))
if self._extend_depth == 0:
self._send(f'WATCHDOG_USEC={usec}\nWATCHDOG=1'
+ (f'\nSTATUS=Busy: {reason}' if reason else ''))
self._extend_depth += 1
try:
yield
finally:
self._extend_depth -= 1
if self._extend_depth == 0:
self._send(f'WATCHDOG_USEC={self._usec}\nWATCHDOG=1\nSTATUS=Rendering')
self._last_beat = self._clock()
self._write_heartbeat(self._last_beat)
def stopping(self) -> None:
"""Clean shutdown: tell systemd, and take the heartbeat down with us.
A heartbeat left behind by a stopped display would read as a frozen
one to the web interface.
"""
if self._usec or self._armed:
self._send('STOPPING=1')
path = self._heartbeat_path()
if path and self._heartbeat_ok:
try:
os.unlink(path)
except OSError:
pass
# -- heartbeat file ----------------------------------------------------
def _heartbeat_path(self) -> Optional[str]:
if not self._heartbeat_dir:
return None
return os.path.join(self._heartbeat_dir, HEARTBEAT_NAME)
def _write_heartbeat(self, now_mono: float) -> None:
path = self._heartbeat_path()
if path is None or self._heartbeat_ok is False:
return
directory = self._heartbeat_dir
try:
if not os.path.isdir(directory):
# An install whose unit predates RuntimeDirectory=: the
# display runs as root and can make it. Anyone else cannot,
# and gets no heartbeat -- which readers treat as "unknown".
os.makedirs(directory, mode=0o755, exist_ok=True)
payload = json.dumps({'pid': os.getpid(), 'mono': now_mono,
'wall': self._wall_clock()})
fd, tmp = tempfile.mkstemp(dir=directory, prefix='.heartbeat-')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(payload)
os.chmod(tmp, 0o644)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
if self._heartbeat_ok is None:
logger.info("Writing the display heartbeat to %s", path)
self._heartbeat_ok = True
except OSError as e:
if self._heartbeat_ok is None:
logger.info("Not writing a display heartbeat (%s: %s); health checks "
"fall back to their older signals", directory, e)
self._heartbeat_ok = False
elif not self._heartbeat_warned:
# It worked before, so keep trying, but say so only once.
logger.warning("Could not update the display heartbeat: %s", e)
self._heartbeat_warned = True
#: The process-wide instance: one display process, one render loop.
watchdog = RenderWatchdog()
def beat() -> None:
"""Module-level shortcut so the Vegas loop and the plugin manager need no reference."""
watchdog.beat()
def note_frame() -> None:
watchdog.note_frame()
def extended(seconds: float, reason: str = ''):
return watchdog.extended(seconds, reason)
+4 -240
View File
@@ -40,13 +40,7 @@ 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
from typing import Dict, Tuple, Optional, Union, Any
logger = logging.getLogger(__name__)
@@ -93,7 +87,7 @@ class FontManager:
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
self.temp_font_dir.mkdir(exist_ok=True)
# Counters behind get_performance_stats().
# Font-load counters, kept up by get_font().
self.performance_stats = {
"cache_hits": 0,
"cache_misses": 0,
@@ -109,12 +103,8 @@ class FontManager:
"tom_thumb": "assets/fonts/tom-thumb.bdf"
}
# Size tokens for convenience
self.size_tokens = {
"xs": 6, "sm": 8, "md": 10, "lg": 12, "xl": 14, "xxl": 16
}
# Font overrides storage (for manual overrides)
# Per-element overrides read from config/font_overrides.json;
# resolve_font applies them.
# 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.
@@ -187,26 +177,6 @@ class FontManager:
if removed:
self.manager_fonts_version += 1
@deprecated("3.7.0")
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
"""
Get registered fonts for a specific manager or all managers.
Args:
manager_id: Optional manager ID, if None returns all
Returns:
Dictionary of registered fonts
"""
if manager_id:
return self.manager_fonts.get(manager_id, {})
return self.manager_fonts.copy()
@deprecated("3.7.0")
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
"""Get all detected font usage across managers."""
return self.detected_fonts.copy()
# ==================== Plugin Font Management ====================
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
@@ -433,51 +403,6 @@ class FontManager:
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."""
try:
if plugin_id in self.plugin_fonts:
# Remove from plugin catalogs
if plugin_id in self.plugin_font_catalogs:
for family in self.plugin_font_catalogs[plugin_id]:
namespaced_family = f"{plugin_id}::{family}"
if namespaced_family in self.font_catalog:
del self.font_catalog[namespaced_family]
del self.plugin_font_catalogs[plugin_id]
# Remove plugin manifest
del self.plugin_fonts[plugin_id]
# Clear related cache entries
self._clear_plugin_font_cache(plugin_id)
logger.info(f"Unregistered fonts for plugin {plugin_id}")
return True
return False
except Exception as e:
logger.error(f"Error unregistering plugin fonts: {e}")
return False
def _clear_plugin_font_cache(self, plugin_id: str):
"""Clear font cache entries for a specific plugin."""
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
for key in keys_to_remove:
del self.font_cache[key]
if keys_to_remove:
# Font objects someone may hold were dropped; see cache_generation.
self.cache_generation += 1
@deprecated("3.7.0")
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
"""Get list of font families registered by a plugin."""
if plugin_id in self.plugin_font_catalogs:
return list(self.plugin_font_catalogs[plugin_id].keys())
return []
# ==================== Font Resolution ====================
def resolve_font(self, element_key: str, family: str, size_px: int,
@@ -668,42 +593,6 @@ class FontManager:
logger.error(f"Error getting font height: {e}", exc_info=True)
return 12 # Default height
# ==================== Override Management ====================
@deprecated("3.7.0")
def set_override(self, element_key: str, family: str = None, size_px: int = None):
"""Set font override for a specific element."""
if element_key not in self.font_overrides:
self.font_overrides[element_key] = {}
if family is not None:
self.font_overrides[element_key]["family"] = family
if size_px is not None:
self.font_overrides[element_key]["size_px"] = size_px
# Remove empty overrides
if not self.font_overrides[element_key]:
del self.font_overrides[element_key]
else:
self._save_overrides()
self.clear_cache()
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
@deprecated("3.7.0")
def remove_override(self, element_key: str):
"""Remove font override for a specific element."""
if element_key in self.font_overrides:
del self.font_overrides[element_key]
self._save_overrides()
self.clear_cache()
logger.info(f"Font override removed for {element_key}")
@deprecated("3.7.0")
def get_overrides(self) -> Dict[str, Dict[str, str]]:
"""Get current font overrides."""
return self.font_overrides.copy()
# ==================== Font Discovery ====================
@staticmethod
@@ -765,17 +654,6 @@ class FontManager:
logger.warning(f"Could not load font overrides: {e}")
self.font_overrides = {}
def _save_overrides(self):
"""Save current font overrides to file."""
try:
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:
json.dump(self.font_overrides, f, indent=2)
logger.info(f"Saved {len(self.font_overrides)} font overrides")
except Exception as e:
logger.error(f"Could not save font overrides: {e}")
# ==================== Utility Methods ====================
def clear_cache(self):
@@ -786,117 +664,3 @@ class FontManager:
# without the bump they kept serving results for the dropped fonts.
self.cache_generation += 1
logger.info("Font cache cleared")
@deprecated("3.7.0", "read font_catalog")
def get_available_fonts(self) -> Dict[str, str]:
"""Get dictionary of available font families and their paths."""
return self.font_catalog.copy()
@deprecated("3.7.0")
def get_size_tokens(self) -> Dict[str, int]:
"""Get available size tokens."""
return self.size_tokens.copy()
@deprecated("3.7.0")
def get_performance_stats(self) -> Dict[str, Any]:
"""Get performance statistics."""
uptime = time.time() - self.performance_stats["start_time"]
return {
"uptime_seconds": uptime,
"cache_hits": self.performance_stats["cache_hits"],
"cache_misses": self.performance_stats["cache_misses"],
"cache_hit_rate": (
self.performance_stats["cache_hits"] /
(self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"])
if (self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"]) > 0 else 0
),
"total_fonts_cached": len(self.font_cache),
"total_metrics_cached": len(self.metrics_cache),
"failed_loads": self.performance_stats["failed_loads"],
"total_fonts_available": len(self.font_catalog),
"plugin_fonts": len(self.plugin_fonts),
"manager_fonts": len(self.manager_fonts),
"detected_fonts": len(self.detected_fonts)
}
@deprecated("3.7.0", "read font_catalog")
def get_font_catalog(self) -> Dict[str, str]:
"""Get the current font catalog."""
return self.font_catalog.copy()
@deprecated("3.7.0")
def add_font(self, font_file_path: str, family_name: str) -> bool:
"""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):
logger.error(f"Font file not found: {font_file_path}")
return False
# Check if family name already exists
if family_name in self.font_catalog:
logger.warning(f"Font family '{family_name}' already exists")
return False
fonts_dir = Path(resolve_asset_path("assets/fonts"))
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
# Add to catalog
self.font_catalog[family_name] = font_file_path
self.clear_cache()
logger.info(f"Added font {family_name}: {font_file_path}")
return True
except Exception as e:
logger.error(f"Error adding font {family_name}: {e}")
return False
@deprecated("3.7.0")
def remove_font(self, family_name: str) -> bool:
"""Remove a font from the catalog."""
try:
if family_name not in self.font_catalog:
logger.warning(f"Font family '{family_name}' not found")
return False
# Check if font is currently in use
in_use = False
for override in self.font_overrides.values():
if override.get("family") == family_name:
in_use = True
break
if in_use:
logger.error(f"Cannot remove font '{family_name}' - it is currently in use")
return False
del self.font_catalog[family_name]
self.clear_cache()
logger.info(f"Removed font {family_name}")
return True
except Exception as e:
logger.error(f"Error removing font {family_name}: {e}")
return False
@deprecated("3.7.0")
def validate_font(self, font_path: str) -> Dict[str, Any]:
"""Validate a font file."""
try:
if not os.path.exists(font_path):
return {"valid": False, "error": "Font file not found"}
if font_path.endswith('.bdf'):
# Try to load BDF font
freetype.Face(font_path)
return {"valid": True, "type": "bdf", "family": "unknown"}
elif font_path.endswith('.ttf'):
# Try to load TTF font
load_truetype(font_path, 12)
return {"valid": True, "type": "ttf", "family": "unknown"}
else:
return {"valid": False, "error": "Unsupported font format"}
except Exception as e:
return {"valid": False, "error": str(e)}
+11
View File
@@ -0,0 +1,11 @@
"""The display process's control socket: web -> display commands with acks.
- :mod:`src.ipc.contract` -- the versioned messages, the framing and where
the socket lives. Shared by both sides; standard library only.
- :mod:`src.ipc.server` -- the display side: a threaded Unix-socket server
whose handlers only queue work for the render thread.
- :mod:`src.ipc.client` -- the web side: one short-timeout request.
See docs/IPC_CONTROL_SOCKET.md for the protocol, the security model and the
stage plan.
"""
+235
View File
@@ -0,0 +1,235 @@
"""The web side of the control socket: one request, a short timeout, no retries.
Every failure -- no socket (the display is stopped, or predates the socket),
a refused or timed-out connection, a reply that breaks the contract, or an
error the display returned -- raises :class:`ControlError` with a short
``reason``, and the caller falls back to the file mailbox. Nothing here
blocks for longer than ``timeout`` in total.
"""
from __future__ import annotations
import socket
import time
import uuid
from typing import Any, Dict, List, Mapping, Optional, Sequence
from src.ipc.contract import (
AWAIT_SECONDS,
MAX_MESSAGE_BYTES,
PROTOCOL_VERSION,
SUPPORTED_VERSIONS,
Command,
FrameReader,
ProtocolError,
Request,
Response,
client_socket_paths,
decode_message,
encode_message,
parse_args,
socket_supported,
)
#: Total budget for one request: connect, send and the reply. The display
#: answers from a thread that does no rendering, normally within a few
#: milliseconds; this only bounds a wedged one. The web route then falls back
#: to the mailbox, so a timeout costs this much latency and nothing else.
DEFAULT_TIMEOUT_SECONDS = 1.0
class ControlError(Exception):
"""The socket could not carry the request. ``reason`` is a short code.
Transport reasons: ``disabled``, ``unsupported``, ``no_socket``,
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
When the display answered with an error, ``reason`` is that error's
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
"""
def __init__(self, reason: str, message: str = ''):
super().__init__(reason, message)
self.reason = reason
self.message = message
def __str__(self) -> str:
return f'{self.reason}: {self.message}' if self.message else self.reason
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
request_id: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Send one command and return its ``result``. Raises :class:`ControlError`."""
args = dict(args or {})
request_id = request_id or str(uuid.uuid4())
try:
# Refuse locally what the display would refuse: a malformed id
# (callers may pass their own) or arguments that break the contract.
envelope = Request.from_dict({'v': PROTOCOL_VERSION, 'id': request_id,
'cmd': cmd, 'args': args})
parse_args(cmd, args)
payload = encode_message(envelope.to_dict())
except ProtocolError as e:
raise ControlError('invalid_request', e.message) from None
if not socket_supported():
raise ControlError('unsupported', 'no Unix sockets on this platform')
candidates: List[str] = list(paths) if paths is not None else client_socket_paths()
if not candidates:
raise ControlError('disabled', 'the control socket is turned off')
deadline = time.monotonic() + timeout
sock = _connect(candidates, deadline)
try:
response = _exchange(sock, payload, deadline)
finally:
sock.close()
# A refusal before the request was read (forbidden, too many
# connections) carries no id.
if response.id != request_id and not (response.id is None and not response.ok):
raise ControlError('bad_response', 'the reply is for a different request')
if not response.ok:
error = response.error
raise ControlError(error.code if error else 'bad_response',
error.message if error else '')
return dict(response.result or {})
def _remaining(deadline: float) -> float:
left = deadline - time.monotonic()
if left <= 0:
raise ControlError('timeout', 'no reply in time')
return left
def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
last = ControlError('no_socket', 'the display is not serving the control socket')
for path in paths:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(_remaining(deadline))
sock.connect(path)
return sock
except (FileNotFoundError, NotADirectoryError):
sock.close()
continue
except ConnectionRefusedError:
sock.close()
last = ControlError('refused', f'nothing is listening at {path}')
except BlockingIOError:
# EAGAIN: the listen backlog is full -- a live but swamped display.
sock.close()
raise ControlError('busy', 'the display is not accepting connections') from None
except PermissionError:
sock.close()
last = ControlError('refused', f'no permission to connect to {path}')
except socket.timeout:
sock.close()
raise ControlError('timeout', 'connect timed out') from None
except ControlError:
sock.close()
raise
except OSError as e:
sock.close()
last = ControlError('refused', f'{path}: {e}')
raise last
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
try:
sock.settimeout(_remaining(deadline))
sock.sendall(payload)
reader = FrameReader(MAX_MESSAGE_BYTES)
while True:
sock.settimeout(_remaining(deadline))
data = sock.recv(4096)
if not data:
raise ControlError('closed', 'the display closed the connection')
lines = reader.feed(data)
if lines:
return Response.from_dict(decode_message(lines[0]))
except socket.timeout:
raise ControlError('timeout', 'no reply in time') from None
except ProtocolError as e:
raise ControlError('bad_response', e.message) from None
except ControlError:
raise
except OSError as e:
raise ControlError('closed', str(e)) from None
# -- commands ---------------------------------------------------------------------------
def on_demand_start(request_id: str, plugin_id: Optional[str], mode: Optional[str],
duration: Any = None, pinned: bool = False, *,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Ask the display to show a plugin now. Returns the ack; raises :class:`ControlError`.
``request_id`` doubles as the on-demand request id, so a request that a
timed-out caller then also writes to the mailbox is processed only once.
"""
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
return request(Command.ON_DEMAND_START, args, request_id=request_id,
timeout=timeout, paths=paths)
def on_demand_stop(request_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Ask the display to end on-demand. Returns the ack; raises :class:`ControlError`."""
return request(Command.ON_DEMAND_STOP, {}, request_id=request_id,
timeout=timeout, paths=paths)
def on_demand_status(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""The display's live on-demand state. Raises :class:`ControlError`."""
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
#: Headroom over the display's own wait for an awaited command, so its
#: ``pending`` answer arrives before the client gives up.
_AWAIT_MARGIN_SECONDS = 1.0
def _awaited_timeout(cmd: str) -> float:
return AWAIT_SECONDS[cmd] + _AWAIT_MARGIN_SECONDS
def brightness_set(brightness: int, *, timeout: Optional[float] = None,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Set the panel's normal brightness now (transient: config.json is not
written). Returns the applied :class:`~src.ipc.contract.BrightnessResult`;
raises :class:`ControlError`.
"""
return request(Command.BRIGHTNESS_SET, {'brightness': brightness},
timeout=_awaited_timeout(Command.BRIGHTNESS_SET) if timeout is None
else timeout, paths=paths)
def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Have the display reload a running plugin from disk.
Returns :class:`~src.ipc.contract.PluginReloadResult` once the new code is
running. Raises :class:`ControlError`: ``not_loaded`` (not running it),
``failed`` (the new version did not load), ``pending`` (not done in
time; it will still happen), or a transport reason.
"""
return request(Command.PLUGIN_RELOAD, {'plugin_id': plugin_id},
timeout=_awaited_timeout(Command.PLUGIN_RELOAD) if timeout is None
else timeout, paths=paths)
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
return request(Command.PING, {}, timeout=timeout, paths=paths)
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Version negotiation: the result's ``version`` is the one both sides speak."""
return request(Command.HELLO, {'versions': list(SUPPORTED_VERSIONS), 'client': client},
timeout=timeout, paths=paths)
+633
View File
@@ -0,0 +1,633 @@
"""The control socket's contract: versioned messages, framing and location.
Both processes import this module -- the display serves the socket
(:mod:`src.ipc.server`) and the web interface calls it
(:mod:`src.ipc.client`) -- so it is the one definition of what goes over the
wire. Standard library only, and no import of the rest of ``src``.
Wire format (protocol version 1)
--------------------------------
One JSON object per line (newline-delimited JSON), UTF-8, at most
:data:`MAX_MESSAGE_BYTES` per line including the newline. Messages are
encoded with ``ensure_ascii``, so a newline never appears inside one.
Request::
{"v": 1, "id": "<1-128 chars>", "cmd": "on_demand.start", "args": {...}}
Response, always carrying the request's ``id`` (``null`` when the request
could not be parsed far enough to have one)::
{"v": 1, "id": "...", "ok": true, "result": {...}}
{"v": 1, "id": "...", "ok": false, "error": {"code": "...", "message": "..."}}
A connection may carry several requests; each gets exactly one response, in
order. Commands that change what the panel shows are *acknowledged*, not
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
has the command queued and will apply it at its next on-demand check. Its
outcome is published the way it always was (``display_on_demand_state``,
later the state stream). A few commands (:data:`AWAITED_COMMANDS`) are
answered only once the render thread has applied them, or with ``pending``
when it has not within :data:`AWAIT_SECONDS`.
New commands are added within a protocol version: a display that does not
know one answers ``unknown_command``, the client falls back, and ``hello``
lists the commands a display knows. The version changes only when the
envelope or the meaning of an existing command changes.
See docs/IPC_CONTROL_SOCKET.md for the full description.
"""
from __future__ import annotations
import json
import math
import os
import tempfile
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Tuple, TypedDict, TypeGuard, Union
# -- versions and limits -------------------------------------------------------
#: The protocol version this code speaks by default.
PROTOCOL_VERSION = 1
#: Every version this code can speak; ``hello`` picks the highest common one.
SUPPORTED_VERSIONS: Tuple[int, ...] = (1,)
#: The largest message either side sends or accepts, newline included. A
#: stage-1 message is well under 1 KiB; this only bounds a broken or hostile
#: peer, so a reader never buffers more than this per connection.
MAX_MESSAGE_BYTES = 64 * 1024
#: Longest request id. Ids are also the on-demand ``request_id``, which the
#: display logs and stores, so they are kept short.
MAX_ID_LENGTH = 128
#: Longest plugin id or mode name an on-demand command may carry.
MAX_NAME_LENGTH = 128
# -- where the socket lives ------------------------------------------------------
#: ``RuntimeDirectory=ledmatrix`` in ledmatrix.service creates this (tmpfs,
#: root-owned, 0755); a display under an older unit creates it itself, as it
#: does for the heartbeat (src/display_watchdog.py).
DEFAULT_SOCKET_DIR = '/run/ledmatrix'
SOCKET_NAME = 'control.sock'
DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR + '/' + SOCKET_NAME
#: Overrides the socket path for both processes (a dev checkout, a second
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
#: display does not serve it and the web interface goes straight to the
#: file mailbox.
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
DISABLED_VALUES = frozenset({'off', '0', 'false', 'no', 'none', 'disabled'})
def socket_supported() -> bool:
"""Whether this platform has Unix sockets at all (Windows Python does not)."""
import socket
return os.name == 'posix' and hasattr(socket, 'AF_UNIX')
def socket_disabled(environ: Optional[Mapping[str, str]] = None) -> bool:
"""True when :data:`SOCKET_PATH_ENV` switches the socket off."""
env = os.environ if environ is None else environ
value = (env.get(SOCKET_PATH_ENV) or '').strip()
return value.lower() in DISABLED_VALUES
def configured_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
"""The path :data:`SOCKET_PATH_ENV` names, or None when it is unset or 'off'."""
env = os.environ if environ is None else environ
value = (env.get(SOCKET_PATH_ENV) or '').strip()
if not value or value.lower() in DISABLED_VALUES:
return None
return value
def dev_socket_path(uid: Optional[int] = None) -> str:
"""Where a display that cannot use /run/ledmatrix serves the socket.
A per-user directory under the temp dir, so a dev checkout run as an
ordinary user (``python3 run.py -e``) and its web interface, run by the
same user, find each other with no configuration.
"""
if uid is None:
getuid = getattr(os, 'getuid', None)
uid = getuid() if getuid is not None else 0
return os.path.join(tempfile.gettempdir(), f'ledmatrix-{uid}', SOCKET_NAME)
def client_socket_paths(environ: Optional[Mapping[str, str]] = None) -> List[str]:
"""The paths a client tries, in order; empty when the socket is off."""
if socket_disabled(environ):
return []
configured = configured_socket_path(environ)
if configured:
return [configured]
return [DEFAULT_SOCKET_PATH, dev_socket_path()]
# -- commands and error codes ----------------------------------------------------
class Command:
"""Command names. Dotted names group a feature's commands."""
HELLO = 'hello'
PING = 'ping'
ON_DEMAND_START = 'on_demand.start'
ON_DEMAND_STOP = 'on_demand.stop'
ON_DEMAND_STATUS = 'on_demand.status'
BRIGHTNESS_SET = 'brightness.set'
PLUGIN_RELOAD = 'plugin.reload'
#: Every command version 1 defines, in the order ``hello`` reports them.
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, within version 1
#: (see the module docstring on adding commands).
COMMANDS: Tuple[str, ...] = (
Command.HELLO,
Command.PING,
Command.ON_DEMAND_START,
Command.ON_DEMAND_STOP,
Command.ON_DEMAND_STATUS,
Command.BRIGHTNESS_SET,
Command.PLUGIN_RELOAD,
)
#: Commands that are queued for the render thread.
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
#: Queued commands whose answer waits for the render thread's outcome
#: instead of being an ack. The value is how long the display waits before
#: answering ``pending``; the command stays queued and is still applied.
#: A plugin reload first lets the current screen end (within a frame on a
#: scrolling screen, at once on a static one) and then imports the plugin,
#: which can take a few seconds on a slow board.
AWAIT_SECONDS: Dict[str, float] = {
Command.BRIGHTNESS_SET: 2.0,
Command.PLUGIN_RELOAD: 10.0,
}
AWAITED_COMMANDS = frozenset(AWAIT_SECONDS)
#: Brightness, in percent, as the display's hardware setting takes it.
MIN_BRIGHTNESS = 0
MAX_BRIGHTNESS = 100
class ErrorCode:
"""``error.code`` values. Clients branch on these, never on the message."""
BAD_JSON = 'bad_json' # a line that is not a JSON object
BAD_REQUEST = 'bad_request' # the envelope is malformed
MESSAGE_TOO_LARGE = 'message_too_large' # over MAX_MESSAGE_BYTES
UNSUPPORTED_VERSION = 'unsupported_version' # no version in common
UNKNOWN_COMMAND = 'unknown_command'
INVALID_ARGS = 'invalid_args'
BUSY = 'busy' # queue full / too many clients
FORBIDDEN = 'forbidden' # peer credentials refused
INTERNAL = 'internal' # a bug on the display side
# From the awaited commands (stage 2):
PENDING = 'pending' # accepted, not applied within AWAIT_SECONDS; still queued
NOT_LOADED = 'not_loaded' # plugin.reload: the display is not running that plugin
FAILED = 'failed' # the render thread tried, and it did not work
class ProtocolError(Exception):
"""A message that breaks the contract. ``code`` is an :class:`ErrorCode`."""
def __init__(self, code: str, message: str, request_id: Optional[str] = None):
super().__init__(code, message, request_id)
self.code = code
self.message = message
self.request_id = request_id
def __str__(self) -> str:
return f'{self.code}: {self.message}'
# -- the envelope ------------------------------------------------------------------
def _is_int(value: Any) -> TypeGuard[int]:
return isinstance(value, int) and not isinstance(value, bool)
def _valid_id(value: Any) -> bool:
return (isinstance(value, str) and 0 < len(value) <= MAX_ID_LENGTH
and value.isprintable())
@dataclass(frozen=True)
class Request:
"""``{v, id, cmd, args}``."""
id: str
cmd: str
args: Dict[str, Any] = field(default_factory=dict)
v: int = PROTOCOL_VERSION
def to_dict(self) -> Dict[str, Any]:
return {'v': self.v, 'id': self.id, 'cmd': self.cmd, 'args': dict(self.args)}
@classmethod
def from_dict(cls, obj: Any) -> 'Request':
"""Validate an envelope. Raises :class:`ProtocolError`.
The version is checked by the server, not here, so that ``hello``
can negotiate across versions.
"""
if not isinstance(obj, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a request must be a JSON object')
raw_id = obj.get('id')
request_id = raw_id if _valid_id(raw_id) else None
if request_id is None:
raise ProtocolError(ErrorCode.BAD_REQUEST,
f'id must be a printable string of 1-{MAX_ID_LENGTH} characters')
version = obj.get('v')
if not _is_int(version):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer', request_id)
cmd = obj.get('cmd')
if not isinstance(cmd, str) or not cmd:
raise ProtocolError(ErrorCode.BAD_REQUEST, 'cmd must be a non-empty string', request_id)
args = obj.get('args', {})
if args is None:
args = {}
if not isinstance(args, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'args must be a JSON object', request_id)
return cls(id=request_id, cmd=cmd, args=args, v=version)
@dataclass(frozen=True)
class ErrorInfo:
code: str
message: str
def to_dict(self) -> Dict[str, str]:
return {'code': self.code, 'message': self.message}
@dataclass(frozen=True)
class Response:
"""``{v, id, ok, result}`` or ``{v, id, ok: false, error: {code, message}}``."""
id: Optional[str]
ok: bool
result: Optional[Dict[str, Any]] = None
error: Optional[ErrorInfo] = None
v: int = PROTOCOL_VERSION
@classmethod
def success(cls, request_id: Optional[str], result: Mapping[str, Any],
v: int = PROTOCOL_VERSION) -> 'Response':
return cls(id=request_id, ok=True, result=dict(result), v=v)
@classmethod
def failure(cls, request_id: Optional[str], code: str, message: str,
v: int = PROTOCOL_VERSION) -> 'Response':
return cls(id=request_id, ok=False, error=ErrorInfo(code, message), v=v)
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {'v': self.v, 'id': self.id, 'ok': self.ok}
if self.ok:
out['result'] = dict(self.result or {})
else:
error = self.error or ErrorInfo(ErrorCode.INTERNAL, 'unknown error')
out['error'] = error.to_dict()
return out
@classmethod
def from_dict(cls, obj: Any) -> 'Response':
"""Validate a response. Raises :class:`ProtocolError` (BAD_REQUEST)."""
if not isinstance(obj, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a response must be a JSON object')
version = obj.get('v')
if not _is_int(version):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer')
raw_id = obj.get('id')
if raw_id is not None and not isinstance(raw_id, str):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'id must be a string or null')
ok = obj.get('ok')
if not isinstance(ok, bool):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'ok must be a boolean')
if ok:
result = obj.get('result', {})
if not isinstance(result, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'result must be a JSON object')
return cls(id=raw_id, ok=True, result=result, v=version)
error = obj.get('error')
if (not isinstance(error, dict) or not isinstance(error.get('code'), str)
or not isinstance(error.get('message', ''), str)):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'error must be {code, message}')
return cls(id=raw_id, ok=False,
error=ErrorInfo(error['code'], error.get('message', '')), v=version)
# -- command arguments -------------------------------------------------------------
def _optional_name(args: Mapping[str, Any], key: str) -> Optional[str]:
value = args.get(key)
if value is None or value == '':
return None
if not isinstance(value, str) or len(value) > MAX_NAME_LENGTH or not value.isprintable():
raise ProtocolError(ErrorCode.INVALID_ARGS,
f'{key} must be a printable string of at most '
f'{MAX_NAME_LENGTH} characters')
return value
def _optional_duration(value: Any) -> Optional[float]:
"""Seconds, or None for "until stopped". 0 means the same as None.
Numbers and numeric strings are accepted, the same as the REST route and
the file mailbox take them; anything else is refused rather than guessed.
"""
if value is None or value == '':
return None
if isinstance(value, bool):
raise ProtocolError(ErrorCode.INVALID_ARGS, 'duration must be a number of seconds')
try:
seconds = float(value)
except (TypeError, ValueError):
raise ProtocolError(ErrorCode.INVALID_ARGS,
'duration must be a number of seconds') from None
if not math.isfinite(seconds) or seconds < 0:
raise ProtocolError(ErrorCode.INVALID_ARGS,
'duration must be a finite, non-negative number of seconds')
return seconds or None
@dataclass(frozen=True)
class HelloArgs:
"""``hello``: the versions the client speaks, and a name for the logs."""
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
client: str = ''
def to_dict(self) -> Dict[str, Any]:
return {'versions': list(self.versions), 'client': self.client}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
versions = args.get('versions', [PROTOCOL_VERSION])
if (not isinstance(versions, list) or not versions or len(versions) > 32
or not all(_is_int(v) for v in versions)):
raise ProtocolError(ErrorCode.INVALID_ARGS, 'versions must be a list of integers')
client = args.get('client', '')
if not isinstance(client, str) or len(client) > MAX_NAME_LENGTH:
raise ProtocolError(ErrorCode.INVALID_ARGS, 'client must be a short string')
return cls(versions=tuple(versions), client=client)
@dataclass(frozen=True)
class OnDemandStartArgs:
"""``on_demand.start``: show a plugin (or one of its modes) now.
The same fields the file mailbox carries. At least one of ``plugin_id``
and ``mode`` is required; the display resolves the other.
"""
plugin_id: Optional[str] = None
mode: Optional[str] = None
duration: Optional[float] = None
pinned: bool = False
def to_dict(self) -> Dict[str, Any]:
return {'plugin_id': self.plugin_id, 'mode': self.mode,
'duration': self.duration, 'pinned': self.pinned}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
plugin_id = _optional_name(args, 'plugin_id')
mode = _optional_name(args, 'mode')
if plugin_id is None and mode is None:
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id or mode is required')
pinned = args.get('pinned', False)
if pinned is None:
pinned = False
if not isinstance(pinned, bool):
raise ProtocolError(ErrorCode.INVALID_ARGS, 'pinned must be a boolean')
return cls(plugin_id=plugin_id, mode=mode,
duration=_optional_duration(args.get('duration')), pinned=pinned)
@dataclass(frozen=True)
class OnDemandStopArgs:
"""``on_demand.stop``: end the on-demand session and resume rotation."""
def to_dict(self) -> Dict[str, Any]:
return {}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
return cls()
@dataclass(frozen=True)
class NoArgs:
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
def to_dict(self) -> Dict[str, Any]:
return {}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
return cls()
@dataclass(frozen=True)
class BrightnessSetArgs:
"""``brightness.set``: the panel's normal brightness, in percent, now.
Transient: nothing is written to config.json, and the next config change
the display picks up (or a restart) goes back to the configured value.
The web interface sends it after saving the setting, so the two agree.
The dim schedule still applies on top, as it does to the saved value.
"""
brightness: int
def to_dict(self) -> Dict[str, Any]:
return {'brightness': self.brightness}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs':
value = args.get('brightness')
if not _is_int(value) or not MIN_BRIGHTNESS <= value <= MAX_BRIGHTNESS:
raise ProtocolError(ErrorCode.INVALID_ARGS,
f'brightness must be an integer from {MIN_BRIGHTNESS} '
f'to {MAX_BRIGHTNESS}')
return cls(brightness=value)
@dataclass(frozen=True)
class PluginReloadArgs:
"""``plugin.reload``: load a running plugin again from disk.
For a plugin the store has just updated. Only a plugin the display is
running can be reloaded (``not_loaded`` otherwise), so the id never
makes the display import anything it was not already running.
"""
plugin_id: str
def to_dict(self) -> Dict[str, Any]:
return {'plugin_id': self.plugin_id}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs':
plugin_id = _optional_name(args, 'plugin_id')
if plugin_id is None:
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id is required')
return cls(plugin_id=plugin_id)
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
BrightnessSetArgs, PluginReloadArgs]
#: The arguments of a command that goes on the render thread's queue.
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
_ARG_TYPES: Dict[str, Any] = {
Command.HELLO: HelloArgs,
Command.PING: NoArgs,
Command.ON_DEMAND_START: OnDemandStartArgs,
Command.ON_DEMAND_STOP: OnDemandStopArgs,
Command.ON_DEMAND_STATUS: NoArgs,
Command.BRIGHTNESS_SET: BrightnessSetArgs,
Command.PLUGIN_RELOAD: PluginReloadArgs,
}
def parse_args(cmd: str, args: Mapping[str, Any]) -> CommandArgs:
"""Typed arguments for ``cmd``. Raises :class:`ProtocolError`."""
arg_type = _ARG_TYPES.get(cmd)
if arg_type is None:
raise ProtocolError(ErrorCode.UNKNOWN_COMMAND, f'unknown command: {cmd[:64]}')
parsed: CommandArgs = arg_type.from_dict(args)
return parsed
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
timestamp: float) -> Dict[str, Any]:
"""The file-mailbox payload for a queued on-demand command.
The display hands socket commands to the same code that handles the
mailbox (``DisplayController._handle_on_demand_request``), so a command
behaves identically whichever way it arrived, and a request that came
both ways (a client that timed out and fell back) is processed once: the
request id is the same.
"""
if isinstance(args, OnDemandStartArgs):
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
'mode': args.mode, 'duration': args.duration, 'pinned': args.pinned,
'timestamp': timestamp, 'source': 'socket'}
return {'request_id': request_id, 'action': 'stop', 'timestamp': timestamp,
'source': 'socket'}
# -- results -----------------------------------------------------------------------
class HelloResult(TypedDict):
version: int
versions: List[int]
commands: List[str]
max_message_bytes: int
server: str
class PingResult(TypedDict):
pong: bool
class AckResult(TypedDict):
"""The answer to a queued command: the render thread will apply it."""
accepted: bool
request_id: str
queued: int
class BrightnessResult(TypedDict):
"""``brightness.set``, once applied.
``panel_brightness`` is what the panel shows now: the dim schedule's
level while it dims, and unchanged while the schedule has the display
off (the new level applies when it comes back on).
"""
brightness: int
panel_brightness: int
dimmed: bool
display_active: bool
class PluginReloadResult(TypedDict):
"""``plugin.reload``, once the plugin is running again."""
plugin_id: str
reloaded: bool
version: Optional[str]
modes: List[str]
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
"""The highest version both sides speak, or None."""
common = set(client_versions) & set(SUPPORTED_VERSIONS)
return max(common) if common else None
# -- framing -----------------------------------------------------------------------
def encode_message(obj: Mapping[str, Any]) -> bytes:
"""One newline-terminated JSON line. Raises :class:`ProtocolError` when too big."""
try:
text = json.dumps(obj, separators=(',', ':'), ensure_ascii=True, allow_nan=False)
except (TypeError, ValueError) as e:
raise ProtocolError(ErrorCode.BAD_REQUEST, f'message is not JSON-serialisable: {e}') from None
data = text.encode('ascii') + b'\n'
if len(data) > MAX_MESSAGE_BYTES:
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
f'message is {len(data)} bytes; the limit is {MAX_MESSAGE_BYTES}')
return data
def decode_message(line: bytes) -> Dict[str, Any]:
"""Parse one line (newline optional). Raises :class:`ProtocolError` (BAD_JSON)."""
try:
obj = json.loads(line.decode('utf-8'))
except ValueError: # UnicodeDecodeError and JSONDecodeError are both ValueErrors
raise ProtocolError(ErrorCode.BAD_JSON, 'not valid UTF-8 JSON') from None
if not isinstance(obj, dict):
raise ProtocolError(ErrorCode.BAD_JSON, 'a message must be a JSON object')
return obj
class FrameReader:
"""Splits a byte stream into lines, never holding more than one message.
``feed()`` returns the complete lines (without their newlines) the new
bytes finished, and raises :class:`ProtocolError` (MESSAGE_TOO_LARGE) as
soon as a line is longer than the limit, newline or not, so a peer that
never sends one cannot make the reader buffer without bound.
"""
def __init__(self, max_bytes: int = MAX_MESSAGE_BYTES):
self._max = max_bytes
self._buffer = bytearray()
@property
def pending(self) -> int:
"""Bytes of an unfinished message held."""
return len(self._buffer)
def feed(self, data: bytes) -> List[bytes]:
self._buffer.extend(data)
lines: List[bytes] = []
while True:
newline = self._buffer.find(b'\n')
if newline < 0:
break
if newline + 1 > self._max:
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
f'message exceeds {self._max} bytes')
line = bytes(self._buffer[:newline])
del self._buffer[:newline + 1]
if line.strip():
lines.append(line)
if len(self._buffer) >= self._max:
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
f'message exceeds {self._max} bytes')
return lines
+749
View File
@@ -0,0 +1,749 @@
"""The display side of the control socket.
A small threaded server on a Unix stream socket (``/run/ledmatrix/control.sock``
by default; see :mod:`src.ipc.contract` for the protocol). It never touches
rendering: a command that changes the panel is validated, put on a bounded
queue and acknowledged, and the render thread drains that queue at the point
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
handing each command to the same code. Queries (``on_demand.status``) are
answered from a snapshot callable the display provides.
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
is what it waits on in place of a sleep, so a command lands within a frame on
every kind of screen. An awaited command (``brightness.set``,
``plugin.reload``) carries a :class:`CommandOutcome` that the render thread
fills in; its connection thread waits for that, bounded, before answering.
Robustness rules, because this runs inside the display process:
* every connection has its own daemon thread, at most :data:`MAX_CLIENTS` at
once; one more is told ``busy`` and closed;
* every read and write has a timeout, a message must arrive whole within
:data:`MESSAGE_TIMEOUT_SECONDS`, and an idle connection is closed after
:data:`IDLE_TIMEOUT_SECONDS` -- a slow or stuck client costs one thread for
a few seconds, never the render loop;
* a line that is not JSON is answered with ``bad_json`` and the connection
carries on; a line over the size limit closes the connection; a client
that disconnects mid-message is simply dropped;
* no exception from a handler leaves the connection thread.
Who may connect (see docs/IPC_CONTROL_SOCKET.md, "Security model"): the
socket file is ``0660`` and group-owned by the group the display and the web
interface share -- the cache directory's group, the same rule DiskCache uses
for the files it shares -- so the kernel refuses everyone else at connect().
Where the kernel reports the peer's credentials (``SO_PEERCRED``, Linux) the
server checks them again: root, its own user, or a member of that group.
"""
from __future__ import annotations
import logging
import os
import queue
import socket
import stat
import struct
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional
from src.ipc.contract import (
AWAIT_SECONDS,
AWAITED_COMMANDS,
COMMANDS,
DEFAULT_SOCKET_DIR,
DEFAULT_SOCKET_PATH,
MAX_MESSAGE_BYTES,
PROTOCOL_VERSION,
QUEUED_COMMANDS,
SUPPORTED_VERSIONS,
AckResult,
BrightnessSetArgs,
Command,
ErrorCode,
FrameReader,
HelloArgs,
HelloResult,
OnDemandStartArgs,
OnDemandStopArgs,
PluginReloadArgs,
ProtocolError,
QueuedArgs,
Request,
Response,
configured_socket_path,
decode_message,
dev_socket_path,
encode_message,
negotiate_version,
on_demand_request,
parse_args,
socket_disabled,
socket_supported,
)
logger = logging.getLogger(__name__)
#: Concurrent connections served. The web interface opens one per request
#: and closes it; this only bounds a misbehaving client.
MAX_CLIENTS = 8
#: Commands waiting for the render thread. It drains them at least every
#: 0.25 s, so a full queue means the render thread is stuck, and the client
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
QUEUE_SIZE = 16
#: Timeout for one recv()/send() on a connection.
IO_TIMEOUT_SECONDS = 2.0
#: A message must arrive whole within this long of its first byte.
MESSAGE_TIMEOUT_SECONDS = 5.0
#: A connection with no message in progress is closed after this long.
IDLE_TIMEOUT_SECONDS = 10.0
#: How often the accept loop wakes to notice close().
_ACCEPT_POLL_SECONDS = 0.5
_LISTEN_BACKLOG = 64
# -- queued work ---------------------------------------------------------------------
class CommandOutcome:
"""How an awaited command turned out, handed from the render thread back
to the connection thread that is waiting to answer.
The render thread calls :meth:`succeed` or :meth:`fail` once; the first
call wins. The connection thread may have stopped waiting already (it
answered ``pending``), and then nobody reads it.
"""
def __init__(self) -> None:
self._done = threading.Event()
self._lock = threading.Lock()
self.result: Optional[Dict[str, Any]] = None
self.error_code: Optional[str] = None
self.error_message = ''
@property
def done(self) -> bool:
return self._done.is_set()
def succeed(self, result: Mapping[str, Any]) -> None:
with self._lock:
if self._done.is_set():
return
self.result = dict(result)
self._done.set()
def fail(self, code: str, message: str) -> None:
with self._lock:
if self._done.is_set():
return
self.error_code = code
self.error_message = message
self._done.set()
def wait(self, timeout: float) -> bool:
return self._done.wait(timeout)
@dataclass(frozen=True)
class QueuedCommand:
"""A command waiting for the render thread.
``outcome`` is set for an awaited command (``AWAITED_COMMANDS``): the
render thread reports through it, and the client's answer waits for it.
"""
request_id: str
cmd: str
args: QueuedArgs
received_at: float # time.time() when it was accepted
peer_uid: Optional[int] = None
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
def as_on_demand_request(self) -> Dict[str, Any]:
"""The mailbox-shaped payload the display's on-demand handler takes."""
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
raise TypeError(f'{self.cmd} is not an on-demand command')
return on_demand_request(self.request_id, self.args, self.received_at)
def succeed(self, result: Mapping[str, Any]) -> None:
"""Report success to a waiting client (a no-op for an acked command)."""
if self.outcome is not None:
self.outcome.succeed(result)
def fail(self, code: str, message: str) -> None:
"""Report failure to a waiting client (a no-op for an acked command)."""
if self.outcome is not None:
self.outcome.fail(code, message)
# -- peer credentials ------------------------------------------------------------------
@dataclass(frozen=True)
class PeerCredentials:
pid: int
uid: int
gid: int
def peer_credentials(conn: socket.socket) -> Optional[PeerCredentials]:
"""The connecting process's pid/uid/gid, where the kernel reports them.
``SO_PEERCRED`` is Linux's; elsewhere this is None and the socket file's
mode is the only gate.
"""
option = getattr(socket, 'SO_PEERCRED', None)
if option is None:
return None
try:
raw = conn.getsockopt(socket.SOL_SOCKET, option, struct.calcsize('3i'))
pid, uid, gid = struct.unpack('3i', raw)
except (OSError, struct.error):
return None
return PeerCredentials(pid=pid, uid=uid, gid=gid)
def process_groups(pid: int) -> Optional[FrozenSet[int]]:
"""A process's supplementary groups, from /proc; None when unreadable.
The web service's primary group is normally its user's own; the shared
group is a supplementary one, which ``SO_PEERCRED`` does not report.
"""
try:
with open(f'/proc/{int(pid)}/status', 'r', encoding='ascii', errors='replace') as f:
for line in f:
if line.startswith('Groups:'):
return frozenset(int(g) for g in line.split()[1:] if g.isdigit())
except (OSError, ValueError):
return None
return frozenset()
def user_in_group(uid: int, gid: int) -> bool:
"""Whether the account ``uid`` is listed in group ``gid`` (the group database)."""
try:
import grp
import pwd
name = pwd.getpwuid(uid).pw_name
group = grp.getgrgid(gid)
except (ImportError, KeyError, OSError):
return False
return name in group.gr_mem or pwd.getpwuid(uid).pw_gid == gid
def peer_allowed(cred: PeerCredentials, own_uid: int, allowed_gid: Optional[int],
groups: Optional[FrozenSet[int]] = None,
in_group: Callable[[int, int], bool] = user_in_group) -> bool:
"""The permission model: root, the server's own user, or the shared group.
``groups`` are the peer's supplementary groups (from /proc); when they
could not be read the group database decides instead.
"""
if cred.uid == 0 or cred.uid == own_uid:
return True
if allowed_gid is None:
return False
if cred.gid == allowed_gid:
return True
if groups is not None:
return allowed_gid in groups
return in_group(cred.uid, allowed_gid)
def resolve_socket_group(cache_dir: Optional[str]) -> Optional[int]:
"""The group the socket should belong to: the one the two services share.
The cache directory's group when the directory is group-writable --
the rule DiskCache applies to every file the display shares with the web
interface (``root:ledmatrix 2775`` on an installed device). Otherwise the
project directory's group (``get_shared_group_gid``), which config files
use. None when neither is known: then only root and the display's own
user can connect.
"""
if cache_dir:
try:
st = os.stat(cache_dir)
if st.st_mode & stat.S_IWGRP:
return st.st_gid
except OSError:
pass
try:
from src.common.permission_utils import get_shared_group_gid
return get_shared_group_gid()
except ImportError: # pragma: no cover - src is always importable here
return None
def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
"""Where the display should serve the socket; None when it should not.
:data:`~src.ipc.contract.SOCKET_PATH_ENV` wins. Otherwise
/run/ledmatrix/control.sock when the display can create or write that
directory (root, which an installed display always is), and the per-user
dev path otherwise (an emulator run from a checkout).
"""
if not socket_supported() or socket_disabled(environ):
return None
configured = configured_socket_path(environ)
if configured:
return configured
geteuid = getattr(os, 'geteuid', None)
if (geteuid is not None and geteuid() == 0) or os.access(DEFAULT_SOCKET_DIR, os.W_OK):
return DEFAULT_SOCKET_PATH
return dev_socket_path()
# -- the server ------------------------------------------------------------------------
StatusProvider = Callable[[], Dict[str, Any]]
class ControlServer:
"""Serves the control socket on background threads.
``start()`` binds and starts accepting; ``drain()`` (render thread) takes
the queued commands; ``close()`` stops and removes the socket file.
"""
def __init__(self, path: str, status_provider: Optional[StatusProvider] = None,
group: Optional[int] = None, *, queue_size: int = QUEUE_SIZE,
max_clients: int = MAX_CLIENTS, io_timeout: float = IO_TIMEOUT_SECONDS,
message_timeout: float = MESSAGE_TIMEOUT_SECONDS,
idle_timeout: float = IDLE_TIMEOUT_SECONDS,
check_peer: bool = True,
await_seconds: Optional[Mapping[str, float]] = None):
self.path = path
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
if await_seconds:
self._await_seconds.update(await_seconds)
self._status_provider = status_provider
self._group = group
self._queue: 'queue.Queue[QueuedCommand]' = queue.Queue(maxsize=queue_size)
self._pending = threading.Event()
self._slots = threading.BoundedSemaphore(max_clients)
self._io_timeout = io_timeout
self._message_timeout = message_timeout
self._idle_timeout = idle_timeout
self._check_peer = check_peer
self._sock: Optional[socket.socket] = None
self._identity: Optional[tuple] = None # (st_dev, st_ino) of our socket file
self._thread: Optional[threading.Thread] = None
self._stopping = threading.Event()
self._own_uid = os.geteuid() if hasattr(os, 'geteuid') else -1
# -- lifecycle -------------------------------------------------------------
@property
def running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def socket_mode(self) -> int:
"""0660 with a shared group; 0600 (the display's user only) without one."""
return 0o660 if self._group is not None else 0o600
def start(self) -> bool:
"""Bind and start serving. False (logged) when the socket cannot be served.
Never raises: without the socket the web interface uses the file
mailbox, exactly as before.
"""
if not socket_supported():
logger.debug("Control socket not started: no Unix sockets on this platform")
return False
try:
self._prepare_directory()
if not self._clear_stale_socket():
return False
self._bind()
except OSError as e:
logger.warning("Control socket not started at %s (%s); the web interface "
"will use the file mailbox", self.path, e)
self._close_socket()
return False
self._stopping.clear()
self._thread = threading.Thread(target=self._accept_loop, name='ledmatrix-ipc',
daemon=True)
self._thread.start()
logger.info("Control socket listening at %s (mode %o, group %s)",
self.path, self.socket_mode,
self._group if self._group is not None else 'none')
return True
def close(self) -> None:
"""Stop accepting and remove the socket file (only if it is still ours)."""
self._stopping.set()
self._close_socket()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
self._thread = None
if self._identity is not None:
try:
st = os.lstat(self.path)
if (st.st_dev, st.st_ino) == self._identity:
os.unlink(self.path)
except OSError:
pass
self._identity = None
def _close_socket(self) -> None:
sock, self._sock = self._sock, None
if sock is not None:
try:
sock.close()
except OSError:
pass
def _prepare_directory(self) -> None:
directory = os.path.dirname(os.path.abspath(self.path))
if self.path == dev_socket_path():
# The dev path is in the shared temp dir: private to this user,
# and refused if someone else got there first.
os.makedirs(directory, mode=0o700, exist_ok=True)
self._check_private_directory(directory)
elif not os.path.isdir(directory):
# /run/ledmatrix under a unit that predates RuntimeDirectory= (the
# display is root and makes it, as it does for the heartbeat), or
# a configured path. 0755: the web interface only needs to reach
# the socket; the socket's own mode decides who may connect.
os.makedirs(directory, mode=0o755, exist_ok=True)
def _check_private_directory(self, directory: str) -> None:
"""Refuse a dev directory someone else made (it lives in a shared /tmp)."""
st = os.lstat(directory)
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
raise OSError(f'{directory} is not a plain directory')
if hasattr(os, 'geteuid') and st.st_uid != os.geteuid():
raise OSError(f'{directory} belongs to uid {st.st_uid}, not this user')
def _clear_stale_socket(self) -> bool:
"""Remove a socket left by a display that died; never a live or foreign file."""
try:
st = os.lstat(self.path)
except FileNotFoundError:
return True
if not stat.S_ISSOCK(st.st_mode):
logger.error("Control socket not started: %s exists and is not a socket", self.path)
return False
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
probe.settimeout(0.5)
try:
probe.connect(self.path)
except OSError:
os.unlink(self.path) # nothing listening: a previous display's leftover
return True
finally:
probe.close()
logger.warning("Control socket not started: another process is serving %s", self.path)
return False
def _bind(self) -> None:
"""Bind under a temporary name, set mode and group, then rename into place.
The rename makes the socket appear with its final permissions, never
briefly with the process umask's.
"""
tmp = f'{self.path}.{os.getpid()}.tmp'
try:
os.unlink(tmp)
except FileNotFoundError:
pass
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._sock = sock
try:
sock.bind(tmp)
os.chmod(tmp, self.socket_mode)
if self._group is not None and hasattr(os, 'chown'):
try:
os.chown(tmp, -1, self._group)
except OSError as e:
# Not root and not in the group (a dev run): only this user
# (and root) can connect, which is what a dev run needs.
logger.debug("Could not give the control socket group %s: %s",
self._group, e)
# The backlog is only the kernel's queue in front of accept();
# MAX_CLIENTS still bounds what is served. A short one makes a
# burst of clients fail connect() with EAGAIN instead of being
# answered (busy or otherwise).
sock.listen(_LISTEN_BACKLOG)
sock.settimeout(_ACCEPT_POLL_SECONDS)
os.rename(tmp, self.path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
st = os.lstat(self.path)
self._identity = (st.st_dev, st.st_ino)
# -- the render thread's side ------------------------------------------------
@property
def has_pending(self) -> bool:
"""Cheap check for queued commands, for the render thread's fast path."""
return self._pending.is_set()
def wait_for_command(self, timeout: float) -> bool:
"""Block up to ``timeout`` seconds for a queued command; True if one is.
The render thread waits here instead of sleeping, in the dwell and
on a static screen, so a command wakes it at once. It is a timed
wait on an Event: no polling, and nothing more than the sleep it
replaces when no command comes. The flag stays set until drain(),
so a caller that does not drain would return at once every time.
"""
return self._pending.wait(timeout)
def drain(self) -> List[QueuedCommand]:
"""Every queued command, oldest first. Called from the render thread."""
commands: List[QueuedCommand] = []
self._pending.clear()
while True:
try:
commands.append(self._queue.get_nowait())
except queue.Empty:
break
return commands
# -- serving -------------------------------------------------------------------
def _accept_loop(self) -> None:
while not self._stopping.is_set():
sock = self._sock
if sock is None:
break
try:
conn, _ = sock.accept()
except socket.timeout:
continue
except OSError as e:
if self._stopping.is_set():
break
logger.warning("Control socket accept failed: %s", e)
time.sleep(0.1)
continue
if not self._slots.acquire(blocking=False):
self._refuse(conn, ErrorCode.BUSY, 'too many connections')
continue
try:
threading.Thread(target=self._serve, args=(conn,), name='ledmatrix-ipc-conn',
daemon=True).start()
except RuntimeError: # can't start a thread: shed the client
self._slots.release()
self._refuse(conn, ErrorCode.BUSY, 'server overloaded')
def _refuse(self, conn: socket.socket, code: str, message: str) -> None:
try:
conn.settimeout(0.2)
conn.sendall(encode_message(Response.failure(None, code, message).to_dict()))
except OSError:
pass
finally:
conn.close()
def _serve(self, conn: socket.socket) -> None:
"""One connection: authenticate, then answer requests until it ends."""
try:
conn.settimeout(self._io_timeout)
peer = peer_credentials(conn)
if self._check_peer and peer is not None and not self._peer_ok(peer):
logger.warning("Control socket refused pid %d (uid %d, gid %d): not root, "
"this user or group %s", peer.pid, peer.uid, peer.gid, self._group)
self._send(conn, Response.failure(None, ErrorCode.FORBIDDEN, 'not permitted'))
return
self._read_requests(conn, peer)
except Exception: # pylint: disable=broad-except
logger.exception("Control socket connection failed")
finally:
try:
conn.close()
except OSError:
pass
self._slots.release()
def _peer_ok(self, peer: PeerCredentials) -> bool:
groups = None
if peer.uid not in (0, self._own_uid) and self._group is not None:
groups = process_groups(peer.pid)
return peer_allowed(peer, self._own_uid, self._group, groups)
def _read_requests(self, conn: socket.socket, peer: Optional[PeerCredentials]) -> None:
reader = FrameReader(MAX_MESSAGE_BYTES)
idle_since = time.monotonic()
message_started: Optional[float] = None
while not self._stopping.is_set():
now = time.monotonic()
if message_started is not None and now - message_started > self._message_timeout:
logger.debug("Control socket: dropping a client too slow to send a message")
return
if message_started is None and now - idle_since > self._idle_timeout:
return
try:
data = conn.recv(4096)
except socket.timeout:
continue
except OSError:
return
if not data:
return # closed, possibly mid-message: nothing to answer
try:
lines = reader.feed(data)
except ProtocolError as e:
self._send(conn, Response.failure(None, e.code, e.message))
return # can't find the next message boundary: hang up
for line in lines:
if not self._send(conn, self.handle_line(line, peer)):
return
if reader.pending:
if message_started is None or lines:
message_started = time.monotonic()
else:
message_started = None
idle_since = time.monotonic()
def _send(self, conn: socket.socket, response: Response) -> bool:
try:
data = encode_message(response.to_dict())
except ProtocolError as e:
# A status snapshot too big (or not JSON) to send is a display bug.
logger.error("Control socket response not sent: %s", e.message)
data = encode_message(Response.failure(
response.id, ErrorCode.INTERNAL, 'response could not be encoded').to_dict())
try:
conn.sendall(data)
return True
except OSError:
return False
# -- requests --------------------------------------------------------------------
def handle_line(self, line: bytes, peer: Optional[PeerCredentials] = None) -> Response:
"""Answer one request line. Never raises."""
request_id: Optional[str] = None
try:
obj = decode_message(line)
raw_id = obj.get('id')
request_id = raw_id if isinstance(raw_id, str) and len(raw_id) <= 128 else None
request = Request.from_dict(obj)
request_id = request.id
return self._dispatch(request, peer)
except ProtocolError as e:
return Response.failure(e.request_id or request_id, e.code, e.message)
except Exception: # pylint: disable=broad-except
logger.exception("Control socket handler failed")
return Response.failure(request_id, ErrorCode.INTERNAL, 'internal error')
def _dispatch(self, request: Request, peer: Optional[PeerCredentials]) -> Response:
if request.cmd == Command.HELLO:
# Exempt from the envelope version check: this is how a client
# that speaks other versions finds out which ones we share.
hello = HelloArgs.from_dict(request.args)
version = negotiate_version(hello.versions)
if version is None:
return Response.failure(
request.id, ErrorCode.UNSUPPORTED_VERSION,
f'no common protocol version; this display speaks {list(SUPPORTED_VERSIONS)}')
result: HelloResult = {
'version': version,
'versions': list(SUPPORTED_VERSIONS),
'commands': list(COMMANDS),
'max_message_bytes': MAX_MESSAGE_BYTES,
'server': 'ledmatrix-display',
}
return Response.success(request.id, dict(result), v=version)
if request.v not in SUPPORTED_VERSIONS:
return Response.failure(
request.id, ErrorCode.UNSUPPORTED_VERSION,
f'protocol version {request.v} is not supported; '
f'this display speaks {list(SUPPORTED_VERSIONS)}')
try:
args = parse_args(request.cmd, request.args)
except ProtocolError as e:
return Response.failure(request.id, e.code, e.message, v=request.v)
if request.cmd == Command.PING:
return Response.success(request.id, {'pong': True}, v=request.v)
if request.cmd == Command.ON_DEMAND_STATUS:
if self._status_provider is None:
return Response.failure(request.id, ErrorCode.INTERNAL, 'no status available',
v=request.v)
return Response.success(request.id, self._status_provider(), v=request.v)
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
awaited = request.cmd in AWAITED_COMMANDS
command = QueuedCommand(request_id=request.id, cmd=request.cmd, args=args,
received_at=time.time(),
peer_uid=peer.uid if peer is not None else None,
outcome=CommandOutcome() if awaited else None)
try:
self._queue.put_nowait(command)
except queue.Full:
logger.warning("Control socket queue full; refusing %s %s",
request.cmd, request.id)
return Response.failure(request.id, ErrorCode.BUSY,
'the display is not taking commands right now',
v=request.v)
self._pending.set()
logger.info("Control socket accepted %s %s", request.cmd, request.id)
if command.outcome is not None:
return self._await_outcome(request, command.outcome)
ack: AckResult = {'accepted': True, 'request_id': request.id,
'queued': self._queue.qsize()}
return Response.success(request.id, dict(ack), v=request.v)
# A command in COMMANDS with no handler here is a bug in this module.
return Response.failure(request.id, ErrorCode.INTERNAL,
f'{request.cmd} is not implemented', v=request.v)
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
"""Answer an awaited command once the render thread has applied it.
Waits on this connection's thread, never the render thread's. If the
render thread does not get to it in time, the answer is ``pending``:
the command stays queued and is still applied, so a client treats
that as "not known to be done" rather than as a refusal.
"""
timeout = self._await_seconds.get(request.cmd, 0.0)
if not outcome.wait(timeout):
logger.warning("Control socket: %s %s not applied within %.1fs; answering pending",
request.cmd, request.id, timeout)
return Response.failure(request.id, ErrorCode.PENDING,
f'accepted, but not applied within {timeout:g}s; '
'the display will still apply it', v=request.v)
if outcome.error_code is not None:
return Response.failure(request.id, outcome.error_code, outcome.error_message,
v=request.v)
return Response.success(request.id, outcome.result or {}, v=request.v)
def start_control_server(status_provider: Optional[StatusProvider] = None,
cache_dir: Optional[str] = None,
environ: Optional[Mapping[str, str]] = None) -> Optional[ControlServer]:
"""Start the display's control socket, or return None when it can't run.
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
bind; in every case the web interface falls back to the file mailbox.
"""
path = server_socket_path(environ)
if path is None:
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
return None
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir))
return server if server.start() else None
__all__ = [
'CommandOutcome', 'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
'peer_allowed', 'peer_credentials', 'process_groups', 'resolve_socket_group',
'server_socket_path', 'start_control_server', 'PROTOCOL_VERSION',
]
+339 -58
View File
@@ -13,7 +13,10 @@ from enum import Enum
from typing import Dict, Any, Optional, List
import os
import sys
from src.deprecation import deprecated, warn_deprecated
from src.logging_config import get_logger
# Re-exported: a plugin may import it from here beside VegasDisplayMode.
from src.plugin_system.vegas_elements import VegasElement # noqa: F401
_shared_fallback_font_manager: Optional[Any] = None
@@ -65,27 +68,178 @@ def _fallback_font_manager() -> Any:
class VegasDisplayMode(Enum):
"""
Display mode for Vegas scroll integration.
Legacy display mode for Vegas scroll integration.
Determines how a plugin's content behaves within the continuous scroll:
Superseded by :meth:`BasePlugin.get_vegas_participation`. Vegas still
reads a plugin's :meth:`BasePlugin.get_vegas_display_mode` to derive its
participation when nothing declares one, and only STATIC matters there:
- SCROLL: Content scrolls continuously within the stream.
Best for multi-item plugins like sports scores, odds tickers, news feeds.
Plugin provides multiple frames via get_vegas_content().
- FIXED_SEGMENT: Content is a fixed-width block that scrolls BY with
the rest of the content. Best for static info like clock, weather.
Plugin provides a single image sized to vegas_panel_count panels.
- STATIC: Scroll pauses, plugin displays for its duration, then scroll
resumes. Best for important alerts or detailed views that need attention.
Plugin uses standard display() method during the pause.
- STATIC: the scroll pauses for the plugin's turn and its display() draws
it full screen -- participation ``'pause'``.
- SCROLL and FIXED_SEGMENT: the plugin's content joins the scroll --
participation ``'scroll'``. Vegas has never told the two apart: a card's
width comes from get_vegas_content() and ``vegas_width_pct``, not from
the mode. The distinction is deprecated and goes away in LEDMatrix 3.9.0.
"""
SCROLL = "scroll"
FIXED_SEGMENT = "fixed"
STATIC = "static"
#: How a plugin takes part in Vegas mode (BasePlugin.get_vegas_participation):
#:
#: - ``'scroll'``: its content joins the scrolling strip.
#: - ``'pause'``: the scroll stops when the plugin's turn comes round, and its
#: display() draws it full screen for its display duration.
#: - ``'exclude'``: it is left out of Vegas mode.
VEGAS_PARTICIPATION_VALUES = ('scroll', 'pause', 'exclude')
#: The release that removes get_supported_vegas_modes(),
#: get_vegas_segment_width(), the ``vegas_panel_count`` setting and the
#: SCROLL / FIXED_SEGMENT distinction. The two @deprecated markers below
#: spell it as a literal, because tools that read markers statically (the
#: deprecation tests, the plugin API usage scan) cannot follow a name.
VEGAS_LEGACY_REMOVAL = "3.9.0"
_vegas_logger = get_logger(__name__)
_vegas_warned: set = set()
def _vegas_warn_once(key: Any, message: str, *args: Any) -> None:
"""Log a warning about a plugin's Vegas settings once per process.
Participation is resolved at every rotation refresh, so a bad value would
otherwise log on every one of them.
"""
if key in _vegas_warned:
return
_vegas_warned.add(key)
_vegas_logger.warning(message, *args)
def vegas_participation_value(value: Any) -> Optional[str]:
"""``value`` as one of VEGAS_PARTICIPATION_VALUES, or None if it is not one.
Case and surrounding whitespace are ignored; anything that is not a string
(None, a MagicMock standing in for a plugin in a test) is not a value.
"""
if isinstance(value, str):
value = value.strip().lower()
if value in VEGAS_PARTICIPATION_VALUES:
return value
return None
def configured_vegas_participation(plugin_id: str, config: Any) -> Optional[str]:
"""The user's ``vegas_participation`` setting in a plugin's config, if valid.
An unset or empty value is no setting. Anything else that is not a
participation is logged once and ignored, so the plugin keeps its own.
"""
if not isinstance(config, dict):
return None
raw = config.get('vegas_participation')
if raw is None or (isinstance(raw, str) and not raw.strip()):
return None
value = vegas_participation_value(raw)
if value is None:
_vegas_warn_once(
('config', plugin_id, repr(raw)),
"[%s] Invalid vegas_participation %r, expected one of %s; ignoring it",
plugin_id, raw, ', '.join(VEGAS_PARTICIPATION_VALUES))
return value
def legacy_vegas_participation(plugin: Any) -> str:
"""The participation a plugin's pre-3.8 Vegas hooks describe.
Exactly what Vegas decided from them before participation existed:
1. get_vegas_display_mode() returning ``VegasDisplayMode.STATIC`` pauses,
whatever the content type -- a STATIC plugin whose content type is
``'none'`` was still kept in the rotation to pause it.
2. Otherwise get_vegas_content_type() returning ``'none'`` excludes.
3. Everything else scrolls. SCROLL and FIXED_SEGMENT were never told
apart, and neither were content types ``'multi'``, ``'static'`` or any
other string.
Only the enum member counts as STATIC (a plugin returning the string
``'static'`` never paused), and a hook that raises or is missing counts as
not STATIC and as content type ``'static'``.
"""
display_mode = None
get_mode = getattr(plugin, 'get_vegas_display_mode', None)
if get_mode is not None:
try:
display_mode = get_mode()
except Exception:
_vegas_logger.debug("get_vegas_display_mode() failed on %s; not pausing",
type(plugin).__name__, exc_info=True)
if display_mode == VegasDisplayMode.STATIC:
return 'pause'
content_type = 'static'
get_type = getattr(plugin, 'get_vegas_content_type', None)
if get_type is not None:
try:
content_type = get_type()
except Exception:
_vegas_logger.debug("get_vegas_content_type() failed on %s; treating as 'static'",
type(plugin).__name__, exc_info=True)
if content_type == 'none':
return 'exclude'
return 'scroll'
def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) -> str:
"""How Vegas mode treats ``plugin``: ``'scroll'``, ``'pause'`` or ``'exclude'``.
What the core calls, rather than the plugin's own
get_vegas_participation(), so the user's setting wins even over a plugin
that overrides that method, and so a plugin that is not a BasePlugin (or a
test double) still gets the legacy derivation:
1. the user's ``vegas_participation`` in the plugin's config;
2. the plugin's get_vegas_participation(), when it returns a valid value
(BasePlugin's reads the manifest's ``vegas_participation``, then
derives one from the legacy hooks);
3. legacy_vegas_participation().
Also where the deprecated ``vegas_panel_count`` setting is reported, once
per plugin. Never raises.
"""
pid = plugin_id or getattr(plugin, 'plugin_id', None) or type(plugin).__name__
config = getattr(plugin, 'config', None)
if isinstance(config, dict) and 'vegas_panel_count' in config:
warn_deprecated(
f"The vegas_panel_count setting (plugin '{pid}')", VEGAS_LEGACY_REMOVAL,
"it has no effect -- use vegas_width_pct to size the plugin's card",
once_key=f"vegas_panel_count:{pid}")
configured = configured_vegas_participation(pid, config)
if configured is not None:
return configured
getter = getattr(plugin, 'get_vegas_participation', None)
if callable(getter):
try:
declared = getter()
except Exception:
_vegas_logger.exception("[%s] get_vegas_participation() failed; "
"using its legacy Vegas hooks", pid)
declared = None
value = vegas_participation_value(declared)
if value is not None:
return value
if isinstance(declared, str):
_vegas_warn_once(
('declared', pid, declared),
"[%s] get_vegas_participation() returned %r, expected one of %s; "
"using its legacy Vegas hooks",
pid, declared, ', '.join(VEGAS_PARTICIPATION_VALUES))
return legacy_vegas_participation(plugin)
class BasePlugin(ABC):
"""
Base class that all plugins must inherit from.
@@ -834,41 +988,179 @@ class BasePlugin(ABC):
"""
return None
def get_vegas_elements(self) -> Optional[List[Any]]:
"""
Vegas content as live elements: named, fixed-width pieces the ticker
can swap in place while they are on screen.
get_vegas_content() hands the ticker pictures, and a picture already
in the scrolling strip keeps what it showed when it was drawn. Return
a list of ``VegasElement`` (src/plugin_system/vegas_elements.py)
instead and the ticker records where each one is; after your update()
it calls this again, compares each element's ``version`` (or pixels)
with what the strip holds, and swaps the changed ones in between two
frames -- a score changes on a card already crossing the panel, and
nothing next to it moves.
The contract:
- Called only on the ticker's background thread, under this plugin's
lock (never while update() runs), on a canvas of its own and told
its width (get_vegas_render_width()), like get_vegas_content().
- Called often -- after every update() while any of your elements is
on or ahead of the screen -- so it must be cheap when nothing
changed (cache images by version), idempotent, and must not fetch.
- A live element's width must not depend on its data: a redraw at a
different width is not swapped in (it shows the next time the
plugin comes round).
- Keys must be unique in the list and stable for the same logical
item.
Return None (the default) to use get_vegas_content(). A core older
than 3.8.0 never calls this, so keep get_vegas_content() working and
floor ``ledmatrix_min_version`` at 3.8.0 only if you rely on it.
Example (scoreboard)::
def get_vegas_elements(self):
return [VegasElement(key=f"game:{g['id']}",
image=self._card_for(g), # cached by fingerprint
version=self._fingerprint(g))
for g in self.games]
Returns:
A list of VegasElement, or None.
"""
return None
def redraw_vegas_element(self, key: str, width: int, height: int,
at: float) -> Optional[Any]:
"""
Redraw one live element for a moment in time, without the plugin lock.
Only for elements returned with ``refresh_hz > 0``: content that
changes with time rather than with data, such as an aircraft moving
between position reports. The ticker calls it up to that often while
the element is on or near the screen.
- Called WITHOUT this plugin's lock, possibly while update() runs, so
read only state that update() replaces in a single assignment (an
immutable snapshot), never state it mutates in place.
- ``at`` is the time.monotonic() at which the pixels are expected to
reach the panel; draw the element as it should look then.
- Return an image of exactly ``width`` x ``height``, or None to skip
this tick.
Returns:
PIL Image of exactly (width, height), or None.
"""
return None
def notify_vegas_data_changed(self) -> None:
"""
Tell the Vegas ticker this plugin's data changed outside update().
The ticker redraws a plugin's live elements when its update()
completes. Data that lands some other way -- a background thread, a
push callback -- calls this so the change reaches the screen without
waiting for the next update(). Cheap and safe from any thread.
"""
notify = getattr(getattr(self, 'plugin_manager', None),
'notify_data_changed', None)
if callable(notify):
notify(self.plugin_id)
def get_vegas_participation(self) -> str:
"""
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
``'exclude'``.
- ``'scroll'``: the plugin's content (get_vegas_content()) joins the
scrolling strip.
- ``'pause'``: the scroll stops when the plugin's turn comes round, and
its display() draws it full screen for get_display_duration().
- ``'exclude'``: the plugin is left out of Vegas mode.
Resolved in this order:
1. the user's ``vegas_participation`` setting in this plugin's config
(the web UI's per-plugin override);
2. ``vegas_participation`` in the plugin's manifest.json -- the way a
plugin declares its own default;
3. derived from the legacy hooks, so a plugin written before this
method existed keeps the behaviour it had: get_vegas_display_mode()
returning ``VegasDisplayMode.STATIC`` pauses, otherwise
get_vegas_content_type() returning ``'none'`` excludes, and
everything else scrolls.
Declare a fixed participation in the manifest rather than overriding
this. Override it only when the answer depends on state -- pause only
while an alert is live, exclude while there is nothing to show. Vegas
applies the user's setting before calling an override, so an override
need not check it.
Returns:
One of VEGAS_PARTICIPATION_VALUES.
Example:
def get_vegas_participation(self):
return 'pause' if self._alert_is_live() else 'scroll'
"""
configured = configured_vegas_participation(self.plugin_id, self.config)
if configured is not None:
return configured
manifest_default = self._manifest_vegas_participation()
if manifest_default is not None:
return manifest_default
return legacy_vegas_participation(self)
def _manifest_vegas_participation(self) -> Optional[str]:
"""``vegas_participation`` from this plugin's manifest, if valid."""
manifests = getattr(self.plugin_manager, 'plugin_manifests', None)
manifest = manifests.get(self.plugin_id) if isinstance(manifests, dict) else None
if not isinstance(manifest, dict) or manifest.get('vegas_participation') is None:
return None
raw = manifest['vegas_participation']
value = vegas_participation_value(raw)
if value is None:
_vegas_warn_once(
('manifest', self.plugin_id, repr(raw)),
"[%s] manifest vegas_participation %r is not one of %s; ignoring it",
self.plugin_id, raw, ', '.join(VEGAS_PARTICIPATION_VALUES))
return value
def get_vegas_content_type(self) -> str:
"""
Indicate the type of content this plugin provides for Vegas scroll.
Legacy: the type of content this plugin provides for Vegas scroll.
Override this to specify how Vegas mode should treat this plugin's content.
Superseded by get_vegas_participation(). Vegas reads it only to derive
a participation when neither the user nor the manifest declares one,
and only ``'none'`` matters there: it excludes the plugin (unless
get_vegas_display_mode() says STATIC). Every other value scrolls.
Returns:
'multi' - Plugin has multiple scrollable items (sports, odds, news)
'static' - Plugin is a static block (clock, weather, music)
'none' - Plugin should not appear in Vegas scroll mode
Example:
def get_vegas_content_type(self):
return 'multi' # We have multiple games to scroll
"""
return 'static'
def get_vegas_display_mode(self) -> VegasDisplayMode:
"""
Get the display mode for Vegas scroll integration.
Legacy: the display mode for Vegas scroll integration.
This method determines how the plugin's content behaves within Vegas mode:
- SCROLL: Content scrolls continuously (multi-item plugins)
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
- STATIC: Pause scroll to display (alerts, detailed views)
Superseded by get_vegas_participation(). Vegas reads it only to derive
a participation when neither the user nor the manifest declares one,
and only STATIC matters there: it pauses the scroll for the plugin's
turn. SCROLL and FIXED_SEGMENT both scroll -- Vegas has never told them
apart, and the distinction is deprecated (removed in 3.9.0).
Override to change default behavior. By default, reads from config
or maps legacy get_vegas_content_type() for backward compatibility.
Reads the plugin's ``vegas_mode`` config value, else maps
get_vegas_content_type() ('multi' to SCROLL, anything else to
FIXED_SEGMENT).
Returns:
VegasDisplayMode enum value
Example:
def get_vegas_display_mode(self):
return VegasDisplayMode.SCROLL
"""
# Check for explicit config setting first
config_mode = self.config.get("vegas_mode")
@@ -888,13 +1180,16 @@ class BasePlugin(ABC):
return VegasDisplayMode.SCROLL
return VegasDisplayMode.FIXED_SEGMENT
@deprecated("3.9.0",
"nothing reads it -- declare vegas_participation in the manifest instead")
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
"""
Return list of Vegas display modes this plugin supports.
Deprecated: the Vegas display modes this plugin supports.
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.
Never consulted by core -- neither Vegas mode nor the web UI calls it
-- and removed in LEDMatrix 3.9.0. A plugin's own override keeps
working for the plugin itself; calling this base implementation logs a
deprecation warning.
By default:
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
@@ -903,11 +1198,6 @@ class BasePlugin(ABC):
Returns:
List of VegasDisplayMode values this plugin can use
Example:
def get_supported_vegas_modes(self):
# This plugin only makes sense as a scrolling ticker
return [VegasDisplayMode.SCROLL]
"""
content_type = self.get_vegas_content_type()
@@ -918,30 +1208,21 @@ class BasePlugin(ABC):
else: # 'static'
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]
@deprecated("3.9.0",
"nothing reads it -- Vegas sizes a card from vegas_width_pct "
"(see get_vegas_render_width())")
def get_vegas_segment_width(self) -> Optional[int]:
"""
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
Deprecated: the number of panels this plugin wanted as a FIXED_SEGMENT.
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
Where single_panel_width comes from display.hardware.cols in config.
Override to provide dynamic sizing based on content.
Returns None to use the default (1 panel).
Never consulted by core: Vegas sizes a card from the
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings (see
get_vegas_render_width()). Removed, with the ``vegas_panel_count``
setting it reads, in LEDMatrix 3.9.0.
Returns:
Number of panels, or None for default (1 panel)
Example:
def get_vegas_segment_width(self):
# Clock needs 2 panels to show time clearly
return 2
``vegas_panel_count`` from config when it is a positive integer,
else None
"""
raw_value = self.config.get("vegas_panel_count", None)
if raw_value is None:
+740
View File
@@ -0,0 +1,740 @@
"""
One field model for a plugin's config form, built from its schema and config.
Today a plugin's settings form is drawn by the ``render_field`` macro in
``web_interface/templates/v3/partials/plugin_config.html``: about 1,100 lines
of Jinja that walk the schema, pick a control per property (or hand it to a
JS widget through an inline ``<script>``) and post flat dotted form keys that
the server rebuilds into JSON. The JS widgets duplicate much of that, and the
two drift.
:func:`build_field_model` walks the schema once, the way the macro does, and
returns a plain, JSON-serialisable tree describing every field: its dotted
path, label, help, widget, starting value, default, constraints and options,
and -- so that the model is provably complete before anything renders from
it -- the exact form controls the macro emits for it today (``inputs``) and
the JS widget it mounts (``mount``). ``test/test_field_model_parity.py``
renders the real macro for every plugin schema it can find and checks that
the names and starting values of those controls match the model exactly.
Nothing renders from this yet. The plan (docs/WEB_FRONTEND_ARCHITECTURE.md):
one ES-module renderer walks this model and mounts every field through the
widget registry, the form posts JSON to the existing JSON save path, and the
macro and the dotted-key reconstruction retire.
The model mirrors the macro's behaviour, quirks included (an enum check runs
before a number's, a list-typed ``type`` uses its first entry, a number whose
default is ``null`` renders the text ``None``), because parity is the point of
this stage. Fixing those is a renderer change for later, made once in one
place.
Shape (all keys always present unless noted)::
{
"version": 1,
"plugin_id": "...",
"rendered_sections": ["key", ...], # the __rendered_section hidden inputs
"fields": [Field, ...], # basic tier, in form order
"advanced_fields": [Field, ...], # flat "x-advanced": true fields
"schemaless": false, # true: no schema, fields from config
}
Field = {
"key", "path", "id", "label", "help",
"type": the macro's field type (first entry of a list type),
"widget": what draws it: checkbox, select, number, text, csv-text,
section, schedule-picker, time-range, style-editor,
toggle-switch, slider, number-input, file-upload,
checkbox-group, google-calendar-picker, day-selector,
custom-feeds, array-table, color-picker, json-file-manager,
any string widget the macro mounts (text-input, ...), or a
plugin-supplied x-widget,
"x_widget": the schema's x-widget, or None,
"value": the value the form starts with,
"default": the schema default (key absent when the schema has none),
"secret": true for "x-secret" fields,
"advanced": true in the Advanced Settings section,
"constraints": {minimum, maximum, ...} as declared,
"options": [{"value", "label"}] for selects and checkbox groups,
"inputs": [Input, ...] form controls the server renders,
"mount": {"widget", "name", "value", "config", "plugin_widget"} or None,
"children": [Field, ...] for sections (and a style-editor's fallback),
"columns" / "rows" / "max_items" for array-table and custom-feeds,
"stale_values" for checkbox groups, "error" for a mis-declared widget,
}
Input = {"name", "control", "value", "encoding"[, "checked"][, "options"]}
control: hidden | text | number | url | date | time | checkbox | select
encoding: how ``value`` is written into the HTML today --
text str(value) json JSON text
csv ", ".join(str(item)) bool "true"/"false"
For a checkbox, ``value`` is its value attribute and
``checked`` its state; for a select, ``value`` is the option
the browser submits and ``options`` the option values.
Secret fields: pass the config *after* ``mask_secret_fields`` (as the route
does); the model copies values verbatim.
"""
from __future__ import annotations
import re
from typing import Any, Dict, Iterator, List, Optional, Tuple
FIELD_MODEL_VERSION = 1
#: String x-widgets the macro mounts as JS widgets (plugin_config.html's
#: ``str_widget in [...]`` list). Any other x-widget on a string is a
#: plugin-supplied widget, loaded through ensureWidget over a text fallback.
STRING_WIDGETS = (
'text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group',
'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input',
'url-input', 'password-input', 'font-selector', 'file-upload-single',
'plugin-file-manager', 'google-oauth',
)
_CONSTRAINT_KEYS = (
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems',
'uniqueItems',
)
_MISSING = object()
# ── Jinja semantics the macro relies on ─────────────────────────────────────
def _is_string(value: Any) -> bool:
return isinstance(value, str)
def _is_iterable(value: Any) -> bool:
"""Jinja's ``is iterable``: anything ``iter()`` accepts (dicts included)."""
try:
iter(value)
except TypeError:
return False
return True
def _is_list_like(value: Any) -> bool:
"""``value is iterable and value is not string``."""
return value is not None and _is_iterable(value) and not _is_string(value)
_WORD_SPLIT = re.compile(r'([-\s({\[<]+)')
def _title(text: Any) -> str:
"""Jinja's ``title`` filter (not str.title: it keeps "2xl" lower case)."""
return ''.join(
item[0].upper() + item[1:].lower()
for item in _WORD_SPLIT.split(str(text)) if item)
def _humanise(key: Any) -> str:
"""``key|replace('_', ' ')|title``."""
return _title(str(key).replace('_', ' '))
def _x_options(prop: Dict[str, Any]) -> Dict[str, Any]:
return prop.get('x-options') or prop.get('x_options') or {}
def _x_widget(prop: Dict[str, Any]) -> Optional[str]:
return prop.get('x-widget') or prop.get('x_widget')
def is_hidden(prop: Any) -> bool:
"""The macro's ``prop_is_hidden``: "x-display": "hidden", or an object
whose every child is hidden."""
if not isinstance(prop, dict):
return False
if prop.get('x-display') == 'hidden':
return True
children = prop.get('properties')
if isinstance(children, dict) and children:
return all(is_hidden(child) for child in children.values())
return False
def field_type(prop: Dict[str, Any]) -> Any:
"""The macro's field type: a string ``type``, the first entry of a list
``type`` (so ``["null", "integer"]`` is ``"null"``), else ``"string"``.
Usually a string; a malformed list type hands back whatever its first
entry is, as the macro does."""
declared = prop.get('type')
if _is_string(declared):
return declared
if declared and _is_list_like(declared):
return next(iter(declared))
return 'string'
def _column_type(col_def: Dict[str, Any]) -> Any:
"""array-table's column type: the first non-"null" entry of a list type."""
raw = col_def.get('type', 'string')
if _is_list_like(raw):
rest = [entry for entry in raw if entry != 'null']
return rest[0] if rest and rest[0] else 'string'
return raw or 'string'
def _array_value(value: Any, prop: Dict[str, Any]) -> Any:
"""The value most array widgets draw: the stored list, else a list
default, else []."""
if _is_list_like(value):
return value
default = prop.get('default', _MISSING)
if default is not _MISSING and _is_list_like(default):
return default
return []
def _constraints(prop: Dict[str, Any]) -> Dict[str, Any]:
return {key: prop[key] for key in _CONSTRAINT_KEYS if key in prop}
def _input(name: str, control: str, value: Any, encoding: str = 'text',
**extra: Any) -> Dict[str, Any]:
item = {'name': name, 'control': control, 'value': value, 'encoding': encoding}
item.update(extra)
return item
def _select_value(options: List[Any], matches: List[Any]) -> Any:
"""What a single <select> submits: the last selected option, else the first."""
if matches:
return matches[-1]
return options[0] if options else None
# ── fields ──────────────────────────────────────────────────────────────────
def _base_node(key: str, prop: Dict[str, Any], value: Any, full_key: str,
plugin_id: str) -> Dict[str, Any]:
node: Dict[str, Any] = {
'key': key,
'path': full_key,
'id': f"{plugin_id}-{full_key}".replace('.', '-').replace('_', '-'),
'label': prop.get('title') or _humanise(key),
'help': prop.get('description') or '',
'type': field_type(prop),
'widget': None,
'x_widget': _x_widget(prop),
'value': value,
'secret': bool(prop.get('x-secret')),
'advanced': False,
'constraints': _constraints(prop),
'options': [],
'inputs': [],
'mount': None,
'children': [],
}
if 'default' in prop:
node['default'] = prop['default']
return node
def _mount(widget: str, name: Optional[str], value: Any,
config: Optional[Dict[str, Any]] = None,
plugin_widget: bool = False) -> Dict[str, Any]:
return {'widget': widget, 'name': name, 'value': value,
'config': config or {}, 'plugin_widget': plugin_widget}
def _build_field(key: str, prop: Any, value: Any, prefix: str,
plugin_id: str) -> Optional[Dict[str, Any]]:
"""``render_field``: one property, or None when the macro draws nothing."""
if not isinstance(prop, dict) or is_hidden(prop):
return None
# A key the saved config doesn't have renders its schema default.
if value is None and 'default' in prop:
value = prop['default']
full_key = f"{prefix}.{key}" if prefix else key
node = _base_node(key, prop, value, full_key, plugin_id)
ftype = node['type']
if ftype == 'object':
return _object_field(node, key, prop, value, prefix, full_key, plugin_id)
if ftype == 'boolean':
_boolean_field(node, prop, value, full_key)
elif prop.get('enum'):
_enum_field(node, prop, value, full_key)
elif ftype in ('number', 'integer'):
_number_field(node, prop, value, full_key, ftype)
elif ftype == 'array':
_array_field(node, prop, value, full_key, plugin_id)
else:
_string_field(node, prop, value, full_key, ftype)
return node
def _object_field(node: Dict[str, Any], key: str, prop: Dict[str, Any], value: Any,
prefix: str, full_key: str, plugin_id: str) -> Optional[Dict[str, Any]]:
widget = _x_widget(prop)
obj_value = value if value is not None else {}
if widget in ('schedule-picker', 'time-range'):
node['widget'] = widget
node['value'] = obj_value
node['inputs'].append(_input(full_key, 'hidden', obj_value, 'json'))
node['mount'] = _mount(widget, None, obj_value, {'x-options': _x_options(prop)})
return node
if widget == 'style-editor':
# The widget renders its own inputs under full_key; until it loads,
# and wherever it declines a block, the nested section is the form.
node['widget'] = 'style-editor'
node['value'] = obj_value
node['mount'] = _mount('style-editor', full_key, obj_value, {'schema': prop})
node['children'] = [_section(key, prop, value, prefix, plugin_id)]
return node
if prop.get('properties'):
return _section(key, prop, value, prefix, plugin_id)
return None # an object with no properties and no widget draws nothing
def _section(key: str, prop: Dict[str, Any], value: Any, prefix: str,
plugin_id: str) -> Dict[str, Any]:
"""``render_nested_section``: a collapsible block of child fields."""
full_key = f"{prefix}.{key}" if prefix else key
# Only a dict can be looked into; a legacy boolean is the block's
# `enabled` switch (schema_manager.legacy_bool_as_object).
properties = prop.get('properties') or {}
if isinstance(value, dict):
nested_value = value
elif isinstance(value, bool) and 'enabled' in properties:
nested_value = {'enabled': value}
else:
nested_value = {}
node = _base_node(key, prop, nested_value, full_key, plugin_id)
node['widget'] = 'section'
node['id'] = f"{plugin_id}-section-{full_key}".replace('.', '-').replace('_', '-')
order = prop['x-propertyOrder'] if 'x-propertyOrder' in prop else list(properties.keys())
for nested_key in order:
if nested_key in properties and not is_hidden(properties[nested_key]):
child = _build_field(nested_key, properties[nested_key],
nested_value[nested_key] if nested_key in nested_value else None,
full_key, plugin_id)
if child is not None:
node['children'].append(child)
return node
def _boolean_field(node, prop, value, full_key):
if _x_widget(prop) == 'toggle-switch':
node['widget'] = 'toggle-switch'
node['mount'] = _mount('toggle-switch', full_key,
value if value is not None else False,
{'type': 'boolean', 'x-options': _x_options(prop)})
else:
node['widget'] = 'checkbox'
node['inputs'].append(_input(full_key, 'checkbox', 'true', checked=bool(value)))
def _enum_field(node, prop, value, full_key):
options = list(prop['enum'])
labels = _x_options(prop).get('labels') or {}
node['widget'] = 'select'
node['options'] = [{'value': option,
'label': labels.get(option, _humanise(option))
if _hashable(option) else _humanise(option)}
for option in options]
posted = _select_value(options, [option for option in options if value == option])
node['inputs'].append(_input(full_key, 'select', posted, options=options))
def _hashable(value: Any) -> bool:
try:
hash(value)
except TypeError:
return False
return True
def _number_field(node, prop, value, full_key, ftype):
widget = _x_widget(prop)
if widget in ('slider', 'number-input'):
node['widget'] = widget
node['mount'] = _mount(widget, full_key, value, {
'type': ftype,
'minimum': prop.get('minimum'),
'maximum': prop.get('maximum'),
'x-options': _x_options(prop),
})
else:
node['widget'] = 'number'
node['inputs'].append(_input(full_key, 'number', _text_value(value, prop)))
def _text_value(value: Any, prop: Dict[str, Any]) -> Any:
"""``value if value is not none else (prop.default if defined else '')``."""
if value is not None:
return value
return prop['default'] if 'default' in prop else ''
def _array_field(node, prop, value, full_key, plugin_id):
items = prop.get('items') or {}
widget = _x_widget(prop) or (
'array-table' if (items.get('type') == 'object' and items.get('properties')) else None)
if widget == 'file-upload':
upload = prop.get('x-upload-config') or {}
images = _array_value(value, prop)
node.update(widget='file-upload', value=images)
node['constraints'].update({
'max_files': upload.get('max_files', 10),
'allowed_types': upload.get('allowed_types',
['image/png', 'image/jpeg', 'image/bmp', 'image/gif']),
'max_size_mb': upload.get('max_size_mb', 5),
'plugin_id': upload.get('plugin_id', plugin_id),
'endpoint': upload.get('endpoint', '/api/v3/plugins/assets/upload'),
'file_type': upload.get('file_type', 'image'),
})
node['inputs'].append(_input(full_key, 'hidden', images, 'json'))
elif widget == 'checkbox-group':
_checkbox_group(node, prop, value, full_key)
elif widget == 'google-calendar-picker':
selected = _calendar_value(value, prop)
node.update(widget=widget, value=selected)
node['mount'] = _mount(widget, full_key, selected, {})
elif widget == 'day-selector':
days = _array_value(value, prop)
node.update(widget=widget, value=days)
node['mount'] = _mount(widget, full_key, days, {'x-options': _x_options(prop)})
elif widget == 'custom-feeds':
_custom_feeds(node, prop, value, full_key, items)
elif widget == 'array-table':
_array_table(node, prop, value, full_key, items)
elif widget == 'color-picker':
_color_picker(node, prop, value, full_key)
else:
# The comma-separated text input; any other x-widget lands here too.
default = prop.get('default', _MISSING)
values = value if value is not None else ([] if default is _MISSING else default)
node.update(widget='csv-text', value=values)
node['inputs'].append(_input(full_key, 'text',
values if _is_list_like(values) else '',
'csv' if _is_list_like(values) else 'text'))
def _checkbox_group(node, prop, value, full_key):
selected = _array_value(value, prop)
items = prop.get('items') or {}
options = items.get('enum') or []
labels = (prop.get('x-options') or {}).get('labels') or {}
# A saved value that is no longer an option is dropped (and reported), so
# the save does not fail validation on a value nobody can see.
stale = [v for v in selected if v not in options] if options else []
if options:
selected = [v for v in selected if v in options]
node.update(widget='checkbox-group', value=selected, stale_values=stale)
node['options'] = [{'value': option,
'label': labels.get(option, _humanise(option))
if _hashable(option) else _humanise(option)}
for option in options]
for option in options:
node['inputs'].append(_input(f"{full_key}[]", 'checkbox', option,
checked=option in selected))
node['inputs'].append(_input(f"{full_key}_data", 'hidden', selected, 'json'))
# Sentinel: posts the field even when every box is unchecked.
node['inputs'].append(_input(f"{full_key}[]", 'hidden', ''))
def _calendar_value(value: Any, prop: Dict[str, Any]) -> Any:
"""google-calendar-picker accepts a legacy comma-separated string."""
if value is not None and _is_string(value) and value:
return [part.strip() for part in value.split(',')]
if _is_list_like(value):
return value
default = prop.get('default', _MISSING)
if default is not _MISSING and _is_string(default) and default:
return [part.strip() for part in default.split(',')]
if default is not _MISSING and _is_list_like(default):
return default
return []
def _custom_feeds(node, prop, value, full_key, items):
node['widget'] = 'custom-feeds'
item_properties = items.get('properties', {})
if not (item_properties.get('name') and item_properties.get('url')):
node['error'] = "Custom feeds widget requires 'name' and 'url' properties in items schema."
return
feeds = _array_value(value, prop)
node.update(value=feeds, rows=feeds, max_items=prop.get('maxItems', 50))
for index, item in enumerate(feeds):
base = f"{full_key}.{index}"
node['inputs'].append(_input(f"{base}.name", 'text', item.get('name', '')))
node['inputs'].append(_input(f"{base}.url", 'url', item.get('url', '')))
logo = item.get('logo') or {}
logo_path = logo.get('path', '')
if logo_path:
node['inputs'].append(_input(f"{base}.logo.path", 'hidden', logo_path))
if logo.get('id'):
node['inputs'].append(_input(f"{base}.logo.id", 'hidden', logo.get('id')))
enabled = bool(item.get('enabled', True))
node['inputs'].append(_input(f"{base}.enabled", 'hidden', enabled, 'bool'))
node['inputs'].append(_input(f"{base}.enabled", 'checkbox', 'true', checked=enabled))
def _table_columns(prop: Dict[str, Any], item_properties: Dict[str, Any]) -> List[str]:
"""x-columns minus hidden ones, else the first four simple properties."""
x_columns = prop.get('x-columns')
if x_columns:
return [name for name in x_columns if not is_hidden(item_properties.get(name))]
columns: List[str] = []
for name, col_def in item_properties.items():
if (col_def.get('type') not in ['object', 'array'] and len(columns) < 4
and not is_hidden(col_def)):
columns.append(name)
return columns
def _array_table(node, prop, value, full_key, items):
item_properties = items.get('properties', {})
rows = _array_value(value, prop)
columns = _table_columns(prop, item_properties)
advanced = {k: v for k, v in item_properties.items()
if k not in columns and k != 'id' and not is_hidden(v)}
node.update(widget='array-table', value=rows, rows=rows,
max_items=prop.get('maxItems', 50))
node['columns'] = [{
'key': name,
'label': (item_properties.get(name) or {}).get('title', _humanise(name)),
'type': _column_type(item_properties.get(name) or {}),
'x_widget': ((item_properties.get(name) or {}).get('x-widget')
or (item_properties.get(name) or {}).get('x_widget', '')),
} for name in columns]
node['advanced_columns'] = list(advanced)
for index, item in enumerate(rows):
base = f"{full_key}.{index}"
for name in columns:
node['inputs'].extend(_table_cell(f"{base}.{name}", item_properties.get(name, {}),
item.get(name, item_properties.get(name, {}).get('default', ''))))
# Hidden item properties have no control, but a posted row replaces
# the stored item wholesale, so their stored values are carried.
for k, v in item_properties.items():
if is_hidden(v) and k in item and item[k] is not None:
node['inputs'].append(_input(f"{base}.{k}", 'hidden', item[k], 'json'))
if advanced:
node['inputs'].extend(_advanced_cells(base, advanced, item))
def _table_cell(name: str, col_def: Dict[str, Any], col_value: Any) -> List[Dict[str, Any]]:
col_type = _column_type(col_def)
col_widget = col_def.get('x-widget') or col_def.get('x_widget', '')
col_enum = col_def.get('enum', [])
if col_type == 'boolean':
return [_input(name, 'hidden', bool(col_value), 'bool'),
_input(name, 'checkbox', 'true', checked=bool(col_value))]
if col_type in ('integer', 'number'):
return [_input(name, 'number', col_value if col_value is not None else '')]
if col_enum:
options = [opt for opt in col_enum if opt is not None]
matches = [opt for opt in options
if col_value == opt or (col_value is None and col_def.get('default') == opt)]
return [_input(name, 'select', _select_value(options, matches), options=options)]
if col_widget == 'date-picker':
return [_input(name, 'date', col_value if col_value is not None else '')]
if col_widget == 'time-picker':
return [_input(name, 'time', col_value if col_value is not None else '00:00')]
return [_input(name, 'text', col_value if col_value is not None else '')]
def _advanced_cells(base: str, advanced: Dict[str, Any], item: Dict[str, Any]) -> List[Dict[str, Any]]:
"""The row's hidden inputs for properties edited in the row editor."""
cells: List[Dict[str, Any]] = []
for prop_name, prop_schema in advanced.items():
if prop_schema.get('type', 'string') == 'object' and prop_schema.get('properties'):
stored = item.get(prop_name)
sub_obj = stored if isinstance(stored, dict) else {}
container = item.get(prop_name, {})
for sub_name, sub_schema in prop_schema.get('properties', {}).items():
name = f"{base}.{prop_name}.{sub_name}"
if is_hidden(sub_schema):
if sub_name in sub_obj and sub_obj[sub_name] is not None:
cells.append(_input(name, 'hidden', sub_obj[sub_name], 'json'))
continue
sub_val = container.get(sub_name) if isinstance(container, dict) else None
final = sub_val if sub_val is not None else sub_schema.get('default')
cells.append(_input(name, 'hidden', final if final is not None else ''))
else:
stored = item.get(prop_name)
final = stored if stored is not None else prop_schema.get('default')
cells.append(_input(f"{base}.{prop_name}", 'hidden', final if final is not None else ''))
return cells
def _color_picker(node, prop, value, full_key):
default = prop.get('default', _MISSING)
if _is_list_like(value):
rgb = value
elif default is not _MISSING and _is_list_like(default):
rgb = default
else:
rgb = [255, 255, 255]
channels = [rgb[i] if len(rgb) > i else 255 for i in range(3)]
node.update(widget='color-picker', value=rgb)
for index, channel in enumerate(channels):
node['inputs'].append(_input(f"{full_key}.{index}", 'number', channel))
def _string_field(node, prop, value, full_key, ftype):
widget = _x_widget(prop)
text = _text_value(value, prop)
node['value'] = text
if widget == 'file-upload':
upload = prop.get('x-upload-config') or {}
node['widget'] = 'file-upload'
node['constraints'].update({
'upload_endpoint': upload.get('upload_endpoint', ''),
'target_filename': upload.get('target_filename', 'file.json'),
'max_size_mb': upload.get('max_size_mb', 1),
'allowed_extensions': upload.get('allowed_extensions', ['.json']),
})
node['inputs'].append(_input(full_key, 'hidden', text))
elif widget == 'json-file-manager':
# An iframe of the plugin's own file manager; it saves on its own.
node['widget'] = 'json-file-manager'
elif widget in STRING_WIDGETS:
node['widget'] = widget
node['mount'] = _mount(widget, full_key, text, {
'type': ftype,
'enum': prop.get('enum') or [],
'minimum': prop.get('minimum'),
'maximum': prop.get('maximum'),
'x-options': _x_options(prop),
'x-upload-config': prop.get('x-upload-config') or prop.get('x_upload_config') or {},
'x-widget-config': prop.get('x-widget-config') or prop.get('x_widget_config') or {},
})
else:
node['widget'] = widget or 'text'
node['inputs'].append(_input(full_key, 'text', text))
if widget:
# A plugin-supplied widget (manifest "widgets"); the text input
# stays as the fallback until it renders.
node['mount'] = _mount(widget, full_key, text, {
'type': ftype,
'enum': prop.get('enum') or [],
'x-options': _x_options(prop),
'x-widget-config': prop.get('x-widget-config') or prop.get('x_widget_config') or {},
}, plugin_widget=True)
# ── the form ────────────────────────────────────────────────────────────────
def _schemaless_field(key: str, value: Any) -> Dict[str, Any]:
"""A plugin with no schema: one plain control per stored key."""
node = _base_node(key, {}, value, key, '')
node['id'] = 'fallback-field-' + str(key).replace(' ', '-')
if value is True or value is False:
node['widget'] = 'checkbox'
node['type'] = 'boolean'
# No value attribute, so a checked box posts "on".
node['inputs'].append(_input(key, 'checkbox', 'on', checked=bool(value)))
elif isinstance(value, (int, float, complex)):
node['widget'] = 'number'
node['type'] = 'number'
node['inputs'].append(_input(key, 'number', value))
else:
node['widget'] = 'text'
node['inputs'].append(_input(key, 'text', value))
return node
def build_field_model(schema: Any, config: Any, plugin_id: str = '') -> Dict[str, Any]:
"""The field model for one plugin's config form.
``schema`` is the plugin's config schema as the route loads it
(``SchemaManager.load_schema``, so style elements are expanded);
``config`` is the plugin's section after defaults are merged and secrets
masked, exactly what ``plugin_config.html`` is rendered with.
"""
config = config if isinstance(config, dict) else {}
model: Dict[str, Any] = {
'version': FIELD_MODEL_VERSION,
'plugin_id': plugin_id,
'rendered_sections': [],
'fields': [],
'advanced_fields': [],
'schemaless': False,
}
properties = schema.get('properties') if isinstance(schema, dict) else None
if not properties:
model['schemaless'] = True
model['fields'] = [_schemaless_field(key, value)
for key, value in config.items() if key not in ['enabled']]
return model
order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else list(properties.keys())
basic: List[str] = []
advanced: List[str] = []
for key in order:
if key in properties and key != 'enabled' and not is_hidden(properties[key]):
prop = properties[key]
declared = prop.get('type') if isinstance(prop, dict) else None
is_object = declared is not None and _is_iterable(declared) and 'object' in declared
if isinstance(prop, dict) and prop.get('x-advanced') and not is_object:
advanced.append(key)
else:
basic.append(key)
model['rendered_sections'] = basic + advanced
for tier, keys in (('fields', basic), ('advanced_fields', advanced)):
for key in keys:
node = _build_field(key, properties[key], config[key] if key in config else None,
'', plugin_id)
if node is not None:
node['advanced'] = tier == 'advanced_fields'
model[tier].append(node)
return model
# ── walking the model ───────────────────────────────────────────────────────
def iter_fields(model: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
"""Every field node, depth first, in form order."""
def walk(nodes):
for node in nodes:
yield node
yield from walk(node.get('children') or [])
yield from walk(model.get('fields') or [])
yield from walk(model.get('advanced_fields') or [])
def form_inputs(model: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Every server-rendered form control, in document order, starting with
the ``__rendered_section`` hidden inputs."""
inputs = [_input('__rendered_section', 'hidden', key)
for key in model.get('rendered_sections') or []]
for node in iter_fields(model):
inputs.extend(node.get('inputs') or [])
return inputs
def widget_mounts(model: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Every JS widget the form mounts, in document order.
A style-editor's fallback section is drawn before the editor's own
script, so a node's children come before its own mount.
"""
mounts: List[Dict[str, Any]] = []
def walk(nodes):
for node in nodes:
walk(node.get('children') or [])
if node.get('mount'):
mounts.append(node['mount'])
walk(model.get('fields') or [])
walk(model.get('advanced_fields') or [])
return mounts
def field_names(model: Dict[str, Any]) -> List[Tuple[str, str]]:
"""(name, source) for every posted name: 'form' controls and named 'widget' mounts."""
names = [(item['name'], 'form') for item in form_inputs(model)]
names += [(mount['name'], 'widget') for mount in widget_mounts(model) if mount['name']]
return names
+258
View File
@@ -0,0 +1,258 @@
"""
Plugin catalog: what the web process knows about installed plugins.
The web interface and the display run as two processes. Only the display
imports plugin code and runs it; the web process reads plugins as files --
manifest, config schema, the plugin's section of config.json, the installed
version -- and never imports a plugin module, instantiates a plugin class or
calls a plugin lifecycle hook. This class is that read side.
It keeps the method names of the read-only part of :class:`PluginManager`
(``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``,
``get_plugin_directory``, ``get_plugin_display_modes``,
``find_plugin_for_mode``), so code that only ever read through a manager
reads through a catalog unchanged. It has nothing that runs a plugin: no
``load_plugin``, ``get_plugin`` or ``plugins``.
Runtime state -- whether the display has a plugin loaded, its health, its
errors -- is not here either. The display process publishes what it knows to
the shared cache (health and resource metrics, the current mode, the error
aggregator snapshot), and the web routes read those publications. What the
display does not publish (which plugins it has loaded, its plugin state
machine) the web cannot know, and reports as unknown.
See docs/ARCHITECTURE.md ("Web and display processes").
"""
import json
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional, Union, cast
from src.common.permission_utils import (
ensure_directory_permissions, get_plugin_dir_mode,
)
from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import (
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
)
PathLike = Union[str, Path]
class PluginCatalog:
"""Manifests, schemas, config and versions of the installed plugins.
Discovery is explicit and cheap to repeat: :meth:`discover_plugins`
rescans the plugins directory and replaces the manifest map, so an
uninstalled plugin disappears and a new one appears.
"""
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
schema_manager: Optional[Any] = None) -> None:
self.plugins_dir: Path = Path(plugins_dir)
self.config_manager = config_manager
self.schema_manager = schema_manager
self.logger = get_logger(__name__)
# Guards plugin_manifests/plugin_directories: request threads read
# them while another request (or startup reconciliation) rescans.
self._lock = threading.RLock()
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
self.plugin_directories: Dict[str, Path] = {}
self._skip_reported: set = set()
# The Plugin Store installs into this directory, so it has to exist.
# The display service logs its own error if it cannot use it; the web
# interface stays up either way.
try:
ensure_directory_permissions(self.plugins_dir, get_plugin_dir_mode())
except OSError as exc:
self.logger.warning("Could not create plugins directory %s: %s",
self.plugins_dir, exc)
# -- discovery --------------------------------------------------------
def discover_plugins(self) -> List[str]:
"""Rescan the plugins directory; return the discovered plugin ids.
The rules for what counts as a plugin and which directory wins for a
duplicated id are :class:`PluginDirectoryIndex`'s, the same ones the
display process loads by. Only the configured directory is scanned.
"""
index = PluginDirectoryIndex.scan(self.plugins_dir)
if index.error is not None:
self.logger.error("Error scanning plugins directory %s: %s",
self.plugins_dir, index.error)
for entry in index.entries:
if entry.status in (ManifestStatus.UNREADABLE, ManifestStatus.NOT_OBJECT,
ManifestStatus.NO_ID):
# The display logs these at load time; once per process is
# enough here, since discovery runs on page loads.
if entry.name not in self._skip_reported:
self._skip_reported.add(entry.name)
self.logger.info("Not listing %s: its manifest.json is unusable (%s)",
entry.name, entry.status)
plugins = index.plugins()
manifests = {pid: entry.manifest for pid, entry in plugins.items()}
directories = {pid: entry.path for pid, entry in plugins.items()}
with self._lock:
self.plugin_manifests.clear()
self.plugin_manifests.update(manifests)
self.plugin_directories.clear()
self.plugin_directories.update(directories)
return list(plugins)
def discovered_plugin_ids(self) -> set:
"""Snapshot of the discovered ids, taken under the lock."""
with self._lock:
return set(self.plugin_manifests)
# -- manifests --------------------------------------------------------
def get_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""A copy of the manifest discovery read for ``plugin_id``, or None."""
with self._lock:
manifest = self.plugin_manifests.get(plugin_id)
return dict(manifest) if manifest else None
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""The plugin's manifest, as a new dict -- metadata only.
Unlike ``PluginManager.get_plugin_info`` there are no ``loaded``,
``runtime_info`` or ``state`` keys: those described plugin instances
in this process, which no longer exist.
"""
return self.get_manifest(plugin_id)
def get_all_plugin_info(self) -> List[Dict[str, Any]]:
""":meth:`get_plugin_info` for every discovered plugin."""
with self._lock:
ids = list(self.plugin_manifests)
return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]
def read_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""The manifest as it is on disk now, not as discovery last saw it.
For reads that must reflect a change made since the last scan -- the
version just after an update, say. None when the plugin has no
directory or its manifest is missing, unreadable or not an object.
"""
plugin_dir = self.get_plugin_directory(plugin_id)
if plugin_dir is None:
return None
try:
with open(Path(plugin_dir) / 'manifest.json', 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (OSError, ValueError) as exc:
self.logger.debug("Could not read manifest for %s: %s", plugin_id, exc)
return None
return manifest if isinstance(manifest, dict) else None
def get_installed_version(self, plugin_id: str) -> str:
"""The installed version from the on-disk manifest, or ''."""
manifest = self.read_manifest(plugin_id) or {}
version = manifest.get('version', '')
return version if isinstance(version, str) else str(version)
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
"""Where ``plugin_id`` is installed, or None.
Same rules as ``PluginManager.get_plugin_directory``: the discovered
directory, else ``<id>`` then ``ledmatrix-<id>`` by name within the
plugins directory. An id that is not one plain path segment is
refused rather than joined onto the plugins directory.
"""
with self._lock:
if plugin_id in self.plugin_directories:
return str(self.plugin_directories[plugin_id])
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]:
"""The manifest's ``display_modes``, or [].
What the display actually rotates can differ: a plugin may compute
its modes at run time (``plugin.modes``). This is the declared list.
"""
with self._lock:
manifest = self.plugin_manifests.get(plugin_id)
modes = (manifest or {}).get('display_modes', [])
return list(modes) if isinstance(modes, list) else []
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
wanted = mode.strip().lower()
with self._lock:
manifests = dict(self.plugin_manifests)
for plugin_id, manifest in manifests.items():
modes = manifest.get('display_modes')
if isinstance(modes, list) and any(
isinstance(m, str) and m.lower() == wanted for m in modes):
return plugin_id
return None
# -- schema and config ------------------------------------------------
def get_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
"""The plugin's config schema through SchemaManager, or None."""
if self.schema_manager is None:
return None
schema = self.schema_manager.load_schema(plugin_id, use_cache=use_cache)
return cast(Optional[Dict[str, Any]], schema)
def get_config(self, plugin_id: str) -> Dict[str, Any]:
"""The plugin's section of config.json (secrets merged), or {}."""
if self.config_manager is None:
return {}
section = (self.config_manager.load_config() or {}).get(plugin_id)
return section if isinstance(section, dict) else {}
def is_enabled(self, plugin_id: str) -> bool:
"""Whether config.json enables the plugin, by the display's rule.
The display loads a plugin only when its section says
``"enabled": true``; a missing flag or section means disabled
(``DisplayController._reconcile_enabled_plugins``).
"""
return bool(self.get_config(plugin_id).get('enabled', False))
def display_restart_required(action: str, plugin_enabled: bool, *,
changed: bool = True,
preserve_config: bool = False) -> bool:
"""Whether a store operation needs a display restart to reach the panel.
The display loads and unloads plugins live only through its config
watcher: when a plugin's ``enabled`` flag changes it reconciles the
running set (``DisplayController._reconcile_enabled_plugins``), and
loading reads the plugin fresh from disk. Nothing makes it reload a
plugin it is already running, and nothing tells it about files changing
under a plugin whose flag did not move. So:
- ``install``: a plugin that is not enabled needs nothing -- enabling it
later loads it. One already enabled in config (a reinstall, or a
config carried over) is not picked up until a restart.
- ``update``: the display keeps running the code it loaded until it
restarts, if it runs the plugin at all -- only when it is enabled.
``changed=False`` (already up to date) needs nothing. The update route
first asks the display to reload it over the control socket
(``_reload_after_store_update``); this answer stands when it cannot.
- ``uninstall``: removing the plugin's config section flips its enabled
flag, and the reconcile unloads it. With ``preserve_config`` the flag
stays, and an enabled plugin keeps running until a restart.
``plugin_enabled`` is the config flag as it was before the operation.
"""
if not plugin_enabled:
return False
if action == 'install':
return True
if action == 'update':
return changed
if action == 'uninstall':
return preserve_config
raise ValueError(f"unknown store action: {action!r}")
+54 -12
View File
@@ -10,6 +10,7 @@ from typing import Any, Dict, Optional, Callable
from threading import Thread
import logging
from src.common.fetch_service import plugin_scope
from src.exceptions import PluginError
from src.logging_config import get_logger
from src.error_aggregator import record_error
@@ -19,8 +20,25 @@ class PluginTimeoutError(Exception):
"""Raised when a plugin operation times out."""
class PluginBusyError(PluginTimeoutError):
"""A plugin's lock stayed held past its bound.
Not raised; recorded. The lock is held by the plugin's own display(),
update(), on_config_change() or a Vegas content render -- slow, or hung
-- so the caller skipped the plugin rather than wait on it. Report-only:
it is kept as the plugin's state error info and counted as a busy skip in
health, never as a failure, so it cannot open the circuit breaker.
"""
class PluginExecutor:
"""Handles plugin execution with timeout and error isolation."""
#: A display() call at least this long is logged and counted as slow.
#: A frame is milliseconds; two seconds is a plugin doing I/O in display().
SLOW_DISPLAY_SECONDS = 2.0
#: An update() call at least this long is logged as slow.
SLOW_UPDATE_SECONDS = 5.0
def __init__(
self,
@@ -41,7 +59,8 @@ class PluginExecutor:
self,
operation: Callable[[], Any],
timeout: Optional[float] = None,
plugin_id: Optional[str] = None
plugin_id: Optional[str] = None,
thread_name: Optional[str] = None
) -> Any:
"""
Execute a plugin operation with timeout.
@@ -50,6 +69,8 @@ class PluginExecutor:
operation: Function to execute
timeout: Timeout in seconds (None = use default)
plugin_id: Optional plugin ID for logging
thread_name: Name for the thread the operation runs on (None
keeps Python's default). Stack dumps list threads by name.
Returns:
Result of operation
@@ -66,13 +87,16 @@ class PluginExecutor:
def target():
try:
result_container['value'] = operation()
# Fetches made by the operation (and by threads the core
# starts from it) are counted against this plugin.
with plugin_scope(plugin_id):
result_container['value'] = operation()
result_container['completed'] = True
except Exception as e:
result_container['exception'] = e
result_container['completed'] = True
thread = Thread(target=target, daemon=True)
thread = Thread(target=target, daemon=True, name=thread_name)
thread.start()
thread.join(timeout=timeout)
@@ -117,15 +141,15 @@ class PluginExecutor:
True if update succeeded, False otherwise
"""
try:
start_time = time.time()
start_time = time.monotonic()
self.execute_with_timeout(
lambda: plugin.update(),
timeout=timeout,
plugin_id=plugin_id
)
duration = time.time() - start_time
duration = time.monotonic() - start_time
if duration > 5.0: # Warn if update takes more than 5 seconds
if duration > self.SLOW_UPDATE_SECONDS:
self.logger.warning(
"Plugin %s update() took %.2fs (consider optimizing)",
plugin_id,
@@ -156,7 +180,8 @@ class PluginExecutor:
force_clear: bool = False,
display_mode: Optional[str] = None,
timeout: Optional[float] = None,
accepts_display_mode: Optional[bool] = None
accepts_display_mode: Optional[bool] = None,
raise_errors: bool = False
) -> bool:
"""
Execute plugin display() method with error handling.
@@ -170,12 +195,21 @@ class PluginExecutor:
accepts_display_mode: Whether plugin.display() takes a
display_mode keyword. Pass it when the caller already knows;
None falls back to inspecting the callable.
raise_errors: Re-raise the PluginError wrapping an exception
display() raised, instead of returning False. False alone
cannot tell "no content" from "raised", and a caller that
feeds the circuit breaker needs that difference. The error
is still logged and recorded first. A timeout still returns
False either way.
Returns:
True if display succeeded, False otherwise
Raises:
PluginError: Only with ``raise_errors``, when display() raised.
"""
try:
start_time = time.time()
start_time = time.monotonic()
# Does display() take a display_mode keyword? The caller usually
# knows and caches the answer, so prefer what it passed.
@@ -192,23 +226,29 @@ class PluginExecutor:
'display_mode' in inspect.signature(plugin.display).parameters)
has_display_mode = accepts_display_mode
# Named for the plugin: this thread presents a screen's first
# frame, so the frame-timing stall watchdog's stack dumps name it.
thread_name = f"display-{plugin_id}"
# Capture the return value from the plugin's display() method
if has_display_mode and display_mode:
result = self.execute_with_timeout(
lambda: plugin.display(display_mode=display_mode, force_clear=force_clear),
timeout=timeout,
plugin_id=plugin_id
plugin_id=plugin_id,
thread_name=thread_name
)
else:
result = self.execute_with_timeout(
lambda: plugin.display(force_clear=force_clear),
timeout=timeout,
plugin_id=plugin_id
plugin_id=plugin_id,
thread_name=thread_name
)
duration = time.time() - start_time
duration = time.monotonic() - start_time
if duration > 2.0: # Warn if display takes more than 2 seconds
if duration > self.SLOW_DISPLAY_SECONDS:
self.logger.warning(
"Plugin %s display() took %.2fs (consider optimizing)",
plugin_id,
@@ -228,6 +268,8 @@ class PluginExecutor:
return False
except PluginError:
# Already logged and recorded in execute_with_timeout
if raise_errors:
raise
return False
except Exception as e:
self.logger.error(
+105 -1
View File
@@ -254,6 +254,104 @@ class PluginHealthTracker:
self._save_health_state(plugin_id, state)
def record_hang(self, plugin_id: str, operation: str, seconds: float,
error: Optional[Exception] = None) -> None:
"""Record a display() or update() call that ran past its limit.
Counts as a failure, so the ordinary circuit breaker handles a plugin
that keeps hanging: after ``failure_threshold`` in a row it is skipped
by both the update scheduler and the display rotation until the
cooldown ends. The hang itself is kept alongside (``hang_count``,
``last_hang``) so the health API can tell "hung" from "raised".
Not for an update skipped because the plugin's lock stayed held: the
holder may be a healthy but long render (Vegas prefetch). That is
:meth:`record_busy_skip`, which never touches the breaker.
Args:
plugin_id: Plugin identifier
operation: What hung: ``"display"`` or ``"update"``.
seconds: How long it had been running when this was recorded.
error: The error to store as ``last_error``; one is built from
the other arguments when omitted.
"""
state = self.get_health_state(plugin_id)
count = state.get('hang_count')
state['hang_count'] = (count if isinstance(count, int) and not isinstance(count, bool)
else 0) + 1
state['last_hang'] = {
'operation': operation,
'seconds': round(float(seconds), 3),
'time': time.time(),
}
if error is None:
error = TimeoutError(f"{operation} still running after {seconds:.1f}s")
# record_failure saves the record, hang fields included.
self.record_failure(plugin_id, error)
#: Minimum seconds between persisting a plugin's slow-call or busy-skip
#: counters. The in-memory record is updated every time; a plugin that is
#: slow on every frame must not become an SD-card write per frame.
SLOW_CALL_PERSIST_INTERVAL = 60.0
def record_slow_call(self, plugin_id: str, operation: str, seconds: float) -> None:
"""Note a call that finished, but slowly. Reporting only.
Unlike :meth:`record_hang` this never touches the circuit breaker: a
slow display() still drew its frame.
"""
state = self.get_health_state(plugin_id)
count = state.get('slow_call_count')
state['slow_call_count'] = (count if isinstance(count, int) and not isinstance(count, bool)
else 0) + 1
now = time.time()
state['last_slow_call'] = {
'operation': operation,
'seconds': round(float(seconds), 3),
'time': now,
}
self._save_reporting_throttled('slow', plugin_id, state, now)
def record_busy_skip(self, plugin_id: str, operation: str, seconds: float) -> None:
"""Note a call skipped because the plugin's lock stayed held. Reporting only.
The update worker gives up on a plugin's lock after
``PluginManager.PLUGIN_LOCK_TIMEOUT``. Whatever held it may be healthy
-- Vegas prefetch holds the lock for a plugin's whole content render,
which on a slow Pi can take longer than that -- so like
:meth:`record_slow_call` this never touches the circuit breaker, the
failure streak or ``last_error``. A real hang is recorded by
:meth:`record_hang` where it is measured.
Args:
plugin_id: Plugin identifier
operation: What was skipped, e.g. ``"update lock wait"``.
seconds: How long the lock was waited on.
"""
state = self.get_health_state(plugin_id)
count = state.get('busy_skip_count')
state['busy_skip_count'] = (count if isinstance(count, int) and not isinstance(count, bool)
else 0) + 1
now = time.time()
state['last_busy_skip'] = {
'operation': operation,
'seconds': round(float(seconds), 3),
'time': now,
}
self._save_reporting_throttled('busy', plugin_id, state, now)
def _save_reporting_throttled(self, kind: str, plugin_id: str,
state: Dict[str, Any], now: float) -> None:
"""Persist a reporting-only change at most once per
SLOW_CALL_PERSIST_INTERVAL per plugin and ``kind``. The first one is
saved at once, so the web process (which reads the persisted record)
sees it; repeats in between stay in memory until the next save."""
saved_at = self.__dict__.setdefault('_reporting_saved_at', {})
last = saved_at.get((kind, plugin_id))
if last is None or now - last >= self.SLOW_CALL_PERSIST_INTERVAL:
saved_at[(kind, plugin_id)] = now
self._save_health_state(plugin_id, state)
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
@@ -345,7 +443,13 @@ class PluginHealthTracker:
'degraded': state.get('degraded', False),
'degraded_reason': state.get('degraded_reason'),
'circuit_opened_time': state.get('circuit_opened_time'),
'half_open_start_time': state.get('half_open_start_time')
'half_open_start_time': state.get('half_open_start_time'),
'hang_count': state.get('hang_count', 0),
'last_hang': state.get('last_hang'),
'slow_call_count': state.get('slow_call_count', 0),
'last_slow_call': state.get('last_slow_call'),
'busy_skip_count': state.get('busy_skip_count', 0),
'last_busy_skip': state.get('last_busy_skip'),
}
def get_all_health_summaries(self) -> Dict[str, Dict[str, Any]]:
+486 -42
View File
@@ -16,12 +16,15 @@ import time
import threading
import types
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
import logging
from src import display_watchdog
from src.exceptions import PluginError, ConfigError
from src.logging_config import get_logger
from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_executor import PluginExecutor
from src.plugin_system.plugin_executor import (
PluginBusyError, PluginExecutor, PluginTimeoutError,
)
from src.plugin_system.plugin_state import PluginStateManager, PluginState
from src.plugin_system.schema_manager import (
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
@@ -29,13 +32,23 @@ from src.plugin_system.schema_manager import (
from src.plugin_system.plugin_dirs import (
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
)
from src.deprecation import deprecated
from src.common.fetch_service import plugin_scope, register_plugin_directory
from src.common.permission_utils import (
ensure_directory_permissions,
get_plugin_dir_mode
)
class _DeferredConfigChange(NamedTuple):
"""Update-queue item: apply the config change parked for ``plugin_id``.
Queued by apply_config_change() when the plugin's lock was busy; the
change itself waits in ``PluginManager._deferred_config_changes`` so only
the latest one is ever applied.
"""
plugin_id: str
class PluginManager:
"""
Manages plugin discovery, loading, and lifecycle.
@@ -56,6 +69,27 @@ class PluginManager:
# How long unload_plugin() waits for an in-flight update() to finish
# before tearing the instance down anyway.
UNLOAD_LOCK_TIMEOUT = 5.0
# How long unload_detached_plugin() (a live reload, off the render thread)
# waits for the old instance's lock. Longer than UNLOAD_LOCK_TIMEOUT
# because nothing is blocked by the wait, and a Vegas content build of the
# old instance can hold the lock for several seconds (5.9 s seen on
# ledpi). Past it the reload is refused rather than tearing down an
# instance a Vegas call may still be running in.
DETACHED_UNLOAD_LOCK_TIMEOUT = 30.0
# How long the update worker and apply_config_change() wait for a
# plugin's lock -- the same bound unload already uses for the same lock.
# A display() frame holds it for milliseconds, so this only runs out when
# the holder is hung or pathologically slow. The worker then skips that
# plugin (recorded as a hang, so repeats open its circuit breaker)
# instead of stalling every other plugin's update behind it.
PLUGIN_LOCK_TIMEOUT = UNLOAD_LOCK_TIMEOUT
# Minimum seconds between repeats of the same hang/slow-call warning for
# one plugin. A hung plugin is re-detected every interval; a slow
# display() can be re-detected every frame.
HANG_LOG_INTERVAL = 60.0
def __init__(self, plugins_dir: str = "plugins",
config_manager: Optional[Any] = None,
@@ -122,7 +156,37 @@ class PluginManager:
# 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()
#
# Which thread runs each plugin hook, and what it holds:
# __init__, on_enable the loading thread (main thread at startup,
# the render thread on a live enable, a
# plugin-reload thread for a control socket
# reload).
# update() plugin-update-worker, under the plugin lock,
# via PluginExecutor (whose daemon thread runs
# the call; if it outlives the executor's
# timeout it keeps the lock until it returns).
# Exceptions: the startup pass
# (DisplayController._run_initial_updates, main
# thread, before the display loop starts) and
# the synchronous_updates kill switch (render
# thread) run it without the lock.
# display() the render thread, under a try-lock: a busy
# lock skips the frame. The first frame of a
# screen goes through PluginExecutor. Vegas
# mode's adapter and coordinator take the lock
# with a bounded wait.
# on_config_change() ConfigService's watcher thread, under the
# plugin lock via apply_config_change(); if the
# lock stays busy it is deferred to the update
# worker, which applies it under the lock.
# cleanup(), on_disable() whoever calls unload_plugin() (or, for a
# reload, unload_detached_plugin() on its
# plugin-reload thread), under the lock with
# UNLOAD_LOCK_TIMEOUT.
# No wait on a plugin lock is unbounded, so one hung plugin can only
# cost the worker PLUGIN_LOCK_TIMEOUT per attempt.
self._update_queue: "queue.Queue[Union[None, Tuple[str, float], _DeferredConfigChange]]" = queue.Queue()
self._pending_updates: set = set()
self._pending_lock = threading.Lock()
# Serializes the "is this plugin eligible?" -> "claim it (RUNNING)"
@@ -145,6 +209,17 @@ class PluginManager:
# run_scheduled_updates_with_changes().
self._completed_updates: set = set()
self._completed_updates_lock = threading.Lock()
# Called with a plugin id the moment its data may have changed: its
# update() completed, or it called notify_vegas_data_changed(). See
# add_update_listener(). A tuple, replaced rather than mutated, so the
# worker can iterate it without a lock.
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
# Config changes that found the plugin's lock busy, latest per plugin,
# with the instance they were meant for. See apply_config_change().
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
self._deferred_config_lock = threading.Lock()
# key -> (monotonic time last logged, repeats suppressed since)
self._rate_limited_warnings: Dict[str, Tuple[float, int]] = {}
self._synchronous_updates = False
if self.config_manager is not None:
try:
@@ -297,6 +372,19 @@ class PluginManager:
return plugin_ids
def load_plugin(self, plugin_id: str, force_enabled: bool = False) -> bool:
"""Load a plugin by ID; see _load_plugin.
Loading can install the plugin's dependencies with pip -- minutes,
not seconds. When that happens on the display's render thread (a
plugin enabled from the web UI, or loaded for on-demand), its
systemd watchdog gets a longer limit for the duration. Start-up
loads, on a thread pool, are covered by the start-up allowance.
"""
with display_watchdog.extended(display_watchdog.PLUGIN_LOAD_ALLOWANCE_SECONDS,
f'loading plugin {plugin_id}'):
return self._load_plugin(plugin_id, force_enabled)
def _load_plugin(self, plugin_id: str, force_enabled: bool = False) -> bool:
"""
Load a plugin by ID.
@@ -348,6 +436,11 @@ class PluginManager:
# Update mapping if found via search
if plugin_id not in self.plugin_directories:
self.plugin_directories[plugin_id] = plugin_dir
# Code under this directory is this plugin's: the fetch service
# counts a request against it even from a thread the plugin
# started itself (src/common/fetch_service.py, caller identity).
register_plugin_directory(plugin_id, plugin_dir)
# Get plugin config
if self.config_manager:
@@ -387,18 +480,20 @@ class PluginManager:
config = dict(config)
config['enabled'] = True
# Use PluginLoader to load plugin
plugin_instance, _module = self.plugin_loader.load_plugin(
plugin_id=plugin_id,
manifest=manifest,
plugin_dir=plugin_dir,
config=config,
display_manager=self.display_manager,
cache_manager=self.cache_manager,
plugin_manager=self,
install_deps=True,
plugins_dir=self.plugins_dir,
)
# Use PluginLoader to load plugin. Fetches the constructor makes
# count against the plugin.
with plugin_scope(plugin_id):
plugin_instance, _module = self.plugin_loader.load_plugin(
plugin_id=plugin_id,
manifest=manifest,
plugin_dir=plugin_dir,
config=config,
display_manager=self.display_manager,
cache_manager=self.cache_manager,
plugin_manager=self,
install_deps=True,
plugins_dir=self.plugins_dir,
)
# Register plugin-shipped fonts with the FontManager (if any).
# Plugin manifests can declare a "fonts" block that ships custom
@@ -452,7 +547,8 @@ class PluginManager:
# Call on_enable if plugin is enabled
if hasattr(plugin_instance, 'on_enable'):
try:
plugin_instance.on_enable()
with plugin_scope(plugin_id):
plugin_instance.on_enable()
except Exception:
# Undo the registration above before the outer
# handler marks it ERROR: left in self.plugins, the
@@ -465,7 +561,13 @@ class PluginManager:
raise
else:
self.state_manager.set_state(plugin_id, PluginState.DISABLED)
# The version this instance runs, for the runtime snapshot the
# web UI reads: the manifest on disk can move on after an update.
version = manifest.get('version')
self.state_manager.record_loaded(
plugin_id, version if isinstance(version, str) else None)
self.logger.info("Loaded plugin: %s", plugin_id)
return True
@@ -512,7 +614,8 @@ class PluginManager:
#: prefix rule would silently stop validating it.
#:
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``).
#: ``vegas_overflow``, ``vegas_live``) and ``base_plugin.py``
#: (``vegas_max_width_screens``, ``vegas_participation``).
#:
#: The list itself lives with the other core-owned per-plugin properties in
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
@@ -661,14 +764,57 @@ class PluginManager:
if lock_acquired:
lock.release()
def _unload_plugin_locked(self, plugin_id: str) -> bool:
"""Body of unload_plugin(); caller holds (or gave up on) the plugin lock."""
if plugin_id not in self.plugins: # unloaded while we waited
def detach_plugin(self, plugin_id: str) -> Optional[Any]:
"""Take a loaded plugin out of ``plugins`` without tearing it down.
The first half of a reload that must not block its caller, the render
thread (DisplayController._start_plugin_reload). Every new call into a
plugin starts by looking it up in ``plugins``: the update scheduler,
the update worker (which looks again under the plugin's lock) and
Vegas's fetches. So once detached, nothing new reaches the instance.
Work already running on it under its lock -- an update(), or a Vegas
content render that can take seconds -- carries on;
unload_detached_plugin() waits for it, on another thread.
Returns the instance, or None when the plugin was not loaded.
"""
return self.plugins.pop(plugin_id, None)
def unload_detached_plugin(self, plugin_id: str, plugin: Any) -> bool:
"""Tear down an instance taken out by detach_plugin(): unload_plugin()
for an instance that is no longer in ``plugins``.
Waits for the plugin's lock, bounded by DETACHED_UNLOAD_LOCK_TIMEOUT,
so it belongs off the render thread. Call it before loading the plugin
again: it drops the plugin's modules and lifecycle state along with the
instance. Unlike unload_plugin() it never tears down without the lock:
a call that took the lock before the detach (a Vegas content build)
may still be running in this instance. Returns False then, and the
caller must not load the plugin again over it.
"""
lock = self.get_plugin_lock(plugin_id)
if not lock.acquire(timeout=self.DETACHED_UNLOAD_LOCK_TIMEOUT):
self.logger.warning(
"Plugin %s still busy after %.1fs; not unloading it while in use",
plugin_id, self.DETACHED_UNLOAD_LOCK_TIMEOUT)
return False
try:
return self._unload_plugin_locked(plugin_id, plugin)
finally:
lock.release()
def _unload_plugin_locked(self, plugin_id: str, detached: Optional[Any] = None) -> bool:
"""Body of unload_plugin(); caller holds (or gave up on) the plugin lock.
``detached`` is an instance already taken out of ``plugins``
(detach_plugin); without it, the loaded instance is unloaded.
"""
if detached is None and plugin_id not in self.plugins: # unloaded while we waited
self.logger.warning("Plugin %s not loaded", plugin_id)
return False
try:
plugin = self.plugins[plugin_id]
plugin = self.plugins[plugin_id] if detached is None else detached
# Call cleanup if available
if hasattr(plugin, 'cleanup'):
@@ -684,8 +830,11 @@ class PluginManager:
except Exception as e:
self.logger.warning("Error during plugin on_disable: %s", e)
# Remove from active plugins
del self.plugins[plugin_id]
# Remove from active plugins (a detached one already is)
if detached is None:
del self.plugins[plugin_id]
with self._deferred_config_lock:
self._deferred_config_changes.pop(plugin_id, None)
with self._plugin_last_update_lock:
self.plugin_last_update.pop(plugin_id, None)
self._update_interval_cache.pop(plugin_id, None)
@@ -714,6 +863,9 @@ class PluginManager:
except Exception as e:
self.logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
if plugin_id not in self.plugins:
# Failed after the instance was dropped: it is not loaded.
self.state_manager.record_unloaded(plugin_id)
return False
def reload_plugin(self, plugin_id: str) -> bool:
@@ -785,16 +937,6 @@ class PluginManager:
"""
return self.plugins.copy()
@deprecated("3.7.0", "check each plugin's enabled flag in plugins")
def get_enabled_plugins(self) -> List[str]:
"""
Get list of enabled plugin IDs.
Returns:
List of plugin IDs that are currently enabled
"""
return [pid for pid, plugin in self.plugins.items() if plugin.enabled]
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""
Get information about a plugin (manifest + runtime info).
@@ -1022,6 +1164,8 @@ class PluginManager:
self,
plugin_id: str,
exc: Optional[Exception] = None,
log: bool = True,
count_failure: bool = True,
) -> None:
"""Apply the standard failure-recovery path for a plugin update.
@@ -1035,6 +1179,11 @@ class PluginManager:
exc: The exception that caused the failure, if any. When None a
synthetic ExecutionFailure exception is constructed from the
timeout/executor-error path.
log: Log the generic failure line. Callers that already logged
something more specific (rate-limited) pass False.
count_failure: Record the failure in plugin health, where it
counts toward the circuit breaker. A busy skip passes False:
it records itself as a busy skip, reporting only.
"""
failure_time = time.time()
if exc is not None:
@@ -1050,13 +1199,91 @@ class PluginManager:
'timestamp': failure_time,
'recoverable': True,
}
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
if log:
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = failure_time
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info)
if self.health_tracker:
if count_failure and self.health_tracker:
self.health_tracker.record_failure(plugin_id, err)
def _warn_rate_limited(self, key: str, message: str, *args: Any) -> None:
"""Log a warning at most once per HANG_LOG_INTERVAL for ``key``.
Repeats in between are counted and the count is appended to the next
one that is logged, so the journal shows the problem continuing
without a line per frame or per scheduler tick.
"""
# setdefault: tests build bare managers with PluginManager.__new__.
seen = self.__dict__.setdefault('_rate_limited_warnings', {})
now = time.monotonic()
last, suppressed = seen.get(key, (None, 0))
if last is not None and now - last < self.HANG_LOG_INTERVAL:
seen[key] = (last, suppressed + 1)
return
seen[key] = (now, 0)
if suppressed:
message += " (%d more since the last warning)"
args = args + (suppressed,)
self.logger.warning(message, *args)
def _record_hang(self, plugin_id: str, operation: str, seconds: float,
err: Exception) -> None:
"""Record a hang in plugin health: a failure to the circuit breaker.
PluginHealthTracker.record_hang also counts the hang separately. Never
raises: this runs on the update worker and the render thread.
"""
tracker = self.health_tracker
if tracker is None:
return
try:
tracker.record_hang(plugin_id, operation, seconds, err)
except Exception as e: # pylint: disable=broad-except
self.logger.debug("Could not record hang for %s: %s", plugin_id, e)
def note_display_duration(self, plugin_id: str, seconds: float) -> None:
"""Account for one display() call that took ``seconds``.
Called by the render loop for every frame, so the common case is one
comparison. At or above PluginExecutor.SLOW_DISPLAY_SECONDS the call
is logged (rate-limited) and counted as slow in plugin health; at or
above the executor's timeout -- the limit the first frame of a screen
is already held to -- it is recorded as a hang, which the circuit
breaker counts as a failure.
"""
if seconds < PluginExecutor.SLOW_DISPLAY_SECONDS:
return
if seconds >= self.plugin_executor.default_timeout:
self.record_display_hang(plugin_id, seconds)
return
self._warn_rate_limited(
"slow-display:" + plugin_id,
"Plugin %s display() took %.2fs; a frame should take milliseconds "
"(is it fetching or loading files in display()?)", plugin_id, seconds)
tracker = self.health_tracker
record_slow = getattr(tracker, 'record_slow_call', None) if tracker is not None else None
if callable(record_slow):
try:
record_slow(plugin_id, 'display', seconds)
except Exception as e: # pylint: disable=broad-except
self.logger.debug("Could not record slow display for %s: %s", plugin_id, e)
def record_display_hang(self, plugin_id: str, seconds: float) -> None:
"""Record a display() call that ran ``seconds``, past its limit.
Either it has since returned (note_display_duration) or it is still
running on the executor's lingering thread, holding the plugin's lock
(the render loop's first-frame dispatch).
"""
self._warn_rate_limited(
"hung-display:" + plugin_id,
"Plugin %s display() ran for at least %.1fs (limit %.0fs); recorded "
"as a hang -- repeated hangs open its circuit breaker",
plugin_id, seconds, self.plugin_executor.default_timeout)
self._record_hang(plugin_id, 'display', seconds, PluginTimeoutError(
f"Plugin {plugin_id} display() ran for at least {seconds:.1f}s"))
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
"""
Trigger plugin updates based on their defined update intervals.
@@ -1090,6 +1317,9 @@ class PluginManager:
# Kill-switch path: the original inline execution
# (blocks the caller until update() completes/times out)
self._execute_update_now(plugin_id, plugin_instance, current_time)
# Up to the executor's 30s each, one after another on the
# render thread: check in with its watchdog between them.
display_watchdog.beat()
else:
self._enqueue_update(plugin_id, current_time)
@@ -1142,11 +1372,13 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
"""Per-plugin lock keeping update() and display() mutually exclusive.
"""Per-plugin lock keeping update(), display() and on_config_change()
mutually exclusive.
The update worker holds it for the duration of a plugin's update();
the display side acquires it non-blocking and skips that frame's
display() call when the plugin is mid-update.
display() call when the plugin is mid-update. Every other waiter uses
a bounded acquire (see the thread notes in __init__).
"""
with self._plugin_locks_guard:
lock = self._plugin_locks.get(plugin_id)
@@ -1212,14 +1444,28 @@ class PluginManager:
real update() call genuinely finishes (see _execute_update_now),
which can be after this dispatch returns if PluginExecutor's own
timeout elapses first.
The lock wait is bounded by PLUGIN_LOCK_TIMEOUT. Whatever holds it
past that -- a hung display() on the render thread, a lingering
executor thread, or a long but healthy Vegas content render -- costs
this worker that long once per attempt, and the plugin's update is
skipped and reported as a busy skip (_skip_busy_update), which never
counts toward the circuit breaker; the other plugins' queued updates
carry on.
"""
while True:
item = self._update_queue.get()
if item is None: # shutdown sentinel
return
if isinstance(item, _DeferredConfigChange):
self._apply_deferred_config_change(item.plugin_id)
continue
plugin_id, scheduled_time = item
lock = self.get_plugin_lock(plugin_id)
lock.acquire()
wait_start = time.monotonic()
if not lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT):
self._skip_busy_update(plugin_id, time.monotonic() - wait_start)
continue
plugin_instance = self.plugins.get(plugin_id)
if plugin_instance is None: # unloaded while queued; its
# lifecycle state was already cleared by unload_plugin —
@@ -1228,6 +1474,9 @@ class PluginManager:
with self._pending_lock:
self._pending_updates.discard(plugin_id)
continue
# A config change that found the lock busy goes in first, so
# this update() runs against the settings the user saved.
self._apply_deferred_config_locked(plugin_id, plugin_instance)
try:
self._execute_update_now(plugin_id, plugin_instance,
scheduled_time, lock=lock)
@@ -1238,6 +1487,142 @@ class PluginManager:
self.logger.exception("update worker: unexpected error for %s",
plugin_id)
def _skip_busy_update(self, plugin_id: str, waited: float) -> None:
"""Give up on a queued update whose plugin lock stayed held.
Same bookkeeping as a failed update() -- pending slot dropped before
the state returns to ENABLED with PluginBusyError error info,
last-update stamped so the retry waits a full interval -- but
report-only in health: counted as a busy skip (``busy_skip_count`` /
``last_busy_skip``), never as a failure or a hang. The lock holder
may be perfectly healthy: Vegas prefetch holds a plugin's lock for its
whole content render, which on a slow Pi can outlast
PLUGIN_LOCK_TIMEOUT, and counting that would pull a healthy plugin
from rotation. Real hangs -- display() or update() past the executor
timeout -- are recorded where they are measured and still open the
breaker.
"""
with self._pending_lock:
self._pending_updates.discard(plugin_id)
if plugin_id not in self.plugins:
# Unloaded while we waited: its lifecycle state is already
# cleared; recording anything would resurrect it as ENABLED.
return
self._warn_rate_limited(
"busy-update:" + plugin_id,
"Plugin %s update skipped: its lock was still held after %.1fs "
"(a display(), Vegas render or update() of it is still running); "
"retrying next interval, not counted as a failure", plugin_id, waited)
self._record_update_failure(
plugin_id,
exc=PluginBusyError(
f"Plugin {plugin_id} busy: its lock was held for over {waited:.1f}s "
"by a slow or hung display()/update(); update skipped"),
log=False,
count_failure=False)
tracker = self.health_tracker
record_busy = getattr(tracker, 'record_busy_skip', None) if tracker is not None else None
if callable(record_busy):
try:
record_busy(plugin_id, 'update lock wait', waited)
except Exception as e: # pylint: disable=broad-except
self.logger.debug("Could not record busy skip for %s: %s", plugin_id, e)
def apply_config_change(self, plugin_id: str, new_config: Dict[str, Any],
plugin_instance: Optional[Any] = None) -> bool:
"""Call ``on_config_change(new_config)`` without racing update()/display().
Runs on the calling thread -- ConfigService's watcher, for the display
service -- holding the plugin's lock, waited on for at most
PLUGIN_LOCK_TIMEOUT. If the lock is still busy (an update() mid-fetch
can outlast that) the change is parked and handed to the update
worker, which applies it under the same lock once it is free, and at
the latest just before the plugin's next update(). A later change for
the same plugin replaces a parked one.
Exceptions from on_config_change propagate on the immediate path,
as they did when the caller invoked it directly.
Args:
plugin_id: Plugin identifier.
new_config: The prepared config to hand the plugin.
plugin_instance: The instance to notify; defaults to the loaded one.
Returns:
True if on_config_change ran now, False if it was deferred or there
is no loaded plugin to notify.
"""
if plugin_instance is None:
plugin_instance = self.plugins.get(plugin_id)
if plugin_instance is None or not hasattr(plugin_instance, 'on_config_change'):
return False
lock = self.get_plugin_lock(plugin_id)
if lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT):
try:
with self._deferred_config_lock:
# This change supersedes any older one still parked.
self._deferred_config_changes.pop(plugin_id, None)
plugin_instance.on_config_change(new_config)
finally:
lock.release()
return True
with self._deferred_config_lock:
self._deferred_config_changes[plugin_id] = (plugin_instance, new_config)
self._warn_rate_limited(
"busy-config:" + plugin_id,
"Plugin %s is busy (lock held for over %.1fs); its config change "
"will be applied by the update worker once it is free",
plugin_id, self.PLUGIN_LOCK_TIMEOUT)
try:
self._ensure_update_worker()
self._update_queue.put(_DeferredConfigChange(plugin_id))
except Exception as exc: # pylint: disable=broad-except
# No worker (thread start refused): still parked, so the next
# update() of this plugin applies it.
self.logger.error(
"Could not queue the config change for plugin %s (%s: %s); it "
"will be applied before its next update()",
plugin_id, type(exc).__name__, exc)
return False
def _apply_deferred_config_change(self, plugin_id: str) -> None:
"""Worker side of a parked config change: take the lock, apply it."""
with self._deferred_config_lock:
if plugin_id not in self._deferred_config_changes:
return # applied or superseded meanwhile
lock = self.get_plugin_lock(plugin_id)
wait_start = time.monotonic()
if not lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT):
self._warn_rate_limited(
"busy-config:" + plugin_id,
"Plugin %s still busy after %.1fs; its config change stays "
"parked until its next update()",
plugin_id, time.monotonic() - wait_start)
return
try:
self._apply_deferred_config_locked(plugin_id, self.plugins.get(plugin_id))
finally:
lock.release()
def _apply_deferred_config_locked(self, plugin_id: str,
current_instance: Optional[Any]) -> None:
"""Apply the parked config change for plugin_id; caller holds its lock."""
with self._deferred_config_lock:
entry = self._deferred_config_changes.pop(plugin_id, None)
if entry is None:
return
instance, new_config = entry
if current_instance is None or instance is not current_instance:
# Unloaded, or reloaded as a new instance built from the current
# config: nothing left to tell.
return
try:
instance.on_config_change(new_config)
self.logger.info("Applied deferred config change for plugin %s", plugin_id)
except Exception: # pylint: disable=broad-except
self.logger.exception("Error in plugin %s config change handler", plugin_id)
def stop_update_worker(self, timeout: float = 5.0) -> None:
"""Signal the worker to exit (used by cleanup; thread is a daemon)."""
if self._update_worker is not None and self._update_worker.is_alive():
@@ -1348,14 +1733,29 @@ class PluginManager:
else:
_finish(True)
started = time.monotonic()
try:
self.plugin_executor.execute_update(
success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=_target_update), plugin_id)
except Exception as exc: # pragma: no cover - defensive; execute_update
# catches everything internally, but guarantee _finish still
# runs (releasing the lock) if something unexpected slips through.
self.logger.exception("Unexpected error dispatching update for %s: %s", plugin_id, exc)
_finish(False, exc=exc)
return
if not success and not finished['done']:
# The executor stopped waiting but update() is still running: it
# keeps the lock and the RUNNING state until it returns (then
# _finish records the outcome). Say so now, rather than leave the
# plugin silently stuck; record_success on a late return clears it.
elapsed = time.monotonic() - started
self._warn_rate_limited(
"hung-update:" + plugin_id,
"Plugin %s update() still running after %.1fs; it keeps its "
"lock until it returns, and is not rescheduled until then",
plugin_id, elapsed)
self._record_hang(plugin_id, 'update', elapsed, PluginTimeoutError(
f"Plugin {plugin_id} update() still running after {elapsed:.1f}s"))
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
"""
@@ -1382,9 +1782,53 @@ class PluginManager:
return self.drain_completed_updates()
def _note_update_completed(self, plugin_id: str) -> None:
"""Record that a plugin's update() finished, for the next poll."""
"""Record that a plugin's update() finished, for the next poll.
Also tells the update listeners at once, so Vegas live elements are
redrawn the moment new data lands instead of at the next ~4s poll.
This runs while the plugin's lock is still held (see _finish), which
is what makes the listeners' contract strict.
"""
with self._completed_updates_lock:
self._completed_updates.add(plugin_id)
self._fire_update_listeners(plugin_id)
def add_update_listener(self, listener: Callable[[str], None]) -> None:
"""Call ``listener(plugin_id)`` whenever a plugin's data may have changed.
That is: its update() completed successfully, or it called
notify_vegas_data_changed(). The listener runs on the thread that
noticed -- the update worker, with the plugin's lock still held, or
the plugin's own thread -- so it must return at once and take no lock
a plugin could hold: record the id and hand off (a dict store, a
queue put). An exception from it is logged and does not reach the
plugin. Adding the same listener twice has no effect.
"""
# __dict__.get: tests build bare managers with PluginManager.__new__.
listeners = self.__dict__.get('_update_listeners', ())
if listener not in listeners:
self._update_listeners = listeners + (listener,)
def remove_update_listener(self, listener: Callable[[str], None]) -> None:
"""Stop calling a listener added with add_update_listener()."""
self._update_listeners = tuple(
fn for fn in self.__dict__.get('_update_listeners', ()) if fn != listener)
def notify_data_changed(self, plugin_id: str) -> None:
"""A plugin's data changed outside update(); tell the update listeners.
BasePlugin.notify_vegas_data_changed() lands here.
"""
self._fire_update_listeners(plugin_id)
def _fire_update_listeners(self, plugin_id: str) -> None:
for listener in self.__dict__.get('_update_listeners', ()):
try:
listener(plugin_id)
except Exception as exc: # pylint: disable=broad-except
self._warn_rate_limited(
"update-listener",
"An update listener failed for plugin %s: %r", plugin_id, exc)
def drain_completed_updates(self) -> List[str]:
"""Return and clear the plugin ids whose update() has since finished."""
+377
View File
@@ -0,0 +1,377 @@
"""The display's plugin runtime snapshot, shared with the web interface.
Only the display process runs plugins, so only it knows which ones it has
loaded, where each is in its lifecycle (``plugin_state.PluginStateManager``),
why one failed and which version it is running. It publishes that to the
shared cache directory -- the channel, and the file permissions, that the
error snapshot, plugin health and ``display_current_state`` already use --
and the web interface reads it back for ``/api/v3/plugins/installed``,
``/api/v3/plugins/state`` and state reconciliation.
PLUGIN_RUNTIME_KEY written by the display service only
Writes. The cache lives on disk, usually the SD card, so the snapshot is
written when something a reader would see changes, at most once every
``MIN_INTERVAL`` seconds, and otherwise once every ``REFRESH_INTERVAL``
seconds as a heartbeat. An ordinary plugin update is not a change: the
RUNNING state it passes through is published as ENABLED
(``plugin_state.published_state``). A display with nothing changing writes
this one small file once a minute.
Staleness. Every snapshot carries ``published_at`` (wall clock) and
``stale_after``. A reader treats a snapshot older than that as unknown, not
as the truth: a display that died without cleaning up leaves its last
snapshot behind. A display that stops cleanly publishes ``running: false``
on the way out, so readers see "stopped" at once rather than after the
stale window. Nothing on the reading side reports a runtime fact from a
snapshot that is not live.
"""
import math
import os
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
from src.logging_config import get_logger
from src.redaction import redact_credentials
logger = get_logger(__name__)
PLUGIN_RUNTIME_KEY = "plugin_runtime_snapshot"
SNAPSHOT_SCHEMA = 1
#: Shortest gap, in seconds, between two change-driven writes. Startup loads
#: every plugin in a burst, and a plugin failing each update cycle changes its
#: error info each time; either is written at most this often.
MIN_INTERVAL = 10.0
#: An unchanged snapshot is rewritten this often so readers can tell a quiet
#: display from a dead one.
REFRESH_INTERVAL = 60.0
#: How often the publisher thread looks for changes: an in-memory comparison.
TICK_INTERVAL = 5.0
#: A snapshot older than this is stale: three missed refreshes.
STALE_AFTER = 3 * REFRESH_INTERVAL
#: Bounds on a published ``stale_after``, so a corrupt value can make a
#: reader neither trust a dead display for hours nor distrust a live one.
_STALE_AFTER_MIN = 30.0
_STALE_AFTER_MAX = 3600.0
_ERROR_MESSAGE_CHARS = 200
_ERROR_TYPE_CHARS = 80
_ID_CHARS = 100
_VERSION_CHARS = 40
#: Reader statuses. Only LIVE carries runtime facts.
LIVE = "live"
STALE = "stale"
STOPPED = "stopped"
UNKNOWN = "unknown"
def _clip(value: Any, limit: int) -> str:
text = value if isinstance(value, str) else str(value)
return text if len(text) <= limit else text[:limit - 3] + "..."
def _epoch(value: Any) -> Optional[float]:
"""Seconds since the epoch for a float or a datetime; None otherwise."""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
number = float(value)
return number if math.isfinite(number) else None
timestamp = getattr(value, "timestamp", None)
if callable(timestamp):
try:
number = float(timestamp())
except (TypeError, ValueError, OverflowError, OSError):
return None
return number if math.isfinite(number) else None
return None
def summarize_error(error_info: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""A short, redacted summary of the state machine's error info.
``message`` is redacted before it is clipped: clipping first could cut a
``token=`` marker off and keep the secret after it. No stack trace: the
full error, with its trace, is in the error snapshot (/api/v3/errors).
"""
if not isinstance(error_info, dict):
return None
message = error_info.get("error")
error_type = error_info.get("error_type")
return {
"type": _clip(error_type, _ERROR_TYPE_CHARS) if error_type else None,
"message": _clip(redact_credentials(message if isinstance(message, str)
else str(message or "")),
_ERROR_MESSAGE_CHARS),
"at": _epoch(error_info.get("timestamp")),
"recoverable": bool(error_info.get("recoverable", False)),
}
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
now: Optional[float] = None,
running: bool = True) -> Dict[str, Any]:
"""The snapshot for ``state_manager`` (a plugin_state.PluginStateManager).
A stopped snapshot (``running=False``) lists no plugins: nothing is
loaded once the display has gone.
"""
plugins: Dict[str, Dict[str, Any]] = {}
if running:
for plugin_id, record in state_manager.runtime_records().items():
version = record.get("version")
plugins[_clip(plugin_id, _ID_CHARS)] = {
"loaded": bool(record.get("loaded")),
"state": record.get("state"),
"error": summarize_error(record.get("error_info")),
"version": _clip(version, _VERSION_CHARS) if version else None,
"loaded_at": _epoch(record.get("loaded_at")),
}
return {
"schema": SNAPSHOT_SCHEMA,
"running": running,
"published_at": time.time() if now is None else now,
"started_at": started_at,
"refresh_interval": REFRESH_INTERVAL,
"stale_after": STALE_AFTER,
"pid": os.getpid(),
"plugins": plugins,
}
class PluginRuntimePublisher:
"""Publishes the display's plugin state machine to the shared cache.
Runs in the display service only. tick() is the whole job; start() calls
it from a daemon thread every TICK_INTERVAL seconds. Nothing here raises:
a failed write is logged at debug and retried on a later tick, at the
throttled rate.
"""
def __init__(self, cache_manager: Any, state_manager: Any,
min_interval: float = MIN_INTERVAL,
refresh_interval: float = REFRESH_INTERVAL,
clock: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time) -> None:
self.cache_manager = cache_manager
self.state_manager = state_manager
self.min_interval = min_interval
self.refresh_interval = refresh_interval
self._clock = clock
self._wall_clock = wall_clock
self.started_at = wall_clock()
# None forces a first publish, which replaces whatever a previous run
# of the service left behind.
self._published_change: Optional[int] = None
self._last_attempt: Optional[float] = None
self._tick_lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def _write(self, running: bool) -> None:
snapshot = build_runtime_snapshot(self.state_manager, started_at=self.started_at,
now=self._wall_clock(), running=running)
self.cache_manager.set(PLUGIN_RUNTIME_KEY, snapshot)
def tick(self) -> bool:
"""Publish if something changed (throttled) or the refresh is due.
True if a snapshot was written."""
with self._tick_lock:
try:
change = self.state_manager.change_count
now = self._clock()
since = None if self._last_attempt is None else now - self._last_attempt
if since is not None:
if change == self._published_change:
if since < self.refresh_interval:
return False
elif since < self.min_interval:
return False
# Stamp the attempt before writing: a cache that keeps failing
# is retried at the throttled rate, not on every tick.
self._last_attempt = now
self._write(running=True)
self._published_change = change
return True
except Exception as err: # never let reporting break the display
logger.debug("Could not publish the plugin runtime snapshot: %s",
err, exc_info=True)
return False
def start(self, interval: float = TICK_INTERVAL) -> None:
"""Tick from a daemon thread until stop(). A no-op while running."""
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
def run() -> None:
self.tick()
while not self._stop.wait(interval):
self.tick()
self._thread = threading.Thread(target=run, name="plugin-runtime-publisher",
daemon=True)
self._thread.start()
def stop(self, publish_stopped: bool = True) -> None:
"""Stop ticking and, by default, publish ``running: false`` so readers
see the display as stopped now rather than after the stale window."""
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
if publish_stopped:
with self._tick_lock:
try:
self._write(running=False)
except Exception as err:
logger.debug("Could not publish the stopped plugin runtime snapshot: %s",
err, exc_info=True)
def start_plugin_runtime_publisher(cache_manager: Any,
state_manager: Any) -> Optional[PluginRuntimePublisher]:
"""Start publishing the display's plugin runtime state. Display service
only: whichever process calls it becomes the source readers trust.
Never raises."""
try:
publisher = PluginRuntimePublisher(cache_manager, state_manager)
publisher.start()
return publisher
except Exception as err:
logger.warning("Plugin runtime reporting to the web interface is unavailable: %s", err)
return None
# --- Reading side (web interface) -------------------------------------------
#: What a reader reports for a plugin when it does not know.
_UNKNOWN_PLUGIN: Dict[str, Any] = {
"loaded": None,
"state": None,
"error_info": None,
"loaded_version": None,
"loaded_at": None,
}
#: A plugin a live snapshot does not list: the display has not loaded it
#: (never enabled, or unloaded since), which is what its state machine
#: reports for an id it has no record of.
_NOT_LOADED_PLUGIN: Dict[str, Any] = {
"loaded": False,
"state": "unloaded",
"error_info": None,
"loaded_version": None,
"loaded_at": None,
}
@dataclass(frozen=True)
class PluginRuntimeView:
"""What a reader may say about the display's plugins right now.
``status``: ``live`` (a fresh snapshot from a running display),
``stale`` (the last snapshot is older than its ``stale_after``: the
display is hung or died without cleaning up), ``stopped`` (the display
said so on its way out) or ``unknown`` (no readable snapshot). Only a
live view reports per-plugin facts; every other status answers None for
them, so a caller cannot pass stale truth on by accident.
"""
status: str
published_at: Optional[float] = None
age_seconds: Optional[float] = None
stale_after: float = STALE_AFTER
plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
@property
def live(self) -> bool:
return self.status == LIVE
def plugin(self, plugin_id: str) -> Dict[str, Any]:
"""``loaded``, ``state``, ``error_info``, ``loaded_version`` and
``loaded_at`` for one plugin; all None unless the view is live."""
if not self.live:
return dict(_UNKNOWN_PLUGIN)
record = self.plugins.get(plugin_id)
if not isinstance(record, dict):
return dict(_NOT_LOADED_PLUGIN)
error = record.get("error")
return {
"loaded": bool(record.get("loaded")),
"state": record.get("state") if isinstance(record.get("state"), str) else None,
"error_info": dict(error) if isinstance(error, dict) else None,
"loaded_version": record.get("version"),
"loaded_at": record.get("loaded_at"),
}
def describe(self) -> Dict[str, Any]:
"""The view's own status, for a response to carry beside the facts."""
return {
"status": self.status,
"published_at": self.published_at,
"age_seconds": None if self.age_seconds is None else round(self.age_seconds, 1),
"stale_after": self.stale_after,
}
def _stale_after_of(snapshot: Dict[str, Any]) -> float:
value = snapshot.get("stale_after")
if isinstance(value, bool) or not isinstance(value, (int, float)):
return STALE_AFTER
number = float(value)
if not math.isfinite(number):
return STALE_AFTER
return min(max(number, _STALE_AFTER_MIN), _STALE_AFTER_MAX)
def view_from_snapshot(snapshot: Any, now: Optional[float] = None) -> PluginRuntimeView:
"""Judge a snapshot read from the cache; never raises."""
if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_SCHEMA:
return PluginRuntimeView(status=UNKNOWN)
published_at = _epoch(snapshot.get("published_at"))
if published_at is None:
return PluginRuntimeView(status=UNKNOWN)
stale_after = _stale_after_of(snapshot)
age = (time.time() if now is None else now) - published_at
if snapshot.get("running") is not True:
return PluginRuntimeView(status=STOPPED, published_at=published_at,
age_seconds=max(age, 0.0), stale_after=stale_after)
# A snapshot from the future is trusted a little: the Pi has no RTC and
# its clock steps when NTP syncs. Far in the future, it cannot be dated.
if age > stale_after or age < -stale_after:
return PluginRuntimeView(status=STALE, published_at=published_at,
age_seconds=age, stale_after=stale_after)
plugins = snapshot.get("plugins")
return PluginRuntimeView(
status=LIVE, published_at=published_at, age_seconds=max(age, 0.0),
stale_after=stale_after,
plugins={k: v for k, v in plugins.items() if isinstance(v, dict)}
if isinstance(plugins, dict) else {},
)
def read_plugin_runtime(cache_manager: Any, now: Optional[float] = None) -> PluginRuntimeView:
"""The display's latest snapshot, judged for staleness. Never raises; a
missing cache manager or an unreadable snapshot is ``unknown``.
memory_ttl=0: the key is written by the other process, so only the file
is current.
"""
if cache_manager is None:
return PluginRuntimeView(status=UNKNOWN)
try:
snapshot = cache_manager.get(PLUGIN_RUNTIME_KEY, max_age=None, memory_ttl=0)
except Exception as err:
logger.debug("Could not read the plugin runtime snapshot: %s", err, exc_info=True)
return PluginRuntimeView(status=UNKNOWN)
return view_from_snapshot(snapshot, now=now)
+97 -4
View File
@@ -1,11 +1,14 @@
"""
Plugin State Management
Manages plugin state machine (loaded → enabled → running → error)
with state transitions and queries.
The display process's plugin state machine (loaded → enabled → running →
error), with state transitions and queries. It is the only record of plugin
lifecycle state: the web process runs no plugins, and reads this state as the
snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
"""
import threading
import time
from enum import Enum
from typing import Optional, Dict, Any
from datetime import datetime
@@ -24,8 +27,27 @@ class PluginState(Enum):
DISABLED = "disabled" # Plugin is disabled in config
def published_state(state: PluginState) -> PluginState:
"""The state as readers outside the scheduler see it.
RUNNING is the scheduler's claim on a plugin for one update() call: every
update flips ENABLED -> RUNNING -> ENABLED. Published as is, that would be
a change -- and a cache write to the SD card -- on every plugin update, and
a reader would see a plugin blink between two states that mean the same
thing to it (loaded and taking part). Readers get ENABLED for both.
"""
return PluginState.ENABLED if state == PluginState.RUNNING else state
class PluginStateManager:
"""Manages plugin state transitions and queries."""
"""Manages plugin state transitions and queries.
Owned by the display process's PluginManager. ``change_count`` moves
whenever something a reader of the published snapshot would see changes
(published state, error info, the loaded record) and stays put across the
RUNNING/ENABLED flip of an ordinary update, so a publisher can tell
"nothing new" without diffing.
"""
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
"""
@@ -41,6 +63,20 @@ class PluginStateManager:
self._state_transition_counts: Dict[str, int] = {}
self._error_info: Dict[str, Dict[str, Any]] = {}
self._last_update: Dict[str, datetime] = {}
# What load_plugin() registered: {'version', 'loaded_at'} per plugin
# whose instance is live. Cleared with the rest of its state on unload.
self._loaded: Dict[str, Dict[str, Any]] = {}
self._change_count = 0
@property
def change_count(self) -> int:
"""Moves on every change a published snapshot would show."""
with self._lock:
return self._change_count
def _note_change(self) -> None:
"""Count a reader-visible change. Callers must already hold ``_lock``."""
self._change_count += 1
def _record_transition(self, plugin_id: str) -> None:
"""Count a state transition. Callers must already hold ``_lock``."""
@@ -63,9 +99,12 @@ class PluginStateManager:
error: Optional error if transitioning to ERROR state
"""
with self._lock:
known = plugin_id in self._states
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
self._states[plugin_id] = state
self._record_transition(plugin_id)
if not known or published_state(old_state) != published_state(state):
self._note_change()
# Store error info if transitioning to ERROR state
if state == PluginState.ERROR and error:
@@ -74,9 +113,11 @@ class PluginStateManager:
'error_type': type(error).__name__,
'timestamp': datetime.now()
}
self._note_change()
elif state != PluginState.ERROR:
# Clear error info when leaving ERROR state
self._error_info.pop(plugin_id, None)
if self._error_info.pop(plugin_id, None) is not None:
self._note_change()
self.logger.debug(
"Plugin %s state transition: %s → %s",
@@ -147,6 +188,7 @@ class PluginStateManager:
self._states[plugin_id] = state
self._record_transition(plugin_id)
self._error_info[plugin_id] = dict(error_info)
self._note_change()
self.logger.debug(
"Plugin %s state transition: %s → %s (recoverable error stored)",
@@ -173,6 +215,52 @@ class PluginStateManager:
info = self._error_info.get(plugin_id)
return dict(info) if info is not None else None
def record_loaded(self, plugin_id: str, version: Optional[str],
loaded_at: Optional[float] = None) -> None:
"""Record that ``plugin_id``'s instance is live, and which version.
Called by PluginManager.load_plugin() once the instance is registered;
clear_state() (unload) forgets it. ``version`` is the manifest's at
load time, which is what the display keeps running until it reloads
the plugin -- the version on disk can move on after a store update.
"""
with self._lock:
self._loaded[plugin_id] = {
'version': version,
'loaded_at': time.time() if loaded_at is None else loaded_at,
}
self._note_change()
def record_unloaded(self, plugin_id: str) -> None:
"""Forget the loaded record alone, keeping state and error info: for
an unload that failed after the instance was already dropped."""
with self._lock:
if self._loaded.pop(plugin_id, None) is not None:
self._note_change()
def runtime_records(self) -> Dict[str, Dict[str, Any]]:
"""Every known plugin's reader-visible state, taken in one critical
section so a concurrent load or unload is seen whole or not at all.
Per plugin: ``state`` (published_state()'s value), ``loaded``,
``version`` and ``loaded_at`` (None unless loaded) and ``error_info``
(a copy, or None).
"""
with self._lock:
records: Dict[str, Dict[str, Any]] = {}
for plugin_id in set(self._states) | set(self._loaded):
loaded = self._loaded.get(plugin_id)
info = self._error_info.get(plugin_id)
records[plugin_id] = {
'state': published_state(
self._states.get(plugin_id, PluginState.UNLOADED)).value,
'loaded': loaded is not None,
'version': loaded['version'] if loaded else None,
'loaded_at': loaded['loaded_at'] if loaded else None,
'error_info': dict(info) if info is not None else None,
}
return records
def record_update(self, plugin_id: str) -> None:
"""Record that plugin update() was called."""
self._last_update[plugin_id] = datetime.now()
@@ -221,8 +309,13 @@ class PluginStateManager:
state.
"""
with self._lock:
had = (plugin_id in self._states or plugin_id in self._loaded
or plugin_id in self._error_info)
self._states.pop(plugin_id, None)
self._state_transition_counts.pop(plugin_id, None)
self._error_info.pop(plugin_id, None)
self._last_update.pop(plugin_id, None)
self._loaded.pop(plugin_id, None)
if had:
self._note_change()
+32 -2
View File
@@ -127,8 +127,9 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
"description": "Enable live priority takeover when plugin has live content"
},
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
# Left untyped: the adapter validates them itself and ignores a bad
# value with a log line, so a stored one must never block a save.
# These three are left untyped: the adapter validates them itself and
# ignores a bad value with a log line, so a stored one must never block a
# save.
"vegas_width_pct": {
"description": "Vegas mode: width of this plugin's card, as a percentage of the panel"
},
@@ -138,6 +139,34 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
"vegas_max_width_screens": {
"description": "Vegas mode: widest this plugin's card may be, in screens"
},
# Read by resolve_vegas_participation / BasePlugin.get_vegas_participation.
# An enum with no default: a default would be written into every plugin's
# config and override the participation the plugin itself declares.
"vegas_participation": {
"type": "string",
"enum": ["scroll", "pause", "exclude"],
"title": "Vegas participation",
"description": (
"Vegas mode: how this plugin takes part in the scrolling ticker. "
"'scroll' = its content scrolls by with everything else; "
"'pause' = the ticker stops for this plugin's turn and shows it "
"full screen for its display duration; "
"'exclude' = leave it out of Vegas mode. "
"Leave unset to use the plugin's own default."
),
},
# Read by vegas_mode/plugin_adapter.py (PluginAdapter.is_live_capable).
# No default, for the same reason: unset means on.
"vegas_live": {
"type": "boolean",
"title": "Update in the Vegas ticker",
"description": (
"Vegas mode: for a plugin with live elements (scores, the flight "
"map), change what is already scrolling when its data changes. "
"Off shows each card as it was when it was drawn, as before. "
"Leave unset for on."
),
},
}
#: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin
@@ -145,6 +174,7 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
#: PluginManager.CORE_OWNED_CONFIG_KEYS).
CORE_VEGAS_TUNING_KEYS = frozenset({
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
'vegas_participation', 'vegas_live',
})
-343
View File
@@ -1,343 +0,0 @@
"""
Centralized plugin state management.
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
with persistence.
"""
import json
import threading
from typing import Dict, Any, Optional
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict
from enum import Enum
from src.config_manager_atomic import atomic_write_text
from src.logging_config import get_logger
class PluginStateStatus(Enum):
"""Status of a plugin."""
INSTALLED = "installed"
ENABLED = "enabled"
DISABLED = "disabled"
ERROR = "error"
UNKNOWN = "unknown"
@dataclass
class PluginState:
"""Represents the state of a plugin."""
plugin_id: str
status: PluginStateStatus
enabled: bool
version: Optional[str] = None
installed_at: Optional[datetime] = None
last_updated: Optional[datetime] = None
# 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):
if self.metadata is None:
self.metadata = {}
def to_dict(self) -> Dict[str, Any]:
"""Convert state to dictionary for serialization."""
result = asdict(self)
# Convert enum to string
result['status'] = self.status.value
# Convert datetime to ISO string
if result.get('installed_at'):
result['installed_at'] = self.installed_at.isoformat()
if result.get('last_updated'):
result['last_updated'] = self.last_updated.isoformat()
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PluginState':
"""Create state from dictionary."""
# Parse enum
if isinstance(data.get('status'), str):
data['status'] = PluginStateStatus(data['status'])
# Parse datetime
if data.get('installed_at') and isinstance(data['installed_at'], str):
data['installed_at'] = datetime.fromisoformat(data['installed_at'])
if data.get('last_updated') and isinstance(data['last_updated'], str):
data['last_updated'] = datetime.fromisoformat(data['last_updated'])
return cls(**data)
class PluginStateManager:
"""
Centralized plugin state manager.
Provides:
- Single source of truth for plugin state
- State persistence
"""
def __init__(
self,
state_file: Optional[str] = None,
auto_save: bool = True,
lazy_load: bool = False
):
"""
Initialize state manager.
Args:
state_file: Path to file for persisting state
auto_save: Whether to automatically save state on changes
lazy_load: If True, defer loading state file until first access
"""
self.logger = get_logger(__name__)
self.state_file = Path(state_file) if state_file else None
self.auto_save = auto_save
self._lazy_load = lazy_load
self._state_loaded = False
# 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
self._lock = threading.RLock()
# Load state from file if it exists (unless lazy loading)
if not self._lazy_load and self.state_file and self.state_file.exists():
self._load_state()
self._state_loaded = True
def _ensure_loaded(self) -> None:
"""Ensure state is loaded (for lazy loading)."""
if not self._state_loaded and self.state_file and self.state_file.exists():
self._load_state()
self._state_loaded = True
def get_plugin_state(self, plugin_id: str) -> Optional[PluginState]:
"""
Get state for a plugin.
Args:
plugin_id: Plugin identifier
Returns:
PluginState if found, None otherwise
"""
self._ensure_loaded()
with self._lock:
return self._states.get(plugin_id)
def get_all_states(self) -> Dict[str, PluginState]:
"""
Get all plugin states.
Returns:
Dictionary mapping plugin_id to PluginState
"""
self._ensure_loaded()
with self._lock:
return self._states.copy()
def update_plugin_state(
self,
plugin_id: str,
updates: Dict[str, Any]
) -> bool:
"""
Update plugin state.
Args:
plugin_id: Plugin identifier
updates: Dictionary of state updates
Returns:
True if update successful
"""
self._ensure_loaded()
with self._lock:
# Get current state or create new
current_state = self._states.get(plugin_id)
if not current_state:
current_state = PluginState(
plugin_id=plugin_id,
status=PluginStateStatus.UNKNOWN,
enabled=False
)
# Apply updates
if 'status' in updates:
if isinstance(updates['status'], str):
current_state.status = PluginStateStatus(updates['status'])
else:
current_state.status = updates['status']
if 'enabled' in updates:
current_state.enabled = bool(updates['enabled'])
if 'version' in updates:
current_state.version = updates['version']
if 'installed_at' in updates:
current_state.installed_at = updates['installed_at']
if 'last_updated' in updates:
current_state.last_updated = updates['last_updated']
else:
current_state.last_updated = datetime.now()
if 'metadata' in updates:
if current_state.metadata is None:
current_state.metadata = {}
current_state.metadata.update(updates['metadata'])
current_state.config_version += 1
# Store updated state
self._states[plugin_id] = current_state
# Auto-save if enabled
if self.auto_save:
self._save_state()
return True
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> bool:
"""
Set plugin enabled/disabled state.
Args:
plugin_id: Plugin identifier
enabled: Whether plugin is enabled
Returns:
True if update successful
"""
status = PluginStateStatus.ENABLED if enabled else PluginStateStatus.DISABLED
return self.update_plugin_state(
plugin_id,
{
'enabled': enabled,
'status': status
}
)
def set_plugin_installed(
self,
plugin_id: str,
version: Optional[str] = None,
installed_at: Optional[datetime] = None
) -> bool:
"""
Mark plugin as installed.
Args:
plugin_id: Plugin identifier
version: Plugin version
installed_at: Installation timestamp
Returns:
True if update successful
"""
return self.update_plugin_state(
plugin_id,
{
'status': PluginStateStatus.INSTALLED,
'version': version,
'installed_at': installed_at or datetime.now()
}
)
def remove_plugin_state(self, plugin_id: str) -> bool:
"""
Remove plugin state (e.g., after uninstall).
Args:
plugin_id: Plugin identifier
Returns:
True if removal successful
"""
self._ensure_loaded()
with self._lock:
if plugin_id in self._states:
del self._states[plugin_id]
# Auto-save if enabled
if self.auto_save:
self._save_state()
return True
return False
def _save_state(self) -> None:
"""Save state to file."""
if not self.state_file:
return
try:
# The write stays under the lock and goes through a temp file:
# Flask serves requests on threads, and two saves racing on a
# plain open('w') could interleave or leave a truncated file
# that _load_state then drops wholesale.
with self._lock:
# Convert states to dicts
states_data = {
plugin_id: state.to_dict()
for plugin_id, state in self._states.items()
}
state_data = {
'version': self._state_version,
'states': states_data,
'last_updated': datetime.now().isoformat()
}
# Ensure directory exists with proper permissions
from src.common.permission_utils import (
ensure_directory_permissions,
get_config_dir_mode
)
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
# Write to file
atomic_write_text(self.state_file, json.dumps(state_data, indent=2))
except Exception as e:
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
def _load_state(self) -> None:
"""Load state from file."""
if not self.state_file or not self.state_file.exists():
return
try:
with open(self.state_file, 'r', encoding='utf-8') as f:
state_data = json.load(f)
with self._lock:
# Load state version
self._state_version = state_data.get('version', 1)
# Load states
states_data = state_data.get('states', {})
for plugin_id, state_dict in states_data.items():
try:
self._states[plugin_id] = PluginState.from_dict(state_dict)
except Exception as e:
self.logger.warning(
f"Error loading state for plugin {plugin_id}: {e}"
)
self.logger.info(f"Loaded {len(self._states)} plugin states from file")
except Exception as e:
self.logger.error(f"Error loading plugin state: {e}", exc_info=True)
+126 -100
View File
@@ -1,22 +1,34 @@
"""
State reconciliation system.
Detects and fixes inconsistencies between:
- Config file state
- Plugin manager state
- Disk state (installed plugins)
- State manager state
Compares what the user wants with what is there and what runs:
- desired: config.json (which plugins are configured, and enabled) plus the
plugins directory on disk (which are installed, at which version);
- observed: the runtime snapshot the display publishes
(src/plugin_system/plugin_runtime.py) -- which plugins it has loaded, at
which version, and why one failed. Only a live snapshot is compared; a
stale, stopped or missing one is unknown and yields no findings.
Desired-state gaps (on disk but not in config, in config but not on disk)
are fixed here. Observed-state gaps (enabled but not loaded, loaded at an
older version) are reported, never "fixed": the display reconciles its own
loaded set against config, and a version gap needs a display restart.
There is no third, persisted record any more. ``data/plugin_state.json``
held a copy of config's enabled flags and the disk's versions, and this
module mostly synced it back to config; it is no longer read or written.
"""
import json
from typing import Dict, Any, List, Set, cast
from typing import Any, Callable, Dict, List, Optional, Set
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.plugin_runtime import PluginRuntimeView, UNKNOWN
from src.logging_config import get_logger
@@ -160,36 +172,40 @@ def still_unresolved(entries: List[Dict[str, Any]],
return live
RuntimeSource = Callable[[], PluginRuntimeView]
class StateReconciliation:
"""
State reconciliation system.
Compares state from multiple sources and detects/fixes inconsistencies.
Compares desired state (config + disk) with observed state (the
display's runtime snapshot) and fixes what can safely be fixed.
"""
def __init__(
self,
state_manager: PluginStateManager,
*,
config_manager,
plugin_manager,
plugins_dir: Path,
store_manager=None
store_manager=None,
runtime_source: Optional[RuntimeSource] = None,
):
"""
Initialize reconciliation system.
Args:
state_manager: PluginStateManager instance
config_manager: ConfigManager instance
plugin_manager: PluginManager instance
plugins_dir: Path to plugins directory
store_manager: Optional PluginStoreManager for auto-repair
runtime_source: Returns the display's runtime snapshot as a
PluginRuntimeView (plugin_runtime.read_plugin_runtime bound to
a cache manager). None: observed state is unknown.
"""
self.state_manager = state_manager
self.config_manager = config_manager
self.plugin_manager = plugin_manager
self.plugins_dir = Path(plugins_dir)
self.store_manager = store_manager
self.runtime_source = runtime_source
self.logger = get_logger(__name__)
# Plugin IDs that failed auto-repair and should NOT be retried this
@@ -230,30 +246,28 @@ class StateReconciliation:
manual_fix_required = []
try:
# Get state from all sources
# Desired: config + disk. Observed: the display's snapshot.
config_state = self._get_config_state()
disk_state = self._get_disk_state()
manager_state = self._get_manager_state()
state_manager_state = self._get_state_manager_state()
# Find all unique plugin IDs
observed = self._get_observed_state()
# Plugins the display reports but neither config nor disk knows
# (removed while it still runs them) are not a finding of their
# own: the display unloads them when their section goes.
all_plugin_ids: Set[str] = set()
all_plugin_ids.update(config_state.keys())
all_plugin_ids.update(disk_state.keys())
all_plugin_ids.update(manager_state.keys())
all_plugin_ids.update(state_manager_state.keys())
# Check each plugin for inconsistencies
for plugin_id in all_plugin_ids:
plugin_inconsistencies = self._check_plugin_consistency(
plugin_id,
config_state,
disk_state,
manager_state,
state_manager_state
observed,
)
inconsistencies.extend(plugin_inconsistencies)
# Attempt to fix auto-fixable inconsistencies
for inconsistency in inconsistencies:
if inconsistency.can_auto_fix and inconsistency.fix_action == FixAction.AUTO_FIX:
@@ -293,11 +307,11 @@ class StateReconciliation:
# Top-level config keys that are NOT plugins. The core keys come from the
# shared list in src/core_config_keys.py -- a private copy here missed
# #581's 'auto_update' and reported it as a plugin missing from disk.
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
# CORE_SECRETS_KEYS are the core's own secrets-file keys. The secrets file
# itself is read at run time too (ignored_config_keys): load_config() merges
# it in, and naming its keys one by one let a 'data' key become a phantom
# plugin permanently reported as "in config but not on disk".
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | CORE_SECRETS_KEYS
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file."""
@@ -309,8 +323,9 @@ class StateReconciliation:
for plugin_id in config_plugin_ids(config, ignored):
plugin_config = config[plugin_id]
state[plugin_id] = {
'enabled': plugin_config.get('enabled', True),
'version': plugin_config.get('version'),
# The display's rule: it runs a plugin only when its
# section says "enabled": true.
'enabled': bool(plugin_config.get('enabled', False)),
'exists_in_config': True
}
except Exception as e:
@@ -339,45 +354,51 @@ class StateReconciliation:
self.logger.warning(f"Error reading disk state: {e}")
return state
def _get_manager_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from plugin manager."""
state = {}
def _get_observed_state(self) -> PluginRuntimeView:
"""The display's runtime snapshot; unknown when there is no source or
it cannot be read. Only a live view is compared."""
if self.runtime_source is None:
return PluginRuntimeView(status=UNKNOWN)
try:
if self.plugin_manager:
# Get discovered plugins
if hasattr(self.plugin_manager, 'plugin_manifests'):
for plugin_id in self.plugin_manager.plugin_manifests.keys():
state[plugin_id] = {
'exists_in_manager': True,
'loaded': plugin_id in getattr(self.plugin_manager, 'plugins', {})
}
return self.runtime_source()
except Exception as e:
self.logger.warning(f"Error reading manager state: {e}")
return state
def _get_state_manager_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from state manager."""
state = {}
try:
all_states = self.state_manager.get_all_states()
for plugin_id, plugin_state in all_states.items():
state[plugin_id] = {
'enabled': plugin_state.enabled,
'status': plugin_state.status.value,
'version': plugin_state.version,
'exists_in_state_manager': True
}
except Exception as e:
self.logger.warning(f"Error reading state manager state: {e}")
return state
self.logger.warning(f"Error reading the display's runtime state: {e}")
return PluginRuntimeView(status=UNKNOWN)
def plugin_states(self) -> Dict[str, Dict[str, Any]]:
"""Desired and observed state for every plugin config or disk knows.
Per plugin: ``installed`` and ``version`` (disk), ``in_config`` and
``enabled`` (config, by the display's rule), and the display's
``loaded`` / ``state`` / ``error_info`` / ``loaded_version`` /
``loaded_at`` (None unless its snapshot is live). What
/api/v3/plugins/state serves, in place of plugin_state.json.
"""
config_state = self._get_config_state()
disk_state = self._get_disk_state()
observed = self._get_observed_state()
states: Dict[str, Dict[str, Any]] = {}
for plugin_id in sorted(set(config_state) | set(disk_state)):
if plugin_id in CORE_CONFIG_KEYS:
continue
config = config_state.get(plugin_id, {})
disk = disk_state.get(plugin_id, {})
states[plugin_id] = {
'plugin_id': plugin_id,
'installed': bool(disk.get('exists_on_disk')),
'version': disk.get('version'),
'in_config': bool(config.get('exists_in_config')),
'enabled': bool(config.get('enabled', False)),
**observed.plugin(plugin_id),
}
return states
def _check_plugin_consistency(
self,
plugin_id: str,
config_state: Dict[str, Dict[str, Any]],
disk_state: Dict[str, Dict[str, Any]],
manager_state: Dict[str, Dict[str, Any]],
state_manager_state: Dict[str, Dict[str, Any]]
observed: PluginRuntimeView,
) -> List[Inconsistency]:
"""Check consistency for a single plugin."""
inconsistencies: List[Inconsistency] = []
@@ -397,7 +418,6 @@ class StateReconciliation:
config = config_state.get(plugin_id, {})
disk = disk_state.get(plugin_id, {})
state_mgr = state_manager_state.get(plugin_id, {})
# Check: Plugin exists on disk but not in config
if disk.get('exists_on_disk') and not config.get('exists_in_config'):
@@ -442,21 +462,47 @@ class StateReconciliation:
can_auto_fix=can_repair
))
# Check: Enabled state mismatch
config_enabled = config.get('enabled', False)
state_mgr_enabled = state_mgr.get('enabled')
# Observed checks: only against a live snapshot, and only for a plugin
# that is both configured and installed (the checks above cover the
# rest). Reported, never fixed here: the display loads and unloads by
# config on its own, so a gap is either transient (it is catching up)
# or something only the user can act on (a failed load, a restart).
if (observed.live and config.get('exists_in_config')
and disk.get('exists_on_disk')):
runtime = observed.plugin(plugin_id)
config_enabled = bool(config.get('enabled', False))
loaded = bool(runtime.get('loaded'))
if config_enabled != loaded:
error = runtime.get('error_info') or {}
why = f" ({error.get('message')})" if error.get('message') else ""
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
description=(
f"Plugin {plugin_id} is {'enabled' if config_enabled else 'disabled'} "
f"in config but the display has it "
f"{'loaded' if loaded else 'not loaded'} "
f"(state {runtime.get('state')}){why}"),
fix_action=FixAction.NO_ACTION,
current_state={'loaded': loaded, 'state': runtime.get('state')},
expected_state={'loaded': config_enabled},
can_auto_fix=False
))
loaded_version = runtime.get('loaded_version')
disk_version = disk.get('version')
if loaded and loaded_version and disk_version and loaded_version != disk_version:
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_VERSION_MISMATCH,
description=(
f"Plugin {plugin_id} {disk_version} is installed but the display "
f"is running {loaded_version}; restart the display to run it"),
fix_action=FixAction.NO_ACTION,
current_state={'version': loaded_version},
expected_state={'version': disk_version},
can_auto_fix=False
))
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
inconsistencies.append(Inconsistency(
plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
fix_action=FixAction.AUTO_FIX,
current_state={'enabled': state_mgr_enabled},
expected_state={'enabled': config_enabled},
can_auto_fix=True
))
return inconsistencies
def _fix_inconsistency(self, inconsistency: Inconsistency) -> bool:
@@ -490,26 +536,6 @@ class StateReconciliation:
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK:
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
# config.json is the user-editable source of truth for enabled state.
# Bring the state manager in sync with config rather than the reverse,
# so that manual config edits (or the state left behind after an
# uninstall+reinstall cycle) don't silently override the user's intent.
# Always set for this type (see _check_plugin_consistency).
config_enabled = cast(bool, inconsistency.expected_state.get('enabled'))
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
if success:
self.logger.info(
f"Fixed: Synced state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id} to match config"
)
else:
self.logger.warning(
f"Failed to sync state manager enabled={config_enabled} for "
f"{inconsistency.plugin_id}"
)
return success
except Exception as e:
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
+44 -4
View File
@@ -63,7 +63,14 @@ class _InstallMixin:
return False
with self._get_reinstall_lock(plugin_id):
plugin_path = self.plugins_dir / plugin_id
# The copy to protect is wherever this plugin is installed, not
# necessarily plugins_dir/<id>: asked for the registry id
# `weather`, the install lives in `ledmatrix-weather/`, the
# manifest's id. Backing up only `weather/` protected nothing,
# and _install_plugin_impl then deleted `ledmatrix-weather/` to
# make room for the download -- so a refusal after that point
# (the post-download compatibility gate) left no plugin at all.
plugin_path = self._existing_install(plugin_id) or self.plugins_dir / plugin_id
if not plugin_path.exists():
return self._install_plugin_impl(plugin_id, branch)
@@ -91,6 +98,26 @@ class _InstallMixin:
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
return False
def _existing_install(self, plugin_id: str) -> Optional[Path]:
"""The installed copy of ``plugin_id`` in plugins_dir, by the id or an
alias the registry proves (`_installed_id_candidates`).
When nothing matches but a ``ledmatrix-<id>`` folder exists and no
registry is loaded yet, the registry is fetched first -- the install
fetches it anyway -- because without it that folder can be neither
protected nor trusted: the download may be renamed onto it.
"""
dirs = [self.plugins_dir]
found = self._resolve_installed(plugin_id, dirs)
if (found is None and not getattr(self, 'registry_cache', None)
and self._unproven_prefix_folder(plugin_id, dirs) is not None):
try:
self.fetch_registry()
except Exception as e: # noqa: BLE001 - proceed as before without proof
self.logger.debug("Registry fetch before installing %s failed: %s", plugin_id, e)
found = self._resolve_installed(plugin_id, dirs)
return found
def _set_aside(self, plugin_path: Path, backup_path: Path) -> Optional[str]:
"""Rename an installed plugin to ``backup_path`` so a failed
(re)install can put it back.
@@ -164,6 +191,16 @@ class _InstallMixin:
self.logger.error(f"Plugin {plugin_id} missing repository URL")
return False
# The registry's floor describes the release on the entry's branch.
# Checked here, before anything is removed or downloaded; the gate on
# the downloaded manifest below stays as the fallback (older
# registries, compatible_versions ranges). A different branch asked
# for by name is a different release, so only the fallback applies.
registry_branch = plugin_info.get('branch') or plugin_info.get('default_branch')
if (not branch or not registry_branch or branch == registry_branch) and \
self._refuse_if_registry_incompatible(plugin_id, plugin_info, "install"):
return False
plugin_subpath = plugin_info.get('plugin_path')
# If branch is provided, prioritize it; otherwise use default logic
branch_candidates = self._distinct_sequence([
@@ -280,9 +317,11 @@ class _InstallMixin:
return False
# Refuse a plugin that needs a newer core than this one. The
# registry carries no compatibility field, so the floor is only
# knowable once the files are down — checking here, before
# dependency installation, is the earliest possible point.
# registry's `ledmatrix_min_version` already refused the
# common case before the download (above); this is the
# fallback for a registry without it, a branch other than the
# registry's, and `compatible_versions`, which only the
# manifest carries. Before dependency installation, still.
#
# Refusing costs the user nothing: on an update this returns
# False and _reinstall_with_rollback restores the version they
@@ -299,6 +338,7 @@ class _InstallMixin:
if not compatible:
self.logger.error(
"Refusing to install %s: %s", plugin_id, reason)
self._note_refusal(requested_id, reason)
self._safe_remove_directory(plugin_path)
return False
+64 -11
View File
@@ -21,7 +21,7 @@ from src.plugin_system.plugin_dirs import (
PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
)
from src.plugin_system.store_install import _InstallMixin
from src.plugin_system.store_registry import _RegistryMixin
from src.plugin_system.store_registry import _RegistryMixin, prefix_hint
from src.plugin_system.store_update import _UpdateMixin
@@ -407,13 +407,20 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
alone reported such a plugin as not installed, so update_plugin()
silently did nothing.
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
a store operation may delete what this returns, so it only accepts a
directory that names the id exactly or declares it. So a registry id
such as `stocks` does not resolve to an installed `ledmatrix-stocks/`
declaring `ledmatrix-stocks` (the monorepo's leaderboard, music,
stocks and weather); callers pass the installed id, and
update_plugin() maps it back to the registry id itself.
When nothing answers to the id itself, the ids the registry proves
are the same plugin are tried the same way
(`_installed_id_candidates`): the entry's own id, its ``aliases`` and
its ``plugin_path`` name. So the registry id `stocks` finds an
installed `ledmatrix-stocks/` declaring `ledmatrix-stocks` (the
monorepo's leaderboard, music, stocks and weather), and uninstalling
by the registry id no longer reports success while leaving the
plugin on disk.
Never ``ledmatrix-<id>`` without that proof -- no registry loaded, or
an entry that doesn't name it: a store operation may delete or
replace what this returns, and an unrelated plugin can own that
folder. Such a folder is only logged, so a person can act on it.
Still no case folding.
Args:
plugin_id: Plugin identifier
@@ -421,9 +428,55 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
Returns:
Path to plugin directory if found, None otherwise
"""
return resolve_plugin_dir(
plugin_id, self._candidate_plugin_dirs(), prefix=False,
case_insensitive=False)
return self._find_with_proof(plugin_id, fetch=False)
def _find_with_proof(self, plugin_id: str, fetch: bool) -> Optional[Path]:
"""`_find_plugin_path`; with ``fetch``, a ``ledmatrix-<id>`` folder
found while no registry is loaded makes it fetch the registry and
look again, since only the registry can prove the folder is this
plugin. Uninstall passes False (it must work offline); update, which
needs the network anyway, passes True."""
search_dirs = self._candidate_plugin_dirs()
found = self._resolve_installed(plugin_id, search_dirs)
if found is not None:
return found
folder = self._unproven_prefix_folder(plugin_id, search_dirs)
if folder is not None and fetch and not getattr(self, 'registry_cache', None):
try:
self.fetch_registry()
except Exception as e: # noqa: BLE001 - fall through to "not found"
self.logger.debug("Registry fetch while looking for %s failed: %s", plugin_id, e)
found = self._resolve_installed(plugin_id, search_dirs)
if found is not None:
return found
if folder is not None:
self.logger.warning(
"Plugin %s not found. %s may be it, but nothing in the plugin "
"registry says so (no alias), so the store leaves it alone; "
"if it is this plugin, manage it as %s.",
plugin_id, folder, prefix_hint(plugin_id))
return None
@staticmethod
def _unproven_prefix_folder(plugin_id: str, search_dirs: List[Path]) -> Optional[Path]:
"""A ``ledmatrix-<id>`` folder, which the store names but won't touch."""
hint = prefix_hint(plugin_id)
if hint is None:
return None
return resolve_plugin_dir(hint, search_dirs, prefix=False, by_manifest=False)
def _resolve_installed(self, plugin_id: str, search_dirs: List[Path]) -> Optional[Path]:
"""The first of ``plugin_id``'s candidate ids found in ``search_dirs``.
The id itself is looked for in every directory before any alias is,
so an exact install anywhere beats an alias in the configured one.
"""
for candidate in self._installed_id_candidates(plugin_id):
found = resolve_plugin_dir(
candidate, search_dirs, prefix=False, case_insensitive=False)
if found is not None:
return found
return None
def _candidate_plugin_dirs(self) -> List[Path]:
"""Directories that may hold installed plugins, configured one first."""
+150 -1
View File
@@ -13,11 +13,62 @@ from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any
from jsonschema import Draft7Validator, ValidationError
from src.plugin_system.plugin_dirs import PLUGIN_DIR_PREFIX
from src.plugin_system.repo_urls import (
github_api_headers, github_owner_repo, normalize_repo_url,
)
# Registry entry fields the plugin monorepo's update_registry.py added after
# 3.7.0. All optional: an older plugins.json has none of them, and every
# reader here treats a missing or malformed one as "not stated".
#
# - ``ledmatrix_min_version``: the floor the plugin's manifest declares, so an
# incompatible install or update is refused before the download
# (`registry_incompatibility`). The post-download gate stays as the fallback.
# - ``aliases``: other ids the plugin goes by (the manifest id when it differs
# from the registry id, e.g. ``ledmatrix-weather`` for ``weather``). With
# ``plugin_path``'s name, the only proof the store accepts that a folder
# under another name is this plugin (`alternate_ids`).
# - ``commit``: the monorepo commit that introduced ``latest_version``.
# Informational only -- installs still come from the branch head.
def declared_aliases(entry: Dict[str, Any]) -> Optional[List[str]]:
"""The entry's ``aliases``, or None when it carries no such list."""
aliases = entry.get('aliases')
if not isinstance(aliases, list):
return None
own = entry.get('id')
return [a for a in aliases if isinstance(a, str) and a and a != own]
def alternate_ids(entry: Dict[str, Any]) -> List[str]:
"""Ids other than the registry id that the registry *proves* an installed
copy may carry: the entry's ``aliases``, then its ``plugin_path``
directory name (all an older registry has).
Never ``ledmatrix-<id>`` on its own say-so. Store operations delete and
replace what these ids resolve to, and an unrelated plugin can live in a
folder of that name (owner decision on #686). A guess is only a hint:
see `prefix_hint`.
"""
own = entry.get('id')
ids: List[str] = list(declared_aliases(entry) or [])
path = entry.get('plugin_path')
if isinstance(path, str) and path.strip('/'):
ids.append(path.rstrip('/').rsplit('/', 1)[-1])
return [g for i, g in enumerate(ids) if g and g != own and g not in ids[:i]]
def prefix_hint(plugin_id: Any) -> Optional[str]:
"""``ledmatrix-<id>``: the legacy folder name worth *mentioning* when
``plugin_id`` is not found -- never one to act on without registry proof."""
if isinstance(plugin_id, str) and plugin_id and not plugin_id.startswith(PLUGIN_DIR_PREFIX):
return PLUGIN_DIR_PREFIX + plugin_id
return None
class _RegistryMixin:
"""PluginStoreManager methods: see the module docstring."""
@@ -821,19 +872,117 @@ class _RegistryMixin:
Matching ``plugin_path`` fixes it without renaming any published id,
which would orphan ``plugin_state.json`` entries keyed on the old ones.
Exact id always wins, so an entry whose *path* happens to collide with
another entry's id cannot shadow it.
another entry's id cannot shadow it. An entry's ``aliases`` (registries
from after 3.7.0) come next, then ``plugin_path``, which is what an
older registry has to go on.
"""
if not plugin_id:
return None
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
if exact is not None:
return exact
for entry in plugins:
if plugin_id in (declared_aliases(entry) or ()):
return entry
for entry in plugins:
path = (entry.get('plugin_path') or '').rstrip('/')
if path and path.rsplit('/', 1)[-1] == plugin_id:
return entry
return None
def registry_incompatibility(self, plugin_id: str,
entry: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Why the registry says this core cannot run the plugin's latest
release, or None when it says nothing against it.
Reads the entry's ``ledmatrix_min_version`` and asks
``compatibility.check`` -- the same function, and so the same wording
and the same leniency (an untrustworthy or unparseable core version
allows), as the gate that runs on the downloaded manifest. That gate
stays: it also sees ``compatible_versions``, and an older registry
without the field says nothing here.
``entry`` defaults to the registry entry for ``plugin_id``. Any
failure to read the registry answers None: the pre-check exists to
refuse early on evidence, never to block on a guess.
"""
if entry is None:
try:
entry = self.get_registry_info(plugin_id)
except Exception as e: # noqa: BLE001 - never block an install on this
self.logger.debug("Registry lookup for %s failed: %s", plugin_id, e)
return None
if not isinstance(entry, dict):
return None
floor = entry.get('ledmatrix_min_version')
if not isinstance(floor, str) or not floor.strip():
return None
from src.plugin_system import compatibility
compatible, reason = compatibility.check(
{'id': entry.get('id') or plugin_id, 'name': entry.get('name'),
'min_ledmatrix_version': floor.strip()},
compatibility.current_core_version())
return None if compatible else reason
def _refuse_if_registry_incompatible(self, plugin_id: str, entry: Optional[Dict[str, Any]],
action: str, record_as: Optional[str] = None) -> bool:
"""Log and record a registry-based refusal; True when refused.
``record_as`` is the id the caller will ask `pop_refusal` about (the
id it was handed, which may be an alias of ``plugin_id``).
"""
reason = self.registry_incompatibility(plugin_id, entry)
if reason is None:
return False
self.logger.error("Refusing to %s %s before downloading it: %s",
action, plugin_id, reason)
self._note_refusal(record_as or plugin_id, reason)
return True
def _note_refusal(self, plugin_id: str, reason: str) -> None:
"""Remember why an install or update of ``plugin_id`` was refused, so
the web route can say so instead of "check logs for details"."""
refusals = self.__dict__.setdefault('_refusals', {})
refusals[plugin_id] = reason
def pop_refusal(self, *plugin_ids: str) -> Optional[str]:
"""The compatibility refusal recorded for any of ``plugin_ids`` since
the last call, clearing them all; None when there was none."""
refusals = self.__dict__.get('_refusals') or {}
found = None
for plugin_id in plugin_ids:
reason = refusals.pop(plugin_id, None)
if found is None and reason:
found = reason
return found
def _installed_id_candidates(self, plugin_id: str) -> List[str]:
"""``plugin_id`` and the other ids the registry proves its installed
copy may carry.
From the registry already in memory -- no fetch, because uninstall
and the update lookup must work offline. With an entry: its id and
`alternate_ids` (``aliases``, ``plugin_path`` name). Without one (no
registry loaded yet, or a plugin that isn't in it): the id alone.
A folder whose manifest declares one of these ids is found by the
resolver's manifest pass whatever it is called.
"""
ids: List[str] = [plugin_id]
cache = getattr(self, 'registry_cache', None)
plugins = cache.get('plugins') if isinstance(cache, dict) else None
entry = None
if isinstance(plugins, list) and isinstance(plugin_id, str):
entry = self._match_registry_entry(
[p for p in plugins if isinstance(p, dict)], plugin_id)
if entry is not None:
ids.append(entry.get('id'))
ids.extend(alternate_ids(entry))
unique: List[str] = []
for candidate in ids:
if isinstance(candidate, str) and candidate and candidate not in unique:
unique.append(candidate)
return unique
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry cache only (no GitHub API calls).
+30 -6
View File
@@ -195,10 +195,12 @@ class _UpdateMixin:
surfaces as one line in the journal and a scoreboard that silently
stopped appearing.
Checked after the pull rather than before it, for the same reason
``_install_plugin_impl`` checks after the download: the registry
carries no compatibility field, so the incoming floor is only knowable
once the new commit is on disk.
The registry's ``ledmatrix_min_version`` refuses most of these before
the pull (``update_plugin``). This is the fallback, for the same cases
``_install_plugin_impl``'s post-download gate covers: a registry
without the field, a checkout on another branch than the registry's,
and ``compatible_versions`` -- all only knowable once the new commit
is on disk.
Undone with ``git reset --hard`` rather than by removing the directory.
This is a live checkout, the previous commit is still in the object
@@ -233,6 +235,7 @@ class _UpdateMixin:
return True
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
self._note_refusal(plugin_id, reason)
if not previous_sha:
self.logger.error(
@@ -310,8 +313,10 @@ class _UpdateMixin:
"""
Update a plugin to the latest commit on its upstream branch.
"""
plugin_path = self._find_plugin_path(plugin_id)
# fetch=True: an update needs the registry anyway, and only it can
# prove a ledmatrix-<id>/ folder is this plugin.
plugin_path = self._find_with_proof(plugin_id, fetch=True)
if plugin_path is None or not plugin_path.exists():
self.logger.error(f"Plugin not installed: {plugin_id}")
return False
@@ -368,6 +373,11 @@ class _UpdateMixin:
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Reinstalling from registry to migrate to new source."
)
# Before the old copy is moved aside: the reinstall
# would only refuse after a download and a restore.
if self._refuse_if_registry_incompatible(
resolved_id, plugin_info_remote, "update", record_as=plugin_id):
return False
return self._reinstall_with_rollback(resolved_id, plugin_path)
# Check if already up to date
@@ -375,6 +385,14 @@ class _UpdateMixin:
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
return True
# The registry's floor describes its branch; a checkout
# on another branch pulls another release, and the gate
# after the pull (_gate_pulled_commit) still covers it.
if (not remote_branch or remote_branch == local_branch) and \
self._refuse_if_registry_incompatible(
resolved_id, plugin_info_remote, "update", record_as=plugin_id):
return False
# Update via git pull
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
try:
@@ -718,6 +736,12 @@ class _UpdateMixin:
except Exception as e:
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
# A newer version this core cannot run: refuse now, while the
# installed copy is untouched, rather than after a download.
if self._refuse_if_registry_incompatible(
registry_id, plugin_info_remote, "update", record_as=plugin_id):
return False
# Plugin is not a git repo but is in registry and has a newer version - reinstall
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
+3 -1
View File
@@ -19,7 +19,7 @@ from datetime import timedelta
import socket
import ssl
import urllib.error
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@@ -84,6 +84,8 @@ class RenderResult:
fill_checked: bool = False
fill_ok: Optional[bool] = None # False only in strict mode
fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y)
# warnings worth printing that do not fail the result
notes: List[str] = field(default_factory=list)
@property
def size_label(self) -> str:
+393
View File
@@ -0,0 +1,393 @@
"""Offline checks for a plugin's live Vegas elements.
A plugin that implements ``get_vegas_elements()`` promises the ticker a few
things it cannot check for itself until they go wrong on a panel: unique,
stable keys; images at the display's height; the same width for the same key
until the data changes; the same result when nothing changed; and, for an
element with ``refresh_hz``, a ``redraw_vegas_element()`` that returns exactly
the size asked for, quickly. :func:`check_vegas_elements` exercises each of
those the way the Vegas ticker calls the hooks -- on a canvas of the plugin's
own, told its render width -- and says what failed.
``scripts/check_plugin.py`` runs it for every plugin that implements the hook.
See "Live Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
"""
from __future__ import annotations
import time
from contextlib import contextmanager, nullcontext
from dataclasses import dataclass, field
from typing import Any, Iterator, List, Optional
from PIL import Image
#: A warm get_vegas_elements() slower than this holds the ticker's single
#: background worker, and the plugin's lock, for longer than it should.
SLOW_ELEMENTS_SECONDS = 0.2
#: A redraw_vegas_element() slower than this cannot keep up with a few Hz.
SLOW_REDRAW_SECONDS = 0.02
#: The narrowed render width the check also tries, as a share of the panel.
NARROW_PCT = 60
@dataclass
class VegasElementReport:
"""What :func:`check_vegas_elements` found."""
implemented: bool
elements: int = 0
live: int = 0
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def implements_vegas_elements(plugin: Any) -> bool:
"""Whether the plugin's class overrides BasePlugin.get_vegas_elements."""
from src.plugin_system.base_plugin import BasePlugin
method = getattr(type(plugin), 'get_vegas_elements', None)
return method is not None and method is not getattr(
BasePlugin, 'get_vegas_elements', None)
@contextmanager
def _as_vegas_canvas(plugin: Any, display_manager: Any, width: int) -> Iterator[None]:
"""Run a hook the way Vegas does: told its width, on a canvas of its own."""
plugin._vegas_render_width = width
try:
offscreen = getattr(display_manager, 'offscreen', None)
with offscreen(width) if offscreen is not None else nullcontext():
yield
finally:
plugin._vegas_render_width = None
def render_vegas_elements(plugin: Any, display_manager: Any,
width: Optional[int] = None) -> Any:
"""Call ``plugin.get_vegas_elements()`` as the Vegas ticker does."""
render_width = int(width or display_manager.width)
with _as_vegas_canvas(plugin, display_manager, render_width):
return plugin.get_vegas_elements()
def redraw_vegas_element(plugin: Any, display_manager: Any, key: str,
width: int, height: int, at: Optional[float] = None,
render_width: Optional[int] = None) -> Any:
"""Call ``plugin.redraw_vegas_element()`` as the Vegas ticker does."""
with _as_vegas_canvas(plugin, display_manager,
int(render_width or display_manager.width)):
return plugin.redraw_vegas_element(
key, width, height, time.monotonic() if at is None else at)
def _refresh_hz(element: Any) -> Optional[float]:
"""An element's refresh_hz as a number (None counts as 0), or None if it is not one."""
try:
return float(element.refresh_hz or 0.0)
except (TypeError, ValueError):
return None
def _usable(element: Any) -> bool:
"""A VegasElement the checks can read: a str key and an image."""
from src.plugin_system.vegas_elements import VegasElement
return (isinstance(element, VegasElement) and isinstance(element.key, str)
and bool(element.key) and isinstance(element.image, Image.Image))
def check_vegas_elements(plugin: Any, display_manager: Any) -> VegasElementReport:
"""Exercise a plugin's live-element hooks and report what breaks the contract.
Errors are what the ticker would refuse or show wrongly; warnings are what
it would cope with but should not have to (slow calls, an element wider
than the width the plugin was asked to render at).
"""
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
if not report.implemented:
return report
from src.plugin_system.vegas_elements import VegasElement
height = int(display_manager.height)
full = int(display_manager.width)
def fetch(width: int, label: str):
started = time.perf_counter()
try:
result = render_vegas_elements(plugin, display_manager, width)
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
report.errors.append(f"get_vegas_elements() raised {exc!r} ({label})")
return None, 0.0
return result, time.perf_counter() - started
first, _ = fetch(full, "full width")
if first is None:
if not report.errors:
report.warnings.append(
"get_vegas_elements() returned None: the ticker will use "
"get_vegas_content() instead")
return report
if not isinstance(first, (list, tuple)):
report.errors.append(
f"get_vegas_elements() returned {type(first).__name__}, expected a list")
return report
seen = set()
widths = {}
for index, element in enumerate(first):
where = f"element[{index}]"
if not isinstance(element, VegasElement):
report.errors.append(f"{where} is a {type(element).__name__}, not a VegasElement")
continue
key = element.key
if not isinstance(key, str) or not key:
report.errors.append(f"{where} has no key (a non-empty str is required)")
continue
where = f"element {key!r}"
if key in seen:
report.errors.append(f"{where} appears twice; keys must be unique")
continue
seen.add(key)
image: Any = element.image # typed Image, but a plugin may pass anything
if not isinstance(image, Image.Image):
report.errors.append(f"{where} image is a {type(image).__name__}")
continue
if element.image.height != height:
report.errors.append(
f"{where} is {element.image.height}px tall; the display is {height}px")
if element.image.width <= 0 or element.image.height <= 0:
report.errors.append(f"{where} image is empty ({element.image.width}x"
f"{element.image.height})")
continue
if element.image.width > full:
report.warnings.append(
f"{where} is {element.image.width}px wide, wider than the "
f"{full}px it was asked to render at")
hz = _refresh_hz(element)
if hz is None:
report.errors.append(
f"{where} refresh_hz {element.refresh_hz!r} is not a number")
elif hz < 0:
report.errors.append(f"{where} has a negative refresh_hz")
report.elements += 1
if element.live:
report.live += 1
widths[key] = element.image.width
if report.errors:
return report
# The same data twice must give the same keys, widths and versions: the
# ticker redraws on every update and swaps in only what changed.
second, warm = fetch(full, "second call")
if second is not None and not isinstance(second, (list, tuple)):
report.errors.append(
f"get_vegas_elements() returned {type(second).__name__} on a second "
"call, expected a list")
elif isinstance(second, (list, tuple)):
again = {e.key: e for e in second if _usable(e)}
for element in first:
other = again.get(element.key)
if other is None:
report.errors.append(
f"element {element.key!r} disappeared on a second call with "
"no new data")
continue
if element.live and other.image.width != element.image.width:
report.errors.append(
f"element {element.key!r} changed width with no new data "
f"({element.image.width} -> {other.image.width}px); a live "
"element's width must not depend on when it is drawn")
if element.version is not None and other.version != element.version \
and not _refresh_hz(element):
report.warnings.append(
f"element {element.key!r} changed version with no new data; "
"every update will redraw it")
if warm > SLOW_ELEMENTS_SECONDS:
report.warnings.append(
f"get_vegas_elements() took {warm * 1000:.0f}ms with nothing new "
f"(over {SLOW_ELEMENTS_SECONDS * 1000:.0f}ms); cache what has not "
"changed")
narrow = max(1, full * NARROW_PCT // 100)
if narrow < full:
narrowed, _ = fetch(narrow, f"{NARROW_PCT}% width")
if isinstance(narrowed, (list, tuple)):
for element in narrowed:
if isinstance(element, VegasElement) and isinstance(element.image, Image.Image) \
and element.image.width > narrow:
report.warnings.append(
f"element {element.key!r} is {element.image.width}px wide at "
f"a {narrow}px render width; read get_vegas_render_width() "
"or display_manager.width when sizing it")
break
has_redraw = type(plugin).redraw_vegas_element is not _base_redraw()
for element in first:
hz = _refresh_hz(element) or 0.0
if not (element.live and hz > 0):
continue
if not has_redraw:
report.warnings.append(
f"element {element.key!r} asks for {hz:g}Hz but "
"redraw_vegas_element() is not implemented; the ticker re-runs "
"get_vegas_elements() under the plugin's lock instead")
continue
w, h = element.image.width, element.image.height
started = time.perf_counter()
try:
redrawn = redraw_vegas_element(plugin, display_manager, element.key, w, h)
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
report.errors.append(f"redraw_vegas_element({element.key!r}) raised {exc!r}")
continue
took = time.perf_counter() - started
if redrawn is not None:
if not isinstance(redrawn, Image.Image):
report.errors.append(
f"redraw_vegas_element({element.key!r}) returned "
f"{type(redrawn).__name__}, expected an Image or None")
elif redrawn.size != (w, h):
report.errors.append(
f"redraw_vegas_element({element.key!r}) returned "
f"{redrawn.width}x{redrawn.height}, asked for {w}x{h}")
if took > SLOW_REDRAW_SECONDS:
report.warnings.append(
f"redraw_vegas_element({element.key!r}) took {took * 1000:.1f}ms "
f"(over {SLOW_REDRAW_SECONDS * 1000:.0f}ms); the ticker will "
"slow its refresh")
return report
def _base_redraw():
from src.plugin_system.base_plugin import BasePlugin
return BasePlugin.redraw_vegas_element
def render_vegas_strip(plugin: Any, plugin_id: str, display_manager: Any,
live: bool = True) -> Any:
"""The plugin's block of the Vegas strip, laid out exactly as the ticker would.
Fetched through the ticker's own adapter (trimming, pinning, width
budget) and joined with its own spacing, so what this draws is what
scrolls. ``live=False`` shows the ordinary get_vegas_content() instead.
Returns ``(block, layout)`` -- layout a list of ``(x, key, width)`` for the
live elements in the block -- or ``(None, [])`` when there is nothing.
"""
_adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, live)
return block, [(x, meta.key, width) for x, meta, width in layout]
def _vegas_block(plugin: Any, plugin_id: str, display_manager: Any, live: bool) -> Any:
"""(adapter, block, layout) for a plugin's Vegas block, through the ticker's own code."""
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.elements import LiveEpochs
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.vegas_mode.render_pipeline import join_plugin_rows
config = VegasModeConfig()
adapter = PluginAdapter(display_manager, config)
adapter.live_elements_enabled = live
adapter.live_epochs = LiveEpochs()
images = adapter.get_content(plugin, plugin_id, offscreen_only=True)
if not images:
return adapter, None, []
block, layout = join_plugin_rows(images, config)
return adapter, block, layout
def render_vegas_timeline(plugin: Any, plugin_id: str, display_manager: Any,
steps: int = 8, step_seconds: float = 0.25,
run_update: bool = False) -> Any:
"""The plugin's Vegas block at successive moments, one row per step.
Row 0 is the block as placed (render_vegas_strip). Each later row is the
same block ``step_seconds`` later, changed the way the ticker would change
it in place: every live element with ``refresh_hz`` redrawn for that
moment through redraw_vegas_element(), and -- with ``run_update`` -- the
plugin's update() run first and every live element redrawn from the new
data. A redraw of another width is left out, as the ticker refuses it.
Rows are separated by a grey line.
Returns ``(image, rows)``, or ``(None, 0)`` when there is nothing.
"""
import time
import numpy as np
adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, True)
if block is None:
return None, 0
base = np.array(block.convert('RGB'))
rows = [base.copy()]
height = base.shape[0]
start = time.monotonic()
for step in range(1, max(1, int(steps))):
frame = rows[-1].copy()
if run_update:
plugin.update()
adapter.live_epochs.bump(plugin_id)
batch = adapter.render_live_elements(plugin, plugin_id, lock_timeout=5.0)
rendered = batch[1] if batch is not None else {}
for x, meta, width in layout:
element = rendered.get(meta.key)
if element is not None and element.width == width:
frame[:, x:x + width] = element.pixels
at = start + step * float(step_seconds)
for x, meta, width in layout:
if meta.refresh_hz > 0:
element = adapter.redraw_live_element(plugin, plugin_id, meta.key,
width, height, at)
if element is not None and element.width == width:
frame[:, x:x + width] = element.pixels
rows.append(frame)
divider = np.full((1, base.shape[1], 3), 60, dtype=np.uint8)
stacked = [rows[0]]
for row in rows[1:]:
stacked.extend([divider, row])
return Image.fromarray(np.concatenate(stacked, axis=0)), len(rows)
def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict,
mock_data: dict, width: int, height: int,
run_update: bool = True) -> VegasElementReport:
"""Load a plugin from its directory at one panel size and check its elements.
What ``scripts/check_plugin.py`` runs: the plugin gets the same mocked
managers as the rendering harness, and its update() is run first (a
network error there is tolerated, as in the harness) so the elements are
drawn from data rather than from an empty start.
"""
from pathlib import Path
from src.plugin_system.testing.harness import _TOLERATED_UPDATE_ERRORS, _instantiate
from src.plugin_system.testing.loading import load_manifest
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager
plugin_dir = Path(plugin_dir)
display_manager = VisualTestDisplayManager(width=width, height=height)
try:
plugin = _instantiate(plugin_id, load_manifest(plugin_dir), plugin_dir,
config, mock_data, display_manager)
except Exception as exc: # noqa: BLE001 - the matrix run reports load errors
report = VegasElementReport(implemented=False)
report.warnings.append(f"not checked: the plugin did not load ({exc!r})")
return report
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
if not report.implemented:
return report
if run_update:
try:
plugin.update()
except Exception as exc: # noqa: BLE001 - a plugin's update can raise anything
if not isinstance(exc, _TOLERATED_UPDATE_ERRORS):
report.errors.append(f"update() raised {exc!r}")
return report
report.warnings.append(f"update() had no network ({exc!r}); checked "
"with whatever data the plugin starts with")
checked = check_vegas_elements(plugin, display_manager)
checked.warnings[:0] = report.warnings
return checked
@@ -14,9 +14,7 @@ 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,
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,
get_font_height, get_text_width, draw_text, format_date_with_ordinal,
capture_mode, set_scrolling_state, is_currently_scrolling,
process_deferred_updates, update_display, render_size, offscreen. A behavior
change to any of those in DisplayManager must be mirrored here, or
@@ -26,13 +24,12 @@ 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
import os
import time
import warnings
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
from typing import Any, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from src.common.bdf_font import draw_bdf_text, load_bdf_face
@@ -63,15 +60,6 @@ class VisualTestDisplayManager:
no emulator dependency.
"""
# Weather icon color constants (same as DisplayManager)
WEATHER_COLORS = {
'sun': (255, 200, 0),
'cloud': (200, 200, 200),
'rain': (0, 100, 255),
'snow': (220, 220, 255),
'storm': (255, 255, 0),
}
def __init__(self, width: int = 128, height: int = 32):
self._width = width
self._height = height
@@ -410,129 +398,6 @@ class VisualTestDisplayManager:
return font.size
return 8
# ------------------------------------------------------------------
# Weather drawing helpers
# ------------------------------------------------------------------
def draw_sun(self, x: int, y: int, size: int = 16):
"""Draw a sun icon using yellow circles and lines."""
self._draw_sun(x, y, size)
def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):
"""Draw a cloud icon."""
self._draw_cloud(x, y, size, color)
def draw_rain(self, x: int, y: int, size: int = 16):
"""Draw rain icon with cloud and droplets."""
self._draw_rain(x, y, size)
def draw_snow(self, x: int, y: int, size: int = 16):
"""Draw snow icon with cloud and snowflakes."""
self._draw_snow(x, y, size)
def _draw_sun(self, x: int, y: int, size: int) -> None:
"""Draw a sun icon with rays (internal weather icon version)."""
center_x, center_y = x + size // 2, y + size // 2
radius = size // 4
ray_length = size // 3
self.draw.ellipse(
[center_x - radius, center_y - radius,
center_x + radius, center_y + radius],
fill=self.WEATHER_COLORS['sun'],
)
for angle in range(0, 360, 45):
rad = math.radians(angle)
start_x = center_x + int((radius + 2) * math.cos(rad))
start_y = center_y + int((radius + 2) * math.sin(rad))
end_x = center_x + int((radius + ray_length) * math.cos(rad))
end_y = center_y + int((radius + ray_length) * math.sin(rad))
self.draw.line([start_x, start_y, end_x, end_y], fill=self.WEATHER_COLORS['sun'], width=2)
def _draw_cloud(self, x: int, y: int, size: int, color: Optional[Tuple[int, int, int]] = None) -> None:
"""Draw a cloud using multiple circles (internal weather icon version)."""
cloud_color = color if color is not None else self.WEATHER_COLORS['cloud']
base_y = y + size // 2
circle_radius = size // 4
positions = [
(x + size // 3, base_y),
(x + size // 2, base_y - size // 6),
(x + 2 * size // 3, base_y),
]
for cx, cy in positions:
self.draw.ellipse(
[cx - circle_radius, cy - circle_radius,
cx + circle_radius, cy + circle_radius],
fill=cloud_color,
)
def _draw_rain(self, x: int, y: int, size: int) -> None:
"""Draw rain drops falling from a cloud."""
self._draw_cloud(x, y, size)
rain_color = self.WEATHER_COLORS['rain']
drop_size = size // 8
drops = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for dx, dy in drops:
self.draw.line([dx, dy, dx - drop_size // 2, dy + drop_size], fill=rain_color, width=2)
def _draw_snow(self, x: int, y: int, size: int) -> None:
"""Draw snowflakes falling from a cloud."""
self._draw_cloud(x, y, size)
snow_color = self.WEATHER_COLORS['snow']
flake_size = size // 6
flakes = [
(x + size // 4, y + 2 * size // 3),
(x + size // 2, y + 3 * size // 4),
(x + 3 * size // 4, y + 2 * size // 3),
]
for fx, fy in flakes:
for angle in range(0, 360, 60):
rad = math.radians(angle)
end_x = fx + int(flake_size * math.cos(rad))
end_y = fy + int(flake_size * math.sin(rad))
self.draw.line([fx, fy, end_x, end_y], fill=snow_color, width=1)
def _draw_storm(self, x: int, y: int, size: int) -> None:
"""Draw a storm cloud with lightning bolt."""
self._draw_cloud(x, y, size)
bolt_color = self.WEATHER_COLORS['storm']
bolt_points = [
(x + size // 2, y + size // 2),
(x + 3 * size // 5, y + 2 * size // 3),
(x + 2 * size // 5, y + 2 * size // 3),
(x + size // 2, y + 5 * size // 6),
]
self.draw.polygon(bolt_points, fill=bolt_color)
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
"""Draw a weather icon based on the condition."""
cond = condition.lower()
if cond in ('clear', 'sunny'):
self._draw_sun(x, y, size)
elif cond in ('clouds', 'cloudy', 'partly cloudy'):
self._draw_cloud(x, y, size)
elif cond in ('rain', 'drizzle', 'shower'):
self._draw_rain(x, y, size)
elif cond in ('snow', 'sleet', 'hail'):
self._draw_snow(x, y, size)
elif cond in ('thunderstorm', 'storm'):
self._draw_storm(x, y, size)
else:
self._draw_sun(x, y, size)
def draw_text_with_icons(self, text: str, icons: List[tuple] = None,
x: int = None, y: int = None,
color: tuple = (255, 255, 255)):
"""Draw text with weather icons at specified positions."""
self.draw_text(text, x, y, color)
if icons:
for icon_type, icon_x, icon_y in icons:
self.draw_weather_icon(icon_type, icon_x, icon_y)
self.update_display()
# ------------------------------------------------------------------
# Scrolling state (no-op interface compat)
# ------------------------------------------------------------------
+73
View File
@@ -0,0 +1,73 @@
"""Live elements: Vegas content that can change while it is on screen.
A plugin's ``get_vegas_content()`` hands the Vegas ticker pictures, and the
ticker bakes them into its strip: a score drawn when the plugin's turn was
prefetched scrolls past with that score, however many goals are scored while
it crosses the panel. A plugin that returns **elements** instead gives each
picture a name and a fixed width. The ticker then keeps track of where each
one is in the strip, and when the plugin's data changes it asks for just the
changed elements and swaps their pixels in place -- on screen included,
between two frames, without anything next to them moving.
A plugin opts in by implementing ``BasePlugin.get_vegas_elements()``, and, for
content that changes with time rather than with data (an aircraft moving
between position reports), ``BasePlugin.redraw_vegas_element()``. See "Live
Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
Added in LEDMatrix 3.8.0. Import it guarded, so the plugin still loads on an
older core (which never calls the hooks)::
try:
from src.plugin_system.vegas_elements import VegasElement
except ImportError: # core older than 3.8.0
VegasElement = None
def get_vegas_elements(self):
if VegasElement is None:
return None
return [VegasElement(key=f"game:{g['id']}", image=self._card(g),
version=self._fingerprint(g))
for g in self._games]
"""
from dataclasses import dataclass
from typing import Hashable, Optional
from PIL import Image
@dataclass(frozen=True, eq=False)
class VegasElement:
"""One named, fixed-width piece of a plugin's Vegas content.
Attributes:
key: Names the element across redraws, unique within one list the
plugin returns: ``"game:nfl:401547417"``, ``"sep:0:nfl"``,
``"map"``. The ticker matches a redraw to the pixels already in
its strip by this key, so it must stay the same for the same
logical thing and must not be reused for a different one.
image: The element as drawn now, at the display's height. For a
``live`` element its width is fixed for as long as the key is on
the strip: a redraw at a different width is never swapped in (it
appears the next time the plugin comes round instead), because
nothing on screen may move. Draw live elements at a width that
does not depend on the data -- a fixed card width, not the
width of the text.
version: Anything hashable that changes exactly when the pixels
would, such as the tuple of fields the element draws. The ticker
skips work for an unchanged version. ``None`` means "compare the
pixels", which is always correct and costs a checksum.
live: False places the element exactly as plain content is placed
(trimmed to its ink, never refreshed): separators, decoration.
refresh_hz: More than 0 asks for ``redraw_vegas_element()`` about
this often while the element is on or near the screen, for
content that changes with time rather than with data. The ticker
caps the rate (``vegas_scroll.live_max_hz``, 1 Hz on a display
without the rebuilt rgbmatrix binding) and slows it for an
element that is slow to draw.
"""
key: str
image: Image.Image
version: Optional[Hashable] = None
live: bool = True
refresh_hz: float = 0.0
+41 -9
View File
@@ -23,6 +23,10 @@ above it near the end, the section below must show one more refresh of lag to
stay continuous with it (and one less where the order jumps the other way).
Stacked parallel chains are lit simultaneously, so each further half adds one.
A frame held for several refreshes (a slower, crisp scroll) is presented as a
sequence of swaps instead of one long hold, so the lagging half can step one
refresh after the rest: see :func:`refresh_plan`.
Only layouts whose physical row order is known are compensated: plain chains,
parallel chains, and a 0 or 180 degree rotation. Other pixel mappers
(U-mapper, 90/270 rotation, ...), special multiplexing and interlaced scan are
@@ -109,19 +113,47 @@ def scan_lag_bands(hardware: Mapping[str, Any], height: int,
return [band for band in bands if band[2] > 0] or None
def compose(image: Image.Image, history: Sequence[Image.Image],
bands: Sequence[Band]) -> Image.Image:
"""``image`` with each band taken from the frame ``lag`` refreshes back.
def refresh_plan(bands: Sequence[Band], hold: int) -> List[Tuple[Tuple[int, ...], int]]:
"""How to present one frame that is held for ``hold`` refreshes.
``history[0]`` is the previous frame. A band whose frame is not available
yet (the first frames of a scroll) is left current. Returns ``image`` itself
when nothing changes, so the caller pays for a copy only when it must.
A band lagging ``lag`` refreshes shows, on refresh ``r`` of the frame, what
the panel showed ``lag`` refreshes earlier: the current frame once
``r >= lag``, else a frame ``ceil((lag - r) / hold)`` back. At one refresh
per frame that is just ``lag`` frames back. Held longer, the lagging band
steps one refresh after the rest instead of one frame, which is the only
way to cancel the offset: it is a fraction of a frame there.
Returns ``[(frames_back_per_band, refreshes), ...]`` in order, merging
neighbouring refreshes that show the same thing so each costs one swap.
"""
plan: List[Tuple[Tuple[int, ...], int]] = []
for r in range(max(1, hold)):
backs = tuple(max(0, -((r - lag) // max(1, hold))) for _, _, lag in bands)
if plan and plan[-1][0] == backs:
plan[-1] = (backs, plan[-1][1] + 1)
else:
plan.append((backs, 1))
return plan
def compose(image: Image.Image, history: Sequence[Image.Image],
bands: Sequence[Band],
backs: Optional[Sequence[int]] = None) -> Image.Image:
"""``image`` with each band taken from an earlier frame.
``history[0]`` is the previous frame. ``backs`` is how many frames back each
band is taken from (0 = the current one); by default that is the band's lag,
which is right when every frame is held for one refresh. A band whose frame
is not available yet (the first frames of a scroll) is left current.
Returns ``image`` itself when nothing changes, so the caller pays for a copy
only when it must.
"""
out = image
for top, bottom, lag in bands:
if lag > len(history):
for i, (top, bottom, lag) in enumerate(bands):
back = lag if backs is None else backs[i]
if back <= 0 or back > len(history):
continue
source = history[lag - 1]
source = history[back - 1]
if source.size != image.size:
continue
if out is image:

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