# Changelog Notable changes to the LEDMatrix core. The version below is the value of `src.__version__`, which the plugin loader reports to compatibility checks and which plugin manifests reference via `ledmatrix_min_version`. **Why this file exists:** the plugin monorepo bundles fallback copies of several core modules (see `docs/plugin-development/08-shared-sports-code.md` in [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin may delete its bundled copy only when its manifest floors on the first core release that ships the module — which requires module additions to be recorded here, against a version number. When you add a module plugins will import via `src.*`, note it in the Unreleased section and bump `src/__init__.py` in the release that ships it. **Use `ledmatrix_min_version` in manifests, not `ledmatrix_min`.** The loader accepts both, but the store flags the old spelling as deprecated (`store_manager.py`) and only the new one is in `schema/manifest_schema.json`. ## Unreleased ### Outlined text: one rasterization - New `draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0), offsets=OUTLINE_SQUARE)` in `src/common/text_helper.py`, with `OUTLINE_SQUARE` (the eight-sided outline the scoreboards draw) and `OUTLINE_CROSS` (four sides). Outlined text was one `draw.text` per outline offset plus one for the text, so FreeType rasterized the same string nine times. This rasterizes it once and stamps the mask at each offset: the same pixels, about 8x faster per outlined string (Pillow 12.3, desktop). `test/test_text_helper.py` compares it with the nine-draw loop across the bundled fonts, image and font modes, colours and positions, and fails if it stops rasterizing once. Fractional coordinates, multiline text, fonts other than a plain `FreeTypeFont`, image modes other than RGB, RGBA and L, and a subclassed or replaced `draw.text` take the old loop unchanged. A whole-pixel float such as `52.0`, which the scorebugs' centring passes, is not fractional. - `SportsCoreSharedMixin._draw_text_with_outline`, which eight of the nine scoreboards inherit for their switch-mode scorebug (ufc has its own), and `TextHelper.draw_text_with_outline` now draw through it. Scroll and Vegas cards still use each plugin's own `game_renderer.py` loop, so building a scroll strip costs the same until the plugins adopt `draw_text_outlined`, importing it with an `ImportError` fallback to their own loop (a separate ledmatrix-plugins change after a core release ships it). ### Shared fetch service (stage 1) Core's own HTTP fetch paths now go through one service, so the plugins that use them get pooling, merging, host budgets and per-plugin request counts without a code change. Return values, exceptions, cache keys, TTLs and retry policies are unchanged. - **What goes through it.** `APIHelper.get`/`post`, `fetch_espn_scoreboard` and its date chunks (`src/common/espn_dates.py` -- every scoreboard's live, recent and upcoming fetch, and `SportsFetchMixin._fetch_season_directly`), `BackgroundDataService` and `BaseOddsManager.get_odds`. Plugins' own `requests` calls are not covered yet. - **Shared connection pools.** Core sessions with the same retry policy mount one shared adapter, so the odds managers (one per scoreboard league manager), the background service and the APIHelpers reuse one connection pool per host. Headers, cookies and auth stay per session. - **Merged requests.** Identical GETs in flight at once (same URL and query, effective headers, timeout and retry policy) go out once; the others get a copy of that response or the same exception. `BackgroundDataService`'s own request opts out (`share_in_flight=False`): it cancels and replaces fetches, and already merges by cache key. - **Host budgets.** Per-host token buckets, `fetch_service.rate_limits` in `config.json` (new optional section in the template). ESPN hosts default to 20 requests/s with a burst of 200, far above normal traffic; no request waits longer than `max_wait_seconds` (2 s). Other hosts are unthrottled. - **Conditional GET.** A response with `ETag` or `Last-Modified` is kept in a small bounded store (64 entries, 4 MB, 1 MB each) and revalidated; a `304` is returned to the caller as the original `200`. ESPN sends neither validator today, so on ESPN this is dormant. - **Counters.** Requests, merged, bytes, 304s, errors, HTTP errors, adapter retries, throttled requests and seconds waited, per plugin and per host. Which plugin made a request comes from a context variable the plugin executor and plugin loader set (carried across the background service's and `espn_dates`' worker threads), or else from the plugin directory on the stack, so a plugin's own threads count too. The display publishes them to the shared cache at most once a minute on change; read them at `GET /api/v3/plugins/fetch-stats`. - `fetch_service` is a core config section (`src/core_config_keys.py`). ### Control socket (stage 2: wake-ups, brightness, plugin reload) - **Socket commands land at once.** Stage 1's socket was no faster than the mailbox: a command waited for the static screen's 1 s frame sleep, the dwell's 0.25 s tick, or Vegas's interrupt check every 10 frames (about 0.4 s on a Pi 4). The render thread now waits on the socket's queue instead of sleeping, and Vegas checks the queue every frame, so an on-demand start or stop is applied within about a millisecond on a static screen or in a dwell, and at the next frame in Vegas or on a scrolling screen. Commands still run only on the render thread. The file mailbox keeps its old delays. Idle CPU is unchanged in practice: the waits are timed `Event` waits with the same wake-ups as the sleeps they replace (about 25 µs more per wait, measured). - **`brightness.set`.** Saving a brightness (`POST /api/v3/config/main`) also puts it on the panel at once over the socket, instead of when the display's config watcher next reads `config.json` (up to about 2 s). The response says `brightness_transport: "socket"`, or `"config"` with `brightness_socket_error` when the watcher applies it as before. The command itself writes nothing; the dim schedule still applies on top. - **`plugin.reload`.** Updating an enabled plugin from the store no longer asks for a display restart when the display can reload it: the update route asks the display over the socket, which reloads the plugin on its render thread at the start of the next screen (its modes keep their place in the rotation) and answers once the new code runs. The response then says `restart_required: false`, `reloaded: true` and `reloaded_version`. Without the socket, with a display older than this command, or when the reload fails, the route answers `restart_required: true` as before, with `reload_error` giving the reason. The route now also uses the manifest's plugin id (the one the display runs it under) for this decision, so an update through a registry alias of an enabled plugin no longer reports that no restart is needed. - Both commands answer with the render thread's outcome, or `pending` when it did not get to them in time (2 s and 10 s). Socket protocol version is still 1: new commands are additive, and an older display answers `unknown_command`, which the web interface falls back from. The security model is unchanged: the same `0660` group socket and peer-credential check. `config.reload` was not added; see `docs/IPC_CONTROL_SOCKET.md` for why. ### New modules - `src/common/fetch_service.py` -- the fetch service above. Core-internal in this release: plugins reach it through `APIHelper` and `espn_dates`, and should not import it directly until a plugin-facing API ships (stage 3), so it sets no `ledmatrix_min_version` floor. ### Tooling - Golden trace tests for the display loop. `test/test_run_loop_golden.py` runs the real `DisplayController.run()` against fake plugins on a fake clock (`test/_run_loop_harness.py`), with no hardware and no real sleeps, and compares which mode was shown, for how long and why it ended with `test/fixtures/run_loop_golden/`. It has 15 scenarios: rotation, empty and failing modes, dynamic duration, live priority, on-demand (including pinned and resumed after a restart), the schedule and dim schedule, WiFi notices, sync follower and Vegas. The whole file runs in about a second. This is stage 1 of restructuring `run()`, described in `docs/RUN_LOOP_REDESIGN.md`. The other part of stage 1 is internal and changes no behaviour: twelve blocks of `run()` move into named helpers (`_dispatch_first_frame`, `_resolve_durations`, `_resolve_active_mode`, `_needs_high_fps`, `_advance_after_screen` and others), and the traces are identical before and after the move. ### Fixes - A plugin whose `display()` raises now opens its circuit breaker. The first frame of each screen goes through the plugin executor, which caught the exception and returned False. The display read that as "no content" and recorded a success, which reset the plugin's failure streak, so the breaker never tripped. The plugin stayed in rotation and logged a traceback on every screen. The raise now counts as a failure, so after three in a row the plugin leaves rotation until the cooldown ends, the same as a raising `update()`. The display still moves straight on to the next mode. A hung `display()` is still recorded once, as a hang. - A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows within about a second of being posted. It was only checked between screens, so a 5 s notice posted during a 20 s screen expired before that screen ended and never appeared. The screen it interrupts comes back in full once the notice ends. When Vegas stops scrolling for a notice, the notice is what shows next, and Vegas resumes after it; before, a rotation screen showed instead and the notice expired behind it. An active on-demand session still holds the panel until it ends. - A game that goes live now takes over the panel within about a second. Live priority was only checked between screens, so a game that went live during a 30 s screen waited for that screen to end. The frame loops and the dwell sleep now check too, at most once a second, and not while an on-demand session is running or a live game is already showing. When Vegas stops for a live game, the game is the next screen. Before, one rotation screen showed first and the game came after it. Each check also asks each plugin `has_live_content()` once, where a plugin registered under several modes used to be asked once per mode. - The display schedule turns the panel off at exactly the end time. A window now runs from its start time up to, but not including, its end time: with 07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end minute counted as on, and because the schedule is checked once a minute, the panel went off at 23:00 or at 23:01 depending on when in the minute that check ran. Windows that cross midnight and per-day schedules follow the same rule, and so does the dim schedule. - An on-demand session that ends during scheduled-off hours, by expiring or being stopped, blanks the panel within about a second. It used to stay on until the next minute, because the once-a-minute schedule check had already run that minute and the session had overridden its answer. ### Scrolling - A scoreboard in scroll mode no longer freezes the panel at the start of a recent or upcoming turn whose games have not changed. `SportsScrollDisplayManager.prepare_and_display()` redrew every card on every turn while the render thread waited (~1.4s for seven football cards at 192x48 on a Pi 4); it now rewinds the strip it built last time when nothing it is drawn from has changed (the games, rankings, config, panel size and date), and redraws it at least every 10 minutes. Each slate (game type and leagues) keeps its own display, so leagues that take turns (`nfl_recent`, `ncaa_fb_recent`) each find their strip again: up to 4 per game type, with at most 6MB per plugin of strips kept for slates not on screen. The first turn of each slate after a start, a slate whose games changed, live strips and a turn with no games are drawn as before. `get_scroll_display()` and `_scroll_displays` still answer with the strip on screen; a sport's `prepare_scroll_content()` is no longer called on every turn. ### Web preview: less work per frame - Mid-scroll, `update_display()` no longer checksums every frame. The checksum (`tobytes()` plus `adler32` over the whole framebuffer: ~0.17 ms a frame at 256x64 on a Pi 4, so roughly twice that at 512x64 and well under 0.1 ms at 128x32) fed only the dirty-tracking skip, which never applies while scrolling, and the preview snapshot's changed-frame check. The snapshot now asks its policy first and hashes the frame only when a write or touch could follow. That changes no snapshot decision: `snapshot_policy.decide()` is monotone in `frame_changed`, and a test holds it to that. Two small differences on the panel: the first static frame after a scroll is pushed even when it matches the scroll's last frame (one extra swap), and the frame on which a scroll that never said it stopped times out is presented at a hold of 1 rather than the scroll's hold. - With the web preview open, the display writes the snapshot at most once a second (`snapshot_policy.VIEWER_INTERVAL`, was 0.2 s). The preview already showed at most one frame a second: its SSE stream re-read the file once a second, so four PNG encodes in five were overwritten unread. The stream now checks the file's mtime every 0.25 s (new `VIEWER_POLL_INTERVAL`) and sends each frame soon after it is written, so the preview stays about as fresh; it still touches the viewer marker once a second, and with no snapshot file it still sends its placeholder once a second. A screen that animates faster than once a second without marking itself as scrolling (a GIF, say) was encoded on the render thread up to five times a second while the preview was open, 12-14 ms each at 512x64 on a Pi 4; now at most once. - The snapshot PNG is written at `compress_level=1`. On a desktop that encoded a text-dense 512x64 frame in about half Pillow's default time, into a larger file (12 KB instead of 7 KB); sparser frames gain less. - `scripts/frame_soak.py --preview` soaks are not comparable across this change: an open preview now costs at most one encode a second, not up to five. Take both sides of an A/B pair on the same side of it. ### Scroller-to-static handovers - A static plugin screen that follows a scroller no longer starts with the scroller's leftovers. Nothing ended the scroll state at a handover; it expired 2 s after the last scroll frame. So on a panel with scan-order compensation the static screen's first frame went out with the lagging rows (the bottom half on a 96x48 panel) taken from the ticker's last frame: for the whole second it stays up after a scroll at one frame per refresh, and for its first refresh after a slower, held one. The display controller now calls the new `DisplayManager.end_scroll_for_static_screen()` just before such a screen's first `display()`, so the frames that call draws go out as drawn, in one swap each, and `set_scrolling_state(False)` once it returns. The scroll state and its frame hold stay until then, so the handover is still timed, against the scroller's own pacing: late-frame counts are unchanged. - The phantom ~1 s freeze when a static plugin screen follows a scroller is no longer recorded: the 1 Hz loop's second frame was timed as a frame of the old scroll, in the soak's freezes and as a `Render stall` in the log. On ledpi that was 17 of 31 `Render stall over` lines (2026-09-15 to 10-01). - A screen's first frame is tagged `handover` in the frame stats, every turn's, also when the rotation comes back to the same mode. A gap of 250 ms or more before it is counted in the new `handover_freezes` (additive; the schema version is unchanged), not in `freezes` / `freeze_by`, and `frame_soak.py` prints it as "Handover gaps": a scroller rebuilding its content at the start of a turn shows up there. **Freeze counts from soaks before and after this change are not comparable.** A stall dump taken while that first `display()` is still drawing says `in a handover gap` instead of `mid-scroll`, and the call runs on a thread named `display-`. ## 3.8.0 Live Vegas elements: plugin content that keeps changing while it scrolls (scores on the scoreboards' cards, the flight map's gliding aircraft, the weather radar's loop), with live games kept in the ticker by default. Also stable/beta update channels, the display control socket, the systemd display watchdog, optional web login, ES-module web UI pages, sports consolidation stage 4, and the removal of the 35 plugin APIs deprecated since 3.5.0 (see Removed). ### New modules A plugin may import these via `src.*` once it floors on 3.8.0 (and should guard the import, since the loader's version check is advisory). - `src/plugin_system/vegas_elements.py` -- `VegasElement`, the unit a plugin's `get_vegas_elements()` returns (also re-exported from `base_plugin`). See "Live Vegas elements" below. - `src/plugin_system/testing/vegas.py` -- the harness for those hooks: `render_vegas_elements`, `check_vegas_elements`, `render_vegas_timeline`. - `src/common/sports_vegas.py` -- what a scoreboard needs for live cards: `game_key`, `game_fingerprint`, `dedupe_games`, `VegasCardCache`, `StickyOdds`, `finished_games`. - `src/common/sports_plugin_host.py`, `sports_live_scroll.py`, `sports_display_rules.py` and `sports_font_path.py` -- sports consolidation stage 4; see "New modules (sports consolidation stage 4)" below. - `src/display_watchdog.py`, `src/plugin_system/plugin_catalog.py`, `src/plugin_system/plugin_runtime.py`, `src/plugin_system/field_model.py` and the `src/ipc/` package -- new core modules (described below) that plugins do not normally import. ### Web UI: ES modules and one form model (stage 1) - The web UI gains a native ES-module layer, loaded with `