# 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 ### Removed: the cache-key mailboxes (control socket stage 5) -- breaking The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the web interface sends the display a command. The file mailboxes it fell back to for one release are gone. - **`display_on_demand_request` and `plugin_error_clear_request` are no longer written or read.** The display stops polling the on-demand mailbox (`MailboxWatch`, `MAILBOX_POLL_INTERVAL_WITH_SOCKET`, `_consume_on_demand_request` and the persisted `display_on_demand_processed_id` guard are removed), and the error publisher stops reading the clear request. `CacheManager.file_signature`, `src.cache_manager.MailboxWatch`, `src.ipc.client.should_fall_back` and `src.error_aggregator.ERROR_CLEAR_REQUEST_KEY` are removed. - **A write to either key is dropped, with one warning per writer.** `CacheManager.save_cache` (and so `set`) refuses `RETIRED_MAILBOX_KEYS` and logs `Ignored a write to the retired '' cache key by plugin ''`, naming the plugin from the call stack or the request. Plugins must use `BasePlugin.request_on_demand()` / `end_on_demand()` (3.8.1). In the official monorepo, birdnet-go, mqtt-notifications, on-air and pomodoro-timer still write the mailbox, but only as their fallback when those methods are missing or answer `None`. - **On-demand routes without a listening display.** `POST /api/v3/display/on-demand/start` with no display listening starts the service (when `start_service`, the default) and answers **`202`** with `status: "starting"` at once; a single background worker in the web process (`web_interface/on_demand_dispatch.py`) sends the request until the display acknowledges it, for up to 45 s (10 s for a service that is running but has no socket yet). `GET /display/on-demand/status` reports it (`starting`; still `starting` with `delivered: true` after the display acknowledges it, until the display publishes the state for that `request_id`, now part of its on-demand state, for at most 30 s; then the display's state, or `error` / `start-timeout`), and `/display/current-status` adds `on_demand_pending`. A newer start replaces a pending one and a stop cancels it (`cancelled_request_id`). The web UI and the MQTT bridge treat `202` as taken. With the service stopped and `start_service` false it answers `400`. Every other socket failure (`unknown_command` from an older display, `disabled`/`unsupported`, `busy`, a timeout) is a `503`. `/stop` answers `503` when no display is listening, unless `stop_service` stops the service. `transport` is always `"socket"`; the `"mailbox"` value is gone. - **`POST /api/v3/errors/clear` without the socket answers `503`** (with `context.socket_error` and a message saying why) instead of recording a request. `clear_pending` in the error routes is now always `false`; `src.error_aggregator.read_error_report()` returns only the snapshot, and `error_summary_from_report()` / `plugin_health_from_report()` / `request_error_clear()` lose their clear-request arguments. - **Kept:** the display still writes `display_current_state`, `display_on_demand_state`, `plugin_runtime_snapshot` and the heartbeat file, because the web interface reads them whenever the socket cannot answer (a stopped or starting display, a web user not yet in the socket's group, Windows), and `display_on_demand_config`, its own record for resuming a session after a restart. - **Windows and `LEDMATRIX_CONTROL_SOCKET=off`:** with no socket, the web interface can no longer start or stop on-demand sessions or clear errors on a running display (the mailbox used to carry them). ## 3.8.2 The display hands freed memory back to the OS (#774), and sports consolidation family 6: `src.common.sports_favorites`, which the scoreboards adopt by flooring on 3.8.2 (#775). ### The display hands freed memory back to the OS The display process's resident memory climbed in steps for hours while the data it held stayed flat: glibc keeps what Python frees in per-thread malloc arenas and returns little of it. `src/malloc_tuning.py` (new, standard library only, a no-op off Linux/glibc) does two things in-process, so it reaches devices without re-running the installer: - **Arena cap at start-up.** `run.py` calls `mallopt(M_ARENA_MAX, 2)` before any thread exists, the same cap as the unit's `Environment=MALLOC_ARENA_MAX=2`. Units installed before that line never got it (systemd runs the copy in `/etc/systemd/system`); a `MALLOC_ARENA_MAX` in the environment still wins. - **`malloc_trim(0)` between screens**, at most every 5 minutes, from the top of the render loop where no frame is being drawn. Measured on a Pi 4: 2-11 ms per call. On ledpi (Pi 4, 192x48, Vegas on, nine plugins, a unit without `MALLOC_ARENA_MAX`), alternated main / branch / branch / main arms of 2.5 h: two hours in, resident memory was 551 MB on main (the second main arm was already at 651 MB after 1 h 44 min) against 412 and 386 MB with this change, and the 20-minute frame soaks came out at 0.147-0.165% late against main's 0.151-0.188%. ### New modules - `src/common/sports_favorites.py` -- sports consolidation family 6, once the plugins made `_is_favorite_game` (seven bodies), `_select_games_for_display` (two) and `_select_recent_games_for_display` (three) one each: `SportsFavoritesMixin` (`SportsCore`: `_is_favorite_game`, `_favorite_code`), `SportsUpcomingFavoritesMixin` and `SportsRecentFavoritesMixin` (the favourites-only picks). Each side of a game is named by the 3.5.0 `_favorite_key` seam and compared with `favorite_teams` stripped and upper-cased; nrl overrides the key with the ESPN team id. Only a game with an id can be a duplicate. A plugin may inherit the mixins once it floors on 3.8.2, and deletes its copies then. (#775) ## 3.8.1 Smooth scrolling at the slower speeds, and the fixes and performance work since 3.8.0. Highlights: the default 50 px/s and every other held-frame speed now scroll cleanly (below), Raspberry Pi OS Bookworm is supported alongside Trixie, updates refresh the systemd units, the display control socket gains stages 2 and 3, the shared fetch service lands (stages 1 and 2), and a run of web UI and Plugin Manager fixes. One new module is for plugins: `src.common.sports_game_over` (sports family 5), which the scoreboards adopt by flooring on 3.8.1; the other new modules are core-internal and set no `ledmatrix_min_version` floor. ### Scroll speed These two entries were the reason for this release: on 3.8.0 a slow scroll either stepped or showed a half-pixel tear across the middle of the panel, so only speeds of one pixel per refresh looked right. - The Vegas Scroll Speed slider now says what the panel will do with the speed it is on, and offers the nearest smooth ones to click. Only speeds that advance a whole number of pixels per refresh look smooth, and which those are depends on the panel (`GET /api/v3/config/scroll-speed-advice`, built on `scroll_config.speed_advice()`; it uses the refresh the display measured, not the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5. - The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5 refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s, which move one pixel at a time. 100 Hz panels are unaffected. (#710) - A held-frame scroll (one pixel every two or more refreshes, such as 50 or 60 px/s on a 100-120 Hz panel) no longer shows a half-pixel step across the middle of the panel. Scan-order compensation ran only at one frame per refresh; a held frame is now presented as a sequence of swaps (`scan_order.refresh_plan()`), so the half of the panel that scans later steps one refresh after the rest. It is skipped when a blit takes more than half a refresh, since the second blit has to land before the next vsync. (#711) ### Web UI: the Display tab is an ES-module page, with a page-visibility service (stage 4) - New `static/v3/js/core/visibility.js`: each page module gets `ctx.visibility` with `whileVisible(start, stop)`, `every(ms, fn)` and `isVisible()`. Work registered there runs only while the page's tab is the active tab and the browser tab is visible, and ends when the page is swapped out, with no teardown code in the page. It reads the active tab from `window.LEDVisibility`, so it agrees with the classic partials that still use that directly. The page registry gained a `mountContext` option for services bound to one mounted page. - The Display tab's inline scripts are now `static/v3/js/pages/display.js`. The partial has no inline script, `onclick` or `onchange` any more. The multi-display sync status poll (every 5 s) runs through `ctx.visibility.every`; the status and scroll-speed hint requests go through `core/api.js` with the page's abort signal, and so does the Vegas order widget's plugin-list request. - Behaviour differences: with sync on, opening the tab asks for the status once instead of twice, and a Display tab loaded while not on screen waits until it is. A login redirect during a poll no longer flashes "Sync status unavailable". The `window.syncStatusInterval` timer id is gone (nothing read it). A pending scroll-hint request or widget retry is dropped when the partial is swapped out. - `window.updateSyncUI` keeps working as a deprecated alias through `window.LEDMatrix` (one console warning). - New suites `test/js/dom/test_visibility_service.js` and `dom/test_display_page.js`; `unit/test_display_partial_ids.js` imports the module instead of slicing the template. ### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()` The in-process way in that stage 5 of the control socket needed (`docs/IPC_CONTROL_SOCKET.md`, "Plugins in the display process"). - **`BasePlugin.request_on_demand(mode=None, duration=None, pinned=False)`** shows the plugin now, and **`BasePlugin.end_on_demand()`** gives the screen back. Both are safe from any thread (an MQTT callback, a timer thread): `PluginManager.request_on_demand()` / `end_on_demand()` hand the request to `DisplayController.submit_plugin_on_demand()`, which only queues it (at most 32) and wakes the render thread through the control socket's flag (`ControlServer.wake()`). The render thread applies it with the socket's commands, through the same handler as a web on-demand request, so it lands within a frame rather than on the mailbox's once-a-second look. Both return the request id, or `None` when no display runs in the process (the web interface, `scripts/check_plugin.py`) or the queue is full. - **A plugin's stop ends only its own session.** A mailbox stop still ends any session, whoever started it. - **Older cores.** Plugins detect the methods with `hasattr` and write the `display_on_demand_request` mailbox when they are missing or answer `None`; the pattern is in `docs/PLUGIN_API_REFERENCE.md` ("On-demand display"). The display still reads the mailbox for plugins that write it. ### Web UI: Schedule and General are ES-module pages (stage 3) - The Schedule and General tabs follow stage 2 (#727): their inline `