The start route held a request open for up to 45 s while a cold-started
display loaded its plugins; the MQTT bridge (15 s timeout) and browsers
reported a failure for a request that was then delivered.
Now, when no display is listening, the route starts the service if asked
and answers 202 with status "starting" at once. A single worker in the web
process (web_interface/on_demand_dispatch.py) sends the request until the
display acknowledges it or the wait runs out (45 s cold start, 10 s for a
running service without a socket yet). A newer start supersedes the
pending one; a stop cancels it (and succeeds, with cancelled_request_id,
even with no display listening). The outcome is reported by
/display/on-demand/status (source "web": starting, or error with
start-timeout or the socket's reason, until the display publishes
something newer) and by /display/current-status as on_demand_pending.
Callers: the web UI's on-demand modal and "Preview on display" treat
"starting" as taken (an info toast); the MQTT bridge already treats any
non-error 2xx as success (now pinned by a test).
Tests: the dispatcher (ack, retry then ack, start-timeout, other failures,
superseded, an in-flight ack for a superseded start, stop while pending,
a per-start wait, outcome lifetime); the routes (202, status routes while
pending and after a timeout, a later display state replacing the failure,
stop while pending, a new start superseding); a JS suite for app.js.
Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4)
- core/visibility.js: each page gets ctx.visibility (whileVisible, every,
isVisible). Work registered there runs only while the page's tab is
active and the browser tab visible, and ends when the page is swapped
out. It reads the active tab from window.LEDVisibility, so it agrees
with the classic partials. The registry gained a mountContext option for
per-mount services.
- pages/display.js replaces display.html's two inline scripts. The 5 s
sync status poll runs through ctx.visibility.every; the status and
scroll-speed hint requests go through ctx.api with ctx.signal, as does
the Vegas order widget's plugin-list request. The Advanced toggle is a
delegated data-action; window.updateSyncUI is a deprecated alias.
- New DOM suites test_visibility_service.js and test_display_page.js;
test_display_partial_ids.js imports the module.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): no computed keys in the Display page's readout and destroy
Codacy's object-injection rule flagged v[id] and ctx.state[name].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(common): sports_game_over -- the reconciled game-over check (sports family 5)
New hardware-free module src/common/sports_game_over.py with
SportsGameOverMixin._is_game_really_over, the scoreboards' SportsLive check
that drops a game ESPN still lists as live, copied from ledmatrix-plugins
claude/family5-reconcile once the nine copies (five bodies) became one.
Over on a final period text; from period FINAL_PERIOD on, also on a 0:00
clock string unless the score is level (a tie at the end of regulation goes
to overtime; a game that ends tied ends on its final status). FINAL_PERIOD
is the one per-sport seam, a class attribute defaulting to None (the clock
never ends a game); the scoreboards declare 3 (hockey), 4 (basketball,
football, lacrosse) or None (afl, nrl, soccer, baseball, ufc).
- test/test_sports_game_over.py: the plugins' pinned matrix folded to the
three FINAL_PERIOD values, edge shapes, the tie guard, ufc's recorded
ESPN MMA states, an override deferring through super() (baseball), the
base order with SportsLiveSharedMixin._detect_stale_games, host contract.
- test/test_sports_game_over_parity.py: with LEDMATRIX_PLUGINS, compares the
body with every plugin copy (drift-report normalisation plus decorators)
and each plugin's FINAL_PERIOD with the owner's decision.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- docs/SPORTS_UNIFICATION.md: family 5 status and decisions; the seam
tables now match the code (FINAL_PERIOD defaults to None; the
CLOCK_COUNTS_DOWN seam never existed and is gone from the doc).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: cite ledmatrix-plugins #625 for the family 5 reconcile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The control socket is now the only way the web interface sends the display a
command. The display stops reading display_on_demand_request and
plugin_error_clear_request, and the web interface stops writing them.
- Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence,
_consume_on_demand_request, the deprecation log) and no persisted
display_on_demand_processed_id guard; the error publisher reads no clear
request. CacheManager.file_signature and MailboxWatch are removed.
- A write to either retired key is dropped by CacheManager.save_cache and
logged once per writer, naming the plugin from the call stack (or the
request's plugin_id), with the API to move to.
- Web: on-demand start with no display listening starts the service (when
start_service) and sends the request again once the socket answers (45 s,
10 s for a running service without a socket yet); every other failure is
a 503 (400 for invalid_args). Stop answers 503 when no display listens,
unless stop_service. errors/clear answers 503 with a reason-specific
message instead of writing a request; clear_pending is always false.
src.ipc.client.should_fall_back is replaced by display_not_listening.
- Kept: display_current_state, display_on_demand_state,
plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot
answer), and display_on_demand_config (the display's resume record).
Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox
cadence, file_signature and MailboxWatch tests); tests that injected
requests through the mailbox now use the socket queue or a plugin's
in-process request. The run-loop harness sends on-demand requests over its
fake control socket, so four golden traces change: on-demand starts and
stops land at the request instant instead of the next 0.25 s mailbox look
(one frame fewer on the screen they end), and in vegas.json within one
frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice
check by under a second.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(fonts): unloading a plugin forgets its manifest fonts
PluginManager.unload_plugin() and the failed-load cleanup only called
FontManager.forget_manager_fonts(), which drops usage data. The plugin's
manifest registrations stayed: plugin_fonts / plugin_font_catalogs, its
plugin_id::family entries in font_catalog, and cached font objects for them
-- so its fonts kept resolving after unload and a family a reinstalled
manifest dropped stayed registered. The deprecated unregister_plugin_fonts
did this cleanup but nothing called it; it was removed in #708.
Add FontManager.forget_plugin_fonts(plugin_id) and call it from both
paths alongside forget_manager_fonts (each guarded on its own, so a font
manager stub with only one still works). FontManager takes no locks, so
like forget_manager_fonts it uses atomic pops over snapshots. A reload
(unload + load) registers the manifest fonts again and they resolve.
Raised by CodeRabbit on #709.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(fonts): call the font manager's forget methods without a None-able local
Pylint E1102 (Codacy) read getattr(..., None) as possibly not callable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(plugins): request_on_demand() / end_on_demand() -- plugins ask for the screen in-process
Four plugins (birdnet-go, mqtt-notifications, on-air, pomodoro-timer) take
the screen by writing the display_on_demand_request mailbox, which the
display reads once a second while the control socket is up and which stage 5
removes. This is the in-process way in that stage needed.
- BasePlugin.request_on_demand(mode=None, duration=None, pinned=False) and
end_on_demand(), safe from any thread, go through PluginManager to
DisplayController.submit_plugin_on_demand, which only queues (at most 32)
and wakes the render thread through ControlServer.wake(). The render
thread applies them in _drain_control_commands, after socket commands,
through _handle_on_demand_request, so they land within a frame; without a
socket, on the next pending-changes pass.
- A plugin's stop ends only its own session; a mailbox stop still ends any.
- Both answer the request id, or None with no display in the process (web
interface, check_plugin.py), a full queue, or a mock manager -- a plugin's
cue to write the mailbox, which the display still reads.
- docs/PLUGIN_API_REFERENCE.md documents the hasattr pattern for plugins
that must keep working on older cores; IPC_CONTROL_SOCKET.md and the
CHANGELOG are updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): wire the on-demand handler only on a manager that has it
Tests and the golden traces stand in simpler plugin managers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web): Schedule and General become ES-module pages (stage 3)
Schedule and General follow stage 2 (#727): no inline scripts or inline
handlers in either partial. Their code moves to static/v3/js/pages/schedule.js
and pages/general.js, started per swap-in by the page registry.
- Schedule: both pickers are drawn from the saved config carried as JSON in
data-* attributes. The forms' hx-on save handlers become one
htmx:afterRequest listener on the page; the forms are marked
data-reports-result, which app.js now treats like an hx-on after-request
handler, so a save still shows one notification.
- General: the timezone picker reads data-timezone. The Security section's
forms and buttons are delegated data-actions; requests go through
core/api.js, so the login redirect is quiet, and a change made just
before a swap is still reported.
- handleScheduleResponse, handleDimScheduleResponse and webLogin stay as
deprecated aliases through window.LEDMatrix.
- New DOM suites test_schedule_page.js and test_general_page.js; the web
login unit suite imports the module; test_es_modules.py pins the pages,
the aliases, and the schedule config's round trip through its attribute.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): no unused catch bindings or computed writes in the stage 3 modules
Codacy flagged two unused catch variables and dynamic-key writes in
pages/schedule.js and boot.js. The schedule config is read with
getAttribute, and the default days and the webLogin alias object are built
with Object.fromEntries. No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- client: ControlError.sent says whether the display had the request;
should_fall_back() allows a mailbox write only when it did not, or when
the display is too old to know the command (upgrade case)
- on-demand start/stop: a display that had the request and failed it is
answered 503 (400 for invalid_args), no mailbox copy
- errors.clear: new socket command, answered on the connection thread by
a handler the display registers; applied and republished before the
answer; plugin_error_clear_request only on fallback
- display: on-demand mailbox looked at once a second while the socket is
up (0.25 s without), read only when its file changed (one stat via
CacheManager.file_signature / MailboxWatch); socket commands no longer
touch the mailbox; a processed duplicate is consumed; writers logged once
- error publisher: mailbox read only when changed; snapshot carries
applied_clear_cutoff so an older mailbox request is not shown pending
- docs and CHANGELOG (mailboxes kept for one release)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(display): run each screen through a ScreenRunner (run loop stage 3)
The two frame loops, the make-up dwell and the dynamic-duration exit move
out of DisplayController.run() into src/screen_runner.py. ScreenRunner
paces with an injected FrameClock (production: this module's time, looked
up per call so the golden harness's fake clock still drives it) and
returns one Outcome whose ExitReason is DURATION, CYCLE_COMPLETE, EMPTY,
ERROR, DISPLAY_FALSE, RELOAD or PREEMPTED.
PREEMPTED replaces the re-checks that used to follow each frame loop and
the make-up dwell (current_display_mode != active_mode, the schedule, a
pending WiFi notice): the runner asks its host at named service points
(FRAME, AFTER_LOOP, after_dwell, FINAL), and on PREEMPTED run() goes to
the next pass without advancing the rotation, as each `continue` did.
RELOAD is the one early end that still advances, as a reload always did.
Each service point reads the WiFi notice file exactly when the loop did
(NoticeRead), because the read is throttled and deletes expired files.
The host answers still use the old checks; the following commits move
them to the Arbiter. _screen_preempted is gone (folded into the FRAME
check); _wait_frame_interval returns the preempting plan instead of a
bool. The frame pacing (8 ms deadline, 1 ms minimum yield, 1 Hz wait with
socket wake) is the same code, moved.
Golden traces unchanged. A capture of every harness run (all 67, with
every sleep, display() call, wifi read, live scan, publish and dwell
logged) is identical to origin/main except for throttled WiFi reads that
returned the cached answer (no side effect) after a notice preempted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(display): on-demand is an Arbiter Source (run loop stage 3)
Arbiter.decide() now answers for an active on-demand session itself
(Source.ON_DEMAND) instead of returning LEGACY:
- ArbiterState gains the session: its mode list, index, expiry and pin,
plus current_mode, snapshotted from the controller's fields by
_arbiter_state(). The controller's attributes stay the record that the
web UI, the control socket and the cache read.
- The OnDemand plan is the session's current mode (an index past the end
of a shortened list starts again at 0), with what is left of a timed
session at `now` as max_duration and the expiry as deadline. A session
with no modes left is a plan with no mode; the controller ends it and
shows the rotation's mode, as _resolve_active_mode did.
- on_demand_bound() is _clamp_to_on_demand made pure. It is still applied
after the first frame, with the clock read there.
- ArbiterState.next_on_demand() is the step _advance_on_demand takes.
run() asks decide() for the screen at the point it used to call
_resolve_active_mode (after any Vegas iteration, so a session that
started mid-iteration still shows next), and _take_plan() applies it.
Golden traces and the 67-run capture identical to origin/main. Adds
TestOnDemand and the bound table to test_display_arbiter.py; the stage-2
table's on-demand rows now name ON_DEMAND instead of LEGACY.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(display): live priority is an Arbiter Source (run loop stage 3)
The live-priority step of run() (step 7) and the live checks around the
Vegas iteration become the Arbiter's Live Source:
- ArbiterInputs gains live_modes (the scan, None where run() made none),
vegas_enabled, vegas_live_in_ticker and vegas_yielded. ArbiterState
gains the rotation and its index, the live resume point and the
"takeover not shown yet" flag.
- Live picks the next live mode round-robin (live_pick, now also what
_check_live_priority returns), not advancing past a mid-screen takeover
that has not shown. It outranks Vegas unless the ticker keeps live
content, in which case it has no say at all, as before. With nothing
live, a plan below it carries ends_live and the interrupted rotation
resumes.
- ArbiterState.claim_live/release_live are _apply_live_priority's
bookkeeping made pure; _apply_live_priority applies them.
run() reads the inputs below the WiFi notice where it always did
(_arbiter_inputs_below_wifi: the Vegas check, then the scan), asks
decide() once more, and _take_plan applies the claim or the resume. The
Vegas iteration moves to _run_vegas_iteration, which re-decides with
vegas_yielded after a yield, so a game that stopped the ticker or an
on-demand session that started mid-iteration still shows next. LEGACY
now means Vegas or the rotation.
One redundant call is gone: a Vegas pass scanned the live plugins twice
at the same instant (step 7, then step 8's "is anything live?"); it scans
once. Golden traces unchanged. The 67-run capture is identical to
origin/main once that duplicate scan and _apply_live_priority(None) calls
that changed nothing are left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(display): the rotation is an Arbiter Source; LEGACY means Vegas (run loop stage 3)
decide() now names the screen for every pass: the Rotation Source
(Source.ROTATION) answers with the rotation's current mode, after the
resume when live priority just ended. LEGACY is left meaning only Vegas,
whose iteration is still run()'s own code until stage 4. Once this pass's
iteration has yielded (vegas_yielded), Vegas passes and the screen it fell
through to is decided like any other.
The rotation's mode is state.current_mode rather than
rotation[rotation_index]: they agree except where something moved the
panel off the list and the rotation carries on from there (a live mode no
entry names, or None after a session ended with nothing to resume to),
and run() always showed current_display_mode.
ArbiterState.after(outcome) is _advance_after_screen's step: an on-demand
session moves to its next mode; otherwise the rotation advances unless the
mode just shown is still live. The Outcome carries the two facts only the
controller can see at the end of the screen (on_demand_active, the live
hold from _still_live). Ending a session with no modes left stays in the
controller, because it is not pure.
Golden traces unchanged; the 67-run capture is identical to origin/main
with the same two exclusions as the previous commit. Adds the Vegas /
Rotation table and TestAfter; the stage-2 rows that said LEGACY for "live,
Vegas or rotation" now say ROTATION.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(display): one decide() call at each of the runner's service points (run loop stage 3)
The three mid-screen checks the frame loops made one after another --
_check_live_takeover, then _screen_preempted with _wifi_notice_pending in
it -- become one call: Arbiter.decide(state, inputs, now, running=plan).
It returns `running` itself while the screen holds, else the plan that
ends it, from these rules in the order the loops checked them:
1. Live: a game went live while a non-live screen runs. First because it
is the one preemption that changes the state (the rotation moves to
the live mode and remembers where it was), and it is still claimed
when a WiFi notice is pending too; the next pass shows the notice,
then the game, as before.
2. The panel's mode moved under the screen (on-demand started, ended or
changed; the rotation was rebuilt).
3. The schedule turned the panel off.
4. A WiFi notice arrived (on-demand outranks it; compared with expiry).
5. A plugin reload is waiting (between frames only).
Each rule is gated by plan.preemptible_by: every screen may be preempted
by the gate, OnDemand, Wifi, Live, Rotation and a reload, except that a
live screen leaves Live out. A follower and Vegas never preempt
mid-screen. The pure helper live_takeover() is the Live rule, shared with
the dwell sleep's _check_live_takeover.
The controller only gathers and applies. _screen_service applies pending
changes and makes the live scan when one is due (_scan_for_takeover: the
same throttle and gates as before); _screen_check reads the WiFi notice
exactly where the loop did (the read is throttled and deletes an expired
file, so an extra read would move both), calls decide() once, and claims
a live takeover. _screen_preempted is gone; _check_live_takeover and
_wifi_notice_pending remain for the dwell sleep and the Vegas yield path,
built on the same rules.
Golden traces unchanged. The 67-run capture is identical to origin/main
(with the earlier two exclusions) except for one event: in the 125 Hz loop
the live scan still runs before the frame's sleep, but the claim is now
made by the service point after it, so the "live" state change is logged
8 ms later (test_live_game_cuts_a_scrolling_screen_short: 8.064 -> 8.072).
The screen still ends at the same frame (8.072) and every frame, sleep
and pass is unchanged.
Adds the mid-screen table (24 rows), live_takeover's table, and
test/test_screen_runner.py (the runner on a scripted host, plus the
controller's service point: which reads it makes at which checkpoint).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs, tests: run loop stage 3 -- doc, changelog, mutation survivors
docs/RUN_LOOP_REDESIGN.md describes run() as it now is (two decide()
calls per pass, the runner and its service points, the state snapshot and
its transitions), records what stage 3 shipped and how it was checked,
and adds one open "may be wrong" behaviour the mutation run surfaced: a
Vegas iteration stopped for a sync follower falls through to a full
rotation screen before the follower gets the panel (pinned by
test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first; passes on
origin/main too). docs/IPC_CONTROL_SOCKET.md no longer names
_screen_preempted. CHANGELOG entry under Unreleased.
A mutation run broke 46 moved or new pieces once each (OnDemand, Live,
Vegas/Rotation, after(), each mid-screen rule, the runner's pacing, exits
and service points, the controller's gathering and claims). Three
survived and get a test here:
- the after-loop service point not reading the WiFi notice: the
completed-loop checkpoint gets its own name, and a run-loop test has a
notice pending when a later frame comes back empty;
- the Vegas yield path not marking vegas_yielded: the follower test above;
- _take_plan not writing back a reset on-demand index: a controller test
with an index past a shortened list.
All 46 now fail at least one test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- POST /api/v3/config/schedule and /config/dim-schedule accept a disabled per-day schedule with every day off, and keep an off day's times.
- POST /api/v3/config/main answers restart_required only when the save changed a setting the running display does not apply live.
- GET /api/v3/health reports degraded with checks.display_loop.status stopped when the display service is stopped.
- GET /api/v3/display/current-status answers unknown (null fields) after the display stops instead of the cached last state. New web_interface.display_state.display_gone().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On-demand: a mode requested by name is shown first (even a quiet live mode); a session that can't resume after a restart, or whose plugin system failed to start, ends with status restore-failed instead of staying dead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
State stream ticks carry the volatile timestamps (display.last_updated, plugins.published_at), so current-status and the plugin runtime stay fresh while one mode stays on screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six small savings with no behaviour change: the odds fetch no longer pretty-prints every response for a debug line; the scroll integer-slice path drops a redundant full-frame np.ascontiguousarray; ledmatrix-web.service gets MALLOC_ARENA_MAX=2 like the display unit; core ESPN responses are parsed via response_json (orjson when installed); and the scroll frame stats go to INFO only for degraded windows plus a 5-minute heartbeat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The installer and scripts/check_system_compatibility.sh share one set of OS rules (scripts/install/lib_os.sh): Bookworm (Debian 12, Python 3.11) and Trixie (Debian 13, Python 3.13) are supported, python3 older than 3.11 stops the install before anything changes, and dhcpcd gets a warning with directions. setcap targets /usr/bin/python3, the apt fallback honours the requirement floors, and the desktop check no longer misreads under pipefail. CI runs the unit and plugin-safety suites on 3.11 and 3.13 (tooling jobs on 3.13); mypy targets 3.11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Updates that move HEAD now install changed systemd units through a root-owned helper (/usr/local/sbin/ledmatrix-refresh-units, two literal sudo lines), with a backup restored on rollback; a refresh that fails part-way puts the old units back. Devices without the new sudo rule keep updating and are told to re-run the installer once. The one-shot installer now checks out the newest vX.Y.Z release (LEDMATRIX_CHANNEL=beta keeps main) and never moves an existing checkout backwards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds state.get / state.subscribe to the display's control socket (StateHub in src/ipc/server.py). The web interface holds one subscription per process (web_interface/display_state.py) and reads current-status, on-demand status, plugin runtime and /health's display_loop from it, falling back to the cache keys and heartbeat file. While the socket serves readers, display_current_state and plugin_runtime_snapshot are written less often (about 1.5 instead of 5 cache writes a minute for 15 s screens).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run() stage 2: a pure Arbiter.decide() (src/display_arbiter.py) chooses scheduled-off, follower and WiFi notices; everything else takes the existing path. Golden traces byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fetch service stage 2: one ESPN scoreboard cache key shared across the sports base classes (legacy keys still read), and a max-age response cache in the fetch service.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rotation, Operation History, Config Editor and Backup & Restore become ES-module pages (stage 2): no inline scripts or onclick in the four partials, delegated data-action listeners, reads cancelled on swap-out, old globals kept as deprecated aliases through window.LEDMatrix, and four new DOM suites.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runtime status snapshot now agrees with the display heartbeat, and current-status is republished when the display wakes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
* 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>
* 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>
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>
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>
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>
* 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>
* 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>
* 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>
* 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>
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>
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>
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>
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>
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>
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>
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>