The pause called display(force_clear=True) once and slept out the
duration, so a plugin that rotates its screens or animates (Fantasy
Blitz's playlist) was frozen on its first frame. The pause now calls
display() again at the rotation's rate: once a second, or the high-FPS
interval for a plugin that declares needs_high_fps (or scrolls). Only
the first call clears. A redraw that finds update() holding the plugin
lock is skipped; the pause carries on. Interrupt checks and the
watchdog beat still run every 0.1 s; a raise still ends the pause.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5)
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>
* test: the on-demand routes' other callers ack over the socket, not the mailbox
test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and
test_web_api's stop test checked the mailbox write; they now get an ack
from a mocked control socket. The stop route touches no manager any more,
so it leaves the removed-catch-all sample. A request that was sent is
never 'not listening' (pins the sent check the mutation run let through).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web): a start that waits for the display answers 202 and is delivered in the background
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>
* fix(web): a cancelled on-demand start never reaches the display after its replacement
CI caught a race in test_a_new_start_supersedes_the_pending_one: the
dispatcher's worker could read the old start, the route cancel it and send
the new one, and the worker's send of the old one land after it. cancel()
now waits out a send already in flight (the worker holds a send lock while
it reads the pending start and sends it), so whatever the caller sends next
lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which
fails without the wait.
The Linux-only TestRealSocket test for a display that went away now
expects the 202 and the start-timeout that follows, as the route answers
since the background dispatcher.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): a delivered on-demand start reads as starting until the display acts on it
On ledpi (three cold starts) the display acknowledged the start as its
socket opened, then took ~5 s to act on it while Vegas built its first
strip; the status routes meanwhile showed the display's own idle state, so
a UI polling every 700 ms flashed idle.
The display's on-demand state now names the request it answers
(request_id). A delivered start keeps reading as status "starting" with
delivered: true, in /display/on-demand/status and as on_demand_pending in
/display/current-status, until the display publishes state for that
request id (a display without the field: any state newer than the
delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's
startup state, which can be published after the acknowledgement, names no
request and does not end it.
Tests: stays starting against the startup idle state (no id, an older id);
the matching active state and the matching error take over; an older
display's newer state takes over; the 30 s cap; the display's state names
its request. Mutation check: 11 mutants, 11 killed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(display): on_demand_request_id has a class default for controllers built without __init__
_on_demand_state now publishes it, and test_state_stream_readers builds
controllers with __new__.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* docs: blank line before 3.8.7 heading
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps src.__version__ to 3.8.7 and turns Unreleased into ## 3.8.7:
src.common.sports_view_model (#796, sports family 10), which the scoreboards
adopt by flooring on 3.8.7. src/common/README.md and
docs/SPORTS_UNIFICATION.md say 3.8.7 for the module.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(common): sports_view_model -- the reconciled game view model (sports family 10)
New hardware-free module src/common/sports_view_model.py, copied from
ledmatrix-plugins claude/family10-reconcile once the nine scoreboards made
_extract_game_details_common (nine bodies) one. It lives on the plugins'
SportsCore, so one mixin, SportsViewModelMixin, carries exactly that method,
the helpers it calls (_status_is_final, _score_text, _team_logo_url,
_extract_team_record), the _logo_filename seam and the two status-name
sets; no manager gains a method it lacks today. The module docstring lists
every key of the game dict with its type and default; keys are additive
only.
Owner decisions (2026-10-13):
- One final rule: state "post", not completed: false (a payload without
the flag is judged on state and name), and not a not-played status (the
union of the nine lists). A forfeit is final. A called-off game is never
live, even while ESPN still reports it "in".
- Scores are integer text everywhere; a missing, empty or non-numeric
score is "0".
- A time without an offset is UTC.
- Halftime reads "HALF" with no clock (each sport's own extractor; not in
this module).
- Superset with safe defaults: period (0) and clock (""). period_text has
none: the renderers read its absence (baseball) as "draw ESPN's status
text", and an empty default blanked baseball's Recent "Final" in the
harness.
- test/test_sports_view_model.py: the plugins' pinned cases (states, the
final rule, scores, records, logos, ids, broadcast, dates, offsets, DST,
malformed events), the seam, and the host contract (every self read
documented, every key in the docstring's table). Mutation-checked (11
mutants, all caught).
- test/test_sports_view_model_parity.py: with LEDMATRIX_PLUGINS, compares
each method and both status sets with every plugin copy. Passes against
claude/family10-reconcile; fails against the pre-reconcile tree.
- test_sports_twins.py, sports_card and sports_game_renderer tests pass
with the reconciled plugins as the corpus.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules),
docs/SPORTS_UNIFICATION.md family 10 status and decisions, and the
_logo_filename seam in the override-points table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: cite ledmatrix-plugins #654 for the family 10 reconcile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(sports): view-model parity requires SportsCore to inherit the mixin
A plugin without a copy counted as adopted if its source merely mentioned
the module. It now also has to list SportsViewModelMixin among SportsCore's
bases (CodeRabbit on #796).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps src.__version__ to 3.8.6 and turns Unreleased into ## 3.8.6: the Start
Display on Boot toggle (#792), the rankings edge-case fixes (#791), and
src.common.sports_live_fetch with the get_espn_scoreboard null-events fix
(#793, sports family 9), which the scoreboards adopt by flooring on 3.8.6.
src/common/README.md and docs/SPORTS_UNIFICATION.md say 3.8.6 for the module,
and family 9's heading no longer says it is awaiting a release.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
New hardware-free module src/common/sports_live_fetch.py, copied from
ledmatrix-plugins claude/family9-reconcile once the nine scoreboards made
_fetch_todays_games (seven bodies when measured) and _fetch_odds (three) one
body each. Both live on the plugins' SportsCore, so one mixin,
SportsLiveFetchMixin, carries exactly those two plus the odds worker
_drain_odds_queue; no manager gains a method it lacks today.
Owner decisions (2026-10-12):
- Live scores are always fresh: every poll asks ESPN through
get_espn_scoreboard and leaves the answer under the shared
espn_scoreboard_cache_key for its dates (max_age=1: only a copy another
reader fetched within the last second is reused). The plugins' 30 s
<sport_key>_scoreboard_current cache is gone.
- College basketball is asked by date like every other league (ESPN
answered single dates for both college leagues on 2026-10-12).
- Odds never block update(): one daemon worker per manager answers a queue
and attaches each line in place under _games_lock; one batch in flight per
manager, games queued meanwhile join it, every dict queued for an id gets
the one answer. show_odds, the two odds intervals and a missing odds
manager (debug, not ERROR) are respected.
- An answer whose events is not a list is no games.
Fix: get_espn_scoreboard() no longer caches an answer whose events is not a
list ("events": null), which every reader of the shared key inherited; it is
still returned to the caller.
- test/test_sports_live_fetch.py: the plugins' pinned cases (requests,
dates and keys at 10:00 and 02:00 Eastern, freshness, malformed and failed
answers, odds that never block and land later, the intervals, one batch per
manager, a worker that cannot start, update and display on two threads),
plus the host contract. Mutation-checked (11 mutants, all caught).
- test/test_sports_live_fetch_parity.py: with LEDMATRIX_PLUGINS, compares
each body with every plugin copy. Passes against claude/family9-reconcile;
fails against the pre-reconcile tree.
- test/test_espn_scoreboard_cache.py: the null-events regression.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules,
Fixes), docs/SPORTS_UNIFICATION.md family 9 status and decisions.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sports_rankings): skip a malformed poll entry; rank each side by its own id
The two review points deferred on #789 until sports_rankings was the only
copy (the nine scoreboards adopted it in ledmatrix-plugins #648):
- A poll entry whose "current" is None or a string raised TypeError at
`current_rank <= 0`; the broad except returned {} without setting the
cache timestamp, so every team lost its rank and the standings were
re-fetched on every update. The entry is now skipped.
- _ranked_positions() fell back to abbreviations for BOTH sides when only
one side's id was unparseable, so a colliding abbreviation could rank the
wrong school. It now decides id-or-abbreviation per side.
Well-formed ESPN data (ids on both sides, int ranks) ranks as before.
SPORTS_UNIFICATION.md: family 8 marked done and adopted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): a numeric-string rank is still accepted; say which values are skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore: prepare the 3.8.5 release
Bumps src.__version__ to 3.8.5 and turns Unreleased into ## 3.8.5:
src.common.sports_rankings (#789, sports family 8), which the scoreboards
adopt by flooring on 3.8.5, and #754's settled-day chunk memory, which had
no CHANGELOG entry and gets one from its commit message. src/common/README.md
and docs/SPORTS_UNIFICATION.md say 3.8.5 for the module.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(sports): family 8's heading says core 3.8.5, not awaiting release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(common): sports_rankings -- the reconciled rankings family (sports family 8)
New hardware-free module src/common/sports_rankings.py, copied from
ledmatrix-plugins claude/family8-reconcile once the nine scoreboards made
_fetch_team_rankings, _choose_poll (three bodies each),
_load_division_team_ids, _passes_other_filters, _best_rank and
_is_ranked_game (two each) one body each. All of them live on the plugins'
SportsCore, so one mixin, SportsRankingsMixin, carries exactly those six
plus _ranked_positions and _poll_season_ended and the constants they read;
no manager gains a method it lacks today.
- Ranks are kept by ESPN team id as well as abbreviation, and a game with
ids is ranked by id (ESPN lists Ohio State and Ohio State Newark both as
OSU). The badge still reads the abbreviation table.
- A poll whose season's endDate has passed loads nothing, so no badge shows
and the "ranked" filter fails open between seasons (ESPN served April's
college basketball poll on 2026-10-12); read from the payload, no
calendar, no sport names.
- College football's division rosters are asked for the season year
(football's July rule; only that league has rosters, so no seam).
- choose_top_division_poll(): the one poll rule, for the dynamic team
resolvers that expand AP_TOP_n.
- test/test_sports_rankings.py: the plugins' pinned cases (poll choice,
which leagues are asked, payload shapes, hourly cache, the finished-season
rows, the rosters' season year and caching, ranked/best rank/filter over
colliding abbreviations), plus the host contract. Mutation-checked.
- test/test_sports_rankings_parity.py: with LEDMATRIX_PLUGINS, compares each
body and constant with every plugin copy. Passes against
claude/family8-reconcile; fails against the pre-reconcile tree.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- sports_shared and sports_rotation docstrings: the three methods leave the
"stay per-plugin" list.
- docs/SPORTS_UNIFICATION.md: family 8 status and decisions; item 8 restated
to match what measuring found (the standings branch never ran; the season
year only matters to college football).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: cite ledmatrix-plugins #645 for the family 8 reconcile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ci: re-run code scanning (the Python analysis job never started)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps src.__version__ to 3.8.4 and turns Unreleased into ## 3.8.4: the
refresh-cap report (#759), a failed on-demand request ending its own session
(#779), reason codes instead of exception messages in API errors (#778),
and src.common.sports_rotation (#786, sports family 7), which the
scoreboards adopt by flooring on 3.8.4. src/common/README.md says 3.8.4 for
it, and the SPORTS_UNIFICATION module table gains its row.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(common): sports_rotation -- the reconciled other-games rotation (sports family 7)
New hardware-free module src/common/sports_rotation.py, copied from
ledmatrix-plugins claude/family7-reconcile once the nine scoreboards made
_by_importance, _other_games_window, _advance_other_games_if_due (two bodies
each), _rotate_other_games_on_display (two) and _attach_odds_to_rotated_games
(three; ufc had none) one body each. All of them live on the plugins'
SportsCore, so one mixin, SportsRotationMixin, carries exactly those five plus
the default _rankings_loaded, and no manager gains a method it lacks today.
- The window advances under _games_lock: update() and display() both advance
it, and interleaved, each added a width and skipped a window.
- The display path's due-check reads the pool _compose_selection will cut,
unfiltered fallback included.
- Rotated-in games get odds when show_odds is on (decided 2026-10-09: ufc
too), skipped without an odds manager.
- _rankings_loaded() is the seam _by_importance asks: the abbreviation table
by default; football overrides it to count its by-id rankings.
- test/test_sports_rotation.py: the plugins' pinned cases (importance order
per rankings table and the seam, the window over time, the display-path
rotation incl. the card on screen, redraws and recompose counts, update()
and display() in sequence and interleaved on two threads -- with and
without the lock -- and rotated-in odds), plus the host contract.
- test/test_sports_rotation_parity.py: with LEDMATRIX_PLUGINS, compares each
body with every plugin copy (drift-report normalisation plus decorators),
checks no other plugin class carries a copy, and that only football
overrides _rankings_loaded. Passes against claude/family7-reconcile; fails
against the pre-reconcile tree, as it should.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- sports_shared docstring: _by_importance and _other_games_window (and the
long-promoted _is_favorite_game) leave the "stay per-plugin" list.
- docs/SPORTS_UNIFICATION.md: family 7 status, decisions, the
_rankings_loaded seam; families 5 and 6 marked done and adopted (3.8.1 /
ledmatrix-plugins #631, 3.8.2 / #637) instead of "adoption waits".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(sports): stage 4 is adopted (core 3.8.0, ledmatrix-plugins #594)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: cite ledmatrix-plugins #641 for the family 7 reconcile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(scroll): report a panel that cannot reach its refresh cap, and suggest one it can hold
Scroll speeds are solved against display.hardware.limit_refresh_rate_hz,
which is only a ceiling. A panel that cannot reach it still moves whole
pixels per frame, but every scroll runs slow by the shortfall and the
"smooth" ladder is the cap's, not the panel's. A user rig (Pi 4, 2x128x64,
adafruit-hat-pwm, pwm_bits 9, gpio_slowdown 5) measured 107.6-113.1 Hz under
a 120 Hz cap: 60 px/s ran at 55, and nothing said why.
- scroll_config: refresh_shortfall() (more than 3% under the planned rate),
holdable_cap() (a multiple of 10, 5% under the measurement, since the
measurement is the fast end of an uncapped panel's drift), and
describe_refresh_shortfall().
- FrameTimingRecorder.plan_refresh(): once the measured period has held for
three trusted windows, a shortfall is logged once as a warning naming the
cap to use. DisplayManager calls it only for a real panel, not the
emulator or the fallback canvas. The stats file records
planned_refresh_hz (additive).
- GET /api/v3/config/refresh-rate, plus a hint under the Display tab's
Limit Refresh Rate field (js/pages/display.js) with a button that fills in
the suggested cap.
- _panel_refresh_hz (behind the Vegas slider's advice) ignores a measurement
written under a different cap, so a changed cap stops being advised from
the old rate before the display restarts.
Verified on ledpi with a temporary 200 Hz cap: the warning logged about a
minute after the restart ("about 132 Hz ... Set Limit Refresh Rate to
120 Hz"), the endpoint returned the same shortfall, and the Display tab
showed the hint; its button filled in 120. ledpi was restored afterwards.
Rebased onto main after the Display tab became an ES-module page (#771); the
hint moved from inline script into display.js, with a jsdom test.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(scroll): count only agreeing windows toward the refresh shortfall; ignore a null planned rate
Review fixes on #759.
- A window the period estimate rejects (more than MAX_REFRESH_DROP faster
than the adopted period) no longer counts toward the shortfall check, and
it restarts the run. After a loaded start fixed a slow period, later
windows at the real, faster rate were rejected yet still counted, so the
warning could name the slow rate against a cap the panel was meeting. It
now needs REFRESH_CHECK_WINDOWS consecutive windows that agree with the
period.
- _panel_refresh_hz treats a stats file whose planned_refresh_hz key is
present but null as no measurement. The emulator and the fallback canvas
write it that way (DisplayManager never plans a refresh for them), and
their frame rate says nothing about the cap. A file with no such key (an
older display) keeps the old behaviour.
Three new tests fail on the previous code and pass now.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* chore: remove dead code, deprecate unused plugin APIs (over-engineering audit)
Whole-tree audit. Every symbol was checked against core, the plugin
monorepo and all eight third-party plugins in plugins.json first.
- Deprecate (removal 3.10.0) plugin-facing methods nothing calls:
LogoDownloader bulk download, ConfigManager backup/secret wrappers,
APIHelper extras, BackgroundDataService poll API, PluginManager /
PluginStateManager info readers, and a few CacheManager, FontManager,
BaseOddsManager, DynamicTeamResolver methods and PluginTestCase.
plugin_api_usage.py learns their receiver names; DEPRECATIONS doc
regenerated.
- Remove core-internal dead code: CacheMetrics, Vegas status/stats
plumbing, sync "new cycle" message (followers ignore unknown types),
unused operation types, test-only PluginCatalog readers, IPC to_dict
and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator
callbacks, duplicate web response helpers.
- Web UI: drop never-mounted json-file-manager.js, the example widget,
utils/error_handler.js, four uncalled PluginAPI methods, and 29
escapeHtml shims (call window.LEDEscape directly). Public globals,
BaseWidget and widget names unchanged.
- Remove six one-off scripts (owner decision) and the unused markupsafe
and pytest-mock pins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): calendar picker error text goes in a text node, not innerHTML
Same output as the escaped innerHTML it replaces; clears Codacy's
XSS-pattern alerts on the line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(install): detect desktops by their metapackages, not name prefixes
The Lite check matched any installed package starting with gnome/kde/
xfce/lxde, so standalone parts (gnome-keyring, xfce4-terminal,
lxde-icon-theme) rejected a Lite system. Match whole names of desktop
metapackages and session managers instead.
Also catch desktops the prefixes missed: Raspberry Pi OS Trixie replaced
raspberrypi-ui-mods with rpd-wayland-core / rpd-x-core, Debian tasksel
desktops (task-*-desktop), and multi-arch names (plasma-workspace:arm64).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
* docs(common): list espn_payload in the common README
#749 added src/common/espn_payload.py without a summary row or section,
so test_common_readme_lists_every_module fails on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
(cherry picked from commit 8333f23e5c)
* fix(install): only a running desktop stops the install
A desktop costs the panel CPU only while it runs, so a running display
manager (checked with systemctl is-active, including the generic
display-manager alias, instead of a grep -q pipe under pipefail) still
stops the installer. Desktop packages or session files on a Pi that boots
to the console now print a warning and the install continues.
Adds installer OS-check tests for running, installed-only and Lite
systems, including the libblockdev and gnome-keyring false positives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(plugins): web mode lookups use the modes the display registered (#668)
A plugin may compute its display modes from its config: soccer-scoreboard
registers soccer_<league>_live/recent/upcoming for every custom_leagues
entry, which no manifest can list ahead of time. The display always rotated
them (_register_loaded_plugin prefers plugin.modes), but the web process
reads plugins as files, so /display/modes, the on-demand dialog and
on-demand/start with a mode and no plugin_id saw only manifests -- a custom
league's mode was missing from every list and 404'd on lookup.
- PluginStateManager.record_modes(): the controller records what it
registered, on the loaded record (an unload or reload forgets it)
- the runtime snapshot carries it per plugin as "modes" (bounded), and
PluginRuntimeView.display_modes() reports it only while live
- PluginCatalog takes a runtime_source; get_plugin_display_modes and
find_plugin_for_mode prefer the live modes, falling back to the manifest
when the display is stopped or has not loaded the plugin. The view is read
at most once a second, so a listing is one read, not one per plugin.
No manifest or plugin change needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(plugins): call the runtime view's display_modes directly
Codacy flagged the getattr/callable indirection as 'lookup is not callable'.
The view is a PluginRuntimeView or None; anything else raises inside the
existing try and falls back to the manifest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(plugins): address review -- no manifest fallback for live plugins, keep mode names whole, send registered spelling
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bumps src.__version__ to 3.8.2 and turns Unreleased into ## 3.8.2: the
display's malloc arena cap and between-screen malloc_trim (#774), and
src.common.sports_favorites (#775, sports family 6), which the scoreboards
adopt by flooring on 3.8.2. src/common/README.md and
docs/SPORTS_UNIFICATION.md say 3.8.2 for it; the SPORTS_UNIFICATION module
table also said "next release" for the four stage 4 modules, which shipped
in 3.8.0.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(common): sports_favorites -- the reconciled favourite matching (sports family 6)
New hardware-free module src/common/sports_favorites.py, copied from
ledmatrix-plugins claude/family6-reconcile once the nine scoreboards made
_is_favorite_game (seven bodies), _select_games_for_display (two) and
_select_recent_games_for_display (three) one body each. One mixin per class
that carries the methods, so adopting one gives no manager a method it did
not have:
- SportsFavoritesMixin (SportsCore): _is_favorite_game and _favorite_code.
- SportsUpcomingFavoritesMixin: _select_games_for_display.
- SportsRecentFavoritesMixin: _select_recent_games_for_display.
Each side of a game is named by the 3.5.0 _favorite_key seam
(SportsHelpersMixin; the abbreviation by default, nrl overrides it with the
ESPN team id and None for a missing id) and compared with favorite_teams
stripped and upper-cased. The selection methods give each favourite up to the
per-team limit, count a game between two favourites for both, treat only
games with an id as possible duplicates and log their summary at INFO.
- test/test_sports_favorites.py: the plugins' pinned cases for an abbreviation
host and an id-keyed (nrl-style) host -- case, spaces, ids, the NEW
collision, the "None" favourite, missing keys; selection order, limits,
duplicates and the id-less fix, the INFO summary; host contract, one carrier
per method, and SportsGameRulesMixin reaching the shared body.
- test/test_sports_favorites_parity.py: with LEDMATRIX_PLUGINS, compares each
body with every plugin copy (drift-report normalisation plus decorators),
checks no other plugin class carries a copy, and that only nrl overrides
_favorite_key.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- sports_helpers docstrings: _favorite_key now has a caller and an override.
- docs/SPORTS_UNIFICATION.md: family 6 status and decisions, and the seam
table. SportsCoreSharedMixin._round_robin_favorites still groups by raw
abbreviation or _team_in: it is not one of the plugin bodies, so it waits
for a later family.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(sports): family 6 also routes the Upcoming favourites-only filter and three live boosts
ledmatrix-plugins claude/family6-reconcile now sends the Upcoming update()'s
favourites-only pre-filter and the basketball, hockey and lacrosse live
favourite boost through _is_favorite_game, so a lower-case favourite works on
a favourites-only Upcoming board. The module is unchanged (update() is not
promoted); the parity test still passes against the branch. Updates the
pinned row and cell counts and what is left for later families.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: cite ledmatrix-plugins #635 for the family 6 reconcile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore: prepare the 3.8.1 release
Bumps src.__version__ to 3.8.1 and moves the Unreleased CHANGELOG entries
under a 3.8.1 heading, with an empty Unreleased above it.
The reason for the release is smooth scrolling at held-frame speeds. On
3.8.0 the default 50 px/s snapped to a stepped 48 px/s on a 120 Hz panel,
and any scroll slower than one pixel per refresh showed a half-pixel step
across the middle of the panel. Both are fixed on main (#710, #711) but
were in no release, so every stable-channel device still had them.
- #710's CHANGELOG entry had been filed under 3.8.0 although it merged
after the v3.8.0 tag; it moves to 3.8.1.
- #711 had no CHANGELOG entry; it gets one.
scripts/check_release_version.py v3.8.1 passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: sports_game_over and draw_text_outlined ship in 3.8.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
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>
* 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>