# 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 ### 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`). ### 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. ### 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 `