mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
Compare commits
40
Commits
v3.7.0
...
53f4f1fdbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f4f1fdbf | ||
|
|
1a1a6f5c65 | ||
|
|
0b3fa712d0 | ||
|
|
fb9820b78e | ||
|
|
ad47f89066 | ||
|
|
f4bda50710 | ||
|
|
56947298d6 | ||
|
|
596809acc3 | ||
|
|
77862b631b | ||
|
|
3973f0c3c5 | ||
|
|
04cad60259 | ||
|
|
22b88c4677 | ||
|
|
a51305bb4f | ||
|
|
d4f828d189 | ||
|
|
ec1aa34f40 | ||
|
|
f96c815e0e | ||
|
|
559ef14a5c | ||
|
|
8cce532ea9 | ||
|
|
876130e93f | ||
|
|
701c220ad0 | ||
|
|
1160eb5efe | ||
|
|
6efea8c19a | ||
|
|
1252df9bad | ||
|
|
7804ea8f69 | ||
|
|
64c7289593 | ||
|
|
b09434a418 | ||
|
|
7ab6fb1aff | ||
|
|
ba6eccb489 | ||
|
|
5ea0d511dc | ||
|
|
1c928b2033 | ||
|
|
b2df0fda1b | ||
|
|
15c61def67 | ||
|
|
c8a0ddcf7b | ||
|
|
9fe23af432 | ||
|
|
c0d97e4867 | ||
|
|
e3c85cece6 | ||
|
|
ba38a83c2c | ||
|
|
c3a7a110c4 | ||
|
|
6047eb5e4e | ||
|
|
c4c46d3ba7 |
@@ -6,3 +6,7 @@
|
||||
# and systemd rejects CRLF unit files.
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
|
||||
# Generated by scripts/build_css.py; collapsed in diffs, not hand-edited.
|
||||
web_interface/static/v3/tailwind.css linguist-generated=true
|
||||
web_interface/static/v3/plugin-frame.css linguist-generated=true
|
||||
|
||||
@@ -113,6 +113,25 @@ jobs:
|
||||
REQUIRE_DOM: "1"
|
||||
run: node test/js/run_all.js
|
||||
|
||||
css-build:
|
||||
name: Tailwind CSS is up to date
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no
|
||||
# Node), rebuilds static/v3/tailwind.css and plugin-frame.css from the
|
||||
# templates and JS, and fails if the committed files differ. Fix a
|
||||
# failure by running `python3 scripts/build_css.py` and committing.
|
||||
- name: Check the committed CSS matches a fresh build
|
||||
run: python scripts/build_css.py --check
|
||||
|
||||
type-check:
|
||||
name: Type check (mypy ratchet)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -140,3 +159,39 @@ jobs:
|
||||
# them, or if a listed file is missing. See CONTRIBUTING.md.
|
||||
- name: Run mypy on the ratchet list
|
||||
run: python scripts/check_types.py
|
||||
|
||||
sports-drift-report:
|
||||
name: Sports drift report (report only)
|
||||
runs-on: ubuntu-latest
|
||||
# A progress measure for docs/SPORTS_UNIFICATION.md, never a gate: the
|
||||
# monorepo's own check_sports_drift.py is the gate. The step summary shows
|
||||
# how many bodies each scoreboard method family still has.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out ledmatrix-plugins (main)
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: ChuckBuilds/ledmatrix-plugins
|
||||
path: ledmatrix-plugins
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# Stdlib only; exits 0 whatever it finds.
|
||||
- name: Report method-family drift across the nine scoreboards
|
||||
run: |
|
||||
python scripts/sports_drift_report.py --plugins ledmatrix-plugins \
|
||||
--markdown --json sports-drift.json >> "$GITHUB_STEP_SUMMARY"
|
||||
python scripts/sports_drift_report.py --plugins ledmatrix-plugins
|
||||
|
||||
- name: Upload the full report
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: sports-drift-report
|
||||
path: sports-drift.json
|
||||
|
||||
+492
-1
@@ -19,6 +19,496 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Update channels
|
||||
|
||||
- Devices no longer pick up every merge to `main`. A new setting,
|
||||
`auto_update.channel`, picks what Update Code and the weekly automatic
|
||||
update install: `stable` follows the newest release tag (`vX.Y.Z` by
|
||||
semantic version; pre-releases and other tags are ignored) and checks it
|
||||
out with a detached HEAD, and `beta` follows `main` as every device did
|
||||
before. New installs default to `stable` (config template and installer).
|
||||
- Nobody is moved backwards. A device running code newer than the newest
|
||||
release, which is any device that pulled `main` since that release, keeps
|
||||
following `main` until a release contains its commit, then moves to it and
|
||||
follows releases. A config written before channels existed behaves the
|
||||
same way and is saved as `stable` when that move happens. Switching from
|
||||
beta to stable says so instead of installing an older version.
|
||||
- Switch channels on the General tab (Update Channel, under Automatic
|
||||
Updates) or with `GET`/`POST /api/v3/system/update-channel`. The Overview
|
||||
update banner compares release tags on stable ("LEDMatrix v3.8.0 is
|
||||
available") rather than commits on `main`. A detached checkout newer
|
||||
than the newest release gets no banner: Update Code leaves it where it
|
||||
is until a release includes it.
|
||||
- A move between `main` and a release tag carries local edits across as the
|
||||
pull's `--autostash` does, and the automatic update's health check rolls
|
||||
it back to where HEAD was: the branch, or the detached release.
|
||||
|
||||
### Frozen-panel detection
|
||||
|
||||
A render loop stuck inside a plugin's `display()` left `ledmatrix.service`
|
||||
"active" with the panel frozen, and nothing noticed: `/api/v3/health` judged
|
||||
the display by the preview PNG's age, and the automatic update's health check
|
||||
passed "service active plus one HTTP 200".
|
||||
|
||||
- **systemd watchdog.** `ledmatrix.service` now has `WatchdogSec=120` and
|
||||
`NotifyAccess=main` (still `Type=simple`). The render thread itself pings
|
||||
systemd over `$NOTIFY_SOCKET` (`src/display_watchdog.py`, standard library
|
||||
only), so a stuck render thread stops the pings even while the update
|
||||
worker and Vegas's tick thread carry on. systemd then kills the display with
|
||||
SIGABRT -- faulthandler writes every thread's stack to the journal, which
|
||||
names the plugin -- and restarts it. The process widens the limit to 15
|
||||
minutes while it starts and while it loads a plugin enabled from the web UI
|
||||
(either can run pip), and sends `READY=1` and narrows it back after its
|
||||
first frame.
|
||||
- **Heartbeat.** The render loop writes `/run/ledmatrix/display-heartbeat.json`
|
||||
every 5 seconds (`RuntimeDirectory=ledmatrix`; tmpfs, so no SD-card
|
||||
writes). `/api/v3/health` reports it as `checks.display_loop`: `running`,
|
||||
`stalled` (older than 60s; the overall status turns `degraded`) or
|
||||
`not_reported` when there is no heartbeat (dev server, emulator, Windows),
|
||||
which leaves the verdict to the older checks as before.
|
||||
- **Update health check.** When the display wrote a heartbeat before an
|
||||
automatic update, the restarted display must keep one fresh (30s) for the
|
||||
update to pass; a frozen panel is rolled back. Code that never wrote one is
|
||||
checked as before. The check runs as the copy taken before the update, so
|
||||
this takes effect from the update after the one that installs it.
|
||||
- **Crash loops back off.** `RestartSteps=4` and `RestartMaxDelaySec=2min`
|
||||
stretch the delay between automatic restarts from 10s to two minutes, instead
|
||||
of retrying every 10s forever. systemd before 254 (Bookworm) ignores the two
|
||||
lines with a warning. A start limit was ruled out: once tripped it leaves the
|
||||
panel dark and refuses the web UI's Start button and the update rollback.
|
||||
- **Existing installs** keep their old unit until `sudo
|
||||
./scripts/install/install_service.sh` is re-run (an update never rewrites
|
||||
units; the startup validator warns about the drift). Until then there is no
|
||||
watchdog, but the display creates `/run/ledmatrix` itself, so the heartbeat,
|
||||
the health check and the update check work straight away.
|
||||
|
||||
### Security
|
||||
|
||||
- The web interface refuses state-changing requests (`POST`, `PUT`, `PATCH`,
|
||||
`DELETE`) sent by another website's page. Any site a LAN user visited could
|
||||
make their browser submit a plain HTML form to `http://<pi>:5000` -- CORS
|
||||
does not stop such a request, only hides its answer -- and
|
||||
`/api/v3/system/action` accepted form bodies, so that page could reboot or
|
||||
power off the Pi, pull code, or reach any other mutating route. A request
|
||||
whose `Origin` (or, without one, `Referer`) is not the host it was sent to,
|
||||
or is `null`, now gets 403 `CROSS_SITE_REQUEST`
|
||||
(`web_interface/origin_guard.py`). `/api/v3/system/action` also refuses a
|
||||
form-encoded or `text/plain` body (415) unless it carries HTMX's
|
||||
`HX-Request` header; every caller in the interface already sends JSON.
|
||||
- **Behaviour change for API scripts:** clients that send no `Origin` or
|
||||
`Referer` -- curl, Python `requests`, Home Assistant, the MQTT bridge --
|
||||
are unaffected. A browser page served from a *different* origin (a
|
||||
dashboard or userscript on another host) can no longer call the mutating
|
||||
API; call it server-side instead. Anyone posting a form body to
|
||||
`system/action` must switch to JSON. Behind a reverse proxy, forward the
|
||||
original `Host`, port included (`proxy_set_header Host $http_host;`;
|
||||
nginx's `$host` drops the port); `X-Forwarded-Host` is not trusted. A
|
||||
TLS-terminating proxy needs nothing more: a portless `Host` matches an
|
||||
`https://` page.
|
||||
|
||||
### Optional web login
|
||||
|
||||
- The web interface can require a password, **off by default**: a device that
|
||||
does not set one behaves exactly as before. Set it under **General >
|
||||
Security**; from then on every page and API route needs a login (a session
|
||||
cookie, 30 days, kept across restarts) or an API token. Unauthenticated page
|
||||
loads go to the new `/login` page, HTMX requests get `HX-Redirect` to it,
|
||||
and API calls get `401` JSON (`AUTH_REQUIRED` / `INVALID_TOKEN`). Wrong
|
||||
passwords are rate-limited per address (5 a minute, 30 an hour, through the
|
||||
existing flask-limiter). Log out from the header. Changing the password
|
||||
signs every other browser out. (`web_interface/auth.py`)
|
||||
- **API tokens** for Home Assistant, scripts and the MQTT bridge: create,
|
||||
list and revoke them in the same section, send them as
|
||||
`Authorization: Bearer <token>`. A token is shown once; only its SHA-256 is
|
||||
stored. Tokens cannot change login settings. The MQTT bridge takes one as
|
||||
`ledmatrix_api_token` (or `LEDMATRIX_MQTT_LEDMATRIX_API_TOKEN`, or the
|
||||
Tools tab); it needs one only when it runs on another machine.
|
||||
- Always open, login or not: requests from the Pi itself (loopback, without
|
||||
proxy headers), the Wi-Fi setup flow (`/setup` and the Wi-Fi status, scan
|
||||
and connect routes) while the Pi is in access-point mode, static files, the
|
||||
captive-portal probe URLs, and `/api/v3/health`, which then answers only
|
||||
`{"status": "healthy" | "degraded"}` to a caller that is not logged in.
|
||||
- The password hash (werkzeug), the token hashes and the cookie-signing key
|
||||
live in the `web_auth` section of `config/config_secrets.json`. No API
|
||||
returns them: `GET /api/v3/config/main`, `GET /api/v3/config/secrets` and
|
||||
the raw JSON editor leave the section out, the raw secrets save keeps the
|
||||
stored one, a `/config/main` save drops a `web_auth` key, and orphaned-plugin
|
||||
cleanup no longer treats it as a plugin (`CORE_SECRETS_KEYS`).
|
||||
- **Lost password:** `sudo python3 scripts/reset_web_password.py` on the Pi
|
||||
turns login off (`--revoke-tokens` also deletes the tokens), or open the
|
||||
interface from the Pi itself.
|
||||
- New routes: `/login`, `/logout`, `GET /api/v3/auth/status`,
|
||||
`POST /api/v3/auth/password`, `POST /api/v3/auth/disable`,
|
||||
`GET|POST /api/v3/auth/tokens`, `DELETE /api/v3/auth/tokens/<id>`.
|
||||
|
||||
### Vegas participation
|
||||
|
||||
A plugin now takes part in Vegas mode in one declared way: `'scroll'` (its
|
||||
content scrolls by), `'pause'` (the scroll stops for its turn and its
|
||||
`display()` draws it full screen) or `'exclude'`. No plugin changes
|
||||
behaviour: one that declares nothing gets exactly what the old hooks gave
|
||||
it, checked against every official plugin.
|
||||
|
||||
- `BasePlugin.get_vegas_participation()` resolves, in order: the user's
|
||||
`vegas_participation` config value, the manifest's `vegas_participation`,
|
||||
then the legacy hooks (`get_vegas_display_mode()` returning `STATIC` →
|
||||
pause, else `get_vegas_content_type()` returning `'none'` → exclude, else
|
||||
scroll). `resolve_vegas_participation()` in `src.plugin_system.base_plugin`
|
||||
is what the core calls; the user's setting wins even over a plugin that
|
||||
overrides the method.
|
||||
- The Vegas stream manager decides inclusion and pauses through it, and
|
||||
`PluginAdapter.get_content_type()` is removed (core-internal, now unused).
|
||||
Swap mode no longer drops a plugin's segment for a cycle when its
|
||||
`get_vegas_display_mode()` raises something other than
|
||||
`AttributeError`/`TypeError`: like every other decision point it now
|
||||
treats that as "not paused".
|
||||
- `vegas_participation` is a core-owned per-plugin property (an enum with no
|
||||
default) and a manifest field in `schema/manifest_schema.json`.
|
||||
- `GET /api/v3/plugins/installed` reports each plugin's
|
||||
`vegas_participation`, and the Vegas plugin-order list badges it (Scroll /
|
||||
Pause / Excluded) instead of the old Scroll / Fixed / Static.
|
||||
- `src.deprecation.warn_deprecated()` warns once per process for what
|
||||
`@deprecated` cannot decorate, such as a config key.
|
||||
|
||||
Deprecated, removed in 3.9.0 (each logs a warning on first use). Vegas never
|
||||
read any of them:
|
||||
|
||||
- `BasePlugin.get_supported_vegas_modes()` and
|
||||
`BasePlugin.get_vegas_segment_width()`.
|
||||
- The `vegas_panel_count` per-plugin setting (warns once per plugin that sets
|
||||
it).
|
||||
- The SCROLL / FIXED_SEGMENT distinction (`vegas_mode` `"scroll"` vs
|
||||
`"fixed"`): both always scrolled. Documented only; no warning, because
|
||||
official plugins' schemas still offer `"fixed"`.
|
||||
|
||||
### Plugin store
|
||||
|
||||
- The store reads three optional registry fields that ledmatrix-plugins'
|
||||
`update_registry.py` now publishes (ChuckBuilds/ledmatrix-plugins#579). An
|
||||
older `plugins.json` without them behaves as before.
|
||||
- `ledmatrix_min_version`: an install or update this core cannot run is
|
||||
refused before anything is downloaded, pulled or moved aside, and the web
|
||||
UI says why ("requires LEDMatrix X or newer…", HTTP 409) instead of "check
|
||||
logs for details". The store card shows a "Needs LEDMatrix X+" badge. The
|
||||
check on the downloaded manifest stays as the fallback (older registries,
|
||||
an explicitly requested other branch, `compatible_versions`).
|
||||
- `aliases`: the entry's other ids. Update, uninstall and reinstall by the
|
||||
registry id now find a plugin installed under its manifest id
|
||||
(`weather` → `ledmatrix-weather/`; likewise leaderboard, music, stocks).
|
||||
Only registry proof counts: the entry's `aliases` or its `plugin_path`
|
||||
name, or a folder whose manifest declares one of those ids. A
|
||||
`ledmatrix-<id>/` folder with no such proof is never replaced or removed;
|
||||
uninstall and update report "not installed" and log the folder's path.
|
||||
Install and update fetch the registry first when such a folder exists
|
||||
and none is loaded; uninstall stays offline.
|
||||
- `commit`: the monorepo commit that introduced the listed version, shown
|
||||
on the store card and linked to the plugin's source at that commit.
|
||||
Informational only; installs still come from the branch head.
|
||||
|
||||
### Changes
|
||||
|
||||
- The web interface no longer loads or runs plugins (web plugin catalog,
|
||||
stage 1). It built its own `PluginManager` and loaded plugins into the web
|
||||
process: store installs and updates loaded or reloaded a web-side copy, and
|
||||
config saves and enable/disable called `on_config_change`, `on_enable` and
|
||||
`on_disable` on it. None of that reached the panel. The web process now
|
||||
reads plugins as files through the new `PluginCatalog`
|
||||
(`src/plugin_system/plugin_catalog.py`); only the display runs them, and
|
||||
config changes reach them through its config watcher, as they already did.
|
||||
- A plugin update, an install of a plugin that is already enabled, or an
|
||||
uninstall that keeps an enabled plugin's config now answers
|
||||
`restart_required: true` and shows the restart banner, because the
|
||||
running display keeps the code it loaded until it restarts. Before, the
|
||||
update looked applied and the panel kept the old version.
|
||||
- The restart banner follows `restart_required` in any response
|
||||
(`POST /api/v3/config/main` sends it) rather than the URL that was
|
||||
called.
|
||||
- `/api/v3/plugins/installed` reports `loaded`, `state` and `error_info`
|
||||
as `null`: the display does not publish them, and the old values
|
||||
described web-side copies. `enabled` follows the display's rule, so a
|
||||
plugin whose config has no `enabled` flag shows as disabled (it never
|
||||
ran). `vegas_mode` is the configured value only.
|
||||
- `vegas_participation` there is the user's setting, else the manifest's
|
||||
declaration, with a new `vegas_participation_source` (`config` or
|
||||
`manifest`). When only the plugin's code decides it (a
|
||||
`get_vegas_participation()` override or the legacy Vegas hooks) it is
|
||||
`null` with source `runtime`: the display derives it, and the web no
|
||||
longer asks a web-side plugin instance.
|
||||
- Starlark routes always use their on-disk path. The one place the web
|
||||
process still imports plugin code -- the Starlark helper modules and an
|
||||
`oauth_flow` action script -- is `_import_plugin_code_in_web_process()`,
|
||||
until a plugin web-entry contract replaces it.
|
||||
- The display publishes its plugin runtime state, and the web interface
|
||||
reads it (web plugin catalog, stage 2). A new snapshot in the shared cache
|
||||
(`plugin_runtime_snapshot`, `src/plugin_system/plugin_runtime.py`) lists,
|
||||
per plugin, whether the display has it loaded, its lifecycle state, a
|
||||
short redacted summary of its last error, the version it loaded and when.
|
||||
It is written when something changes (at most every 10 s; an ordinary
|
||||
plugin update is not a change) and otherwise once a minute, carries its
|
||||
publish time, and says `running: false` when the display stops.
|
||||
- `/api/v3/plugins/installed` fills `loaded`, `state` and `error_info`
|
||||
again, from that snapshot, and adds `loaded_version` and `loaded_at`.
|
||||
Only a live snapshot counts: when the display is stopped, has not
|
||||
published, or has not refreshed for 3 minutes, those fields are `null`
|
||||
and the new `data.runtime.status` says `stopped`, `unknown` or `stale`.
|
||||
- `data/plugin_state.json` is retired: nothing reads or writes it. It held
|
||||
copies of config.json's enabled flags and the manifests' versions, plus
|
||||
install timestamps only `GET /api/v3/plugins/state` returned, so nothing
|
||||
in it is migrated; an existing file is left in place and can be deleted.
|
||||
The web-side `PluginStateManager` (`src/plugin_system/state_manager.py`)
|
||||
that wrote it is removed; the display's state machine in
|
||||
`plugin_state.py` is now the only `PluginStateManager`.
|
||||
- `GET /api/v3/plugins/state` is built per request from config.json, the
|
||||
plugins on disk and the display's snapshot (`installed`, `in_config`,
|
||||
`enabled`, `version`, `status`, the runtime fields, and `installed_at` /
|
||||
`last_updated` from the operation history), with a top-level `runtime`.
|
||||
It no longer returns `config_version` or `metadata`.
|
||||
- State reconciliation compares desired state (config.json plus disk) with
|
||||
the display's snapshot. New findings -- enabled but not loaded (with the
|
||||
load error), and loaded at an older version than is installed -- are
|
||||
reported with `fix_action: no_action`; the unresolved-issues banner is
|
||||
unchanged. `StateReconciliation` takes `config_manager`, `plugins_dir`,
|
||||
`store_manager` and `runtime_source` as keywords.
|
||||
- Backups list the installed plugins from disk, with `enabled` from
|
||||
config.json, instead of merging in `plugin_state.json`. A plugin that
|
||||
only that file still named (not installed, not configured) is no longer
|
||||
listed. Restores are unchanged.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Reinstalling a plugin by its registry id when it is installed under its
|
||||
manifest id (`weather` in `ledmatrix-weather/`) no longer deletes it when
|
||||
the install then fails. The safety copy was taken of `weather/`, which did
|
||||
not exist, and the real install was removed to make room for the download,
|
||||
so a refusal by the compatibility gate left no plugin at all. Uninstalling
|
||||
by the registry id reported success and removed nothing; updating by it
|
||||
said "not installed". All three now find the install.
|
||||
|
||||
- On-demand no longer restarts a running display. `POST
|
||||
/display/on-demand/start` treated `start_service` (on by default, and what
|
||||
"Preview on display", the on-demand dialog and the MQTT bridge all send) as
|
||||
"restart": it stopped the service, waited 1.5s and started it again, so
|
||||
every request reloaded every plugin and left the panel blank for seconds.
|
||||
The running display already reads the request within a quarter of a second,
|
||||
mid-screen and mid-Vegas included, so the route now only starts the service
|
||||
when it is not running. `POST /display/on-demand/stop` reads
|
||||
`stop_service` as a boolean, so `"false"` no longer stops the service.
|
||||
- On-demand works for a disabled plugin. The display only loads enabled
|
||||
plugins, so "Preview on display" on a disabled plugin's config page (which
|
||||
says the plugin will be enabled for the preview) failed with
|
||||
`invalid-mode`. The display now loads the plugin live for the session,
|
||||
without writing `enabled` to `config.json`, and unloads it when on-demand
|
||||
is stopped, expires or moves to another plugin. A plugin that fails to
|
||||
load reports on-demand status `error` with `load-failed`. A session
|
||||
restored after a restart unloads its disabled plugin the same way; it used
|
||||
to stay loaded until the next restart.
|
||||
- A stop request now clears an on-demand error. After a failed request,
|
||||
`/display/on-demand/status` kept reporting `status: error` for up to two
|
||||
minutes even after a stop.
|
||||
- One hung plugin no longer stops every plugin from updating. The single
|
||||
update worker waited on each plugin's lock with no time limit, and the
|
||||
render thread holds that lock while it runs the plugin's display(); a
|
||||
display() that never returned (or a first frame still running after the
|
||||
executor's 30s timeout) parked the worker for good, so scores, weather and
|
||||
clocks all froze while the panel kept scrolling. The worker now waits at
|
||||
most 5s (the bound `unload_plugin()` already uses) and skips that update;
|
||||
the other plugins keep updating. The skip is logged (at most once a minute
|
||||
per plugin) and counted in plugin health as a busy skip (`busy_skip_count`,
|
||||
`last_busy_skip`), but it is not a failure and never opens the circuit
|
||||
breaker: Vegas mode holds a plugin's lock for its whole content render,
|
||||
which on a slow Pi can outlast 5s, and a healthy plugin must not be pulled
|
||||
from rotation for that.
|
||||
- display() calls are timed on every frame. One taking 2s or more is logged
|
||||
(at most once a minute per plugin) and counted in plugin health
|
||||
(`slow_call_count`, `last_slow_call`); one that runs past the executor's
|
||||
timeout counts as a hang (`hang_count`, `last_hang`) and as a failure to
|
||||
the circuit breaker. A first frame that times out is no longer recorded as
|
||||
a success, and an update() still running after its timeout is recorded as
|
||||
a hang instead of leaving the plugin silently stuck. Only these real hangs
|
||||
count toward the breaker.
|
||||
- A plugin's `on_config_change()` no longer runs while its update() is
|
||||
running on the worker thread. It now runs under the plugin's lock; if the
|
||||
lock stays busy past the same 5s bound the change is handed to the update
|
||||
worker, which applies the latest one as soon as the lock frees, and before
|
||||
the plugin's next update() at the latest. The plugin API is unchanged.
|
||||
- With a Vegas width budget set (`max_plugin_width_ratio` or a plugin's
|
||||
`vegas_max_width_screens`), a single image over the budget with no gaps
|
||||
between items -- a map, one long headline -- no longer takes a pass of its
|
||||
own showing four blank columns. The cut landed in the middle of the blank
|
||||
margin trimming leaves at the image's edge; margins are no longer cut
|
||||
points, so such an image is cropped to the budget as intended.
|
||||
|
||||
### Live Vegas elements (plugin API)
|
||||
|
||||
- New plugin hooks for content that can change while it scrolls:
|
||||
`BasePlugin.get_vegas_elements()` returns `VegasElement`s -- named,
|
||||
fixed-width pieces of Vegas content -- instead of pictures;
|
||||
`redraw_vegas_element(key, width, height, at)` redraws one without the
|
||||
plugin lock for content that changes with time; and
|
||||
`notify_vegas_data_changed()` reports data that arrived outside
|
||||
`update()`. New module `src/plugin_system/vegas_elements.py`
|
||||
(`VegasElement`, also re-exported from `base_plugin`). See "Live Vegas
|
||||
elements" in `docs/PLUGIN_API_REFERENCE.md`.
|
||||
- The ticker asks a plugin that implements the hook for elements on its
|
||||
background fetch (under the plugin's lock, on a canvas of its own) and
|
||||
records where each one lands in the strip, in absolute columns a trim does
|
||||
not move (`src/vegas_mode/elements.py`). Live elements are never trimmed to
|
||||
their ink: each is padded with `content_padding` black columns either side.
|
||||
Every other path -- the first strip, the render-thread fallback, plugins
|
||||
without the hook -- is unchanged. Swapping redraws into the strip builds
|
||||
on this.
|
||||
- `PluginManager.add_update_listener()` / `remove_update_listener()` /
|
||||
`notify_data_changed()`: a listener hears a plugin id the moment its
|
||||
`update()` completes, rather than at the next ~4s Vegas poll.
|
||||
- New `display.vegas_scroll` settings: `live_refresh` (default `true`; the
|
||||
kill switch), `live_max_hz`, `live_min_interval`, `live_lead_screens`, and
|
||||
a per-plugin core-owned `vegas_live`. Live elements are off whatever these
|
||||
say under multi-display sync, in swap mode and with `offscreen_prefetch`
|
||||
off.
|
||||
- `scripts/check_plugin.py` checks the element contract for any plugin that
|
||||
implements it (`src/plugin_system/testing/vegas.py`), and
|
||||
`test/fixtures/plugins/vegas-live-stub` is a working example.
|
||||
- **Live elements update in place.** When a plugin's `update()` completes,
|
||||
one background worker (`src/vegas_mode/live_worker.py`) redraws its live
|
||||
elements that are on or ahead of the screen, nearest first, and hands the
|
||||
ones whose pixels changed to the render thread, which copies them into the
|
||||
strip between two frames (`RenderPipeline.apply_live_patches`,
|
||||
`ScrollHelper.patch_columns`): at most four patches or two screens of bytes
|
||||
a frame, no drawing and no locks on the render thread. Elements with
|
||||
`refresh_hz` are redrawn that often while near the screen, through the
|
||||
plugin's lock-free `redraw_vegas_element()`. The worker also takes over
|
||||
group prefetching once the strip holds a live element, so one thread
|
||||
still does all the drawing; it runs inside the render gate, starts only
|
||||
when a live element is placed, and is restarted if it dies (three times in
|
||||
ten minutes turns live updates off for the run). While live elements exist,
|
||||
the Vegas update tick runs every second instead of every four.
|
||||
- Web UI: "Update live content while it scrolls" under Vegas mode's Cycle
|
||||
Pacing (`display.vegas_scroll.live_refresh`).
|
||||
- **Live cards for the scoreboards (shared code).** New module
|
||||
`src/common/sports_vegas.py`: `game_key()`, `dedupe_games()`,
|
||||
`VegasCardCache` (draws a card only when its fingerprint changes) and
|
||||
`StickyOdds` (keeps a card's odds through a live poll that left them out),
|
||||
`finished_games()` and `with_finished_games()` (a game that just went final
|
||||
keeps its card, showing FINAL, where its live card was).
|
||||
`SportsScrollDisplay` gains `make_vegas_renderer()` (the override point; a
|
||||
sport that does not implement it keeps its ordinary Vegas content),
|
||||
`render_vegas_card()`, `vegas_separator()` and `build_vegas_elements()`,
|
||||
and `SportsScrollDisplayManager` gains `get_vegas_elements_for()`.
|
||||
`SportsLiveSharedMixin` gains `_record_finished_game()` /
|
||||
`finished_games_snapshot()`, so a game that goes final keeps a card to show
|
||||
FINAL on until the hourly recent list takes it over.
|
||||
- `scripts/render_plugin.py --vegas` renders a plugin's block of the Vegas
|
||||
strip as the ticker lays it out (live elements, or with `--no-live` its
|
||||
ordinary content) and writes the live elements' keys and columns beside
|
||||
it. `--timeline ROWS` stacks the block at successive moments as the
|
||||
ticker would update it in place (`--timeline-step`, and
|
||||
`--timeline-update` to run `update()` between rows).
|
||||
`render_vegas_strip()` and `render_vegas_timeline()` in
|
||||
`src/plugin_system/testing/vegas.py`; the join is now
|
||||
`render_pipeline.join_plugin_rows()`.
|
||||
- **Behaviour change: live games stay in the Vegas ticker by default.**
|
||||
`display.vegas_scroll.live_in_ticker` now defaults to `true`: the marquee
|
||||
keeps running through a live game, which takes extra turns in it, instead
|
||||
of giving way to the full-screen scoreboard. Existing configs all held the
|
||||
old `false`, copied from the template, so the first start turns it on once
|
||||
(`ConfigManager._migrate_live_in_ticker_default`; the previous config is
|
||||
kept as `config.json.backup` and `live_in_ticker_migrated` records that it
|
||||
ran). To keep the full-screen scoreboard, untick the new **Keep live games
|
||||
in the ticker** under Vegas mode; a `false` set after the migration stays.
|
||||
|
||||
### Scrolling
|
||||
|
||||
- A Vegas strip extension no longer costs a late frame. Appending the next
|
||||
group rebuilt the whole strip (`np.concatenate`, 2-2.6ms for a 10-14k px
|
||||
strip at 512x64 on a Pi 4) and trimming copied what was left (1.2-1.8ms),
|
||||
so on hdpi every extension frame missed its refresh. The strip now lives in
|
||||
a buffer with spare room (`ScrollHelper.STRIP_SPARE_FACTOR`): an append
|
||||
writes only the new columns (~0.2ms), a trim only moves the start, and the
|
||||
one full copy happens when the buffer is reallocated, about once every two
|
||||
strip-lengths scrolled. A strip set from outside (the multi-display
|
||||
follower's) is never written through.
|
||||
- A Vegas strip extension costs the render thread about a third of what it
|
||||
did. Appending the next group and trimming what has scrolled past each
|
||||
rebuilt the strip's PIL image from its numpy array in full
|
||||
(`Image.fromarray`: 1.7ms for an 8,000px strip, 3.8ms for 20,000px, on a
|
||||
Pi 4 -- twice per extension), though every frame is cut from the array and
|
||||
nothing on the frame path reads the image's pixels. `ScrollHelper` now
|
||||
builds `cached_image` only when something reads it, which in Vegas means
|
||||
only a multi-display sync push, and the strip is no longer held in memory
|
||||
twice. Assigning `cached_image` still stores exactly what was assigned.
|
||||
New `ScrollHelper.has_strip()` says whether there is a strip without
|
||||
building its image; the frame path and Vegas use it.
|
||||
|
||||
### Tooling
|
||||
|
||||
- The frame-timing recorder says which render-thread work a late frame
|
||||
followed. Work done between two frames calls
|
||||
`FrameTimingRecorder.note_op(kind, nbytes)` and the next presented frame
|
||||
carries the tag; the stats gain `op_frames`, `late_op_frames`, `op_freezes`
|
||||
and `op_bytes` per kind (additive; the file's schema version is unchanged).
|
||||
Vegas tags every strip `compose` and `extend`, and `frame_soak.py` prints an
|
||||
"after work" table with each kind's own late rate.
|
||||
`scripts/render_bench.py` can drive the same work on a panel with nothing
|
||||
else running: `--strip-screens` for a Vegas-sized strip, `--patch-bytes /
|
||||
--patch-every / --patch-where` for in-place column writes, and
|
||||
`--extend-every-screens` for appending and trimming on a fixed cadence.
|
||||
See "Soaking a rig" in `docs/SCROLL_PERFORMANCE.md`.
|
||||
- `scripts/sports_drift_report.py`: for a ledmatrix-plugins checkout, counts
|
||||
how many different bodies each method family has across the nine
|
||||
scoreboards' `sports.py`, `manager.py` and `game_renderer.py`, lists the
|
||||
families still identical everywhere and those with one outlier, and with
|
||||
`--family ... --diff` shows the variants. It is the progress measure for
|
||||
the reconcile-then-promote roadmap in `docs/SPORTS_UNIFICATION.md`, which
|
||||
this release rewrites. CI runs it against the monorepo's main as a
|
||||
report-only job ("Sports drift report"; never fails the build).
|
||||
|
||||
### Deprecations
|
||||
|
||||
- The 35 plugin-facing methods deprecated in 3.5.0 are now removed in 3.8.0,
|
||||
not 3.7.0: 3.7.0 shipped with all of them still in place, still warning
|
||||
"will be removed in LEDMatrix 3.7.0". The warning, the docs and
|
||||
`test/test_deprecation.py` now say 3.8.0. Nothing is removed yet.
|
||||
- New `scripts/plugin_api_usage.py` lists every `@deprecated` core method and
|
||||
scans core, the plugin monorepo and the registry's third-party plugins for
|
||||
calls and overrides, telling real uses from unrelated methods of the same
|
||||
name. Its output is `docs/DEPRECATIONS_3.8.md` (linked from
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis`): 34 of the 35 are unused;
|
||||
`CacheManager.get_memory_cache_stats` is still called by core's own
|
||||
`log_memory_cache_stats()`, so it stays until that call migrates.
|
||||
- `test/test_deprecation.py` fails while any `@deprecated` marker names a
|
||||
release at or below `src.__version__`, so a release can no longer ship
|
||||
warning about a removal it has already passed.
|
||||
|
||||
### Web UI styling: a real Tailwind build
|
||||
|
||||
- The web UI's utility classes now come from a generated
|
||||
`static/v3/tailwind.css` (Tailwind v3.4.19 standalone CLI, no Node)
|
||||
instead of ~500 hand-written rules in `app.css`. The CSS is built on a
|
||||
dev machine with `python3 scripts/build_css.py` and committed; the Pi
|
||||
never builds anything. CI's new "Tailwind CSS is up to date" job rebuilds
|
||||
it and fails when the committed file is stale. `app.css` keeps the theme
|
||||
tokens, components and dark theme, and loads after `tailwind.css`. The
|
||||
values `app.css` had customised (darker gray text, emerald/amber button
|
||||
fills, token shadows, font line-heights, keyboard-only focus rings) are
|
||||
kept in `web_interface/tailwind/tailwind.config.js`.
|
||||
- Border utilities now draw. `border-b`, `border-t` and `divide-y` set only
|
||||
a width, and nothing gave them a style, so the tab-row underlines and
|
||||
section dividers the markup asks for never showed. They do now.
|
||||
- `2xl:` classes now apply (the hand-written `.2xl\:…` selectors were
|
||||
invalid CSS): at 1536px and wider the plugin grids show five columns and
|
||||
the page gutters widen, as the markup intended.
|
||||
- Classes the hand-written file never defined now work, e.g. the teal
|
||||
"configure" badge in Operation History, the button of a purple
|
||||
`web_ui_actions` card (it had white text on no background), the
|
||||
toggle-switch knob offsets, the slider accent colours and the password
|
||||
strength colours.
|
||||
- A scrollable container with its own background (the live preview stage,
|
||||
command output in Tools) keeps it. The scroll-hint rule's `background`
|
||||
shorthand wiped it, so the preview stage rendered white instead of dark.
|
||||
- Plugin `web_ui/` pages no longer load Tailwind from a CDN, which failed
|
||||
in AP mode with no internet. They get a local `static/v3/plugin-frame.css`
|
||||
with the v2 palette they were written against.
|
||||
|
||||
## 3.7.0
|
||||
|
||||
Sports consolidation stage 3 (#672). No behaviour change: nothing in core
|
||||
@@ -166,7 +656,8 @@ New names in existing modules (a plugin using these must floor on 3.5.0):
|
||||
- `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`, and
|
||||
`FontManager.forget_manager_fonts()` is new (see Fonts).
|
||||
|
||||
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
|
||||
Deprecated for removal in 3.7.0, later moved to 3.8.0 (each logs a warning
|
||||
on first use; see
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
||||
core, the monorepo or the registry's third-party plugins calls them:
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
- Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
|
||||
- Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction
|
||||
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
|
||||
- Optional registry entry fields (`store_registry.py`): `ledmatrix_min_version` refuses an incompatible install/update before the download (the post-download manifest gate stays as the fallback); `aliases` are the entry's other ids (manifest id `ledmatrix-weather` for `weather`), used with the `plugin_path` name by update/uninstall/reinstall to find the install — only this registry proof counts, never a bare `ledmatrix-<id>` folder (owner decision, #686; such a folder is only logged); `commit` is informational. An older plugins.json has none of them
|
||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||
|
||||
|
||||
+5
-1
@@ -71,7 +71,11 @@ integration tests.
|
||||
annotation-only where you can -- widen a hint rather than delete a
|
||||
defensive runtime check mypy calls unreachable. HTML/JS in
|
||||
`web_interface/` follows the patterns already in `templates/v3/`
|
||||
and `static/v3/`.
|
||||
and `static/v3/`. If you change a template or a static JS file,
|
||||
run `python3 scripts/build_css.py` and commit the regenerated
|
||||
`static/v3/tailwind.css` with it -- CI fails when the committed CSS
|
||||
is out of date. It needs no Node; see
|
||||
[`web_interface/README.md`](web_interface/README.md#styling-tailwind-css).
|
||||
5. **Update documentation** alongside code changes. If you add a
|
||||
config key, document it in the relevant `*.md` file (or, for
|
||||
plugins, in `config_schema.json` so the form is auto-generated).
|
||||
|
||||
+25
-2
@@ -61,8 +61,31 @@ Out of scope (please report upstream):
|
||||
LEDMatrix is designed for trusted local networks. Several limitations
|
||||
are intentional rather than vulnerabilities:
|
||||
|
||||
- **No web UI authentication.** The web interface assumes the network
|
||||
it's running on is trusted. Don't expose port 5000 to the internet.
|
||||
- **Web UI authentication is optional and off by default.** Out of the
|
||||
box the web interface assumes the network it's running on is trusted.
|
||||
Setting a password under **General > Security** makes every page and
|
||||
API route require a login or an API token (`Authorization: Bearer`),
|
||||
with wrong passwords rate-limited per address
|
||||
(`web_interface/auth.py`). Deliberately left open even then: requests
|
||||
from the Pi itself (loopback without proxy headers; a reverse proxy on
|
||||
the Pi must add `X-Forwarded-For`, or every request it relays counts as
|
||||
local), the Wi-Fi setup flow while the Pi is in access-point mode,
|
||||
static files, and a status-only `/api/v3/health`. The password is a
|
||||
werkzeug hash and tokens are stored as SHA-256, in
|
||||
`config/config_secrets.json`, which no API returns. There is no TLS:
|
||||
over plain HTTP the password and tokens cross the LAN in the clear, so
|
||||
still don't expose port 5000 to the internet; put a TLS reverse proxy
|
||||
or a VPN in front for remote access. Anyone with shell access to the Pi
|
||||
can turn login off (`scripts/reset_web_password.py`), which is the
|
||||
documented recovery path.
|
||||
"Trusted network" does not mean "trusted websites", though: any page
|
||||
a LAN user opens could make their browser POST to the Pi. So the
|
||||
interface refuses a `POST`/`PUT`/`PATCH`/`DELETE` whose `Origin` (or
|
||||
`Referer`) header names another site (`web_interface/origin_guard.py`),
|
||||
and `/api/v3/system/action` only accepts JSON or HTMX requests. Tools
|
||||
that send neither header (curl, Home Assistant, the MQTT bridge) are
|
||||
unaffected. Not covered: DNS rebinding, and anyone who can reach the
|
||||
port directly.
|
||||
- **Plugins run unsandboxed.** Installed plugins execute in the same
|
||||
Python process as the display loop with full file-system and
|
||||
network access. Review plugin code (especially third-party plugins
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"auto_update": {
|
||||
"enabled": false
|
||||
"enabled": false,
|
||||
"channel": "stable"
|
||||
},
|
||||
"schedule": {
|
||||
"enabled": false,
|
||||
@@ -133,7 +134,7 @@
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false,
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5,
|
||||
"enabled": false,
|
||||
|
||||
+91
-75
@@ -10,22 +10,27 @@ This guide covers advanced LEDMatrix features for users and developers, includin
|
||||
|
||||
Vegas scroll mode displays content from multiple plugins in a continuous horizontal scroll, similar to news tickers seen in Las Vegas casinos. Plugins contribute content segments that flow across the display in a seamless ticker-style presentation.
|
||||
|
||||
### Display Modes
|
||||
### How a Plugin Takes Part
|
||||
|
||||
**SCROLL (Continuous Scrolling):**
|
||||
- Content scrolls continuously left
|
||||
- Smooth, fluid motion
|
||||
- Best for news-ticker style displays
|
||||
Each plugin has a *Vegas participation*:
|
||||
|
||||
**FIXED_SEGMENT (Fixed-Width Block):**
|
||||
- Plugin gets fixed-width block on display
|
||||
- Content doesn't scroll out of its segment
|
||||
- Multiple plugins can share the display simultaneously
|
||||
**`scroll` (the default):**
|
||||
- The plugin's content scrolls by with everyone else's
|
||||
- Best for news-ticker style content: scores, headlines, prices, the time
|
||||
|
||||
**STATIC (Scroll Pauses):**
|
||||
- Scrolling pauses when content is fully visible
|
||||
- Displays for specified duration, then resumes scrolling
|
||||
- Best for content that needs to be fully read
|
||||
**`pause`:**
|
||||
- The scroll stops when the plugin's turn comes round
|
||||
- The plugin draws the whole panel for its display duration, then the
|
||||
scroll resumes
|
||||
- Best for content that needs to be read in full, or alerts
|
||||
|
||||
**`exclude`:**
|
||||
- The plugin is left out of Vegas mode
|
||||
|
||||
A plugin declares its default; set `vegas_participation` in a plugin's
|
||||
config to override it (see [Per-Plugin Configuration](#per-plugin-configuration)).
|
||||
Older documentation also describes a *fixed segment* mode; Vegas never
|
||||
implemented one, and it has always behaved exactly like `scroll`.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -70,17 +75,31 @@ total. See the full list in
|
||||
|
||||
### Live Content in the Ticker
|
||||
|
||||
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||
live priority, the display controller refuses to run the ticker and shows
|
||||
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||
but the marquee stops entirely for the duration of the game.
|
||||
By default (since 3.8.0) live content **stays in the ticker** and takes
|
||||
**extra turns inside it**, and a scoreboard that supports live cards updates
|
||||
the score on a card already crossing the screen (`live_refresh`, "Update live
|
||||
content while it scrolls").
|
||||
|
||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||
**extra turns inside it** instead:
|
||||
To get the old behaviour back -- live content **preempts** Vegas mode: while
|
||||
any plugin reports live priority the ticker stops and that plugin's
|
||||
full-screen display is shown instead -- untick **Keep live games in the
|
||||
ticker** under Vegas mode, or set `live_in_ticker` to `false`:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false
|
||||
}
|
||||
```
|
||||
|
||||
Until 3.8.0 `false` was the default and every config held it, copied from
|
||||
the template. The first start on 3.8.0 turns it on once (a backup of the
|
||||
config is kept as `config.json.backup`, and `live_in_ticker_migrated` records
|
||||
that it ran); a `false` set after that is left alone.
|
||||
|
||||
The weights below apply while live content is in the ticker:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5
|
||||
}
|
||||
@@ -164,8 +183,7 @@ Override Vegas behavior for specific plugins:
|
||||
{
|
||||
"my_plugin": {
|
||||
"enabled": true,
|
||||
"vegas_mode": "scroll",
|
||||
"vegas_panel_count": 2,
|
||||
"vegas_participation": "pause",
|
||||
"display_duration": 10
|
||||
}
|
||||
}
|
||||
@@ -175,19 +193,30 @@ Override Vegas behavior for specific plugins:
|
||||
|
||||
| Setting | Values | Description |
|
||||
|---------|--------|-------------|
|
||||
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
|
||||
| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) |
|
||||
| `display_duration` | seconds | Pause duration for STATIC mode |
|
||||
| `vegas_participation` | `scroll`, `pause`, `exclude` | How this plugin takes part: its content scrolls by, the scroll pauses for its turn and shows it full screen, or it is left out. Unset uses the plugin's own default |
|
||||
| `display_duration` | seconds | How long a `pause` plugin holds the screen |
|
||||
| `vegas_width_pct` | 10–100 | Width of this plugin's card, as a percentage of the panel |
|
||||
| `vegas_overflow` | `rotate`, `truncate` | What to do when its content is wider than its allowance |
|
||||
| `vegas_max_width_screens` | number of screens | The widest its card may be |
|
||||
|
||||
Plugins may also set `vegas_overflow` and `vegas_max_width_screens` in
|
||||
their config section to control how oversized content is handled (see
|
||||
`PluginManager` in `src/plugin_system/plugin_manager.py`).
|
||||
These are core-owned settings (see
|
||||
[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)): every
|
||||
plugin accepts them whether or not its own schema lists them. Set them in
|
||||
the plugin's section of config.json, in the web UI's **Config Editor**
|
||||
tab.
|
||||
|
||||
Some plugins also offer a `vegas_mode` setting of their own (`scroll`,
|
||||
`fixed` or `static`). It still works — `static` pauses, the other two scroll
|
||||
— but `vegas_participation` takes precedence, and `fixed` has never done
|
||||
anything different from `scroll`. The old `vegas_panel_count` setting never
|
||||
had an effect and is deprecated (removed in 3.9.0).
|
||||
|
||||
### Plugin Integration (Developer Guide)
|
||||
|
||||
All of these have defaults in
|
||||
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you
|
||||
need.
|
||||
need. The reference is
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-scroll-hooks).
|
||||
|
||||
**1. Implement Content Method:**
|
||||
|
||||
@@ -203,43 +232,41 @@ If it returns `None` (the default), Vegas falls back to the plugin's
|
||||
(`PluginAdapter.get_content()` in
|
||||
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
|
||||
|
||||
**2. Specify Content Type:**
|
||||
**2. Declare how the plugin takes part:**
|
||||
|
||||
```python
|
||||
def get_vegas_content_type(self):
|
||||
# 'multi' | 'static' | 'none' -- default is 'static'
|
||||
return 'multi'
|
||||
Most plugins need nothing: the default is `scroll`. A plugin that should
|
||||
pause the scroll, or stay out of Vegas, says so in `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"vegas_participation": "pause"
|
||||
}
|
||||
```
|
||||
|
||||
`'none'` excludes the plugin from Vegas mode.
|
||||
|
||||
**3. Optionally Specify Display Mode:**
|
||||
|
||||
These return `VegasDisplayMode` members, not strings:
|
||||
The user's own `vegas_participation` setting overrides the manifest. When
|
||||
the answer depends on state, override the method instead:
|
||||
|
||||
```python
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
|
||||
def get_vegas_display_mode(self):
|
||||
return VegasDisplayMode.SCROLL
|
||||
|
||||
def get_supported_vegas_modes(self):
|
||||
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
|
||||
def get_vegas_participation(self):
|
||||
# 'scroll' | 'pause' | 'exclude'
|
||||
return 'pause' if self._alert_is_live() else 'scroll'
|
||||
```
|
||||
|
||||
`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and
|
||||
`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the
|
||||
plugin's `vegas_mode` config value if set, otherwise maps the content type
|
||||
(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`).
|
||||
A plugin written for an older core that declares nothing keeps its
|
||||
behaviour: `get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`
|
||||
pauses, `get_vegas_content_type()` returning `'none'` excludes, and
|
||||
everything else scrolls. `get_supported_vegas_modes()`,
|
||||
`get_vegas_segment_width()` and the SCROLL / FIXED_SEGMENT distinction are
|
||||
deprecated (removed in 3.9.0): Vegas never read them.
|
||||
|
||||
### Content Rendering Guidelines
|
||||
|
||||
**Image Dimensions:**
|
||||
- **Height:** Must match display height (typically 32 pixels)
|
||||
- **Width:** Varies by mode:
|
||||
- SCROLL: Any width (recommended 64-512 pixels)
|
||||
- FIXED_SEGMENT: `panel_count * display_width`
|
||||
- STATIC: Any width, optimized for readability
|
||||
- **Width:** Any width for `scroll` (recommended 64-512 pixels);
|
||||
`get_vegas_render_width()` is the width Vegas would like, and it narrows
|
||||
`display_manager` to match while it asks. A `pause` plugin draws the
|
||||
whole panel in `display()`.
|
||||
|
||||
**Color Mode:**
|
||||
- Use RGB color mode
|
||||
@@ -289,17 +316,10 @@ class WeatherPlugin(BasePlugin):
|
||||
def get_vegas_content(self):
|
||||
"""Return cached Vegas image"""
|
||||
return self.vegas_image
|
||||
|
||||
def get_vegas_content_type(self):
|
||||
return 'multi'
|
||||
|
||||
def get_vegas_display_mode(self):
|
||||
return 'scroll'
|
||||
|
||||
def get_supported_vegas_modes(self):
|
||||
return ['scroll', 'static']
|
||||
```
|
||||
|
||||
It scrolls, the default participation, so it declares nothing else.
|
||||
|
||||
### System Architecture
|
||||
|
||||
Vegas mode consists of four core components working together to provide smooth 125 FPS continuous scrolling:
|
||||
@@ -382,7 +402,8 @@ Vegas mode consists of four core components working together to provide smooth 1
|
||||
|
||||
**Responsibilities:**
|
||||
- Convert plugin content to scrollable images
|
||||
- Handle different Vegas display modes (SCROLL, FIXED, STATIC)
|
||||
- Fetch the content of `scroll` plugins (a `pause` plugin is drawn by
|
||||
its own `display()` when the scroll pauses; see StreamManager)
|
||||
- Manage fallback for plugins without Vegas support
|
||||
- Cache plugin content for performance
|
||||
|
||||
@@ -391,21 +412,16 @@ Vegas mode consists of four core components working together to provide smooth 1
|
||||
- Calls `get_vegas_content()` if available
|
||||
- Falls back to `display()` method if not
|
||||
|
||||
2. **Handle display mode:**
|
||||
- SCROLL: Returns image as-is for continuous scrolling
|
||||
- FIXED_SEGMENT: Creates fixed-width block (panel_count * display_width)
|
||||
- STATIC: Marks content for pause-when-visible behavior
|
||||
|
||||
3. **Content type handling:**
|
||||
- `multi`: Multiple segments (list of images)
|
||||
- `static`: Single static image
|
||||
- `none`: Skip this plugin in current cycle
|
||||
2. **Participation** is decided by the StreamManager, not here
|
||||
(`resolve_vegas_participation()` in
|
||||
[`base_plugin.py`](../src/plugin_system/base_plugin.py)): `exclude`
|
||||
plugins never reach the adapter, and `pause` plugins are not fetched.
|
||||
|
||||
**Fallback Behavior:**
|
||||
- If plugin doesn't implement Vegas methods:
|
||||
- Calls plugin's `display()` method
|
||||
- Captures rendered display as static image
|
||||
- Treats as fixed segment
|
||||
- Scrolls it by as one block
|
||||
- Ensures all plugins work in Vegas mode without explicit support
|
||||
|
||||
#### 4. RenderPipeline
|
||||
@@ -508,7 +524,7 @@ All components use thread-safe patterns:
|
||||
If a plugin doesn't implement Vegas methods:
|
||||
- System calls the plugin's `display()` method
|
||||
- Captures the rendered display as a static image
|
||||
- Treats it as a fixed segment
|
||||
- Scrolls it by as one block
|
||||
|
||||
This ensures all plugins work in Vegas mode, even without explicit support.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
||||
|
||||
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
|
||||
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
|
||||
are deprecated, removed in 3.7.0. Draw your own icons instead: render them
|
||||
are deprecated, removed in 3.8.0. Draw your own icons instead: render them
|
||||
onto a PIL image and paste it onto `self.display_manager.image`, or ship
|
||||
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
|
||||
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
@@ -194,7 +194,7 @@ def update(self):
|
||||
sport_key = "nhl"
|
||||
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
||||
|
||||
# get_background_cached_data() is deprecated, removed in 3.7.0 — use get()
|
||||
# get_background_cached_data() is deprecated, removed in 3.8.0 — use get()
|
||||
cached = self.cache_manager.get(cache_key, max_age=60)
|
||||
|
||||
if cached:
|
||||
@@ -596,7 +596,7 @@ def update(self):
|
||||
|
||||
```python
|
||||
def update(self):
|
||||
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check the
|
||||
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check the
|
||||
# instance's `enabled` flag instead
|
||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||
if weather_plugin is not None and weather_plugin.enabled:
|
||||
|
||||
+183
-11
@@ -48,12 +48,123 @@ each other. They share three things:
|
||||
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||
| Plugin runtime (loaded, state, last error, version) | cache `plugin_runtime_snapshot` | display: `PluginRuntimePublisher` ([`src/plugin_system/plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)) | web: `read_plugin_runtime()` for `/api/v3/plugins/installed`, `/plugins/state`, reconciliation |
|
||||
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
|
||||
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
|
||||
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
|
||||
| Render-loop heartbeat | `/run/ledmatrix/display-heartbeat.json` (tmpfs) | display: the render thread, via [`display_watchdog`](../src/display_watchdog.py) | web: `/api/v3/health` (`checks.display_loop`); the update health check |
|
||||
|
||||
The on-demand start route also restarts `ledmatrix.service` by default so the
|
||||
request takes effect straight away.
|
||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||
(`start_service`, on by default) but never restarts a running one: the display
|
||||
reads the mailbox every `ON_DEMAND_POLL_INTERVAL` (0.25s), from its dwell
|
||||
sleep, its render loops and Vegas's interrupt check as well as the main loop.
|
||||
|
||||
### Web and display processes: who runs plugins
|
||||
|
||||
Only the display process imports plugin code, instantiates plugins and calls
|
||||
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
|
||||
`on_disable`). The web process is metadata-only: it reads plugins as files
|
||||
through `PluginCatalog`
|
||||
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py))
|
||||
-- manifests, config schemas (through `SchemaManager`), each plugin's
|
||||
section of `config.json`, and installed versions. The catalog keeps the
|
||||
read-only method names of `PluginManager` and has nothing that can run a
|
||||
plugin (no `load_plugin`, `get_plugin` or `plugins`).
|
||||
|
||||
How a web-side change reaches the running plugins:
|
||||
|
||||
| Change | How the display picks it up |
|
||||
|---|---|
|
||||
| Plugin settings saved, config reset | `ConfigService` sees the new `config.json` and calls the plugin's `on_config_change` with the prepared section |
|
||||
| Plugin enabled or disabled | `ConfigService` → `_controller_config_change` flags a reconcile; `_reconcile_enabled_plugins` loads it (fresh from disk) or unloads it on the render thread |
|
||||
| Plugin uninstalled (config removed) | the removed section flips its `enabled` flag, and the reconcile unloads it |
|
||||
| Plugin installed, not enabled | nothing to do until it is enabled, which loads it |
|
||||
| Plugin installed while already enabled, updated while enabled, or uninstalled with its config kept | **not picked up**: the display keeps running what it loaded. The route answers `restart_required: true` and the UI shows its restart banner |
|
||||
|
||||
`display_restart_required()` in `plugin_catalog.py` holds that last rule;
|
||||
routes return it as `restart_required` (with the banner's wording in
|
||||
`restart_message`), and `window.noteRestartRequired()` in
|
||||
`static/v3/app.js` raises the banner for any response that carries it,
|
||||
`POST /api/v3/config/main` included.
|
||||
|
||||
Runtime state shown in the UI comes from what the display publishes to the
|
||||
shared cache: health and metrics (`/api/v3/plugins/health`,
|
||||
`/plugins/metrics`), errors (`/api/v3/errors/*`), the current mode, and the
|
||||
plugin runtime snapshot described below. `enabled` is read from
|
||||
`config.json` by the display's rule (a missing flag is disabled).
|
||||
|
||||
Plugin code still runs in the web process in one place,
|
||||
`_import_plugin_code_in_web_process()` in
|
||||
[`api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py): the
|
||||
Starlark routes import the starlark-apps plugin's `tronbyte_repository` and
|
||||
`pixlet_renderer` helper modules (never the plugin class), and a web-UI
|
||||
action with `oauth_flow` imports its script for `get_auth_url()`. Every
|
||||
other web-UI action runs its script as a subprocess. A later, explicit
|
||||
**plugin web-entry contract** -- a declared entry point for plugin web code
|
||||
-- replaces that function.
|
||||
|
||||
Next stages: a **control socket** from the web process to the display
|
||||
(reload one plugin, ask for its state) in place of `restart_required` and
|
||||
the cache-key mailboxes, and the plugin web-entry contract above.
|
||||
|
||||
### Plugin state: desired, observed, and who owns it
|
||||
|
||||
There is one plugin state machine, and the display owns it:
|
||||
`PluginStateManager` in
|
||||
[`plugin_state.py`](../src/plugin_system/plugin_state.py) (unloaded →
|
||||
loaded → enabled ⇄ running, error, disabled), held by the display's
|
||||
`PluginManager`. It also records, per loaded plugin, the manifest version it
|
||||
loaded and when. Nothing else keeps plugin state:
|
||||
|
||||
| Question | Answered by |
|
||||
|---|---|
|
||||
| Is it installed, at which version? | the plugins directory (`manifest.json`) |
|
||||
| Should it run? | `config.json` (`<id>.enabled`, missing = disabled) |
|
||||
| Has the user uninstalled it for good? | the store's uninstalled-plugins record |
|
||||
| Is the display running it, at which version, and why not? | the display's runtime snapshot |
|
||||
|
||||
**The runtime snapshot.** `PluginRuntimePublisher`
|
||||
([`plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)), started by
|
||||
`DisplayController` right after it creates the `PluginManager`, writes the
|
||||
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
||||
(type, a redacted message of at most 200 characters, when, recoverable),
|
||||
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
|
||||
The cache is on disk, usually the SD card, so it writes when something a
|
||||
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
||||
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
||||
published as ENABLED, so plugin updates alone never cause a write.
|
||||
`cleanup()` publishes `running: false`.
|
||||
|
||||
**Reading it.** `read_plugin_runtime()` judges the snapshot before anyone
|
||||
uses it: `live` (fresh, from a running display), `stale` (older than
|
||||
`stale_after`, 3 minutes: a hung or crashed display), `stopped` or
|
||||
`unknown` (none, unreadable, or another schema). Only a live view reports
|
||||
per-plugin facts; every other status answers `null` for them, so stale
|
||||
truth cannot leak into a response. `/api/v3/plugins/installed` returns
|
||||
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
||||
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
||||
`/api/v3/plugins/state` returns the same beside the desired state.
|
||||
|
||||
**Reconciliation**
|
||||
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
||||
compares desired state (config + disk) with observed state (the snapshot).
|
||||
It fixes desired-state gaps -- a plugin on disk with no config section gets
|
||||
`{"enabled": false}`, a configured plugin missing from disk is reinstalled
|
||||
unless the user uninstalled it -- and only reports observed-state gaps
|
||||
(enabled but not loaded, loaded at an older version): the display loads and
|
||||
unloads by config on its own, and a version gap needs a restart.
|
||||
|
||||
**`data/plugin_state.json` is retired.** The web process used to keep a
|
||||
second `PluginStateManager` (`state_manager.py`) persisted to that file:
|
||||
per plugin an enabled flag copied from config, a version copied from the
|
||||
manifest (when set at all), a status derived from those, and install/update
|
||||
timestamps. Reconciliation mostly synced it back to config and backups
|
||||
merged it into their plugin list. Every field is derivable (the timestamps
|
||||
from the operation history), so nothing is migrated: no code reads or
|
||||
writes the file, and a copy left on a device is inert and safe to delete.
|
||||
The two classes shared a name but not a concern -- a persisted install
|
||||
record versus the live lifecycle -- so they were not merged; the persisted
|
||||
one had nothing left to hold and was removed.
|
||||
|
||||
## Display loop
|
||||
|
||||
@@ -82,7 +193,11 @@ then normal rotation.
|
||||
- **On-demand.** A request from the web interface pins one plugin (or mode)
|
||||
for a duration. `_activate_on_demand()` / `_clear_on_demand()`; the
|
||||
session is saved under `display_on_demand_config` so it survives a
|
||||
restart. It also keeps the display on during scheduled off hours.
|
||||
restart. It also keeps the display on during scheduled off hours. A
|
||||
request for a plugin that is disabled in config loads it live
|
||||
(`_load_plugin_for_on_demand()`, `load_plugin(force_enabled=True)`)
|
||||
without writing `config.json`; the main loop unloads it once on-demand
|
||||
moves off it (`_release_on_demand_plugins()`).
|
||||
- **Live priority.** `_check_live_priority()` looks for a plugin whose
|
||||
`has_live_priority()` and `has_live_content()` are both true and switches
|
||||
to it, rotating between several live games.
|
||||
@@ -99,7 +214,8 @@ then normal rotation.
|
||||
changes. The controller refreshes its cached settings; enabling or
|
||||
disabling a plugin queues `_reconcile_enabled_plugins()`, which loads or
|
||||
unloads it on the display thread; each plugin gets `on_config_change()`
|
||||
for its own section. Set `LEDMATRIX_HOT_RELOAD=false` to turn this off.
|
||||
for its own section, under its plugin lock
|
||||
(`PluginManager.apply_config_change()`). Set `LEDMATRIX_HOT_RELOAD=false` to turn this off.
|
||||
Matrix hardware settings are only read at start-up.
|
||||
- **Vegas mode.** [`src/vegas_mode/`](../src/vegas_mode/): the display loop
|
||||
calls `VegasModeCoordinator.run_iteration()`
|
||||
@@ -113,6 +229,43 @@ then normal rotation.
|
||||
`sync.role`: a leader sends a follower its share of each frame over UDP
|
||||
(port 5765).
|
||||
|
||||
### Liveness
|
||||
|
||||
A render thread stuck inside a plugin leaves the service "active" and the
|
||||
panel frozen, so liveness is reported by the render thread itself
|
||||
([`src/display_watchdog.py`](../src/display_watchdog.py), standard library
|
||||
only). `beat()` from any other thread is ignored: the update worker, Vegas's
|
||||
tick thread and the prefetcher keep running while the render thread is stuck,
|
||||
and must not vouch for it.
|
||||
|
||||
- **Check-in points.** The top of `run()`'s loop (`loop_pass()`), every
|
||||
dwell second (`_sleep_with_plugin_updates`), every frame of the per-screen
|
||||
loops (`_display_once`), every frame of Vegas's own loop and static pause
|
||||
(`coordinator.run_iteration`), each plugin fetched for a Vegas cycle
|
||||
(`StreamManager._fetch_plugin_content`), each update on the
|
||||
`synchronous_updates` path, and every frame pushed
|
||||
(`DisplayManager.update_display` -> `note_frame()`). Beats are
|
||||
rate-limited to one ping and one heartbeat write every 5 s.
|
||||
- **systemd watchdog.** `ledmatrix.service` is `Type=simple` with
|
||||
`WatchdogSec=120` and `NotifyAccess=main`. `run.py` sends
|
||||
`WATCHDOG_USEC` = 15 minutes before importing anything heavy (start-up loads
|
||||
plugins and runs the 20 s update budget, and the watchdog clock starts with
|
||||
the process). After the first frame -- or the first full pass, when there is
|
||||
nothing to draw -- the loop sends `READY=1`, restores the unit's 120 s and
|
||||
pings. `PluginManager.load_plugin()` on the render thread (a plugin enabled
|
||||
from the web UI, or loaded for on-demand) gets 15 minutes again, since it
|
||||
can run pip. A missed deadline is a SIGABRT; faulthandler, enabled on
|
||||
arming, dumps every thread's stack to the journal.
|
||||
- **Heartbeat.** `/run/ledmatrix/display-heartbeat.json`
|
||||
(`{"pid", "mono", "wall"}`; `RuntimeDirectory=ledmatrix`, 0755, file 0644 so
|
||||
the web user can read it). Readers compare `mono` with their own
|
||||
`time.monotonic()` -- CLOCK_MONOTONIC is shared by every process and does not
|
||||
jump when NTP first sets an RTC-less Pi's clock. `/api/v3/health` calls it
|
||||
`stalled` past 60 s; no file is `not_reported` and changes nothing. A clean
|
||||
stop removes it. Without `RuntimeDirectory=` (an older unit) the display,
|
||||
as root, creates the directory itself; off Linux, or without root, there
|
||||
is no heartbeat.
|
||||
|
||||
## Plugin system
|
||||
|
||||
[`src/plugin_system/`](../src/plugin_system/):
|
||||
@@ -121,7 +274,8 @@ then normal rotation.
|
||||
|---|---|
|
||||
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
|
||||
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
|
||||
| Discovery, load, unload, scheduled updates | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||
| Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||
| Manifest, schema, config and version reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
|
||||
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
|
||||
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
|
||||
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
||||
@@ -148,8 +302,9 @@ everything else through `_reinstall_with_rollback()`.
|
||||
## Web interface
|
||||
|
||||
- **App.** [`web_interface/app.py`](../web_interface/app.py) builds the
|
||||
Flask `app` at import time, creates the managers, and registers two
|
||||
blueprints. `web_interface/start.py` runs it on port 5000.
|
||||
Flask `app` at import time, creates the managers -- a `PluginCatalog`,
|
||||
never a `PluginManager` -- and registers two blueprints.
|
||||
`web_interface/start.py` runs it on port 5000.
|
||||
- **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py)
|
||||
serves the shell `templates/v3/base.html` at `/` and each tab as a
|
||||
partial at `/partials/<name>` (templates in
|
||||
@@ -181,8 +336,19 @@ everything else through `_reinstall_with_rollback()`.
|
||||
- **Update Code** on the Overview tab and the automatic updater both call
|
||||
`perform_core_update()` in
|
||||
[`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py):
|
||||
`git pull --rebase`, reinstall changed requirement files, report whether a
|
||||
restart is needed.
|
||||
fetch branches and tags, move the checkout for the update channel, reinstall
|
||||
changed requirement files, report whether a restart is needed.
|
||||
- **Update channels** (`auto_update.channel`):
|
||||
[`web_interface/update_channel.py`](../web_interface/update_channel.py)
|
||||
decides the move. `stable` checks out the newest `vX.Y.Z` tag (detached
|
||||
HEAD) when it contains the current commit; `beta` is
|
||||
`git pull --rebase --autostash` on the current branch, and leaves a
|
||||
detached release for `main` first. A stable device newer than the newest
|
||||
release keeps pulling `main` until a release contains its commit, so no
|
||||
update ever moves backwards; a config without the key is written as
|
||||
`stable` once the device reaches a release. Checkouts carry uncommitted
|
||||
edits across with `git stash create`/`apply`, and keep them in the stash
|
||||
list if they no longer apply.
|
||||
- **Automatic updates** (`auto_update.enabled`, off by default):
|
||||
`AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py)
|
||||
runs in the web process, checks every 30 minutes, and updates at most
|
||||
@@ -193,7 +359,11 @@ everything else through `_reinstall_with_rollback()`.
|
||||
`ledmatrix-update-verify.path`, which runs the verifier as a separate unit
|
||||
(so restarting the web service does not kill it). The verifier restarts
|
||||
both services, waits for the web API to answer and the display service to
|
||||
stay up, and on failure resets to the previous commit and restarts again.
|
||||
stay up -- and, when the display wrote a heartbeat before the update, to
|
||||
keep one fresh from the restarted process (see Liveness) -- and on failure
|
||||
returns to where HEAD was (the branch, or detached on the previous
|
||||
release; `old_ref` in the pending file), resets to the previous commit
|
||||
and restarts again.
|
||||
Plugin updates run only after a verified core update. State is in
|
||||
`data/auto_update_state.json` and `data/auto_update_pending.json`.
|
||||
- **Startup validator.** `StartupValidator`
|
||||
@@ -201,7 +371,9 @@ everything else through `_reinstall_with_rollback()`.
|
||||
`DisplayController.__init__`: config and cache directory first, then
|
||||
enabled plugins once the plugin manager exists. It also warns when an
|
||||
installed systemd unit differs from its template in `systemd/`. Results
|
||||
are logged; startup continues either way.
|
||||
are logged; startup continues either way. Nothing rewrites installed units
|
||||
on update: a unit change such as the watchdog reaches an existing install
|
||||
only when `install_service.sh` is re-run.
|
||||
|
||||
## Where to start reading
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ tooling against it.
|
||||
|---|---|---|---|
|
||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||
| `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) |
|
||||
| `auto_update.channel` | `"stable"` or `"beta"`, `"stable"` (template) | What Update Code and the weekly update install. `stable`: the newest `vX.Y.Z` release tag (pre-releases ignored), checked out with a detached HEAD. `beta`: `main`. Never moves a device backwards: one newer than the newest release keeps following `main` until a release contains its commit. Missing (configs from before channels) behaves like `stable` and is saved as `stable` once the device is on a release. General tab, Update Channel | `web_interface/update_channel.py` (`resolve()`) |
|
||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
|
||||
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps |
|
||||
@@ -131,6 +132,10 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
|
||||
| `live_refresh` | bool, `true` — live elements: a plugin that supports them (scores, the flight map) has what is already scrolling updated when its data changes, instead of freezing each card as it was drawn. Always off under multi-display sync, in swap mode and with `offscreen_prefetch` off. `false` restores the frozen behaviour exactly. Per plugin: `vegas_live` in the plugin's section |
|
||||
| `live_max_hz` | float, `5` (0–10) — ceiling on how often an animated live element (a moving aircraft) is redrawn; `0` keeps data updates and turns animation off. Capped at 1 Hz without the rebuilt rgbmatrix binding |
|
||||
| `live_min_interval` | float, `2` (0.5–60) — shortest time between two data redraws of one plugin; a faster plugin is redrawn at this rate, never skipped |
|
||||
| `live_lead_screens` | float, `1` (0–5) — how far ahead of the screen, in screen widths, an animated element starts being redrawn |
|
||||
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
||||
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
|
||||
| `extend_threshold_screens` | float, `2.0` |
|
||||
@@ -147,7 +152,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
|
||||
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_in_ticker` | bool, `true` — keep scrolling during live games instead of handing the display to a full-screen scoreboard. `false` was the default before 3.8.0; the first start on 3.8.0 turns a stored `false` on once and sets `live_in_ticker_migrated` |
|
||||
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Deprecated plugin APIs: usage scan
|
||||
|
||||
Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)).
|
||||
|
||||
- Scanned: 2026-09-30, core 3.7.0
|
||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4327c2e4), 46 plugins
|
||||
- Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar)
|
||||
|
||||
**37 deprecated methods: 36 unused, 1 still used, 0 need review.**
|
||||
|
||||
Counted per plugin: a *call* is `<receiver>.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files.
|
||||
|
||||
| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| `CacheManager.has_data_changed` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | blackjack (1 call, 1 override); calendar (1 override); olympics (1 override) | — | still used by blackjack, calendar, olympics — keep or migrate first |
|
||||
| `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.9.0 |
|
||||
| `PluginManager.get_enabled_plugins` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
|
||||
## Unused — safe to remove (36)
|
||||
|
||||
`CacheManager.has_data_changed`, `CacheManager.update_cache`, `CacheManager.setup_persistent_cache`, `CacheManager.get_sport_live_interval`, `CacheManager.get_sport_key_from_cache_key`, `CacheManager.get_background_cached_data`, `CacheManager.is_background_data_available`, `CacheManager.record_cache_hit`, `CacheManager.record_cache_miss`, `CacheManager.record_fetch_time`, `CacheManager.get_cache_metrics`, `CacheManager.log_cache_metrics`, `CacheManager.get_memory_cache_stats`, `DisplayManager.draw_sun`, `DisplayManager.draw_cloud`, `DisplayManager.draw_rain`, `DisplayManager.draw_snow`, `DisplayManager.draw_weather_icon`, `DisplayManager.draw_text_with_icons`, `DisplayManager.get_scrolling_stats`, `FontManager.get_manager_fonts`, `FontManager.get_detected_fonts`, `FontManager.unregister_plugin_fonts`, `FontManager.get_plugin_fonts`, `FontManager.set_override`, `FontManager.remove_override`, `FontManager.get_overrides`, `FontManager.get_available_fonts`, `FontManager.get_size_tokens`, `FontManager.get_performance_stats`, `FontManager.get_font_catalog`, `FontManager.add_font`, `FontManager.remove_font`, `FontManager.validate_font`, `BasePlugin.get_vegas_segment_width`, `PluginManager.get_enabled_plugins`
|
||||
|
||||
## Still used — keep or migrate first (1)
|
||||
|
||||
`BasePlugin.get_supported_vegas_modes`
|
||||
|
||||
## Every hit
|
||||
|
||||
File paths are relative to the plugin's directory (core: the repo root).
|
||||
|
||||
| Method | Where | File:line | Kind | Code |
|
||||
|---|---|---|---|---|
|
||||
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:28 | unrelated | `def get_sport_live_interval(self, sport_key: str) -> int:` |
|
||||
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` |
|
||||
| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache/cache_strategy.py:214 | unrelated | `def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:` |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:806 | unrelated | `return self._strategy_component.get_sport_key_from_cache_key(key)` |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:816 | unrelated | `sport_key = self._strategy_component.get_sport_key_from_cache_key(key)` |
|
||||
| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` |
|
||||
| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` |
|
||||
| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` |
|
||||
| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||
| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1356 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1371 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1515 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:76 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1265 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1544 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1635 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` |
|
||||
| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` |
|
||||
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:732 | override | `def get_supported_vegas_modes(self):` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:695 | call | `if mode in self.get_supported_vegas_modes():` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | calendar | manager.py:875 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | olympics | manager.py:624 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
||||
| `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` |
|
||||
|
||||
## Sources scanned
|
||||
|
||||
| Source | Group | Python files | Hits |
|
||||
|---|---|---|---|
|
||||
| core | core | 164 | 20 |
|
||||
| core tests | core-tests | 323 | 17 |
|
||||
| 7-segment-clock | monorepo | 3 | 0 |
|
||||
| afl-scoreboard | monorepo | 34 | 0 |
|
||||
| baseball-scoreboard | monorepo | 60 | 0 |
|
||||
| basketball-scoreboard | monorepo | 48 | 0 |
|
||||
| birdnet-go | monorepo | 2 | 0 |
|
||||
| blackjack | monorepo | 7 | 2 |
|
||||
| calendar | monorepo | 5 | 1 |
|
||||
| christmas-countdown | monorepo | 3 | 0 |
|
||||
| clock-simple | monorepo | 2 | 0 |
|
||||
| countdown | monorepo | 5 | 0 |
|
||||
| cricket-scoreboard | monorepo | 8 | 0 |
|
||||
| f1-scoreboard | monorepo | 15 | 0 |
|
||||
| fantasy-blitz | monorepo | 13 | 0 |
|
||||
| football-scoreboard | monorepo | 73 | 0 |
|
||||
| geochron | monorepo | 10 | 0 |
|
||||
| hello-world | monorepo | 2 | 0 |
|
||||
| hockey-scoreboard | monorepo | 51 | 0 |
|
||||
| incoming-packages | monorepo | 8 | 0 |
|
||||
| jellyfin-now-playing | monorepo | 4 | 0 |
|
||||
| lacrosse-scoreboard | monorepo | 39 | 0 |
|
||||
| ledmatrix-elections | monorepo | 12 | 0 |
|
||||
| ledmatrix-flights | monorepo | 45 | 0 |
|
||||
| ledmatrix-leaderboard | monorepo | 9 | 0 |
|
||||
| ledmatrix-music | monorepo | 11 | 0 |
|
||||
| ledmatrix-stocks | monorepo | 7 | 0 |
|
||||
| ledmatrix-weather | monorepo | 14 | 6 |
|
||||
| march-madness | monorepo | 4 | 0 |
|
||||
| masters-tournament | monorepo | 10 | 0 |
|
||||
| mqtt-notifications | monorepo | 4 | 0 |
|
||||
| news | monorepo | 6 | 0 |
|
||||
| nfl-draft | monorepo | 3 | 0 |
|
||||
| nfl-stat-leaders | monorepo | 8 | 0 |
|
||||
| nrl-scoreboard | monorepo | 29 | 0 |
|
||||
| odds-ticker | monorepo | 9 | 0 |
|
||||
| of-the-day | monorepo | 14 | 0 |
|
||||
| olympics | monorepo | 16 | 1 |
|
||||
| on-air | monorepo | 2 | 0 |
|
||||
| pomodoro-timer | monorepo | 3 | 0 |
|
||||
| soccer-scoreboard | monorepo | 46 | 0 |
|
||||
| static-image | monorepo | 3 | 0 |
|
||||
| stock-news | monorepo | 3 | 0 |
|
||||
| text-display | monorepo | 4 | 0 |
|
||||
| tide-display | monorepo | 3 | 0 |
|
||||
| ufc-scoreboard | monorepo | 34 | 0 |
|
||||
| web-ui-info | monorepo | 2 | 0 |
|
||||
| youtube-stats | monorepo | 5 | 0 |
|
||||
| f1-live | third-party | 10 | 0 |
|
||||
| gif-player | third-party | 1 | 0 |
|
||||
| pga-tour-leaderboard | third-party | 2 | 0 |
|
||||
| plex-marquee | third-party | 1 | 0 |
|
||||
| ledmatrix-dresden-departures | third-party | 1 | 0 |
|
||||
| tidbyt-baseball-scoreboard | third-party | 2 | 0 |
|
||||
| sleeper-fantasy | third-party | 1 | 0 |
|
||||
| ledmatrix-nascar | third-party | 1 | 0 |
|
||||
|
||||
## How to re-run
|
||||
|
||||
```bash
|
||||
# Clones the monorepo and each third-party plugin (depth 1) into a temp cache:
|
||||
python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md
|
||||
# Or scan a local monorepo checkout (read only) instead of cloning it:
|
||||
python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins
|
||||
```
|
||||
|
||||
Before removing a method in its release, re-run the scan against the current monorepo and registry: a plugin added since this file was generated may have started calling it. Remove only methods the fresh scan reports unused; move the rest to a later release (the test in `test/test_deprecation.py` fails while a marker names a release at or below `src.__version__`).
|
||||
@@ -54,7 +54,7 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||
|
||||
# Weather icons: draw_weather_icon() is deprecated, removed in 3.7.0 —
|
||||
# Weather icons: draw_weather_icon() is deprecated, removed in 3.8.0 —
|
||||
# draw your own icons (the weather plugin ships WeatherIcons)
|
||||
|
||||
# Scrolling state
|
||||
@@ -78,7 +78,7 @@ strategy = cache_manager.get_cache_strategy("weather")
|
||||
```
|
||||
|
||||
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
|
||||
are deprecated, removed in 3.7.0. See
|
||||
are deprecated, removed in 3.8.0. See
|
||||
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
|
||||
## Plugin Manager Quick Methods
|
||||
@@ -87,7 +87,7 @@ are deprecated, removed in 3.7.0. See
|
||||
# Get plugins
|
||||
plugin = plugin_manager.get_plugin("plugin-id")
|
||||
all_plugins = plugin_manager.get_all_plugins()
|
||||
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check `enabled`
|
||||
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check `enabled`
|
||||
# on the entries in plugin_manager.plugins
|
||||
|
||||
# Get info
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
|
||||
which plugin uses which font so the web UI can show it.
|
||||
|
||||
Several methods are deprecated and will be removed in LEDMatrix 3.7.0; they
|
||||
Several methods are deprecated and will be removed in LEDMatrix 3.8.0; they
|
||||
log a warning on first call. They are listed in
|
||||
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
|
||||
[`test/test_deprecation.py`](../test/test_deprecation.py).
|
||||
@@ -209,7 +209,7 @@ Current methods:
|
||||
|
||||
### Deprecated methods
|
||||
|
||||
Removed in 3.7.0. Each logs a warning on first call.
|
||||
Removed in 3.8.0. Each logs a warning on first call.
|
||||
|
||||
| Method | Use instead |
|
||||
|---|---|
|
||||
|
||||
@@ -240,6 +240,22 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
||||
- Install community plugins straight from a GitHub URL via
|
||||
**Install from GitHub** on the same tab.
|
||||
|
||||
### Keep LEDMatrix Up to Date
|
||||
|
||||
- **Update Code** on the **Overview** tab installs the newest version, and a
|
||||
banner at the top of the page says when one is available.
|
||||
- **General → Automatic Updates** does it once a week, overnight, with a
|
||||
health check that undoes an update that breaks the device.
|
||||
- **General → Update Channel** picks which version that is. **Stable** (the
|
||||
default) installs releases, which have been tested and have release
|
||||
notes. **Beta** installs the newest code as soon as it is written, before
|
||||
it is released: fixes arrive sooner, and so do new problems.
|
||||
- Switching to Stable never installs an older version than the one you
|
||||
have. If your device is already newer than the latest release (which is
|
||||
normal if it was set up or updated from the newest code), it keeps
|
||||
getting the newest code until the next release includes it, then follows
|
||||
releases from there. The General tab says when this is the case.
|
||||
|
||||
### Enable Advanced Features
|
||||
|
||||
**Vegas Scroll Mode:**
|
||||
|
||||
+116
-150
@@ -1,9 +1,10 @@
|
||||
# Offscreen Rendering
|
||||
|
||||
**Status (2026-09-24):** step 1, offscreen rendering, is implemented
|
||||
**Status (2026-09-30):** offscreen rendering is implemented
|
||||
(`DisplayManager.offscreen()`, the adapter on the prefetch thread, the plugin
|
||||
lock). Steps 2 and 3 are proposed. When all three land, this file becomes the
|
||||
reference for how plugin content is rendered off the render thread.
|
||||
lock), and so are live elements, which grew out of steps 2 and 3 below: see
|
||||
*Live elements*. The segment strip proposed as step 2 was not needed; *Why not
|
||||
a SegmentStrip* says why.
|
||||
|
||||
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
|
||||
runs, A/B/B/A):
|
||||
@@ -153,136 +154,101 @@ particular keeps presenting while a plugin draws elsewhere.
|
||||
fetch left is the inline fallback when no prepared group is ready, which in
|
||||
practice is the first extension. Prefetching at start removes that too.
|
||||
|
||||
## Keeping live content fresh
|
||||
## Live elements: content that changes while it scrolls
|
||||
|
||||
Offscreen rendering is also what makes fresh sports scores possible. Today a
|
||||
plugin's segment is drawn when its group is prefetched, and the strip carries
|
||||
7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still
|
||||
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
|
||||
70–100 seconds later. When a plugin reports new data, Vegas only drops its
|
||||
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
|
||||
*next* turn, several minutes later. A segment already in the strip scrolls by
|
||||
with the data it was drawn with.
|
||||
Offscreen rendering is also what makes fresh content possible. A plugin's
|
||||
segment is drawn when its group is prefetched, and the strip carries
|
||||
7,000-10,000 px of content ahead of the viewport, so at ~100 px/s a score drawn
|
||||
then reaches the screen 70-100 seconds later -- and once in the strip it never
|
||||
changed: when a plugin reported new data, Vegas only dropped its caches, so the
|
||||
change appeared on the plugin's *next* turn, minutes later.
|
||||
|
||||
That was the right trade while every redraw of a canvas-bound plugin stalled
|
||||
the scroll. Off the render thread a redraw costs the scroll nothing, so the
|
||||
strip can afford three things.
|
||||
A plugin can now hand Vegas **live elements** instead of pictures
|
||||
(`BasePlugin.get_vegas_elements()`, see "Live Vegas elements" in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)):
|
||||
named, fixed-width pieces of content -- one per game card, one for a map. Vegas
|
||||
records where each lands in the strip and, when the plugin's data changes,
|
||||
redraws just the changed ones off the render thread and copies their pixels
|
||||
over the old ones between two frames. A card already crossing the panel
|
||||
changes; nothing next to it moves.
|
||||
|
||||
### 1. Refresh at the gate
|
||||
### Why not redraw every frame
|
||||
|
||||
Before a segment enters the viewport, check whether its plugin has updated
|
||||
since the segment was drawn. If it has, redraw it offscreen and replace it
|
||||
while it is still out of sight. Width changes are fine here, because
|
||||
everything from that segment onward is still invisible.
|
||||
On a Pi the render thread has about 4 ms of slack per refresh at 512x64 after
|
||||
the ~6 ms blit, and a scoreboard card is ~29 ms of Pillow work that holds the
|
||||
GIL. Drawing on the render thread is out of the question at any rate, so the
|
||||
render thread only ever *copies* pixels that are already drawn. Measured on a
|
||||
Pi 4 (ledpi): writing a 35 KB card into a 20,000 px strip takes 8.5 µs, a
|
||||
101 KB map 17 µs, four cards (the per-frame cap) 34 µs -- against 124 µs for
|
||||
the viewport slice every frame already does.
|
||||
|
||||
The gate sits `lead` pixels ahead of the viewport's right edge:
|
||||
`lead = max(one screen, speed × (render time + margin))`. The render time is
|
||||
the plugin's own, measured on each render (sports cards take the longest,
|
||||
hundreds of ms up to seconds per the prefetch notes). A plugin whose render
|
||||
does not finish before its segment reaches the viewport keeps the old segment.
|
||||
The scroll never waits for it.
|
||||
### How an update reaches the screen
|
||||
|
||||
Content is then at most `lead / speed` seconds old when it appears, a few
|
||||
seconds instead of minutes, without changing how far ahead the rotation
|
||||
fetches.
|
||||
1. A plugin's `update()` completes. The update worker calls
|
||||
`PluginManager._note_update_completed`, which calls the update listeners
|
||||
(`add_update_listener`) there and then, with the plugin's lock still held.
|
||||
Vegas's listener moves the plugin's **epoch** on
|
||||
(`src/vegas_mode/elements.py`, `LiveEpochs`) and wakes the live worker.
|
||||
2. The **live worker** (`src/vegas_mode/live_worker.py`), the one background
|
||||
thread that draws for the strip once it holds a live element, finds the
|
||||
plugin's elements whose recorded epoch is older than its current one,
|
||||
nearest the screen first, and calls `get_vegas_elements()` under the
|
||||
plugin's lock (0.25 s wait, then a 1 s backoff). Elements whose `version`
|
||||
is unchanged cost nothing; the rest are pinned and checksummed, and each
|
||||
whose pixels changed becomes a patch in a one-per-element slot (the latest
|
||||
wins).
|
||||
3. Between two frames the render thread
|
||||
(`RenderPipeline.apply_live_patches`, from `coordinator.run_frame`) pops at
|
||||
most four patches or two screens of bytes and copies each into the strip
|
||||
with `ScrollHelper.patch_columns`. It takes no lock and draws nothing. A
|
||||
patch made for an older strip, for an element trimmed away or already
|
||||
behind the screen, or from older data than the strip shows, is dropped.
|
||||
|
||||
### 2. Replace ahead of the screen
|
||||
End to end, a new score reaches a card already on screen within one poll of
|
||||
the data source (30 s for live games) plus about a second: the listener is
|
||||
immediate, and while live elements exist the update tick that schedules
|
||||
plugins runs every second instead of every four.
|
||||
|
||||
When a plugin reports new data (the Vegas update tick already names them), any
|
||||
of its segments that are **anywhere ahead of the viewport** are redrawn and
|
||||
replaced straight away, not only at the gate. That covers the long stretch of
|
||||
strip between prefetch and the gate.
|
||||
Elements that change with **time** rather than data (an aircraft moving
|
||||
between position reports) ask for `refresh_hz`; the worker calls
|
||||
`redraw_vegas_element()` -- without the plugin's lock, from state the plugin
|
||||
publishes in one assignment -- that often while the element is on or within
|
||||
`live_lead_screens` of the screen, capped by `live_max_hz` (5), at 1 Hz
|
||||
without the render gate, and halved for an element whose redraws average over
|
||||
50 ms.
|
||||
|
||||
### 3. Update on screen
|
||||
### Geometry
|
||||
|
||||
A segment that is already **visible** is patched in place when the redrawn
|
||||
version has the same geometry: the same total width, and the same width for
|
||||
each card (a sports plugin returns one image per game, joined with
|
||||
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
|
||||
patches in and the digits update as the card scrolls past. The patch is a
|
||||
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
|
||||
between frames, so a frame never shows half of a patch.
|
||||
A live element is never trimmed to its ink: the adapter pads it with
|
||||
`content_padding` black columns either side and pins its width, and a redraw
|
||||
at any other width is refused (it shows the next time the plugin comes round).
|
||||
Records keep **absolute** strip columns -- the strip column plus everything
|
||||
trimmed off the front since the strip was composed -- so a trim moves one
|
||||
origin rather than every record. Nothing on screen is ever moved, inserted or
|
||||
resized; a game added to a slate appears on the plugin's next turn.
|
||||
|
||||
When the geometry differs (a game added or dropped, a card that grew), the
|
||||
visible part cannot change without a jump. Only the cards not yet on screen
|
||||
are replaced, and only if the geometry up to that point is unchanged. Otherwise
|
||||
the segment keeps its snapshot until it has scrolled off.
|
||||
### Why not a SegmentStrip
|
||||
|
||||
### Avoiding wasted work
|
||||
The proposal here was to replace the single strip with a list of segments.
|
||||
In-place patching of the single strip meets every goal without that: the
|
||||
patch is O(element) and the strip layout never changes. What a segment list
|
||||
would still buy is cheaper extensions, and most of that came from making the
|
||||
strip's PIL copy lazy instead (`ScrollHelper.cached_image`: an extension used
|
||||
to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of
|
||||
`frame_soak.py`'s "after work" table says whether the rest is worth it.
|
||||
|
||||
- **Change detection.** `run_scheduled_updates_with_changes()` names a plugin
|
||||
whenever its `update()` ran, not when its data changed. On hdpi
|
||||
`clock-simple` and `ledmatrix-music` are named on every 4-second tick. A
|
||||
redraw whose pixels hash the same as the segment's is discarded without a
|
||||
swap.
|
||||
- **Redraw on real updates only.** Vegas makes no API calls. Each plugin
|
||||
fetches on its own schedule, and a redraw is triggered only when the
|
||||
plugin's `update()` has run since its segment was drawn. On hdpi live
|
||||
football, baseball and hockey poll every 30 s (live odds every 60 s,
|
||||
everything else hourly), so a live sports card is redrawn once per poll.
|
||||
- **Floor.** A plugin is redrawn at most once per
|
||||
`vegas_scroll.refresh_min_interval` (proposed 10 s), and never while its
|
||||
previous redraw is still running. The floor never holds back a sports card
|
||||
polling every 30 s. It exists for chatty plugins: `clock-simple` updates
|
||||
every second and `ledmatrix-music` polls every 2 s.
|
||||
- **One worker.** Redraws go through the same background worker as prefetch,
|
||||
one plugin at a time at `nice 10`, under the plugin's lock.
|
||||
### When it is off
|
||||
|
||||
Data freshness is still bounded by each plugin's own fetch interval (how often
|
||||
it polls live scores). Drawing faster cannot beat the data source.
|
||||
|
||||
### The strip becomes a list of segments
|
||||
|
||||
All three need the strip to be replaceable by segment. Today it is one
|
||||
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
|
||||
`append_content()` rebuilds the whole thing on the render thread for every
|
||||
appended block. That is also a pause source.
|
||||
|
||||
Proposed `SegmentStrip`, used by Vegas in place of the single image:
|
||||
|
||||
- an ordered list of segments: plugin id, card boundaries, a pixel array, the
|
||||
render time, and the plugin data version it was drawn from, plus its
|
||||
x-offset in the strip;
|
||||
- `visible(x, width)` assembles the viewport by slicing across at most a few
|
||||
segments: the same ~100 KB copy per frame that slicing the single image
|
||||
costs today;
|
||||
- append and trim become O(block) list operations, not a copy of the strip;
|
||||
- replace swaps one list entry and shifts the offsets of the segments after it
|
||||
(dozens at most). A same-geometry patch copies pixels into the existing array.
|
||||
|
||||
Every mutation is prepared off the render thread and applied by the render
|
||||
thread at a frame boundary, so the strip the render loop reads is never
|
||||
half-changed.
|
||||
|
||||
### Multi-display sync
|
||||
|
||||
The follower renders from its own copy of the strip, offset from the leader's
|
||||
scroll position. Today the leader sends that copy whole, and only in
|
||||
`start_new_cycle()` (`send_scroll_image`), plus the scroll position every
|
||||
frame. Continuous scroll, the default, extends and trims the strip without
|
||||
starting a new cycle, and nothing sends those changes. From reading the code,
|
||||
the follower therefore probably falls out of step after the first extension
|
||||
already, before any of this design. That is untested; it needs a two-Pi rig.
|
||||
|
||||
With a segment strip, keeping the follower identical becomes **replaying the
|
||||
leader's operations**:
|
||||
|
||||
- Every strip mutation (append, trim, replace, patch) is one operation in
|
||||
strip coordinates. The leader applies it and sends the same operation to the
|
||||
follower over the existing TCP channel. Segments are small: a card is ~29 KB
|
||||
raw and compresses well.
|
||||
- Operations on off-screen segments apply on arrival. A patch to a segment
|
||||
that is on either panel carries an *apply at scroll position X* stamp a
|
||||
couple of hundred milliseconds ahead. Both sides apply it when their scroll
|
||||
position passes X, so both panels change on the same frame, within the
|
||||
existing position-sync jitter.
|
||||
- Each operation carries a sequence number. A follower that sees a gap (a
|
||||
reconnect, a dropped message) asks for a full snapshot, which is today's
|
||||
`send_scroll_image` path.
|
||||
|
||||
That also fixes the probable continuous-mode gap as a side effect, since
|
||||
appends and trims become operations too. Until it is in place, fresh-content
|
||||
updates are disabled while sync is active.
|
||||
- `display.vegas_scroll.live_refresh: false` (the kill switch; also in the
|
||||
web UI), or `vegas_live: false` in one plugin's section.
|
||||
- Always under multi-display sync: the follower mirrors whole strips only, so
|
||||
a patch would never reach it. (Continuous-mode sync has a separate problem:
|
||||
the follower is not sent extensions or trims at all.)
|
||||
- In swap mode (`continuous_scroll: false`) and with `offscreen_prefetch:
|
||||
false`.
|
||||
- For plugins without the hook, which are drawn and placed exactly as before,
|
||||
and on the paths that fetch without the plugin's lock (the first strip of a
|
||||
run, the render thread's fallback fetch), which use `get_vegas_content()`.
|
||||
|
||||
## Risks, and what was checked
|
||||
|
||||
@@ -328,11 +294,10 @@ updates are disabled while sync is active.
|
||||
source of the single-refresh late frames. Holding frames for two refreshes
|
||||
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
|
||||
step.
|
||||
- **Live refreshes pushed from `update()`.** Some sports plugins call
|
||||
`display()` and `update_display()` from inside `update()`, which runs on the
|
||||
update worker and can push to the panel mid-Vegas. That is a separate
|
||||
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
|
||||
while Vegas owns the panel), but it is out of scope here.
|
||||
- **Multi-display sync in continuous mode.** The follower is sent the whole
|
||||
strip only at a new cycle and on connect, never the extensions and trims of
|
||||
continuous mode, so it drifts from the leader after the first extension.
|
||||
Live elements stay off under sync for that reason.
|
||||
|
||||
## Test plan
|
||||
|
||||
@@ -348,14 +313,15 @@ updates are disabled while sync is active.
|
||||
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
|
||||
300 ms. The Vegas render loop never goes a frame without presenting (frame
|
||||
timing recorder: zero freezes).
|
||||
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
|
||||
matches slicing one concatenated image, pixel for pixel. Append, trim,
|
||||
replace-ahead and same-geometry patch each leave every other column
|
||||
unchanged. A geometry-changing patch of a visible segment is refused.
|
||||
- **Freshness:** a stub sports plugin whose score changes every second. The
|
||||
score on screen is never older than `lead / speed` plus the plugin's fetch
|
||||
interval. A visible card's digits change without the frame-timing recorder
|
||||
seeing a late frame. An unchanged redraw is discarded.
|
||||
- **Live elements** (`test/test_vegas_live_*.py`,
|
||||
`test/test_vegas_elements_*.py`, `test/test_scroll_helper_patch.py`): every
|
||||
record points at exactly its element's pixels through any sequence of
|
||||
compose, extend and trim; a patch changes only its element's columns (a
|
||||
property test against a twin strip that is never patched); the render
|
||||
thread's apply takes no lock and draws nothing; the worker's priorities,
|
||||
floors, backoff and hand-over; and, end to end on the emulator with the stub
|
||||
plugin (`test/fixtures/plugins/vegas-live-stub`), an update changes a card
|
||||
already in the strip and an animated element moves with no update at all.
|
||||
- **Hardware:** an hdpi soak, A/B against the #628 build, alternating order.
|
||||
Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late
|
||||
rate below 0.66%. Plus, for freshness: log each segment's age when it enters
|
||||
@@ -363,26 +329,26 @@ updates are disabled while sync is active.
|
||||
|
||||
## Rollout
|
||||
|
||||
Three changes, each soaked on hdpi before the next:
|
||||
1. **Offscreen rendering** (shipped): `offscreen()`, the adapter on the
|
||||
prefetch thread, and the plugin lock. Removed the render-thread pauses.
|
||||
2. **Measurement and the lazy strip image:** late frames attributed to the
|
||||
render-thread work before them (`FrameTimingRecorder.note_op`, the "after
|
||||
work" table), and extensions no longer rebuilding the strip's PIL copy.
|
||||
3. **Live elements:** the plugin API, the records, the worker and in-place
|
||||
patches, with the sports scoreboards and the flight map adopting it.
|
||||
4. **Live games in the ticker by default:** `live_in_ticker` true, so a live
|
||||
game's cards update in the marquee instead of the full-screen scoreboard
|
||||
replacing it; existing configs are switched once (`ConfigManager`).
|
||||
|
||||
1. **Offscreen rendering:** `offscreen()`, the adapter on the prefetch thread,
|
||||
and the plugin lock. Removes the render-thread pauses.
|
||||
2. **`SegmentStrip`:** Vegas's strip becomes a list of segments. Removes the
|
||||
whole-strip copy on append. No visible behaviour change.
|
||||
3. **Fresh content:** refresh at the gate, replace ahead, patch on screen,
|
||||
with change detection and the rate limit.
|
||||
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores today's
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
|
||||
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
|
||||
`true`) turns off step 3. Keep both for one release, then delete the old paths.
|
||||
`true`) turns live elements off. Keep both for one release, then delete the
|
||||
old paths.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Keep the kill switch, or ship without one?
|
||||
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
|
||||
or wait longer?
|
||||
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
|
||||
live sports are redrawn once per 30 s poll regardless.
|
||||
4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is
|
||||
proposed as part of the segment strip (step 2), with fresh content
|
||||
disabled under sync until it has been verified on real hardware.
|
||||
1. Multi-display sync: is there a two-Pi rig to test on? Replaying strip
|
||||
operations to the follower (append, trim, patch, in absolute columns) would
|
||||
fix continuous-mode sync and let live elements run under it.
|
||||
2. Is the `extend` cost worth a segment list after the lazy image? The soak's
|
||||
"after work" table answers it per rig.
|
||||
|
||||
+199
-31
@@ -149,15 +149,27 @@ Clean up resources when plugin is unloaded. Override to close connections, stop
|
||||
|
||||
#### `on_config_change(new_config: Dict[str, Any]) -> None`
|
||||
|
||||
Called after plugin configuration is updated via web API.
|
||||
Called after the plugin's section of `config.json` changes -- a save in the
|
||||
web UI, say. Every lifecycle hook runs in the display process, which is the
|
||||
only process that runs plugins: the web interface writes `config.json`, and
|
||||
the display's config watcher calls this with the prepared section. See
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md#web-and-display-processes-who-runs-plugins).
|
||||
|
||||
In the display service it runs on the config watcher thread while holding
|
||||
the plugin's lock, so it never overlaps your `update()` or `display()`. If
|
||||
the plugin stays busy for more than 5 seconds, the change is applied later
|
||||
from the update thread: as soon as the plugin is free, and before its next
|
||||
`update()` at the latest.
|
||||
|
||||
#### `on_enable() -> None`
|
||||
|
||||
Called when plugin is enabled.
|
||||
Called when the display loads the plugin enabled: at startup, or when it is
|
||||
switched on in the web UI.
|
||||
|
||||
#### `on_disable() -> None`
|
||||
|
||||
Called when plugin is disabled.
|
||||
Called when the display unloads the plugin, e.g. when it is switched off in
|
||||
the web UI.
|
||||
|
||||
#### `get_update_interval() -> Optional[float]`
|
||||
|
||||
@@ -296,9 +308,9 @@ the core then falls back to its own live-content check — so a plugin whose
|
||||
weight calculation is broken still gets `live_weight` for a game that really
|
||||
is live, rather than being demoted to 1.
|
||||
|
||||
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||
to be weighted within. See
|
||||
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
|
||||
3.8.0). With it off live content preempts Vegas entirely and there is no
|
||||
ticker to be weighted within. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
### Vegas scroll hooks
|
||||
@@ -308,6 +320,58 @@ rotating one at a time. Plugins control how their content appears via
|
||||
these hooks. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for the user
|
||||
side of Vegas mode.
|
||||
|
||||
#### Vegas participation
|
||||
|
||||
Each plugin takes part in Vegas mode in one of three ways:
|
||||
|
||||
| Participation | What Vegas does |
|
||||
|---|---|
|
||||
| `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else |
|
||||
| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes |
|
||||
| `'exclude'` | The plugin is left out of Vegas mode |
|
||||
|
||||
Declare the plugin's default in `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-alerts",
|
||||
"vegas_participation": "pause"
|
||||
}
|
||||
```
|
||||
|
||||
The user can override it per plugin with `vegas_participation` in that
|
||||
plugin's config section (it is one of the core-owned properties, see
|
||||
[PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md)).
|
||||
Vegas resolves it in this order:
|
||||
|
||||
1. the user's `vegas_participation` config value;
|
||||
2. the plugin's `get_vegas_participation()` — the default implementation
|
||||
reads the manifest's `vegas_participation`, then derives a value from
|
||||
the legacy hooks below;
|
||||
3. derived from the legacy hooks: `get_vegas_display_mode()` returning
|
||||
`VegasDisplayMode.STATIC` → `'pause'`; otherwise
|
||||
`get_vegas_content_type()` returning `'none'` → `'exclude'`; everything
|
||||
else → `'scroll'`.
|
||||
|
||||
Step 3 is exactly what Vegas did before participation existed, so a plugin
|
||||
that declares nothing behaves as it always has. Manifest
|
||||
`vegas_participation` is new in core 3.8.0; older cores ignore it and use
|
||||
the legacy hooks.
|
||||
|
||||
#### `get_vegas_participation() -> str`
|
||||
|
||||
Returns `'scroll'`, `'pause'` or `'exclude'`. Override it only when the
|
||||
answer depends on state — pause only while an alert is live, exclude while
|
||||
there is nothing to show; for a fixed answer use the manifest. Vegas applies
|
||||
the user's config value before calling an override, so an override does not
|
||||
need to check it. A value that is not one of the three is ignored with a log
|
||||
line and the legacy hooks decide.
|
||||
|
||||
```python
|
||||
def get_vegas_participation(self):
|
||||
return 'pause' if self._alert_is_live() else 'scroll'
|
||||
```
|
||||
|
||||
#### `get_vegas_content() -> Optional[PIL.Image | List[PIL.Image] | None]`
|
||||
|
||||
Return content to inject into the scroll. Multi-item plugins (sports,
|
||||
@@ -315,26 +379,113 @@ odds, news) should return a *list* of PIL Images so each item scrolls
|
||||
independently. Static plugins (clock, weather) can return a single image.
|
||||
Returning `None` falls back to capturing whatever `display()` produces.
|
||||
|
||||
#### `get_vegas_content_type() -> str`
|
||||
#### `get_vegas_render_width() -> int`
|
||||
|
||||
`'multi'`, `'static'`, or `'none'`. Affects how Vegas mode treats the
|
||||
plugin. Default `'static'`.
|
||||
The width Vegas wants this plugin's content to occupy, from the plugin's
|
||||
`vegas_width_pct` config value or the global
|
||||
`display.vegas_scroll.render_width_pct`. Vegas also narrows
|
||||
`display_manager` while it asks for content, so a plugin that sizes itself
|
||||
from `display_manager.width` does not need to read this.
|
||||
|
||||
#### `get_vegas_display_mode() -> VegasDisplayMode`
|
||||
#### Live Vegas elements
|
||||
|
||||
Returns one of `VegasDisplayMode.SCROLL`, `FIXED_SEGMENT`, or `STATIC`.
|
||||
Read from `config["vegas_mode"]` or override directly.
|
||||
*New in core 3.8.0.* Content from `get_vegas_content()` is baked into the
|
||||
ticker's strip when the plugin's turn is prefetched, so a score drawn then
|
||||
scrolls past with that score however many goals are scored while it crosses
|
||||
the panel. A plugin that returns **live elements** instead gets them updated
|
||||
in place: after its `update()` the ticker asks again, compares each element
|
||||
with what the strip holds, and swaps the changed ones in between two frames
|
||||
-- on screen included -- without anything next to them moving.
|
||||
|
||||
#### `get_supported_vegas_modes() -> List[VegasDisplayMode]`
|
||||
```python
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0: the hook is never called
|
||||
VegasElement = None
|
||||
|
||||
The set of Vegas modes this plugin can render. Used by the UI to populate
|
||||
the mode selector for this plugin.
|
||||
class MyScoreboard(BasePlugin):
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
return [VegasElement(key=f"game:{g['id']}",
|
||||
image=self._card(g), # cache by fingerprint
|
||||
version=self._fingerprint(g)) # changes iff pixels would
|
||||
for g in self.games]
|
||||
```
|
||||
|
||||
#### `get_vegas_segment_width() -> Optional[int]`
|
||||
`VegasElement(key, image, version=None, live=True, refresh_hz=0.0)`:
|
||||
|
||||
For `FIXED_SEGMENT` plugins, the number of *panels* the segment
|
||||
occupies in the scroll (pixel width = panels × `single_panel_width`,
|
||||
from `display.hardware.cols`). `None` uses the default of 1 panel.
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `key` | Names the element across redraws; unique in the list, stable for the same logical item (`"game:nfl:401547417"`, `"map"`). |
|
||||
| `image` | The element now, at the display's height. A live element's **width must not depend on its data**: a redraw at another width is never swapped in (it appears the next time the plugin comes round), because nothing on screen may move. |
|
||||
| `version` | Anything hashable that changes exactly when the pixels would. Handed back with the **same image object** as last time, it lets the ticker skip converting the element; a new image is always converted and compared by its pixels, so a redraw for new settings is never missed. `None` means "compare pixels". |
|
||||
| `live` | `False` places it as plain content (trimmed, never refreshed): separators, decoration. |
|
||||
| `refresh_hz` | For content that changes with **time** rather than data (an aircraft moving between position reports): the ticker calls `redraw_vegas_element()` about this often while the element is on or near the screen, capped by `vegas_scroll.live_max_hz` and at 1 Hz without the rebuilt rgbmatrix binding. |
|
||||
|
||||
**`get_vegas_elements() -> Optional[List[VegasElement]]`** — called on the
|
||||
ticker's background thread under the plugin's lock (never while `update()`
|
||||
runs), on a canvas of its own and told its render width, exactly like
|
||||
`get_vegas_content()`. It is called after every `update()` while any of the
|
||||
plugin's elements is on or ahead of the screen, so it must be cheap when
|
||||
nothing changed (cache images by version), idempotent, and must not fetch.
|
||||
Return `None` to use `get_vegas_content()`, which a plugin must keep working
|
||||
for older cores and for the paths that do not ask for elements (the ticker's
|
||||
first strip, multi-display sync, the `live_refresh` switch).
|
||||
|
||||
**`redraw_vegas_element(key, width, height, at) -> Optional[PIL.Image]`** —
|
||||
only for elements with `refresh_hz`. Called **without** the plugin's lock,
|
||||
possibly while `update()` runs, so read only state `update()` replaces in one
|
||||
assignment (an immutable snapshot), never state it mutates in place. `at` is
|
||||
the `time.monotonic()` the pixels are expected on the panel: draw the element
|
||||
as it should look then. Return exactly `width` x `height`, or `None` to skip
|
||||
the tick.
|
||||
|
||||
**`notify_vegas_data_changed()`** — data that arrives outside `update()` (a
|
||||
background thread, a push callback) calls this so the ticker redraws without
|
||||
waiting for the next `update()`. Safe from any thread.
|
||||
|
||||
Live elements are never trimmed to their ink: the ticker pads each with
|
||||
`content_padding` black columns either side, the margin trimming would have
|
||||
left. A single element wider than the plugin's width budget
|
||||
(`vegas_max_width_screens`, not counting that padding) is cropped like any
|
||||
other content and scrolls by as plain, no longer live. The user can turn them off per plugin with `vegas_live: false` (a
|
||||
core-owned property) or for the whole ticker with
|
||||
`display.vegas_scroll.live_refresh: false`; they are always off under
|
||||
multi-display sync.
|
||||
|
||||
`scripts/check_plugin.py` checks the contract for any plugin that implements
|
||||
the hook (unique keys, height, width stable with no new data, redraw size,
|
||||
slow calls) and prints a `vegas elements` row; the checks are in
|
||||
`src/plugin_system/testing/vegas.py`. `test/fixtures/plugins/vegas-live-stub`
|
||||
is a small working example.
|
||||
|
||||
#### Legacy: `get_vegas_content_type()` and `get_vegas_display_mode()`
|
||||
|
||||
Superseded by participation, and still read to derive it when neither the
|
||||
user nor the manifest declares one (step 3 above). Only two answers ever
|
||||
mattered: `get_vegas_content_type()` returning `'none'`, and
|
||||
`get_vegas_display_mode()` returning `VegasDisplayMode.STATIC`.
|
||||
|
||||
- `get_vegas_content_type()` returns `'multi'`, `'static'` or `'none'`
|
||||
(default `'static'`).
|
||||
- `get_vegas_display_mode()` returns a `VegasDisplayMode` member (not a
|
||||
string — the string `'static'` never paused anything). The default reads
|
||||
the plugin's `vegas_mode` config value (`"scroll"`, `"fixed"` or
|
||||
`"static"`), else maps content type `'multi'` to `SCROLL` and anything
|
||||
else to `FIXED_SEGMENT`.
|
||||
|
||||
`SCROLL` and `FIXED_SEGMENT` (and `vegas_mode` `"scroll"` and `"fixed"`)
|
||||
have always behaved identically: both scroll. The distinction is deprecated
|
||||
and goes away in 3.9.0 — see [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
#### Deprecated: `get_supported_vegas_modes()` and `get_vegas_segment_width()`
|
||||
|
||||
Never read by core, and removed in 3.9.0: calling the `BasePlugin`
|
||||
implementation logs a deprecation warning. A plugin's own override keeps
|
||||
working for the plugin itself. `get_vegas_segment_width()` read the
|
||||
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
||||
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
@@ -479,7 +630,7 @@ This is the canonical way to render arbitrary images.
|
||||
|
||||
### Weather Icons (deprecated)
|
||||
|
||||
> Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin
|
||||
> Deprecated, removed in 3.8.0 — draw your own icons (the weather plugin
|
||||
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
|
||||
@@ -581,7 +732,7 @@ Process any deferred updates if not currently scrolling. Called automatically by
|
||||
|
||||
#### `get_scrolling_stats() -> dict`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get current scrolling statistics for debugging.
|
||||
|
||||
@@ -724,7 +875,7 @@ data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
|
||||
|
||||
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
||||
|
||||
> Deprecated, removed in 3.7.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
|
||||
> Deprecated, removed in 3.8.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get background service cached data with sport-specific intervals.
|
||||
|
||||
@@ -763,7 +914,7 @@ max_age = strategy['max_age'] # Get configured max age
|
||||
|
||||
#### `get_sport_live_interval(sport_key: str) -> int`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
|
||||
@@ -789,7 +940,7 @@ Extract data type from cache key to determine appropriate cache strategy.
|
||||
|
||||
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Extract sport key from cache key for sport-specific strategies.
|
||||
|
||||
@@ -839,7 +990,7 @@ for file_info in files:
|
||||
|
||||
#### `get_cache_metrics() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get cache performance metrics.
|
||||
|
||||
@@ -853,7 +1004,7 @@ self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
|
||||
|
||||
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get memory cache statistics.
|
||||
|
||||
@@ -899,7 +1050,7 @@ for plugin_id, plugin in all_plugins.items():
|
||||
|
||||
#### `get_enabled_plugins() -> List[str]`
|
||||
|
||||
> Deprecated, removed in 3.7.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
|
||||
> Deprecated, removed in 3.8.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get list of enabled plugin IDs.
|
||||
|
||||
@@ -1070,10 +1221,13 @@ if weather is not None and weather.enabled:
|
||||
|
||||
## Deprecated APIs
|
||||
|
||||
These still work in 3.6 but log a warning the first time they are called
|
||||
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.7.0**.
|
||||
Nothing in core, the official plugins or the third-party plugins in the
|
||||
registry calls them.
|
||||
These still work but log a warning the first time they are called
|
||||
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.8.0**
|
||||
(first announced for 3.7.0, which shipped with them still in place).
|
||||
[DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan behind that
|
||||
decision: which of these the official plugins, the registry's third-party
|
||||
plugins and core still call or override. Only methods that scan reports unused
|
||||
are removed in 3.8.0; the rest stay until their callers migrate.
|
||||
|
||||
| Object | Methods | Instead |
|
||||
|---|---|---|
|
||||
@@ -1086,3 +1240,17 @@ registry calls them.
|
||||
| `font_manager` | `set_override`, `remove_override`, `get_overrides`, `add_font`, `remove_font`, `validate_font`, `get_size_tokens`, `get_performance_stats`, `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, `unregister_plugin_fonts` | no replacement |
|
||||
| `plugin_manager` | `get_enabled_plugins` | check `enabled` on the entries in `plugin_manager.plugins` |
|
||||
|
||||
### Removed in 3.9.0
|
||||
|
||||
The Vegas APIs that described a fixed-width segment, which Vegas never
|
||||
implemented. Vegas participation (`'scroll'`, `'pause'`, `'exclude'`, see
|
||||
[Vegas scroll hooks](#vegas-scroll-hooks)) replaces them. Calling one of the
|
||||
methods, or setting `vegas_panel_count`, logs a warning once per process.
|
||||
No official plugin calls them; calendar, olympics and blackjack override
|
||||
`get_supported_vegas_modes()`, which keeps working for the plugin itself.
|
||||
|
||||
| What | Instead |
|
||||
|---|---|
|
||||
| `BasePlugin.get_supported_vegas_modes()` | declare `vegas_participation` in the manifest |
|
||||
| `BasePlugin.get_vegas_segment_width()` and the `vegas_panel_count` config key | nothing: a card's width comes from `get_vegas_content()` and `vegas_width_pct` |
|
||||
| `VegasDisplayMode.SCROLL` vs `FIXED_SEGMENT` (`vegas_mode` `"scroll"` vs `"fixed"`) | `'scroll'` participation; the two always behaved the same |
|
||||
|
||||
@@ -33,6 +33,31 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`):
|
||||
- Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which
|
||||
validate the values themselves and ignore a bad one with a log line
|
||||
|
||||
5. **`vegas_participation`** (string enum: `"scroll"`, `"pause"`,
|
||||
`"exclude"`; no default)
|
||||
- Description: how this plugin takes part in Vegas mode — its content
|
||||
scrolls by, the scroll pauses for its turn and shows it full screen, or
|
||||
it is left out
|
||||
- Overrides the plugin's own default (its manifest's
|
||||
`vegas_participation`, else what its legacy Vegas hooks say); unset
|
||||
means "use the plugin's default"
|
||||
- Deliberately has no default: one would be written into every plugin's
|
||||
config and override what each plugin declares
|
||||
- Read by `resolve_vegas_participation()` in
|
||||
`src/plugin_system/base_plugin.py`; see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)
|
||||
|
||||
6. **`vegas_live`** (boolean; no default, unset means on)
|
||||
- Description: for a plugin with live Vegas elements (it implements
|
||||
`get_vegas_elements()`), whether the ticker changes what is already
|
||||
scrolling when the plugin's data changes. `false` shows each card as it
|
||||
was when drawn, as before live elements existed
|
||||
- Ignored by plugins without live elements, and whenever live elements
|
||||
are off for the whole ticker (`display.vegas_scroll.live_refresh`)
|
||||
- Read by `PluginAdapter.is_live_capable()` in
|
||||
`src/vegas_mode/plugin_adapter.py`; see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)
|
||||
|
||||
`skin` and `skin_options` were core properties until the skin system was
|
||||
removed. A plugin config saved with them still loads and saves; the keys are
|
||||
dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`).
|
||||
|
||||
@@ -520,14 +520,14 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
|
||||
`display_manager.image` (a PIL Image) and call `update_display()`;
|
||||
there is no `draw_image()` helper method.
|
||||
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
||||
(deprecated, removed in 3.7.0 — draw your own icons)
|
||||
(deprecated, removed in 3.8.0 — draw your own icons)
|
||||
- `get_text_width()`, `get_font_height()` - Text utilities
|
||||
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
||||
|
||||
**Cache Manager** (`self.cache_manager`):
|
||||
- `get()`, `set()`, `delete()` - Basic caching
|
||||
- `get_cached_data_with_strategy()` - Advanced caching with strategies
|
||||
- `get_background_cached_data()` - deprecated, removed in 3.7.0 — use `get()`
|
||||
- `get_background_cached_data()` - deprecated, removed in 3.8.0 — use `get()`
|
||||
|
||||
**Plugin Manager** (`self.plugin_manager`):
|
||||
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
||||
@@ -535,7 +535,7 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
|
||||
|
||||
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete
|
||||
documentation, and its [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis)
|
||||
table for everything removed in 3.7.0.
|
||||
table for everything removed in 3.8.0.
|
||||
|
||||
## 3rd Party Plugin Development
|
||||
|
||||
|
||||
+314
-29
@@ -18,6 +18,39 @@ top level instead of under `data` (install-from-url, registry-from-url, the
|
||||
auth endpoints, upload endpoints, `system/git-info`, `system/check-update`),
|
||||
the entry below says so.
|
||||
|
||||
**Cross-site requests are refused.** A `POST`, `PUT`, `PATCH` or `DELETE`
|
||||
carrying an `Origin` header (or, without one, a `Referer`) that is not the
|
||||
host the request was sent to gets `403` with `"error_code":
|
||||
"CROSS_SITE_REQUEST"`; so does `Origin: null`. This stops other websites from
|
||||
driving the Pi through a LAN user's browser. Scripts, curl, Home Assistant and
|
||||
the MQTT bridge send neither header and are unaffected. A browser page on
|
||||
another origin (a dashboard you host elsewhere, say) can no longer call the
|
||||
API; call it server-side instead. Behind a reverse proxy, pass the original
|
||||
`Host` through, port included (nginx: `proxy_set_header Host $http_host;`;
|
||||
`$host` drops the port) -- `X-Forwarded-Host` is not read.
|
||||
|
||||
**Authentication (optional, off by default).** With no web password set,
|
||||
nothing below needs credentials. Once one is set (General > Security, or
|
||||
[`POST /auth/password`](#web-login-and-api-tokens)), every route needs a login
|
||||
session or an API token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current
|
||||
```
|
||||
|
||||
Without either, an API route answers `401` with
|
||||
`{"status": "error", "error_code": "AUTH_REQUIRED", "message": ...}`, a
|
||||
`WWW-Authenticate: Bearer realm="LEDMatrix"` header and the login page's URL
|
||||
in `X-LEDMatrix-Login`; an unknown or revoked token gets `"error_code":
|
||||
"INVALID_TOKEN"`. Browser page loads are redirected to `/login` instead, and
|
||||
HTMX requests get `401` with `HX-Redirect: /login?...`. Never asked for
|
||||
credentials: requests from the Pi itself (loopback, with no `X-Forwarded-For`,
|
||||
`X-Real-IP`, `Forwarded` or `X-Forwarded-Host` header), `/static/*`, the
|
||||
captive-portal probe URLs, `/login`, `/api/v3/health` (status only, see
|
||||
[Health Check](#health-check)), and -- only while the Pi is in access-point
|
||||
mode -- `/setup`, `GET /wifi/status`, `GET /wifi/scan` and
|
||||
`POST /wifi/connect`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Configuration](#configuration)
|
||||
@@ -35,6 +68,7 @@ the entry below says so.
|
||||
- [Health and Status](#health-and-status)
|
||||
- [Schedule (dim/power)](#schedule-dimpower)
|
||||
- [Integrations](#integrations)
|
||||
- [Web login and API tokens](#web-login-and-api-tokens)
|
||||
- [Plugin-specific endpoints](#plugin-specific-endpoints)
|
||||
- [Starlark Apps](#starlark-apps)
|
||||
|
||||
@@ -120,10 +154,17 @@ there an unchecked checkbox — which the browser omits — is saved as
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Configuration saved successfully"
|
||||
"message": "Configuration saved successfully",
|
||||
"restart_required": true
|
||||
}
|
||||
```
|
||||
|
||||
`restart_required` is always true here: display hardware, rotation,
|
||||
durations and general settings take effect when the display restarts, and
|
||||
the web UI shows its restart banner on the flag. (Plugin sections saved
|
||||
through this route reach the running plugin live, like
|
||||
`POST /plugins/config`.)
|
||||
|
||||
Invalid values (e.g. an out-of-range `target_fps`, a hardware option the
|
||||
Raspberry Pi 5 driver cannot use) are rejected with `400` and nothing is
|
||||
saved.
|
||||
@@ -213,7 +254,10 @@ times `07:00`-`23:00`. At least one day must be enabled.
|
||||
|
||||
Retrieve `config/config_secrets.json` with every set value replaced by eight
|
||||
bullet characters (`"••••••••"`). Empty values and `YOUR_*` placeholders
|
||||
are returned as-is, so a client can tell "set" from "not set".
|
||||
are returned as-is, so a client can tell "set" from "not set". The
|
||||
`web_auth` section (the web login's password hash, API-token hashes and
|
||||
cookie key) is left out entirely; [Get Main Configuration](#get-main-configuration)
|
||||
leaves it out too.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -238,7 +282,8 @@ Replace `config/config.json` with the JSON body (advanced use only).
|
||||
Save the secrets file (advanced use only). Masked values (`"••••••••"`)
|
||||
and blank strings in the body are dropped, and the rest is merged onto the
|
||||
stored secrets, so posting back the GET response unchanged changes nothing.
|
||||
A secret cannot be cleared by blanking it here.
|
||||
A secret cannot be cleared by blanking it here. A `web_auth` key in the body
|
||||
is ignored; the stored login settings are kept.
|
||||
|
||||
---
|
||||
|
||||
@@ -390,7 +435,7 @@ Request a specific plugin to display on-demand.
|
||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||
- `start_service` (boolean, optional): (Re)start the display service so it picks the request up (default: true)
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -465,21 +510,65 @@ List all installed plugins with their status and metadata.
|
||||
"enabled": true,
|
||||
"verified": true,
|
||||
"loaded": true,
|
||||
"state": "loaded",
|
||||
"state": "enabled",
|
||||
"error_info": null,
|
||||
"loaded_version": "1.2.3",
|
||||
"loaded_at": 1790000000.0,
|
||||
"last_updated": "2025-01-15T10:30:00Z",
|
||||
"last_commit": "abc1234",
|
||||
"last_commit_message": "feat: Add live game updates",
|
||||
"branch": "main",
|
||||
"web_ui_actions": [],
|
||||
"vegas_mode": null,
|
||||
"vegas_content_type": null
|
||||
"vegas_content_type": null,
|
||||
"vegas_participation": "scroll",
|
||||
"vegas_participation_source": "manifest"
|
||||
}
|
||||
]
|
||||
],
|
||||
"runtime": {
|
||||
"status": "live",
|
||||
"published_at": 1790000030.0,
|
||||
"age_seconds": 12.4,
|
||||
"stale_after": 180.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Metadata comes from each plugin's files on disk; `enabled` is the plugin's
|
||||
`enabled` flag in `config.json` (missing means disabled, as the display
|
||||
reads it). `vegas_mode` is the plugin's configured `vegas_mode`, or `null`.
|
||||
|
||||
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` come from
|
||||
the runtime snapshot the display publishes (the web process runs no plugin
|
||||
code). `state` is the display's lifecycle state (`loaded` while loading,
|
||||
`enabled`, `disabled`, `error`, `unloaded`); `error_info` is `null` or
|
||||
`{"type", "message", "at", "recoverable"}`, with the message redacted and at
|
||||
most 200 characters (the full error is at `/errors/*`). `loaded_version` is
|
||||
the version the display loaded, which differs from `version` after an update
|
||||
until the display restarts. A plugin a live snapshot does not list is
|
||||
`loaded: false`, `state: "unloaded"`.
|
||||
|
||||
`runtime.status` says whether to believe them: `live` (fresh snapshot from
|
||||
a running display), `stale` (not refreshed within `stale_after` seconds: the
|
||||
display is hung or died), `stopped` (the display shut down) or `unknown`
|
||||
(nothing published yet). Unless it is `live`, every one of those fields is
|
||||
`null`. Health and metrics are at [`/plugins/health`](#get-plugin-health)
|
||||
and `/plugins/metrics`.
|
||||
|
||||
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
|
||||
`"pause"` or `"exclude"` (see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)),
|
||||
and `vegas_participation_source` says where it came from. The web reads it
|
||||
the way the display resolves it, as far as files can tell: the user's own
|
||||
`vegas_participation` setting (`"config"`), else the manifest's declared
|
||||
`vegas_participation` (`"manifest"`). Past those the display derives it from
|
||||
the plugin's code -- a `get_vegas_participation()` override or the legacy
|
||||
Vegas hooks -- which the web process never runs, so `vegas_participation`
|
||||
is `null` and the source is `"runtime"`. A plugin that overrides
|
||||
`get_vegas_participation()` decides at run time and can differ from its
|
||||
manifest's declaration. `vegas_content_type` is always `null`.
|
||||
|
||||
### Get Plugin Configuration
|
||||
|
||||
**GET** `/api/v3/plugins/config?plugin_id=<plugin_id>`
|
||||
@@ -639,7 +728,19 @@ Install a plugin from the plugin store.
|
||||
```
|
||||
|
||||
When the operation queue is unavailable the install runs synchronously and
|
||||
the response has only a `message`.
|
||||
the response has only a `message` and the restart fields below.
|
||||
|
||||
The finished operation's `result` (from `/plugins/operation/<operation_id>`)
|
||||
carries `restart_required`: true when the plugin is already enabled in
|
||||
`config.json`, because the running display does not load newly installed
|
||||
files by itself; `restart_message` then holds the restart banner's wording.
|
||||
A plugin that is not enabled needs no restart: enabling it loads it.
|
||||
|
||||
A plugin whose registry entry (or downloaded manifest) needs a newer
|
||||
LEDMatrix is refused: the synchronous install answers `409` with a message
|
||||
such as `Failed to install plugin x: X requires LEDMatrix 3.8.0 or newer…`,
|
||||
and a queued one fails with that message. Nothing already installed is
|
||||
changed.
|
||||
|
||||
### Uninstall Plugin
|
||||
|
||||
@@ -664,6 +765,11 @@ Remove an installed plugin.
|
||||
}
|
||||
```
|
||||
|
||||
The finished operation's `result` carries `restart_required`. Removing the
|
||||
plugin's config (the default) lets the display unload it by itself, so it is
|
||||
false; with `preserve_config: true` an enabled plugin keeps running until
|
||||
the display restarts, and it is true.
|
||||
|
||||
### Update Plugin
|
||||
|
||||
**POST** `/api/v3/plugins/update`
|
||||
@@ -684,11 +790,21 @@ Update a plugin to the latest version. Runs synchronously.
|
||||
"message": "Plugin football-scoreboard updated ...",
|
||||
"data": {
|
||||
"last_updated": "2025-01-15T10:30:00Z",
|
||||
"commit": "abc1234..."
|
||||
}
|
||||
"commit": "abc1234...",
|
||||
"update_status": "updated"
|
||||
},
|
||||
"restart_required": true,
|
||||
"restart_message": "Plugin updated — restart the display to run the new version"
|
||||
}
|
||||
```
|
||||
|
||||
`update_status` is `updated`, `up_to_date` or `local_only`.
|
||||
`restart_required` is true when the plugin changed and is enabled: the
|
||||
running display keeps the code it loaded until it restarts.
|
||||
|
||||
An update this core cannot run answers `409` with `Plugin update refused:`
|
||||
and the reason; the installed version is left as it was.
|
||||
|
||||
### Install Plugin from URL
|
||||
|
||||
**POST** `/api/v3/plugins/install-from-url`
|
||||
@@ -717,10 +833,13 @@ Install a plugin directly from a GitHub repository URL. Runs synchronously.
|
||||
"message": "Plugin my-plugin installed successfully",
|
||||
"plugin_id": "my-plugin",
|
||||
"name": "My Plugin",
|
||||
"branch": "main"
|
||||
"branch": "main",
|
||||
"restart_required": false
|
||||
}
|
||||
```
|
||||
|
||||
`restart_required` follows the same rule as `/plugins/install`.
|
||||
|
||||
### Load Registry from URL
|
||||
|
||||
**POST** `/api/v3/plugins/registry-from-url`
|
||||
@@ -892,8 +1011,11 @@ copy, not the display service's in-memory state.
|
||||
|
||||
**GET** `/api/v3/plugins/state`
|
||||
|
||||
Get the state manager's record for every plugin, keyed by plugin id. Pass
|
||||
`?plugin_id=<id>` for one plugin (`data` is then that record).
|
||||
Every plugin that is installed or configured, keyed by plugin id: desired
|
||||
state from `config.json` and the plugins directory, observed state from the
|
||||
display's runtime snapshot. Built per request; there is no state file.
|
||||
Pass `?plugin_id=<id>` for one plugin (`data` is then that record; 404 if
|
||||
it is neither installed nor configured).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -902,23 +1024,41 @@ Get the state manager's record for every plugin, keyed by plugin id. Pass
|
||||
"data": {
|
||||
"football-scoreboard": {
|
||||
"plugin_id": "football-scoreboard",
|
||||
"status": "loaded",
|
||||
"status": "enabled",
|
||||
"installed": true,
|
||||
"in_config": true,
|
||||
"enabled": true,
|
||||
"version": "1.2.3",
|
||||
"loaded": true,
|
||||
"state": "enabled",
|
||||
"error_info": null,
|
||||
"loaded_version": "1.2.3",
|
||||
"loaded_at": 1790000000.0,
|
||||
"installed_at": "2025-01-15T10:30:00",
|
||||
"last_updated": "2025-01-15T10:30:00",
|
||||
"config_version": 1,
|
||||
"metadata": {}
|
||||
"last_updated": "2025-01-15T10:30:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtime": {"status": "live", "published_at": 1790000030.0, "age_seconds": 12.4, "stale_after": 180.0}
|
||||
}
|
||||
```
|
||||
|
||||
`status` is `enabled` / `disabled` for an installed plugin, `unknown` for
|
||||
one that is configured but not installed, and `error` when the display
|
||||
reports its state as `error`. `installed_at` and `last_updated` are the
|
||||
newest successful install, and install or update, in the operation history
|
||||
(`null` when it has none). The runtime fields follow the same rule as
|
||||
[`/plugins/installed`](#get-installed-plugins): `null` unless
|
||||
`runtime.status` is `live`.
|
||||
|
||||
### Reconcile Plugin State
|
||||
|
||||
**POST** `/api/v3/plugins/state/reconcile`
|
||||
|
||||
Reconcile plugin state across config, disk and the state manager.
|
||||
Reconcile desired state (`config.json` plus the plugins on disk) with the
|
||||
display's runtime snapshot. Desired-state gaps are fixed (a plugin on disk
|
||||
with no config section is added disabled); observed-state gaps -- enabled
|
||||
but not loaded, loaded at an older version than is installed -- are
|
||||
reported with `fix_action: "no_action"`.
|
||||
|
||||
**Request Body** (optional):
|
||||
```json
|
||||
@@ -1189,13 +1329,22 @@ searches.
|
||||
"version": "1.2.3",
|
||||
"branch": "main",
|
||||
"default_branch": "main",
|
||||
"plugin_path": "plugins/football-scoreboard"
|
||||
"plugin_path": "plugins/football-scoreboard",
|
||||
"commit": "843588025a81197056f8d96779ccb2be19337ab8",
|
||||
"ledmatrix_min_version": "3.7.0",
|
||||
"aliases": [],
|
||||
"incompatible_reason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`commit` (the monorepo commit that introduced `version`),
|
||||
`ledmatrix_min_version` and `aliases` come from the registry entry and are
|
||||
`null` / `[]` when an older registry lacks them. `incompatible_reason` is the
|
||||
message an install would be refused with on this core, or `null`.
|
||||
|
||||
### Get GitHub Status
|
||||
|
||||
**GET** `/api/v3/plugins/store/github-status`
|
||||
@@ -1336,20 +1485,59 @@ Get LEDMatrix repository version.
|
||||
|
||||
**GET** `/api/v3/system/check-update`
|
||||
|
||||
Whether `origin/main` has commits the checkout lacks. Cached briefly.
|
||||
Fields at the top level (no envelope):
|
||||
Whether newer code is available on this device's update channel. On
|
||||
`stable` that is a newer release tag than the checkout (`target_version`
|
||||
names it); on `beta`, and on `stable` while it waits on a branch for a
|
||||
release that contains the current commit, it is commits on `origin/main`
|
||||
the checkout lacks. A detached checkout newer than the newest release is
|
||||
never offered an update: Update Code leaves it where it is until a release
|
||||
includes it, and `channel_message` says so in the General tab's words.
|
||||
Cached briefly. Fields at the top level (no envelope):
|
||||
|
||||
```json
|
||||
{
|
||||
"update_available": true,
|
||||
"remote_sha": "abc123...",
|
||||
"commits_behind": 3
|
||||
"commits_behind": 3,
|
||||
"target_version": "v3.8.0",
|
||||
"channel": "stable",
|
||||
"configured_channel": "stable",
|
||||
"waiting": false,
|
||||
"newest_release": "v3.8.0",
|
||||
"current_release": null,
|
||||
"channel_message": "Stable: release v3.8.0 is available."
|
||||
}
|
||||
```
|
||||
|
||||
When git cannot run the check, the response also carries
|
||||
`"check_failed": true` and an `error` explaining why.
|
||||
|
||||
### Update Channel
|
||||
|
||||
**GET** `/api/v3/system/update-channel`
|
||||
|
||||
The update channel and what the next Update Code or weekly update would do
|
||||
(in `data`): `configured` (`"stable"`, `"beta"` or `null` for a config from
|
||||
before channels), `channel` (the one in effect), `waiting` (stable, but the
|
||||
device is newer than the newest release, so it follows `main` for now),
|
||||
`action` (`none`, `checkout_tag`, `pull` or `switch_to_beta`),
|
||||
`newest_release`, `current_release`, `branch` (`""` when on a release tag),
|
||||
`message`. Reads local refs; `?fetch=1` fetches from origin first.
|
||||
|
||||
**POST** `/api/v3/system/update-channel`
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "beta"
|
||||
}
|
||||
```
|
||||
|
||||
Saves `auto_update.channel`. The next update applies it; switching to
|
||||
`stable` never installs an older version than the one running, and the
|
||||
`message` says when the device keeps following `main` until a newer release.
|
||||
400 for anything but `stable` or `beta`. The General tab form also accepts
|
||||
`auto_update_channel` on `POST /api/v3/config/main`.
|
||||
|
||||
### Automatic Update Status
|
||||
|
||||
**GET** `/api/v3/system/auto-update`
|
||||
@@ -1375,7 +1563,9 @@ Hide the current automatic-update alert until a new one replaces it.
|
||||
|
||||
Branch, dirty state, recent commits and remote for the Tools tab. Fields at
|
||||
the top level: `branch`, `dirty`, `status`, `recent_commits`, `remote_url`
|
||||
(credentials scrubbed), `upstream`, `can_pull`.
|
||||
(credentials scrubbed), `upstream`, `can_pull`, and for the update channel
|
||||
`detached`, `version` (`git describe`), `current_release` (the release tag
|
||||
HEAD is exactly on, else `null`) and `channel_message` (detached only).
|
||||
|
||||
### Git Branches
|
||||
|
||||
@@ -1388,7 +1578,10 @@ Fetches `origin` and lists branches to switch to: `current`, `upstream`,
|
||||
|
||||
**POST** `/api/v3/system/action`
|
||||
|
||||
Execute system-level actions. JSON or form data.
|
||||
Execute system-level actions. Send JSON (`Content-Type: application/json`).
|
||||
A form-encoded or `text/plain` body is accepted only with an `HX-Request`
|
||||
header (HTMX sends it; a cross-site HTML form cannot) and is otherwise
|
||||
refused with `415`.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
@@ -1949,6 +2142,18 @@ Health of the web interface, display service, config file, plugin system and
|
||||
display snapshot. `data.status` is `healthy` or `degraded`, with
|
||||
`data.services` and `data.checks`.
|
||||
|
||||
`data.checks.display_loop` is the display's render-loop heartbeat: `running`
|
||||
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
|
||||
frozen even if the service is active; the status turns `degraded`), or
|
||||
`not_reported` when the display writes none (not started yet, the dev server,
|
||||
Windows), which does not affect the status.
|
||||
|
||||
Open even when the web login is on, for uptime monitors; a caller that is not
|
||||
logged in (and has no token) then gets only `{"status": "success", "data":
|
||||
{"status": "healthy" | "degraded"}}`. A stalled render loop still shows there
|
||||
as `degraded`; the `checks` detail is only for logged-in callers, tokens and
|
||||
requests from the Pi itself.
|
||||
|
||||
### Hardware Status
|
||||
|
||||
**GET** `/api/v3/hardware/status`
|
||||
@@ -2014,20 +2219,100 @@ enabled.
|
||||
|
||||
Home Assistant MQTT bridge service state and settings: `data.service`,
|
||||
`data.config_exists`, `data.config_path`, `data.config` (password
|
||||
omitted), `data.password_set`, `data.env_override_prefix`.
|
||||
omitted), `data.password_set`, `data.api_token_set`, `data.env_override_prefix`.
|
||||
|
||||
**PUT** `/api/v3/integrations/mqtt-bridge/config`
|
||||
|
||||
Write `integrations/mqtt_bridge/bridge_config.json`. Only the keys you send
|
||||
change. The password is write-only: omit `mqtt_password` to keep it, send a
|
||||
value to replace it, or send `"clear_password": true`. A password with
|
||||
value to replace it, or send `"clear_password": true`. The web-login API token
|
||||
the bridge sends (`ledmatrix_api_token`, needed only when login is on and the
|
||||
bridge runs on another machine) is write-only the same way, cleared with
|
||||
`"clear_api_token": true`. A password with
|
||||
`mqtt_tls` off is refused unless `allow_insecure_mqtt` is true. Returns
|
||||
`data.password_set` and `data.restart_required` (the bridge must be
|
||||
restarted to pick up changes). See
|
||||
`data.password_set`, `data.api_token_set` and `data.restart_required` (the
|
||||
bridge must be restarted to pick up changes). See
|
||||
[integrations/mqtt_bridge/README.md](../integrations/mqtt_bridge/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Web login and API tokens
|
||||
|
||||
The optional password and API tokens (`web_auth` in
|
||||
`config/config_secrets.json`, `web_interface/auth.py`). No route here returns
|
||||
the password hash, a token hash or the cookie key. A request authenticated by
|
||||
an API token gets `403` `TOKEN_NOT_ALLOWED` from every route in this section:
|
||||
tokens are for integrations, not for changing who can log in. Wrong current
|
||||
passwords (`403` `WRONG_PASSWORD`) count against the same per-address limit as
|
||||
the login page: 5 a minute, 30 an hour, then `429`.
|
||||
|
||||
Lost password: run `sudo python3 scripts/reset_web_password.py` on the Pi.
|
||||
|
||||
### Login status
|
||||
|
||||
**GET** `/api/v3/auth/status`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"signed_in": true,
|
||||
"access": "session",
|
||||
"min_password_length": 8,
|
||||
"tokens": [
|
||||
{"id": "3f9c1a2b4d5e6f70", "name": "Home Assistant", "prefix": "lmx_Ab3d",
|
||||
"created_at": "2026-09-29T20:14:03+00:00"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`access` is how this request got in: `open` (login off), `session`,
|
||||
`localhost`, `ap-setup` or `token`.
|
||||
|
||||
### Set or change the password
|
||||
|
||||
**POST** `/api/v3/auth/password`
|
||||
|
||||
Body: `{"new_password": "...", "current_password": "..."}`.
|
||||
`current_password` is required once login is on. At least 8 characters, no
|
||||
leading or trailing space (`400` `WEAK_PASSWORD`). Setting the first password
|
||||
turns login on. Every existing login session ends; the caller's own browser
|
||||
is signed in again with the answer.
|
||||
|
||||
### Turn login off
|
||||
|
||||
**POST** `/api/v3/auth/disable`
|
||||
|
||||
Body: `{"current_password": "..."}`. Removes the password; API tokens are
|
||||
kept (and are needed again if login is turned back on).
|
||||
|
||||
### API tokens
|
||||
|
||||
**GET** `/api/v3/auth/tokens` — `data.tokens`, as in the status answer.
|
||||
|
||||
**POST** `/api/v3/auth/tokens` — body `{"name": "Home Assistant"}` (1-60
|
||||
characters). Answers `201` with `data.token`, the token itself (`lmx_` plus 43
|
||||
characters), and `data.record`. **The token is never shown again**; only its
|
||||
SHA-256 is stored. At most 50 tokens.
|
||||
|
||||
**DELETE** `/api/v3/auth/tokens/<id>` — revoke; it stops working on the next
|
||||
request. `404` for an unknown id.
|
||||
|
||||
Send a token as `Authorization: Bearer <token>`.
|
||||
|
||||
### Login page
|
||||
|
||||
`GET /login` shows the login form (and redirects home when login is off or
|
||||
this browser is already signed in); `POST /login` with a form field `password`
|
||||
(and optional `next`, a path on this server) signs in and redirects to `next`,
|
||||
or answers `401` with the form again. `POST /logout` ends the session. Both
|
||||
are outside `/api/v3` and go through the cross-site check like every other
|
||||
`POST`.
|
||||
|
||||
---
|
||||
|
||||
## Plugin-specific endpoints
|
||||
|
||||
A handful of endpoints belong to individual plugins. The music plugin's
|
||||
|
||||
@@ -342,6 +342,7 @@ service's user.
|
||||
| **wait** | Time blocked in `SwapOnVSync`, i.e. the slack left in each refresh. A p50 near zero means the rig has no headroom and anything extra lands a frame late. |
|
||||
| **work** | Everything else between two frames: drawing, scrolling, and waiting for the GIL. A wide gap between its p50 and p99 is another thread getting in the way. |
|
||||
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
|
||||
| **after work** | Frames presented straight after tagged render-thread work, with their own late rate: `extend` and `compose` (Vegas building its strip), `patch` (live elements, once they land). A kind whose late rate sits well above the overall one is the work making frames late. Shown only when something tagged its work. |
|
||||
|
||||
The refresh rate is estimated from the frames themselves (swaps that block on
|
||||
vsync can only land on refresh boundaries). Cross-check it with
|
||||
@@ -406,6 +407,10 @@ sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) spe
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
|
||||
|
||||
# render-thread strip work, each tagged so the report gives it a late rate:
|
||||
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25 # a live map patch
|
||||
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6 # Vegas extensions
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
```
|
||||
|
||||
@@ -468,6 +473,8 @@ refreshes" comes from.
|
||||
| `duplicate` | frames that advanced no pixels. A crisp fixed-step scroll should show none; any at all means the loop is presenting faster than the strip is moving. |
|
||||
| `blank` | frames with no visible slice to draw: the helper had no content. Should be zero. |
|
||||
| `restarts` | how many times the strip was scrolled through end to end. Informational: the bench restarts the strip where a plugin would hand over to the next one. |
|
||||
| `patches` | `--patch-bytes N --patch-every K`: N bytes of columns written into the strip in place every K frames, on screen or (`--patch-where ahead`) just past it -- what a live element update costs the render thread. Their frames are the `patch` row under *after work*. |
|
||||
| `extensions` | `--extend-every-screens N`: a block appended and the scrolled-past columns trimmed every N screens, as continuous Vegas does. The cost is a copy of the whole strip, so size it like Vegas's with `--strip-screens` (8,000-20,000px). Their frames are the `extend` row. |
|
||||
|
||||
`--json` writes the full report plus the panel geometry, the solved speed and
|
||||
these counters, so two rigs (or one rig before and after a change) can be
|
||||
|
||||
+272
-52
@@ -35,10 +35,11 @@ defaults, or as capabilities they opt into.
|
||||
|
||||
### Reusability — write once, nine plugins benefit
|
||||
|
||||
Only code that is **identical in intent across all nine** moves into the base
|
||||
class. That set is small and knowable — it is exactly the methods present in every
|
||||
copy today (phase B1 below). Everything else stays where it is until it earns
|
||||
promotion.
|
||||
Only code that is **identical across every plugin that carries it** moves into
|
||||
core. Stages 0–3 moved the copies that already were; what is left has drifted,
|
||||
and earns promotion by being reconciled first — made identical in all nine
|
||||
plugins, one method family per release, with every visible difference decided
|
||||
rather than averaged away. See [Roadmap](#roadmap).
|
||||
|
||||
### Modularity — a change to one feature cannot reach a plugin that doesn't use it
|
||||
|
||||
@@ -97,9 +98,10 @@ modules taken from the plugin copies, each a **new module** rather than growth
|
||||
on an existing one: a plugin that deletes a method copy and relies on an older
|
||||
module having gained it fails at runtime with an `AttributeError`, while a
|
||||
missing module fails at load, where the version checks can see it.
|
||||
`sports_helpers.py` is the newest (it holds `_favorite_key`, the override point
|
||||
listed below, for later phases); its parity test compares every body against
|
||||
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
||||
`sports_helpers.py` holds `_favorite_key`, the override point listed below.
|
||||
Each promoted module has a parity test that compares its bodies against the
|
||||
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
|
||||
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
|
||||
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
||||
`rgbmatrix`, `src.display_manager` and `src.plugin_system`. How a plugin adopts a
|
||||
module and drops its copy is documented in the plugins repo's
|
||||
@@ -235,12 +237,249 @@ legacy compatibility rather than the mechanism.
|
||||
> (`display_manager.refresh_hz`), and speed comes from
|
||||
> `scroll_settings.scroll_speed` alone. See `docs/SCROLL_PERFORMANCE.md`.
|
||||
|
||||
## Phases
|
||||
## Roadmap
|
||||
|
||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||
**rollout**, and it splits into three phases with very different risk profiles.
|
||||
The original plan folded the last two together; they are separated here because
|
||||
one of them cannot break a user on an old core and the other can.
|
||||
### Done: stages 0–3
|
||||
|
||||
The second project, after the B phases below: move what the nine `sports.py`
|
||||
copies (and their support files) carried byte-identically into `src/common`,
|
||||
one new module per stage, and delete the copies once the plugins floor on the
|
||||
release that ships it.
|
||||
|
||||
| Stage | Core | Plugins (ledmatrix-plugins) | What moved |
|
||||
|---|---|---|---|
|
||||
| 0 | none needed; found #662 (odds `no_odds` marker) and #663 (a reloaded plugin's dir goes first on `sys.path`) | #562 | Deleted the bundled copies nothing could reach (`base_odds_manager`, `logo_downloader`, three unused data sources, ~4.2k lines); three UFC fixes |
|
||||
| 1 | 3.5.0: `sports_helpers` (#583), `espn_dates`, `json_body` | #563 (1a, the eight team scoreboards), #564 (1b, ufc); floor 3.5.0 | The identical helpers and ESPN date-range handling; ufc also adopted the `sports_shared` mixins |
|
||||
| 2 | 3.6.0: `favorite_team_check`, `sports_timezone`; fixes in 3.6.1 (#667) and 3.6.2 (#670) | #565 (guarded adoption), #567 (f1), #570 (sunset, floor 3.6.1), #571 (soccer, 3.6.2) | The favourite-team check (seven copies) and the timezone resolver (ten); each plugin keeps a thin timezone binding |
|
||||
| 3 | 3.7.0 (#672): `sports_celebration`, `sports_fetch`, `sports_card_wrappers` | #572 (goldens first), #574 (floor 3.7.0, copies deleted) | Celebration drawing (five plugins), four fetch methods (nine), seventeen card delegations (eight renderers) |
|
||||
|
||||
Stage 3 was re-checked independently when this roadmap was written: #574's
|
||||
parent and #574 itself, rendered through the core harness against core 3.7.0,
|
||||
gave pixel-identical output for all 399 frames (192 harness screens across the
|
||||
nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
|
||||
celebration frames), with a parent-vs-parent rerun as the determinism control.
|
||||
|
||||
### Why the method changes
|
||||
|
||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||
`4327c2e` (2026-09-29, after stage 3) with `scripts/sports_drift_report.py`:
|
||||
|
||||
| File | Method families | In all nine | Method lines | Identical copies beyond the first | Drifted families |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| `sports.py` | 94 | 30 | 30,976 | 1,479 lines | 19 |
|
||||
| `manager.py` | 114 | 36 | 27,137 | 3,198 lines | 38 |
|
||||
| `game_renderer.py` (8 plugins) | 89 | — | 6,830 | 546 lines | 11 |
|
||||
|
||||
"Drifted" means in at least seven plugins with at least three different
|
||||
bodies. Everything still identical adds up to about 5,200 duplicated lines;
|
||||
the rest of the ~65,000 method lines is drifted, one outlier away from
|
||||
identical, or unique to one plugin. Drifted code cannot move unchanged, so
|
||||
consolidation stalls unless the copies are made identical first.
|
||||
`manager.py`, the largest copy of all and the layer the display controller and
|
||||
Vegas talk to, was in no plan before this one.
|
||||
|
||||
### The method: reconcile, then promote
|
||||
|
||||
**Owner decision (2026-09-29):** each release, pick one drifted method family,
|
||||
make all nine copies identical, then promote it to core. A *family* here is a
|
||||
set of methods that share state and ship together (the rankings methods, the
|
||||
game-over check); the report measures each method in it. The procedure:
|
||||
|
||||
1. **Measure.** `python scripts/sports_drift_report.py --family sports.py::<name> --diff`
|
||||
lists which plugins share each body and diffs every variant against the
|
||||
most common one. Put the grouping in the PR.
|
||||
2. **Classify every difference**, and say which class in the PR:
|
||||
- *A fix one copy has and the others lack* (a lock, a guard, a correct
|
||||
season year). Port it. It is a behaviour change, so it gets a CHANGELOG
|
||||
line in each plugin.
|
||||
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
|
||||
Make it a declared class constant or override point with a default, as
|
||||
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
|
||||
`_favorite_key` are, and add it to the tables above. Never a sport-name
|
||||
branch: core must not learn sport names.
|
||||
- *A product difference*: anything a user can see (which games show, a
|
||||
colour, a date, a badge, how long a screen stays). The owner picks the
|
||||
behaviour before the code changes; the decision goes in the PR and in a
|
||||
test that pins it (as `test/test_sports_twins.py` pins the twins).
|
||||
- *Noise*: comments, log wording, dead branches. Pick one.
|
||||
3. **Pin the output first.** Before touching the family, its output must be
|
||||
covered: the harness goldens (`test/golden`), the scroll cards
|
||||
(`golden-cards`) and celebrations (`golden-celebration`) for drawing
|
||||
families; for logic families, a table-driven test over the nine plugins'
|
||||
fixture games. Missing coverage lands in its own PR first, as #572 did for
|
||||
stage 3.
|
||||
4. **Reconcile in the plugins** (a monorepo PR). The report must show one
|
||||
variant per class for the family. Render every touched plugin before and
|
||||
after through the harness and diff pixels, not hashes. Every differing
|
||||
frame must match a recorded product decision; any other difference is a
|
||||
bug. Bump each plugin's `version`, add a `versions[]` entry and a CHANGELOG
|
||||
entry, and run `update_registry.py`.
|
||||
5. **Promote in core**: a new `src/common` module per family (a new module, not
|
||||
growth on an old one, for the reason under Converging on `src/common`), a
|
||||
parity test against the plugin copies, and a CHANGELOG module entry naming
|
||||
the release that ships it.
|
||||
6. **Adopt** once that release is out: each plugin floors on it, inherits the
|
||||
mixin, deletes its copy, gains a sunset guard (like the monorepo's
|
||||
`scripts/test_stage3_mixin_copies.py`), and is pixel-diffed again; the
|
||||
expected difference is zero.
|
||||
7. **Re-measure** and update the numbers here.
|
||||
|
||||
A family is only reconciled when *all nine* agree. Leaving one plugin behind
|
||||
recreates the drift the report exists to measure.
|
||||
|
||||
Soaks: pixel diffs prove the drawing, not the timing. A family that changes
|
||||
when data arrives or which games are live (5, 7, 9, 13 and 14 below) needs a
|
||||
live-game soak on a rig, and out-of-season sports wait for their season.
|
||||
Before a soak, check the rig's `*_display_mode`: a board in `switch` mode tells
|
||||
you nothing about the scroll path.
|
||||
|
||||
### Order
|
||||
|
||||
One family per release, in this order. Variant counts are from the report
|
||||
above (per method: distinct bodies across the plugins that carry it, counted
|
||||
per class role). Stage 4 needs no reconciliation and can ride along with any
|
||||
release.
|
||||
|
||||
| # | Family | Methods (variants) | Why here |
|
||||
|---|---|---|---|
|
||||
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) is replaced by core's `font_layout.resolve_asset_path` rather than promoted |
|
||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure |
|
||||
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam |
|
||||
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
|
||||
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
|
||||
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
||||
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
|
||||
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
||||
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
|
||||
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
|
||||
| 14 | `manager.py` host | Nine different bodies in nine plugins: `__init__`, `display`, `update`, `has_live_content`, `get_live_modes`, `get_vegas_content`, `on_config_change`, `_initialize_managers`, `_get_available_modes`, `_get_current_manager`, `_adapt_config_for_manager`. Dynamic duration: `_evaluate_dynamic_cycle_completion` (9), `_record_dynamic_progress` (8), `get_cycle_duration` (8), `get_dynamic_duration_cap` (6), `supports_dynamic_duration`, `is_cycle_complete` (5 each), `reset_cycle_state` (4). Scroll: `_display_scroll_mode` (8), `_ensure_scroll_content_for_vegas` (8), `_collect_games_for_scroll` (7), `_should_use_scroll_mode`, `_has_any_scroll_mode` (6 each) | See below |
|
||||
|
||||
**`manager.py`.** Reconciling it body by body would take a release per
|
||||
method. The plan is a host class in core, `SportsScoreboardPlugin(BasePlugin)`,
|
||||
that takes the plugin's leagues as data (key, label, ESPN path, Live, Recent
|
||||
and Upcoming classes: basketball's `manager.py` already describes its leagues
|
||||
as such a table) and a typed mode key instead of the mode-name string parsing
|
||||
(`endswith('_live')`, `split('_')`) every copy repeats. Order within it:
|
||||
stage 4's identical helpers first; then dynamic duration, then live priority (`has_live_content`,
|
||||
`get_live_modes`, `has_live_priority`), then Vegas content, then mode
|
||||
resolution, then the lifecycle methods. Pilot the whole host on nrl or afl,
|
||||
the smallest copies (about 1,850 lines each), with a frame soak and a
|
||||
live-game soak before a second plugin moves. `get_vegas_content` is also being
|
||||
changed by the scroll-performance work: coordinate before touching it.
|
||||
|
||||
**Held:** `data_sources.py` (nine copies; soccer's matches core's) and
|
||||
`dynamic_team_resolver.py` (eight true forks, a different constructor from
|
||||
core's). Effort on data fetching is better spent on the shared poller that
|
||||
family 9 prepares.
|
||||
|
||||
### Product decisions each family needs
|
||||
|
||||
Owner calls to make before (or while) reconciling. Items marked *verify* are
|
||||
suspected behaviour that needs a payload or a rig to confirm first.
|
||||
|
||||
- **5, game-over check.** Which rule each sport gets: the clock never ends a
|
||||
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
|
||||
0:00 from period 3, basketball, football and lacrosse from period 4.
|
||||
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
|
||||
in baseball (its games carry no `period`), and not triggered by ufc's round
|
||||
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
|
||||
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
|
||||
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
|
||||
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
|
||||
this also closes a ~1 s window at the horn when the ticking clock reads
|
||||
`0:00`), or its own final period.
|
||||
- **6, favourite matching.** NRL keeps matching favourites by team id
|
||||
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
|
||||
own copies of the selection methods. Six plugins log the recent-games
|
||||
selection at INFO; baseball, football and ufc do not.
|
||||
- **7, other-games rotation.** football advances the rotation window under
|
||||
`_games_lock` (update() and display() both advance it; interleaved, a
|
||||
window of games is skipped) and fixes a favourites-only pool that recomposed
|
||||
the list on every frame. Port both. ufc does not attach odds to fights
|
||||
rotated in: decide whether rotated fights show odds.
|
||||
- **8, rankings.** (a) afl, basketball, nrl and soccer turn a *standings*
|
||||
payload into ranks (a pro league's standings position becomes the rank
|
||||
badge); baseball, hockey, lacrosse, ufc and football do not. Which is
|
||||
right is visible on every pro-league card with "show ranking" on.
|
||||
(b) football also keys ranks by team id, so two schools sharing an
|
||||
abbreviation across divisions cannot be confused: adopt for all.
|
||||
(c) football asks for the division roster of the *season* year (July
|
||||
onward is this year's season), which is right for football and wrong for
|
||||
college basketball, hockey and lacrosse, whose ESPN season is the year it
|
||||
ends: a per-sport seam, not football's constant. (d) baseball's
|
||||
`_choose_poll` calls `dynamic_team_resolver.choose_top_division_poll`, the
|
||||
others inline it: one home, in core.
|
||||
- **9, live fetch and odds.** (a) afl, basketball, nrl and soccer cache the
|
||||
live scoreboard for 30 s under `<sport_key>_scoreboard_current`; the others
|
||||
do not (the harness fixtures seed that key, so the change shows up there).
|
||||
(b) basketball fetches college games with no `dates` parameter, citing a
|
||||
404 (*verify* now that `espn_dates` handles ranges). (c) odds are fetched
|
||||
three ways: blocking (hockey, lacrosse), a thread waited on for 1.5–2 s
|
||||
(six plugins), or fire-and-forget for upcoming games (basketball). This
|
||||
sets how long `update()` takes and when an odds line appears. (d) nrl
|
||||
guards on a missing odds manager; port it.
|
||||
- **10, view model.** Per key, whether every sport emits it. Additive only:
|
||||
no key is renamed or removed.
|
||||
- **11 and 12, the scorebug and the card.** The pinned divergences in
|
||||
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
|
||||
same game differently:
|
||||
- weekday timezone: the card reads only `config["timezone"]` and falls back
|
||||
to UTC, so a board with only the global zone labels an evening kickoff
|
||||
with the next day. **Decided 2026-09-24: use the plugin's timezone
|
||||
(fix); not yet implemented;**
|
||||
- an out-of-range start time: the scorebug drops the weekday, the card
|
||||
raises;
|
||||
- favourite result on a nested payload, which score wins when flat and
|
||||
nested disagree, and where the favourites come from (the manager's list
|
||||
vs the game's stamped list plus config);
|
||||
- the element vocabulary (`team_text` vs `team_name`; rank and odds in one
|
||||
map, not the other), and its consequences: a `team_name` colour reaching
|
||||
one team face and not the other, and the odds face shared with the score
|
||||
face in scroll mode only;
|
||||
- per-mode colour overrides, which apply in switch mode only;
|
||||
- by design, kept unless the owner says otherwise: the date format
|
||||
(`switch_date_format` "numeric" vs the card's "abbrev") and the upcoming
|
||||
centre (`switch_upcoming_center` "date_time" vs "vs"), both with an
|
||||
"inherit" opt-in; and the two schema-font caches (per class vs per path).
|
||||
|
||||
Also: football's `_fit_score_font` swaps to the narrow score face at any
|
||||
panel height when the score overflows, where the other seven keep the
|
||||
design face at or below the design height (a 64x32 board shows the
|
||||
difference); and whether switch mode and the card become one renderer drawn
|
||||
at two sizes.
|
||||
- **13, mode lifecycle.** The live-rotation dialect per sport (incremental
|
||||
SWRR in afl, nrl and soccer; a precomputed schedule elsewhere); which sports
|
||||
arm celebrations and on what (stays in each plugin, as in stage 3).
|
||||
- **14, `manager.py`.** Dynamic-duration semantics (what completes a cycle,
|
||||
the floor and cap per mode), what counts as live content for live priority
|
||||
(favourites only or any live game), and the order of Vegas content.
|
||||
|
||||
### Measuring progress
|
||||
|
||||
`scripts/sports_drift_report.py` prints the numbers above for any
|
||||
ledmatrix-plugins checkout (`--plugins <path>` or `LEDMATRIX_PLUGINS`). CI runs
|
||||
it on every push and PR against the monorepo's main (the "Sports drift report"
|
||||
job in `.github/workflows/test.yml`): report only, never failing, with the
|
||||
tables in the job summary and the full JSON as an artifact. The monorepo's
|
||||
`scripts/check_sports_drift.py` is the gate: it fails when a function that
|
||||
agrees across the plugins starts to differ. A stage is done when its family
|
||||
shows one variant per class here and its copies are gone.
|
||||
|
||||
```
|
||||
python scripts/sports_drift_report.py --plugins ../ledmatrix-plugins
|
||||
python scripts/sports_drift_report.py --family sports.py::_is_game_really_over --diff
|
||||
```
|
||||
|
||||
## Phases B0–B6 (history)
|
||||
|
||||
The first project: it moved the scroll orchestration into core and proved the
|
||||
upgrade path (floors, the store's compatibility gate, the sunset). All seven
|
||||
phases are done. They are kept because the reasoning in B4–B6 is what every
|
||||
later stage relies on; the plan from here is [Roadmap](#roadmap).
|
||||
|
||||
B0–B3 shipped in core 3.2.0. The rollout after them split into three phases
|
||||
with very different risk profiles, because one of them cannot break a user on
|
||||
an old core and the other can.
|
||||
|
||||
| Phase | Scope | Status | Gate |
|
||||
|---|---|---|---|
|
||||
@@ -275,8 +514,12 @@ a floor can be trusted against, and today it is not:
|
||||
the update path that re-downloads.
|
||||
`update_plugin`'s git branch pulls in place and re-downloads nothing, so it
|
||||
stayed ungated until `_gate_pulled_commit` closed it — checked after the pull
|
||||
(the registry carries no floor field, so the incoming floor is unknowable
|
||||
before it) and undone with `git reset --hard` to the pre-pull commit. That
|
||||
(the registry then carried no floor field, so the incoming floor was
|
||||
unknowable before it) and undone with `git reset --hard` to the pre-pull
|
||||
commit. The registry now publishes `ledmatrix_min_version`, and install and
|
||||
update refuse on it before downloading or pulling; both post-download gates
|
||||
remain as the fallback for older registries, other branches and
|
||||
`compatible_versions`. That
|
||||
route is rare in practice, since monorepo plugins install as archives; it was
|
||||
closed because the sunset rule in the plugins repo's
|
||||
`08-shared-sports-code.md` states as **condition 3** that the core enforces
|
||||
@@ -439,10 +682,13 @@ deprecated `ledmatrix_min`). See
|
||||
order any floor-raising tool must reproduce — and note the name is **inverted**
|
||||
between the top level and `versions[]`.
|
||||
|
||||
**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and
|
||||
`base_odds_manager.py`. The standing decision held them until B6 closed; it now
|
||||
has, so they can be reconsidered — with B5's lesson applied, which is to build
|
||||
the object and diff rendered output rather than trust a static check.
|
||||
**The modules held back then** (`data_sources.py`, `game_renderer.py`,
|
||||
`base_odds_manager.py`) have since gone different ways: the eight team
|
||||
scoreboards import core's `base_odds_manager` (ufc keeps an MMA fork), the
|
||||
game renderers inherit core's `SportsCardWrappersMixin` (3.7.0) but keep their
|
||||
drawing, and `data_sources.py` is still copied. Their status is under
|
||||
[Roadmap](#roadmap). B5's lesson applies to all of them: build the object and
|
||||
diff rendered output rather than trust a static check.
|
||||
|
||||
### B5 retrospective — what the adoption actually cost
|
||||
|
||||
@@ -485,40 +731,12 @@ and its one delivered user-visible gain was that adopted plugins honoured the
|
||||
global `target_fps` instead of hardcoding ~100 FPS (since withdrawn: see the
|
||||
note under the B3 design above).
|
||||
|
||||
### Decision: stop adopting further modules until B6 closes
|
||||
### Decision: stop adopting further modules until B6 closes (lifted)
|
||||
|
||||
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
|
||||
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
|
||||
carrying cost — a second copy to keep in step — against a payoff that is
|
||||
contingent on B6, and B6 is gated on an installed base we cannot currently
|
||||
measure. Consolidate what is already committed; revisit when B6 does.
|
||||
|
||||
## What's next
|
||||
|
||||
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
|
||||
a version number CI now asserts (#428), the compatibility gate is in
|
||||
`install_plugin` and reads `compatible_versions` as well as the floor
|
||||
(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version`
|
||||
(plugins #244), and all eight plugins have adopted the scroll orchestration
|
||||
(plugins #245–#249, repaired in #251, tidied in #252).
|
||||
|
||||
What actually remains, smallest first:
|
||||
|
||||
1. **Soak the adoptions on hardware.** football and hockey have been run on a
|
||||
live rig through real games; baseball was watched through one earlier. The
|
||||
rest are proven by harness, unit tests and pixel comparison. Out-of-season
|
||||
sports cannot be soaked until their season starts. When you do, **check the
|
||||
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
|
||||
sunset plugin and tell you nothing about the scroll code the sunset changed.
|
||||
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
|
||||
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
|
||||
that landed after 3.2.0, so it is un-installable until the release exists.
|
||||
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||
~36,500 more in the eight `sports.py`. The `src/base_classes/sports/`
|
||||
package promoted in B1/B2 was never imported by a plugin and has been
|
||||
removed, so the plugin copies are the only starting point.
|
||||
Held from B5 until B6 ran on 2026-09-01: each adoption added a second copy to
|
||||
keep in step against a payoff that depended on the sunset. Once the store
|
||||
refused a too-new plugin on every route, adopting and sunsetting in one stage
|
||||
became safe, and stages 0–3 under [Roadmap](#roadmap) did exactly that.
|
||||
|
||||
## How to keep this project healthy
|
||||
|
||||
@@ -545,7 +763,9 @@ Lessons this migration paid for, worth applying beyond it:
|
||||
## Rules for contributors
|
||||
|
||||
- **Promote on evidence, not intuition.** A method moves to core when every copy
|
||||
has it and they agree on intent. Otherwise it stays in the plugins.
|
||||
that has it is identical. Drifted copies are reconciled first, one family
|
||||
per release, with each visible difference an owner decision (see
|
||||
[Roadmap](#roadmap)); until then they stay in the plugins.
|
||||
- **Never add a sport name to core.** If core needs to know which sport it is,
|
||||
the design is wrong — add an override point instead.
|
||||
- **A capability that is not opted into must not execute.** If you find yourself
|
||||
|
||||
@@ -295,6 +295,42 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
---
|
||||
|
||||
#### Issue: Updates and the update channel
|
||||
|
||||
**Symptoms:**
|
||||
- The General tab says "Stable: this device runs code newer than the newest
|
||||
release ... keeps following main"
|
||||
- Tools shows a version such as `v3.8.0` instead of a branch name, or `git
|
||||
status` over SSH says `HEAD detached at v3.8.0`
|
||||
- Update Code says "already up to date" while GitHub's `main` has newer commits
|
||||
|
||||
**Explanation:** these are the Stable update channel working as intended
|
||||
(`auto_update.channel`, General → Update Channel). Stable installs the
|
||||
newest release tag, which git checks out without a branch ("detached
|
||||
HEAD"); that is normal and every update path handles it. Stable never
|
||||
installs an older version than the one running, so a device that is ahead of
|
||||
the newest release keeps following `main` until a release includes its
|
||||
commit, then switches to releases on its own.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Want the newest code instead?** Set Update Channel to **Beta** and click
|
||||
Update Code. The device leaves the release for `main` and pulls it.
|
||||
Or from SSH:
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/v3/system/update-channel \
|
||||
-H 'Content-Type: application/json' -d '{"channel": "beta"}'
|
||||
```
|
||||
2. **See what the next update will do:**
|
||||
```bash
|
||||
curl 'http://localhost:5000/api/v3/system/update-channel?fetch=1'
|
||||
```
|
||||
3. **Local changes after a channel switch:** edits that no longer fit the new
|
||||
version are kept in the git stash rather than lost; `git stash list`
|
||||
shows them as "LEDMatrix autostash before update".
|
||||
|
||||
---
|
||||
|
||||
### WiFi & AP Mode Issues
|
||||
|
||||
#### AP Mode Not Activating
|
||||
@@ -516,6 +552,64 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
python3 scripts/check_plugin.py --plugin plugin-id
|
||||
```
|
||||
|
||||
#### Panel Frozen, or the Display Restarts Every Few Minutes
|
||||
|
||||
**Symptoms:**
|
||||
- The panel stops changing while `systemctl status ledmatrix` says `active`
|
||||
- The display restarts on its own, a couple of minutes after it froze
|
||||
- `/api/v3/health` shows `checks.display_loop.status` as `stalled`
|
||||
|
||||
The display's render loop checks in with systemd every few seconds
|
||||
(`WatchdogSec=120` in `ledmatrix.service`) and writes a heartbeat to
|
||||
`/run/ledmatrix/display-heartbeat.json`. When the loop gets stuck -- almost
|
||||
always inside one plugin's `display()` -- the check-ins stop, and after two
|
||||
minutes systemd kills and restarts the display. The kill dumps every thread's
|
||||
stack into the log, so it says which plugin was stuck.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Find the stuck plugin.** Look for the watchdog kill and the stack dump
|
||||
after it. The render loop is the thread whose stack runs through
|
||||
`display_controller.py` in `run` (usually the `Current thread` block);
|
||||
the first `plugin-repos/...` file in it is the plugin:
|
||||
```bash
|
||||
sudo journalctl -u ledmatrix --since "1 hour ago" | grep -A40 "Watchdog timeout"
|
||||
```
|
||||
|
||||
2. **Check the heartbeat by hand.** Its age should stay under about ten
|
||||
seconds while the display runs:
|
||||
```bash
|
||||
cat /run/ledmatrix/display-heartbeat.json
|
||||
curl -s http://localhost:5000/api/v3/health | python3 -m json.tool | grep -A3 display_loop
|
||||
```
|
||||
`not_reported` means the display writes no heartbeat: it has not drawn
|
||||
its first frame yet, or it runs an older version.
|
||||
|
||||
3. **Disable the plugin** in the web UI and report it to its author with the
|
||||
stack dump. Restarts that repeat back off from 10 seconds to two minutes
|
||||
apart, so a plugin that hangs on every start does not restart the display
|
||||
hundreds of times an hour.
|
||||
|
||||
4. **Is the watchdog installed?** Installs from before it keep their old unit
|
||||
until the installer is re-run (a startup warning says the unit differs
|
||||
from its template):
|
||||
```bash
|
||||
systemctl show -p WatchdogUSec ledmatrix # 2min once running; 0 = not installed
|
||||
sudo ./scripts/install/install_service.sh
|
||||
```
|
||||
`WatchdogUSec` reads `15min` for the first minutes after a start: that is
|
||||
the start-up allowance, narrowed to two minutes once the first frame is on
|
||||
the panel.
|
||||
|
||||
5. **A plugin that legitimately blocks longer** than two minutes (it should
|
||||
not; `display()` runs on the render thread) can be given more time with a
|
||||
drop-in, `sudo systemctl edit ledmatrix`:
|
||||
```ini
|
||||
[Service]
|
||||
WatchdogSec=300
|
||||
```
|
||||
`WatchdogSec=0` turns the watchdog off.
|
||||
|
||||
#### Stale Cache Data
|
||||
|
||||
**Symptoms:**
|
||||
@@ -952,6 +1046,11 @@ git reset --hard HEAD~1
|
||||
# Or rollback to specific commit
|
||||
git reset --hard <commit-hash>
|
||||
|
||||
# On the Stable update channel HEAD is a release tag, not a branch:
|
||||
# go back to an earlier release instead (the next update moves forward again)
|
||||
git tag --list 'v*' --sort=-v:refname | head
|
||||
git checkout --detach v3.7.0
|
||||
|
||||
# Restart all services
|
||||
sudo systemctl restart ledmatrix
|
||||
sudo systemctl restart ledmatrix-web
|
||||
|
||||
@@ -78,7 +78,9 @@ The Overview tab provides at-a-glance information and quick actions:
|
||||
- **Start Display** / **Stop Display** — control the display service
|
||||
- **Restart Display Service** — apply configuration changes
|
||||
- **Restart Web Service** — restart the web UI itself
|
||||
- **Update Code** — `git pull` the latest version (stashes local changes)
|
||||
- **Update Code** — update to the newest version on the update channel (the
|
||||
newest release on Stable, the newest code on `main` on Beta; stashes local
|
||||
changes). The channel is set on the General tab.
|
||||
- **Reboot System** / **Shutdown System** — confirm-gated power controls
|
||||
|
||||
**Display Preview:**
|
||||
@@ -90,6 +92,11 @@ The Overview tab provides at-a-glance information and quick actions:
|
||||
|
||||
Configure basic system settings:
|
||||
|
||||
- **Automatic Updates** — weekly updates with a health check and rollback
|
||||
- **Update Channel** — **Stable** (default) installs releases; **Beta**
|
||||
installs the newest code on `main` before it is released. Switching to
|
||||
Stable never installs an older version: a device ahead of the newest
|
||||
release keeps following `main` until a release includes it
|
||||
- **Timezone** — used by all time/date displays
|
||||
- **Location** — city/state/country for weather and other location-aware
|
||||
plugins
|
||||
@@ -130,6 +137,34 @@ Configure basic system settings:
|
||||
Click **Save** to write changes to `config/config.json`. Most changes
|
||||
require a display service restart from **Overview**.
|
||||
|
||||
Below the settings, the **Security** section (its own buttons, not the Save
|
||||
button) controls the optional login:
|
||||
|
||||
- **Web interface password** — off by default. Setting one turns login on:
|
||||
browsers on your network then see a login page, and stay logged in for 30
|
||||
days (across restarts). The browser you set it from stays logged in.
|
||||
Changing the password logs every other browser out. **Turn login off**
|
||||
needs the current password. A **Log out** button appears in the header
|
||||
while you are logged in. Five wrong passwords in a minute (or 30 in an
|
||||
hour) from one address make it wait.
|
||||
- **API tokens** — for Home Assistant, scripts, or the MQTT bridge on another
|
||||
machine. Give it a name, click **Create token**, and copy the token right
|
||||
away: it is shown once. Revoke it here when it is no longer needed.
|
||||
- Never asked for a password: a browser on the Pi itself, and the Wi-Fi setup
|
||||
page while the Pi is in access-point mode (so you can always get it back on
|
||||
a network).
|
||||
|
||||
**Forgot the password?** SSH into the Pi and run:
|
||||
|
||||
```bash
|
||||
sudo python3 ~/LEDMatrix/scripts/reset_web_password.py
|
||||
```
|
||||
|
||||
(use the folder LEDMatrix is installed in). Login is off again right away,
|
||||
no restart needed, and you can set a new password. API tokens are kept; add
|
||||
`--revoke-tokens` to delete them too. Alternatively, open
|
||||
`http://localhost:5000` in a browser on the Pi itself.
|
||||
|
||||
### Display Tab
|
||||
|
||||
Configure your LED matrix hardware:
|
||||
@@ -346,6 +381,14 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
- `POST /api/v3/plugins/install` — Install a plugin from the store
|
||||
- `POST /api/v3/plugins/install-from-url` — Install a plugin from a GitHub URL
|
||||
|
||||
If the optional login is on, send an API token (General > Security):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer lmx_..." http://your-pi-ip:5000/api/v3/display/current
|
||||
```
|
||||
|
||||
Scripts running on the Pi itself need no token.
|
||||
|
||||
**Note:** See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete API documentation.
|
||||
|
||||
---
|
||||
@@ -408,9 +451,27 @@ The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
## Security Considerations
|
||||
|
||||
**Network Access:**
|
||||
- The interface is accessible to anyone on your local network
|
||||
- No authentication is currently implemented
|
||||
- Recommended for trusted networks only
|
||||
- By default the interface is accessible to anyone on your local network
|
||||
- An optional password (General > Security) makes every page and API call
|
||||
need a login or an API token; see [General Tab](#general-tab). Requests
|
||||
from the Pi itself and the Wi-Fi setup flow in access-point mode stay open,
|
||||
and `/api/v3/health` answers only its overall status without a login
|
||||
- The interface speaks plain HTTP, so the password and tokens cross your
|
||||
network unencrypted: still recommended for trusted networks only
|
||||
- Behind a reverse proxy **on the Pi**, make it send `X-Forwarded-For`
|
||||
(nginx: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`).
|
||||
Without it every proxied request looks like it comes from the Pi itself,
|
||||
which is never asked to log in
|
||||
|
||||
**Other websites:**
|
||||
- A web page you open elsewhere could otherwise make your browser send
|
||||
commands to the Pi (reboot, update, config changes). The interface refuses
|
||||
any change request whose `Origin`/`Referer` header names a different site
|
||||
(403 `CROSS_SITE_REQUEST`), so use the interface from its own address.
|
||||
- Scripts, curl, Home Assistant and the MQTT bridge send no such header and
|
||||
keep working. Behind a reverse proxy, forward the original `Host` header
|
||||
with its port (nginx: `proxy_set_header Host $http_host;` -- `$host`
|
||||
drops the port).
|
||||
|
||||
**Best Practices:**
|
||||
1. Run on a private network (not exposed to internet)
|
||||
@@ -428,7 +489,9 @@ The web interface uses modern web technologies:
|
||||
|
||||
- **Backend:** Flask with Blueprint-based modular design
|
||||
- **Frontend:** HTMX for dynamic content, Alpine.js for reactive components
|
||||
- **Styling:** Tailwind CSS for responsive design
|
||||
- **Styling:** Tailwind CSS utilities, generated at development time and
|
||||
committed (the Pi never builds CSS; see
|
||||
[`web_interface/README.md`](../web_interface/README.md#styling-tailwind-css))
|
||||
- **Real-Time:** Server-Sent Events (SSE) for live updates
|
||||
|
||||
### File Locations
|
||||
|
||||
@@ -835,6 +835,10 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config.json" ]; then
|
||||
cat > "$PROJECT_ROOT_DIR/config/config.json" <<'EOF'
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"auto_update": {
|
||||
"enabled": false,
|
||||
"channel": "stable"
|
||||
},
|
||||
"timezone": "America/Chicago",
|
||||
"display": {
|
||||
"hardware": {
|
||||
|
||||
@@ -96,6 +96,13 @@ environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
|
||||
which keeps a broker password out of a file on disk — put it in a systemd
|
||||
drop-in with `Environment=` or `EnvironmentFile=` instead.
|
||||
|
||||
**Web login.** If the web interface's optional login is on (General >
|
||||
Security), a bridge running on the Pi itself still needs nothing: requests from
|
||||
the Pi are never asked to log in. A bridge on another machine needs an API
|
||||
token: create one under General > Security and set `"ledmatrix_api_token"`
|
||||
(or `LEDMATRIX_MQTT_LEDMATRIX_API_TOKEN`, or the token field in the Tools tab's
|
||||
bridge settings). It is sent as `Authorization: Bearer <token>`.
|
||||
|
||||
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips
|
||||
certificate verification and exists only for a self-signed broker on a
|
||||
trusted LAN; it logs a warning when used.
|
||||
|
||||
@@ -66,6 +66,10 @@ DEFAULTS = {
|
||||
"mqtt_tls": False,
|
||||
"mqtt_tls_insecure": False,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
# Only needed when the web interface's optional login is on AND the bridge
|
||||
# reaches it from another machine: requests from the Pi itself never need
|
||||
# one. Create it under General > Security; sent as a Bearer token.
|
||||
"ledmatrix_api_token": None,
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": None,
|
||||
"log_level": "INFO",
|
||||
@@ -125,10 +129,13 @@ class LEDMatrixClient:
|
||||
"""
|
||||
|
||||
def __init__(self, api_base: str, timeout: int = 15,
|
||||
session: Optional[requests.Session] = None):
|
||||
session: Optional[requests.Session] = None,
|
||||
api_token: Optional[str] = None):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.session = session or requests.Session()
|
||||
if api_token:
|
||||
self.session.headers["Authorization"] = f"Bearer {api_token}"
|
||||
|
||||
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||
url = f"{self.api_base}/api/v3{path}"
|
||||
@@ -403,7 +410,8 @@ class Bridge:
|
||||
self.status_topic = f"{self.command_topic}/status"
|
||||
self.state_topic = f"{self.command_topic}/state"
|
||||
self.availability_topic = f"{self.command_topic}/availability"
|
||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
|
||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"],
|
||||
api_token=config.get("ledmatrix_api_token") or None)
|
||||
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
|
||||
self._stop = threading.Event()
|
||||
self._mqtt = None
|
||||
|
||||
@@ -37,6 +37,7 @@ src/common/sports_celebration.py
|
||||
src/common/sports_fetch.py
|
||||
src/common/sports_scroll.py
|
||||
src/common/sports_timezone.py
|
||||
src/common/sports_vegas.py
|
||||
src/config_service.py
|
||||
src/core_config_keys.py
|
||||
src/deprecation.py
|
||||
@@ -54,10 +55,12 @@ src/plugin_system/compatibility.py
|
||||
src/plugin_system/operation_history.py
|
||||
src/plugin_system/operation_queue.py
|
||||
src/plugin_system/operation_types.py
|
||||
src/plugin_system/plugin_catalog.py
|
||||
src/plugin_system/plugin_dirs.py
|
||||
src/plugin_system/plugin_executor.py
|
||||
src/plugin_system/plugin_health.py
|
||||
src/plugin_system/plugin_loader.py
|
||||
src/plugin_system/plugin_runtime.py
|
||||
src/plugin_system/plugin_state.py
|
||||
src/plugin_system/repo_urls.py
|
||||
src/plugin_system/resource_monitor.py
|
||||
@@ -70,13 +73,17 @@ src/plugin_system/testing/loading.py
|
||||
src/plugin_system/testing/mocks.py
|
||||
src/plugin_system/testing/plugin_test_base.py
|
||||
src/plugin_system/testing/sizes.py
|
||||
src/plugin_system/testing/vegas.py
|
||||
src/plugin_system/vegas_elements.py
|
||||
src/redaction.py
|
||||
src/scan_order.py
|
||||
src/startup_validator.py
|
||||
src/vegas_mode/__init__.py
|
||||
src/vegas_mode/config.py
|
||||
src/vegas_mode/coordinator.py
|
||||
src/vegas_mode/elements.py
|
||||
src/vegas_mode/geometry.py
|
||||
src/vegas_mode/live_worker.py
|
||||
src/vegas_mode/stream_manager.py
|
||||
src/web_interface/api_helpers.py
|
||||
src/web_interface/config_arrays.py
|
||||
|
||||
@@ -14,6 +14,14 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_dir not in sys.path:
|
||||
sys.path.insert(0, project_dir)
|
||||
|
||||
# Under systemd the watchdog clock is already running, and start-up (plugin
|
||||
# loads, initial updates) takes far longer than the render loop's limit. Widen
|
||||
# it before anything slow is imported; the render loop narrows it again once
|
||||
# its first frame is on the panel. A no-op outside systemd. Standard library
|
||||
# only -- see src/display_watchdog.py.
|
||||
from src import display_watchdog
|
||||
display_watchdog.watchdog.begin_startup()
|
||||
|
||||
# Parse command-line arguments BEFORE any imports
|
||||
parser = argparse.ArgumentParser(description='LEDMatrix Display Controller')
|
||||
parser.add_argument('-e', '--emulator', action='store_true',
|
||||
|
||||
@@ -149,6 +149,11 @@
|
||||
},
|
||||
"description": "Array of display mode names this plugin provides"
|
||||
},
|
||||
"vegas_participation": {
|
||||
"type": "string",
|
||||
"enum": ["scroll", "pause", "exclude"],
|
||||
"description": "How this plugin takes part in Vegas mode by default: 'scroll' (its content scrolls by), 'pause' (the scroll stops for its turn and display() draws it full screen) or 'exclude' (left out). A user's per-plugin vegas_participation setting overrides it. Omit it to derive the participation from get_vegas_display_mode() / get_vegas_content_type(). Cores before 3.8.0 ignore it."
|
||||
},
|
||||
"api_requirements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -34,11 +34,14 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
|
||||
| `frame_soak.py` | diagnostic | Soaks a running display and reports how often frames reached the panel late (docs/SCROLL_PERFORMANCE.md) |
|
||||
| `install_dependencies_apt.py` | keep | Dependency installer that tries apt packages first, then pip (installer Step 7, plugin loader) |
|
||||
| `install_plugin_dependencies.sh` | diagnostic | Installs plugin requirements by hand when the automatic install fails |
|
||||
| `plugin_api_usage.py` | dev-only | Scans core, the plugin monorepo and the registry's third-party plugins for callers of every `@deprecated` core method; its output is [docs/DEPRECATIONS_3.8.md](../docs/DEPRECATIONS_3.8.md) |
|
||||
| `prove_security.py` | keep | Security property checks run by pre-commit |
|
||||
| `render_bench.py` | diagnostic | Benchmarks the render loop against the panel's real refresh rate on a synthetic strip |
|
||||
| `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
|
||||
| `reset_web_password.py` | keep | Turns the optional web login off when the password is lost (`sudo python3 scripts/reset_web_password.py`; docs/WEB_INTERFACE_GUIDE.md) |
|
||||
| `run_plugin_tests.py` | dev-only | Discovers and runs plugin test suites |
|
||||
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
|
||||
| `sports_drift_report.py` | keep | Counts the different bodies of each method across the nine scoreboards in a `ledmatrix-plugins` checkout (report-only CI job; docs/SPORTS_UNIFICATION.md) |
|
||||
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
|
||||
| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo |
|
||||
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the web UI's Tailwind CSS with the pinned standalone Tailwind CLI.
|
||||
|
||||
The generated files are committed, so the Pi never builds anything. Run this
|
||||
on a dev machine (or let CI run it) after changing a template, a static JS
|
||||
file, or anything under ``web_interface/tailwind/``:
|
||||
|
||||
python3 scripts/build_css.py # rebuild the committed CSS
|
||||
python3 scripts/build_css.py --check # exit 1 if the committed CSS is stale
|
||||
|
||||
No Node or npm: the script downloads Tailwind's standalone CLI (a single
|
||||
executable) for this OS and CPU from the Tailwind GitHub release, checks it
|
||||
against the SHA-256 pinned below, and caches it outside the repo
|
||||
(``$LEDMATRIX_TAILWIND_CACHE``, else the per-user cache directory).
|
||||
|
||||
Outputs (see ``BUILDS``):
|
||||
|
||||
- ``web_interface/static/v3/tailwind.css``: the utilities the templates and
|
||||
static JS use. Linked before ``app.css`` in ``base.html``.
|
||||
- ``web_interface/static/v3/plugin-frame.css``: preflight plus a broad set of
|
||||
common utilities, for plugin ``web_ui/`` fragments served in an iframe.
|
||||
Their markup lives in plugin repos, so it can't be scanned; the safelist in
|
||||
``plugin-frame.config.js`` stands in for it.
|
||||
|
||||
To move to a new Tailwind v3 release, change ``TAILWIND_VERSION`` and every
|
||||
hash in ``TAILWIND_ASSETS`` (the release's ``sha256sums.txt``, or the digests
|
||||
from ``gh api repos/tailwindlabs/tailwindcss/releases/tags/<tag>``), rebuild,
|
||||
and review the diff of the generated CSS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
TAILWIND_DIR = PROJECT_ROOT / "web_interface" / "tailwind"
|
||||
STATIC_V3 = PROJECT_ROOT / "web_interface" / "static" / "v3"
|
||||
|
||||
TAILWIND_VERSION = "3.4.19"
|
||||
|
||||
# asset name -> SHA-256, from the v3.4.19 release.
|
||||
TAILWIND_ASSETS = {
|
||||
"tailwindcss-linux-arm64": "e5b2d27694daa80cc52ec29553ba2c6bd43d86bd51a9d633ed24058b9c05a676",
|
||||
"tailwindcss-linux-armv7": "e3610b109a64720295e1c00a18dd2d6d79d3cddc618219aa0830de97a55429a4",
|
||||
"tailwindcss-linux-x64": "4af3198c015616ea7d6617974ec3d70d987ecc00c1ca8463b0a30fd65cc7c06e",
|
||||
"tailwindcss-macos-arm64": "7fdeb00818b6214a337383063282b2361ecb08bbc08f8c8a7ba97ee1e2eaa4fe",
|
||||
"tailwindcss-macos-x64": "a597f407e0f1f03535731f5b42f1576a8152cb5fffc2f38e754722bc0c280045",
|
||||
"tailwindcss-windows-arm64.exe": "f2b6b999747aa0ae31999d59db117b1ba1e4e15e17675d7108e30aac4b680686",
|
||||
"tailwindcss-windows-x64.exe": "a15158c4c5e0e7a75f7229bfe4986fe7710d2edc468b6f96c8981f78ab211347",
|
||||
}
|
||||
|
||||
DOWNLOAD_URL = (
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/download/v{version}/{asset}"
|
||||
)
|
||||
|
||||
# (input CSS, config, output) -- all relative to the project root.
|
||||
BUILDS = (
|
||||
(
|
||||
"web_interface/tailwind/app.input.css",
|
||||
"web_interface/tailwind/tailwind.config.js",
|
||||
"web_interface/static/v3/tailwind.css",
|
||||
),
|
||||
(
|
||||
"web_interface/tailwind/plugin-frame.input.css",
|
||||
"web_interface/tailwind/plugin-frame.config.js",
|
||||
"web_interface/static/v3/plugin-frame.css",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def asset_name() -> str:
|
||||
"""The release asset for this OS and CPU."""
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
if machine in ("x86_64", "amd64"):
|
||||
arch = "x64"
|
||||
elif machine in ("aarch64", "arm64"):
|
||||
arch = "arm64"
|
||||
elif machine.startswith("armv7") or machine == "armv8l":
|
||||
arch = "armv7"
|
||||
else:
|
||||
raise SystemExit(f"No standalone Tailwind CLI for CPU {machine!r}.")
|
||||
|
||||
if system == "Linux":
|
||||
name = f"tailwindcss-linux-{arch}"
|
||||
elif system == "Darwin":
|
||||
name = f"tailwindcss-macos-{arch}"
|
||||
elif system == "Windows":
|
||||
name = f"tailwindcss-windows-{arch}.exe"
|
||||
else:
|
||||
raise SystemExit(f"No standalone Tailwind CLI for {system!r}.")
|
||||
if name not in TAILWIND_ASSETS:
|
||||
raise SystemExit(f"No standalone Tailwind CLI for {system} {machine}.")
|
||||
return name
|
||||
|
||||
|
||||
def cache_dir() -> Path:
|
||||
override = os.environ.get("LEDMATRIX_TAILWIND_CACHE")
|
||||
if override:
|
||||
return Path(override)
|
||||
if platform.system() == "Windows":
|
||||
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||
elif platform.system() == "Darwin":
|
||||
base = Path.home() / "Library" / "Caches"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
||||
return base / "ledmatrix" / "tailwindcss"
|
||||
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def ensure_cli() -> Path:
|
||||
"""Path to the verified CLI, downloading it on first use."""
|
||||
name = asset_name()
|
||||
expected = TAILWIND_ASSETS[name]
|
||||
target = cache_dir() / f"v{TAILWIND_VERSION}" / name
|
||||
|
||||
if target.is_file():
|
||||
if sha256_of(target) == expected:
|
||||
return target
|
||||
print(f"Cached {target} fails its SHA-256 check; downloading it again.")
|
||||
target.unlink()
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
url = DOWNLOAD_URL.format(version=TAILWIND_VERSION, asset=name)
|
||||
if not url.startswith("https://"):
|
||||
raise SystemExit(f"Refusing to download the Tailwind CLI over a non-https URL: {url}")
|
||||
print(f"Downloading Tailwind CLI v{TAILWIND_VERSION} ({name})...")
|
||||
fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=".download-")
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, urllib.request.urlopen(url, timeout=120) as resp: # nosec B310 - https only, checked above
|
||||
shutil.copyfileobj(resp, out)
|
||||
actual = sha256_of(tmp)
|
||||
if actual != expected:
|
||||
raise SystemExit(
|
||||
f"SHA-256 mismatch for {url}\n expected {expected}\n got {actual}"
|
||||
)
|
||||
tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
os.replace(tmp, target)
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
return target
|
||||
|
||||
|
||||
def run_build(
|
||||
cli: Path, input_css: str, config: str, output: Path, work_dir: Path
|
||||
) -> None:
|
||||
# The minifier's rule merging depends on the input file's line endings,
|
||||
# so a Windows checkout (core.autocrlf, CRLF) would build different bytes
|
||||
# than CI's Linux one and --check would fail. Feed the CLI an LF copy.
|
||||
# (Content files' line endings don't matter; @import isn't used, so the
|
||||
# copy's location doesn't either.)
|
||||
lf_input = work_dir / (Path(input_css).name)
|
||||
lf_input.write_bytes(
|
||||
(PROJECT_ROOT / input_css).read_bytes().replace(b"\r\n", b"\n")
|
||||
)
|
||||
cmd = [
|
||||
str(cli),
|
||||
"--input", str(lf_input),
|
||||
"--config", str(PROJECT_ROOT / config),
|
||||
"--output", str(output),
|
||||
"--minify",
|
||||
]
|
||||
# NODE_ENV=production and no browserslist lookup keep the output the
|
||||
# same on every machine.
|
||||
env = dict(os.environ, NODE_ENV="production", BROWSERSLIST_IGNORE_OLD_DATA="1")
|
||||
# The CLI path is computed here (cache dir + pinned asset name) and the
|
||||
# binary was SHA-256-verified by ensure_cli(); env is os.environ plus two
|
||||
# fixed values.
|
||||
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env, capture_output=True, text=True) # nosec B603 - list-form argv, no shell # nosemgrep
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout + result.stderr)
|
||||
raise SystemExit(f"Tailwind build failed for {input_css}")
|
||||
# The CLI writes without a trailing newline; add one so the committed
|
||||
# file is a well-formed text file and editors leave it alone.
|
||||
text = output.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
if not text.endswith("\n"):
|
||||
text += "\n"
|
||||
output.write_bytes(text.encode("utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="build to a temp dir and fail if the committed CSS differs",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
cli = ensure_cli()
|
||||
stale = []
|
||||
with tempfile.TemporaryDirectory(prefix="ledmatrix-css-") as tmp:
|
||||
for input_css, config, output in BUILDS:
|
||||
committed = PROJECT_ROOT / output
|
||||
built = Path(tmp) / Path(output).name if args.check else committed
|
||||
run_build(cli, input_css, config, built, Path(tmp))
|
||||
if args.check:
|
||||
old = (
|
||||
committed.read_bytes().replace(b"\r\n", b"\n")
|
||||
if committed.is_file()
|
||||
else None
|
||||
)
|
||||
if old != built.read_bytes():
|
||||
stale.append(output)
|
||||
else:
|
||||
print(f"Wrote {output} ({committed.stat().st_size:,} bytes)")
|
||||
|
||||
if stale:
|
||||
print(
|
||||
"The committed CSS is out of date: " + ", ".join(stale) + "\n"
|
||||
"Run `python3 scripts/build_css.py` and commit the result."
|
||||
)
|
||||
return 1
|
||||
if args.check:
|
||||
print("Committed CSS is up to date.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -72,6 +72,7 @@ from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
)
|
||||
from src.plugin_system.testing.vegas import check_plugin_vegas_elements # noqa: E402
|
||||
|
||||
logger = get_logger("[Check Plugin]")
|
||||
|
||||
@@ -193,6 +194,24 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
|
||||
all_run_results.extend(results)
|
||||
|
||||
# Live Vegas elements, for a plugin that has them: checked once, at the
|
||||
# first size, with the base config.
|
||||
width, height = effective_sizes[0]
|
||||
try:
|
||||
vegas = check_plugin_vegas_elements(
|
||||
plugin_id, plugin_dir, full_config, effective_mock_data, width, height,
|
||||
run_update=effective_run_update)
|
||||
except Exception as exc: # noqa: BLE001 - one plugin must not end an --all run
|
||||
all_run_results.append(RenderResult(
|
||||
plugin_id, width, height, "vegas elements",
|
||||
error=f"the element check itself failed: {exc!r}"))
|
||||
return all_run_results
|
||||
if vegas.implemented:
|
||||
all_run_results.append(RenderResult(
|
||||
plugin_id, width, height, "vegas elements",
|
||||
error="; ".join(vegas.errors) or None,
|
||||
notes=[f"{vegas.elements} element(s), {vegas.live} live"] + vegas.warnings))
|
||||
|
||||
return all_run_results
|
||||
|
||||
|
||||
@@ -236,6 +255,8 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
if r.notes:
|
||||
detail += f" ({'; '.join(r.notes)})"
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
print()
|
||||
return everything_ok
|
||||
|
||||
+54
-3
@@ -200,12 +200,21 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
#: /api/render "vegas" values: the plugin's block of the Vegas strip, built
|
||||
#: from its live elements (falling back to its Vegas content, as the ticker
|
||||
#: does) or from its ordinary Vegas content only.
|
||||
VEGAS_VIEWS = ('live', 'plain')
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
skip_update, vegas=None):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||
never share state.
|
||||
never share state. With ``vegas`` set ('live' or 'plain') the image is the
|
||||
plugin's block of the Vegas strip instead of its display(), laid out by
|
||||
the ticker's own code (src/plugin_system/testing/vegas.py), and the
|
||||
response lists where each live element sits in it.
|
||||
"""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
@@ -243,6 +252,10 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
if vegas:
|
||||
return _vegas_response(plugin_id, plugin_instance, display_manager, vegas,
|
||||
start_time, errors, warnings)
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
@@ -262,6 +275,40 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
}
|
||||
|
||||
|
||||
def _vegas_response(plugin_id, plugin_instance, display_manager, vegas, start_time,
|
||||
errors, warnings):
|
||||
"""The /api/render response for the Vegas strip view."""
|
||||
import base64
|
||||
import io
|
||||
|
||||
from src.plugin_system.testing.vegas import render_vegas_strip
|
||||
|
||||
block, layout = None, []
|
||||
try:
|
||||
block, layout = render_vegas_strip(plugin_instance, plugin_id, display_manager,
|
||||
live=(vegas == 'live'))
|
||||
except Exception as e:
|
||||
logger.warning("Vegas render raised for plugin %s", plugin_id, exc_info=True)
|
||||
errors.append(f"Vegas render raised: {type(e).__name__} — see server log")
|
||||
if block is None:
|
||||
if not errors:
|
||||
errors.append("The plugin has no Vegas content")
|
||||
block = display_manager.image
|
||||
elif vegas == 'live' and not layout:
|
||||
warnings.append("No live elements: this is the plugin's ordinary Vegas content")
|
||||
buffer = io.BytesIO()
|
||||
block.convert('RGB').save(buffer, format='PNG')
|
||||
return {
|
||||
'image': 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode('ascii'),
|
||||
'width': block.width,
|
||||
'height': block.height,
|
||||
'render_time_ms': round((time.time() - start_time) * 1000, 1),
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'live_elements': [{'key': key, 'x': x, 'width': width} for x, key, width in layout],
|
||||
}
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
@@ -333,6 +380,10 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
vegas = data.get('vegas') or None
|
||||
if vegas is not None and vegas not in VEGAS_VIEWS:
|
||||
return jsonify({'error': f'vegas must be one of {", ".join(VEGAS_VIEWS)}'}), 400
|
||||
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
@@ -345,7 +396,7 @@ def api_render():
|
||||
|
||||
try:
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
mock_data, width, height, skip_update, vegas=vegas)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
|
||||
@@ -35,6 +35,9 @@ blit copying the frame into the matrix canvas (rgbmatrix SetImage).
|
||||
wait blocked in SwapOnVSync, i.e. slack before the refresh.
|
||||
work everything else between two frames: drawing, scrolling, and
|
||||
waiting for the GIL.
|
||||
after work frames presented straight after tagged render-thread work
|
||||
(Vegas strip extensions, live-element patches), with their own
|
||||
late rate. Shown only when something tagged its work.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -126,6 +129,33 @@ def _edge(index: int, bucket_ms: float):
|
||||
return round((index + 1) * bucket_ms, 2)
|
||||
|
||||
|
||||
def op_rows(totals: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Per kind of noted render-thread work: how often its frame was late.
|
||||
|
||||
A kind's frames are the ones presented straight after that work ran (see
|
||||
"Operations" in src/common/frame_timing.py). Stats from a recorder that
|
||||
predates the counters have none, and give an empty table.
|
||||
"""
|
||||
frames = totals.get("op_frames") or {}
|
||||
late = totals.get("late_op_frames") or {}
|
||||
freezes = totals.get("op_freezes") or {}
|
||||
moved = totals.get("op_bytes") or {}
|
||||
rows = {}
|
||||
for kind in sorted(set(frames) | set(freezes)):
|
||||
count = frames.get(kind, 0)
|
||||
if not count and not freezes.get(kind, 0):
|
||||
continue
|
||||
rows[kind] = {
|
||||
"frames": count,
|
||||
"late": late.get(kind, 0),
|
||||
"late_pct": (round(100.0 * late.get(kind, 0) / count, 3)
|
||||
if count else None),
|
||||
"freezes": freezes.get(kind, 0),
|
||||
"bytes": moved.get(kind, 0),
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||
delta = diff(before, after)
|
||||
totals = delta["totals"]
|
||||
@@ -159,6 +189,7 @@ def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||
if totals["worst_interval_ms"] else None),
|
||||
"timing_ms": {name: percentiles(h, bucket_ms)
|
||||
for name, h in delta["histograms"].items()},
|
||||
"ops": op_rows(totals),
|
||||
}
|
||||
# The rate the panel held while rendering: the typical frame's interval
|
||||
# per refresh held. A few percent under the idle rate is normal (the Pi is
|
||||
@@ -216,6 +247,17 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
|
||||
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
|
||||
for k in ("p50", "p95", "p99", "max")))
|
||||
print()
|
||||
ops = report.get("ops") or {}
|
||||
if ops:
|
||||
# Frames presented straight after render-thread work of each kind. A
|
||||
# late rate well above the overall one points at that work.
|
||||
print(f"{'after work':<18}{'frames':>8}{'late':>8}{'late %':>8}"
|
||||
f"{'freezes':>9}{'MB moved':>10}")
|
||||
for kind, row in ops.items():
|
||||
pct = "-" if row["late_pct"] is None else f"{row['late_pct']:g}"
|
||||
print(f"{kind:<18}{row['frames']:>8}{row['late']:>8}{pct:>8}"
|
||||
f"{row['freezes']:>9}{row['bytes'] / 1e6:>10.2f}")
|
||||
print()
|
||||
if report["late_pct"] is None:
|
||||
print("RESULT nothing scrolled - no verdict")
|
||||
elif not locked(report, limit):
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Who still calls or overrides the core methods marked ``@deprecated``?
|
||||
|
||||
A deprecated plugin-facing method may only be removed once nothing uses it,
|
||||
and plugins live in other repositories. This script answers the question for
|
||||
every method ``src/deprecation.py``'s decorator marks in core:
|
||||
|
||||
1. it lists the markers by parsing ``src/`` (so the list can never drift from
|
||||
the code);
|
||||
2. it scans, with the ``ast`` module, core itself (``src/``,
|
||||
``web_interface/``, ``scripts/``, the top-level ``*.py``; ``test/``
|
||||
separately), the official monorepo's ``plugins/`` directory, and every
|
||||
third-party plugin the monorepo's ``plugins.json`` lists with its own repo
|
||||
URL (shallow-cloned read-only into a cache directory);
|
||||
3. it reports, per method and per plugin, the calls and overrides it found,
|
||||
and a verdict: unused (safe to remove in the marker's release), still used
|
||||
(keep or migrate those plugins first), or needs review.
|
||||
|
||||
Matching is by method name, so it has to separate real uses from unrelated
|
||||
methods that happen to share the name (the weather plugin's own ``draw_sun``,
|
||||
say). Each hit is classified by what it is attached to:
|
||||
|
||||
* **call** -- ``<receiver>.name`` where the receiver is named like the owning
|
||||
object (``self.cache_manager``, ``display_manager``, ``plugin_manager`` ...,
|
||||
or a local alias assigned from one), or ``self``/``super()`` inside a class
|
||||
that subclasses the owner. Attribute references that are not called
|
||||
(``callback=cm.get_cache_metrics``) count too.
|
||||
* **override** -- ``def name`` in a class that subclasses the owner.
|
||||
* **review** -- ``<receiver>.name`` where the receiver says nothing about its
|
||||
type, or ``getattr(obj, "name")``. Possibly a real use; read the listed line.
|
||||
* **unrelated** -- ``self.name`` inside a class that defines ``name`` itself
|
||||
and does not subclass the owner, ``Klass.name`` where the same tree defines
|
||||
``Klass.name``, or ``def name`` in such a class: a name collision, not a use.
|
||||
* **internal** -- a hit inside the body of another deprecated core method
|
||||
(``draw_rain`` calling ``draw_cloud``): it keeps the method only as long as
|
||||
that caller is kept.
|
||||
|
||||
Only calls and overrides make a method "still used"; review hits make it
|
||||
"needs review"; hits in test files are listed but never block removal (a test
|
||||
that mocks a method does not need it to exist).
|
||||
|
||||
python3 scripts/plugin_api_usage.py # clone everything, print Markdown
|
||||
python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins
|
||||
python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md
|
||||
python3 scripts/plugin_api_usage.py --format json
|
||||
|
||||
Nothing is ever written to the repositories it scans: the monorepo path is only
|
||||
read, and clones live in ``--cache-dir``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Iterator, List, Optional, Set, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MONOREPO_URL = "https://github.com/ChuckBuilds/ledmatrix-plugins"
|
||||
MONOREPO_SLUG = "chuckbuilds/ledmatrix-plugins"
|
||||
|
||||
#: Receiver names that mean "this is the owning core object". Compared against
|
||||
#: the last name in the receiver (``self.plugin_manager.cache_manager`` ->
|
||||
#: ``cache_manager``), lower-cased with leading underscores stripped.
|
||||
OWNER_RECEIVERS: Dict[str, Tuple[str, ...]] = {
|
||||
"CacheManager": ("cache_manager", "cache_mgr", "cachemanager", "cache", "cm"),
|
||||
"DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"),
|
||||
"FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"),
|
||||
"PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"),
|
||||
}
|
||||
|
||||
#: Directories never scanned (vendored environments, VCS metadata, caches).
|
||||
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "env",
|
||||
"site-packages", ".tox", ".mypy_cache", ".pytest_cache"}
|
||||
|
||||
CORE_DIRS = ("src", "web_interface", "scripts")
|
||||
CORE_TEST_DIRS = ("test",)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Markers
|
||||
|
||||
|
||||
@dataclass
|
||||
class Marker:
|
||||
owner: str # class name, e.g. "CacheManager"
|
||||
method: str
|
||||
removal: str
|
||||
alternative: Optional[str]
|
||||
module: str # e.g. "src.cache_manager"
|
||||
line: int
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.owner}.{self.method}"
|
||||
|
||||
|
||||
def _decorator_name(node: ast.expr) -> Optional[str]:
|
||||
target = node.func if isinstance(node, ast.Call) else node
|
||||
if isinstance(target, ast.Name):
|
||||
return target.id
|
||||
if isinstance(target, ast.Attribute):
|
||||
return target.attr
|
||||
return None
|
||||
|
||||
|
||||
def find_markers(core_root: Path) -> List[Marker]:
|
||||
"""Every ``@deprecated(...)`` method under ``core_root/src``."""
|
||||
markers: List[Marker] = []
|
||||
for path in sorted((core_root / "src").rglob("*.py")):
|
||||
if path.name == "deprecation.py":
|
||||
continue
|
||||
tree = _parse(path)
|
||||
if tree is None:
|
||||
continue
|
||||
module = ".".join(path.relative_to(core_root).with_suffix("").parts)
|
||||
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
|
||||
for fn in cls.body:
|
||||
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
for dec in fn.decorator_list:
|
||||
if _decorator_name(dec) != "deprecated" or not isinstance(dec, ast.Call):
|
||||
continue
|
||||
args = [a.value if isinstance(a, ast.Constant) else None for a in dec.args]
|
||||
kw = {k.arg: k.value.value for k in dec.keywords
|
||||
if isinstance(k.value, ast.Constant)}
|
||||
removal = args[0] if args else kw.get("removal")
|
||||
alternative = args[1] if len(args) > 1 else kw.get("alternative")
|
||||
markers.append(Marker(cls.name, fn.name, str(removal), alternative,
|
||||
module, fn.lineno))
|
||||
return markers
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Scanning
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hit:
|
||||
kind: str # call | override | review | unrelated | internal
|
||||
path: str
|
||||
line: int
|
||||
code: str
|
||||
test: bool
|
||||
via: Optional[str] = None # internal: the deprecated core method it sits in
|
||||
|
||||
|
||||
@dataclass
|
||||
class Source:
|
||||
"""One plugin (or core) tree to scan."""
|
||||
name: str
|
||||
group: str # core | core-tests | monorepo | third-party
|
||||
root: Optional[Path]
|
||||
error: Optional[str] = None
|
||||
hits: Dict[str, List[Hit]] = field(default_factory=lambda: defaultdict(list))
|
||||
files: int = 0 # Python files scanned
|
||||
|
||||
|
||||
def _parse(path: Path) -> Optional[ast.AST]:
|
||||
try:
|
||||
return ast.parse(path.read_text(encoding="utf-8", errors="replace"), str(path))
|
||||
except (SyntaxError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _iter_py(root: Path) -> Iterator[Path]:
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for name in filenames:
|
||||
if name.endswith(".py"):
|
||||
yield Path(dirpath) / name
|
||||
|
||||
|
||||
def _is_test_path(rel: Path) -> bool:
|
||||
parts = [p.lower() for p in rel.parts]
|
||||
return (any(p in ("test", "tests") for p in parts[:-1])
|
||||
or parts[-1].startswith("test_") or parts[-1].endswith("_test.py")
|
||||
or parts[-1] == "conftest.py")
|
||||
|
||||
|
||||
def _terminal(node: ast.expr) -> Optional[str]:
|
||||
"""The last name in a receiver expression, or None if it has none."""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr
|
||||
if isinstance(node, ast.Call):
|
||||
return _terminal(node.func)
|
||||
if isinstance(node, ast.Subscript):
|
||||
return _terminal(node.value)
|
||||
return None
|
||||
|
||||
|
||||
def _norm(name: Optional[str]) -> str:
|
||||
return (name or "").lstrip("_").lower()
|
||||
|
||||
|
||||
def _base_names(cls: ast.ClassDef) -> List[str]:
|
||||
return [t for t in (_terminal(b) for b in cls.bases) if t]
|
||||
|
||||
|
||||
class _Scanner(ast.NodeVisitor):
|
||||
"""Collect hits for every marked method name in one file."""
|
||||
|
||||
def __init__(self, markers: Dict[str, List[Marker]], lines: List[str],
|
||||
rel: str, test: bool, core_modules: Dict[str, str], module: Optional[str],
|
||||
local_definers: Dict[str, Set[str]], built: Dict[str, str]):
|
||||
self.markers = markers # method name -> markers with that name
|
||||
self.local_definers = local_definers # method name -> this tree's own classes/modules defining it
|
||||
self.built = built # ``x``/``self.x`` -> class it was built from in this file
|
||||
self.lines = lines
|
||||
self.rel = rel
|
||||
self.test = test
|
||||
self.core_modules = core_modules # owner class -> defining module (core only)
|
||||
self.module = module # this file's module when scanning core
|
||||
self.classes: List[ast.ClassDef] = []
|
||||
self.scope: List[ast.AST] = [] # enclosing classes and functions
|
||||
self.aliases: List[Dict[str, str]] = [{}] # local name -> owner class
|
||||
self.out: Dict[str, List[Hit]] = defaultdict(list)
|
||||
|
||||
# -- helpers
|
||||
def _code(self, node: ast.AST) -> str:
|
||||
line = self.lines[node.lineno - 1] if 0 < node.lineno <= len(self.lines) else ""
|
||||
return line.strip()[:160]
|
||||
|
||||
def _add(self, marker: Marker, kind: str, node: ast.AST) -> None:
|
||||
via = self._inside_deprecated()
|
||||
if via and kind != "unrelated":
|
||||
# Only reached through another deprecated method: goes when that does.
|
||||
kind = "internal"
|
||||
self.out[marker.key].append(Hit(kind, self.rel, node.lineno, self._code(node),
|
||||
self.test, via if kind == "internal" else None))
|
||||
|
||||
def _inside_deprecated(self) -> Optional[str]:
|
||||
"""``Owner.method`` when this node sits in a deprecated core method's body."""
|
||||
for i in range(len(self.scope) - 2, -1, -1):
|
||||
cls, fn = self.scope[i], self.scope[i + 1]
|
||||
if isinstance(cls, ast.ClassDef):
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
for m in self.markers.get(fn.name, ()):
|
||||
if self._is_owner_class(cls, m.owner):
|
||||
return m.key
|
||||
return None
|
||||
return None
|
||||
|
||||
def _owner_for_receiver(self, name: Optional[str]) -> Optional[str]:
|
||||
n = _norm(name)
|
||||
for scope in reversed(self.aliases):
|
||||
if name in scope:
|
||||
return scope[name]
|
||||
for owner, receivers in OWNER_RECEIVERS.items():
|
||||
if n in receivers:
|
||||
return owner
|
||||
return None
|
||||
|
||||
def _is_owner_class(self, cls: ast.ClassDef, owner: str) -> bool:
|
||||
"""True for the real core class (only when scanning its own module)."""
|
||||
return (self.module is not None and cls.name == owner
|
||||
and self.core_modules.get(owner) == self.module)
|
||||
|
||||
def _subclasses(self, cls: ast.ClassDef, owner: str) -> bool:
|
||||
return owner in _base_names(cls)
|
||||
|
||||
def _class_defines(self, cls: ast.ClassDef, name: str) -> bool:
|
||||
return any(isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name
|
||||
for n in cls.body)
|
||||
|
||||
# -- scopes
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
for fn in node.body:
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name in self.markers:
|
||||
for m in self.markers[fn.name]:
|
||||
if self._is_owner_class(node, m.owner):
|
||||
continue # the definition itself
|
||||
kind = "override" if self._subclasses(node, m.owner) else "unrelated"
|
||||
self._add(m, kind, fn)
|
||||
self.classes.append(node)
|
||||
self.scope.append(node)
|
||||
self.generic_visit(node)
|
||||
self.scope.pop()
|
||||
self.classes.pop()
|
||||
|
||||
def _visit_function(self, node) -> None:
|
||||
self.aliases.append({})
|
||||
self.scope.append(node)
|
||||
self.generic_visit(node)
|
||||
self.scope.pop()
|
||||
self.aliases.pop()
|
||||
|
||||
visit_FunctionDef = _visit_function
|
||||
visit_AsyncFunctionDef = _visit_function
|
||||
|
||||
def visit_Assign(self, node: ast.Assign) -> None:
|
||||
# ``dm = self.display_manager`` makes ``dm.draw_sun()`` a call.
|
||||
owner = self._owner_for_receiver(_terminal(node.value))
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and owner:
|
||||
self.aliases[-1][target.id] = owner
|
||||
self.generic_visit(node)
|
||||
|
||||
# -- uses
|
||||
def visit_Attribute(self, node: ast.Attribute) -> None:
|
||||
if node.attr in self.markers:
|
||||
for m in self.markers[node.attr]:
|
||||
self._add(m, self._classify(node, m), node)
|
||||
self.generic_visit(node)
|
||||
|
||||
def _classify(self, node: ast.Attribute, m: Marker) -> str:
|
||||
recv = node.value
|
||||
cls = self.classes[-1] if self.classes else None
|
||||
is_self = isinstance(recv, ast.Name) and recv.id in ("self", "cls")
|
||||
is_super = (isinstance(recv, ast.Call) and isinstance(recv.func, ast.Name)
|
||||
and recv.func.id == "super")
|
||||
if is_self or is_super:
|
||||
if cls is not None and (self._is_owner_class(cls, m.owner) or self._subclasses(cls, m.owner)):
|
||||
return "call"
|
||||
if cls is not None and self._class_defines(cls, m.method):
|
||||
return "unrelated"
|
||||
return "review"
|
||||
name = _terminal(recv)
|
||||
if self._owner_for_receiver(name) == m.owner:
|
||||
return "call"
|
||||
definers = self.local_definers.get(m.method, ())
|
||||
if name in definers or self.built.get(name or "") in definers:
|
||||
# e.g. the weather plugin's WeatherIcons.draw_sun, or
|
||||
# self._strategy_component = CacheStrategy(); ...get_sport_live_interval()
|
||||
return "unrelated"
|
||||
return "review"
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
func = node.func
|
||||
if (isinstance(func, ast.Name) and func.id in ("getattr", "hasattr", "setattr", "delattr")
|
||||
and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant)
|
||||
and node.args[1].value in self.markers):
|
||||
for m in self.markers[node.args[1].value]:
|
||||
owner = self._owner_for_receiver(_terminal(node.args[0]))
|
||||
self._add(m, "call" if owner == m.owner else "review", node)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def _built_from(tree: ast.AST) -> Dict[str, str]:
|
||||
"""``{name: Class}`` for every ``name = Class(...)`` / ``self.name = Class(...)``."""
|
||||
built: Dict[str, str] = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
||||
cls = _terminal(node.value.func)
|
||||
for target in node.targets:
|
||||
name = _terminal(target) if isinstance(target, (ast.Name, ast.Attribute)) else None
|
||||
if name and cls:
|
||||
built[name] = cls
|
||||
return built
|
||||
|
||||
|
||||
def scan_tree(source: Source, roots: Iterable[Path], base: Path, markers: List[Marker],
|
||||
core: bool, test_override: Optional[bool] = None,
|
||||
definer_roots: Iterable[Path] = ()) -> None:
|
||||
by_name: Dict[str, List[Marker]] = defaultdict(list)
|
||||
for m in markers:
|
||||
by_name[m.method].append(m)
|
||||
core_modules = {m.owner: m.module for m in markers}
|
||||
owners = {m.owner for m in markers}
|
||||
files: List[Tuple[Path, str, Optional[ast.AST]]] = []
|
||||
for root in roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in ([root] if root.is_file() else sorted(_iter_py(root))):
|
||||
source.files += 1
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if any(name in text for name in by_name):
|
||||
files.append((path, text, _parse(path)))
|
||||
|
||||
# Classes (and modules) in this tree with their own method of a marked
|
||||
# name, so ``WeatherIcons.draw_sun()`` is recognised as theirs.
|
||||
local_definers: Dict[str, Set[str]] = defaultdict(set)
|
||||
definer_files = list(files)
|
||||
for root in definer_roots:
|
||||
for path in (sorted(_iter_py(root)) if root.is_dir() else ()):
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if any(name in text for name in by_name):
|
||||
definer_files.append((path, text, _parse(path)))
|
||||
for path, _, tree in definer_files:
|
||||
for node in (tree.body if tree is not None else ()):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in by_name:
|
||||
local_definers[node.name].add(path.stem)
|
||||
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)) if tree else ():
|
||||
if cls.name in owners and core:
|
||||
continue
|
||||
if owners & set(_base_names(cls)):
|
||||
continue
|
||||
for fn in cls.body:
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name in by_name:
|
||||
local_definers[fn.name].add(cls.name)
|
||||
|
||||
for path, text, tree in files:
|
||||
rel = path.relative_to(base)
|
||||
test = _is_test_path(rel) if test_override is None else test_override
|
||||
if tree is None:
|
||||
# Unparseable (Python 2, a template ...): fall back to text, as review.
|
||||
for no, line in enumerate(text.splitlines(), 1):
|
||||
for name in by_name:
|
||||
if re.search(rf"{re.escape(name)}", line):
|
||||
for m in by_name[name]:
|
||||
source.hits[m.key].append(
|
||||
Hit("review", rel.as_posix(), no, line.strip()[:160], test))
|
||||
continue
|
||||
module = ".".join(rel.with_suffix("").parts) if core else None
|
||||
scanner = _Scanner(by_name, text.splitlines(), rel.as_posix(), test,
|
||||
core_modules, module, local_definers, _built_from(tree))
|
||||
scanner.visit(tree)
|
||||
for key, hits in scanner.out.items():
|
||||
source.hits[key].extend(hits)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fetching plugin trees (read-only)
|
||||
|
||||
|
||||
def _git(*args: str, cwd: Optional[Path] = None) -> subprocess.CompletedProcess:
|
||||
# Never stop to ask for credentials: a deleted or private plugin repo
|
||||
# should be reported as not scanned, not hang the scan.
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
||||
return subprocess.run( # nosec B603 B607 - list-form git argv, no shell; URLs follow "--" # nosemgrep
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=300, env=env)
|
||||
|
||||
|
||||
def _rmtree(path: Path) -> None:
|
||||
"""Delete a clone; git marks pack files read-only, which Windows refuses to delete."""
|
||||
import shutil
|
||||
import stat
|
||||
|
||||
def retry(func, target, _exc):
|
||||
os.chmod(target, stat.S_IWRITE)
|
||||
func(target)
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
shutil.rmtree(path, onexc=retry)
|
||||
else:
|
||||
shutil.rmtree(path, onerror=retry)
|
||||
|
||||
|
||||
def shallow_clone(url: str, branch: Optional[str], dest: Path, reuse: bool) -> Optional[str]:
|
||||
"""Clone ``url`` into ``dest`` (depth 1), replacing any earlier clone.
|
||||
|
||||
Returns an error string, or None on success.
|
||||
"""
|
||||
if reuse and (dest / ".git").exists():
|
||||
return None
|
||||
if dest.exists():
|
||||
_rmtree(dest)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
args = ["-c", "core.longpaths=true", "clone", "--quiet", "--depth", "1"]
|
||||
if branch:
|
||||
args += ["--branch", branch]
|
||||
# "--" ends option parsing: a registry URL starting with "-" (for example
|
||||
# "--upload-pack=...") is then only ever a repository argument.
|
||||
result = _git(*args, "--", url, str(dest))
|
||||
if result.returncode != 0 and branch:
|
||||
result = _git("-c", "core.longpaths=true", "clone", "--quiet", "--depth", "1",
|
||||
"--", url, str(dest))
|
||||
if result.returncode != 0:
|
||||
lines = (result.stderr or result.stdout).strip().splitlines()
|
||||
return lines[-1] if lines else "git clone failed"
|
||||
return None
|
||||
|
||||
|
||||
def _head(path: Path, branch: bool = True) -> str:
|
||||
"""Commit (and branch) of a checkout, via read-only git calls."""
|
||||
rev = _git("--no-optional-locks", "rev-parse", "--short=8", "HEAD", cwd=path)
|
||||
if rev.returncode != 0:
|
||||
return "unknown revision"
|
||||
if not branch:
|
||||
return rev.stdout.strip()
|
||||
ref = _git("--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD", cwd=path)
|
||||
return f"{ref.stdout.strip()} @ {rev.stdout.strip()}"
|
||||
|
||||
|
||||
def _plugin_id(plugin_dir: Path) -> str:
|
||||
try:
|
||||
return json.loads((plugin_dir / "manifest.json").read_text(encoding="utf-8"))["id"]
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return plugin_dir.name
|
||||
|
||||
|
||||
def _is_monorepo(url: str) -> bool:
|
||||
return MONOREPO_SLUG in url.lower().rstrip("/").removesuffix(".git")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Report
|
||||
|
||||
|
||||
def verdicts(markers: List[Marker], sources: List[Source]) -> Dict[str, Tuple[str, str]]:
|
||||
"""``{Owner.method: (status, text)}`` across every source.
|
||||
|
||||
An *internal* hit (a call from inside another deprecated method) keeps a
|
||||
method only while that caller is itself kept, so statuses are resolved
|
||||
until they stop changing.
|
||||
"""
|
||||
failed = [s.name for s in sources if s.error]
|
||||
status: Dict[str, Tuple[str, str]] = {}
|
||||
for _ in range(len(markers) + 1):
|
||||
changed = False
|
||||
for m in markers:
|
||||
used, review = [], []
|
||||
for s in sources:
|
||||
live = [h for h in s.hits.get(m.key, []) if not h.test]
|
||||
if any(h.kind in ("call", "override") for h in live) or any(
|
||||
h.kind == "internal" and status.get(h.via, ("",))[0] == "used"
|
||||
for h in live):
|
||||
used.append(s.name)
|
||||
elif any(h.kind == "review" for h in live) or any(
|
||||
h.kind == "internal" and status.get(h.via, ("",))[0] == "review"
|
||||
for h in live):
|
||||
review.append(s.name)
|
||||
if used:
|
||||
new = ("used", f"still used by {', '.join(used)} — keep or migrate first")
|
||||
elif review:
|
||||
new = ("review", f"needs review: possible use in {', '.join(review)}")
|
||||
elif failed:
|
||||
new = ("unknown", f"not proven unused: {len(failed)} plugin(s) could not be scanned")
|
||||
else:
|
||||
new = ("unused", f"unused — safe to remove in {m.removal}")
|
||||
if status.get(m.key) != new:
|
||||
status[m.key] = new
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
return status
|
||||
|
||||
|
||||
def _counts(hits: List[Hit]) -> Dict[str, int]:
|
||||
c: Dict[str, int] = defaultdict(int)
|
||||
for h in hits:
|
||||
c[("test " if h.test else "") + h.kind] += 1
|
||||
return c
|
||||
|
||||
|
||||
def _usage_cell(marker: Marker, sources: List[Source], kinds: Tuple[str, ...]) -> str:
|
||||
parts = []
|
||||
for s in sources:
|
||||
c = _counts(s.hits.get(marker.key, []))
|
||||
bits = [f"{c[k]} {k}{'s' if c[k] != 1 else ''}" for k in kinds if c[k]]
|
||||
if bits:
|
||||
parts.append(f"{s.name} ({', '.join(bits)})")
|
||||
return "; ".join(parts) or "—"
|
||||
|
||||
|
||||
def render_markdown(markers: List[Marker], sources: List[Source], meta: Dict[str, str]) -> str:
|
||||
out: List[str] = []
|
||||
w = out.append
|
||||
w("# Deprecated plugin APIs: usage scan")
|
||||
w("")
|
||||
w("Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it "
|
||||
"(see [How to re-run](#how-to-re-run)).")
|
||||
w("")
|
||||
w(f"- Scanned: {meta['date']}, core {meta['core_version']}")
|
||||
w(f"- Monorepo: {meta['monorepo']}")
|
||||
w(f"- Third-party plugins: {meta['third_party']}")
|
||||
failed = [s for s in sources if s.error]
|
||||
if failed:
|
||||
w("- **Not scanned:** " + "; ".join(f"{s.name} ({s.error})" for s in failed))
|
||||
w("")
|
||||
status = verdicts(markers, sources)
|
||||
tally: Dict[str, int] = defaultdict(int)
|
||||
for st, _ in status.values():
|
||||
tally[st] += 1
|
||||
w(f"**{len(markers)} deprecated methods: {tally['unused']} unused, "
|
||||
f"{tally['used']} still used, {tally['review']} need review"
|
||||
+ (f", {tally['unknown']} not proven" if tally["unknown"] else "") + ".**")
|
||||
w("")
|
||||
w("Counted per plugin: a *call* is `<receiver>.method` on an object named like "
|
||||
"the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), "
|
||||
"or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass "
|
||||
"of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot "
|
||||
"tell. *Internal* hits sit inside another deprecated core method and go with it. "
|
||||
"*Unrelated* hits are a different class's own method with the same name "
|
||||
"(a name collision), and never block removal; neither do hits in test files.")
|
||||
w("")
|
||||
w("| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |")
|
||||
w("|---|---|---|---|---|---|")
|
||||
core_sources = [s for s in sources if s.group in ("core", "core-tests")]
|
||||
plugin_sources = [s for s in sources if s.group not in ("core", "core-tests")]
|
||||
for m in markers:
|
||||
core = _usage_cell(m, core_sources, ("call", "override", "review", "internal",
|
||||
"test call", "test override", "test review",
|
||||
"test internal"))
|
||||
plugins = _usage_cell(m, plugin_sources, ("call", "override", "review"))
|
||||
other = _usage_cell(m, plugin_sources, ("unrelated", "test call", "test override",
|
||||
"test review", "test unrelated"))
|
||||
w(f"| `{m.key}` | {m.removal} | {core} | {plugins} | {other} | {status[m.key][1]} |")
|
||||
w("")
|
||||
|
||||
groups = [("unused", "Unused — safe to remove"), ("used", "Still used — keep or migrate first"),
|
||||
("review", "Needs review"), ("unknown", "Not proven unused")]
|
||||
for st, title in groups:
|
||||
names = [m.key for m in markers if status[m.key][0] == st]
|
||||
if names:
|
||||
w(f"## {title} ({len(names)})")
|
||||
w("")
|
||||
w(", ".join(f"`{n}`" for n in names))
|
||||
w("")
|
||||
|
||||
detail = [(m, s, h) for m in markers for s in sources
|
||||
for h in s.hits.get(m.key, []) if h.kind != "unrelated" or not h.test]
|
||||
if detail:
|
||||
w("## Every hit")
|
||||
w("")
|
||||
w("File paths are relative to the plugin's directory (core: the repo root).")
|
||||
w("")
|
||||
w("| Method | Where | File:line | Kind | Code |")
|
||||
w("|---|---|---|---|---|")
|
||||
for m, s, h in detail:
|
||||
kind = ("test " if h.test else "") + h.kind
|
||||
if h.via:
|
||||
kind += f" (in `{h.via}`)"
|
||||
code = h.code.replace("|", "\\|").replace("`", "'")
|
||||
w(f"| `{m.key}` | {s.name} | {h.path}:{h.line} | {kind} | `{code}` |")
|
||||
w("")
|
||||
|
||||
w("## Sources scanned")
|
||||
w("")
|
||||
w("| Source | Group | Python files | Hits |")
|
||||
w("|---|---|---|---|")
|
||||
for s in sources:
|
||||
n = sum(len(v) for v in s.hits.values())
|
||||
files = f"not scanned: {s.error}" if s.error else str(s.files)
|
||||
w(f"| {s.name} | {s.group} | {files} | {n} |")
|
||||
w("")
|
||||
|
||||
w("## How to re-run")
|
||||
w("")
|
||||
w("```bash")
|
||||
w("# Clones the monorepo and each third-party plugin (depth 1) into a temp cache:")
|
||||
w("python3 scripts/plugin_api_usage.py --output docs/DEPRECATIONS_3.8.md")
|
||||
w("# Or scan a local monorepo checkout (read only) instead of cloning it:")
|
||||
w("python3 scripts/plugin_api_usage.py --monorepo ../ledmatrix-plugins")
|
||||
w("```")
|
||||
w("")
|
||||
w("Before removing a method in its release, re-run the scan against the current "
|
||||
"monorepo and registry: a plugin added since this file was generated may have "
|
||||
"started calling it. Remove only methods the fresh scan reports unused; move "
|
||||
"the rest to a later release (the test in `test/test_deprecation.py` fails "
|
||||
"while a marker names a release at or below `src.__version__`).")
|
||||
w("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def render_json(markers: List[Marker], sources: List[Source], meta: Dict[str, str]) -> str:
|
||||
data = {"meta": meta, "sources": [{"name": s.name, "group": s.group, "error": s.error}
|
||||
for s in sources], "methods": []}
|
||||
status = verdicts(markers, sources)
|
||||
for m in markers:
|
||||
st, text = status[m.key]
|
||||
data["methods"].append({
|
||||
"method": m.key, "module": m.module, "removal": m.removal,
|
||||
"alternative": m.alternative, "status": st, "verdict": text,
|
||||
"hits": [{"source": s.name, **h.__dict__} for s in sources
|
||||
for h in s.hits.get(m.key, [])],
|
||||
})
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--monorepo", type=Path,
|
||||
help="local ledmatrix-plugins checkout to scan (read only); "
|
||||
"default: shallow-clone its main branch")
|
||||
parser.add_argument("--registry", type=Path,
|
||||
help="plugins.json to read third-party plugins from "
|
||||
"(default: the monorepo's)")
|
||||
parser.add_argument("--cache-dir", type=Path,
|
||||
default=Path(tempfile.gettempdir()) / "ledmatrix-plugin-api-usage",
|
||||
help="where clones go (default: %(default)s)")
|
||||
parser.add_argument("--reuse-cache", action="store_true",
|
||||
help="scan clones already in --cache-dir instead of re-cloning "
|
||||
"(offline re-runs; the report may then be stale)")
|
||||
parser.add_argument("--no-third-party", action="store_true",
|
||||
help="skip third-party plugins (the report then cannot prove anything unused)")
|
||||
parser.add_argument("--format", choices=("md", "json"), default="md")
|
||||
parser.add_argument("--output", type=Path, help="write the report here instead of stdout")
|
||||
args = parser.parse_args(argv)
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
markers = find_markers(REPO_ROOT)
|
||||
if not markers:
|
||||
print("No @deprecated markers found in src/.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
try:
|
||||
from src import __version__ as core_version
|
||||
except Exception: # noqa: BLE001 -- reporting only
|
||||
core_version = "unknown"
|
||||
|
||||
sources: List[Source] = []
|
||||
core = Source("core", "core", REPO_ROOT)
|
||||
scan_tree(core, [REPO_ROOT / d for d in CORE_DIRS] + sorted(REPO_ROOT.glob("*.py")),
|
||||
REPO_ROOT, markers, core=True)
|
||||
core_tests = Source("core tests", "core-tests", REPO_ROOT)
|
||||
scan_tree(core_tests, [REPO_ROOT / d for d in CORE_TEST_DIRS], REPO_ROOT, markers,
|
||||
core=True, test_override=True,
|
||||
definer_roots=[REPO_ROOT / d for d in CORE_DIRS])
|
||||
sources += [core, core_tests]
|
||||
|
||||
# Monorepo
|
||||
if args.monorepo:
|
||||
mono = args.monorepo.resolve()
|
||||
mono_desc = f"local checkout `{mono.name}` ({_head(mono)})"
|
||||
else:
|
||||
mono = args.cache_dir / "ledmatrix-plugins"
|
||||
err = shallow_clone(MONOREPO_URL, "main", mono, args.reuse_cache)
|
||||
if err:
|
||||
print(f"Could not clone the monorepo: {err}", file=sys.stderr)
|
||||
return 1
|
||||
mono_desc = f"[ChuckBuilds/ledmatrix-plugins]({MONOREPO_URL}) ({_head(mono)})"
|
||||
plugins_dir = mono / "plugins"
|
||||
mono_dirs = sorted(p for p in plugins_dir.iterdir() if p.is_dir()) if plugins_dir.is_dir() else []
|
||||
for d in mono_dirs:
|
||||
s = Source(_plugin_id(d), "monorepo", d)
|
||||
scan_tree(s, [d], d, markers, core=False)
|
||||
sources.append(s)
|
||||
mono_desc += f", {len(mono_dirs)} plugins"
|
||||
|
||||
# Third-party plugins from the registry
|
||||
registry = args.registry or (mono / "plugins.json")
|
||||
third: List[dict] = []
|
||||
try:
|
||||
reg = json.loads(registry.read_text(encoding="utf-8"))
|
||||
entries = reg["plugins"] if isinstance(reg, dict) else reg
|
||||
third = [e for e in entries if e.get("repo") and not _is_monorepo(e["repo"])]
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
print(f"Could not read {registry}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if args.no_third_party:
|
||||
tp_desc = "skipped (--no-third-party)"
|
||||
else:
|
||||
for e in third:
|
||||
dest = args.cache_dir / "third-party" / re.sub(r"[^\w.-]", "_", e["id"])
|
||||
err = shallow_clone(e["repo"], e.get("branch") or None, dest, args.reuse_cache)
|
||||
root = dest / e["plugin_path"] if e.get("plugin_path") else dest
|
||||
s = Source(e["id"], "third-party", root, error=err)
|
||||
if not err:
|
||||
scan_tree(s, [root], root, markers, core=False)
|
||||
sources.append(s)
|
||||
tp_desc = (f"{len(third)} with their own repo in `plugins.json` "
|
||||
f"({', '.join(e['id'] for e in third)})")
|
||||
|
||||
meta = {
|
||||
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
"core_version": core_version,
|
||||
"core_rev": _head(REPO_ROOT, branch=False),
|
||||
"monorepo": mono_desc,
|
||||
"third_party": tp_desc,
|
||||
}
|
||||
report = (render_json if args.format == "json" else render_markdown)(markers, sources, meta)
|
||||
if args.output:
|
||||
args.output.write_text(report, encoding="utf-8", newline="\n")
|
||||
print(f"Wrote {args.output}", file=sys.stderr)
|
||||
else:
|
||||
sys.stdout.write(report)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+192
-7
@@ -25,6 +25,14 @@ against another) and for A/B testing a change to the render path.
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with background load
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
|
||||
|
||||
# the cost of changing pixels under a moving strip (Vegas live elements):
|
||||
# a 101KB write into the visible columns every 25 frames
|
||||
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25
|
||||
|
||||
# the cost of extending a Vegas-sized strip on the render thread: a 30-screen
|
||||
# strip, extended by 6 screens (and trimmed) every 6 screens scrolled
|
||||
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
|
||||
@@ -92,13 +100,17 @@ def load_config() -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def build_strip(width: int, height: int, label: str):
|
||||
"""A marquee strip a few screens wide, with text and colour.
|
||||
def build_strip(width: int, height: int, label: str, screens: float = 4.0):
|
||||
"""A marquee strip about ``screens`` screens wide, with text and colour.
|
||||
|
||||
Deliberately not plain white text on black: how long ``SetImage`` takes
|
||||
depends on how many subpixels are lit, so a strip that is mostly dark
|
||||
flatters the panel and hides exactly the regression this benchmark exists
|
||||
to catch.
|
||||
|
||||
The width matters to the extension mode, whose cost is a copy of the whole
|
||||
strip: Vegas carries 8,000-20,000 columns, so measure extension against a
|
||||
strip that wide (``--strip-screens``), not the four-screen default.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -123,7 +135,7 @@ def build_strip(width: int, height: int, label: str):
|
||||
text_width = max(1, box[2] - box[0])
|
||||
text_height = box[3] - box[1]
|
||||
|
||||
reps = max(2, (width * 4) // text_width + 1)
|
||||
reps = max(2, int(width * screens) // text_width + 1)
|
||||
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(strip)
|
||||
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
|
||||
@@ -179,6 +191,119 @@ class BackgroundLoad:
|
||||
zlib.compress(image.tobytes(), 1)
|
||||
|
||||
|
||||
class StripWork:
|
||||
"""Render-thread work a Vegas strip does between frames, on a schedule.
|
||||
|
||||
Patching writes a block of columns into the strip in place, as a live
|
||||
element update does. Extending appends a block and trims what has scrolled
|
||||
past, as continuous Vegas does (render_pipeline.extend_scroll_content).
|
||||
Both run where Vegas runs them -- on the frame loop, before the next frame
|
||||
is drawn -- and are tagged with ``FrameTimingRecorder.note_op``, so the
|
||||
report shows how often the frame straight after each one was late.
|
||||
|
||||
The content comes from the benchmark's own strip, prepared before the run:
|
||||
in Vegas it is drawn off the render thread, so drawing it here would time
|
||||
work the render thread never does.
|
||||
"""
|
||||
|
||||
def __init__(self, helper, recorder, source, *, patch_bytes: int = 0,
|
||||
patch_every: int = 25, patch_where: str = "visible",
|
||||
extend_every_screens: float = 0.0, extend_width: int = 0,
|
||||
separator: int = 32) -> None:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
self.helper = helper
|
||||
self.recorder = recorder
|
||||
self.width = helper.display_width
|
||||
self.height = helper.display_height
|
||||
self.patch_every = max(1, int(patch_every))
|
||||
self.patch_where = patch_where
|
||||
self.separator = max(0, int(separator))
|
||||
self.patches = 0
|
||||
self.patched_bytes = 0
|
||||
self.extensions = 0
|
||||
self._frames = 0
|
||||
self._position_at_extend = 0.0
|
||||
|
||||
pixels = np.asarray(source.convert("RGB"))
|
||||
source_width = pixels.shape[1]
|
||||
|
||||
def columns(count: int, offset: int):
|
||||
# Wraps around the source, so any width can be cut from it.
|
||||
return np.ascontiguousarray(
|
||||
pixels[:, (np.arange(count) + offset) % source_width])
|
||||
|
||||
# Two versions to alternate between, so every patch changes pixels.
|
||||
self._patches = []
|
||||
if patch_bytes > 0:
|
||||
count = max(1, int(patch_bytes) // (self.height * 3))
|
||||
self._patches = [columns(count, 0), columns(count, count)]
|
||||
|
||||
self.extend_every = (int(extend_every_screens * self.width)
|
||||
if extend_every_screens > 0 else 0)
|
||||
self._blocks = []
|
||||
if self.extend_every:
|
||||
# By default each append (block plus its separator) replaces
|
||||
# exactly what scrolled past since the last one, so the strip
|
||||
# holds its width, as Vegas's does in the steady state.
|
||||
count = int(extend_width) or max(1, self.extend_every - self.separator)
|
||||
self._blocks = [Image.fromarray(columns(count, 0)),
|
||||
Image.fromarray(columns(count, count))]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""The strip was restarted from the beginning."""
|
||||
self._position_at_extend = self.helper.scroll_position
|
||||
|
||||
def before_frame(self) -> None:
|
||||
"""Do whatever work is due before the next frame is drawn."""
|
||||
if self._blocks:
|
||||
self._extend_if_due()
|
||||
if self._patches:
|
||||
self._frames += 1
|
||||
if self._frames % self.patch_every == 0:
|
||||
self._patch()
|
||||
|
||||
def _extend_if_due(self) -> None:
|
||||
helper = self.helper
|
||||
if helper.scroll_position < self._position_at_extend:
|
||||
self._position_at_extend = helper.scroll_position
|
||||
# Due on a fixed cadence rather than N screens after the last one ran,
|
||||
# which would drift by the overshoot of a multi-pixel step each time.
|
||||
due_at = self._position_at_extend + self.extend_every
|
||||
if helper.scroll_position < due_at:
|
||||
return
|
||||
block = self._blocks[self.extensions % 2]
|
||||
helper.append_content([block], item_gap=self.separator, element_gap=0)
|
||||
moved = helper.cached_array.nbytes
|
||||
# One screen kept behind the viewport, as Vegas does. The trim shifts
|
||||
# every strip coordinate, the cadence's included.
|
||||
cut = helper.drop_scrolled_prefix(keep_before=self.width)
|
||||
if cut:
|
||||
moved += helper.cached_array.nbytes
|
||||
self._position_at_extend = due_at - cut
|
||||
self.extensions += 1
|
||||
self.recorder.note_op("extend", moved)
|
||||
|
||||
def _patch(self) -> None:
|
||||
patch = self._patches[self.patches % 2]
|
||||
strip = self.helper.cached_array
|
||||
count = patch.shape[1]
|
||||
if strip is None or count > strip.shape[1]:
|
||||
return
|
||||
start = int(self.helper.scroll_position)
|
||||
if self.patch_where == "visible":
|
||||
x = start + max(0, (self.width - count) // 2)
|
||||
else:
|
||||
# Just past the right edge: a change to content not yet on screen.
|
||||
x = start + self.width + 16
|
||||
x = max(0, min(x, strip.shape[1] - count))
|
||||
strip[:, x:x + count] = patch
|
||||
self.patches += 1
|
||||
self.patched_bytes += patch.nbytes
|
||||
self.recorder.note_op("patch", patch.nbytes)
|
||||
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -202,8 +327,36 @@ def main(argv=None) -> int:
|
||||
help="also write the report as JSON, for comparing rigs")
|
||||
parser.add_argument("--label", default=None,
|
||||
help="name for this run in the JSON report (default: hostname)")
|
||||
parser.add_argument("--strip-screens", type=float, default=4.0, metavar="S",
|
||||
help="strip width in screens (default 4). Vegas strips are "
|
||||
"8,000-20,000px; the extension cost scales with it")
|
||||
parser.add_argument("--patch-bytes", type=int, default=0, metavar="N",
|
||||
help="write N bytes of columns into the strip in place "
|
||||
"every --patch-every frames, as a Vegas live element "
|
||||
"update does (a 150x64 card is ~29KB, a 512x64 map "
|
||||
"~100KB)")
|
||||
parser.add_argument("--patch-every", type=int, default=25, metavar="K",
|
||||
help="frames between patches (default 25; 1 = every frame)")
|
||||
parser.add_argument("--patch-where", choices=("visible", "ahead"),
|
||||
default="visible",
|
||||
help="patch the columns on screen, or just past its right "
|
||||
"edge (default visible)")
|
||||
parser.add_argument("--extend-every-screens", type=float, default=0.0,
|
||||
metavar="N",
|
||||
help="append a block and trim the strip every N screens "
|
||||
"scrolled, as continuous Vegas does")
|
||||
parser.add_argument("--extend-width", type=int, default=0, metavar="W",
|
||||
help="width of each appended block in px (default: N "
|
||||
"screens less the separator, so the strip holds its "
|
||||
"width)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.extend_every_screens > 0 and args.strip_screens < args.extend_every_screens + 3:
|
||||
# The strip must stay ahead of the viewport between extensions.
|
||||
args.strip_screens = args.extend_every_screens + 3
|
||||
print(f"strip widened to {args.strip_screens:g} screens so extensions "
|
||||
"keep ahead of the viewport")
|
||||
|
||||
# Everything the display service logs would otherwise land in the middle of
|
||||
# the report; the benchmark's own output is the point. The stall watchdog
|
||||
# is the exception: a stack dump naming what held a frame up belongs here.
|
||||
@@ -272,8 +425,9 @@ def main(argv=None) -> int:
|
||||
print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
|
||||
|
||||
helper.set_sub_pixel_scrolling(False)
|
||||
helper.set_scrolling_image(
|
||||
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s"))
|
||||
strip = build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s",
|
||||
screens=args.strip_screens)
|
||||
helper.set_scrolling_image(strip)
|
||||
|
||||
# The display service's own recorder, owned outright here: never flushed to
|
||||
# the service's stats file, drained exactly at the start and end of the
|
||||
@@ -288,8 +442,23 @@ def main(argv=None) -> int:
|
||||
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
|
||||
display.frame_timing = recorder
|
||||
|
||||
print(f"scrolling {width}x{height} for {args.seconds:.0f}s"
|
||||
+ (f" with {args.busy} background worker(s)" if args.busy else "")
|
||||
work = StripWork(helper, recorder, strip,
|
||||
patch_bytes=args.patch_bytes, patch_every=args.patch_every,
|
||||
patch_where=args.patch_where,
|
||||
extend_every_screens=args.extend_every_screens,
|
||||
extend_width=args.extend_width)
|
||||
|
||||
doing = []
|
||||
if args.busy:
|
||||
doing.append(f"{args.busy} background worker(s)")
|
||||
if args.patch_bytes > 0:
|
||||
doing.append(f"a {args.patch_bytes}B {args.patch_where} patch every "
|
||||
f"{args.patch_every} frame(s)")
|
||||
if work.extend_every:
|
||||
doing.append(f"an extension every {args.extend_every_screens:g} screens")
|
||||
print(f"scrolling {width}x{height} ({helper.cached_array.shape[1]}px strip) "
|
||||
f"for {args.seconds:.0f}s"
|
||||
+ (" with " + ", ".join(doing) if doing else "")
|
||||
+ " ...", flush=True)
|
||||
|
||||
frames = 0
|
||||
@@ -309,8 +478,11 @@ def main(argv=None) -> int:
|
||||
before = recorder.snapshot()
|
||||
run_started = now
|
||||
frames = duplicates = blanks = restarts = 0
|
||||
work.patches = work.patched_bytes = work.extensions = 0
|
||||
if run_started is not None and now - run_started >= args.seconds:
|
||||
break
|
||||
# Where Vegas does its strip work: before the frame is drawn.
|
||||
work.before_frame()
|
||||
helper.update_scroll_position()
|
||||
if helper.is_scroll_complete():
|
||||
# The helper parks at the end of the strip and stops
|
||||
@@ -320,6 +492,7 @@ def main(argv=None) -> int:
|
||||
# the benchmark measures a still image for the rest of the
|
||||
# run and reports a smoothness it never demonstrated.
|
||||
helper.reset_scroll()
|
||||
work.reset()
|
||||
restarts += 1
|
||||
visible = helper.get_visible_portion()
|
||||
column = int(helper.scroll_position)
|
||||
@@ -375,6 +548,11 @@ def main(argv=None) -> int:
|
||||
if restarts:
|
||||
print(f"restarts {restarts} (the strip was scrolled through "
|
||||
f"{restarts} time{'s' if restarts != 1 else ''})")
|
||||
if work.patches:
|
||||
print(f"patches {work.patches} ({work.patched_bytes / 1e6:.1f} MB "
|
||||
"written into the strip)")
|
||||
if work.extensions:
|
||||
print(f"extensions {work.extensions}")
|
||||
|
||||
if args.json_path:
|
||||
report.update({
|
||||
@@ -388,6 +566,13 @@ def main(argv=None) -> int:
|
||||
"duplicate_frames": duplicates,
|
||||
"blank_frames": blanks,
|
||||
"strip_restarts": restarts,
|
||||
"strip_screens": args.strip_screens,
|
||||
"patch_bytes": args.patch_bytes,
|
||||
"patch_every": args.patch_every,
|
||||
"patch_where": args.patch_where,
|
||||
"patches": work.patches,
|
||||
"extend_every_screens": args.extend_every_screens,
|
||||
"extensions": work.extensions,
|
||||
"max_late_pct": args.max_late_pct,
|
||||
"passed": frame_soak.passed(report, args.max_late_pct),
|
||||
})
|
||||
|
||||
@@ -56,9 +56,33 @@ def main() -> int:
|
||||
help='Display mode to render, for plugins that declare '
|
||||
'more than one in their manifest (e.g. nrl_live). '
|
||||
'Omitted, the plugin picks its own default.')
|
||||
parser.add_argument('--vegas', action='store_true',
|
||||
help="Render the plugin's block of the Vegas ticker strip "
|
||||
"instead of display(): its live elements if it has "
|
||||
"them, else its Vegas content, laid out as the "
|
||||
"ticker lays them out. Also writes the live "
|
||||
"elements' keys and columns to <output>.json")
|
||||
parser.add_argument('--no-live', action='store_true',
|
||||
help="With --vegas: ignore live elements and render the "
|
||||
"plugin's ordinary Vegas content (for before/after)")
|
||||
parser.add_argument('--timeline', type=int, default=0, metavar='ROWS',
|
||||
help="With --vegas: render ROWS rows, each the block a "
|
||||
"--timeline-step later as the ticker would update it "
|
||||
"in place (animated elements redrawn for that moment)")
|
||||
parser.add_argument('--timeline-step', type=float, default=0.25, metavar='SECONDS',
|
||||
help="Seconds between --timeline rows (default 0.25)")
|
||||
parser.add_argument('--timeline-update', action='store_true',
|
||||
help="With --timeline: run update() before each row and "
|
||||
"redraw every live element from the new data")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.timeline > 1 and args.no_live:
|
||||
# A timeline shows live elements changing; plain content never does.
|
||||
parser.error("--timeline shows live elements; it cannot be combined with --no-live")
|
||||
if (args.timeline or args.no_live) and not args.vegas:
|
||||
parser.error("--timeline and --no-live need --vegas")
|
||||
|
||||
if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION):
|
||||
print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})")
|
||||
raise SystemExit(1)
|
||||
@@ -145,6 +169,39 @@ def main() -> int:
|
||||
except Exception as e:
|
||||
logger.warning("update() raised: %s — continuing to display()", e)
|
||||
|
||||
if args.vegas:
|
||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.vegas and args.timeline > 1:
|
||||
from src.plugin_system.testing.vegas import render_vegas_timeline
|
||||
image, rows = render_vegas_timeline(
|
||||
plugin_instance, args.plugin, display_manager, steps=args.timeline,
|
||||
step_seconds=args.timeline_step, run_update=args.timeline_update)
|
||||
if image is None:
|
||||
logger.error("Plugin '%s' has no Vegas content", args.plugin)
|
||||
return 1
|
||||
image.save(args.output)
|
||||
logger.info("Saved a %d-row Vegas timeline (%dx%d) to %s",
|
||||
rows, image.width, image.height, args.output)
|
||||
return 0
|
||||
|
||||
if args.vegas:
|
||||
from src.plugin_system.testing.vegas import render_vegas_strip
|
||||
block, layout = render_vegas_strip(
|
||||
plugin_instance, args.plugin, display_manager, live=not args.no_live)
|
||||
if block is None:
|
||||
logger.error("Plugin '%s' has no Vegas content", args.plugin)
|
||||
return 1
|
||||
block.save(args.output)
|
||||
sidecar = Path(args.output).with_suffix('.json')
|
||||
sidecar.write_text(json.dumps(
|
||||
{"width": block.width, "height": block.height,
|
||||
"live_elements": [{"key": k, "x": x, "width": w} for x, k, w in layout]},
|
||||
indent=2) + "\n", encoding="utf-8")
|
||||
logger.info("Saved Vegas strip %dx%d (%d live element(s)) to %s and %s",
|
||||
block.width, block.height, len(layout), args.output, sidecar)
|
||||
return 0
|
||||
|
||||
# A plugin that declares several display modes usually renders nothing
|
||||
# useful without being told which one to draw: the scoreboards keep their
|
||||
# state on per-mode sub-managers and their no-argument path returns False.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Turn the web interface's optional login off, for when the password is lost.
|
||||
|
||||
Removes the password (and the key that signs login cookies) from the
|
||||
``web_auth`` section of ``config/config_secrets.json``. The interface is then
|
||||
open again, as it is before a password is ever set, and a new password can be
|
||||
set under General > Security. API tokens are kept unless ``--revoke-tokens``
|
||||
is given. Nothing else in the secrets file is touched, and the web service
|
||||
does not need a restart: it notices the change on the next request.
|
||||
|
||||
Run it on the Pi, from any directory:
|
||||
|
||||
sudo python3 ~/LEDMatrix/scripts/reset_web_password.py
|
||||
|
||||
``sudo`` because the secrets file is not readable by every user. The file
|
||||
keeps its owner and permissions.
|
||||
|
||||
Another way in without the password: open the interface from the Pi itself
|
||||
(http://localhost:5000). Requests from the Pi are never asked to log in.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config_manager_atomic import atomic_write_json # noqa: E402
|
||||
|
||||
SECTION = 'web_auth' # web_interface/auth.py; not imported to keep Flask out
|
||||
LOGIN_KEYS = ('password_hash', 'session_secret', 'password_set_at')
|
||||
|
||||
|
||||
def reset(settings_file: Path, revoke_tokens: bool = False) -> str:
|
||||
"""Clear the login from ``settings_file`` (config_secrets.json).
|
||||
|
||||
Returns what was done. The message names the file and counts tokens; it
|
||||
never includes anything read from the file.
|
||||
"""
|
||||
if not settings_file.exists():
|
||||
return f'{settings_file} does not exist, so no password is set. Nothing to do.'
|
||||
with open(settings_file, 'r', encoding='utf-8') as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f'{settings_file} does not hold a JSON object')
|
||||
|
||||
section = data.get(SECTION)
|
||||
if not isinstance(section, dict):
|
||||
return 'No web login password is set. Nothing to do.'
|
||||
|
||||
had_password = bool(section.get('password_hash'))
|
||||
token_count = len(section.get('tokens') or [])
|
||||
for key in LOGIN_KEYS:
|
||||
section.pop(key, None)
|
||||
if revoke_tokens:
|
||||
section.pop('tokens', None)
|
||||
if section:
|
||||
data[SECTION] = section
|
||||
else:
|
||||
data.pop(SECTION, None)
|
||||
|
||||
if not had_password and not (revoke_tokens and token_count):
|
||||
return 'No web login password is set. Nothing to do.'
|
||||
atomic_write_json(settings_file, data)
|
||||
|
||||
done = []
|
||||
if had_password:
|
||||
done.append('Web login is off: the interface opens without a password. '
|
||||
'Set a new one under General > Security.')
|
||||
if revoke_tokens and token_count:
|
||||
done.append(f'Revoked {token_count} API token(s).')
|
||||
elif token_count:
|
||||
done.append(f'{token_count} API token(s) kept (use --revoke-tokens to remove them).')
|
||||
return ' '.join(done)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Turn the LEDMatrix web login off (lost password recovery).')
|
||||
parser.add_argument('--secrets', dest='settings_file', type=Path,
|
||||
default=PROJECT_ROOT / 'config' / 'config_secrets.json',
|
||||
help='secrets file (default: config/config_secrets.json '
|
||||
'in this LEDMatrix checkout)')
|
||||
parser.add_argument('--revoke-tokens', action='store_true',
|
||||
help='also delete every API token')
|
||||
args = parser.parse_args(argv)
|
||||
settings_file = args.settings_file
|
||||
try:
|
||||
outcome = reset(settings_file, revoke_tokens=args.revoke_tokens)
|
||||
except PermissionError:
|
||||
print(f'Permission denied reading or writing {settings_file}. Run it with sudo.',
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
except (OSError, ValueError) as err:
|
||||
print(f'Could not reset the web login: {err}', file=sys.stderr)
|
||||
return 1
|
||||
print(outcome)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report how far apart the nine scoreboards' copies of each method are.
|
||||
|
||||
The sports consolidation (docs/SPORTS_UNIFICATION.md) moves shared code from
|
||||
the scoreboard plugins into ``src/common``. Byte-identical copies have mostly
|
||||
been moved; what is left has drifted, and is promoted one *method family* at a
|
||||
time by first making every copy identical ("reconcile, then promote"). This
|
||||
report is the progress measure for that: for every method in the tracked
|
||||
files it counts the copies and the distinct bodies among them, so a stage can
|
||||
say "``_is_game_really_over``: 5 variants -> 1" instead of remembering it.
|
||||
|
||||
It reads a ledmatrix-plugins checkout and never fails a build: it is a report,
|
||||
not a gate. The monorepo's own ``scripts/check_sports_drift.py`` is the gate
|
||||
(it fails when a function that agrees across the plugins starts to differ).
|
||||
|
||||
Definitions
|
||||
-----------
|
||||
family
|
||||
One method name in one tracked file, across every class that defines it
|
||||
and every plugin. ``sports.py::update`` covers ``SportsLive.update``,
|
||||
``SportsRecent.update`` and ``SportsUpcoming.update`` in all nine plugins.
|
||||
Module-level functions are families too.
|
||||
copies
|
||||
How many definitions the family has (plugin x class).
|
||||
plugins
|
||||
How many of the nine plugins define it at least once.
|
||||
variants
|
||||
Distinct bodies among the copies, compared as ASTs with docstrings,
|
||||
comments, formatting, decorators and annotations ignored. A family is
|
||||
reconciled when every class in it is down to one variant.
|
||||
per-class variants
|
||||
The same count within one class role (``SportsLive.update`` across the
|
||||
plugins). Class names are folded the way the plugins name them
|
||||
(``SoccerScoreboardPlugin`` and ``UFCScoreboardPlugin`` are both
|
||||
``SScoreboardPlugin``), so manager.py lines up across sports.
|
||||
folded
|
||||
Variants left after sport and league names are folded to a placeholder
|
||||
(``self.nfl_live`` == ``self.nhl_live``, ``"NFL"`` == ``"NHL"``). The gap
|
||||
between ``variants`` and ``folded`` is drift that is only naming.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python scripts/sports_drift_report.py --plugins ../ledmatrix-plugins
|
||||
python scripts/sports_drift_report.py --markdown # for a CI summary
|
||||
python scripts/sports_drift_report.py --json out.json # machine-readable
|
||||
python scripts/sports_drift_report.py --family sports.py::update
|
||||
|
||||
``--plugins`` defaults to ``$LEDMATRIX_PLUGINS`` (a checkout root or its
|
||||
``plugins/`` directory, the same variable the core parity tests read). With no
|
||||
checkout it says so and exits 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import collections
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
#: The nine scoreboards the consolidation covers, by directory prefix.
|
||||
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
||||
"nrl", "soccer", "ufc")
|
||||
|
||||
#: Files every scoreboard carries a copy of. sports.py and game_renderer.py
|
||||
#: are the consolidation's subject; manager.py (the BasePlugin host, the
|
||||
#: largest copy of all) joined the plan with the reconcile-then-promote
|
||||
#: method. ufc has no game_renderer.py (it draws fights in fight_renderer.py).
|
||||
DEFAULT_FILES = ("sports.py", "manager.py", "game_renderer.py")
|
||||
|
||||
#: A family is "drifted" when it is widespread and has several bodies. The
|
||||
#: defaults match the review that introduced this report (at least 7 plugins,
|
||||
#: at least 3 variants).
|
||||
DEFAULT_MIN_PLUGINS = 7
|
||||
DEFAULT_MIN_VARIANTS = 3
|
||||
|
||||
#: Sport, league and competition names that legitimately differ between the
|
||||
#: plugins. Only used for the ``folded`` column.
|
||||
SPORT_TOKENS = (
|
||||
"afl", "nrl", "baseball", "basketball", "football", "hockey", "soccer",
|
||||
"lacrosse", "ufc", "mma", "mlb", "milb", "nhl", "nfl", "nba", "wnba",
|
||||
"ncaa", "ncaafb", "ncaam", "ncaaw", "ncaa_fb", "ncaa_baseball",
|
||||
"ncaa_basketball", "ncaam_hockey", "ncaaw_hockey", "ncaam_lacrosse",
|
||||
"ncaaw_lacrosse", "ncaam_basketball", "ncaaw_basketball", "epl",
|
||||
"uefa", "mls", "laliga", "bundesliga", "seriea", "ligue1",
|
||||
)
|
||||
_TOKEN_RE = re.compile(
|
||||
r"(?<![A-Za-z0-9])(" + "|".join(sorted(SPORT_TOKENS, key=len, reverse=True))
|
||||
+ r")(?![A-Za-z0-9])", re.IGNORECASE)
|
||||
_TOKEN_SET = {t.lower() for t in SPORT_TOKENS}
|
||||
_CAMEL_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+|_")
|
||||
|
||||
|
||||
def fold(name: str) -> str:
|
||||
"""Replace sport and league names in an identifier or string with ``S``.
|
||||
|
||||
Both spellings the plugins use: snake_case (``nfl_live`` -> ``S_live``)
|
||||
and CamelCase (``UFCScoreboardPlugin`` -> ``SScoreboardPlugin``).
|
||||
"""
|
||||
name = _TOKEN_RE.sub("S", name)
|
||||
# sub, not findall + join: characters between words (spaces, dots,
|
||||
# braces in a log string) must survive, or distinct text folds together.
|
||||
return _CAMEL_RE.sub(
|
||||
lambda m: "S" if m.group(0).lower() in _TOKEN_SET else m.group(0), name)
|
||||
|
||||
|
||||
def _strip_docstring(body: List[ast.stmt]) -> List[ast.stmt]:
|
||||
if (body and isinstance(body[0], ast.Expr)
|
||||
and isinstance(body[0].value, ast.Constant)
|
||||
and isinstance(body[0].value.value, str)):
|
||||
return body[1:] or [ast.Pass()]
|
||||
return body
|
||||
|
||||
|
||||
class _Canonical(ast.NodeTransformer):
|
||||
"""Drop what is not behaviour: docstrings, decorators, annotations."""
|
||||
|
||||
def _func(self, node):
|
||||
self.generic_visit(node)
|
||||
node.body = _strip_docstring(node.body)
|
||||
node.decorator_list = []
|
||||
node.returns = None
|
||||
return node
|
||||
|
||||
visit_FunctionDef = _func
|
||||
visit_AsyncFunctionDef = _func
|
||||
|
||||
def visit_ClassDef(self, node):
|
||||
self.generic_visit(node)
|
||||
node.body = _strip_docstring(node.body)
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
node.annotation = None
|
||||
return node
|
||||
|
||||
|
||||
class _Folded(_Canonical):
|
||||
"""Canonical, plus sport names folded out of identifiers and strings."""
|
||||
|
||||
def visit_Name(self, node):
|
||||
node.id = fold(node.id)
|
||||
return node
|
||||
|
||||
def visit_Attribute(self, node):
|
||||
self.generic_visit(node)
|
||||
node.attr = fold(node.attr)
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
node = super().visit_arg(node)
|
||||
node.arg = fold(node.arg)
|
||||
return node
|
||||
|
||||
def visit_keyword(self, node):
|
||||
self.generic_visit(node)
|
||||
if node.arg:
|
||||
node.arg = fold(node.arg)
|
||||
return node
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
node.value = fold(node.value)
|
||||
return node
|
||||
|
||||
def _func(self, node):
|
||||
node = super()._func(node)
|
||||
node.name = fold(node.name)
|
||||
return node
|
||||
|
||||
visit_FunctionDef = _func
|
||||
visit_AsyncFunctionDef = _func
|
||||
|
||||
|
||||
def _digest(node: ast.AST, transformer: ast.NodeTransformer) -> str:
|
||||
# Re-parse a copy so the transformers never mutate the tree being walked.
|
||||
clone = ast.parse(ast.unparse(node)).body[0]
|
||||
clone = transformer.visit(clone)
|
||||
# The function's own name is the family key, not part of its body.
|
||||
if isinstance(clone, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
clone.name = "_"
|
||||
return hashlib.sha256(ast.dump(clone).encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
class Copy:
|
||||
"""One definition of a method (or module-level function) in one plugin."""
|
||||
|
||||
__slots__ = ("plugin", "cls", "name", "lines", "exact", "folded", "source")
|
||||
|
||||
def __init__(self, plugin, cls, name, lines, exact, folded, source=""):
|
||||
self.plugin = plugin
|
||||
self.cls = cls
|
||||
self.name = name
|
||||
self.lines = lines
|
||||
self.exact = exact
|
||||
self.folded = folded
|
||||
self.source = source
|
||||
|
||||
|
||||
def collect_file(path: Path, plugin: str) -> List[Copy]:
|
||||
"""Every top-level function and class method in one file."""
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError as exc:
|
||||
print(f" ! {path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
out = []
|
||||
|
||||
def add(node, cls):
|
||||
out.append(Copy(plugin, cls, node.name,
|
||||
node.end_lineno - node.lineno + 1,
|
||||
_digest(node, _Canonical()), _digest(node, _Folded()),
|
||||
ast.get_source_segment(text, node) or ""))
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
add(node, "<module>")
|
||||
elif isinstance(node, ast.ClassDef):
|
||||
for child in node.body:
|
||||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
add(child, fold(node.name))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_plugins_dir(raw: Optional[str]) -> Optional[Path]:
|
||||
"""A checkout root or its plugins/ directory; None when there is neither."""
|
||||
if not raw:
|
||||
return None
|
||||
root = Path(raw)
|
||||
if (root / "plugins").is_dir():
|
||||
root = root / "plugins"
|
||||
if not any((root / f"{s}-scoreboard").is_dir() for s in SPORTS):
|
||||
return None
|
||||
return root
|
||||
|
||||
|
||||
def build(plugins_dir: Path, files: Iterable[str]) -> Dict[Tuple[str, str], List[Copy]]:
|
||||
"""{(file, method name): [Copy, ...]} across the nine scoreboards."""
|
||||
families: Dict[Tuple[str, str], List[Copy]] = collections.defaultdict(list)
|
||||
for fname in files:
|
||||
for sport in SPORTS:
|
||||
path = plugins_dir / f"{sport}-scoreboard" / fname
|
||||
if path.is_file():
|
||||
for copy in collect_file(path, sport):
|
||||
families[(fname, copy.name)].append(copy)
|
||||
return families
|
||||
|
||||
|
||||
def summarise(key: Tuple[str, str], copies: List[Copy]) -> dict:
|
||||
"""The numbers for one family."""
|
||||
per_class = collections.defaultdict(list)
|
||||
for c in copies:
|
||||
per_class[c.cls].append(c)
|
||||
classes = []
|
||||
for cls, members in sorted(per_class.items()):
|
||||
groups = collections.defaultdict(list)
|
||||
for c in members:
|
||||
groups[c.exact].append(c.plugin)
|
||||
classes.append({
|
||||
"class": cls,
|
||||
"copies": len(members),
|
||||
"variants": len(groups),
|
||||
"folded": len({c.folded for c in members}),
|
||||
"groups": sorted((sorted(p) for p in groups.values()),
|
||||
key=lambda g: (-len(g), g)),
|
||||
})
|
||||
total_lines = sum(c.lines for c in copies)
|
||||
# What promotion would remove: every copy but one per class role.
|
||||
one_each = sum(max(c.lines for c in members) for members in per_class.values())
|
||||
return {
|
||||
"file": key[0],
|
||||
"family": key[1],
|
||||
"plugins": len({c.plugin for c in copies}),
|
||||
"copies": len(copies),
|
||||
"variants": len({(c.cls, c.exact) for c in copies}),
|
||||
"folded": len({(c.cls, c.folded) for c in copies}),
|
||||
"worst_class_variants": max(k["variants"] for k in classes),
|
||||
"lines": total_lines,
|
||||
"duplicated_lines": total_lines - one_each,
|
||||
"classes": classes,
|
||||
}
|
||||
|
||||
|
||||
def report(families, min_plugins: int, min_variants: int) -> dict:
|
||||
rows = [summarise(k, v) for k, v in families.items()]
|
||||
by_file = collections.defaultdict(list)
|
||||
for r in rows:
|
||||
by_file[r["file"]].append(r)
|
||||
files = {}
|
||||
for fname, frows in sorted(by_file.items()):
|
||||
files[fname] = {
|
||||
"families": len(frows),
|
||||
"in_all_plugins": sum(1 for r in frows if r["plugins"] == len(SPORTS)),
|
||||
"lines": sum(r["lines"] for r in frows),
|
||||
"identical_duplicated_lines": sum(
|
||||
r["duplicated_lines"] for r in frows if r["worst_class_variants"] == 1),
|
||||
}
|
||||
drifted = sorted(
|
||||
(r for r in rows
|
||||
if r["plugins"] >= min_plugins and r["variants"] >= min_variants),
|
||||
key=lambda r: (-r["variants"], -r["lines"], r["file"], r["family"]))
|
||||
identical = sorted(
|
||||
(r for r in rows if r["copies"] >= 2 and r["worst_class_variants"] == 1),
|
||||
key=lambda r: (-r["duplicated_lines"], r["file"], r["family"]))
|
||||
# One body shared by every plugin but one: the cheapest reconciliations.
|
||||
# "One" across the whole family: a class role whose odd one out is a
|
||||
# different plugin from another role's is two outliers, not one.
|
||||
one_outlier = sorted(
|
||||
(r for r in rows
|
||||
if r["plugins"] >= min_plugins and r["worst_class_variants"] == 2
|
||||
and all(len(k["groups"]) < 2 or len(k["groups"][1]) == 1
|
||||
for k in r["classes"])
|
||||
and len(_minorities(r)) == 1),
|
||||
key=lambda r: (-r["duplicated_lines"], r["file"], r["family"]))
|
||||
return {"files": files, "drifted": drifted, "identical": identical,
|
||||
"one_outlier": one_outlier,
|
||||
"rows": rows, "thresholds": {"min_plugins": min_plugins,
|
||||
"min_variants": min_variants}}
|
||||
|
||||
|
||||
def _minorities(r) -> set:
|
||||
"""Every plugin in a minority body, across the family's class roles."""
|
||||
return {p for k in r["classes"] for g in k["groups"][1:] for p in g}
|
||||
|
||||
|
||||
def _outlier(r) -> str:
|
||||
"""The plugin whose body differs, for a one-outlier family."""
|
||||
return ", ".join(sorted(_minorities(r)))
|
||||
|
||||
|
||||
def _text(rep, top_identical: int) -> str:
|
||||
out = []
|
||||
out.append("Per file (all methods and module functions):")
|
||||
for fname, f in rep["files"].items():
|
||||
out.append(f" {fname:<18} {f['families']:>4} families, "
|
||||
f"{f['in_all_plugins']:>3} in all {len(SPORTS)} plugins, "
|
||||
f"{f['lines']:>6} lines; identical copies beyond the first: "
|
||||
f"{f['identical_duplicated_lines']} lines")
|
||||
t = rep["thresholds"]
|
||||
out.append("")
|
||||
out.append(f"Drifted families (in >= {t['min_plugins']} plugins, "
|
||||
f">= {t['min_variants']} variants): {len(rep['drifted'])}")
|
||||
out.append(f" {'file::family':<58} {'plug':>4} {'copies':>6} {'var':>4} "
|
||||
f"{'fold':>4} {'worst':>5} {'lines':>6}")
|
||||
for r in rep["drifted"]:
|
||||
name = f"{r['file']}::{r['family']}"
|
||||
out.append(f" {name:<58} {r['plugins']:>4} {r['copies']:>6} "
|
||||
f"{r['variants']:>4} {r['folded']:>4} "
|
||||
f"{r['worst_class_variants']:>5} {r['lines']:>6}")
|
||||
out.append("")
|
||||
out.append(f"One outlier (in >= {t['min_plugins']} plugins, every plugin but "
|
||||
f"one agrees): {len(rep['one_outlier'])}")
|
||||
for r in rep["one_outlier"]:
|
||||
name = f"{r['file']}::{r['family']}"
|
||||
out.append(f" {name:<58} {r['plugins']:>4} plugins, differs in "
|
||||
f"{_outlier(r)}; {r['lines']} lines")
|
||||
out.append("")
|
||||
out.append(f"Identical in every copy (promote as-is), top {top_identical} "
|
||||
f"by duplicated lines, of {len(rep['identical'])}:")
|
||||
for r in rep["identical"][:top_identical]:
|
||||
name = f"{r['file']}::{r['family']}"
|
||||
out.append(f" {name:<58} {r['plugins']:>4} plugins "
|
||||
f"{r['duplicated_lines']:>5} duplicated lines")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _markdown(rep, top_identical: int, source: str) -> str:
|
||||
t = rep["thresholds"]
|
||||
out = ["## Sports drift report", "",
|
||||
f"Scoreboard copies read from `{source}`. Report only: this never fails "
|
||||
"the build. See docs/SPORTS_UNIFICATION.md.", "",
|
||||
"| File | Families | In all 9 | Lines | Identical duplicated lines |",
|
||||
"|---|---:|---:|---:|---:|"]
|
||||
for fname, f in rep["files"].items():
|
||||
out.append(f"| `{fname}` | {f['families']} | {f['in_all_plugins']} | "
|
||||
f"{f['lines']} | {f['identical_duplicated_lines']} |")
|
||||
out += ["", f"### Drifted families (in >= {t['min_plugins']} plugins, "
|
||||
f">= {t['min_variants']} variants): {len(rep['drifted'])}", "",
|
||||
"| Family | Plugins | Copies | Variants | Folded | Worst class | Lines |",
|
||||
"|---|---:|---:|---:|---:|---:|---:|"]
|
||||
for r in rep["drifted"]:
|
||||
out.append(f"| `{r['file']}::{r['family']}` | {r['plugins']} | {r['copies']} | "
|
||||
f"{r['variants']} | {r['folded']} | {r['worst_class_variants']} | "
|
||||
f"{r['lines']} |")
|
||||
out += ["", f"### One outlier (every plugin but one agrees): "
|
||||
f"{len(rep['one_outlier'])}", "",
|
||||
"| Family | Plugins | Differs in | Lines |", "|---|---:|---|---:|"]
|
||||
for r in rep["one_outlier"]:
|
||||
out.append(f"| `{r['file']}::{r['family']}` | {r['plugins']} | "
|
||||
f"{_outlier(r)} | {r['lines']} |")
|
||||
out += ["", f"### Identical in every copy: {len(rep['identical'])} "
|
||||
f"(top {top_identical} by duplicated lines)", "",
|
||||
"| Family | Plugins | Duplicated lines |", "|---|---:|---:|"]
|
||||
for r in rep["identical"][:top_identical]:
|
||||
out.append(f"| `{r['file']}::{r['family']}` | {r['plugins']} | "
|
||||
f"{r['duplicated_lines']} |")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def _family_detail(rep, families, wanted: str, show_diff: bool) -> str:
|
||||
"""Which plugins share each body of one family; optionally the diffs.
|
||||
|
||||
The diff is against the body most plugins share (the first group), which
|
||||
is where a reconciliation usually starts.
|
||||
"""
|
||||
fname, _, family = wanted.partition("::")
|
||||
for r in rep["rows"]:
|
||||
if r["file"] == fname and r["family"] == family:
|
||||
out = [f"{wanted}: {r['plugins']} plugins, {r['copies']} copies, "
|
||||
f"{r['variants']} variants ({r['folded']} after folding sport "
|
||||
f"names), {r['lines']} lines"]
|
||||
copies = families[(fname, family)]
|
||||
for k in r["classes"]:
|
||||
out.append(f" {k['class']}: {k['variants']} variant(s) "
|
||||
f"({k['folded']} folded)")
|
||||
for g in k["groups"]:
|
||||
out.append(f" {', '.join(g)}")
|
||||
if not show_diff or len(k["groups"]) < 2:
|
||||
continue
|
||||
by_plugin = {c.plugin: c for c in copies if c.cls == k["class"]}
|
||||
base = by_plugin[k["groups"][0][0]]
|
||||
for g in k["groups"][1:]:
|
||||
other = by_plugin[g[0]]
|
||||
out.extend(difflib.unified_diff(
|
||||
base.source.splitlines(), other.source.splitlines(),
|
||||
f"{base.plugin}-scoreboard/{fname}",
|
||||
f"{other.plugin}-scoreboard/{fname}", lineterm="", n=2))
|
||||
return "\n".join(out)
|
||||
return f"{wanted}: no such family"
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("--plugins", default=os.environ.get("LEDMATRIX_PLUGINS"),
|
||||
help="ledmatrix-plugins checkout (default: $LEDMATRIX_PLUGINS)")
|
||||
ap.add_argument("--files", default=",".join(DEFAULT_FILES),
|
||||
help="comma-separated files to compare (default: %(default)s)")
|
||||
ap.add_argument("--min-plugins", type=int, default=DEFAULT_MIN_PLUGINS)
|
||||
ap.add_argument("--min-variants", type=int, default=DEFAULT_MIN_VARIANTS)
|
||||
ap.add_argument("--top-identical", type=int, default=15)
|
||||
ap.add_argument("--markdown", action="store_true",
|
||||
help="print a Markdown summary (for $GITHUB_STEP_SUMMARY)")
|
||||
ap.add_argument("--json", metavar="PATH",
|
||||
help="also write the full report as JSON")
|
||||
ap.add_argument("--family", action="append", default=[],
|
||||
help="show which plugins share each body, e.g. sports.py::update")
|
||||
ap.add_argument("--diff", action="store_true",
|
||||
help="with --family, also diff each variant against the most common one")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
plugins_dir = resolve_plugins_dir(args.plugins)
|
||||
if plugins_dir is None:
|
||||
msg = ("No ledmatrix-plugins checkout: pass --plugins or set "
|
||||
"LEDMATRIX_PLUGINS. Nothing to report.")
|
||||
print(f"_{msg}_\n" if args.markdown else msg)
|
||||
return 0
|
||||
|
||||
files = [f.strip() for f in args.files.split(",") if f.strip()]
|
||||
families = build(plugins_dir, files)
|
||||
rep = report(families, args.min_plugins, args.min_variants)
|
||||
|
||||
if args.json:
|
||||
with open(args.json, "w", encoding="utf-8") as fh:
|
||||
json.dump(rep, fh, indent=2)
|
||||
fh.write("\n")
|
||||
if args.markdown:
|
||||
print(_markdown(rep, args.top_identical, str(plugins_dir)), end="")
|
||||
else:
|
||||
print(_text(rep, args.top_identical))
|
||||
for wanted in args.family:
|
||||
print()
|
||||
print(_family_detail(rep, families, wanted, args.diff))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -247,6 +247,19 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- What to render: the plugin's screen, or its block of the Vegas strip -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="viewSelect" class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">View</label>
|
||||
<select id="viewSelect" onchange="onConfigChange()"
|
||||
class="flex-1 px-2 py-1.5 rounded-lg text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
title="Vegas strip: the plugin's block of the Vegas ticker, laid out as the ticker lays it out">
|
||||
<option value="">Display</option>
|
||||
<option value="live">Vegas strip (live elements)</option>
|
||||
<option value="plain">Vegas strip (plain Vegas content)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
@@ -489,6 +502,7 @@
|
||||
width: width,
|
||||
height: height,
|
||||
mock_data: mockData,
|
||||
vegas: document.getElementById('viewSelect').value || null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -510,8 +524,11 @@
|
||||
updateZoom();
|
||||
|
||||
// Show render time
|
||||
const live = data.live_elements;
|
||||
document.getElementById('renderTimeText').textContent =
|
||||
`${data.render_time_ms}ms`;
|
||||
`${data.render_time_ms}ms` + (live ? ` · ${data.width}px strip, ` +
|
||||
`${live.length} live element(s)` +
|
||||
(live.length ? `: ${live.map(e => e.key).join(', ')}` : '') : '');
|
||||
|
||||
// Show warnings/errors
|
||||
showMessages(data.errors || [], data.warnings || []);
|
||||
|
||||
@@ -14,12 +14,20 @@ the same reason: the rollback cannot depend on packages the update changed.
|
||||
The updater leaves data/auto_update_pending.json:
|
||||
|
||||
{"status": "pending", "old_head": ..., "new_head": ...,
|
||||
"old_ref": "main" | "" (detached) | absent (older updaters),
|
||||
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
|
||||
|
||||
This moves its status to "verifying" and then to one of "success",
|
||||
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
|
||||
The web interface reports that outcome and raises a banner for anything but
|
||||
success.
|
||||
|
||||
"The display service is active" does not mean the panel is drawing: a render
|
||||
loop stuck inside a plugin leaves the service active and the panel frozen.
|
||||
Where the display writes a heartbeat (/run/ledmatrix, see
|
||||
src/display_watchdog.py), the display also has to keep it fresh, from the
|
||||
restarted process, to count as healthy. Where it never wrote one -- the code
|
||||
being updated predates it -- the check is what it always was.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -36,6 +44,14 @@ from pathlib import Path
|
||||
PENDING_NAME = 'auto_update_pending.json'
|
||||
REQUIREMENT_FILES = ('requirements.txt', 'web_interface/requirements.txt')
|
||||
WEB_HEALTH_URL = 'http://127.0.0.1:5000/api/v3/system/version'
|
||||
#: Written by the display's render loop every few seconds. A copy of
|
||||
#: src/display_watchdog.HEARTBEAT_PATH, not an import: this file runs as a
|
||||
#: copy made before the update and must not depend on the code it checks.
|
||||
HEARTBEAT_PATH = '/run/ledmatrix/display-heartbeat.json'
|
||||
#: How old the heartbeat may be. Well under STABLE_SECONDS: a display that
|
||||
#: draws its first frame and then freezes must go stale inside the window it
|
||||
#: has to stay healthy for, or the check would pass it.
|
||||
HEARTBEAT_FRESH_SECONDS = 30
|
||||
#: How long the services get to come up after a restart...
|
||||
HEALTH_TIMEOUT_SECONDS = 180
|
||||
#: ...and how long they must then stay up. Restart=on-failure makes a crash
|
||||
@@ -113,16 +129,35 @@ def _short(sha):
|
||||
return (sha or 'unknown')[:7]
|
||||
|
||||
|
||||
def _read_heartbeat(path=HEARTBEAT_PATH):
|
||||
"""The display's heartbeat, or None when there is none (or it is unreadable)."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
class Verifier:
|
||||
def __init__(self, project_root, run=subprocess.run, sleep=time.sleep,
|
||||
clock=time.monotonic, web_responds=_web_responds, log=None):
|
||||
clock=time.monotonic, web_responds=_web_responds, log=None,
|
||||
read_heartbeat=_read_heartbeat):
|
||||
self.project_root = Path(project_root)
|
||||
self.pending_file = pending_path(project_root)
|
||||
self.run = run
|
||||
self.sleep = sleep
|
||||
# Monotonic, and compared with the heartbeat's own monotonic stamp:
|
||||
# CLOCK_MONOTONIC is one clock for every process on the machine.
|
||||
self.clock = clock
|
||||
self.web_responds = web_responds
|
||||
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
|
||||
self.read_heartbeat = read_heartbeat
|
||||
#: Whether the display was writing a heartbeat before the update.
|
||||
self.expect_heartbeat = False
|
||||
#: When the display was last restarted; an older heartbeat is the
|
||||
#: previous process's, not proof the new one draws.
|
||||
self.display_restarted_at = None
|
||||
|
||||
def _run(self, args, timeout=GIT_TIMEOUT_SECONDS):
|
||||
try:
|
||||
@@ -154,18 +189,32 @@ class Verifier:
|
||||
ok = True
|
||||
# A display the user had stopped stays stopped.
|
||||
if display:
|
||||
self.display_restarted_at = self.clock()
|
||||
ok = self.restart('ledmatrix') and ok
|
||||
return self.restart('ledmatrix-web') and ok
|
||||
|
||||
def display_drawing(self):
|
||||
"""True while the restarted display keeps its heartbeat fresh."""
|
||||
data = self.read_heartbeat()
|
||||
mono = data.get('mono') if data else None
|
||||
if not isinstance(mono, (int, float)) or isinstance(mono, bool):
|
||||
return False
|
||||
if self.display_restarted_at is not None and mono < self.display_restarted_at:
|
||||
return False # still the process from before the restart
|
||||
return self.clock() - mono <= HEARTBEAT_FRESH_SECONDS
|
||||
|
||||
def wait_healthy(self, display):
|
||||
"""None once the services are up and stay up, else what went wrong."""
|
||||
deadline = self.clock() + HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS
|
||||
healthy_since = baseline = None
|
||||
web = disp = False
|
||||
active = drawing = True
|
||||
count_known = True
|
||||
while self.clock() < deadline:
|
||||
web = self.web_responds()
|
||||
disp = self.service_active('ledmatrix') if display else True
|
||||
active = self.service_active('ledmatrix') if display else True
|
||||
drawing = self.display_drawing() if (display and self.expect_heartbeat) else True
|
||||
disp = active and drawing
|
||||
restarts = self.restart_count('ledmatrix') if display else None
|
||||
# Without a restart count a crash loop looks healthy between
|
||||
# attempts, so an unreadable count never counts as stable.
|
||||
@@ -181,8 +230,11 @@ class Verifier:
|
||||
problems = []
|
||||
if not web:
|
||||
problems.append('the web interface did not respond')
|
||||
if not disp:
|
||||
if not active:
|
||||
problems.append('the display service did not stay running')
|
||||
elif not drawing:
|
||||
problems.append('the display service is running but its panel is not '
|
||||
'being drawn (no fresh heartbeat)')
|
||||
if web and disp and not count_known:
|
||||
problems.append("the display service's restart count could not be read")
|
||||
return '; '.join(problems) or 'the display service kept restarting'
|
||||
@@ -225,6 +277,20 @@ class Verifier:
|
||||
if not old:
|
||||
return False, 'the commit to roll back to is unknown'
|
||||
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
|
||||
# An update may have moved HEAD between main and a detached release
|
||||
# tag (the stable/beta channels). Go back to where HEAD was -- the
|
||||
# branch, or detached -- before resetting, or resetting would drag
|
||||
# the wrong ref: main onto a release commit, or leave a device that
|
||||
# was following main stuck on a detached one. No old_ref (an older
|
||||
# updater wrote this file) means HEAD never moved between refs.
|
||||
old_ref = pending.get('old_ref')
|
||||
if old_ref is not None:
|
||||
move = (['git', 'checkout', '--quiet', '--force', old_ref] if old_ref
|
||||
else ['git', 'checkout', '--quiet', '--force', '--detach', old])
|
||||
result = self._run(move, timeout=GIT_RESET_TIMEOUT_SECONDS)
|
||||
if result.returncode != 0:
|
||||
return False, (f'"{" ".join(move)}" failed: '
|
||||
f'{(result.stderr or result.stdout or "").strip()}')
|
||||
# --hard: the updater refuses to run with local edits to tracked core
|
||||
# files (web_interface/auto_update.local_changes), so outside the
|
||||
# plugin folders the only thing this discards is the update. Edits
|
||||
@@ -258,6 +324,10 @@ class Verifier:
|
||||
write_pending(self.pending_file, pending)
|
||||
|
||||
display = bool(pending.get('display_was_active'))
|
||||
# Read before anything restarts: the display still running is the
|
||||
# pre-update code, and whether it writes a heartbeat decides whether
|
||||
# the updated one must.
|
||||
self.expect_heartbeat = display and self.read_heartbeat() is not None
|
||||
dependency_failures = pending.get('dependency_failures') or []
|
||||
if dependency_failures:
|
||||
# Never restart onto code whose packages did not install.
|
||||
|
||||
+32
-35
@@ -88,7 +88,6 @@ _WIFI_REL = Path("config/wifi_config.json")
|
||||
_YTM_REL = Path("config/ytm_auth.json")
|
||||
_FONTS_REL = Path("assets/fonts")
|
||||
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||
_STATE_REL = Path("data/plugin_state.json")
|
||||
|
||||
#: The sections that are one file each: (section name, path, the
|
||||
#: RestoreOptions flag that restores it). create, preview, validate and
|
||||
@@ -179,20 +178,27 @@ def _build_manifest(contents: List[str]) -> Dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plugins_directory(project_root: Path) -> Path:
|
||||
"""The plugin install directory: ``plugin_system.plugins_directory`` from
|
||||
config/config.json (relative to ``project_root`` unless absolute), or
|
||||
``plugin-repos`` when the config does not say or cannot be read."""
|
||||
configured: Any = None
|
||||
def _read_config(project_root: Path) -> Dict[str, Any]:
|
||||
"""config/config.json as a dict; empty when missing or unreadable."""
|
||||
try:
|
||||
with (project_root / _CONFIG_REL).open("r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if isinstance(config, dict):
|
||||
plugin_system = config.get("plugin_system")
|
||||
if isinstance(plugin_system, dict):
|
||||
configured = plugin_system.get("plugins_directory")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return {}
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
|
||||
def _plugins_directory(project_root: Path,
|
||||
config: Optional[Dict[str, Any]] = None) -> Path:
|
||||
"""The plugin install directory: ``plugin_system.plugins_directory`` from
|
||||
config/config.json (relative to ``project_root`` unless absolute), or
|
||||
``plugin-repos`` when the config does not say or cannot be read."""
|
||||
if config is None:
|
||||
config = _read_config(project_root)
|
||||
configured: Any = None
|
||||
plugin_system = config.get("plugin_system")
|
||||
if isinstance(plugin_system, dict):
|
||||
configured = plugin_system.get("plugins_directory")
|
||||
if not isinstance(configured, str) or not configured.strip():
|
||||
configured = "plugin-repos"
|
||||
path = Path(configured)
|
||||
@@ -202,33 +208,23 @@ def _plugins_directory(project_root: Path) -> Path:
|
||||
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return a list of currently-installed plugins suitable for the backup
|
||||
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||
manifest. Each entry has ``plugin_id``, ``version`` and ``enabled``.
|
||||
|
||||
Reads ``data/plugin_state.json`` if present, then adds any plugin it
|
||||
does not list from the ``manifest.json`` files in the configured plugin
|
||||
directory (see :func:`_plugins_directory`).
|
||||
The plugins are the ``manifest.json`` files in the configured plugin
|
||||
directory (see :func:`_plugins_directory`), with the manifest's version;
|
||||
``enabled`` is config.json's flag by the display's rule (a missing flag
|
||||
is disabled). A restore reinstalls every listed plugin and takes enabled
|
||||
state from the restored config.json, so ``enabled`` is informational.
|
||||
|
||||
``data/plugin_state.json`` is not read: it only ever repeated config's
|
||||
enabled flags and the manifests' versions, and is retired (nothing
|
||||
writes it any more). An old backup that listed a plugin only from that
|
||||
file still restores it, since restore reads ``plugins.json`` as written.
|
||||
"""
|
||||
plugins: Dict[str, Dict[str, Any]] = {}
|
||||
config = _read_config(project_root)
|
||||
|
||||
state_file = project_root / _STATE_REL
|
||||
if state_file.exists():
|
||||
try:
|
||||
with state_file.open("r", encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
|
||||
if isinstance(raw_plugins, dict):
|
||||
for plugin_id, info in raw_plugins.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
plugins[plugin_id] = {
|
||||
"plugin_id": plugin_id,
|
||||
"version": info.get("version") or "",
|
||||
"enabled": bool(info.get("enabled", True)),
|
||||
}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Could not read plugin_state.json: %s", e)
|
||||
|
||||
plugins_root = _plugins_directory(project_root)
|
||||
plugins_root = _plugins_directory(project_root, config)
|
||||
if plugins_root.exists():
|
||||
for entry in sorted(plugins_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
@@ -247,10 +243,11 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
continue
|
||||
plugin_id = data.get("id") or entry.name
|
||||
if plugin_id not in plugins:
|
||||
section = config.get(plugin_id)
|
||||
plugins[plugin_id] = {
|
||||
"plugin_id": plugin_id,
|
||||
"version": data.get("version", ""),
|
||||
"enabled": True,
|
||||
"enabled": isinstance(section, dict) and bool(section.get("enabled", False)),
|
||||
}
|
||||
|
||||
return sorted(plugins.values(), key=lambda p: p["plugin_id"])
|
||||
|
||||
+16
-14
@@ -408,7 +408,7 @@ class CacheManager:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
|
||||
"""Check if data has changed from cached version."""
|
||||
cached_data = self.load_cache(data_type)
|
||||
@@ -514,7 +514,7 @@ class CacheManager:
|
||||
"""Check if the US stock market is currently open."""
|
||||
return self._strategy_component.is_market_open()
|
||||
|
||||
@deprecated("3.7.0", "use set()")
|
||||
@deprecated("3.8.0", "use set()")
|
||||
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
|
||||
"""Update cache with new data."""
|
||||
cache_data = {
|
||||
@@ -564,7 +564,7 @@ class CacheManager:
|
||||
cache_data['data'] = data
|
||||
self.save_cache(key, cache_data)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def setup_persistent_cache(self) -> bool:
|
||||
"""
|
||||
Set up a persistent cache directory with proper permissions.
|
||||
@@ -776,7 +776,7 @@ class CacheManager:
|
||||
else:
|
||||
self.logger.info("Disk cache cleanup thread stopped successfully")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
||||
"""
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
@@ -798,7 +798,7 @@ class CacheManager:
|
||||
"""
|
||||
return self._strategy_component.get_data_type_from_key(key)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Extract sport key from cache key to determine appropriate live_update_interval.
|
||||
@@ -838,7 +838,7 @@ class CacheManager:
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
return self.get_cached_data_with_strategy(key, data_type)
|
||||
|
||||
@deprecated("3.7.0", "use get()")
|
||||
@deprecated("3.8.0", "use get()")
|
||||
def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from background service cache with appropriate strategy.
|
||||
@@ -876,7 +876,7 @@ class CacheManager:
|
||||
self.record_cache_miss('background')
|
||||
return None
|
||||
|
||||
@deprecated("3.7.0", "use get()")
|
||||
@deprecated("3.8.0", "use get()")
|
||||
def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if background service has fresh data available.
|
||||
@@ -906,32 +906,32 @@ class CacheManager:
|
||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||
return f"{sport}_{date_str}"
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def record_cache_hit(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache hit for performance monitoring."""
|
||||
self._metrics_component.record_hit(cache_type)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def record_cache_miss(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache miss for performance monitoring."""
|
||||
self._metrics_component.record_miss(cache_type)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def record_fetch_time(self, duration: float) -> None:
|
||||
"""Record fetch operation duration for performance monitoring."""
|
||||
self._metrics_component.record_fetch_time(duration)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_cache_metrics(self) -> Dict[str, Any]:
|
||||
"""Get current cache performance metrics."""
|
||||
return self._metrics_component.get_metrics()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def log_cache_metrics(self) -> None:
|
||||
"""Log current cache performance metrics."""
|
||||
self._metrics_component.log_metrics()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_memory_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the memory cache.
|
||||
@@ -943,7 +943,9 @@ class CacheManager:
|
||||
|
||||
def log_memory_cache_stats(self) -> None:
|
||||
"""Log current memory cache statistics."""
|
||||
stats = self.get_memory_cache_stats()
|
||||
# Not get_memory_cache_stats(): that is deprecated, and core must not
|
||||
# trip its own deprecation warning every time memory logging runs.
|
||||
stats = self._memory_cache_component.get_stats()
|
||||
self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} "
|
||||
f"({stats['usage_percent']:.1f}%), "
|
||||
f"Last cleanup: {time.time() - stats['last_cleanup']:.1f}s ago")
|
||||
@@ -45,6 +45,7 @@ Rules for the package:
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||
@@ -280,6 +281,17 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
||||
the attributes the host class must have and the three methods deliberately
|
||||
left out.
|
||||
|
||||
### sports_vegas
|
||||
|
||||
[`sports_vegas.py`](sports_vegas.py). What a scoreboard needs for live Vegas
|
||||
cards (one element per game, swapped in place while it scrolls):
|
||||
`game_key()`, `game_fingerprint()`, `dedupe_games()`, `VegasCardCache` (draws
|
||||
a card only when its fingerprint changes), `StickyOdds` (keeps a card's odds
|
||||
through a live poll that left them out), and `finished_games()` /
|
||||
`with_finished_games()` (a game that just went final keeps its card, showing
|
||||
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
|
||||
them together; a scoreboard not built on it (UFC) uses them directly.
|
||||
|
||||
### sports_timezone
|
||||
|
||||
[`sports_timezone.py`](sports_timezone.py).
|
||||
|
||||
+69
-12
@@ -66,6 +66,17 @@ faster than the panel (every frame early) or sits at half its rate (every
|
||||
frame late), both of which look self-consistent to an estimate taken from
|
||||
their own intervals.
|
||||
|
||||
Operations
|
||||
----------
|
||||
The late count says how often, not which work did it. Render-thread work that
|
||||
happens between two frames -- extending the Vegas strip, patching a live
|
||||
element into it -- calls :meth:`FrameTimingRecorder.note_op` first, and the
|
||||
next presented frame carries the tag: the interval that frame ends is the one
|
||||
the work landed in. ``op_frames`` counts timed frames per kind,
|
||||
``late_op_frames`` the late ones among them, ``op_freezes`` those that were a
|
||||
freeze instead, and ``op_bytes`` what the work moved. A kind whose late rate
|
||||
sits well above the overall one is the work to look at.
|
||||
|
||||
Stall watchdog
|
||||
--------------
|
||||
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
|
||||
@@ -153,11 +164,20 @@ def default_stats_path() -> str:
|
||||
return os.path.join(base, STATS_FILENAME)
|
||||
|
||||
|
||||
#: One presented frame's interval: (interval, blit, wait, hold, ops), where
|
||||
#: ops is the work noted before it (kind -> bytes) or None.
|
||||
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
|
||||
|
||||
|
||||
def _bucket(seconds: float) -> int:
|
||||
index = int(seconds * 1000.0 / BUCKET_MS)
|
||||
return min(max(index, 0), BUCKET_COUNT - 1)
|
||||
|
||||
|
||||
def _bump(counter: Dict[str, int], key: str, by: int = 1) -> None:
|
||||
counter[key] = counter.get(key, 0) + by
|
||||
|
||||
|
||||
def binding_releases_gil() -> Optional[bool]:
|
||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
|
||||
|
||||
@@ -250,12 +270,14 @@ class FrameTimingRecorder:
|
||||
self.info = dict(info or {})
|
||||
|
||||
# Render-thread state.
|
||||
self._pending: List[Tuple[float, float, float, int]] = []
|
||||
self._pending: List[_Frame] = []
|
||||
self._static_frames = 0
|
||||
self._previous: Optional[Tuple[float, bool, int]] = None
|
||||
# The interval ended by a static frame that followed a scrolling one,
|
||||
# until the next frame shows whether the scroll went on.
|
||||
self._unsure: Optional[Tuple[float, float, float, int]] = None
|
||||
self._unsure: Optional[_Frame] = None
|
||||
# Work noted since the last frame (kind -> bytes), for the next one.
|
||||
self._ops: Optional[Dict[str, int]] = None
|
||||
self._last_flush: Optional[float] = None
|
||||
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
@@ -281,6 +303,11 @@ class FrameTimingRecorder:
|
||||
"freeze_seconds": 0.0,
|
||||
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
|
||||
"worst_interval_ms": 0.0,
|
||||
# Per kind of noted render-thread work; see "Operations".
|
||||
"op_frames": {},
|
||||
"late_op_frames": {},
|
||||
"op_freezes": {},
|
||||
"op_bytes": {},
|
||||
}
|
||||
self.histograms: Dict[str, Dict[int, int]] = {
|
||||
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
|
||||
@@ -305,6 +332,21 @@ class FrameTimingRecorder:
|
||||
|
||||
# -- render thread ------------------------------------------------------
|
||||
|
||||
def note_op(self, kind: str, nbytes: int = 0) -> None:
|
||||
"""Tag the next presented frame with work done before it.
|
||||
|
||||
Render thread only, like :meth:`record`, which consumes the tag: the
|
||||
interval the next frame ends is the one this work landed in. Several
|
||||
notes before one frame accumulate, per kind. See "Operations".
|
||||
|
||||
:param kind: a short name for the work, e.g. ``"extend"``, ``"patch"``.
|
||||
:param nbytes: how much the work moved, summed into ``op_bytes``.
|
||||
"""
|
||||
ops = self._ops
|
||||
if ops is None:
|
||||
ops = self._ops = {}
|
||||
ops[kind] = ops.get(kind, 0) + int(nbytes)
|
||||
|
||||
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
|
||||
presented_at: float) -> None:
|
||||
"""One frame reached the panel.
|
||||
@@ -318,6 +360,7 @@ class FrameTimingRecorder:
|
||||
previous = self._previous
|
||||
self._previous = (presented_at, scrolling, hold)
|
||||
self.last_frame = (presented_at, scrolling, threading.get_ident())
|
||||
ops, self._ops = self._ops, None
|
||||
if not scrolling:
|
||||
self._static_frames += 1
|
||||
# The scroll ended, or its state went missing for this frame: the
|
||||
@@ -325,7 +368,8 @@ class FrameTimingRecorder:
|
||||
# state, so the interval is due at the scroll's own.
|
||||
self._unsure = None
|
||||
if previous is not None and previous[1]:
|
||||
self._unsure = (presented_at - previous[0], blit, wait, previous[2])
|
||||
self._unsure = (presented_at - previous[0], blit, wait,
|
||||
previous[2], ops)
|
||||
elif self.watchdog is None and self.scrolling_now is not None \
|
||||
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
|
||||
self.watchdog = StallWatchdog(self, **watchdog_settings())
|
||||
@@ -335,14 +379,14 @@ class FrameTimingRecorder:
|
||||
unsure, self._unsure = self._unsure, None
|
||||
if previous[1]:
|
||||
if interval < GAP_SECONDS:
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
self._pending.append((interval, blit, wait, hold, ops))
|
||||
elif unsure is not None and interval < RESUME_SECONDS:
|
||||
# One static frame between two scrolling ones: the scroll never
|
||||
# stopped, only its state did. Both intervals were motion.
|
||||
self._static_frames -= 1
|
||||
if unsure[0] < GAP_SECONDS:
|
||||
self._pending.append(unsure)
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
self._pending.append((interval, blit, wait, hold, ops))
|
||||
|
||||
if self._last_flush is None:
|
||||
self._last_flush = presented_at
|
||||
@@ -382,15 +426,17 @@ class FrameTimingRecorder:
|
||||
except Exception: # never let telemetry take anything down
|
||||
logger.debug("Frame timing flush failed", exc_info=True)
|
||||
|
||||
def aggregate(self, batch: List[Tuple[float, float, float, int]],
|
||||
static: int) -> None:
|
||||
"""Fold one window of frames into the running totals."""
|
||||
def aggregate(self, batch: List[_Frame], static: int) -> None:
|
||||
"""Fold one window of frames into the running totals.
|
||||
|
||||
Each frame is ``(interval, blit, wait, hold, ops)``; ``ops`` (the work
|
||||
noted before it, or None) may be left off.
|
||||
"""
|
||||
totals = self.totals
|
||||
totals["static_frames"] += static
|
||||
|
||||
per_hold = sorted(interval / max(1, hold)
|
||||
for interval, _, _, hold in batch
|
||||
if interval < FREEZE_SECONDS)
|
||||
per_hold = sorted(frame[0] / max(1, frame[3]) for frame in batch
|
||||
if frame[0] < FREEZE_SECONDS)
|
||||
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
|
||||
estimate = per_hold[len(per_hold) // 10]
|
||||
current = self.refresh_period
|
||||
@@ -412,15 +458,22 @@ class FrameTimingRecorder:
|
||||
period = self.refresh_period
|
||||
|
||||
histograms = self.histograms
|
||||
for interval, blit, wait, hold in batch:
|
||||
for frame in batch:
|
||||
interval, blit, wait, hold = frame[:4]
|
||||
ops = frame[4] if len(frame) > 4 else None
|
||||
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
|
||||
interval * 1000.0)
|
||||
if ops:
|
||||
for kind, nbytes in ops.items():
|
||||
_bump(totals["op_bytes"], kind, nbytes)
|
||||
if interval >= FREEZE_SECONDS:
|
||||
totals["freezes"] += 1
|
||||
totals["freeze_seconds"] += interval
|
||||
label = next(name for limit, name in FREEZE_BUCKETS
|
||||
if interval < limit)
|
||||
totals["freeze_by"][label] += 1
|
||||
for kind in ops or ():
|
||||
_bump(totals["op_freezes"], kind)
|
||||
continue
|
||||
totals["scroll_frames"] += 1
|
||||
for name, value in (("blit", blit), ("wait", wait),
|
||||
@@ -432,6 +485,10 @@ class FrameTimingRecorder:
|
||||
if period:
|
||||
totals["timed_frames"] += 1
|
||||
missed = round(interval / period) - hold
|
||||
for kind in ops or ():
|
||||
_bump(totals["op_frames"], kind)
|
||||
if missed >= 1:
|
||||
_bump(totals["late_op_frames"], kind)
|
||||
if missed >= 1:
|
||||
totals["late_frames"] += 1
|
||||
totals["missed_refreshes"] += missed
|
||||
|
||||
+174
-23
@@ -111,9 +111,21 @@ class ScrollHelper:
|
||||
self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds
|
||||
self.scroll_speed = 1.0
|
||||
self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms)
|
||||
self.cached_image: Optional[Image.Image] = None
|
||||
self.cached_image = None # see the property below
|
||||
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
|
||||
self.total_scroll_width = 0
|
||||
# An extended strip lives in a buffer with spare room after it, and
|
||||
# cached_array is a view of the buffer's live columns: an append writes
|
||||
# only the new columns, and a trim only moves the view's start. See
|
||||
# append_content. _strip_view is the view this helper last made; a
|
||||
# cached_array that is anything else was set from outside and is not
|
||||
# written through.
|
||||
self._strip_buffer: Optional[np.ndarray] = None
|
||||
self._strip_view: Optional[np.ndarray] = None
|
||||
self._strip_start = 0
|
||||
#: Bytes the last append_content / drop_scrolled_prefix copied, for
|
||||
#: frame-timing attribution (src/common/frame_timing.py note_op).
|
||||
self.last_copy_bytes = 0
|
||||
|
||||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||||
self._frame_buffer: Optional[np.ndarray] = None
|
||||
@@ -172,7 +184,53 @@ class ScrollHelper:
|
||||
# Scrolling state management
|
||||
self.is_scrolling = False
|
||||
self.scroll_complete = False
|
||||
|
||||
|
||||
# -- the strip as a PIL image ---------------------------------------------
|
||||
#
|
||||
# Every frame is cut from cached_array; nothing on the frame path reads the
|
||||
# PIL image's pixels. Extending and trimming a strip (append_content,
|
||||
# drop_scrolled_prefix) used to rebuild that image in full each time
|
||||
# anyway: Image.fromarray of a Vegas-sized strip is 1.7-3.8ms on a Pi 4,
|
||||
# twice per extension, on the render thread. Those two now leave it to be
|
||||
# built from the array on first read, which in Vegas means only by a
|
||||
# multi-display sync push -- and the strip is not held twice in memory.
|
||||
#
|
||||
# Assigning cached_image still stores exactly what was assigned; a lazy
|
||||
# image is only ever one the helper derived from its own array.
|
||||
|
||||
@property
|
||||
def cached_image(self) -> Optional[Image.Image]:
|
||||
"""The strip as a PIL image, built from ``cached_array`` if deferred."""
|
||||
image = self.__dict__.get('_cached_image')
|
||||
if image is not None:
|
||||
return image
|
||||
source = self.__dict__.get('_image_source')
|
||||
if source is None:
|
||||
return None
|
||||
# Built from the array this read started with. Another thread (the
|
||||
# sync push) may read while the render thread extends the strip; it
|
||||
# then gets the strip as it was, as it did when the image was built
|
||||
# eagerly, and the stale build is not kept.
|
||||
image = Image.fromarray(source)
|
||||
if self.__dict__.get('_image_source') is source:
|
||||
self._cached_image = image
|
||||
return image
|
||||
|
||||
@cached_image.setter
|
||||
def cached_image(self, image: Optional[Image.Image]) -> None:
|
||||
self._cached_image = image
|
||||
self._image_source = None
|
||||
|
||||
def _defer_image(self) -> None:
|
||||
"""The array just changed under the image: rebuild it only if read."""
|
||||
self._cached_image = None
|
||||
self._image_source = self.cached_array
|
||||
|
||||
def has_strip(self) -> bool:
|
||||
"""Whether there is a strip (an image, or one deferred), not reading it."""
|
||||
return (self.__dict__.get('_cached_image') is not None
|
||||
or self.__dict__.get('_image_source') is not None)
|
||||
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 16,
|
||||
@@ -203,6 +261,7 @@ class ScrollHelper:
|
||||
self.total_scroll_width = 0
|
||||
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
|
||||
self.cached_array = np.array(self.cached_image)
|
||||
self._forget_strip_buffer()
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
@@ -238,6 +297,7 @@ class ScrollHelper:
|
||||
self.cached_image = full_image
|
||||
# Convert to numpy array for fast operations
|
||||
self.cached_array = np.array(full_image)
|
||||
self._forget_strip_buffer()
|
||||
actual_image_width = full_image.width
|
||||
self.total_scroll_width = actual_image_width
|
||||
|
||||
@@ -283,7 +343,7 @@ class ScrollHelper:
|
||||
Otherwise the position advances by elapsed time at the configured
|
||||
speed.
|
||||
"""
|
||||
if not self.cached_image:
|
||||
if not self.has_strip():
|
||||
return
|
||||
|
||||
# Calculate frame time for consistent scroll speed regardless of FPS
|
||||
@@ -427,7 +487,7 @@ class ScrollHelper:
|
||||
Returns:
|
||||
PIL Image showing the visible portion, or None if no cached image
|
||||
"""
|
||||
if not self.cached_image or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
return None
|
||||
|
||||
start_x_int = int(self.scroll_position)
|
||||
@@ -501,7 +561,7 @@ class ScrollHelper:
|
||||
slices (128×32 = 12 KB) used here.
|
||||
"""
|
||||
_size = (self.display_width, self.display_height)
|
||||
img_w = self.cached_image.width
|
||||
img_w = self.cached_array.shape[1]
|
||||
|
||||
if end_x <= img_w:
|
||||
# Normal case: single contiguous slice (fastest path)
|
||||
@@ -646,7 +706,7 @@ class ScrollHelper:
|
||||
if not content_items:
|
||||
return False
|
||||
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
# Nothing to extend yet — this is just the first build.
|
||||
self.create_scrolling_image(
|
||||
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||||
@@ -666,13 +726,14 @@ class ScrollHelper:
|
||||
addition.paste(img, (x, 0))
|
||||
x += img.width + element_gap
|
||||
|
||||
# numpy concatenate then one conversion back, rather than allocating a
|
||||
# full-width PIL image and pasting twice: the strip can be tens of
|
||||
# thousands of columns wide and this runs on the render path.
|
||||
self.cached_array = np.concatenate(
|
||||
(self.cached_array, np.array(addition)), axis=1)
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
# Written into the spare room after the strip, when there is some:
|
||||
# the strip can be tens of thousands of columns wide and this runs on
|
||||
# the render thread, where copying all of it (2-3 ms at 512x64 on a
|
||||
# Pi 4) cost the frame after every extension. The PIL image is built
|
||||
# from the array only if something reads it (see cached_image).
|
||||
self.cached_array = self._extended_strip(np.asarray(addition))
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_complete = False
|
||||
|
||||
self.logger.info(
|
||||
@@ -682,6 +743,44 @@ class ScrollHelper:
|
||||
)
|
||||
return True
|
||||
|
||||
#: Room an extended strip's buffer is given, as a multiple of what it
|
||||
#: holds when (re)allocated. Trims free columns at the front and appends
|
||||
#: use them at the back, so with 3x the buffer is reallocated -- the one
|
||||
#: full copy -- about once every two strip-lengths scrolled.
|
||||
STRIP_SPARE_FACTOR = 3.0
|
||||
|
||||
def _extended_strip(self, addition: np.ndarray) -> np.ndarray:
|
||||
"""The strip with ``addition`` after it, written in place when it fits."""
|
||||
live = self.cached_array
|
||||
if live is None:
|
||||
# append_content builds a first strip itself and never comes here.
|
||||
raise RuntimeError("no strip to extend")
|
||||
width = live.shape[1]
|
||||
added = addition.shape[1]
|
||||
buffer = self._strip_buffer
|
||||
if (live is self._strip_view and buffer is not None
|
||||
and self._strip_start + width + added <= buffer.shape[1]):
|
||||
end = self._strip_start + width
|
||||
buffer[:, end:end + added] = addition
|
||||
self.last_copy_bytes = addition.nbytes
|
||||
else:
|
||||
total = width + added
|
||||
buffer = np.empty((live.shape[0], max(total + 1, int(total * self.STRIP_SPARE_FACTOR)))
|
||||
+ live.shape[2:], dtype=live.dtype)
|
||||
buffer[:, :width] = live
|
||||
buffer[:, width:total] = addition
|
||||
self._strip_buffer = buffer
|
||||
self._strip_start = 0
|
||||
self.last_copy_bytes = live.nbytes + addition.nbytes
|
||||
self._strip_view = buffer[:, self._strip_start:self._strip_start + width + added]
|
||||
return self._strip_view
|
||||
|
||||
def _forget_strip_buffer(self) -> None:
|
||||
"""A new strip replaces the extended one: let its buffer go."""
|
||||
self._strip_buffer = None
|
||||
self._strip_view = None
|
||||
self._strip_start = 0
|
||||
|
||||
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
||||
"""
|
||||
Discard columns that have already scrolled past, to bound memory.
|
||||
@@ -699,14 +798,15 @@ class ScrollHelper:
|
||||
Returns:
|
||||
Number of columns actually removed
|
||||
"""
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
return 0
|
||||
strip_width = self.cached_array.shape[1]
|
||||
|
||||
# While the viewport wraps, get_visible_portion fills its right-hand side
|
||||
# from the *head* of the strip, so trimming the head would change what
|
||||
# is on screen. Continuous mode extends before ever reaching that state;
|
||||
# refusing here keeps "trimming is invisible" true unconditionally.
|
||||
if self.scroll_position + self.display_width > self.cached_image.width:
|
||||
if self.scroll_position + self.display_width > strip_width:
|
||||
return 0
|
||||
|
||||
cut = int(self.scroll_position) - max(0, keep_before)
|
||||
@@ -714,15 +814,24 @@ class ScrollHelper:
|
||||
return 0
|
||||
# Never trim so far that the remaining strip is narrower than the
|
||||
# viewport, or get_visible_portion has nothing to slice.
|
||||
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
||||
cut = min(cut, max(0, strip_width - self.display_width))
|
||||
if cut <= 0:
|
||||
return 0
|
||||
|
||||
# .copy() so the original buffer is released rather than kept alive by
|
||||
# a numpy view.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
if self.cached_array is self._strip_view:
|
||||
# Only the view's start moves; the columns behind it are reused
|
||||
# when the buffer is next reallocated (append_content).
|
||||
self._strip_view = self.cached_array[:, cut:]
|
||||
self._strip_start += cut
|
||||
self.cached_array = self._strip_view
|
||||
self.last_copy_bytes = 0
|
||||
else:
|
||||
# Not a strip this helper extended: .copy() so the original
|
||||
# buffer is released rather than kept alive by a view.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.last_copy_bytes = self.cached_array.nbytes
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_position -= cut
|
||||
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
||||
|
||||
@@ -732,9 +841,46 @@ class ScrollHelper:
|
||||
)
|
||||
return cut
|
||||
|
||||
def patch_columns(self, x: int, pixels: np.ndarray) -> int:
|
||||
"""Overwrite the strip's columns from ``x`` with ``pixels``, in place.
|
||||
|
||||
What a live Vegas element update is (src/vegas_mode/elements.py): the
|
||||
strip keeps its width, the scroll keeps its position, and only these
|
||||
columns change. Call it between frames on the thread that draws them;
|
||||
every frame copies its slice out of the strip (get_visible_portion),
|
||||
so no frame already handed on can see half a patch.
|
||||
|
||||
Clipped to the strip at both ends. Refused (0) for an array this
|
||||
helper may not write -- the multi-display follower adopts a read-only
|
||||
one -- or for pixels of another height. The PIL image is deferred, so
|
||||
a later read of cached_image shows the patch.
|
||||
|
||||
Args:
|
||||
x: Strip column of the first column of ``pixels``
|
||||
pixels: uint8 array (height, width, 3)
|
||||
|
||||
Returns:
|
||||
Bytes written.
|
||||
"""
|
||||
strip = self.cached_array
|
||||
if strip is None or not strip.flags.writeable:
|
||||
return 0
|
||||
if pixels.ndim != 3 or pixels.shape[0] != strip.shape[0] \
|
||||
or pixels.shape[2] != strip.shape[2]:
|
||||
return 0
|
||||
width = pixels.shape[1]
|
||||
lo, hi = max(0, int(x)), min(strip.shape[1], int(x) + width)
|
||||
if hi <= lo:
|
||||
return 0
|
||||
strip[:, lo:hi] = pixels[:, lo - int(x):hi - int(x)]
|
||||
if self.__dict__.get('_cached_image') is not None \
|
||||
or self.__dict__.get('_image_source') is not None:
|
||||
self._defer_image()
|
||||
return (hi - lo) * strip.shape[0] * strip.shape[2]
|
||||
|
||||
def remaining_unscrolled(self) -> int:
|
||||
"""Columns of strip still to the right of the viewport."""
|
||||
if self.cached_image is None:
|
||||
if not self.has_strip():
|
||||
return 0
|
||||
return max(0, self.total_scroll_width - int(self.scroll_position)
|
||||
- self.display_width)
|
||||
@@ -796,6 +942,7 @@ class ScrollHelper:
|
||||
|
||||
# Convert to numpy array for fast operations (required for get_visible_portion)
|
||||
self.cached_array = np.array(image)
|
||||
self._forget_strip_buffer()
|
||||
|
||||
# Update scroll width
|
||||
self.total_scroll_width = image.width
|
||||
@@ -1053,6 +1200,7 @@ class ScrollHelper:
|
||||
"""
|
||||
self.cached_image = None
|
||||
self.cached_array = None
|
||||
self._forget_strip_buffer()
|
||||
self.total_scroll_width = 0
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
@@ -1082,5 +1230,8 @@ class ScrollHelper:
|
||||
'elapsed_time': (time.time() - self.scroll_start_time)
|
||||
if self.scroll_start_time
|
||||
else None,
|
||||
'cached_image_size': (self.cached_image.width, self.cached_image.height) if self.cached_image else None
|
||||
# From the array: reading cached_image would build a deferred one.
|
||||
'cached_image_size': ((self.cached_array.shape[1], self.cached_array.shape[0])
|
||||
if self.cached_array is not None and self.has_strip()
|
||||
else None)
|
||||
}
|
||||
|
||||
+184
-2
@@ -42,17 +42,19 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.common import scroll_config
|
||||
from src.common import scroll_config, sports_vegas
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Defaults every copy agreed on. A subclass overrides
|
||||
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
|
||||
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
|
||||
@@ -413,6 +415,160 @@ class SportsScrollDisplay:
|
||||
"""Whether content is prepared and ready to scroll."""
|
||||
return bool(self.scroll_helper.cached_image)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Live Vegas cards
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# One live element per game (src/plugin_system/vegas_elements.py): the
|
||||
# ticker swaps a card in place when its game changes. A sport opts in by
|
||||
# implementing make_vegas_renderer(); everything else is here.
|
||||
|
||||
def make_vegas_renderer(self, card_width: int,
|
||||
rankings_cache: Optional[Dict[str, int]] = None) -> Any:
|
||||
"""The renderer this sport draws one game card with, at ``card_width``.
|
||||
|
||||
**Override point.** Return the object whose ``render_game_card(game,
|
||||
game_type)`` draws one card exactly ``card_width`` wide at the display's
|
||||
height -- the one prepare_scroll_content already builds -- without the
|
||||
black padding prepare_scroll_content adds around each card (the ticker
|
||||
adds its own). Raising NotImplementedError, the default, keeps the
|
||||
plugin on its ordinary Vegas content.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} has no live Vegas cards (make_vegas_renderer)")
|
||||
|
||||
def _determine_game_type(self, game: Dict[str, Any]) -> str:
|
||||
"""The card a game is drawn as: 'live', 'recent' or 'upcoming'.
|
||||
|
||||
From the game's state; a sport whose scroll display decides it
|
||||
differently (most define their own) overrides this.
|
||||
"""
|
||||
return {'in': 'live', 'post': 'recent'}.get(sports_vegas._state(game), 'upcoming')
|
||||
|
||||
def render_vegas_card(self, renderer: Any, game: Dict[str, Any]) -> Image.Image:
|
||||
"""Draw one game's card. Override only if the renderer is called differently."""
|
||||
card: Image.Image = renderer.render_game_card(game, self._determine_game_type(game))
|
||||
return card
|
||||
|
||||
def vegas_separator(self, league: str) -> Optional[Image.Image]:
|
||||
"""The league separator shown before a league's cards, if there is an icon."""
|
||||
icon = self._separator_icons.get(league)
|
||||
if icon is None:
|
||||
return None
|
||||
gap = self._vegas_settings(league).get("gap_between_games", 48)
|
||||
pad = max(4, int(gap) // 2)
|
||||
image = Image.new('RGB', (icon.width + pad * 2, self.display_height), (0, 0, 0))
|
||||
mask = icon if icon.mode == 'RGBA' else None
|
||||
image.paste(icon, (pad, (self.display_height - icon.height) // 2), mask)
|
||||
return image
|
||||
|
||||
def _vegas_memo(self) -> Dict[Any, Any]:
|
||||
"""Per-size, per-config memo for the live path; emptied when either changes."""
|
||||
stamp = (self.display_width, self.display_height, id(self.config))
|
||||
memo: Optional[Tuple[Any, Dict[Any, Any]]] = getattr(self, '_vegas_memo_store', None)
|
||||
if memo is None or memo[0] != stamp:
|
||||
memo = (stamp, {})
|
||||
self._vegas_memo_store = memo
|
||||
store: Dict[Any, Any] = memo[1]
|
||||
return store
|
||||
|
||||
def _vegas_settings(self, league: Optional[str]) -> Dict[str, Any]:
|
||||
"""A league's scroll settings, looked up once per size and config.
|
||||
|
||||
The live path asks after every update; a sport's settings lookup can
|
||||
be expensive (sizing the default card width builds probe renderers).
|
||||
"""
|
||||
memo = self._vegas_memo()
|
||||
key = ('settings', league)
|
||||
if key not in memo:
|
||||
memo[key] = dict(self._get_scroll_settings(league))
|
||||
settings: Dict[str, Any] = memo[key]
|
||||
return settings
|
||||
|
||||
def _vegas_renderer(self, card_width: int,
|
||||
rankings_cache: Optional[Dict[str, int]]) -> Any:
|
||||
"""The sport's renderer for one card width, built once rather than per slate.
|
||||
|
||||
Building one loads fonts and, for the default card width, probes the
|
||||
layout; the scroll path pays that on every prepare, which the live
|
||||
path would repeat on every update.
|
||||
"""
|
||||
memo = self._vegas_memo()
|
||||
key = ('renderer', card_width)
|
||||
if key not in memo:
|
||||
memo[key] = self.make_vegas_renderer(card_width, rankings_cache)
|
||||
renderer = memo[key]
|
||||
if hasattr(renderer, 'set_rankings_cache'):
|
||||
# Every time, empty included: the renderer is reused across
|
||||
# slates, and ranks cleared since must not stay drawn.
|
||||
renderer.set_rankings_cache(rankings_cache or {})
|
||||
return renderer
|
||||
|
||||
def build_vegas_elements(
|
||||
self,
|
||||
games: List[Dict[str, Any]],
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
now: Optional[float] = None,
|
||||
) -> Optional[List[Any]]:
|
||||
"""The slate as live Vegas elements: one card per game, separators between leagues.
|
||||
|
||||
Only cards whose fingerprint changed are drawn; the rest come from the
|
||||
cache. ``fingerprint(game)`` should return what the card draws (the
|
||||
plugin's own signature fields, the clock included for live games); by
|
||||
default the whole game dict is used, which redraws on any change. The
|
||||
teams' ranks from ``rankings_cache`` count too: the renderer draws
|
||||
them from there, not from the game.
|
||||
|
||||
Raises NotImplementedError when the sport has no make_vegas_renderer.
|
||||
"""
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
games = sports_vegas.dedupe_games(games)
|
||||
if not games:
|
||||
return None
|
||||
# Settings follow each game's own league, not the slate's first one:
|
||||
# a card's width must not change because another league has no games
|
||||
# today (the ticker refuses a redraw of another width).
|
||||
first = self._vegas_settings(leagues[0] if leagues else None)
|
||||
|
||||
cards = getattr(self, '_vegas_cards', None)
|
||||
if cards is None:
|
||||
cards = self._vegas_cards = sports_vegas.VegasCardCache()
|
||||
odds = getattr(self, '_vegas_odds', None)
|
||||
if odds is None:
|
||||
odds = self._vegas_odds = sports_vegas.StickyOdds()
|
||||
fingerprint = fingerprint or sports_vegas.game_fingerprint
|
||||
|
||||
elements: List[Any] = []
|
||||
keys: List[str] = []
|
||||
current_league = None
|
||||
separators = 0
|
||||
for game in games:
|
||||
league = game.get("league")
|
||||
settings = self._vegas_settings(league) if league else first
|
||||
card_width = int(settings.get("game_card_width", self.display_width))
|
||||
if settings.get("show_league_separators", True) and league != current_league:
|
||||
separator = self.vegas_separator(league) if league else None
|
||||
if separator is not None:
|
||||
elements.append(VegasElement(
|
||||
key=f"sep:{separators}:{league}", image=separator, live=False))
|
||||
separators += 1
|
||||
current_league = league
|
||||
key = sports_vegas.game_key(game)
|
||||
drawn = odds.apply(key, game, now)
|
||||
ranks = (rankings_cache.get(str(drawn.get("home_abbr"))),
|
||||
rankings_cache.get(str(drawn.get("away_abbr")))) if rankings_cache else None
|
||||
renderer = self._vegas_renderer(card_width, rankings_cache)
|
||||
elements.append(cards.element(
|
||||
key, (fingerprint(drawn), ranks, card_width, self.display_height),
|
||||
functools.partial(self.render_vegas_card, renderer, drawn)))
|
||||
keys.append(key)
|
||||
cards.retain(keys)
|
||||
odds.retain(keys)
|
||||
return elements
|
||||
|
||||
def get_current_game_count(self) -> int:
|
||||
return len(self._current_games)
|
||||
|
||||
@@ -525,6 +681,32 @@ class SportsScrollDisplayManager:
|
||||
scroll_display.clear()
|
||||
self._current_game_type = ""
|
||||
|
||||
def get_vegas_elements_for(
|
||||
self,
|
||||
game_type: str,
|
||||
games: List[Dict[str, Any]],
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
) -> Optional[List[Any]]:
|
||||
"""Live Vegas cards for a slate, built on the ``game_type`` display.
|
||||
|
||||
None when the sport has no live cards (it does not implement
|
||||
make_vegas_renderer) or building them failed, so the plugin's
|
||||
get_vegas_elements() can return it and the ticker falls back to the
|
||||
plugin's ordinary Vegas content.
|
||||
"""
|
||||
scroll_display = self.get_scroll_display(game_type)
|
||||
try:
|
||||
return scroll_display.build_vegas_elements(
|
||||
games, leagues, rankings_cache, fingerprint)
|
||||
except NotImplementedError:
|
||||
return None
|
||||
except Exception:
|
||||
# Built straight from feed data, like prepare_scroll_content.
|
||||
self.logger.exception("Error building live Vegas cards")
|
||||
return None
|
||||
|
||||
def get_all_vegas_content_items(self) -> List[Image.Image]:
|
||||
"""Every display's Vegas items, for splicing into the marquee."""
|
||||
items: List[Image.Image] = []
|
||||
|
||||
@@ -1348,6 +1348,55 @@ class SportsLiveSharedMixin:
|
||||
or candidate < current):
|
||||
self._next_scheduled_start_ts = candidate
|
||||
|
||||
#: How long a game that finished live is still reported by
|
||||
#: finished_games_snapshot(): long enough for the recent-games list, which
|
||||
#: refreshes about hourly, to take it over well before most slates would.
|
||||
FINISHED_GAME_TTL = 900.0
|
||||
|
||||
def _record_finished_game(self, details: Dict) -> None:
|
||||
"""Remember a game that was live and has just gone final (or looks over).
|
||||
|
||||
A finished game leaves ``live_games`` at the next poll, and the recent
|
||||
list that will show it refreshes about hourly, so in between nothing
|
||||
holds the game's final score -- and a live Vegas card for it would keep
|
||||
its last live score. Call this wherever a poll drops a game as final
|
||||
or over. Only a game this manager had as live is taken; one already
|
||||
held takes the newer details (a game dropped by an "is it over"
|
||||
heuristic, then marked final by the feed) but keeps its expiry, so a
|
||||
feed that lists finals all day cannot keep one here all day.
|
||||
"""
|
||||
game_id = details.get("id") if isinstance(details, dict) else None
|
||||
if not game_id:
|
||||
return
|
||||
finished = self.__dict__.setdefault("_finished_games", {})
|
||||
held = finished.get(game_id)
|
||||
if held is not None:
|
||||
finished[game_id] = (held[0], dict(details))
|
||||
return
|
||||
if not any(g.get("id") == game_id for g in getattr(self, "live_games", ()) or ()):
|
||||
return
|
||||
finished[game_id] = (time.monotonic(), dict(details))
|
||||
|
||||
def finished_games_snapshot(self) -> List[Dict]:
|
||||
"""Games that went final here within FINISHED_GAME_TTL, newest data first.
|
||||
|
||||
Copies, safe to decorate. The caller dedupes them against its other
|
||||
lists (src/common/sports_vegas.dedupe_games keeps the liveliest copy,
|
||||
and a final beats nothing but a live one).
|
||||
"""
|
||||
finished = self.__dict__.get("_finished_games")
|
||||
if not finished:
|
||||
return []
|
||||
now = time.monotonic()
|
||||
# A copy first: a manager finishing its update in the background (off
|
||||
# the plugin's lock) may record a game while the ticker reads these.
|
||||
held = list(finished.items())
|
||||
for game_id, (seen, _game) in held:
|
||||
if now - seen > self.FINISHED_GAME_TTL:
|
||||
finished.pop(game_id, None)
|
||||
return [dict(game) for _id, (seen, game) in held
|
||||
if now - seen <= self.FINISHED_GAME_TTL]
|
||||
|
||||
def _note_live_fetch(self, found_live: bool) -> None:
|
||||
"""Record whether a look for live games found any."""
|
||||
if found_live:
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Live Vegas cards for the sports scoreboards.
|
||||
|
||||
A scoreboard hands the Vegas ticker one card per game. As live elements
|
||||
(src/plugin_system/vegas_elements.py) those cards change on the panel while
|
||||
they scroll: a goal redraws its game's card and the ticker swaps it in place.
|
||||
This module is what every scoreboard needs for that and would otherwise write
|
||||
nine times:
|
||||
|
||||
- :func:`game_key` -- a stable key per game, so the ticker can tell which card
|
||||
a redraw belongs to however the slate is re-sorted.
|
||||
- :class:`VegasCardCache` -- draws a card only when what it shows changed
|
||||
(its fingerprint), so an unchanged slate costs a dictionary lookup per game
|
||||
and a changed one only the cards that changed.
|
||||
- :class:`StickyOdds` -- live odds are fetched only for games near the front
|
||||
of the rotation, so a card's odds come and go between polls; this keeps the
|
||||
last odds for a while instead of redrawing the card without them.
|
||||
- :func:`dedupe_games` -- a game present in two managers' lists (live and
|
||||
recent, around the final whistle) appears once, its liveliest copy.
|
||||
- :func:`finished_games` / :func:`with_finished_games` -- a game that has just
|
||||
gone final keeps its card, now showing FINAL, where its live card was,
|
||||
until the recent list (refreshed about hourly) takes it over.
|
||||
- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole
|
||||
game dict, frozen hashable.
|
||||
|
||||
SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts
|
||||
them together; a plugin adopts it by implementing make_vegas_renderer().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
#: Which copy of a duplicated game wins: the liveliest.
|
||||
_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1}
|
||||
|
||||
|
||||
def _state(game: Dict[str, Any]) -> str:
|
||||
status = game.get('status')
|
||||
state = status.get('state') if isinstance(status, dict) else status
|
||||
if isinstance(state, str):
|
||||
return state
|
||||
if game.get('is_live'):
|
||||
return 'in'
|
||||
if game.get('is_final'):
|
||||
return 'post'
|
||||
return 'pre'
|
||||
|
||||
|
||||
def _freeze(value: Any) -> Any:
|
||||
"""A hashable, order-stable copy of feed data."""
|
||||
if isinstance(value, dict):
|
||||
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(_freeze(v) for v in value)
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return repr(value)
|
||||
|
||||
|
||||
def game_fingerprint(game: Dict[str, Any]) -> Hashable:
|
||||
"""Everything in a game dict, hashable: a card drawn from it changes only if this does.
|
||||
|
||||
The default card version. Nothing a card could draw is left out, so no
|
||||
field is ever frozen on the panel; the cost is a redraw when a field the
|
||||
card does not draw changes too, which feed data rarely does between polls.
|
||||
"""
|
||||
frozen: Hashable = _freeze(game)
|
||||
return frozen
|
||||
|
||||
|
||||
def game_key(game: Dict[str, Any]) -> str:
|
||||
"""A key that names this game and nothing else, across polls.
|
||||
|
||||
``game:<league>:<id>`` from the feed's own id. A game without one falls
|
||||
back to its teams and start time, which is stable for the life of a game.
|
||||
"""
|
||||
league = game.get('league') or 'game'
|
||||
game_id = game.get('id') or game.get('game_id')
|
||||
if game_id not in (None, ''):
|
||||
return f"game:{league}:{game_id}"
|
||||
away = game.get('away_abbr') or game.get('away_team') or '?'
|
||||
home = game.get('home_abbr') or game.get('home_team') or '?'
|
||||
start = game.get('start_time_utc') or game.get('start_time') or ''
|
||||
return f"game:{league}:{away}@{home}:{start}"
|
||||
|
||||
|
||||
def dedupe_games(games: Iterable[Dict[str, Any]],
|
||||
key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]:
|
||||
"""Each game once, in first-seen order, keeping its liveliest copy.
|
||||
|
||||
Around a final whistle a game can be in the live list (last poll) and the
|
||||
recent list (next poll) at once; two cards with one key would be refused
|
||||
by the ticker, and showing the game twice is wrong anyway.
|
||||
"""
|
||||
chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
||||
for game in games:
|
||||
key = key_fn(game)
|
||||
current = chosen.get(key)
|
||||
if current is None or _STATE_PRIORITY.get(_state(game), 0) > \
|
||||
_STATE_PRIORITY.get(_state(current), 0):
|
||||
chosen[key] = game
|
||||
return list(chosen.values())
|
||||
|
||||
|
||||
def finished_games(
|
||||
live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Games that just left these live managers' lists, final ones as recent games.
|
||||
|
||||
``live_managers`` pairs each league with its live manager (None is
|
||||
skipped). Each manager reports what SportsLiveSharedMixin recorded
|
||||
(finished_games_snapshot, copies), with its league. A final game is
|
||||
drawn as a recent card. One a poll only judged over -- a tied end of
|
||||
regulation looks like that too -- keeps its last live state, so its card
|
||||
never says FINAL early; if play resumes the live list has it again, and
|
||||
dedupe_games keeps that copy.
|
||||
"""
|
||||
finished: List[Dict[str, Any]] = []
|
||||
for league, manager in live_managers:
|
||||
snapshot = getattr(manager, 'finished_games_snapshot', None)
|
||||
if not callable(snapshot):
|
||||
continue
|
||||
for game in snapshot():
|
||||
game['league'] = league
|
||||
if game.get('is_final'):
|
||||
status = game.get('status')
|
||||
status = dict(status) if isinstance(status, dict) else {}
|
||||
status['state'] = 'post'
|
||||
game.update(status=status, is_live=False)
|
||||
finished.append(game)
|
||||
return finished
|
||||
|
||||
|
||||
def with_finished_games(
|
||||
games: List[Dict[str, Any]], leagues: List[str],
|
||||
finished: List[Dict[str, Any]],
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""The slate with games that just went final where their live cards were.
|
||||
|
||||
A slate lists each league's games together, live ones first. Each
|
||||
finished game goes after its league's live games, ahead of the rest; a
|
||||
league with no games left in the slate is added at the end. A finished
|
||||
game the slate also has (the recent list caught up) is left for
|
||||
dedupe_games, which keeps one copy.
|
||||
"""
|
||||
if not finished:
|
||||
return list(games), list(leagues)
|
||||
pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict()
|
||||
for game in finished:
|
||||
pending.setdefault(game.get('league'), []).append(game)
|
||||
merged: List[Dict[str, Any]] = []
|
||||
for index, game in enumerate(games):
|
||||
league = game.get('league')
|
||||
if league in pending and _state(game) != 'in':
|
||||
merged.extend(pending.pop(league))
|
||||
merged.append(game)
|
||||
following = games[index + 1] if index + 1 < len(games) else None
|
||||
if league in pending and (following is None or following.get('league') != league):
|
||||
merged.extend(pending.pop(league)) # the league's games were all live
|
||||
leagues = list(leagues)
|
||||
for league, rest in pending.items():
|
||||
merged.extend(rest)
|
||||
if league not in leagues:
|
||||
leagues.append(league)
|
||||
return merged, leagues
|
||||
|
||||
|
||||
class VegasCardCache:
|
||||
"""Cards drawn once per fingerprint, kept for as long as their game is.
|
||||
|
||||
``element(key, fingerprint, render)`` returns a VegasElement whose image is
|
||||
``render()``'s -- called only when the fingerprint differs from the one the
|
||||
cached card was drawn for. The fingerprint is also the element's version,
|
||||
so the ticker skips unchanged cards without comparing pixels.
|
||||
|
||||
Bounded: keys not passed to :meth:`retain` after a slate are dropped, and
|
||||
at most ``max_entries`` are ever held (oldest first).
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 96) -> None:
|
||||
self.max_entries = max(1, int(max_entries))
|
||||
self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict()
|
||||
self.renders = 0
|
||||
|
||||
def element(self, key: str, fingerprint: Hashable,
|
||||
render: Callable[[], Image.Image], live: bool = True) -> Any:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
cached = self._cards.get(key)
|
||||
if cached is not None and cached[0] == fingerprint:
|
||||
self._cards.move_to_end(key)
|
||||
image = cached[1]
|
||||
else:
|
||||
image = render()
|
||||
self.renders += 1
|
||||
self._cards[key] = (fingerprint, image)
|
||||
self._cards.move_to_end(key)
|
||||
while len(self._cards) > self.max_entries:
|
||||
self._cards.popitem(last=False)
|
||||
return VegasElement(key=key, image=image, version=fingerprint, live=live)
|
||||
|
||||
def retain(self, keys: Iterable[str]) -> None:
|
||||
"""Forget every card whose key is not in ``keys``."""
|
||||
keep = set(keys)
|
||||
for key in [k for k in self._cards if k not in keep]:
|
||||
self._cards.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cards.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cards)
|
||||
|
||||
|
||||
class StickyOdds:
|
||||
"""Keep a game's last odds on its card while a live poll leaves them out.
|
||||
|
||||
Live odds are fetched only for games near the front of the rotation
|
||||
(src/common/sports_fetch.py), so the same game's dict has odds on one poll
|
||||
and none on the next. Drawn as-is that redraws the card every poll with
|
||||
the odds flickering in and out. ``apply`` returns the game with its last
|
||||
non-empty odds put back, for up to ``ttl_s`` seconds after they were seen.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_s: float = 600.0) -> None:
|
||||
self.ttl_s = float(ttl_s)
|
||||
self._seen: Dict[str, Tuple[float, Any]] = {}
|
||||
|
||||
def apply(self, key: str, game: Dict[str, Any],
|
||||
now: Optional[float] = None) -> Dict[str, Any]:
|
||||
now = time.monotonic() if now is None else now
|
||||
odds = game.get('odds')
|
||||
if odds:
|
||||
self._seen[key] = (now, odds)
|
||||
return game
|
||||
seen = self._seen.get(key)
|
||||
if seen is None or now - seen[0] > self.ttl_s:
|
||||
self._seen.pop(key, None)
|
||||
return game
|
||||
refilled = dict(game)
|
||||
refilled['odds'] = seen[1]
|
||||
return refilled
|
||||
|
||||
def retain(self, keys: Iterable[str]) -> None:
|
||||
keep = set(keys)
|
||||
for key in [k for k in self._seen if k not in keep]:
|
||||
self._seen.pop(key, None)
|
||||
+45
-3
@@ -451,8 +451,10 @@ class ConfigManager:
|
||||
template_config = json.load(f)
|
||||
|
||||
# Check if migration is needed
|
||||
if self._config_needs_migration(self.config, template_config):
|
||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
||||
needs_merge = self._config_needs_migration(self.config, template_config)
|
||||
if needs_merge or self._live_in_ticker_needs_migration():
|
||||
if needs_merge:
|
||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
||||
|
||||
# Create backup of current config
|
||||
backup_path = f"{self.config_path}.backup"
|
||||
@@ -461,7 +463,9 @@ class ConfigManager:
|
||||
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
|
||||
|
||||
# Merge template defaults into current config
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
if needs_merge:
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
self._migrate_live_in_ticker_default()
|
||||
|
||||
# save_config_atomic strips the merged secrets back out and
|
||||
# keeps the file's owner and mode.
|
||||
@@ -482,6 +486,44 @@ class ConfigManager:
|
||||
self.logger.error(f"Error during config migration: {e}")
|
||||
# Don't raise - continue with current config
|
||||
|
||||
#: Set in display.vegas_scroll once _migrate_live_in_ticker_default() has
|
||||
#: run. Never in the template: the template merge would add it first, and
|
||||
#: the flip would then never run.
|
||||
LIVE_IN_TICKER_MARKER = 'live_in_ticker_migrated'
|
||||
|
||||
def _vegas_scroll_section(self) -> Optional[Dict[str, Any]]:
|
||||
display = self.config.get('display')
|
||||
vegas = display.get('vegas_scroll') if isinstance(display, dict) else None
|
||||
return vegas if isinstance(vegas, dict) else None
|
||||
|
||||
def _live_in_ticker_needs_migration(self) -> bool:
|
||||
vegas = self._vegas_scroll_section()
|
||||
return vegas is not None and not vegas.get(self.LIVE_IN_TICKER_MARKER)
|
||||
|
||||
def _migrate_live_in_ticker_default(self) -> None:
|
||||
"""Turn on live_in_ticker for a config that only ever had the old default. Once.
|
||||
|
||||
LEDMatrix 3.8.0 makes ``display.vegas_scroll.live_in_ticker`` true:
|
||||
live games stay in the Vegas ticker, their cards updating while they
|
||||
scroll, instead of the ticker giving way to the full-screen
|
||||
scoreboard. Every existing config holds an explicit ``false`` copied
|
||||
from the template -- there was no control for it -- and the template
|
||||
merge only adds missing keys, so the new default would reach nobody.
|
||||
This rewrites that ``false`` once and marks the config, so a
|
||||
``false`` chosen afterwards (the Vegas checkbox, or by hand) stays.
|
||||
"""
|
||||
vegas = self._vegas_scroll_section()
|
||||
if vegas is None or vegas.get(self.LIVE_IN_TICKER_MARKER):
|
||||
return
|
||||
vegas[self.LIVE_IN_TICKER_MARKER] = True
|
||||
if vegas.get('live_in_ticker') is False:
|
||||
vegas['live_in_ticker'] = True
|
||||
self.logger.info(
|
||||
"Vegas mode now keeps live games in the ticker (the new default): "
|
||||
"display.vegas_scroll.live_in_ticker turned on, once. Untick "
|
||||
"\"Keep live games in the ticker\" under Vegas mode for the "
|
||||
"full-screen scoreboard.")
|
||||
|
||||
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
|
||||
"""Check if config needs migration by comparing with template."""
|
||||
return self._has_new_keys(current_config, template_config)
|
||||
|
||||
@@ -43,11 +43,14 @@ CORE_CONFIG_KEYS = frozenset({
|
||||
})
|
||||
|
||||
#: Top-level keys of ``config_secrets.json`` that belong to the core rather than
|
||||
#: to a plugin: the GitHub token the Plugin Store reads, and the historical
|
||||
#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything
|
||||
#: deciding whether a secrets section is a plugin's needs this as well as
|
||||
#: ``CORE_CONFIG_KEYS``.
|
||||
#: to a plugin: the GitHub token the Plugin Store reads, the historical
|
||||
#: ``youtube`` section, and ``web_auth`` (the optional web login's password
|
||||
#: hash and API-token hashes, web_interface/auth.py) -- which orphan-plugin
|
||||
#: cleanup would otherwise delete, logging everyone out. Plugin secrets are
|
||||
#: namespaced by plugin id, so anything deciding whether a secrets section is a
|
||||
#: plugin's needs this as well as ``CORE_CONFIG_KEYS``.
|
||||
CORE_SECRETS_KEYS = frozenset({
|
||||
'github',
|
||||
'youtube',
|
||||
'web_auth',
|
||||
})
|
||||
|
||||
@@ -6,6 +6,14 @@ still be called by a plugin nobody has checked. Such methods get
|
||||
process logs a warning naming the method and the release that removes it
|
||||
(visible in ``journalctl -u ledmatrix``), and emits a DeprecationWarning for
|
||||
tooling.
|
||||
|
||||
Before a release removes anything, ``scripts/plugin_api_usage.py`` scans core,
|
||||
the official plugin monorepo and the registry's third-party plugins for callers
|
||||
and overriders of every marked method. Its latest output is
|
||||
``docs/DEPRECATIONS_3.8.md``; remove only what it reports unused, and move the
|
||||
rest to a later release. ``test/test_deprecation.py`` fails while any marker
|
||||
names a release at or below ``src.__version__``, so a release cannot ship with
|
||||
a removal date it has already passed.
|
||||
"""
|
||||
|
||||
import functools
|
||||
@@ -45,3 +53,27 @@ def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F],
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def warn_deprecated(what: str, removal: str, alternative: Optional[str] = None,
|
||||
once_key: Optional[str] = None) -> bool:
|
||||
"""Warn that ``what`` will be removed in ``removal``, once per process.
|
||||
|
||||
For what ``@deprecated`` cannot decorate: a config key, a manifest field,
|
||||
a value a hook returns. Same message, log line and DeprecationWarning as
|
||||
the decorator. ``once_key`` (default: ``what``) is what "once" counts
|
||||
against, so one deprecated key can warn once for each plugin that sets it.
|
||||
|
||||
Returns whether this call warned.
|
||||
"""
|
||||
message = f"{what} is deprecated and will be removed in LEDMatrix {removal}"
|
||||
if alternative:
|
||||
message += f"; {alternative}"
|
||||
key = once_key or what
|
||||
with _warned_lock:
|
||||
if key in _warned:
|
||||
return False
|
||||
_warned.add(key)
|
||||
logger.warning(message)
|
||||
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||
return True
|
||||
|
||||
+280
-29
@@ -29,11 +29,12 @@ import threading
|
||||
import types
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Any, List, Optional, Callable, Tuple
|
||||
from typing import Dict, Any, List, Optional, Callable, Set, Tuple
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||
import pytz
|
||||
|
||||
from src import display_watchdog
|
||||
from src.display_manager import DisplayManager
|
||||
from src.config_manager import ConfigManager
|
||||
from src.config_service import ConfigService
|
||||
@@ -218,6 +219,7 @@ class DisplayController:
|
||||
# Initialize Plugin System
|
||||
plugin_time = time.time()
|
||||
self.plugin_manager = None
|
||||
self._plugin_runtime_publisher = None
|
||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||
@@ -266,6 +268,10 @@ class DisplayController:
|
||||
self.on_demand_last_error: Optional[str] = None
|
||||
self.on_demand_last_event: Optional[str] = None
|
||||
self.on_demand_schedule_override = False
|
||||
# Plugins that are disabled in config and loaded only because an
|
||||
# on-demand request named them. The main loop unloads each one once
|
||||
# on-demand has moved off it (_release_on_demand_plugins).
|
||||
self._on_demand_loaded_plugins: Set[str] = set()
|
||||
self.rotation_resume_index: Optional[int] = None
|
||||
# Saved rotation position when a live-priority plugin preempts the
|
||||
# rotation, so it resumes where it left off (not after the live plugin)
|
||||
@@ -318,6 +324,13 @@ class DisplayController:
|
||||
font_manager=self.font_manager
|
||||
)
|
||||
|
||||
# The web UI's loaded / state / error_info for each plugin read
|
||||
# what this publishes. Started before loading, so the loads that
|
||||
# follow are published as they land.
|
||||
from src.plugin_system.plugin_runtime import start_plugin_runtime_publisher
|
||||
self._plugin_runtime_publisher = start_plugin_runtime_publisher(
|
||||
self.cache_manager, self.plugin_manager.state_manager)
|
||||
|
||||
# Activate the plugin health/metrics subsystem. PluginManager leaves
|
||||
# health_tracker/resource_monitor as None by default; wiring real
|
||||
# instances here turns on the circuit breaker (a repeatedly-failing
|
||||
@@ -369,7 +382,11 @@ class DisplayController:
|
||||
"""Load a single plugin and return result."""
|
||||
plugin_load_start = time.time()
|
||||
try:
|
||||
if self.plugin_manager.load_plugin(plugin_id):
|
||||
if plugin_id in self._on_demand_loaded_plugins:
|
||||
loaded = self.plugin_manager.load_plugin(plugin_id, force_enabled=True)
|
||||
else:
|
||||
loaded = self.plugin_manager.load_plugin(plugin_id)
|
||||
if loaded:
|
||||
plugin_load_time = time.time() - plugin_load_start
|
||||
return {
|
||||
'success': True,
|
||||
@@ -444,6 +461,12 @@ class DisplayController:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Plugin system initialization failed")
|
||||
self.plugin_manager = None
|
||||
# Its state machine no longer describes what runs; let the last
|
||||
# snapshot go stale (readers then say unknown) rather than keep
|
||||
# refreshing it.
|
||||
if self._plugin_runtime_publisher is not None:
|
||||
self._plugin_runtime_publisher.stop(publish_stopped=False)
|
||||
self._plugin_runtime_publisher = None
|
||||
|
||||
# The web UI's Fonts tab ("Used by") reads what this publishes.
|
||||
from src.font_usage import start_font_usage_publisher
|
||||
@@ -1021,17 +1044,30 @@ class DisplayController:
|
||||
accepts_display_mode: Whether display() takes ``display_mode``.
|
||||
force_clear: Passed through to display().
|
||||
|
||||
Each call is timed (two monotonic reads) and handed to
|
||||
PluginManager.note_display_duration, which logs and records slow
|
||||
calls and counts one that ran past the executor's timeout as a hang.
|
||||
|
||||
Returns:
|
||||
display()'s result, or True when the frame was skipped because
|
||||
the plugin's update() holds its lock (the panel keeps the last
|
||||
frame; that is not a failure).
|
||||
"""
|
||||
with self._display_lock_or_skip(getattr(plugin, 'plugin_id', None)) as can_display:
|
||||
# Every frame of both per-screen render loops comes through here.
|
||||
display_watchdog.watchdog.beat()
|
||||
plugin_id = getattr(plugin, 'plugin_id', None)
|
||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||
if not can_display:
|
||||
return True
|
||||
if accepts_display_mode:
|
||||
return plugin.display(display_mode=mode, force_clear=force_clear)
|
||||
return plugin.display(force_clear=force_clear)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
if accepts_display_mode:
|
||||
return plugin.display(display_mode=mode, force_clear=force_clear)
|
||||
return plugin.display(force_clear=force_clear)
|
||||
finally:
|
||||
note = getattr(self.plugin_manager, 'note_display_duration', None)
|
||||
if note is not None and plugin_id:
|
||||
note(plugin_id, time.monotonic() - started)
|
||||
|
||||
def _health_tracker(self):
|
||||
"""The plugin circuit breaker, or None when it is not enabled."""
|
||||
@@ -1115,6 +1151,9 @@ class DisplayController:
|
||||
|
||||
sleep_time = min(tick_interval, remaining)
|
||||
time.sleep(sleep_time)
|
||||
# A dwell can be a minute long (sixty seconds while scheduled
|
||||
# off); the watchdog must hear from this thread throughout.
|
||||
display_watchdog.watchdog.beat()
|
||||
self._tick_plugin_updates()
|
||||
self._service_pending_changes()
|
||||
if (self.current_display_mode != mode
|
||||
@@ -1475,8 +1514,13 @@ class DisplayController:
|
||||
On-demand still resumes on its saved mode; this only widens what gets
|
||||
loaded, so normal rotation has somewhere to return to when it ends.
|
||||
A plugin that is disabled in config but named by the on-demand request
|
||||
is still enabled and added, since otherwise the mode being resumed
|
||||
would have nothing behind it.
|
||||
is still loaded, since otherwise the mode being resumed would have
|
||||
nothing behind it. It is tracked as loaded for on-demand only, the
|
||||
same as one loaded live by _activate_on_demand, so it is unloaded
|
||||
when the session ends instead of staying loaded until the next
|
||||
restart. Its config section is not touched: setting ``enabled`` in
|
||||
self.config wrote into the dict config_manager caches and returns to
|
||||
every later load_config() in this process.
|
||||
"""
|
||||
enabled_plugins = [p for p in discovered_plugins
|
||||
if self.config.get(p, {}).get('enabled', False)]
|
||||
@@ -1491,11 +1535,10 @@ class DisplayController:
|
||||
logger.warning("Falling back to normal mode (all enabled plugins)")
|
||||
return enabled_plugins
|
||||
|
||||
if not self.config.get(on_demand_plugin_id, {}).get('enabled', False):
|
||||
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
||||
self.config.setdefault(on_demand_plugin_id, {})['enabled'] = True
|
||||
if on_demand_plugin_id not in enabled_plugins:
|
||||
enabled_plugins.append(on_demand_plugin_id)
|
||||
if on_demand_plugin_id not in enabled_plugins:
|
||||
logger.info("Loading disabled plugin '%s' for on-demand mode only", on_demand_plugin_id)
|
||||
self._on_demand_loaded_plugins.add(on_demand_plugin_id)
|
||||
enabled_plugins.append(on_demand_plugin_id)
|
||||
|
||||
# Restore on-demand state from the cached request so it resumes.
|
||||
self.on_demand_active = True
|
||||
@@ -1591,6 +1634,11 @@ class DisplayController:
|
||||
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
||||
# Still update request_id to acknowledge the request
|
||||
self.on_demand_request_id = request_id
|
||||
if self.on_demand_status == 'error':
|
||||
# A failed request left status 'error' published, and
|
||||
# without this the status route kept reporting it until
|
||||
# the state aged out (120s) or another request came in.
|
||||
self._clear_on_demand(reason='requested-stop')
|
||||
# Stop requests are deliberately exempt from the request_id/
|
||||
# processed_id guards above, so that a second click stops a mode
|
||||
# that a race left running. Consuming the mailbox is therefore the
|
||||
@@ -1757,10 +1805,136 @@ class DisplayController:
|
||||
plugin_id, ordered_modes, self.on_demand_mode_index,
|
||||
ordered_modes[self.on_demand_mode_index] if ordered_modes else 'N/A')
|
||||
|
||||
def _load_plugin_for_on_demand(self, plugin_id: str) -> bool:
|
||||
"""Load an installed plugin that isn't running so on-demand can show it.
|
||||
|
||||
This process only loads the plugins enabled in config, so a request
|
||||
for a disabled one -- the config page's "Preview on display" button
|
||||
offers it on every plugin -- failed with "invalid-mode" while the UI
|
||||
said the plugin would be enabled for the session. Nothing did that
|
||||
short of a restart, and restarts no longer happen on a request.
|
||||
|
||||
Loads through the same path as a live enable (load_plugin, then
|
||||
_register_loaded_plugin), with force_enabled so the instance runs
|
||||
enabled while config.json keeps saying disabled. The plugin is
|
||||
recorded in _on_demand_loaded_plugins, and the main loop unloads it
|
||||
once on-demand moves off it (_release_on_demand_plugins).
|
||||
|
||||
Returns False after publishing an error when the load fails. A
|
||||
plugin that isn't installed returns True without loading anything:
|
||||
the mode checks that follow report it as they always have.
|
||||
"""
|
||||
if self.plugin_manager is None:
|
||||
return True
|
||||
try:
|
||||
known = self.plugin_manager.discovered_plugin_ids()
|
||||
except AttributeError:
|
||||
known = set(getattr(self.plugin_manager, 'plugin_manifests', ()) or ())
|
||||
if plugin_id not in known:
|
||||
# Installed after this process scanned: the web process checked
|
||||
# its own, fresher list before posting the request.
|
||||
try:
|
||||
known = set(self.plugin_manager.discover_plugins())
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("On-demand: plugin discovery failed")
|
||||
known = set()
|
||||
if plugin_id not in known:
|
||||
return True
|
||||
|
||||
logger.info("On-demand: loading disabled plugin '%s' for this session only", plugin_id)
|
||||
self._on_demand_loaded_plugins.add(plugin_id)
|
||||
try:
|
||||
loaded = self.plugin_manager.load_plugin(plugin_id, force_enabled=True)
|
||||
if loaded:
|
||||
modes = self._register_loaded_plugin(plugin_id)
|
||||
logger.info("On-demand: loaded plugin '%s' (modes: %s)", plugin_id, modes)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("On-demand: error loading plugin '%s'", plugin_id)
|
||||
loaded = False
|
||||
if not loaded:
|
||||
# Stays in _on_demand_loaded_plugins so the main loop removes
|
||||
# whatever part of it did get registered.
|
||||
logger.error("On-demand: could not load plugin '%s'", plugin_id)
|
||||
self._set_on_demand_error("load-failed")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _release_on_demand_plugins(self) -> None:
|
||||
"""Unload plugins loaded only for on-demand that it has moved off.
|
||||
|
||||
Runs from the main loop, right after its own on-demand poll, not
|
||||
where on-demand ends: a stop, an expiry or the next request is often
|
||||
read from inside a render loop or a dwell sleep, where the plugin
|
||||
being released may still be on the stack mid-display(). Unloading
|
||||
goes through _unregister_plugin, as a live disable does, and nothing
|
||||
is written to config.json.
|
||||
|
||||
A plugin the user enabled in the meantime stays loaded and takes its
|
||||
place in the rotation, which is what the reconcile that the enable
|
||||
queued would have done.
|
||||
"""
|
||||
if self.plugin_manager is None: # plugin system failed after startup restore
|
||||
self._on_demand_loaded_plugins.clear()
|
||||
return
|
||||
keep = self.on_demand_plugin_id if self.on_demand_active else None
|
||||
releasable = [p for p in self._on_demand_loaded_plugins if p != keep]
|
||||
if not releasable:
|
||||
return
|
||||
try:
|
||||
config = self.config_service.get_config()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.warning("On-demand release: falling back to cached config: %s", e)
|
||||
config = self.config
|
||||
previous_mode = self.current_display_mode
|
||||
for plugin_id in releasable:
|
||||
self._on_demand_loaded_plugins.discard(plugin_id)
|
||||
section = config.get(plugin_id)
|
||||
if isinstance(section, dict) and section.get('enabled', False):
|
||||
logger.info("On-demand: keeping plugin '%s' loaded; it was enabled "
|
||||
"while on-demand showed it", plugin_id)
|
||||
continue
|
||||
if (plugin_id in self.plugin_display_modes
|
||||
or self.plugin_manager.get_plugin(plugin_id) is not None):
|
||||
logger.info("On-demand: unloading plugin '%s'; it is disabled in config",
|
||||
plugin_id)
|
||||
self._unregister_plugin(plugin_id)
|
||||
if not self.on_demand_active:
|
||||
# Only outside a session: rotation_resume_index points into
|
||||
# available_modes until the session ends.
|
||||
self._apply_plugin_rotation_order()
|
||||
self._resync_mode_index_after_change(previous_mode)
|
||||
if self.current_display_mode != previous_mode:
|
||||
self.force_change = True
|
||||
|
||||
def _rotation_index_outside_on_demand(self, start: int) -> Optional[int]:
|
||||
"""First index from `start` (wrapping) whose mode is not owned by a
|
||||
plugin loaded only for on-demand, or None if every mode is.
|
||||
|
||||
Ending a session must not resume the rotation onto the plugin that
|
||||
is about to be unloaded. A live load appends that plugin's modes
|
||||
after the saved resume index, but a session restored after a
|
||||
restart has no saved index and its plugin was ordered in with the
|
||||
rest -- the rotation resumed onto it, and a stop read during its own
|
||||
screen changed nothing on the panel until that screen ended.
|
||||
"""
|
||||
if not self._on_demand_loaded_plugins:
|
||||
return start
|
||||
on_demand_only = {mode for plugin_id in self._on_demand_loaded_plugins
|
||||
for mode in self.plugin_display_modes.get(plugin_id, [])}
|
||||
count = len(self.available_modes)
|
||||
for step in range(count):
|
||||
index = (start + step) % count
|
||||
if self.available_modes[index] not in on_demand_only:
|
||||
return index
|
||||
return None
|
||||
|
||||
def _activate_on_demand(self, request: Dict[str, Any]) -> None:
|
||||
"""Activate on-demand mode for a specific plugin display."""
|
||||
plugin_id = request.get('plugin_id')
|
||||
mode = request.get('mode')
|
||||
if (plugin_id and plugin_id not in self.plugin_display_modes
|
||||
and not self._load_plugin_for_on_demand(plugin_id)):
|
||||
return
|
||||
resolved_mode = self._resolve_mode_for_plugin(plugin_id, mode)
|
||||
|
||||
if not resolved_mode:
|
||||
@@ -1866,6 +2040,15 @@ class DisplayController:
|
||||
self.on_demand_last_event = 'stop-request-ignored' # Already idle
|
||||
self._publish_on_demand_state()
|
||||
return
|
||||
if not self.on_demand_active and self.on_demand_status == 'error':
|
||||
# _set_on_demand_error already ended any session and dropped
|
||||
# rotation_resume_index; the full clear below would only move
|
||||
# the rotation and force a redraw. Just drop the error.
|
||||
self.on_demand_status = 'idle'
|
||||
self.on_demand_last_error = None
|
||||
self.on_demand_last_event = reason or 'cleared'
|
||||
self._publish_on_demand_state()
|
||||
return
|
||||
|
||||
self._reset_on_demand_fields()
|
||||
self.on_demand_status = 'idle'
|
||||
@@ -1875,17 +2058,27 @@ class DisplayController:
|
||||
# Clear on-demand configuration from cache
|
||||
self.cache_manager.clear_cache('display_on_demand_config')
|
||||
|
||||
if self.rotation_resume_index is not None and self.available_modes:
|
||||
self.current_mode_index = self.rotation_resume_index % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
logger.info("Resuming rotation from saved index %d: mode '%s'",
|
||||
self.rotation_resume_index, self.current_display_mode)
|
||||
elif self.available_modes:
|
||||
# Default to first mode if no resume index
|
||||
self.current_mode_index = self.current_mode_index % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
logger.info("Resuming rotation to mode '%s' (index %d)",
|
||||
self.current_display_mode, self.current_mode_index)
|
||||
if self.available_modes:
|
||||
saved = self.rotation_resume_index
|
||||
# Default to the current index if no resume index
|
||||
start = saved if saved is not None else self.current_mode_index
|
||||
index = self._rotation_index_outside_on_demand(start % len(self.available_modes))
|
||||
if index is None:
|
||||
# Every mode belongs to a plugin loaded only for on-demand,
|
||||
# which the main loop is about to unload; it then idles.
|
||||
self.current_mode_index = 0
|
||||
self.current_display_mode = None
|
||||
logger.info("No enabled mode to resume rotation to")
|
||||
elif saved is not None:
|
||||
self.current_mode_index = index
|
||||
self.current_display_mode = self.available_modes[index]
|
||||
logger.info("Resuming rotation from saved index %d: mode '%s'",
|
||||
saved, self.current_display_mode)
|
||||
else:
|
||||
self.current_mode_index = index
|
||||
self.current_display_mode = self.available_modes[index]
|
||||
logger.info("Resuming rotation to mode '%s' (index %d)",
|
||||
self.current_display_mode, self.current_mode_index)
|
||||
else:
|
||||
logger.warning("No available modes to resume rotation to")
|
||||
|
||||
@@ -2013,7 +2206,7 @@ class DisplayController:
|
||||
"""Whether live content should stay in the ticker instead of preempting it."""
|
||||
coordinator = self.vegas_coordinator
|
||||
config = getattr(coordinator, 'vegas_config', None)
|
||||
return bool(getattr(config, 'live_in_ticker', False))
|
||||
return bool(getattr(config, 'live_in_ticker', True))
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
@@ -2051,6 +2244,11 @@ class DisplayController:
|
||||
"plugin is enabled via the web UI."
|
||||
)
|
||||
|
||||
# This thread is the one the systemd watchdog and the heartbeat
|
||||
# vouch for: beats from any other thread are ignored, so a render
|
||||
# thread stuck inside a plugin stops them.
|
||||
display_watchdog.watchdog.bind_render_thread()
|
||||
|
||||
try:
|
||||
# Initialize with cached data for fast startup - let background updates refresh naturally
|
||||
logger.info("Starting display with cached data (fast startup mode)")
|
||||
@@ -2059,6 +2257,11 @@ class DisplayController:
|
||||
self._publish_current_mode_state()
|
||||
|
||||
while True:
|
||||
# Arms the watchdog after the first frame -- or after the
|
||||
# first full pass, when there is nothing to draw -- and pings
|
||||
# it from then on.
|
||||
display_watchdog.watchdog.loop_pass()
|
||||
|
||||
# Apply plugin enable/disable edits saved via the web UI. The
|
||||
# config-watcher thread only sets the flag; loading/unloading and
|
||||
# rebuilding available_modes happens here on the render thread so
|
||||
@@ -2082,6 +2285,14 @@ class DisplayController:
|
||||
# Handle on-demand commands before rendering
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
# Unload plugins loaded only to show them on-demand once it
|
||||
# has moved off them. Here, where no display() is on the
|
||||
# stack; one ended from inside a screen is caught here on
|
||||
# the next pass.
|
||||
if self._on_demand_loaded_plugins:
|
||||
self._release_on_demand_plugins()
|
||||
if not self.available_modes:
|
||||
continue # it was all there was; idle as above
|
||||
self._tick_plugin_updates()
|
||||
|
||||
# Clean up expired WiFi status messages
|
||||
@@ -2328,6 +2539,7 @@ class DisplayController:
|
||||
pm = self.plugin_manager
|
||||
display_lock = pm.get_plugin_lock(plugin_id) if pm else None
|
||||
can_display = display_lock is None or display_lock.acquire(blocking=False)
|
||||
display_hung = False
|
||||
|
||||
if display_lock is None:
|
||||
# Only when plugin loading failed part-way.
|
||||
@@ -2347,7 +2559,7 @@ class DisplayController:
|
||||
# thread actually finishes it, rather than
|
||||
# here when this dispatch merely returns.
|
||||
release_guard = threading.Lock()
|
||||
released = {'done': False}
|
||||
released = {'done': False, 'started': False}
|
||||
|
||||
def _release_display_lock():
|
||||
with release_guard:
|
||||
@@ -2358,6 +2570,7 @@ class DisplayController:
|
||||
|
||||
if _accepts_display_mode:
|
||||
def _display_target(display_mode=None, force_clear=False):
|
||||
released['started'] = True
|
||||
try:
|
||||
return manager_to_display.display(
|
||||
display_mode=display_mode, force_clear=force_clear)
|
||||
@@ -2365,11 +2578,13 @@ class DisplayController:
|
||||
_release_display_lock()
|
||||
else:
|
||||
def _display_target(force_clear=False):
|
||||
released['started'] = True
|
||||
try:
|
||||
return manager_to_display.display(force_clear=force_clear)
|
||||
finally:
|
||||
_release_display_lock()
|
||||
|
||||
dispatch_start = time.monotonic()
|
||||
try:
|
||||
result = pm.plugin_executor.execute_display(
|
||||
types.SimpleNamespace(display=_display_target),
|
||||
@@ -2392,6 +2607,18 @@ class DisplayController:
|
||||
_release_display_lock()
|
||||
raise
|
||||
|
||||
dispatch_seconds = time.monotonic() - dispatch_start
|
||||
if released['started'] and not released['done']:
|
||||
# The executor gave up waiting and display()
|
||||
# is still running on its thread, holding
|
||||
# the lock. A hang, not a success: recorded
|
||||
# so repeats open the circuit breaker, and
|
||||
# the update worker's bounded wait skips it.
|
||||
display_hung = True
|
||||
pm.record_display_hang(plugin_id, dispatch_seconds)
|
||||
else:
|
||||
pm.note_display_duration(plugin_id, dispatch_seconds)
|
||||
|
||||
logger.debug(f"display() returned: {result} (type: {type(result)})")
|
||||
if isinstance(result, bool):
|
||||
display_result = result
|
||||
@@ -2405,7 +2632,7 @@ class DisplayController:
|
||||
# be lost when display() finally does run.
|
||||
if can_display:
|
||||
health_tracker = self._health_tracker()
|
||||
if health_tracker is not None:
|
||||
if health_tracker is not None and not display_hung:
|
||||
health_tracker.record_success(plugin_id)
|
||||
self.force_change = False
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
@@ -3099,8 +3326,23 @@ class DisplayController:
|
||||
prepared = prepare(_pid, new_config) if callable(prepare) else None
|
||||
if isinstance(prepared, dict):
|
||||
new_config = prepared
|
||||
_plugin.on_config_change(new_config)
|
||||
logger.debug("Plugin %s notified of config change", _pid)
|
||||
if _pid in self._on_demand_loaded_plugins:
|
||||
# Saved while on-demand shows it: config.json still
|
||||
# says disabled, and on_config_change would switch
|
||||
# the instance off mid-session.
|
||||
new_config = {**new_config, 'enabled': True}
|
||||
# Runs on ConfigService's watcher thread. Under the
|
||||
# plugin's lock, so it cannot interleave with update()
|
||||
# on the worker or display() on the render thread; a
|
||||
# lock held past the bound defers it to the worker.
|
||||
apply = getattr(self.plugin_manager, 'apply_config_change', None)
|
||||
if callable(apply):
|
||||
applied = apply(_pid, new_config, plugin_instance=_plugin)
|
||||
else:
|
||||
_plugin.on_config_change(new_config)
|
||||
applied = True
|
||||
logger.debug("Plugin %s notified of config change%s", _pid,
|
||||
"" if applied else " (deferred: plugin busy)")
|
||||
except Exception as e:
|
||||
logger.error("Error in plugin %s config change handler: %s", _pid, e, exc_info=True)
|
||||
|
||||
@@ -3399,6 +3641,9 @@ class DisplayController:
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
# First: a clean stop is not a hang, and a heartbeat left behind
|
||||
# would read as a frozen panel to the web interface.
|
||||
display_watchdog.watchdog.stopping()
|
||||
# Stop the async update worker first so no in-flight update() call
|
||||
# is still touching display/cache-backed resources while they're
|
||||
# torn down below.
|
||||
@@ -3429,6 +3674,12 @@ class DisplayController:
|
||||
logger.warning("Error shutting down config service: %s", e)
|
||||
if getattr(self, '_font_usage_publisher', None) is not None:
|
||||
self._font_usage_publisher.stop()
|
||||
# Publishes "stopped", so the web UI stops reporting what was loaded.
|
||||
if getattr(self, '_plugin_runtime_publisher', None) is not None:
|
||||
try:
|
||||
self._plugin_runtime_publisher.stop()
|
||||
except Exception as e:
|
||||
logger.warning("Error stopping the plugin runtime publisher: %s", e)
|
||||
logger.info("Cleaning up display controller...")
|
||||
if hasattr(self, 'display_manager'):
|
||||
self.display_manager.cleanup()
|
||||
|
||||
+12
-9
@@ -57,6 +57,7 @@ import zlib
|
||||
import freetype
|
||||
|
||||
from src.common import snapshot_policy
|
||||
from src import display_watchdog
|
||||
from src.common.frame_timing import FrameTimingRecorder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -914,8 +915,7 @@ class DisplayManager:
|
||||
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
||||
|
||||
Serialized via ``_update_lock``: plugins can call this directly from
|
||||
background threads (e.g. sports base classes push an immediate
|
||||
"live" refresh from inside update()), so without a lock two callers
|
||||
background threads of their own, so without a lock two callers
|
||||
could both pass the digest check before either writes it back,
|
||||
double-pushing a frame, or interleave the offscreen/current canvas
|
||||
swap below. The lock is scoped to this method, so callers never
|
||||
@@ -928,6 +928,9 @@ class DisplayManager:
|
||||
# the fallback branch, so captured content never reaches the
|
||||
# web preview either.
|
||||
return
|
||||
# The render loop's watchdog arms on the first frame to reach
|
||||
# the panel (or the emulator/fallback path standing in for it).
|
||||
display_watchdog.note_frame()
|
||||
with self._update_lock:
|
||||
if self.matrix is None:
|
||||
# Fallback mode - no actual hardware to update
|
||||
@@ -1320,7 +1323,7 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing text: {e}", exc_info=True)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def draw_sun(self, x: int, y: int, size: int = 16):
|
||||
"""Draw a sun icon using yellow circles and lines."""
|
||||
center = (x + size//2, y + size//2)
|
||||
@@ -1341,7 +1344,7 @@ class DisplayManager:
|
||||
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
|
||||
"""Draw a cloud icon."""
|
||||
# Draw multiple circles to form a cloud shape
|
||||
@@ -1349,7 +1352,7 @@ class DisplayManager:
|
||||
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
|
||||
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def draw_rain(self, x: int, y: int, size: int = 16):
|
||||
"""Draw rain icon with cloud and droplets."""
|
||||
# Draw cloud
|
||||
@@ -1364,7 +1367,7 @@ class DisplayManager:
|
||||
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
|
||||
fill=drop_color, width=2)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def draw_snow(self, x: int, y: int, size: int = 16):
|
||||
"""Draw snow icon with cloud and snowflakes."""
|
||||
# Draw cloud
|
||||
@@ -1485,7 +1488,7 @@ class DisplayManager:
|
||||
]
|
||||
self.draw.polygon(bolt_points, fill=bolt_color)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
||||
"""Draw a weather icon based on the condition."""
|
||||
if condition.lower() in ['clear', 'sunny']:
|
||||
@@ -1502,7 +1505,7 @@ class DisplayManager:
|
||||
self._draw_sun(x, y, size)
|
||||
# Note: No update_display() here - let the caller handle the update
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
|
||||
color: tuple = (255, 255, 255)):
|
||||
"""Draw text with weather icons at specified positions."""
|
||||
@@ -1828,7 +1831,7 @@ class DisplayManager:
|
||||
if removed_count > 0:
|
||||
logger.debug(f"Cleaned up {removed_count} expired deferred updates")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_scrolling_stats(self) -> dict:
|
||||
"""Get current scrolling statistics for debugging."""
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Render-loop liveness: systemd watchdog pings and a heartbeat file.
|
||||
|
||||
A panel can freeze while ``ledmatrix.service`` stays "active": a plugin's
|
||||
``display()`` that never returns, a deadlock, a stuck hardware swap. Nothing
|
||||
outside the process could tell, so nothing restarted it. This module lets the
|
||||
render loop prove it is still going round, in two ways:
|
||||
|
||||
* **systemd watchdog.** The unit sets ``WatchdogSec=`` and
|
||||
``NotifyAccess=main``; this sends ``WATCHDOG=1`` over ``$NOTIFY_SOCKET``.
|
||||
When the pings stop, systemd kills the process (SIGABRT, so faulthandler
|
||||
prints every thread's stack to the journal first) and ``Restart=`` brings it
|
||||
back.
|
||||
* **Heartbeat file**, ``/run/ledmatrix/display-heartbeat.json``, for the web
|
||||
interface's ``/api/v3/health`` and the automatic update's health check.
|
||||
``/run`` is tmpfs, so the writes never reach the SD card.
|
||||
|
||||
Both are driven only from the render thread -- ``beat()`` from any other
|
||||
thread is ignored -- so a render thread stuck inside a plugin stops them even
|
||||
while every other thread carries on. The unit's ``WatchdogSec=`` is the
|
||||
steady-state limit; start-up (plugin loads, the 20s initial update budget,
|
||||
dependency installs) is far longer and happens before the render loop exists,
|
||||
so ``begin_startup()`` widens the limit for it and the render loop narrows it
|
||||
back, sends ``READY=1`` and starts pinging once its first frame is on the
|
||||
panel. See docs/ARCHITECTURE.md ("Liveness") for the unit settings.
|
||||
|
||||
Standard library only, and no import of the rest of ``src``: ``run.py`` loads
|
||||
this before anything heavy so the start-up allowance is in place long before
|
||||
the unit's own ``WatchdogSec`` could expire. Without ``$NOTIFY_SOCKET`` (dev
|
||||
server, emulator, Windows, an older unit) every call is a cheap no-op, and the
|
||||
heartbeat is written only where ``/run/ledmatrix`` exists or can be created.
|
||||
"""
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Iterator, Mapping, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Where the display writes its heartbeat. ``RuntimeDirectory=ledmatrix`` in
|
||||
#: the unit creates the directory; a display running under an older unit
|
||||
#: creates it itself (it runs as root). The web interface, which is not root,
|
||||
#: only reads it: the directory is 0755 and the file 0644.
|
||||
HEARTBEAT_DIR = '/run/ledmatrix'
|
||||
HEARTBEAT_NAME = 'display-heartbeat.json'
|
||||
HEARTBEAT_PATH = HEARTBEAT_DIR + '/' + HEARTBEAT_NAME
|
||||
|
||||
#: How often the render loop pings systemd and rewrites the heartbeat. Beats
|
||||
#: come many times a second; this is the rate limit on the side effects.
|
||||
BEAT_INTERVAL_SECONDS = 5.0
|
||||
|
||||
#: A heartbeat older than this means the render loop has stopped. Above the
|
||||
#: longest gap a healthy loop has (the executor's 30s display() timeout), so a
|
||||
#: slow plugin does not read as a frozen panel.
|
||||
HEARTBEAT_STALE_SECONDS = 60.0
|
||||
|
||||
#: The watchdog limit while the process starts, before the render loop runs.
|
||||
#: Start-up loads every plugin (pip included, when a dependency is missing:
|
||||
#: up to 300s a try), then spends up to 20s on initial updates. A hang in
|
||||
#: there is still caught, just later.
|
||||
STARTUP_ALLOWANCE_SECONDS = 15 * 60
|
||||
|
||||
#: The watchdog limit while the render thread loads a plugin that was just
|
||||
#: enabled from the web UI: loading can run pip, on this thread.
|
||||
PLUGIN_LOAD_ALLOWANCE_SECONDS = 15 * 60
|
||||
|
||||
|
||||
# -- sd_notify -------------------------------------------------------------
|
||||
|
||||
def notify(message: str, environ: Optional[Mapping[str, str]] = None,
|
||||
socket_factory: Optional[Callable[..., Any]] = None) -> bool:
|
||||
"""Send ``message`` to systemd over ``$NOTIFY_SOCKET``; True if it was sent.
|
||||
|
||||
The same protocol as libsystemd's ``sd_notify()``: one datagram of
|
||||
newline-separated ``KEY=VALUE`` lines to an AF_UNIX socket. An address
|
||||
starting with ``@`` is in the abstract namespace (a leading NUL byte).
|
||||
Never raises: a missing socket or a failed send is simply False, so a
|
||||
display run outside systemd behaves exactly as before.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
address = env.get('NOTIFY_SOCKET') or ''
|
||||
if address.startswith('@'):
|
||||
address = '\0' + address[1:]
|
||||
elif not address.startswith('/'):
|
||||
# Unset, or a vsock: address (systemd 253+, VMs only).
|
||||
return False
|
||||
family = getattr(socket, 'AF_UNIX', None)
|
||||
if family is None:
|
||||
return False
|
||||
factory = socket_factory or socket.socket
|
||||
try:
|
||||
sock = factory(family, socket.SOCK_DGRAM | getattr(socket, 'SOCK_CLOEXEC', 0))
|
||||
try:
|
||||
sock.connect(address)
|
||||
sock.sendall(message.encode('utf-8'))
|
||||
finally:
|
||||
sock.close()
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.debug("sd_notify(%r) failed: %s", message, e)
|
||||
return False
|
||||
|
||||
|
||||
def watchdog_usec(environ: Optional[Mapping[str, str]] = None) -> Optional[int]:
|
||||
"""The unit's ``WatchdogSec`` in microseconds, or None when it has none.
|
||||
|
||||
systemd passes it as ``$WATCHDOG_USEC``, with ``$WATCHDOG_PID`` naming the
|
||||
process it is meant for (a child that inherited the environment must not
|
||||
think the watchdog is its own).
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
pid = env.get('WATCHDOG_PID')
|
||||
if pid and pid != str(os.getpid()):
|
||||
return None
|
||||
try:
|
||||
usec = int(env.get('WATCHDOG_USEC', ''))
|
||||
except ValueError:
|
||||
return None
|
||||
return usec if usec > 0 else None
|
||||
|
||||
|
||||
# -- heartbeat reading (web interface) ---------------------------------------
|
||||
|
||||
def read_heartbeat(path: str = HEARTBEAT_PATH) -> Optional[Dict[str, Any]]:
|
||||
"""The heartbeat the display last wrote, or None when there is none.
|
||||
|
||||
None covers a display that does not write one -- dev server, emulator,
|
||||
Windows, a display that has not drawn its first frame yet -- as well as an
|
||||
unreadable file, so callers fall back to whatever they did before.
|
||||
"""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def heartbeat_age(data: Mapping[str, Any], now_mono: Optional[float] = None,
|
||||
now_wall: Optional[float] = None) -> Optional[float]:
|
||||
"""Seconds since the heartbeat in ``data`` was written, or None if it has no time.
|
||||
|
||||
Measured on the monotonic clock when it can be: on Linux that is
|
||||
CLOCK_MONOTONIC, shared by every process, and it does not jump when NTP
|
||||
first corrects the clock of a Pi with no RTC. /run is emptied at boot, so
|
||||
a heartbeat always comes from this boot. Falls back to the wall clock.
|
||||
"""
|
||||
now_mono = time.monotonic() if now_mono is None else now_mono
|
||||
now_wall = time.time() if now_wall is None else now_wall
|
||||
mono = data.get('mono')
|
||||
if isinstance(mono, (int, float)) and not isinstance(mono, bool):
|
||||
age = now_mono - mono
|
||||
if age >= -1.0: # a clock this far behind is not the same clock
|
||||
return max(age, 0.0)
|
||||
wall = data.get('wall')
|
||||
if isinstance(wall, (int, float)) and not isinstance(wall, bool):
|
||||
return max(now_wall - wall, 0.0)
|
||||
return None
|
||||
|
||||
|
||||
# -- the render loop's side ----------------------------------------------------
|
||||
|
||||
_DEFAULT_DIR = object()
|
||||
|
||||
|
||||
class RenderWatchdog:
|
||||
"""Pings systemd and writes the heartbeat, from the render thread only.
|
||||
|
||||
Lifecycle: ``begin_startup()`` as the process starts, ``bind_render_thread()``
|
||||
when ``DisplayController.run()`` starts, then ``note_frame()`` for every
|
||||
frame pushed to the panel and ``beat()`` / ``loop_pass()`` from every place
|
||||
the render loop reliably comes back to. The first beat after the first
|
||||
frame (or after the loop's first full pass, when there is nothing to draw)
|
||||
arms it: ``READY=1``, the unit's own ``WatchdogSec``, and the heartbeat.
|
||||
"""
|
||||
|
||||
def __init__(self, environ: Optional[Mapping[str, str]] = None,
|
||||
send: Optional[Callable[[str], bool]] = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
wall_clock: Callable[[], float] = time.time,
|
||||
heartbeat_dir: Any = _DEFAULT_DIR,
|
||||
enable_faulthandler: bool = True):
|
||||
env = dict(os.environ if environ is None else environ)
|
||||
self._send = send or (lambda message: notify(message, env))
|
||||
self._clock = clock
|
||||
self._wall_clock = wall_clock
|
||||
self._usec = watchdog_usec(env)
|
||||
if heartbeat_dir is _DEFAULT_DIR:
|
||||
# /run exists only on Linux; elsewhere (Windows dev) there is no
|
||||
# heartbeat rather than a C:\run folder.
|
||||
heartbeat_dir = HEARTBEAT_DIR if os.name == 'posix' else None
|
||||
self._heartbeat_dir: Optional[str] = heartbeat_dir
|
||||
# None until the first write; False for good if that one failed
|
||||
# (nowhere to write: not root, no /run); True once one landed.
|
||||
self._heartbeat_ok: Optional[bool] = None
|
||||
self._heartbeat_warned = False
|
||||
self._enable_faulthandler = enable_faulthandler
|
||||
self._render_thread: Optional[int] = None
|
||||
self._frame_pushed = False
|
||||
self._passes = 0
|
||||
self._armed = False
|
||||
self._last_beat: Optional[float] = None
|
||||
self._extend_depth = 0
|
||||
interval = BEAT_INTERVAL_SECONDS
|
||||
if self._usec:
|
||||
# systemd's advice is to ping at half the limit; a third leaves
|
||||
# room for one late beat even if someone sets a very short one.
|
||||
interval = min(interval, self._usec / 1e6 / 3)
|
||||
self._interval = interval
|
||||
|
||||
@property
|
||||
def armed(self) -> bool:
|
||||
return self._armed
|
||||
|
||||
def _on_render_thread(self) -> bool:
|
||||
return self._render_thread is not None and threading.get_ident() == self._render_thread
|
||||
|
||||
def begin_startup(self) -> None:
|
||||
"""Widen the watchdog to cover start-up. Call as early as possible.
|
||||
|
||||
systemd starts the watchdog clock when a Type=simple service starts,
|
||||
and start-up routinely takes longer than the render loop's limit.
|
||||
Only widens: an operator who set a longer ``WatchdogSec`` keeps it.
|
||||
"""
|
||||
if not self._usec:
|
||||
return
|
||||
allowance = max(self._usec, int(STARTUP_ALLOWANCE_SECONDS * 1e6))
|
||||
self._send(f'WATCHDOG_USEC={allowance}\nSTATUS=Starting: loading plugins')
|
||||
|
||||
def bind_render_thread(self) -> None:
|
||||
"""Mark the calling thread as the render thread; beats from others are ignored."""
|
||||
self._render_thread = threading.get_ident()
|
||||
self._frame_pushed = False
|
||||
self._passes = 0
|
||||
|
||||
def note_frame(self) -> None:
|
||||
"""A frame was pushed to the panel (DisplayManager.update_display).
|
||||
|
||||
Any thread may push the first one -- the first dispatch of a screen
|
||||
runs on PluginExecutor's thread -- so this only records it; the
|
||||
render thread's next beat arms the watchdog.
|
||||
"""
|
||||
if self._render_thread is None:
|
||||
return # start-up screens, before the render loop exists
|
||||
self._frame_pushed = True
|
||||
if self._on_render_thread():
|
||||
self.beat()
|
||||
|
||||
def loop_pass(self) -> None:
|
||||
"""The top of the render loop's ``while True``.
|
||||
|
||||
A second arrival here means a whole pass finished. That counts as the
|
||||
first frame when there was nothing to draw (no plugins enabled, every
|
||||
screen empty): the loop is plainly alive, and a watchdog that never
|
||||
armed would leave a later hang uncaught.
|
||||
"""
|
||||
if not self._on_render_thread():
|
||||
return
|
||||
self._passes += 1
|
||||
if self._passes > 1:
|
||||
self._frame_pushed = True
|
||||
self.beat()
|
||||
|
||||
def beat(self) -> None:
|
||||
"""The render loop is still going round. Cheap; call it freely."""
|
||||
if not self._on_render_thread():
|
||||
return
|
||||
if not self._armed:
|
||||
if not self._frame_pushed:
|
||||
return
|
||||
self._arm()
|
||||
return
|
||||
now = self._clock()
|
||||
if self._last_beat is not None and now - self._last_beat < self._interval:
|
||||
return
|
||||
self._last_beat = now
|
||||
if self._usec:
|
||||
self._send('WATCHDOG=1')
|
||||
self._write_heartbeat(now)
|
||||
|
||||
def _arm(self) -> None:
|
||||
self._armed = True
|
||||
self._last_beat = self._clock()
|
||||
if self._usec:
|
||||
# Back from the start-up allowance to the unit's own limit.
|
||||
self._send(f'READY=1\nWATCHDOG_USEC={self._usec}\nWATCHDOG=1\nSTATUS=Rendering')
|
||||
self._install_faulthandler()
|
||||
logger.info("systemd watchdog armed: the render loop must check in every %.0fs",
|
||||
self._usec / 1e6)
|
||||
else:
|
||||
self._send('READY=1\nSTATUS=Rendering')
|
||||
self._write_heartbeat(self._last_beat)
|
||||
|
||||
def _install_faulthandler(self) -> None:
|
||||
"""Dump every thread's stack when the watchdog's SIGABRT arrives.
|
||||
|
||||
That trace, in the journal, is what says which plugin the render
|
||||
thread was stuck in.
|
||||
"""
|
||||
if not self._enable_faulthandler:
|
||||
return
|
||||
try:
|
||||
import faulthandler
|
||||
import sys
|
||||
if not faulthandler.is_enabled() and sys.stderr is not None:
|
||||
faulthandler.enable(all_threads=True)
|
||||
except (ImportError, RuntimeError, ValueError, OSError, AttributeError) as e:
|
||||
logger.debug("faulthandler not enabled: %s", e)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def extended(self, seconds: float, reason: str = '') -> Iterator[None]:
|
||||
"""Allow the render thread ``seconds`` for one blocking job.
|
||||
|
||||
For the few legitimate jobs that can outlast the watchdog, such as
|
||||
loading a newly enabled plugin, which can run pip on this thread.
|
||||
Nests; the unit's limit comes back when the outermost one ends.
|
||||
"""
|
||||
if not (self._armed and self._usec and self._on_render_thread()):
|
||||
yield
|
||||
return
|
||||
usec = max(self._usec, int(seconds * 1e6))
|
||||
if self._extend_depth == 0:
|
||||
self._send(f'WATCHDOG_USEC={usec}\nWATCHDOG=1'
|
||||
+ (f'\nSTATUS=Busy: {reason}' if reason else ''))
|
||||
self._extend_depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._extend_depth -= 1
|
||||
if self._extend_depth == 0:
|
||||
self._send(f'WATCHDOG_USEC={self._usec}\nWATCHDOG=1\nSTATUS=Rendering')
|
||||
self._last_beat = self._clock()
|
||||
self._write_heartbeat(self._last_beat)
|
||||
|
||||
def stopping(self) -> None:
|
||||
"""Clean shutdown: tell systemd, and take the heartbeat down with us.
|
||||
|
||||
A heartbeat left behind by a stopped display would read as a frozen
|
||||
one to the web interface.
|
||||
"""
|
||||
if self._usec or self._armed:
|
||||
self._send('STOPPING=1')
|
||||
path = self._heartbeat_path()
|
||||
if path and self._heartbeat_ok:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# -- heartbeat file ----------------------------------------------------
|
||||
|
||||
def _heartbeat_path(self) -> Optional[str]:
|
||||
if not self._heartbeat_dir:
|
||||
return None
|
||||
return os.path.join(self._heartbeat_dir, HEARTBEAT_NAME)
|
||||
|
||||
def _write_heartbeat(self, now_mono: float) -> None:
|
||||
path = self._heartbeat_path()
|
||||
if path is None or self._heartbeat_ok is False:
|
||||
return
|
||||
directory = self._heartbeat_dir
|
||||
try:
|
||||
if not os.path.isdir(directory):
|
||||
# An install whose unit predates RuntimeDirectory=: the
|
||||
# display runs as root and can make it. Anyone else cannot,
|
||||
# and gets no heartbeat -- which readers treat as "unknown".
|
||||
os.makedirs(directory, mode=0o755, exist_ok=True)
|
||||
payload = json.dumps({'pid': os.getpid(), 'mono': now_mono,
|
||||
'wall': self._wall_clock()})
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix='.heartbeat-')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(payload)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
if self._heartbeat_ok is None:
|
||||
logger.info("Writing the display heartbeat to %s", path)
|
||||
self._heartbeat_ok = True
|
||||
except OSError as e:
|
||||
if self._heartbeat_ok is None:
|
||||
logger.info("Not writing a display heartbeat (%s: %s); health checks "
|
||||
"fall back to their older signals", directory, e)
|
||||
self._heartbeat_ok = False
|
||||
elif not self._heartbeat_warned:
|
||||
# It worked before, so keep trying, but say so only once.
|
||||
logger.warning("Could not update the display heartbeat: %s", e)
|
||||
self._heartbeat_warned = True
|
||||
|
||||
|
||||
#: The process-wide instance: one display process, one render loop.
|
||||
watchdog = RenderWatchdog()
|
||||
|
||||
|
||||
def beat() -> None:
|
||||
"""Module-level shortcut so the Vegas loop and the plugin manager need no reference."""
|
||||
watchdog.beat()
|
||||
|
||||
|
||||
def note_frame() -> None:
|
||||
watchdog.note_frame()
|
||||
|
||||
|
||||
def extended(seconds: float, reason: str = ''):
|
||||
return watchdog.extended(seconds, reason)
|
||||
+14
-14
@@ -187,7 +187,7 @@ class FontManager:
|
||||
if removed:
|
||||
self.manager_fonts_version += 1
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get registered fonts for a specific manager or all managers.
|
||||
@@ -202,7 +202,7 @@ class FontManager:
|
||||
return self.manager_fonts.get(manager_id, {})
|
||||
return self.manager_fonts.copy()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all detected font usage across managers."""
|
||||
return self.detected_fonts.copy()
|
||||
@@ -433,7 +433,7 @@ class FontManager:
|
||||
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
|
||||
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Unregister all fonts for a plugin."""
|
||||
try:
|
||||
@@ -471,7 +471,7 @@ class FontManager:
|
||||
# Font objects someone may hold were dropped; see cache_generation.
|
||||
self.cache_generation += 1
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
||||
"""Get list of font families registered by a plugin."""
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
@@ -670,7 +670,7 @@ class FontManager:
|
||||
|
||||
# ==================== Override Management ====================
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def set_override(self, element_key: str, family: str = None, size_px: int = None):
|
||||
"""Set font override for a specific element."""
|
||||
if element_key not in self.font_overrides:
|
||||
@@ -690,7 +690,7 @@ class FontManager:
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def remove_override(self, element_key: str):
|
||||
"""Remove font override for a specific element."""
|
||||
if element_key in self.font_overrides:
|
||||
@@ -699,7 +699,7 @@ class FontManager:
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override removed for {element_key}")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_overrides(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Get current font overrides."""
|
||||
return self.font_overrides.copy()
|
||||
@@ -787,17 +787,17 @@ class FontManager:
|
||||
self.cache_generation += 1
|
||||
logger.info("Font cache cleared")
|
||||
|
||||
@deprecated("3.7.0", "read font_catalog")
|
||||
@deprecated("3.8.0", "read font_catalog")
|
||||
def get_available_fonts(self) -> Dict[str, str]:
|
||||
"""Get dictionary of available font families and their paths."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_size_tokens(self) -> Dict[str, int]:
|
||||
"""Get available size tokens."""
|
||||
return self.size_tokens.copy()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def get_performance_stats(self) -> Dict[str, Any]:
|
||||
"""Get performance statistics."""
|
||||
uptime = time.time() - self.performance_stats["start_time"]
|
||||
@@ -819,12 +819,12 @@ class FontManager:
|
||||
"detected_fonts": len(self.detected_fonts)
|
||||
}
|
||||
|
||||
@deprecated("3.7.0", "read font_catalog")
|
||||
@deprecated("3.8.0", "read font_catalog")
|
||||
def get_font_catalog(self) -> Dict[str, str]:
|
||||
"""Get the current font catalog."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
||||
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
|
||||
stays where it is; only assets/fonts is created if it is missing."""
|
||||
@@ -852,7 +852,7 @@ class FontManager:
|
||||
logger.error(f"Error adding font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def remove_font(self, family_name: str) -> bool:
|
||||
"""Remove a font from the catalog."""
|
||||
try:
|
||||
@@ -880,7 +880,7 @@ class FontManager:
|
||||
logger.error(f"Error removing font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.7.0")
|
||||
@deprecated("3.8.0")
|
||||
def validate_font(self, font_path: str) -> Dict[str, Any]:
|
||||
"""Validate a font file."""
|
||||
try:
|
||||
|
||||
@@ -13,7 +13,10 @@ from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
import os
|
||||
import sys
|
||||
from src.deprecation import deprecated, warn_deprecated
|
||||
from src.logging_config import get_logger
|
||||
# Re-exported: a plugin may import it from here beside VegasDisplayMode.
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: F401
|
||||
|
||||
|
||||
_shared_fallback_font_manager: Optional[Any] = None
|
||||
@@ -65,27 +68,178 @@ def _fallback_font_manager() -> Any:
|
||||
|
||||
class VegasDisplayMode(Enum):
|
||||
"""
|
||||
Display mode for Vegas scroll integration.
|
||||
Legacy display mode for Vegas scroll integration.
|
||||
|
||||
Determines how a plugin's content behaves within the continuous scroll:
|
||||
Superseded by :meth:`BasePlugin.get_vegas_participation`. Vegas still
|
||||
reads a plugin's :meth:`BasePlugin.get_vegas_display_mode` to derive its
|
||||
participation when nothing declares one, and only STATIC matters there:
|
||||
|
||||
- SCROLL: Content scrolls continuously within the stream.
|
||||
Best for multi-item plugins like sports scores, odds tickers, news feeds.
|
||||
Plugin provides multiple frames via get_vegas_content().
|
||||
|
||||
- FIXED_SEGMENT: Content is a fixed-width block that scrolls BY with
|
||||
the rest of the content. Best for static info like clock, weather.
|
||||
Plugin provides a single image sized to vegas_panel_count panels.
|
||||
|
||||
- STATIC: Scroll pauses, plugin displays for its duration, then scroll
|
||||
resumes. Best for important alerts or detailed views that need attention.
|
||||
Plugin uses standard display() method during the pause.
|
||||
- STATIC: the scroll pauses for the plugin's turn and its display() draws
|
||||
it full screen -- participation ``'pause'``.
|
||||
- SCROLL and FIXED_SEGMENT: the plugin's content joins the scroll --
|
||||
participation ``'scroll'``. Vegas has never told the two apart: a card's
|
||||
width comes from get_vegas_content() and ``vegas_width_pct``, not from
|
||||
the mode. The distinction is deprecated and goes away in LEDMatrix 3.9.0.
|
||||
"""
|
||||
SCROLL = "scroll"
|
||||
FIXED_SEGMENT = "fixed"
|
||||
STATIC = "static"
|
||||
|
||||
|
||||
#: How a plugin takes part in Vegas mode (BasePlugin.get_vegas_participation):
|
||||
#:
|
||||
#: - ``'scroll'``: its content joins the scrolling strip.
|
||||
#: - ``'pause'``: the scroll stops when the plugin's turn comes round, and its
|
||||
#: display() draws it full screen for its display duration.
|
||||
#: - ``'exclude'``: it is left out of Vegas mode.
|
||||
VEGAS_PARTICIPATION_VALUES = ('scroll', 'pause', 'exclude')
|
||||
|
||||
#: The release that removes get_supported_vegas_modes(),
|
||||
#: get_vegas_segment_width(), the ``vegas_panel_count`` setting and the
|
||||
#: SCROLL / FIXED_SEGMENT distinction. The two @deprecated markers below
|
||||
#: spell it as a literal, because tools that read markers statically (the
|
||||
#: deprecation tests, the plugin API usage scan) cannot follow a name.
|
||||
VEGAS_LEGACY_REMOVAL = "3.9.0"
|
||||
|
||||
_vegas_logger = get_logger(__name__)
|
||||
_vegas_warned: set = set()
|
||||
|
||||
|
||||
def _vegas_warn_once(key: Any, message: str, *args: Any) -> None:
|
||||
"""Log a warning about a plugin's Vegas settings once per process.
|
||||
|
||||
Participation is resolved at every rotation refresh, so a bad value would
|
||||
otherwise log on every one of them.
|
||||
"""
|
||||
if key in _vegas_warned:
|
||||
return
|
||||
_vegas_warned.add(key)
|
||||
_vegas_logger.warning(message, *args)
|
||||
|
||||
|
||||
def vegas_participation_value(value: Any) -> Optional[str]:
|
||||
"""``value`` as one of VEGAS_PARTICIPATION_VALUES, or None if it is not one.
|
||||
|
||||
Case and surrounding whitespace are ignored; anything that is not a string
|
||||
(None, a MagicMock standing in for a plugin in a test) is not a value.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
value = value.strip().lower()
|
||||
if value in VEGAS_PARTICIPATION_VALUES:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def configured_vegas_participation(plugin_id: str, config: Any) -> Optional[str]:
|
||||
"""The user's ``vegas_participation`` setting in a plugin's config, if valid.
|
||||
|
||||
An unset or empty value is no setting. Anything else that is not a
|
||||
participation is logged once and ignored, so the plugin keeps its own.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
raw = config.get('vegas_participation')
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
return None
|
||||
value = vegas_participation_value(raw)
|
||||
if value is None:
|
||||
_vegas_warn_once(
|
||||
('config', plugin_id, repr(raw)),
|
||||
"[%s] Invalid vegas_participation %r, expected one of %s; ignoring it",
|
||||
plugin_id, raw, ', '.join(VEGAS_PARTICIPATION_VALUES))
|
||||
return value
|
||||
|
||||
|
||||
def legacy_vegas_participation(plugin: Any) -> str:
|
||||
"""The participation a plugin's pre-3.8 Vegas hooks describe.
|
||||
|
||||
Exactly what Vegas decided from them before participation existed:
|
||||
|
||||
1. get_vegas_display_mode() returning ``VegasDisplayMode.STATIC`` pauses,
|
||||
whatever the content type -- a STATIC plugin whose content type is
|
||||
``'none'`` was still kept in the rotation to pause it.
|
||||
2. Otherwise get_vegas_content_type() returning ``'none'`` excludes.
|
||||
3. Everything else scrolls. SCROLL and FIXED_SEGMENT were never told
|
||||
apart, and neither were content types ``'multi'``, ``'static'`` or any
|
||||
other string.
|
||||
|
||||
Only the enum member counts as STATIC (a plugin returning the string
|
||||
``'static'`` never paused), and a hook that raises or is missing counts as
|
||||
not STATIC and as content type ``'static'``.
|
||||
"""
|
||||
display_mode = None
|
||||
get_mode = getattr(plugin, 'get_vegas_display_mode', None)
|
||||
if get_mode is not None:
|
||||
try:
|
||||
display_mode = get_mode()
|
||||
except Exception:
|
||||
_vegas_logger.debug("get_vegas_display_mode() failed on %s; not pausing",
|
||||
type(plugin).__name__, exc_info=True)
|
||||
if display_mode == VegasDisplayMode.STATIC:
|
||||
return 'pause'
|
||||
|
||||
content_type = 'static'
|
||||
get_type = getattr(plugin, 'get_vegas_content_type', None)
|
||||
if get_type is not None:
|
||||
try:
|
||||
content_type = get_type()
|
||||
except Exception:
|
||||
_vegas_logger.debug("get_vegas_content_type() failed on %s; treating as 'static'",
|
||||
type(plugin).__name__, exc_info=True)
|
||||
if content_type == 'none':
|
||||
return 'exclude'
|
||||
return 'scroll'
|
||||
|
||||
|
||||
def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) -> str:
|
||||
"""How Vegas mode treats ``plugin``: ``'scroll'``, ``'pause'`` or ``'exclude'``.
|
||||
|
||||
What the core calls, rather than the plugin's own
|
||||
get_vegas_participation(), so the user's setting wins even over a plugin
|
||||
that overrides that method, and so a plugin that is not a BasePlugin (or a
|
||||
test double) still gets the legacy derivation:
|
||||
|
||||
1. the user's ``vegas_participation`` in the plugin's config;
|
||||
2. the plugin's get_vegas_participation(), when it returns a valid value
|
||||
(BasePlugin's reads the manifest's ``vegas_participation``, then
|
||||
derives one from the legacy hooks);
|
||||
3. legacy_vegas_participation().
|
||||
|
||||
Also where the deprecated ``vegas_panel_count`` setting is reported, once
|
||||
per plugin. Never raises.
|
||||
"""
|
||||
pid = plugin_id or getattr(plugin, 'plugin_id', None) or type(plugin).__name__
|
||||
config = getattr(plugin, 'config', None)
|
||||
if isinstance(config, dict) and 'vegas_panel_count' in config:
|
||||
warn_deprecated(
|
||||
f"The vegas_panel_count setting (plugin '{pid}')", VEGAS_LEGACY_REMOVAL,
|
||||
"it has no effect -- use vegas_width_pct to size the plugin's card",
|
||||
once_key=f"vegas_panel_count:{pid}")
|
||||
|
||||
configured = configured_vegas_participation(pid, config)
|
||||
if configured is not None:
|
||||
return configured
|
||||
|
||||
getter = getattr(plugin, 'get_vegas_participation', None)
|
||||
if callable(getter):
|
||||
try:
|
||||
declared = getter()
|
||||
except Exception:
|
||||
_vegas_logger.exception("[%s] get_vegas_participation() failed; "
|
||||
"using its legacy Vegas hooks", pid)
|
||||
declared = None
|
||||
value = vegas_participation_value(declared)
|
||||
if value is not None:
|
||||
return value
|
||||
if isinstance(declared, str):
|
||||
_vegas_warn_once(
|
||||
('declared', pid, declared),
|
||||
"[%s] get_vegas_participation() returned %r, expected one of %s; "
|
||||
"using its legacy Vegas hooks",
|
||||
pid, declared, ', '.join(VEGAS_PARTICIPATION_VALUES))
|
||||
return legacy_vegas_participation(plugin)
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""
|
||||
Base class that all plugins must inherit from.
|
||||
@@ -834,41 +988,179 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_vegas_elements(self) -> Optional[List[Any]]:
|
||||
"""
|
||||
Vegas content as live elements: named, fixed-width pieces the ticker
|
||||
can swap in place while they are on screen.
|
||||
|
||||
get_vegas_content() hands the ticker pictures, and a picture already
|
||||
in the scrolling strip keeps what it showed when it was drawn. Return
|
||||
a list of ``VegasElement`` (src/plugin_system/vegas_elements.py)
|
||||
instead and the ticker records where each one is; after your update()
|
||||
it calls this again, compares each element's ``version`` (or pixels)
|
||||
with what the strip holds, and swaps the changed ones in between two
|
||||
frames -- a score changes on a card already crossing the panel, and
|
||||
nothing next to it moves.
|
||||
|
||||
The contract:
|
||||
|
||||
- Called only on the ticker's background thread, under this plugin's
|
||||
lock (never while update() runs), on a canvas of its own and told
|
||||
its width (get_vegas_render_width()), like get_vegas_content().
|
||||
- Called often -- after every update() while any of your elements is
|
||||
on or ahead of the screen -- so it must be cheap when nothing
|
||||
changed (cache images by version), idempotent, and must not fetch.
|
||||
- A live element's width must not depend on its data: a redraw at a
|
||||
different width is not swapped in (it shows the next time the
|
||||
plugin comes round).
|
||||
- Keys must be unique in the list and stable for the same logical
|
||||
item.
|
||||
|
||||
Return None (the default) to use get_vegas_content(). A core older
|
||||
than 3.8.0 never calls this, so keep get_vegas_content() working and
|
||||
floor ``ledmatrix_min_version`` at 3.8.0 only if you rely on it.
|
||||
|
||||
Example (scoreboard)::
|
||||
|
||||
def get_vegas_elements(self):
|
||||
return [VegasElement(key=f"game:{g['id']}",
|
||||
image=self._card_for(g), # cached by fingerprint
|
||||
version=self._fingerprint(g))
|
||||
for g in self.games]
|
||||
|
||||
Returns:
|
||||
A list of VegasElement, or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def redraw_vegas_element(self, key: str, width: int, height: int,
|
||||
at: float) -> Optional[Any]:
|
||||
"""
|
||||
Redraw one live element for a moment in time, without the plugin lock.
|
||||
|
||||
Only for elements returned with ``refresh_hz > 0``: content that
|
||||
changes with time rather than with data, such as an aircraft moving
|
||||
between position reports. The ticker calls it up to that often while
|
||||
the element is on or near the screen.
|
||||
|
||||
- Called WITHOUT this plugin's lock, possibly while update() runs, so
|
||||
read only state that update() replaces in a single assignment (an
|
||||
immutable snapshot), never state it mutates in place.
|
||||
- ``at`` is the time.monotonic() at which the pixels are expected to
|
||||
reach the panel; draw the element as it should look then.
|
||||
- Return an image of exactly ``width`` x ``height``, or None to skip
|
||||
this tick.
|
||||
|
||||
Returns:
|
||||
PIL Image of exactly (width, height), or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def notify_vegas_data_changed(self) -> None:
|
||||
"""
|
||||
Tell the Vegas ticker this plugin's data changed outside update().
|
||||
|
||||
The ticker redraws a plugin's live elements when its update()
|
||||
completes. Data that lands some other way -- a background thread, a
|
||||
push callback -- calls this so the change reaches the screen without
|
||||
waiting for the next update(). Cheap and safe from any thread.
|
||||
"""
|
||||
notify = getattr(getattr(self, 'plugin_manager', None),
|
||||
'notify_data_changed', None)
|
||||
if callable(notify):
|
||||
notify(self.plugin_id)
|
||||
|
||||
def get_vegas_participation(self) -> str:
|
||||
"""
|
||||
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
||||
``'exclude'``.
|
||||
|
||||
- ``'scroll'``: the plugin's content (get_vegas_content()) joins the
|
||||
scrolling strip.
|
||||
- ``'pause'``: the scroll stops when the plugin's turn comes round, and
|
||||
its display() draws it full screen for get_display_duration().
|
||||
- ``'exclude'``: the plugin is left out of Vegas mode.
|
||||
|
||||
Resolved in this order:
|
||||
|
||||
1. the user's ``vegas_participation`` setting in this plugin's config
|
||||
(the web UI's per-plugin override);
|
||||
2. ``vegas_participation`` in the plugin's manifest.json -- the way a
|
||||
plugin declares its own default;
|
||||
3. derived from the legacy hooks, so a plugin written before this
|
||||
method existed keeps the behaviour it had: get_vegas_display_mode()
|
||||
returning ``VegasDisplayMode.STATIC`` pauses, otherwise
|
||||
get_vegas_content_type() returning ``'none'`` excludes, and
|
||||
everything else scrolls.
|
||||
|
||||
Declare a fixed participation in the manifest rather than overriding
|
||||
this. Override it only when the answer depends on state -- pause only
|
||||
while an alert is live, exclude while there is nothing to show. Vegas
|
||||
applies the user's setting before calling an override, so an override
|
||||
need not check it.
|
||||
|
||||
Returns:
|
||||
One of VEGAS_PARTICIPATION_VALUES.
|
||||
|
||||
Example:
|
||||
def get_vegas_participation(self):
|
||||
return 'pause' if self._alert_is_live() else 'scroll'
|
||||
"""
|
||||
configured = configured_vegas_participation(self.plugin_id, self.config)
|
||||
if configured is not None:
|
||||
return configured
|
||||
manifest_default = self._manifest_vegas_participation()
|
||||
if manifest_default is not None:
|
||||
return manifest_default
|
||||
return legacy_vegas_participation(self)
|
||||
|
||||
def _manifest_vegas_participation(self) -> Optional[str]:
|
||||
"""``vegas_participation`` from this plugin's manifest, if valid."""
|
||||
manifests = getattr(self.plugin_manager, 'plugin_manifests', None)
|
||||
manifest = manifests.get(self.plugin_id) if isinstance(manifests, dict) else None
|
||||
if not isinstance(manifest, dict) or manifest.get('vegas_participation') is None:
|
||||
return None
|
||||
raw = manifest['vegas_participation']
|
||||
value = vegas_participation_value(raw)
|
||||
if value is None:
|
||||
_vegas_warn_once(
|
||||
('manifest', self.plugin_id, repr(raw)),
|
||||
"[%s] manifest vegas_participation %r is not one of %s; ignoring it",
|
||||
self.plugin_id, raw, ', '.join(VEGAS_PARTICIPATION_VALUES))
|
||||
return value
|
||||
|
||||
def get_vegas_content_type(self) -> str:
|
||||
"""
|
||||
Indicate the type of content this plugin provides for Vegas scroll.
|
||||
Legacy: the type of content this plugin provides for Vegas scroll.
|
||||
|
||||
Override this to specify how Vegas mode should treat this plugin's content.
|
||||
Superseded by get_vegas_participation(). Vegas reads it only to derive
|
||||
a participation when neither the user nor the manifest declares one,
|
||||
and only ``'none'`` matters there: it excludes the plugin (unless
|
||||
get_vegas_display_mode() says STATIC). Every other value scrolls.
|
||||
|
||||
Returns:
|
||||
'multi' - Plugin has multiple scrollable items (sports, odds, news)
|
||||
'static' - Plugin is a static block (clock, weather, music)
|
||||
'none' - Plugin should not appear in Vegas scroll mode
|
||||
|
||||
Example:
|
||||
def get_vegas_content_type(self):
|
||||
return 'multi' # We have multiple games to scroll
|
||||
"""
|
||||
return 'static'
|
||||
|
||||
def get_vegas_display_mode(self) -> VegasDisplayMode:
|
||||
"""
|
||||
Get the display mode for Vegas scroll integration.
|
||||
Legacy: the display mode for Vegas scroll integration.
|
||||
|
||||
This method determines how the plugin's content behaves within Vegas mode:
|
||||
- SCROLL: Content scrolls continuously (multi-item plugins)
|
||||
- FIXED_SEGMENT: Fixed block that scrolls by (clock, weather)
|
||||
- STATIC: Pause scroll to display (alerts, detailed views)
|
||||
Superseded by get_vegas_participation(). Vegas reads it only to derive
|
||||
a participation when neither the user nor the manifest declares one,
|
||||
and only STATIC matters there: it pauses the scroll for the plugin's
|
||||
turn. SCROLL and FIXED_SEGMENT both scroll -- Vegas has never told them
|
||||
apart, and the distinction is deprecated (removed in 3.9.0).
|
||||
|
||||
Override to change default behavior. By default, reads from config
|
||||
or maps legacy get_vegas_content_type() for backward compatibility.
|
||||
Reads the plugin's ``vegas_mode`` config value, else maps
|
||||
get_vegas_content_type() ('multi' to SCROLL, anything else to
|
||||
FIXED_SEGMENT).
|
||||
|
||||
Returns:
|
||||
VegasDisplayMode enum value
|
||||
|
||||
Example:
|
||||
def get_vegas_display_mode(self):
|
||||
return VegasDisplayMode.SCROLL
|
||||
"""
|
||||
# Check for explicit config setting first
|
||||
config_mode = self.config.get("vegas_mode")
|
||||
@@ -888,13 +1180,16 @@ class BasePlugin(ABC):
|
||||
return VegasDisplayMode.SCROLL
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
|
||||
@deprecated("3.9.0",
|
||||
"nothing reads it -- declare vegas_participation in the manifest instead")
|
||||
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
||||
"""
|
||||
Return list of Vegas display modes this plugin supports.
|
||||
Deprecated: the Vegas display modes this plugin supports.
|
||||
|
||||
Not currently consulted by core: neither Vegas mode nor the web UI
|
||||
calls it. It is kept, and plugins override it, as the declared set of
|
||||
modes a future mode picker would offer.
|
||||
Never consulted by core -- neither Vegas mode nor the web UI calls it
|
||||
-- and removed in LEDMatrix 3.9.0. A plugin's own override keeps
|
||||
working for the plugin itself; calling this base implementation logs a
|
||||
deprecation warning.
|
||||
|
||||
By default:
|
||||
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
||||
@@ -903,11 +1198,6 @@ class BasePlugin(ABC):
|
||||
|
||||
Returns:
|
||||
List of VegasDisplayMode values this plugin can use
|
||||
|
||||
Example:
|
||||
def get_supported_vegas_modes(self):
|
||||
# This plugin only makes sense as a scrolling ticker
|
||||
return [VegasDisplayMode.SCROLL]
|
||||
"""
|
||||
content_type = self.get_vegas_content_type()
|
||||
|
||||
@@ -918,30 +1208,21 @@ class BasePlugin(ABC):
|
||||
else: # 'static'
|
||||
return [VegasDisplayMode.FIXED_SEGMENT, VegasDisplayMode.STATIC]
|
||||
|
||||
@deprecated("3.9.0",
|
||||
"nothing reads it -- Vegas sizes a card from vegas_width_pct "
|
||||
"(see get_vegas_render_width())")
|
||||
def get_vegas_segment_width(self) -> Optional[int]:
|
||||
"""
|
||||
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
||||
Deprecated: the number of panels this plugin wanted as a FIXED_SEGMENT.
|
||||
|
||||
Not currently consulted by core: Vegas mode sizes a card from the
|
||||
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings
|
||||
(see get_vegas_render_width()). Kept because plugins override it.
|
||||
|
||||
Returns the number of panels this plugin should occupy when displayed
|
||||
as a fixed segment. The actual pixel width is calculated as:
|
||||
width = panels * single_panel_width
|
||||
|
||||
Where single_panel_width comes from display.hardware.cols in config.
|
||||
|
||||
Override to provide dynamic sizing based on content.
|
||||
Returns None to use the default (1 panel).
|
||||
Never consulted by core: Vegas sizes a card from the
|
||||
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings (see
|
||||
get_vegas_render_width()). Removed, with the ``vegas_panel_count``
|
||||
setting it reads, in LEDMatrix 3.9.0.
|
||||
|
||||
Returns:
|
||||
Number of panels, or None for default (1 panel)
|
||||
|
||||
Example:
|
||||
def get_vegas_segment_width(self):
|
||||
# Clock needs 2 panels to show time clearly
|
||||
return 2
|
||||
``vegas_panel_count`` from config when it is a positive integer,
|
||||
else None
|
||||
"""
|
||||
raw_value = self.config.get("vegas_panel_count", None)
|
||||
if raw_value is None:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Plugin catalog: what the web process knows about installed plugins.
|
||||
|
||||
The web interface and the display run as two processes. Only the display
|
||||
imports plugin code and runs it; the web process reads plugins as files --
|
||||
manifest, config schema, the plugin's section of config.json, the installed
|
||||
version -- and never imports a plugin module, instantiates a plugin class or
|
||||
calls a plugin lifecycle hook. This class is that read side.
|
||||
|
||||
It keeps the method names of the read-only part of :class:`PluginManager`
|
||||
(``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``,
|
||||
``get_plugin_directory``, ``get_plugin_display_modes``,
|
||||
``find_plugin_for_mode``), so code that only ever read through a manager
|
||||
reads through a catalog unchanged. It has nothing that runs a plugin: no
|
||||
``load_plugin``, ``get_plugin`` or ``plugins``.
|
||||
|
||||
Runtime state -- whether the display has a plugin loaded, its health, its
|
||||
errors -- is not here either. The display process publishes what it knows to
|
||||
the shared cache (health and resource metrics, the current mode, the error
|
||||
aggregator snapshot), and the web routes read those publications. What the
|
||||
display does not publish (which plugins it has loaded, its plugin state
|
||||
machine) the web cannot know, and reports as unknown.
|
||||
|
||||
See docs/ARCHITECTURE.md ("Web and display processes").
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions, get_plugin_dir_mode,
|
||||
)
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_dirs import (
|
||||
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
|
||||
)
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
|
||||
|
||||
class PluginCatalog:
|
||||
"""Manifests, schemas, config and versions of the installed plugins.
|
||||
|
||||
Discovery is explicit and cheap to repeat: :meth:`discover_plugins`
|
||||
rescans the plugins directory and replaces the manifest map, so an
|
||||
uninstalled plugin disappears and a new one appears.
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
||||
schema_manager: Optional[Any] = None) -> None:
|
||||
self.plugins_dir: Path = Path(plugins_dir)
|
||||
self.config_manager = config_manager
|
||||
self.schema_manager = schema_manager
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Guards plugin_manifests/plugin_directories: request threads read
|
||||
# them while another request (or startup reconciliation) rescans.
|
||||
self._lock = threading.RLock()
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
self.plugin_directories: Dict[str, Path] = {}
|
||||
self._skip_reported: set = set()
|
||||
|
||||
# The Plugin Store installs into this directory, so it has to exist.
|
||||
# The display service logs its own error if it cannot use it; the web
|
||||
# interface stays up either way.
|
||||
try:
|
||||
ensure_directory_permissions(self.plugins_dir, get_plugin_dir_mode())
|
||||
except OSError as exc:
|
||||
self.logger.warning("Could not create plugins directory %s: %s",
|
||||
self.plugins_dir, exc)
|
||||
|
||||
# -- discovery --------------------------------------------------------
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""Rescan the plugins directory; return the discovered plugin ids.
|
||||
|
||||
The rules for what counts as a plugin and which directory wins for a
|
||||
duplicated id are :class:`PluginDirectoryIndex`'s, the same ones the
|
||||
display process loads by. Only the configured directory is scanned.
|
||||
"""
|
||||
index = PluginDirectoryIndex.scan(self.plugins_dir)
|
||||
if index.error is not None:
|
||||
self.logger.error("Error scanning plugins directory %s: %s",
|
||||
self.plugins_dir, index.error)
|
||||
for entry in index.entries:
|
||||
if entry.status in (ManifestStatus.UNREADABLE, ManifestStatus.NOT_OBJECT,
|
||||
ManifestStatus.NO_ID):
|
||||
# The display logs these at load time; once per process is
|
||||
# enough here, since discovery runs on page loads.
|
||||
if entry.name not in self._skip_reported:
|
||||
self._skip_reported.add(entry.name)
|
||||
self.logger.info("Not listing %s: its manifest.json is unusable (%s)",
|
||||
entry.name, entry.status)
|
||||
|
||||
plugins = index.plugins()
|
||||
manifests = {pid: entry.manifest for pid, entry in plugins.items()}
|
||||
directories = {pid: entry.path for pid, entry in plugins.items()}
|
||||
with self._lock:
|
||||
self.plugin_manifests.clear()
|
||||
self.plugin_manifests.update(manifests)
|
||||
self.plugin_directories.clear()
|
||||
self.plugin_directories.update(directories)
|
||||
return list(plugins)
|
||||
|
||||
def discovered_plugin_ids(self) -> set:
|
||||
"""Snapshot of the discovered ids, taken under the lock."""
|
||||
with self._lock:
|
||||
return set(self.plugin_manifests)
|
||||
|
||||
# -- manifests --------------------------------------------------------
|
||||
|
||||
def get_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""A copy of the manifest discovery read for ``plugin_id``, or None."""
|
||||
with self._lock:
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
return dict(manifest) if manifest else None
|
||||
|
||||
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""The plugin's manifest, as a new dict -- metadata only.
|
||||
|
||||
Unlike ``PluginManager.get_plugin_info`` there are no ``loaded``,
|
||||
``runtime_info`` or ``state`` keys: those described plugin instances
|
||||
in this process, which no longer exist.
|
||||
"""
|
||||
return self.get_manifest(plugin_id)
|
||||
|
||||
def get_all_plugin_info(self) -> List[Dict[str, Any]]:
|
||||
""":meth:`get_plugin_info` for every discovered plugin."""
|
||||
with self._lock:
|
||||
ids = list(self.plugin_manifests)
|
||||
return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]
|
||||
|
||||
def read_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""The manifest as it is on disk now, not as discovery last saw it.
|
||||
|
||||
For reads that must reflect a change made since the last scan -- the
|
||||
version just after an update, say. None when the plugin has no
|
||||
directory or its manifest is missing, unreadable or not an object.
|
||||
"""
|
||||
plugin_dir = self.get_plugin_directory(plugin_id)
|
||||
if plugin_dir is None:
|
||||
return None
|
||||
try:
|
||||
with open(Path(plugin_dir) / 'manifest.json', 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, ValueError) as exc:
|
||||
self.logger.debug("Could not read manifest for %s: %s", plugin_id, exc)
|
||||
return None
|
||||
return manifest if isinstance(manifest, dict) else None
|
||||
|
||||
def get_installed_version(self, plugin_id: str) -> str:
|
||||
"""The installed version from the on-disk manifest, or ''."""
|
||||
manifest = self.read_manifest(plugin_id) or {}
|
||||
version = manifest.get('version', '')
|
||||
return version if isinstance(version, str) else str(version)
|
||||
|
||||
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
|
||||
"""Where ``plugin_id`` is installed, or None.
|
||||
|
||||
Same rules as ``PluginManager.get_plugin_directory``: the discovered
|
||||
directory, else ``<id>`` then ``ledmatrix-<id>`` by name within the
|
||||
plugins directory. An id that is not one plain path segment is
|
||||
refused rather than joined onto the plugins directory.
|
||||
"""
|
||||
with self._lock:
|
||||
if plugin_id in self.plugin_directories:
|
||||
return str(self.plugin_directories[plugin_id])
|
||||
plugin_dir = resolve_plugin_dir(
|
||||
plugin_id, [self.plugins_dir], prefix=True, case_insensitive=False,
|
||||
by_manifest=False)
|
||||
return str(plugin_dir) if plugin_dir is not None else None
|
||||
|
||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||
"""The manifest's ``display_modes``, or [].
|
||||
|
||||
What the display actually rotates can differ: a plugin may compute
|
||||
its modes at run time (``plugin.modes``). This is the declared list.
|
||||
"""
|
||||
with self._lock:
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
modes = (manifest or {}).get('display_modes', [])
|
||||
return list(modes) if isinstance(modes, list) else []
|
||||
|
||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
|
||||
wanted = mode.strip().lower()
|
||||
with self._lock:
|
||||
manifests = dict(self.plugin_manifests)
|
||||
for plugin_id, manifest in manifests.items():
|
||||
modes = manifest.get('display_modes')
|
||||
if isinstance(modes, list) and any(
|
||||
isinstance(m, str) and m.lower() == wanted for m in modes):
|
||||
return plugin_id
|
||||
return None
|
||||
|
||||
# -- schema and config ------------------------------------------------
|
||||
|
||||
def get_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""The plugin's config schema through SchemaManager, or None."""
|
||||
if self.schema_manager is None:
|
||||
return None
|
||||
schema = self.schema_manager.load_schema(plugin_id, use_cache=use_cache)
|
||||
return cast(Optional[Dict[str, Any]], schema)
|
||||
|
||||
def get_config(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""The plugin's section of config.json (secrets merged), or {}."""
|
||||
if self.config_manager is None:
|
||||
return {}
|
||||
section = (self.config_manager.load_config() or {}).get(plugin_id)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
def is_enabled(self, plugin_id: str) -> bool:
|
||||
"""Whether config.json enables the plugin, by the display's rule.
|
||||
|
||||
The display loads a plugin only when its section says
|
||||
``"enabled": true``; a missing flag or section means disabled
|
||||
(``DisplayController._reconcile_enabled_plugins``).
|
||||
"""
|
||||
return bool(self.get_config(plugin_id).get('enabled', False))
|
||||
|
||||
|
||||
def display_restart_required(action: str, plugin_enabled: bool, *,
|
||||
changed: bool = True,
|
||||
preserve_config: bool = False) -> bool:
|
||||
"""Whether a store operation needs a display restart to reach the panel.
|
||||
|
||||
The display loads and unloads plugins live only through its config
|
||||
watcher: when a plugin's ``enabled`` flag changes it reconciles the
|
||||
running set (``DisplayController._reconcile_enabled_plugins``), and
|
||||
loading reads the plugin fresh from disk. Nothing makes it reload a
|
||||
plugin it is already running, and nothing tells it about files changing
|
||||
under a plugin whose flag did not move. So:
|
||||
|
||||
- ``install``: a plugin that is not enabled needs nothing -- enabling it
|
||||
later loads it. One already enabled in config (a reinstall, or a
|
||||
config carried over) is not picked up until a restart.
|
||||
- ``update``: the display keeps running the code it loaded until it
|
||||
restarts, if it runs the plugin at all -- only when it is enabled.
|
||||
``changed=False`` (already up to date) needs nothing.
|
||||
- ``uninstall``: removing the plugin's config section flips its enabled
|
||||
flag, and the reconcile unloads it. With ``preserve_config`` the flag
|
||||
stays, and an enabled plugin keeps running until a restart.
|
||||
|
||||
``plugin_enabled`` is the config flag as it was before the operation.
|
||||
"""
|
||||
if not plugin_enabled:
|
||||
return False
|
||||
if action == 'install':
|
||||
return True
|
||||
if action == 'update':
|
||||
return changed
|
||||
if action == 'uninstall':
|
||||
return preserve_config
|
||||
raise ValueError(f"unknown store action: {action!r}")
|
||||
@@ -19,8 +19,25 @@ class PluginTimeoutError(Exception):
|
||||
"""Raised when a plugin operation times out."""
|
||||
|
||||
|
||||
class PluginBusyError(PluginTimeoutError):
|
||||
"""A plugin's lock stayed held past its bound.
|
||||
|
||||
Not raised; recorded. The lock is held by the plugin's own display(),
|
||||
update(), on_config_change() or a Vegas content render -- slow, or hung
|
||||
-- so the caller skipped the plugin rather than wait on it. Report-only:
|
||||
it is kept as the plugin's state error info and counted as a busy skip in
|
||||
health, never as a failure, so it cannot open the circuit breaker.
|
||||
"""
|
||||
|
||||
|
||||
class PluginExecutor:
|
||||
"""Handles plugin execution with timeout and error isolation."""
|
||||
|
||||
#: A display() call at least this long is logged and counted as slow.
|
||||
#: A frame is milliseconds; two seconds is a plugin doing I/O in display().
|
||||
SLOW_DISPLAY_SECONDS = 2.0
|
||||
#: An update() call at least this long is logged as slow.
|
||||
SLOW_UPDATE_SECONDS = 5.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -117,15 +134,15 @@ class PluginExecutor:
|
||||
True if update succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
start_time = time.monotonic()
|
||||
self.execute_with_timeout(
|
||||
lambda: plugin.update(),
|
||||
timeout=timeout,
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
duration = time.time() - start_time
|
||||
duration = time.monotonic() - start_time
|
||||
|
||||
if duration > 5.0: # Warn if update takes more than 5 seconds
|
||||
if duration > self.SLOW_UPDATE_SECONDS:
|
||||
self.logger.warning(
|
||||
"Plugin %s update() took %.2fs (consider optimizing)",
|
||||
plugin_id,
|
||||
@@ -175,7 +192,7 @@ class PluginExecutor:
|
||||
True if display succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
start_time = time.time()
|
||||
start_time = time.monotonic()
|
||||
|
||||
# Does display() take a display_mode keyword? The caller usually
|
||||
# knows and caches the answer, so prefer what it passed.
|
||||
@@ -206,9 +223,9 @@ class PluginExecutor:
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
duration = time.monotonic() - start_time
|
||||
|
||||
if duration > 2.0: # Warn if display takes more than 2 seconds
|
||||
if duration > self.SLOW_DISPLAY_SECONDS:
|
||||
self.logger.warning(
|
||||
"Plugin %s display() took %.2fs (consider optimizing)",
|
||||
plugin_id,
|
||||
|
||||
@@ -254,6 +254,104 @@ class PluginHealthTracker:
|
||||
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def record_hang(self, plugin_id: str, operation: str, seconds: float,
|
||||
error: Optional[Exception] = None) -> None:
|
||||
"""Record a display() or update() call that ran past its limit.
|
||||
|
||||
Counts as a failure, so the ordinary circuit breaker handles a plugin
|
||||
that keeps hanging: after ``failure_threshold`` in a row it is skipped
|
||||
by both the update scheduler and the display rotation until the
|
||||
cooldown ends. The hang itself is kept alongside (``hang_count``,
|
||||
``last_hang``) so the health API can tell "hung" from "raised".
|
||||
|
||||
Not for an update skipped because the plugin's lock stayed held: the
|
||||
holder may be a healthy but long render (Vegas prefetch). That is
|
||||
:meth:`record_busy_skip`, which never touches the breaker.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
operation: What hung: ``"display"`` or ``"update"``.
|
||||
seconds: How long it had been running when this was recorded.
|
||||
error: The error to store as ``last_error``; one is built from
|
||||
the other arguments when omitted.
|
||||
"""
|
||||
state = self.get_health_state(plugin_id)
|
||||
count = state.get('hang_count')
|
||||
state['hang_count'] = (count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else 0) + 1
|
||||
state['last_hang'] = {
|
||||
'operation': operation,
|
||||
'seconds': round(float(seconds), 3),
|
||||
'time': time.time(),
|
||||
}
|
||||
if error is None:
|
||||
error = TimeoutError(f"{operation} still running after {seconds:.1f}s")
|
||||
# record_failure saves the record, hang fields included.
|
||||
self.record_failure(plugin_id, error)
|
||||
|
||||
#: Minimum seconds between persisting a plugin's slow-call or busy-skip
|
||||
#: counters. The in-memory record is updated every time; a plugin that is
|
||||
#: slow on every frame must not become an SD-card write per frame.
|
||||
SLOW_CALL_PERSIST_INTERVAL = 60.0
|
||||
|
||||
def record_slow_call(self, plugin_id: str, operation: str, seconds: float) -> None:
|
||||
"""Note a call that finished, but slowly. Reporting only.
|
||||
|
||||
Unlike :meth:`record_hang` this never touches the circuit breaker: a
|
||||
slow display() still drew its frame.
|
||||
"""
|
||||
state = self.get_health_state(plugin_id)
|
||||
count = state.get('slow_call_count')
|
||||
state['slow_call_count'] = (count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else 0) + 1
|
||||
now = time.time()
|
||||
state['last_slow_call'] = {
|
||||
'operation': operation,
|
||||
'seconds': round(float(seconds), 3),
|
||||
'time': now,
|
||||
}
|
||||
self._save_reporting_throttled('slow', plugin_id, state, now)
|
||||
|
||||
def record_busy_skip(self, plugin_id: str, operation: str, seconds: float) -> None:
|
||||
"""Note a call skipped because the plugin's lock stayed held. Reporting only.
|
||||
|
||||
The update worker gives up on a plugin's lock after
|
||||
``PluginManager.PLUGIN_LOCK_TIMEOUT``. Whatever held it may be healthy
|
||||
-- Vegas prefetch holds the lock for a plugin's whole content render,
|
||||
which on a slow Pi can take longer than that -- so like
|
||||
:meth:`record_slow_call` this never touches the circuit breaker, the
|
||||
failure streak or ``last_error``. A real hang is recorded by
|
||||
:meth:`record_hang` where it is measured.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
operation: What was skipped, e.g. ``"update lock wait"``.
|
||||
seconds: How long the lock was waited on.
|
||||
"""
|
||||
state = self.get_health_state(plugin_id)
|
||||
count = state.get('busy_skip_count')
|
||||
state['busy_skip_count'] = (count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else 0) + 1
|
||||
now = time.time()
|
||||
state['last_busy_skip'] = {
|
||||
'operation': operation,
|
||||
'seconds': round(float(seconds), 3),
|
||||
'time': now,
|
||||
}
|
||||
self._save_reporting_throttled('busy', plugin_id, state, now)
|
||||
|
||||
def _save_reporting_throttled(self, kind: str, plugin_id: str,
|
||||
state: Dict[str, Any], now: float) -> None:
|
||||
"""Persist a reporting-only change at most once per
|
||||
SLOW_CALL_PERSIST_INTERVAL per plugin and ``kind``. The first one is
|
||||
saved at once, so the web process (which reads the persisted record)
|
||||
sees it; repeats in between stay in memory until the next save."""
|
||||
saved_at = self.__dict__.setdefault('_reporting_saved_at', {})
|
||||
last = saved_at.get((kind, plugin_id))
|
||||
if last is None or now - last >= self.SLOW_CALL_PERSIST_INTERVAL:
|
||||
saved_at[(kind, plugin_id)] = now
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
|
||||
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
|
||||
|
||||
@@ -345,7 +443,13 @@ class PluginHealthTracker:
|
||||
'degraded': state.get('degraded', False),
|
||||
'degraded_reason': state.get('degraded_reason'),
|
||||
'circuit_opened_time': state.get('circuit_opened_time'),
|
||||
'half_open_start_time': state.get('half_open_start_time')
|
||||
'half_open_start_time': state.get('half_open_start_time'),
|
||||
'hang_count': state.get('hang_count', 0),
|
||||
'last_hang': state.get('last_hang'),
|
||||
'slow_call_count': state.get('slow_call_count', 0),
|
||||
'last_slow_call': state.get('last_slow_call'),
|
||||
'busy_skip_count': state.get('busy_skip_count', 0),
|
||||
'last_busy_skip': state.get('last_busy_skip'),
|
||||
}
|
||||
|
||||
def get_all_health_summaries(self) -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
@@ -16,12 +16,15 @@ import time
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
||||
import logging
|
||||
from src import display_watchdog
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
from src.plugin_system.plugin_executor import (
|
||||
PluginBusyError, PluginExecutor, PluginTimeoutError,
|
||||
)
|
||||
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
||||
from src.plugin_system.schema_manager import (
|
||||
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
|
||||
@@ -36,6 +39,16 @@ from src.common.permission_utils import (
|
||||
)
|
||||
|
||||
|
||||
class _DeferredConfigChange(NamedTuple):
|
||||
"""Update-queue item: apply the config change parked for ``plugin_id``.
|
||||
|
||||
Queued by apply_config_change() when the plugin's lock was busy; the
|
||||
change itself waits in ``PluginManager._deferred_config_changes`` so only
|
||||
the latest one is ever applied.
|
||||
"""
|
||||
plugin_id: str
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""
|
||||
Manages plugin discovery, loading, and lifecycle.
|
||||
@@ -56,6 +69,19 @@ class PluginManager:
|
||||
# How long unload_plugin() waits for an in-flight update() to finish
|
||||
# before tearing the instance down anyway.
|
||||
UNLOAD_LOCK_TIMEOUT = 5.0
|
||||
|
||||
# How long the update worker and apply_config_change() wait for a
|
||||
# plugin's lock -- the same bound unload already uses for the same lock.
|
||||
# A display() frame holds it for milliseconds, so this only runs out when
|
||||
# the holder is hung or pathologically slow. The worker then skips that
|
||||
# plugin (recorded as a hang, so repeats open its circuit breaker)
|
||||
# instead of stalling every other plugin's update behind it.
|
||||
PLUGIN_LOCK_TIMEOUT = UNLOAD_LOCK_TIMEOUT
|
||||
|
||||
# Minimum seconds between repeats of the same hang/slow-call warning for
|
||||
# one plugin. A hung plugin is re-detected every interval; a slow
|
||||
# display() can be re-detected every frame.
|
||||
HANG_LOG_INTERVAL = 60.0
|
||||
|
||||
def __init__(self, plugins_dir: str = "plugins",
|
||||
config_manager: Optional[Any] = None,
|
||||
@@ -122,7 +148,33 @@ class PluginManager:
|
||||
# post-timeout window.
|
||||
# Kill switch: plugin_system.synchronous_updates: true restores the
|
||||
# inline path.
|
||||
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
||||
#
|
||||
# Which thread runs each plugin hook, and what it holds:
|
||||
# __init__, on_enable the loading thread (main thread at startup,
|
||||
# the render thread on a live enable).
|
||||
# update() plugin-update-worker, under the plugin lock,
|
||||
# via PluginExecutor (whose daemon thread runs
|
||||
# the call; if it outlives the executor's
|
||||
# timeout it keeps the lock until it returns).
|
||||
# Exceptions: the startup pass
|
||||
# (DisplayController._run_initial_updates, main
|
||||
# thread, before the display loop starts) and
|
||||
# the synchronous_updates kill switch (render
|
||||
# thread) run it without the lock.
|
||||
# display() the render thread, under a try-lock: a busy
|
||||
# lock skips the frame. The first frame of a
|
||||
# screen goes through PluginExecutor. Vegas
|
||||
# mode's adapter and coordinator take the lock
|
||||
# with a bounded wait.
|
||||
# on_config_change() ConfigService's watcher thread, under the
|
||||
# plugin lock via apply_config_change(); if the
|
||||
# lock stays busy it is deferred to the update
|
||||
# worker, which applies it under the lock.
|
||||
# cleanup(), on_disable() whoever calls unload_plugin(), under the
|
||||
# lock with UNLOAD_LOCK_TIMEOUT.
|
||||
# No wait on a plugin lock is unbounded, so one hung plugin can only
|
||||
# cost the worker PLUGIN_LOCK_TIMEOUT per attempt.
|
||||
self._update_queue: "queue.Queue[Union[None, Tuple[str, float], _DeferredConfigChange]]" = queue.Queue()
|
||||
self._pending_updates: set = set()
|
||||
self._pending_lock = threading.Lock()
|
||||
# Serializes the "is this plugin eligible?" -> "claim it (RUNNING)"
|
||||
@@ -145,6 +197,17 @@ class PluginManager:
|
||||
# run_scheduled_updates_with_changes().
|
||||
self._completed_updates: set = set()
|
||||
self._completed_updates_lock = threading.Lock()
|
||||
# Called with a plugin id the moment its data may have changed: its
|
||||
# update() completed, or it called notify_vegas_data_changed(). See
|
||||
# add_update_listener(). A tuple, replaced rather than mutated, so the
|
||||
# worker can iterate it without a lock.
|
||||
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
|
||||
# Config changes that found the plugin's lock busy, latest per plugin,
|
||||
# with the instance they were meant for. See apply_config_change().
|
||||
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
|
||||
self._deferred_config_lock = threading.Lock()
|
||||
# key -> (monotonic time last logged, repeats suppressed since)
|
||||
self._rate_limited_warnings: Dict[str, Tuple[float, int]] = {}
|
||||
self._synchronous_updates = False
|
||||
if self.config_manager is not None:
|
||||
try:
|
||||
@@ -296,7 +359,20 @@ class PluginManager:
|
||||
|
||||
return plugin_ids
|
||||
|
||||
def load_plugin(self, plugin_id: str) -> bool:
|
||||
def load_plugin(self, plugin_id: str, force_enabled: bool = False) -> bool:
|
||||
"""Load a plugin by ID; see _load_plugin.
|
||||
|
||||
Loading can install the plugin's dependencies with pip -- minutes,
|
||||
not seconds. When that happens on the display's render thread (a
|
||||
plugin enabled from the web UI, or loaded for on-demand), its
|
||||
systemd watchdog gets a longer limit for the duration. Start-up
|
||||
loads, on a thread pool, are covered by the start-up allowance.
|
||||
"""
|
||||
with display_watchdog.extended(display_watchdog.PLUGIN_LOAD_ALLOWANCE_SECONDS,
|
||||
f'loading plugin {plugin_id}'):
|
||||
return self._load_plugin(plugin_id, force_enabled)
|
||||
|
||||
def _load_plugin(self, plugin_id: str, force_enabled: bool = False) -> bool:
|
||||
"""
|
||||
Load a plugin by ID.
|
||||
|
||||
@@ -310,6 +386,10 @@ class PluginManager:
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
force_enabled: Run the plugin enabled even though config.json has
|
||||
it disabled. On-demand uses this to show a disabled plugin
|
||||
(DisplayController._load_plugin_for_on_demand). Only the
|
||||
instance's config says enabled; config.json is not written.
|
||||
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
@@ -376,6 +456,12 @@ class PluginManager:
|
||||
# (prepare_plugin_config). In memory only: config.json is written
|
||||
# by saves, never by loading a plugin.
|
||||
config = self.prepare_plugin_config(plugin_id, config, schema=schema)
|
||||
if force_enabled:
|
||||
# A copy: prepare_plugin_config can hand back the section from
|
||||
# config_manager's cached config, and setting the flag there
|
||||
# would read as enabled to everything else in this process.
|
||||
config = dict(config)
|
||||
config['enabled'] = True
|
||||
|
||||
# Use PluginLoader to load plugin
|
||||
plugin_instance, _module = self.plugin_loader.load_plugin(
|
||||
@@ -455,7 +541,13 @@ class PluginManager:
|
||||
raise
|
||||
else:
|
||||
self.state_manager.set_state(plugin_id, PluginState.DISABLED)
|
||||
|
||||
|
||||
# The version this instance runs, for the runtime snapshot the
|
||||
# web UI reads: the manifest on disk can move on after an update.
|
||||
version = manifest.get('version')
|
||||
self.state_manager.record_loaded(
|
||||
plugin_id, version if isinstance(version, str) else None)
|
||||
|
||||
self.logger.info("Loaded plugin: %s", plugin_id)
|
||||
|
||||
return True
|
||||
@@ -502,7 +594,8 @@ class PluginManager:
|
||||
#: prefix rule would silently stop validating it.
|
||||
#:
|
||||
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
|
||||
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``).
|
||||
#: ``vegas_overflow``, ``vegas_live``) and ``base_plugin.py``
|
||||
#: (``vegas_max_width_screens``, ``vegas_participation``).
|
||||
#:
|
||||
#: The list itself lives with the other core-owned per-plugin properties in
|
||||
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
|
||||
@@ -676,6 +769,8 @@ class PluginManager:
|
||||
|
||||
# Remove from active plugins
|
||||
del self.plugins[plugin_id]
|
||||
with self._deferred_config_lock:
|
||||
self._deferred_config_changes.pop(plugin_id, None)
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
@@ -704,6 +799,9 @@ class PluginManager:
|
||||
except Exception as e:
|
||||
self.logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
if plugin_id not in self.plugins:
|
||||
# Failed after the instance was dropped: it is not loaded.
|
||||
self.state_manager.record_unloaded(plugin_id)
|
||||
return False
|
||||
|
||||
def reload_plugin(self, plugin_id: str) -> bool:
|
||||
@@ -775,7 +873,7 @@ class PluginManager:
|
||||
"""
|
||||
return self.plugins.copy()
|
||||
|
||||
@deprecated("3.7.0", "check each plugin's enabled flag in plugins")
|
||||
@deprecated("3.8.0", "check each plugin's enabled flag in plugins")
|
||||
def get_enabled_plugins(self) -> List[str]:
|
||||
"""
|
||||
Get list of enabled plugin IDs.
|
||||
@@ -1012,6 +1110,8 @@ class PluginManager:
|
||||
self,
|
||||
plugin_id: str,
|
||||
exc: Optional[Exception] = None,
|
||||
log: bool = True,
|
||||
count_failure: bool = True,
|
||||
) -> None:
|
||||
"""Apply the standard failure-recovery path for a plugin update.
|
||||
|
||||
@@ -1025,6 +1125,11 @@ class PluginManager:
|
||||
exc: The exception that caused the failure, if any. When None a
|
||||
synthetic ExecutionFailure exception is constructed from the
|
||||
timeout/executor-error path.
|
||||
log: Log the generic failure line. Callers that already logged
|
||||
something more specific (rate-limited) pass False.
|
||||
count_failure: Record the failure in plugin health, where it
|
||||
counts toward the circuit breaker. A busy skip passes False:
|
||||
it records itself as a busy skip, reporting only.
|
||||
"""
|
||||
failure_time = time.time()
|
||||
if exc is not None:
|
||||
@@ -1040,13 +1145,91 @@ class PluginManager:
|
||||
'timestamp': failure_time,
|
||||
'recoverable': True,
|
||||
}
|
||||
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||
if log:
|
||||
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = failure_time
|
||||
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info)
|
||||
if self.health_tracker:
|
||||
if count_failure and self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, err)
|
||||
|
||||
def _warn_rate_limited(self, key: str, message: str, *args: Any) -> None:
|
||||
"""Log a warning at most once per HANG_LOG_INTERVAL for ``key``.
|
||||
|
||||
Repeats in between are counted and the count is appended to the next
|
||||
one that is logged, so the journal shows the problem continuing
|
||||
without a line per frame or per scheduler tick.
|
||||
"""
|
||||
# setdefault: tests build bare managers with PluginManager.__new__.
|
||||
seen = self.__dict__.setdefault('_rate_limited_warnings', {})
|
||||
now = time.monotonic()
|
||||
last, suppressed = seen.get(key, (None, 0))
|
||||
if last is not None and now - last < self.HANG_LOG_INTERVAL:
|
||||
seen[key] = (last, suppressed + 1)
|
||||
return
|
||||
seen[key] = (now, 0)
|
||||
if suppressed:
|
||||
message += " (%d more since the last warning)"
|
||||
args = args + (suppressed,)
|
||||
self.logger.warning(message, *args)
|
||||
|
||||
def _record_hang(self, plugin_id: str, operation: str, seconds: float,
|
||||
err: Exception) -> None:
|
||||
"""Record a hang in plugin health: a failure to the circuit breaker.
|
||||
|
||||
PluginHealthTracker.record_hang also counts the hang separately. Never
|
||||
raises: this runs on the update worker and the render thread.
|
||||
"""
|
||||
tracker = self.health_tracker
|
||||
if tracker is None:
|
||||
return
|
||||
try:
|
||||
tracker.record_hang(plugin_id, operation, seconds, err)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self.logger.debug("Could not record hang for %s: %s", plugin_id, e)
|
||||
|
||||
def note_display_duration(self, plugin_id: str, seconds: float) -> None:
|
||||
"""Account for one display() call that took ``seconds``.
|
||||
|
||||
Called by the render loop for every frame, so the common case is one
|
||||
comparison. At or above PluginExecutor.SLOW_DISPLAY_SECONDS the call
|
||||
is logged (rate-limited) and counted as slow in plugin health; at or
|
||||
above the executor's timeout -- the limit the first frame of a screen
|
||||
is already held to -- it is recorded as a hang, which the circuit
|
||||
breaker counts as a failure.
|
||||
"""
|
||||
if seconds < PluginExecutor.SLOW_DISPLAY_SECONDS:
|
||||
return
|
||||
if seconds >= self.plugin_executor.default_timeout:
|
||||
self.record_display_hang(plugin_id, seconds)
|
||||
return
|
||||
self._warn_rate_limited(
|
||||
"slow-display:" + plugin_id,
|
||||
"Plugin %s display() took %.2fs; a frame should take milliseconds "
|
||||
"(is it fetching or loading files in display()?)", plugin_id, seconds)
|
||||
tracker = self.health_tracker
|
||||
record_slow = getattr(tracker, 'record_slow_call', None) if tracker is not None else None
|
||||
if callable(record_slow):
|
||||
try:
|
||||
record_slow(plugin_id, 'display', seconds)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self.logger.debug("Could not record slow display for %s: %s", plugin_id, e)
|
||||
|
||||
def record_display_hang(self, plugin_id: str, seconds: float) -> None:
|
||||
"""Record a display() call that ran ``seconds``, past its limit.
|
||||
|
||||
Either it has since returned (note_display_duration) or it is still
|
||||
running on the executor's lingering thread, holding the plugin's lock
|
||||
(the render loop's first-frame dispatch).
|
||||
"""
|
||||
self._warn_rate_limited(
|
||||
"hung-display:" + plugin_id,
|
||||
"Plugin %s display() ran for at least %.1fs (limit %.0fs); recorded "
|
||||
"as a hang -- repeated hangs open its circuit breaker",
|
||||
plugin_id, seconds, self.plugin_executor.default_timeout)
|
||||
self._record_hang(plugin_id, 'display', seconds, PluginTimeoutError(
|
||||
f"Plugin {plugin_id} display() ran for at least {seconds:.1f}s"))
|
||||
|
||||
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
||||
"""
|
||||
Trigger plugin updates based on their defined update intervals.
|
||||
@@ -1080,6 +1263,9 @@ class PluginManager:
|
||||
# Kill-switch path: the original inline execution
|
||||
# (blocks the caller until update() completes/times out)
|
||||
self._execute_update_now(plugin_id, plugin_instance, current_time)
|
||||
# Up to the executor's 30s each, one after another on the
|
||||
# render thread: check in with its watchdog between them.
|
||||
display_watchdog.beat()
|
||||
else:
|
||||
self._enqueue_update(plugin_id, current_time)
|
||||
|
||||
@@ -1132,11 +1318,13 @@ class PluginManager:
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
|
||||
def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
|
||||
"""Per-plugin lock keeping update() and display() mutually exclusive.
|
||||
"""Per-plugin lock keeping update(), display() and on_config_change()
|
||||
mutually exclusive.
|
||||
|
||||
The update worker holds it for the duration of a plugin's update();
|
||||
the display side acquires it non-blocking and skips that frame's
|
||||
display() call when the plugin is mid-update.
|
||||
display() call when the plugin is mid-update. Every other waiter uses
|
||||
a bounded acquire (see the thread notes in __init__).
|
||||
"""
|
||||
with self._plugin_locks_guard:
|
||||
lock = self._plugin_locks.get(plugin_id)
|
||||
@@ -1202,14 +1390,28 @@ class PluginManager:
|
||||
real update() call genuinely finishes (see _execute_update_now),
|
||||
which can be after this dispatch returns if PluginExecutor's own
|
||||
timeout elapses first.
|
||||
|
||||
The lock wait is bounded by PLUGIN_LOCK_TIMEOUT. Whatever holds it
|
||||
past that -- a hung display() on the render thread, a lingering
|
||||
executor thread, or a long but healthy Vegas content render -- costs
|
||||
this worker that long once per attempt, and the plugin's update is
|
||||
skipped and reported as a busy skip (_skip_busy_update), which never
|
||||
counts toward the circuit breaker; the other plugins' queued updates
|
||||
carry on.
|
||||
"""
|
||||
while True:
|
||||
item = self._update_queue.get()
|
||||
if item is None: # shutdown sentinel
|
||||
return
|
||||
if isinstance(item, _DeferredConfigChange):
|
||||
self._apply_deferred_config_change(item.plugin_id)
|
||||
continue
|
||||
plugin_id, scheduled_time = item
|
||||
lock = self.get_plugin_lock(plugin_id)
|
||||
lock.acquire()
|
||||
wait_start = time.monotonic()
|
||||
if not lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT):
|
||||
self._skip_busy_update(plugin_id, time.monotonic() - wait_start)
|
||||
continue
|
||||
plugin_instance = self.plugins.get(plugin_id)
|
||||
if plugin_instance is None: # unloaded while queued; its
|
||||
# lifecycle state was already cleared by unload_plugin —
|
||||
@@ -1218,6 +1420,9 @@ class PluginManager:
|
||||
with self._pending_lock:
|
||||
self._pending_updates.discard(plugin_id)
|
||||
continue
|
||||
# A config change that found the lock busy goes in first, so
|
||||
# this update() runs against the settings the user saved.
|
||||
self._apply_deferred_config_locked(plugin_id, plugin_instance)
|
||||
try:
|
||||
self._execute_update_now(plugin_id, plugin_instance,
|
||||
scheduled_time, lock=lock)
|
||||
@@ -1228,6 +1433,142 @@ class PluginManager:
|
||||
self.logger.exception("update worker: unexpected error for %s",
|
||||
plugin_id)
|
||||
|
||||
def _skip_busy_update(self, plugin_id: str, waited: float) -> None:
|
||||
"""Give up on a queued update whose plugin lock stayed held.
|
||||
|
||||
Same bookkeeping as a failed update() -- pending slot dropped before
|
||||
the state returns to ENABLED with PluginBusyError error info,
|
||||
last-update stamped so the retry waits a full interval -- but
|
||||
report-only in health: counted as a busy skip (``busy_skip_count`` /
|
||||
``last_busy_skip``), never as a failure or a hang. The lock holder
|
||||
may be perfectly healthy: Vegas prefetch holds a plugin's lock for its
|
||||
whole content render, which on a slow Pi can outlast
|
||||
PLUGIN_LOCK_TIMEOUT, and counting that would pull a healthy plugin
|
||||
from rotation. Real hangs -- display() or update() past the executor
|
||||
timeout -- are recorded where they are measured and still open the
|
||||
breaker.
|
||||
"""
|
||||
with self._pending_lock:
|
||||
self._pending_updates.discard(plugin_id)
|
||||
if plugin_id not in self.plugins:
|
||||
# Unloaded while we waited: its lifecycle state is already
|
||||
# cleared; recording anything would resurrect it as ENABLED.
|
||||
return
|
||||
self._warn_rate_limited(
|
||||
"busy-update:" + plugin_id,
|
||||
"Plugin %s update skipped: its lock was still held after %.1fs "
|
||||
"(a display(), Vegas render or update() of it is still running); "
|
||||
"retrying next interval, not counted as a failure", plugin_id, waited)
|
||||
self._record_update_failure(
|
||||
plugin_id,
|
||||
exc=PluginBusyError(
|
||||
f"Plugin {plugin_id} busy: its lock was held for over {waited:.1f}s "
|
||||
"by a slow or hung display()/update(); update skipped"),
|
||||
log=False,
|
||||
count_failure=False)
|
||||
tracker = self.health_tracker
|
||||
record_busy = getattr(tracker, 'record_busy_skip', None) if tracker is not None else None
|
||||
if callable(record_busy):
|
||||
try:
|
||||
record_busy(plugin_id, 'update lock wait', waited)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self.logger.debug("Could not record busy skip for %s: %s", plugin_id, e)
|
||||
|
||||
def apply_config_change(self, plugin_id: str, new_config: Dict[str, Any],
|
||||
plugin_instance: Optional[Any] = None) -> bool:
|
||||
"""Call ``on_config_change(new_config)`` without racing update()/display().
|
||||
|
||||
Runs on the calling thread -- ConfigService's watcher, for the display
|
||||
service -- holding the plugin's lock, waited on for at most
|
||||
PLUGIN_LOCK_TIMEOUT. If the lock is still busy (an update() mid-fetch
|
||||
can outlast that) the change is parked and handed to the update
|
||||
worker, which applies it under the same lock once it is free, and at
|
||||
the latest just before the plugin's next update(). A later change for
|
||||
the same plugin replaces a parked one.
|
||||
|
||||
Exceptions from on_config_change propagate on the immediate path,
|
||||
as they did when the caller invoked it directly.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier.
|
||||
new_config: The prepared config to hand the plugin.
|
||||
plugin_instance: The instance to notify; defaults to the loaded one.
|
||||
|
||||
Returns:
|
||||
True if on_config_change ran now, False if it was deferred or there
|
||||
is no loaded plugin to notify.
|
||||
"""
|
||||
if plugin_instance is None:
|
||||
plugin_instance = self.plugins.get(plugin_id)
|
||||
if plugin_instance is None or not hasattr(plugin_instance, 'on_config_change'):
|
||||
return False
|
||||
lock = self.get_plugin_lock(plugin_id)
|
||||
if lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT):
|
||||
try:
|
||||
with self._deferred_config_lock:
|
||||
# This change supersedes any older one still parked.
|
||||
self._deferred_config_changes.pop(plugin_id, None)
|
||||
plugin_instance.on_config_change(new_config)
|
||||
finally:
|
||||
lock.release()
|
||||
return True
|
||||
|
||||
with self._deferred_config_lock:
|
||||
self._deferred_config_changes[plugin_id] = (plugin_instance, new_config)
|
||||
self._warn_rate_limited(
|
||||
"busy-config:" + plugin_id,
|
||||
"Plugin %s is busy (lock held for over %.1fs); its config change "
|
||||
"will be applied by the update worker once it is free",
|
||||
plugin_id, self.PLUGIN_LOCK_TIMEOUT)
|
||||
try:
|
||||
self._ensure_update_worker()
|
||||
self._update_queue.put(_DeferredConfigChange(plugin_id))
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# No worker (thread start refused): still parked, so the next
|
||||
# update() of this plugin applies it.
|
||||
self.logger.error(
|
||||
"Could not queue the config change for plugin %s (%s: %s); it "
|
||||
"will be applied before its next update()",
|
||||
plugin_id, type(exc).__name__, exc)
|
||||
return False
|
||||
|
||||
def _apply_deferred_config_change(self, plugin_id: str) -> None:
|
||||
"""Worker side of a parked config change: take the lock, apply it."""
|
||||
with self._deferred_config_lock:
|
||||
if plugin_id not in self._deferred_config_changes:
|
||||
return # applied or superseded meanwhile
|
||||
lock = self.get_plugin_lock(plugin_id)
|
||||
wait_start = time.monotonic()
|
||||
if not lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT):
|
||||
self._warn_rate_limited(
|
||||
"busy-config:" + plugin_id,
|
||||
"Plugin %s still busy after %.1fs; its config change stays "
|
||||
"parked until its next update()",
|
||||
plugin_id, time.monotonic() - wait_start)
|
||||
return
|
||||
try:
|
||||
self._apply_deferred_config_locked(plugin_id, self.plugins.get(plugin_id))
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
def _apply_deferred_config_locked(self, plugin_id: str,
|
||||
current_instance: Optional[Any]) -> None:
|
||||
"""Apply the parked config change for plugin_id; caller holds its lock."""
|
||||
with self._deferred_config_lock:
|
||||
entry = self._deferred_config_changes.pop(plugin_id, None)
|
||||
if entry is None:
|
||||
return
|
||||
instance, new_config = entry
|
||||
if current_instance is None or instance is not current_instance:
|
||||
# Unloaded, or reloaded as a new instance built from the current
|
||||
# config: nothing left to tell.
|
||||
return
|
||||
try:
|
||||
instance.on_config_change(new_config)
|
||||
self.logger.info("Applied deferred config change for plugin %s", plugin_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self.logger.exception("Error in plugin %s config change handler", plugin_id)
|
||||
|
||||
def stop_update_worker(self, timeout: float = 5.0) -> None:
|
||||
"""Signal the worker to exit (used by cleanup; thread is a daemon)."""
|
||||
if self._update_worker is not None and self._update_worker.is_alive():
|
||||
@@ -1338,14 +1679,29 @@ class PluginManager:
|
||||
else:
|
||||
_finish(True)
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
self.plugin_executor.execute_update(
|
||||
success = self.plugin_executor.execute_update(
|
||||
types.SimpleNamespace(update=_target_update), plugin_id)
|
||||
except Exception as exc: # pragma: no cover - defensive; execute_update
|
||||
# catches everything internally, but guarantee _finish still
|
||||
# runs (releasing the lock) if something unexpected slips through.
|
||||
self.logger.exception("Unexpected error dispatching update for %s: %s", plugin_id, exc)
|
||||
_finish(False, exc=exc)
|
||||
return
|
||||
if not success and not finished['done']:
|
||||
# The executor stopped waiting but update() is still running: it
|
||||
# keeps the lock and the RUNNING state until it returns (then
|
||||
# _finish records the outcome). Say so now, rather than leave the
|
||||
# plugin silently stuck; record_success on a late return clears it.
|
||||
elapsed = time.monotonic() - started
|
||||
self._warn_rate_limited(
|
||||
"hung-update:" + plugin_id,
|
||||
"Plugin %s update() still running after %.1fs; it keeps its "
|
||||
"lock until it returns, and is not rescheduled until then",
|
||||
plugin_id, elapsed)
|
||||
self._record_hang(plugin_id, 'update', elapsed, PluginTimeoutError(
|
||||
f"Plugin {plugin_id} update() still running after {elapsed:.1f}s"))
|
||||
|
||||
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
|
||||
"""
|
||||
@@ -1372,9 +1728,53 @@ class PluginManager:
|
||||
return self.drain_completed_updates()
|
||||
|
||||
def _note_update_completed(self, plugin_id: str) -> None:
|
||||
"""Record that a plugin's update() finished, for the next poll."""
|
||||
"""Record that a plugin's update() finished, for the next poll.
|
||||
|
||||
Also tells the update listeners at once, so Vegas live elements are
|
||||
redrawn the moment new data lands instead of at the next ~4s poll.
|
||||
This runs while the plugin's lock is still held (see _finish), which
|
||||
is what makes the listeners' contract strict.
|
||||
"""
|
||||
with self._completed_updates_lock:
|
||||
self._completed_updates.add(plugin_id)
|
||||
self._fire_update_listeners(plugin_id)
|
||||
|
||||
def add_update_listener(self, listener: Callable[[str], None]) -> None:
|
||||
"""Call ``listener(plugin_id)`` whenever a plugin's data may have changed.
|
||||
|
||||
That is: its update() completed successfully, or it called
|
||||
notify_vegas_data_changed(). The listener runs on the thread that
|
||||
noticed -- the update worker, with the plugin's lock still held, or
|
||||
the plugin's own thread -- so it must return at once and take no lock
|
||||
a plugin could hold: record the id and hand off (a dict store, a
|
||||
queue put). An exception from it is logged and does not reach the
|
||||
plugin. Adding the same listener twice has no effect.
|
||||
"""
|
||||
# __dict__.get: tests build bare managers with PluginManager.__new__.
|
||||
listeners = self.__dict__.get('_update_listeners', ())
|
||||
if listener not in listeners:
|
||||
self._update_listeners = listeners + (listener,)
|
||||
|
||||
def remove_update_listener(self, listener: Callable[[str], None]) -> None:
|
||||
"""Stop calling a listener added with add_update_listener()."""
|
||||
self._update_listeners = tuple(
|
||||
fn for fn in self.__dict__.get('_update_listeners', ()) if fn != listener)
|
||||
|
||||
def notify_data_changed(self, plugin_id: str) -> None:
|
||||
"""A plugin's data changed outside update(); tell the update listeners.
|
||||
|
||||
BasePlugin.notify_vegas_data_changed() lands here.
|
||||
"""
|
||||
self._fire_update_listeners(plugin_id)
|
||||
|
||||
def _fire_update_listeners(self, plugin_id: str) -> None:
|
||||
for listener in self.__dict__.get('_update_listeners', ()):
|
||||
try:
|
||||
listener(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_rate_limited(
|
||||
"update-listener",
|
||||
"An update listener failed for plugin %s: %r", plugin_id, exc)
|
||||
|
||||
def drain_completed_updates(self) -> List[str]:
|
||||
"""Return and clear the plugin ids whose update() has since finished."""
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""The display's plugin runtime snapshot, shared with the web interface.
|
||||
|
||||
Only the display process runs plugins, so only it knows which ones it has
|
||||
loaded, where each is in its lifecycle (``plugin_state.PluginStateManager``),
|
||||
why one failed and which version it is running. It publishes that to the
|
||||
shared cache directory -- the channel, and the file permissions, that the
|
||||
error snapshot, plugin health and ``display_current_state`` already use --
|
||||
and the web interface reads it back for ``/api/v3/plugins/installed``,
|
||||
``/api/v3/plugins/state`` and state reconciliation.
|
||||
|
||||
PLUGIN_RUNTIME_KEY written by the display service only
|
||||
|
||||
Writes. The cache lives on disk, usually the SD card, so the snapshot is
|
||||
written when something a reader would see changes, at most once every
|
||||
``MIN_INTERVAL`` seconds, and otherwise once every ``REFRESH_INTERVAL``
|
||||
seconds as a heartbeat. An ordinary plugin update is not a change: the
|
||||
RUNNING state it passes through is published as ENABLED
|
||||
(``plugin_state.published_state``). A display with nothing changing writes
|
||||
this one small file once a minute.
|
||||
|
||||
Staleness. Every snapshot carries ``published_at`` (wall clock) and
|
||||
``stale_after``. A reader treats a snapshot older than that as unknown, not
|
||||
as the truth: a display that died without cleaning up leaves its last
|
||||
snapshot behind. A display that stops cleanly publishes ``running: false``
|
||||
on the way out, so readers see "stopped" at once rather than after the
|
||||
stale window. Nothing on the reading side reports a runtime fact from a
|
||||
snapshot that is not live.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from src.redaction import redact_credentials
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
PLUGIN_RUNTIME_KEY = "plugin_runtime_snapshot"
|
||||
SNAPSHOT_SCHEMA = 1
|
||||
|
||||
#: Shortest gap, in seconds, between two change-driven writes. Startup loads
|
||||
#: every plugin in a burst, and a plugin failing each update cycle changes its
|
||||
#: error info each time; either is written at most this often.
|
||||
MIN_INTERVAL = 10.0
|
||||
|
||||
#: An unchanged snapshot is rewritten this often so readers can tell a quiet
|
||||
#: display from a dead one.
|
||||
REFRESH_INTERVAL = 60.0
|
||||
|
||||
#: How often the publisher thread looks for changes: an in-memory comparison.
|
||||
TICK_INTERVAL = 5.0
|
||||
|
||||
#: A snapshot older than this is stale: three missed refreshes.
|
||||
STALE_AFTER = 3 * REFRESH_INTERVAL
|
||||
|
||||
#: Bounds on a published ``stale_after``, so a corrupt value can make a
|
||||
#: reader neither trust a dead display for hours nor distrust a live one.
|
||||
_STALE_AFTER_MIN = 30.0
|
||||
_STALE_AFTER_MAX = 3600.0
|
||||
|
||||
_ERROR_MESSAGE_CHARS = 200
|
||||
_ERROR_TYPE_CHARS = 80
|
||||
_ID_CHARS = 100
|
||||
_VERSION_CHARS = 40
|
||||
|
||||
#: Reader statuses. Only LIVE carries runtime facts.
|
||||
LIVE = "live"
|
||||
STALE = "stale"
|
||||
STOPPED = "stopped"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def _clip(value: Any, limit: int) -> str:
|
||||
text = value if isinstance(value, str) else str(value)
|
||||
return text if len(text) <= limit else text[:limit - 3] + "..."
|
||||
|
||||
|
||||
def _epoch(value: Any) -> Optional[float]:
|
||||
"""Seconds since the epoch for a float or a datetime; None otherwise."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
number = float(value)
|
||||
return number if math.isfinite(number) else None
|
||||
timestamp = getattr(value, "timestamp", None)
|
||||
if callable(timestamp):
|
||||
try:
|
||||
number = float(timestamp())
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
return None
|
||||
|
||||
|
||||
def summarize_error(error_info: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""A short, redacted summary of the state machine's error info.
|
||||
|
||||
``message`` is redacted before it is clipped: clipping first could cut a
|
||||
``token=`` marker off and keep the secret after it. No stack trace: the
|
||||
full error, with its trace, is in the error snapshot (/api/v3/errors).
|
||||
"""
|
||||
if not isinstance(error_info, dict):
|
||||
return None
|
||||
message = error_info.get("error")
|
||||
error_type = error_info.get("error_type")
|
||||
return {
|
||||
"type": _clip(error_type, _ERROR_TYPE_CHARS) if error_type else None,
|
||||
"message": _clip(redact_credentials(message if isinstance(message, str)
|
||||
else str(message or "")),
|
||||
_ERROR_MESSAGE_CHARS),
|
||||
"at": _epoch(error_info.get("timestamp")),
|
||||
"recoverable": bool(error_info.get("recoverable", False)),
|
||||
}
|
||||
|
||||
|
||||
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||
now: Optional[float] = None,
|
||||
running: bool = True) -> Dict[str, Any]:
|
||||
"""The snapshot for ``state_manager`` (a plugin_state.PluginStateManager).
|
||||
|
||||
A stopped snapshot (``running=False``) lists no plugins: nothing is
|
||||
loaded once the display has gone.
|
||||
"""
|
||||
plugins: Dict[str, Dict[str, Any]] = {}
|
||||
if running:
|
||||
for plugin_id, record in state_manager.runtime_records().items():
|
||||
version = record.get("version")
|
||||
plugins[_clip(plugin_id, _ID_CHARS)] = {
|
||||
"loaded": bool(record.get("loaded")),
|
||||
"state": record.get("state"),
|
||||
"error": summarize_error(record.get("error_info")),
|
||||
"version": _clip(version, _VERSION_CHARS) if version else None,
|
||||
"loaded_at": _epoch(record.get("loaded_at")),
|
||||
}
|
||||
return {
|
||||
"schema": SNAPSHOT_SCHEMA,
|
||||
"running": running,
|
||||
"published_at": time.time() if now is None else now,
|
||||
"started_at": started_at,
|
||||
"refresh_interval": REFRESH_INTERVAL,
|
||||
"stale_after": STALE_AFTER,
|
||||
"pid": os.getpid(),
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
|
||||
class PluginRuntimePublisher:
|
||||
"""Publishes the display's plugin state machine to the shared cache.
|
||||
|
||||
Runs in the display service only. tick() is the whole job; start() calls
|
||||
it from a daemon thread every TICK_INTERVAL seconds. Nothing here raises:
|
||||
a failed write is logged at debug and retried on a later tick, at the
|
||||
throttled rate.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager: Any, state_manager: Any,
|
||||
min_interval: float = MIN_INTERVAL,
|
||||
refresh_interval: float = REFRESH_INTERVAL,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
wall_clock: Callable[[], float] = time.time) -> None:
|
||||
self.cache_manager = cache_manager
|
||||
self.state_manager = state_manager
|
||||
self.min_interval = min_interval
|
||||
self.refresh_interval = refresh_interval
|
||||
self._clock = clock
|
||||
self._wall_clock = wall_clock
|
||||
self.started_at = wall_clock()
|
||||
# None forces a first publish, which replaces whatever a previous run
|
||||
# of the service left behind.
|
||||
self._published_change: Optional[int] = None
|
||||
self._last_attempt: Optional[float] = None
|
||||
self._tick_lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def _write(self, running: bool) -> None:
|
||||
snapshot = build_runtime_snapshot(self.state_manager, started_at=self.started_at,
|
||||
now=self._wall_clock(), running=running)
|
||||
self.cache_manager.set(PLUGIN_RUNTIME_KEY, snapshot)
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Publish if something changed (throttled) or the refresh is due.
|
||||
True if a snapshot was written."""
|
||||
with self._tick_lock:
|
||||
try:
|
||||
change = self.state_manager.change_count
|
||||
now = self._clock()
|
||||
since = None if self._last_attempt is None else now - self._last_attempt
|
||||
if since is not None:
|
||||
if change == self._published_change:
|
||||
if since < self.refresh_interval:
|
||||
return False
|
||||
elif since < self.min_interval:
|
||||
return False
|
||||
# Stamp the attempt before writing: a cache that keeps failing
|
||||
# is retried at the throttled rate, not on every tick.
|
||||
self._last_attempt = now
|
||||
self._write(running=True)
|
||||
self._published_change = change
|
||||
return True
|
||||
except Exception as err: # never let reporting break the display
|
||||
logger.debug("Could not publish the plugin runtime snapshot: %s",
|
||||
err, exc_info=True)
|
||||
return False
|
||||
|
||||
def start(self, interval: float = TICK_INTERVAL) -> None:
|
||||
"""Tick from a daemon thread until stop(). A no-op while running."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
|
||||
def run() -> None:
|
||||
self.tick()
|
||||
while not self._stop.wait(interval):
|
||||
self.tick()
|
||||
|
||||
self._thread = threading.Thread(target=run, name="plugin-runtime-publisher",
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, publish_stopped: bool = True) -> None:
|
||||
"""Stop ticking and, by default, publish ``running: false`` so readers
|
||||
see the display as stopped now rather than after the stale window."""
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
if publish_stopped:
|
||||
with self._tick_lock:
|
||||
try:
|
||||
self._write(running=False)
|
||||
except Exception as err:
|
||||
logger.debug("Could not publish the stopped plugin runtime snapshot: %s",
|
||||
err, exc_info=True)
|
||||
|
||||
|
||||
def start_plugin_runtime_publisher(cache_manager: Any,
|
||||
state_manager: Any) -> Optional[PluginRuntimePublisher]:
|
||||
"""Start publishing the display's plugin runtime state. Display service
|
||||
only: whichever process calls it becomes the source readers trust.
|
||||
Never raises."""
|
||||
try:
|
||||
publisher = PluginRuntimePublisher(cache_manager, state_manager)
|
||||
publisher.start()
|
||||
return publisher
|
||||
except Exception as err:
|
||||
logger.warning("Plugin runtime reporting to the web interface is unavailable: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
# --- Reading side (web interface) -------------------------------------------
|
||||
|
||||
#: What a reader reports for a plugin when it does not know.
|
||||
_UNKNOWN_PLUGIN: Dict[str, Any] = {
|
||||
"loaded": None,
|
||||
"state": None,
|
||||
"error_info": None,
|
||||
"loaded_version": None,
|
||||
"loaded_at": None,
|
||||
}
|
||||
|
||||
#: A plugin a live snapshot does not list: the display has not loaded it
|
||||
#: (never enabled, or unloaded since), which is what its state machine
|
||||
#: reports for an id it has no record of.
|
||||
_NOT_LOADED_PLUGIN: Dict[str, Any] = {
|
||||
"loaded": False,
|
||||
"state": "unloaded",
|
||||
"error_info": None,
|
||||
"loaded_version": None,
|
||||
"loaded_at": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginRuntimeView:
|
||||
"""What a reader may say about the display's plugins right now.
|
||||
|
||||
``status``: ``live`` (a fresh snapshot from a running display),
|
||||
``stale`` (the last snapshot is older than its ``stale_after``: the
|
||||
display is hung or died without cleaning up), ``stopped`` (the display
|
||||
said so on its way out) or ``unknown`` (no readable snapshot). Only a
|
||||
live view reports per-plugin facts; every other status answers None for
|
||||
them, so a caller cannot pass stale truth on by accident.
|
||||
"""
|
||||
|
||||
status: str
|
||||
published_at: Optional[float] = None
|
||||
age_seconds: Optional[float] = None
|
||||
stale_after: float = STALE_AFTER
|
||||
plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self.status == LIVE
|
||||
|
||||
def plugin(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""``loaded``, ``state``, ``error_info``, ``loaded_version`` and
|
||||
``loaded_at`` for one plugin; all None unless the view is live."""
|
||||
if not self.live:
|
||||
return dict(_UNKNOWN_PLUGIN)
|
||||
record = self.plugins.get(plugin_id)
|
||||
if not isinstance(record, dict):
|
||||
return dict(_NOT_LOADED_PLUGIN)
|
||||
error = record.get("error")
|
||||
return {
|
||||
"loaded": bool(record.get("loaded")),
|
||||
"state": record.get("state") if isinstance(record.get("state"), str) else None,
|
||||
"error_info": dict(error) if isinstance(error, dict) else None,
|
||||
"loaded_version": record.get("version"),
|
||||
"loaded_at": record.get("loaded_at"),
|
||||
}
|
||||
|
||||
def describe(self) -> Dict[str, Any]:
|
||||
"""The view's own status, for a response to carry beside the facts."""
|
||||
return {
|
||||
"status": self.status,
|
||||
"published_at": self.published_at,
|
||||
"age_seconds": None if self.age_seconds is None else round(self.age_seconds, 1),
|
||||
"stale_after": self.stale_after,
|
||||
}
|
||||
|
||||
|
||||
def _stale_after_of(snapshot: Dict[str, Any]) -> float:
|
||||
value = snapshot.get("stale_after")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return STALE_AFTER
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return STALE_AFTER
|
||||
return min(max(number, _STALE_AFTER_MIN), _STALE_AFTER_MAX)
|
||||
|
||||
|
||||
def view_from_snapshot(snapshot: Any, now: Optional[float] = None) -> PluginRuntimeView:
|
||||
"""Judge a snapshot read from the cache; never raises."""
|
||||
if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_SCHEMA:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
published_at = _epoch(snapshot.get("published_at"))
|
||||
if published_at is None:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
stale_after = _stale_after_of(snapshot)
|
||||
age = (time.time() if now is None else now) - published_at
|
||||
if snapshot.get("running") is not True:
|
||||
return PluginRuntimeView(status=STOPPED, published_at=published_at,
|
||||
age_seconds=max(age, 0.0), stale_after=stale_after)
|
||||
# A snapshot from the future is trusted a little: the Pi has no RTC and
|
||||
# its clock steps when NTP syncs. Far in the future, it cannot be dated.
|
||||
if age > stale_after or age < -stale_after:
|
||||
return PluginRuntimeView(status=STALE, published_at=published_at,
|
||||
age_seconds=age, stale_after=stale_after)
|
||||
plugins = snapshot.get("plugins")
|
||||
return PluginRuntimeView(
|
||||
status=LIVE, published_at=published_at, age_seconds=max(age, 0.0),
|
||||
stale_after=stale_after,
|
||||
plugins={k: v for k, v in plugins.items() if isinstance(v, dict)}
|
||||
if isinstance(plugins, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def read_plugin_runtime(cache_manager: Any, now: Optional[float] = None) -> PluginRuntimeView:
|
||||
"""The display's latest snapshot, judged for staleness. Never raises; a
|
||||
missing cache manager or an unreadable snapshot is ``unknown``.
|
||||
|
||||
memory_ttl=0: the key is written by the other process, so only the file
|
||||
is current.
|
||||
"""
|
||||
if cache_manager is None:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
try:
|
||||
snapshot = cache_manager.get(PLUGIN_RUNTIME_KEY, max_age=None, memory_ttl=0)
|
||||
except Exception as err:
|
||||
logger.debug("Could not read the plugin runtime snapshot: %s", err, exc_info=True)
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
return view_from_snapshot(snapshot, now=now)
|
||||
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
Plugin State Management
|
||||
|
||||
Manages plugin state machine (loaded → enabled → running → error)
|
||||
with state transitions and queries.
|
||||
The display process's plugin state machine (loaded → enabled → running →
|
||||
error), with state transitions and queries. It is the only record of plugin
|
||||
lifecycle state: the web process runs no plugins, and reads this state as the
|
||||
snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -24,8 +27,27 @@ class PluginState(Enum):
|
||||
DISABLED = "disabled" # Plugin is disabled in config
|
||||
|
||||
|
||||
def published_state(state: PluginState) -> PluginState:
|
||||
"""The state as readers outside the scheduler see it.
|
||||
|
||||
RUNNING is the scheduler's claim on a plugin for one update() call: every
|
||||
update flips ENABLED -> RUNNING -> ENABLED. Published as is, that would be
|
||||
a change -- and a cache write to the SD card -- on every plugin update, and
|
||||
a reader would see a plugin blink between two states that mean the same
|
||||
thing to it (loaded and taking part). Readers get ENABLED for both.
|
||||
"""
|
||||
return PluginState.ENABLED if state == PluginState.RUNNING else state
|
||||
|
||||
|
||||
class PluginStateManager:
|
||||
"""Manages plugin state transitions and queries."""
|
||||
"""Manages plugin state transitions and queries.
|
||||
|
||||
Owned by the display process's PluginManager. ``change_count`` moves
|
||||
whenever something a reader of the published snapshot would see changes
|
||||
(published state, error info, the loaded record) and stays put across the
|
||||
RUNNING/ENABLED flip of an ordinary update, so a publisher can tell
|
||||
"nothing new" without diffing.
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
@@ -41,6 +63,20 @@ class PluginStateManager:
|
||||
self._state_transition_counts: Dict[str, int] = {}
|
||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||
self._last_update: Dict[str, datetime] = {}
|
||||
# What load_plugin() registered: {'version', 'loaded_at'} per plugin
|
||||
# whose instance is live. Cleared with the rest of its state on unload.
|
||||
self._loaded: Dict[str, Dict[str, Any]] = {}
|
||||
self._change_count = 0
|
||||
|
||||
@property
|
||||
def change_count(self) -> int:
|
||||
"""Moves on every change a published snapshot would show."""
|
||||
with self._lock:
|
||||
return self._change_count
|
||||
|
||||
def _note_change(self) -> None:
|
||||
"""Count a reader-visible change. Callers must already hold ``_lock``."""
|
||||
self._change_count += 1
|
||||
|
||||
def _record_transition(self, plugin_id: str) -> None:
|
||||
"""Count a state transition. Callers must already hold ``_lock``."""
|
||||
@@ -63,9 +99,12 @@ class PluginStateManager:
|
||||
error: Optional error if transitioning to ERROR state
|
||||
"""
|
||||
with self._lock:
|
||||
known = plugin_id in self._states
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
self._record_transition(plugin_id)
|
||||
if not known or published_state(old_state) != published_state(state):
|
||||
self._note_change()
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
@@ -74,9 +113,11 @@ class PluginStateManager:
|
||||
'error_type': type(error).__name__,
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
self._note_change()
|
||||
elif state != PluginState.ERROR:
|
||||
# Clear error info when leaving ERROR state
|
||||
self._error_info.pop(plugin_id, None)
|
||||
if self._error_info.pop(plugin_id, None) is not None:
|
||||
self._note_change()
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s",
|
||||
@@ -147,6 +188,7 @@ class PluginStateManager:
|
||||
self._states[plugin_id] = state
|
||||
self._record_transition(plugin_id)
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
self._note_change()
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s (recoverable error stored)",
|
||||
@@ -173,6 +215,52 @@ class PluginStateManager:
|
||||
info = self._error_info.get(plugin_id)
|
||||
return dict(info) if info is not None else None
|
||||
|
||||
def record_loaded(self, plugin_id: str, version: Optional[str],
|
||||
loaded_at: Optional[float] = None) -> None:
|
||||
"""Record that ``plugin_id``'s instance is live, and which version.
|
||||
|
||||
Called by PluginManager.load_plugin() once the instance is registered;
|
||||
clear_state() (unload) forgets it. ``version`` is the manifest's at
|
||||
load time, which is what the display keeps running until it reloads
|
||||
the plugin -- the version on disk can move on after a store update.
|
||||
"""
|
||||
with self._lock:
|
||||
self._loaded[plugin_id] = {
|
||||
'version': version,
|
||||
'loaded_at': time.time() if loaded_at is None else loaded_at,
|
||||
}
|
||||
self._note_change()
|
||||
|
||||
def record_unloaded(self, plugin_id: str) -> None:
|
||||
"""Forget the loaded record alone, keeping state and error info: for
|
||||
an unload that failed after the instance was already dropped."""
|
||||
with self._lock:
|
||||
if self._loaded.pop(plugin_id, None) is not None:
|
||||
self._note_change()
|
||||
|
||||
def runtime_records(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Every known plugin's reader-visible state, taken in one critical
|
||||
section so a concurrent load or unload is seen whole or not at all.
|
||||
|
||||
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
||||
``version`` and ``loaded_at`` (None unless loaded) and ``error_info``
|
||||
(a copy, or None).
|
||||
"""
|
||||
with self._lock:
|
||||
records: Dict[str, Dict[str, Any]] = {}
|
||||
for plugin_id in set(self._states) | set(self._loaded):
|
||||
loaded = self._loaded.get(plugin_id)
|
||||
info = self._error_info.get(plugin_id)
|
||||
records[plugin_id] = {
|
||||
'state': published_state(
|
||||
self._states.get(plugin_id, PluginState.UNLOADED)).value,
|
||||
'loaded': loaded is not None,
|
||||
'version': loaded['version'] if loaded else None,
|
||||
'loaded_at': loaded['loaded_at'] if loaded else None,
|
||||
'error_info': dict(info) if info is not None else None,
|
||||
}
|
||||
return records
|
||||
|
||||
def record_update(self, plugin_id: str) -> None:
|
||||
"""Record that plugin update() was called."""
|
||||
self._last_update[plugin_id] = datetime.now()
|
||||
@@ -221,8 +309,13 @@ class PluginStateManager:
|
||||
state.
|
||||
"""
|
||||
with self._lock:
|
||||
had = (plugin_id in self._states or plugin_id in self._loaded
|
||||
or plugin_id in self._error_info)
|
||||
self._states.pop(plugin_id, None)
|
||||
self._state_transition_counts.pop(plugin_id, None)
|
||||
self._error_info.pop(plugin_id, None)
|
||||
self._last_update.pop(plugin_id, None)
|
||||
self._loaded.pop(plugin_id, None)
|
||||
if had:
|
||||
self._note_change()
|
||||
|
||||
|
||||
@@ -127,8 +127,9 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
|
||||
# Left untyped: the adapter validates them itself and ignores a bad
|
||||
# value with a log line, so a stored one must never block a save.
|
||||
# These three are left untyped: the adapter validates them itself and
|
||||
# ignores a bad value with a log line, so a stored one must never block a
|
||||
# save.
|
||||
"vegas_width_pct": {
|
||||
"description": "Vegas mode: width of this plugin's card, as a percentage of the panel"
|
||||
},
|
||||
@@ -138,6 +139,34 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
"vegas_max_width_screens": {
|
||||
"description": "Vegas mode: widest this plugin's card may be, in screens"
|
||||
},
|
||||
# Read by resolve_vegas_participation / BasePlugin.get_vegas_participation.
|
||||
# An enum with no default: a default would be written into every plugin's
|
||||
# config and override the participation the plugin itself declares.
|
||||
"vegas_participation": {
|
||||
"type": "string",
|
||||
"enum": ["scroll", "pause", "exclude"],
|
||||
"title": "Vegas participation",
|
||||
"description": (
|
||||
"Vegas mode: how this plugin takes part in the scrolling ticker. "
|
||||
"'scroll' = its content scrolls by with everything else; "
|
||||
"'pause' = the ticker stops for this plugin's turn and shows it "
|
||||
"full screen for its display duration; "
|
||||
"'exclude' = leave it out of Vegas mode. "
|
||||
"Leave unset to use the plugin's own default."
|
||||
),
|
||||
},
|
||||
# Read by vegas_mode/plugin_adapter.py (PluginAdapter.is_live_capable).
|
||||
# No default, for the same reason: unset means on.
|
||||
"vegas_live": {
|
||||
"type": "boolean",
|
||||
"title": "Update in the Vegas ticker",
|
||||
"description": (
|
||||
"Vegas mode: for a plugin with live elements (scores, the flight "
|
||||
"map), change what is already scrolling when its data changes. "
|
||||
"Off shows each card as it was when it was drawn, as before. "
|
||||
"Leave unset for on."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
#: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin
|
||||
@@ -145,6 +174,7 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
#: PluginManager.CORE_OWNED_CONFIG_KEYS).
|
||||
CORE_VEGAS_TUNING_KEYS = frozenset({
|
||||
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
|
||||
'vegas_participation', 'vegas_live',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
"""
|
||||
Centralized plugin state management.
|
||||
|
||||
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
|
||||
with persistence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
|
||||
from src.config_manager_atomic import atomic_write_text
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class PluginStateStatus(Enum):
|
||||
"""Status of a plugin."""
|
||||
INSTALLED = "installed"
|
||||
ENABLED = "enabled"
|
||||
DISABLED = "disabled"
|
||||
ERROR = "error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginState:
|
||||
"""Represents the state of a plugin."""
|
||||
plugin_id: str
|
||||
status: PluginStateStatus
|
||||
enabled: bool
|
||||
version: Optional[str] = None
|
||||
installed_at: Optional[datetime] = None
|
||||
last_updated: Optional[datetime] = None
|
||||
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
|
||||
# plugin_state.json keeps the shape older releases load with cls(**data).
|
||||
config_version: int = 1
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert state to dictionary for serialization."""
|
||||
result = asdict(self)
|
||||
# Convert enum to string
|
||||
result['status'] = self.status.value
|
||||
# Convert datetime to ISO string
|
||||
if result.get('installed_at'):
|
||||
result['installed_at'] = self.installed_at.isoformat()
|
||||
if result.get('last_updated'):
|
||||
result['last_updated'] = self.last_updated.isoformat()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PluginState':
|
||||
"""Create state from dictionary."""
|
||||
# Parse enum
|
||||
if isinstance(data.get('status'), str):
|
||||
data['status'] = PluginStateStatus(data['status'])
|
||||
|
||||
# Parse datetime
|
||||
if data.get('installed_at') and isinstance(data['installed_at'], str):
|
||||
data['installed_at'] = datetime.fromisoformat(data['installed_at'])
|
||||
if data.get('last_updated') and isinstance(data['last_updated'], str):
|
||||
data['last_updated'] = datetime.fromisoformat(data['last_updated'])
|
||||
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class PluginStateManager:
|
||||
"""
|
||||
Centralized plugin state manager.
|
||||
|
||||
Provides:
|
||||
- Single source of truth for plugin state
|
||||
- State persistence
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_file: Optional[str] = None,
|
||||
auto_save: bool = True,
|
||||
lazy_load: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize state manager.
|
||||
|
||||
Args:
|
||||
state_file: Path to file for persisting state
|
||||
auto_save: Whether to automatically save state on changes
|
||||
lazy_load: If True, defer loading state file until first access
|
||||
"""
|
||||
self.logger = get_logger(__name__)
|
||||
self.state_file = Path(state_file) if state_file else None
|
||||
self.auto_save = auto_save
|
||||
self._lazy_load = lazy_load
|
||||
self._state_loaded = False
|
||||
|
||||
# State storage
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
# The file's top-level "version", written back as read. Nothing
|
||||
# checks it yet; it is there for a future format change to branch on.
|
||||
self._state_version = 1
|
||||
|
||||
# Threading
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Load state from file if it exists (unless lazy loading)
|
||||
if not self._lazy_load and self.state_file and self.state_file.exists():
|
||||
self._load_state()
|
||||
self._state_loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure state is loaded (for lazy loading)."""
|
||||
if not self._state_loaded and self.state_file and self.state_file.exists():
|
||||
self._load_state()
|
||||
self._state_loaded = True
|
||||
|
||||
def get_plugin_state(self, plugin_id: str) -> Optional[PluginState]:
|
||||
"""
|
||||
Get state for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
PluginState if found, None otherwise
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._states.get(plugin_id)
|
||||
|
||||
def get_all_states(self) -> Dict[str, PluginState]:
|
||||
"""
|
||||
Get all plugin states.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin_id to PluginState
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._states.copy()
|
||||
|
||||
def update_plugin_state(
|
||||
self,
|
||||
plugin_id: str,
|
||||
updates: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Update plugin state.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
updates: Dictionary of state updates
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
# Get current state or create new
|
||||
current_state = self._states.get(plugin_id)
|
||||
if not current_state:
|
||||
current_state = PluginState(
|
||||
plugin_id=plugin_id,
|
||||
status=PluginStateStatus.UNKNOWN,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
if 'status' in updates:
|
||||
if isinstance(updates['status'], str):
|
||||
current_state.status = PluginStateStatus(updates['status'])
|
||||
else:
|
||||
current_state.status = updates['status']
|
||||
|
||||
if 'enabled' in updates:
|
||||
current_state.enabled = bool(updates['enabled'])
|
||||
|
||||
if 'version' in updates:
|
||||
current_state.version = updates['version']
|
||||
|
||||
if 'installed_at' in updates:
|
||||
current_state.installed_at = updates['installed_at']
|
||||
|
||||
if 'last_updated' in updates:
|
||||
current_state.last_updated = updates['last_updated']
|
||||
else:
|
||||
current_state.last_updated = datetime.now()
|
||||
|
||||
if 'metadata' in updates:
|
||||
if current_state.metadata is None:
|
||||
current_state.metadata = {}
|
||||
current_state.metadata.update(updates['metadata'])
|
||||
|
||||
current_state.config_version += 1
|
||||
|
||||
# Store updated state
|
||||
self._states[plugin_id] = current_state
|
||||
|
||||
# Auto-save if enabled
|
||||
if self.auto_save:
|
||||
self._save_state()
|
||||
|
||||
return True
|
||||
|
||||
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> bool:
|
||||
"""
|
||||
Set plugin enabled/disabled state.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
enabled: Whether plugin is enabled
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
status = PluginStateStatus.ENABLED if enabled else PluginStateStatus.DISABLED
|
||||
return self.update_plugin_state(
|
||||
plugin_id,
|
||||
{
|
||||
'enabled': enabled,
|
||||
'status': status
|
||||
}
|
||||
)
|
||||
|
||||
def set_plugin_installed(
|
||||
self,
|
||||
plugin_id: str,
|
||||
version: Optional[str] = None,
|
||||
installed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Mark plugin as installed.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
version: Plugin version
|
||||
installed_at: Installation timestamp
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
return self.update_plugin_state(
|
||||
plugin_id,
|
||||
{
|
||||
'status': PluginStateStatus.INSTALLED,
|
||||
'version': version,
|
||||
'installed_at': installed_at or datetime.now()
|
||||
}
|
||||
)
|
||||
|
||||
def remove_plugin_state(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Remove plugin state (e.g., after uninstall).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if removal successful
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
if plugin_id in self._states:
|
||||
del self._states[plugin_id]
|
||||
|
||||
# Auto-save if enabled
|
||||
if self.auto_save:
|
||||
self._save_state()
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _save_state(self) -> None:
|
||||
"""Save state to file."""
|
||||
if not self.state_file:
|
||||
return
|
||||
|
||||
try:
|
||||
# The write stays under the lock and goes through a temp file:
|
||||
# Flask serves requests on threads, and two saves racing on a
|
||||
# plain open('w') could interleave or leave a truncated file
|
||||
# that _load_state then drops wholesale.
|
||||
with self._lock:
|
||||
# Convert states to dicts
|
||||
states_data = {
|
||||
plugin_id: state.to_dict()
|
||||
for plugin_id, state in self._states.items()
|
||||
}
|
||||
|
||||
state_data = {
|
||||
'version': self._state_version,
|
||||
'states': states_data,
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Ensure directory exists with proper permissions
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
|
||||
|
||||
# Write to file
|
||||
atomic_write_text(self.state_file, json.dumps(state_data, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Load state from file."""
|
||||
if not self.state_file or not self.state_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.state_file, 'r', encoding='utf-8') as f:
|
||||
state_data = json.load(f)
|
||||
|
||||
with self._lock:
|
||||
# Load state version
|
||||
self._state_version = state_data.get('version', 1)
|
||||
|
||||
# Load states
|
||||
states_data = state_data.get('states', {})
|
||||
for plugin_id, state_dict in states_data.items():
|
||||
try:
|
||||
self._states[plugin_id] = PluginState.from_dict(state_dict)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Error loading state for plugin {plugin_id}: {e}"
|
||||
)
|
||||
|
||||
self.logger.info(f"Loaded {len(self._states)} plugin states from file")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading plugin state: {e}", exc_info=True)
|
||||
@@ -1,22 +1,34 @@
|
||||
"""
|
||||
State reconciliation system.
|
||||
|
||||
Detects and fixes inconsistencies between:
|
||||
- Config file state
|
||||
- Plugin manager state
|
||||
- Disk state (installed plugins)
|
||||
- State manager state
|
||||
Compares what the user wants with what is there and what runs:
|
||||
|
||||
- desired: config.json (which plugins are configured, and enabled) plus the
|
||||
plugins directory on disk (which are installed, at which version);
|
||||
- observed: the runtime snapshot the display publishes
|
||||
(src/plugin_system/plugin_runtime.py) -- which plugins it has loaded, at
|
||||
which version, and why one failed. Only a live snapshot is compared; a
|
||||
stale, stopped or missing one is unknown and yields no findings.
|
||||
|
||||
Desired-state gaps (on disk but not in config, in config but not on disk)
|
||||
are fixed here. Observed-state gaps (enabled but not loaded, loaded at an
|
||||
older version) are reported, never "fixed": the display reconciles its own
|
||||
loaded set against config, and a version gap needs a display restart.
|
||||
|
||||
There is no third, persisted record any more. ``data/plugin_state.json``
|
||||
held a copy of config's enabled flags and the disk's versions, and this
|
||||
module mostly synced it back to config; it is no longer read or written.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List, Set, cast
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
|
||||
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.plugin_runtime import PluginRuntimeView, UNKNOWN
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
@@ -160,36 +172,40 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
||||
return live
|
||||
|
||||
|
||||
RuntimeSource = Callable[[], PluginRuntimeView]
|
||||
|
||||
|
||||
class StateReconciliation:
|
||||
"""
|
||||
State reconciliation system.
|
||||
|
||||
Compares state from multiple sources and detects/fixes inconsistencies.
|
||||
|
||||
Compares desired state (config + disk) with observed state (the
|
||||
display's runtime snapshot) and fixes what can safely be fixed.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_manager: PluginStateManager,
|
||||
*,
|
||||
config_manager,
|
||||
plugin_manager,
|
||||
plugins_dir: Path,
|
||||
store_manager=None
|
||||
store_manager=None,
|
||||
runtime_source: Optional[RuntimeSource] = None,
|
||||
):
|
||||
"""
|
||||
Initialize reconciliation system.
|
||||
|
||||
Args:
|
||||
state_manager: PluginStateManager instance
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: PluginManager instance
|
||||
plugins_dir: Path to plugins directory
|
||||
store_manager: Optional PluginStoreManager for auto-repair
|
||||
runtime_source: Returns the display's runtime snapshot as a
|
||||
PluginRuntimeView (plugin_runtime.read_plugin_runtime bound to
|
||||
a cache manager). None: observed state is unknown.
|
||||
"""
|
||||
self.state_manager = state_manager
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.store_manager = store_manager
|
||||
self.runtime_source = runtime_source
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Plugin IDs that failed auto-repair and should NOT be retried this
|
||||
@@ -230,30 +246,28 @@ class StateReconciliation:
|
||||
manual_fix_required = []
|
||||
|
||||
try:
|
||||
# Get state from all sources
|
||||
# Desired: config + disk. Observed: the display's snapshot.
|
||||
config_state = self._get_config_state()
|
||||
disk_state = self._get_disk_state()
|
||||
manager_state = self._get_manager_state()
|
||||
state_manager_state = self._get_state_manager_state()
|
||||
|
||||
# Find all unique plugin IDs
|
||||
observed = self._get_observed_state()
|
||||
|
||||
# Plugins the display reports but neither config nor disk knows
|
||||
# (removed while it still runs them) are not a finding of their
|
||||
# own: the display unloads them when their section goes.
|
||||
all_plugin_ids: Set[str] = set()
|
||||
all_plugin_ids.update(config_state.keys())
|
||||
all_plugin_ids.update(disk_state.keys())
|
||||
all_plugin_ids.update(manager_state.keys())
|
||||
all_plugin_ids.update(state_manager_state.keys())
|
||||
|
||||
|
||||
# Check each plugin for inconsistencies
|
||||
for plugin_id in all_plugin_ids:
|
||||
plugin_inconsistencies = self._check_plugin_consistency(
|
||||
plugin_id,
|
||||
config_state,
|
||||
disk_state,
|
||||
manager_state,
|
||||
state_manager_state
|
||||
observed,
|
||||
)
|
||||
inconsistencies.extend(plugin_inconsistencies)
|
||||
|
||||
|
||||
# Attempt to fix auto-fixable inconsistencies
|
||||
for inconsistency in inconsistencies:
|
||||
if inconsistency.can_auto_fix and inconsistency.fix_action == FixAction.AUTO_FIX:
|
||||
@@ -293,11 +307,11 @@ class StateReconciliation:
|
||||
# Top-level config keys that are NOT plugins. The core keys come from the
|
||||
# shared list in src/core_config_keys.py -- a private copy here missed
|
||||
# #581's 'auto_update' and reported it as a plugin missing from disk.
|
||||
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
|
||||
# CORE_SECRETS_KEYS are the core's own secrets-file keys. The secrets file
|
||||
# itself is read at run time too (ignored_config_keys): load_config() merges
|
||||
# it in, and naming its keys one by one let a 'data' key become a phantom
|
||||
# plugin permanently reported as "in config but not on disk".
|
||||
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
|
||||
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | CORE_SECRETS_KEYS
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from config file."""
|
||||
@@ -309,8 +323,9 @@ class StateReconciliation:
|
||||
for plugin_id in config_plugin_ids(config, ignored):
|
||||
plugin_config = config[plugin_id]
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_config.get('enabled', True),
|
||||
'version': plugin_config.get('version'),
|
||||
# The display's rule: it runs a plugin only when its
|
||||
# section says "enabled": true.
|
||||
'enabled': bool(plugin_config.get('enabled', False)),
|
||||
'exists_in_config': True
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -339,45 +354,51 @@ class StateReconciliation:
|
||||
self.logger.warning(f"Error reading disk state: {e}")
|
||||
return state
|
||||
|
||||
def _get_manager_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from plugin manager."""
|
||||
state = {}
|
||||
def _get_observed_state(self) -> PluginRuntimeView:
|
||||
"""The display's runtime snapshot; unknown when there is no source or
|
||||
it cannot be read. Only a live view is compared."""
|
||||
if self.runtime_source is None:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
try:
|
||||
if self.plugin_manager:
|
||||
# Get discovered plugins
|
||||
if hasattr(self.plugin_manager, 'plugin_manifests'):
|
||||
for plugin_id in self.plugin_manager.plugin_manifests.keys():
|
||||
state[plugin_id] = {
|
||||
'exists_in_manager': True,
|
||||
'loaded': plugin_id in getattr(self.plugin_manager, 'plugins', {})
|
||||
}
|
||||
return self.runtime_source()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading manager state: {e}")
|
||||
return state
|
||||
|
||||
def _get_state_manager_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from state manager."""
|
||||
state = {}
|
||||
try:
|
||||
all_states = self.state_manager.get_all_states()
|
||||
for plugin_id, plugin_state in all_states.items():
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_state.enabled,
|
||||
'status': plugin_state.status.value,
|
||||
'version': plugin_state.version,
|
||||
'exists_in_state_manager': True
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading state manager state: {e}")
|
||||
return state
|
||||
|
||||
self.logger.warning(f"Error reading the display's runtime state: {e}")
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
|
||||
def plugin_states(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Desired and observed state for every plugin config or disk knows.
|
||||
|
||||
Per plugin: ``installed`` and ``version`` (disk), ``in_config`` and
|
||||
``enabled`` (config, by the display's rule), and the display's
|
||||
``loaded`` / ``state`` / ``error_info`` / ``loaded_version`` /
|
||||
``loaded_at`` (None unless its snapshot is live). What
|
||||
/api/v3/plugins/state serves, in place of plugin_state.json.
|
||||
"""
|
||||
config_state = self._get_config_state()
|
||||
disk_state = self._get_disk_state()
|
||||
observed = self._get_observed_state()
|
||||
states: Dict[str, Dict[str, Any]] = {}
|
||||
for plugin_id in sorted(set(config_state) | set(disk_state)):
|
||||
if plugin_id in CORE_CONFIG_KEYS:
|
||||
continue
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
states[plugin_id] = {
|
||||
'plugin_id': plugin_id,
|
||||
'installed': bool(disk.get('exists_on_disk')),
|
||||
'version': disk.get('version'),
|
||||
'in_config': bool(config.get('exists_in_config')),
|
||||
'enabled': bool(config.get('enabled', False)),
|
||||
**observed.plugin(plugin_id),
|
||||
}
|
||||
return states
|
||||
|
||||
def _check_plugin_consistency(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_state: Dict[str, Dict[str, Any]],
|
||||
disk_state: Dict[str, Dict[str, Any]],
|
||||
manager_state: Dict[str, Dict[str, Any]],
|
||||
state_manager_state: Dict[str, Dict[str, Any]]
|
||||
observed: PluginRuntimeView,
|
||||
) -> List[Inconsistency]:
|
||||
"""Check consistency for a single plugin."""
|
||||
inconsistencies: List[Inconsistency] = []
|
||||
@@ -397,7 +418,6 @@ class StateReconciliation:
|
||||
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
state_mgr = state_manager_state.get(plugin_id, {})
|
||||
|
||||
# Check: Plugin exists on disk but not in config
|
||||
if disk.get('exists_on_disk') and not config.get('exists_in_config'):
|
||||
@@ -442,21 +462,47 @@ class StateReconciliation:
|
||||
can_auto_fix=can_repair
|
||||
))
|
||||
|
||||
# Check: Enabled state mismatch
|
||||
config_enabled = config.get('enabled', False)
|
||||
state_mgr_enabled = state_mgr.get('enabled')
|
||||
# Observed checks: only against a live snapshot, and only for a plugin
|
||||
# that is both configured and installed (the checks above cover the
|
||||
# rest). Reported, never fixed here: the display loads and unloads by
|
||||
# config on its own, so a gap is either transient (it is catching up)
|
||||
# or something only the user can act on (a failed load, a restart).
|
||||
if (observed.live and config.get('exists_in_config')
|
||||
and disk.get('exists_on_disk')):
|
||||
runtime = observed.plugin(plugin_id)
|
||||
config_enabled = bool(config.get('enabled', False))
|
||||
loaded = bool(runtime.get('loaded'))
|
||||
if config_enabled != loaded:
|
||||
error = runtime.get('error_info') or {}
|
||||
why = f" ({error.get('message')})" if error.get('message') else ""
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=(
|
||||
f"Plugin {plugin_id} is {'enabled' if config_enabled else 'disabled'} "
|
||||
f"in config but the display has it "
|
||||
f"{'loaded' if loaded else 'not loaded'} "
|
||||
f"(state {runtime.get('state')}){why}"),
|
||||
fix_action=FixAction.NO_ACTION,
|
||||
current_state={'loaded': loaded, 'state': runtime.get('state')},
|
||||
expected_state={'loaded': config_enabled},
|
||||
can_auto_fix=False
|
||||
))
|
||||
loaded_version = runtime.get('loaded_version')
|
||||
disk_version = disk.get('version')
|
||||
if loaded and loaded_version and disk_version and loaded_version != disk_version:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_VERSION_MISMATCH,
|
||||
description=(
|
||||
f"Plugin {plugin_id} {disk_version} is installed but the display "
|
||||
f"is running {loaded_version}; restart the display to run it"),
|
||||
fix_action=FixAction.NO_ACTION,
|
||||
current_state={'version': loaded_version},
|
||||
expected_state={'version': disk_version},
|
||||
can_auto_fix=False
|
||||
))
|
||||
|
||||
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'enabled': state_mgr_enabled},
|
||||
expected_state={'enabled': config_enabled},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
return inconsistencies
|
||||
|
||||
def _fix_inconsistency(self, inconsistency: Inconsistency) -> bool:
|
||||
@@ -490,26 +536,6 @@ class StateReconciliation:
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK:
|
||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# config.json is the user-editable source of truth for enabled state.
|
||||
# Bring the state manager in sync with config rather than the reverse,
|
||||
# so that manual config edits (or the state left behind after an
|
||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
||||
# Always set for this type (see _check_plugin_consistency).
|
||||
config_enabled = cast(bool, inconsistency.expected_state.get('enabled'))
|
||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
||||
if success:
|
||||
self.logger.info(
|
||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id} to match config"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Failed to sync state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
|
||||
@@ -63,7 +63,14 @@ class _InstallMixin:
|
||||
return False
|
||||
|
||||
with self._get_reinstall_lock(plugin_id):
|
||||
plugin_path = self.plugins_dir / plugin_id
|
||||
# The copy to protect is wherever this plugin is installed, not
|
||||
# necessarily plugins_dir/<id>: asked for the registry id
|
||||
# `weather`, the install lives in `ledmatrix-weather/`, the
|
||||
# manifest's id. Backing up only `weather/` protected nothing,
|
||||
# and _install_plugin_impl then deleted `ledmatrix-weather/` to
|
||||
# make room for the download -- so a refusal after that point
|
||||
# (the post-download compatibility gate) left no plugin at all.
|
||||
plugin_path = self._existing_install(plugin_id) or self.plugins_dir / plugin_id
|
||||
if not plugin_path.exists():
|
||||
return self._install_plugin_impl(plugin_id, branch)
|
||||
|
||||
@@ -91,6 +98,26 @@ class _InstallMixin:
|
||||
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
||||
return False
|
||||
|
||||
def _existing_install(self, plugin_id: str) -> Optional[Path]:
|
||||
"""The installed copy of ``plugin_id`` in plugins_dir, by the id or an
|
||||
alias the registry proves (`_installed_id_candidates`).
|
||||
|
||||
When nothing matches but a ``ledmatrix-<id>`` folder exists and no
|
||||
registry is loaded yet, the registry is fetched first -- the install
|
||||
fetches it anyway -- because without it that folder can be neither
|
||||
protected nor trusted: the download may be renamed onto it.
|
||||
"""
|
||||
dirs = [self.plugins_dir]
|
||||
found = self._resolve_installed(plugin_id, dirs)
|
||||
if (found is None and not getattr(self, 'registry_cache', None)
|
||||
and self._unproven_prefix_folder(plugin_id, dirs) is not None):
|
||||
try:
|
||||
self.fetch_registry()
|
||||
except Exception as e: # noqa: BLE001 - proceed as before without proof
|
||||
self.logger.debug("Registry fetch before installing %s failed: %s", plugin_id, e)
|
||||
found = self._resolve_installed(plugin_id, dirs)
|
||||
return found
|
||||
|
||||
def _set_aside(self, plugin_path: Path, backup_path: Path) -> Optional[str]:
|
||||
"""Rename an installed plugin to ``backup_path`` so a failed
|
||||
(re)install can put it back.
|
||||
@@ -164,6 +191,16 @@ class _InstallMixin:
|
||||
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
||||
return False
|
||||
|
||||
# The registry's floor describes the release on the entry's branch.
|
||||
# Checked here, before anything is removed or downloaded; the gate on
|
||||
# the downloaded manifest below stays as the fallback (older
|
||||
# registries, compatible_versions ranges). A different branch asked
|
||||
# for by name is a different release, so only the fallback applies.
|
||||
registry_branch = plugin_info.get('branch') or plugin_info.get('default_branch')
|
||||
if (not branch or not registry_branch or branch == registry_branch) and \
|
||||
self._refuse_if_registry_incompatible(plugin_id, plugin_info, "install"):
|
||||
return False
|
||||
|
||||
plugin_subpath = plugin_info.get('plugin_path')
|
||||
# If branch is provided, prioritize it; otherwise use default logic
|
||||
branch_candidates = self._distinct_sequence([
|
||||
@@ -280,9 +317,11 @@ class _InstallMixin:
|
||||
return False
|
||||
|
||||
# Refuse a plugin that needs a newer core than this one. The
|
||||
# registry carries no compatibility field, so the floor is only
|
||||
# knowable once the files are down — checking here, before
|
||||
# dependency installation, is the earliest possible point.
|
||||
# registry's `ledmatrix_min_version` already refused the
|
||||
# common case before the download (above); this is the
|
||||
# fallback for a registry without it, a branch other than the
|
||||
# registry's, and `compatible_versions`, which only the
|
||||
# manifest carries. Before dependency installation, still.
|
||||
#
|
||||
# Refusing costs the user nothing: on an update this returns
|
||||
# False and _reinstall_with_rollback restores the version they
|
||||
@@ -299,6 +338,7 @@ class _InstallMixin:
|
||||
if not compatible:
|
||||
self.logger.error(
|
||||
"Refusing to install %s: %s", plugin_id, reason)
|
||||
self._note_refusal(requested_id, reason)
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from src.plugin_system.plugin_dirs import (
|
||||
PluginDirectoryIndex, resolve_plugin_dir, store_search_dirs,
|
||||
)
|
||||
from src.plugin_system.store_install import _InstallMixin
|
||||
from src.plugin_system.store_registry import _RegistryMixin
|
||||
from src.plugin_system.store_registry import _RegistryMixin, prefix_hint
|
||||
from src.plugin_system.store_update import _UpdateMixin
|
||||
|
||||
|
||||
@@ -407,13 +407,20 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
||||
alone reported such a plugin as not installed, so update_plugin()
|
||||
silently did nothing.
|
||||
|
||||
No ``ledmatrix-`` prefix and no case folding here, unlike the loader:
|
||||
a store operation may delete what this returns, so it only accepts a
|
||||
directory that names the id exactly or declares it. So a registry id
|
||||
such as `stocks` does not resolve to an installed `ledmatrix-stocks/`
|
||||
declaring `ledmatrix-stocks` (the monorepo's leaderboard, music,
|
||||
stocks and weather); callers pass the installed id, and
|
||||
update_plugin() maps it back to the registry id itself.
|
||||
When nothing answers to the id itself, the ids the registry proves
|
||||
are the same plugin are tried the same way
|
||||
(`_installed_id_candidates`): the entry's own id, its ``aliases`` and
|
||||
its ``plugin_path`` name. So the registry id `stocks` finds an
|
||||
installed `ledmatrix-stocks/` declaring `ledmatrix-stocks` (the
|
||||
monorepo's leaderboard, music, stocks and weather), and uninstalling
|
||||
by the registry id no longer reports success while leaving the
|
||||
plugin on disk.
|
||||
|
||||
Never ``ledmatrix-<id>`` without that proof -- no registry loaded, or
|
||||
an entry that doesn't name it: a store operation may delete or
|
||||
replace what this returns, and an unrelated plugin can own that
|
||||
folder. Such a folder is only logged, so a person can act on it.
|
||||
Still no case folding.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
@@ -421,9 +428,55 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
||||
Returns:
|
||||
Path to plugin directory if found, None otherwise
|
||||
"""
|
||||
return resolve_plugin_dir(
|
||||
plugin_id, self._candidate_plugin_dirs(), prefix=False,
|
||||
case_insensitive=False)
|
||||
return self._find_with_proof(plugin_id, fetch=False)
|
||||
|
||||
def _find_with_proof(self, plugin_id: str, fetch: bool) -> Optional[Path]:
|
||||
"""`_find_plugin_path`; with ``fetch``, a ``ledmatrix-<id>`` folder
|
||||
found while no registry is loaded makes it fetch the registry and
|
||||
look again, since only the registry can prove the folder is this
|
||||
plugin. Uninstall passes False (it must work offline); update, which
|
||||
needs the network anyway, passes True."""
|
||||
search_dirs = self._candidate_plugin_dirs()
|
||||
found = self._resolve_installed(plugin_id, search_dirs)
|
||||
if found is not None:
|
||||
return found
|
||||
folder = self._unproven_prefix_folder(plugin_id, search_dirs)
|
||||
if folder is not None and fetch and not getattr(self, 'registry_cache', None):
|
||||
try:
|
||||
self.fetch_registry()
|
||||
except Exception as e: # noqa: BLE001 - fall through to "not found"
|
||||
self.logger.debug("Registry fetch while looking for %s failed: %s", plugin_id, e)
|
||||
found = self._resolve_installed(plugin_id, search_dirs)
|
||||
if found is not None:
|
||||
return found
|
||||
if folder is not None:
|
||||
self.logger.warning(
|
||||
"Plugin %s not found. %s may be it, but nothing in the plugin "
|
||||
"registry says so (no alias), so the store leaves it alone; "
|
||||
"if it is this plugin, manage it as %s.",
|
||||
plugin_id, folder, prefix_hint(plugin_id))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _unproven_prefix_folder(plugin_id: str, search_dirs: List[Path]) -> Optional[Path]:
|
||||
"""A ``ledmatrix-<id>`` folder, which the store names but won't touch."""
|
||||
hint = prefix_hint(plugin_id)
|
||||
if hint is None:
|
||||
return None
|
||||
return resolve_plugin_dir(hint, search_dirs, prefix=False, by_manifest=False)
|
||||
|
||||
def _resolve_installed(self, plugin_id: str, search_dirs: List[Path]) -> Optional[Path]:
|
||||
"""The first of ``plugin_id``'s candidate ids found in ``search_dirs``.
|
||||
|
||||
The id itself is looked for in every directory before any alias is,
|
||||
so an exact install anywhere beats an alias in the configured one.
|
||||
"""
|
||||
for candidate in self._installed_id_candidates(plugin_id):
|
||||
found = resolve_plugin_dir(
|
||||
candidate, search_dirs, prefix=False, case_insensitive=False)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
def _candidate_plugin_dirs(self) -> List[Path]:
|
||||
"""Directories that may hold installed plugins, configured one first."""
|
||||
|
||||
@@ -13,11 +13,62 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
from src.plugin_system.plugin_dirs import PLUGIN_DIR_PREFIX
|
||||
from src.plugin_system.repo_urls import (
|
||||
github_api_headers, github_owner_repo, normalize_repo_url,
|
||||
)
|
||||
|
||||
|
||||
# Registry entry fields the plugin monorepo's update_registry.py added after
|
||||
# 3.7.0. All optional: an older plugins.json has none of them, and every
|
||||
# reader here treats a missing or malformed one as "not stated".
|
||||
#
|
||||
# - ``ledmatrix_min_version``: the floor the plugin's manifest declares, so an
|
||||
# incompatible install or update is refused before the download
|
||||
# (`registry_incompatibility`). The post-download gate stays as the fallback.
|
||||
# - ``aliases``: other ids the plugin goes by (the manifest id when it differs
|
||||
# from the registry id, e.g. ``ledmatrix-weather`` for ``weather``). With
|
||||
# ``plugin_path``'s name, the only proof the store accepts that a folder
|
||||
# under another name is this plugin (`alternate_ids`).
|
||||
# - ``commit``: the monorepo commit that introduced ``latest_version``.
|
||||
# Informational only -- installs still come from the branch head.
|
||||
|
||||
|
||||
def declared_aliases(entry: Dict[str, Any]) -> Optional[List[str]]:
|
||||
"""The entry's ``aliases``, or None when it carries no such list."""
|
||||
aliases = entry.get('aliases')
|
||||
if not isinstance(aliases, list):
|
||||
return None
|
||||
own = entry.get('id')
|
||||
return [a for a in aliases if isinstance(a, str) and a and a != own]
|
||||
|
||||
|
||||
def alternate_ids(entry: Dict[str, Any]) -> List[str]:
|
||||
"""Ids other than the registry id that the registry *proves* an installed
|
||||
copy may carry: the entry's ``aliases``, then its ``plugin_path``
|
||||
directory name (all an older registry has).
|
||||
|
||||
Never ``ledmatrix-<id>`` on its own say-so. Store operations delete and
|
||||
replace what these ids resolve to, and an unrelated plugin can live in a
|
||||
folder of that name (owner decision on #686). A guess is only a hint:
|
||||
see `prefix_hint`.
|
||||
"""
|
||||
own = entry.get('id')
|
||||
ids: List[str] = list(declared_aliases(entry) or [])
|
||||
path = entry.get('plugin_path')
|
||||
if isinstance(path, str) and path.strip('/'):
|
||||
ids.append(path.rstrip('/').rsplit('/', 1)[-1])
|
||||
return [g for i, g in enumerate(ids) if g and g != own and g not in ids[:i]]
|
||||
|
||||
|
||||
def prefix_hint(plugin_id: Any) -> Optional[str]:
|
||||
"""``ledmatrix-<id>``: the legacy folder name worth *mentioning* when
|
||||
``plugin_id`` is not found -- never one to act on without registry proof."""
|
||||
if isinstance(plugin_id, str) and plugin_id and not plugin_id.startswith(PLUGIN_DIR_PREFIX):
|
||||
return PLUGIN_DIR_PREFIX + plugin_id
|
||||
return None
|
||||
|
||||
|
||||
class _RegistryMixin:
|
||||
"""PluginStoreManager methods: see the module docstring."""
|
||||
|
||||
@@ -821,19 +872,117 @@ class _RegistryMixin:
|
||||
Matching ``plugin_path`` fixes it without renaming any published id,
|
||||
which would orphan ``plugin_state.json`` entries keyed on the old ones.
|
||||
Exact id always wins, so an entry whose *path* happens to collide with
|
||||
another entry's id cannot shadow it.
|
||||
another entry's id cannot shadow it. An entry's ``aliases`` (registries
|
||||
from after 3.7.0) come next, then ``plugin_path``, which is what an
|
||||
older registry has to go on.
|
||||
"""
|
||||
if not plugin_id:
|
||||
return None
|
||||
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
|
||||
if exact is not None:
|
||||
return exact
|
||||
for entry in plugins:
|
||||
if plugin_id in (declared_aliases(entry) or ()):
|
||||
return entry
|
||||
for entry in plugins:
|
||||
path = (entry.get('plugin_path') or '').rstrip('/')
|
||||
if path and path.rsplit('/', 1)[-1] == plugin_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def registry_incompatibility(self, plugin_id: str,
|
||||
entry: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Why the registry says this core cannot run the plugin's latest
|
||||
release, or None when it says nothing against it.
|
||||
|
||||
Reads the entry's ``ledmatrix_min_version`` and asks
|
||||
``compatibility.check`` -- the same function, and so the same wording
|
||||
and the same leniency (an untrustworthy or unparseable core version
|
||||
allows), as the gate that runs on the downloaded manifest. That gate
|
||||
stays: it also sees ``compatible_versions``, and an older registry
|
||||
without the field says nothing here.
|
||||
|
||||
``entry`` defaults to the registry entry for ``plugin_id``. Any
|
||||
failure to read the registry answers None: the pre-check exists to
|
||||
refuse early on evidence, never to block on a guess.
|
||||
"""
|
||||
if entry is None:
|
||||
try:
|
||||
entry = self.get_registry_info(plugin_id)
|
||||
except Exception as e: # noqa: BLE001 - never block an install on this
|
||||
self.logger.debug("Registry lookup for %s failed: %s", plugin_id, e)
|
||||
return None
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
floor = entry.get('ledmatrix_min_version')
|
||||
if not isinstance(floor, str) or not floor.strip():
|
||||
return None
|
||||
from src.plugin_system import compatibility
|
||||
compatible, reason = compatibility.check(
|
||||
{'id': entry.get('id') or plugin_id, 'name': entry.get('name'),
|
||||
'min_ledmatrix_version': floor.strip()},
|
||||
compatibility.current_core_version())
|
||||
return None if compatible else reason
|
||||
|
||||
def _refuse_if_registry_incompatible(self, plugin_id: str, entry: Optional[Dict[str, Any]],
|
||||
action: str, record_as: Optional[str] = None) -> bool:
|
||||
"""Log and record a registry-based refusal; True when refused.
|
||||
|
||||
``record_as`` is the id the caller will ask `pop_refusal` about (the
|
||||
id it was handed, which may be an alias of ``plugin_id``).
|
||||
"""
|
||||
reason = self.registry_incompatibility(plugin_id, entry)
|
||||
if reason is None:
|
||||
return False
|
||||
self.logger.error("Refusing to %s %s before downloading it: %s",
|
||||
action, plugin_id, reason)
|
||||
self._note_refusal(record_as or plugin_id, reason)
|
||||
return True
|
||||
|
||||
def _note_refusal(self, plugin_id: str, reason: str) -> None:
|
||||
"""Remember why an install or update of ``plugin_id`` was refused, so
|
||||
the web route can say so instead of "check logs for details"."""
|
||||
refusals = self.__dict__.setdefault('_refusals', {})
|
||||
refusals[plugin_id] = reason
|
||||
|
||||
def pop_refusal(self, *plugin_ids: str) -> Optional[str]:
|
||||
"""The compatibility refusal recorded for any of ``plugin_ids`` since
|
||||
the last call, clearing them all; None when there was none."""
|
||||
refusals = self.__dict__.get('_refusals') or {}
|
||||
found = None
|
||||
for plugin_id in plugin_ids:
|
||||
reason = refusals.pop(plugin_id, None)
|
||||
if found is None and reason:
|
||||
found = reason
|
||||
return found
|
||||
|
||||
def _installed_id_candidates(self, plugin_id: str) -> List[str]:
|
||||
"""``plugin_id`` and the other ids the registry proves its installed
|
||||
copy may carry.
|
||||
|
||||
From the registry already in memory -- no fetch, because uninstall
|
||||
and the update lookup must work offline. With an entry: its id and
|
||||
`alternate_ids` (``aliases``, ``plugin_path`` name). Without one (no
|
||||
registry loaded yet, or a plugin that isn't in it): the id alone.
|
||||
A folder whose manifest declares one of these ids is found by the
|
||||
resolver's manifest pass whatever it is called.
|
||||
"""
|
||||
ids: List[str] = [plugin_id]
|
||||
cache = getattr(self, 'registry_cache', None)
|
||||
plugins = cache.get('plugins') if isinstance(cache, dict) else None
|
||||
entry = None
|
||||
if isinstance(plugins, list) and isinstance(plugin_id, str):
|
||||
entry = self._match_registry_entry(
|
||||
[p for p in plugins if isinstance(p, dict)], plugin_id)
|
||||
if entry is not None:
|
||||
ids.append(entry.get('id'))
|
||||
ids.extend(alternate_ids(entry))
|
||||
unique: List[str] = []
|
||||
for candidate in ids:
|
||||
if isinstance(candidate, str) and candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
|
||||
@@ -195,10 +195,12 @@ class _UpdateMixin:
|
||||
surfaces as one line in the journal and a scoreboard that silently
|
||||
stopped appearing.
|
||||
|
||||
Checked after the pull rather than before it, for the same reason
|
||||
``_install_plugin_impl`` checks after the download: the registry
|
||||
carries no compatibility field, so the incoming floor is only knowable
|
||||
once the new commit is on disk.
|
||||
The registry's ``ledmatrix_min_version`` refuses most of these before
|
||||
the pull (``update_plugin``). This is the fallback, for the same cases
|
||||
``_install_plugin_impl``'s post-download gate covers: a registry
|
||||
without the field, a checkout on another branch than the registry's,
|
||||
and ``compatible_versions`` -- all only knowable once the new commit
|
||||
is on disk.
|
||||
|
||||
Undone with ``git reset --hard`` rather than by removing the directory.
|
||||
This is a live checkout, the previous commit is still in the object
|
||||
@@ -233,6 +235,7 @@ class _UpdateMixin:
|
||||
return True
|
||||
|
||||
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||
self._note_refusal(plugin_id, reason)
|
||||
|
||||
if not previous_sha:
|
||||
self.logger.error(
|
||||
@@ -310,8 +313,10 @@ class _UpdateMixin:
|
||||
"""
|
||||
Update a plugin to the latest commit on its upstream branch.
|
||||
"""
|
||||
plugin_path = self._find_plugin_path(plugin_id)
|
||||
|
||||
# fetch=True: an update needs the registry anyway, and only it can
|
||||
# prove a ledmatrix-<id>/ folder is this plugin.
|
||||
plugin_path = self._find_with_proof(plugin_id, fetch=True)
|
||||
|
||||
if plugin_path is None or not plugin_path.exists():
|
||||
self.logger.error(f"Plugin not installed: {plugin_id}")
|
||||
return False
|
||||
@@ -368,6 +373,11 @@ class _UpdateMixin:
|
||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||
f"Reinstalling from registry to migrate to new source."
|
||||
)
|
||||
# Before the old copy is moved aside: the reinstall
|
||||
# would only refuse after a download and a restore.
|
||||
if self._refuse_if_registry_incompatible(
|
||||
resolved_id, plugin_info_remote, "update", record_as=plugin_id):
|
||||
return False
|
||||
return self._reinstall_with_rollback(resolved_id, plugin_path)
|
||||
|
||||
# Check if already up to date
|
||||
@@ -375,6 +385,14 @@ class _UpdateMixin:
|
||||
self.logger.info(f"Plugin {plugin_id} already matches remote commit {remote_sha[:7]}")
|
||||
return True
|
||||
|
||||
# The registry's floor describes its branch; a checkout
|
||||
# on another branch pulls another release, and the gate
|
||||
# after the pull (_gate_pulled_commit) still covers it.
|
||||
if (not remote_branch or remote_branch == local_branch) and \
|
||||
self._refuse_if_registry_incompatible(
|
||||
resolved_id, plugin_info_remote, "update", record_as=plugin_id):
|
||||
return False
|
||||
|
||||
# Update via git pull
|
||||
self.logger.info(f"Updating {plugin_id} via git pull (local branch: {local_branch})...")
|
||||
try:
|
||||
@@ -718,6 +736,12 @@ class _UpdateMixin:
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
|
||||
|
||||
# A newer version this core cannot run: refuse now, while the
|
||||
# installed copy is untouched, rather than after a download.
|
||||
if self._refuse_if_registry_incompatible(
|
||||
registry_id, plugin_info_remote, "update", record_as=plugin_id):
|
||||
return False
|
||||
|
||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from datetime import timedelta
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.error
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -84,6 +84,8 @@ class RenderResult:
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y)
|
||||
# warnings worth printing that do not fail the result
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def size_label(self) -> str:
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Offline checks for a plugin's live Vegas elements.
|
||||
|
||||
A plugin that implements ``get_vegas_elements()`` promises the ticker a few
|
||||
things it cannot check for itself until they go wrong on a panel: unique,
|
||||
stable keys; images at the display's height; the same width for the same key
|
||||
until the data changes; the same result when nothing changed; and, for an
|
||||
element with ``refresh_hz``, a ``redraw_vegas_element()`` that returns exactly
|
||||
the size asked for, quickly. :func:`check_vegas_elements` exercises each of
|
||||
those the way the Vegas ticker calls the hooks -- on a canvas of the plugin's
|
||||
own, told its render width -- and says what failed.
|
||||
|
||||
``scripts/check_plugin.py`` runs it for every plugin that implements the hook.
|
||||
See "Live Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterator, List, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
#: A warm get_vegas_elements() slower than this holds the ticker's single
|
||||
#: background worker, and the plugin's lock, for longer than it should.
|
||||
SLOW_ELEMENTS_SECONDS = 0.2
|
||||
#: A redraw_vegas_element() slower than this cannot keep up with a few Hz.
|
||||
SLOW_REDRAW_SECONDS = 0.02
|
||||
#: The narrowed render width the check also tries, as a share of the panel.
|
||||
NARROW_PCT = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class VegasElementReport:
|
||||
"""What :func:`check_vegas_elements` found."""
|
||||
implemented: bool
|
||||
elements: int = 0
|
||||
live: int = 0
|
||||
errors: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
|
||||
def implements_vegas_elements(plugin: Any) -> bool:
|
||||
"""Whether the plugin's class overrides BasePlugin.get_vegas_elements."""
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
method = getattr(type(plugin), 'get_vegas_elements', None)
|
||||
return method is not None and method is not getattr(
|
||||
BasePlugin, 'get_vegas_elements', None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _as_vegas_canvas(plugin: Any, display_manager: Any, width: int) -> Iterator[None]:
|
||||
"""Run a hook the way Vegas does: told its width, on a canvas of its own."""
|
||||
plugin._vegas_render_width = width
|
||||
try:
|
||||
offscreen = getattr(display_manager, 'offscreen', None)
|
||||
with offscreen(width) if offscreen is not None else nullcontext():
|
||||
yield
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
|
||||
|
||||
def render_vegas_elements(plugin: Any, display_manager: Any,
|
||||
width: Optional[int] = None) -> Any:
|
||||
"""Call ``plugin.get_vegas_elements()`` as the Vegas ticker does."""
|
||||
render_width = int(width or display_manager.width)
|
||||
with _as_vegas_canvas(plugin, display_manager, render_width):
|
||||
return plugin.get_vegas_elements()
|
||||
|
||||
|
||||
def redraw_vegas_element(plugin: Any, display_manager: Any, key: str,
|
||||
width: int, height: int, at: Optional[float] = None,
|
||||
render_width: Optional[int] = None) -> Any:
|
||||
"""Call ``plugin.redraw_vegas_element()`` as the Vegas ticker does."""
|
||||
with _as_vegas_canvas(plugin, display_manager,
|
||||
int(render_width or display_manager.width)):
|
||||
return plugin.redraw_vegas_element(
|
||||
key, width, height, time.monotonic() if at is None else at)
|
||||
|
||||
|
||||
def _refresh_hz(element: Any) -> Optional[float]:
|
||||
"""An element's refresh_hz as a number (None counts as 0), or None if it is not one."""
|
||||
try:
|
||||
return float(element.refresh_hz or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _usable(element: Any) -> bool:
|
||||
"""A VegasElement the checks can read: a str key and an image."""
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
return (isinstance(element, VegasElement) and isinstance(element.key, str)
|
||||
and bool(element.key) and isinstance(element.image, Image.Image))
|
||||
|
||||
|
||||
def check_vegas_elements(plugin: Any, display_manager: Any) -> VegasElementReport:
|
||||
"""Exercise a plugin's live-element hooks and report what breaks the contract.
|
||||
|
||||
Errors are what the ticker would refuse or show wrongly; warnings are what
|
||||
it would cope with but should not have to (slow calls, an element wider
|
||||
than the width the plugin was asked to render at).
|
||||
"""
|
||||
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
|
||||
if not report.implemented:
|
||||
return report
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
height = int(display_manager.height)
|
||||
full = int(display_manager.width)
|
||||
|
||||
def fetch(width: int, label: str):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
result = render_vegas_elements(plugin, display_manager, width)
|
||||
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
|
||||
report.errors.append(f"get_vegas_elements() raised {exc!r} ({label})")
|
||||
return None, 0.0
|
||||
return result, time.perf_counter() - started
|
||||
|
||||
first, _ = fetch(full, "full width")
|
||||
if first is None:
|
||||
if not report.errors:
|
||||
report.warnings.append(
|
||||
"get_vegas_elements() returned None: the ticker will use "
|
||||
"get_vegas_content() instead")
|
||||
return report
|
||||
if not isinstance(first, (list, tuple)):
|
||||
report.errors.append(
|
||||
f"get_vegas_elements() returned {type(first).__name__}, expected a list")
|
||||
return report
|
||||
|
||||
seen = set()
|
||||
widths = {}
|
||||
for index, element in enumerate(first):
|
||||
where = f"element[{index}]"
|
||||
if not isinstance(element, VegasElement):
|
||||
report.errors.append(f"{where} is a {type(element).__name__}, not a VegasElement")
|
||||
continue
|
||||
key = element.key
|
||||
if not isinstance(key, str) or not key:
|
||||
report.errors.append(f"{where} has no key (a non-empty str is required)")
|
||||
continue
|
||||
where = f"element {key!r}"
|
||||
if key in seen:
|
||||
report.errors.append(f"{where} appears twice; keys must be unique")
|
||||
continue
|
||||
seen.add(key)
|
||||
image: Any = element.image # typed Image, but a plugin may pass anything
|
||||
if not isinstance(image, Image.Image):
|
||||
report.errors.append(f"{where} image is a {type(image).__name__}")
|
||||
continue
|
||||
if element.image.height != height:
|
||||
report.errors.append(
|
||||
f"{where} is {element.image.height}px tall; the display is {height}px")
|
||||
if element.image.width <= 0 or element.image.height <= 0:
|
||||
report.errors.append(f"{where} image is empty ({element.image.width}x"
|
||||
f"{element.image.height})")
|
||||
continue
|
||||
if element.image.width > full:
|
||||
report.warnings.append(
|
||||
f"{where} is {element.image.width}px wide, wider than the "
|
||||
f"{full}px it was asked to render at")
|
||||
hz = _refresh_hz(element)
|
||||
if hz is None:
|
||||
report.errors.append(
|
||||
f"{where} refresh_hz {element.refresh_hz!r} is not a number")
|
||||
elif hz < 0:
|
||||
report.errors.append(f"{where} has a negative refresh_hz")
|
||||
report.elements += 1
|
||||
if element.live:
|
||||
report.live += 1
|
||||
widths[key] = element.image.width
|
||||
|
||||
if report.errors:
|
||||
return report
|
||||
|
||||
# The same data twice must give the same keys, widths and versions: the
|
||||
# ticker redraws on every update and swaps in only what changed.
|
||||
second, warm = fetch(full, "second call")
|
||||
if second is not None and not isinstance(second, (list, tuple)):
|
||||
report.errors.append(
|
||||
f"get_vegas_elements() returned {type(second).__name__} on a second "
|
||||
"call, expected a list")
|
||||
elif isinstance(second, (list, tuple)):
|
||||
again = {e.key: e for e in second if _usable(e)}
|
||||
for element in first:
|
||||
other = again.get(element.key)
|
||||
if other is None:
|
||||
report.errors.append(
|
||||
f"element {element.key!r} disappeared on a second call with "
|
||||
"no new data")
|
||||
continue
|
||||
if element.live and other.image.width != element.image.width:
|
||||
report.errors.append(
|
||||
f"element {element.key!r} changed width with no new data "
|
||||
f"({element.image.width} -> {other.image.width}px); a live "
|
||||
"element's width must not depend on when it is drawn")
|
||||
if element.version is not None and other.version != element.version \
|
||||
and not _refresh_hz(element):
|
||||
report.warnings.append(
|
||||
f"element {element.key!r} changed version with no new data; "
|
||||
"every update will redraw it")
|
||||
if warm > SLOW_ELEMENTS_SECONDS:
|
||||
report.warnings.append(
|
||||
f"get_vegas_elements() took {warm * 1000:.0f}ms with nothing new "
|
||||
f"(over {SLOW_ELEMENTS_SECONDS * 1000:.0f}ms); cache what has not "
|
||||
"changed")
|
||||
|
||||
narrow = max(1, full * NARROW_PCT // 100)
|
||||
if narrow < full:
|
||||
narrowed, _ = fetch(narrow, f"{NARROW_PCT}% width")
|
||||
if isinstance(narrowed, (list, tuple)):
|
||||
for element in narrowed:
|
||||
if isinstance(element, VegasElement) and isinstance(element.image, Image.Image) \
|
||||
and element.image.width > narrow:
|
||||
report.warnings.append(
|
||||
f"element {element.key!r} is {element.image.width}px wide at "
|
||||
f"a {narrow}px render width; read get_vegas_render_width() "
|
||||
"or display_manager.width when sizing it")
|
||||
break
|
||||
|
||||
has_redraw = type(plugin).redraw_vegas_element is not _base_redraw()
|
||||
for element in first:
|
||||
hz = _refresh_hz(element) or 0.0
|
||||
if not (element.live and hz > 0):
|
||||
continue
|
||||
if not has_redraw:
|
||||
report.warnings.append(
|
||||
f"element {element.key!r} asks for {hz:g}Hz but "
|
||||
"redraw_vegas_element() is not implemented; the ticker re-runs "
|
||||
"get_vegas_elements() under the plugin's lock instead")
|
||||
continue
|
||||
w, h = element.image.width, element.image.height
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
redrawn = redraw_vegas_element(plugin, display_manager, element.key, w, h)
|
||||
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
|
||||
report.errors.append(f"redraw_vegas_element({element.key!r}) raised {exc!r}")
|
||||
continue
|
||||
took = time.perf_counter() - started
|
||||
if redrawn is not None:
|
||||
if not isinstance(redrawn, Image.Image):
|
||||
report.errors.append(
|
||||
f"redraw_vegas_element({element.key!r}) returned "
|
||||
f"{type(redrawn).__name__}, expected an Image or None")
|
||||
elif redrawn.size != (w, h):
|
||||
report.errors.append(
|
||||
f"redraw_vegas_element({element.key!r}) returned "
|
||||
f"{redrawn.width}x{redrawn.height}, asked for {w}x{h}")
|
||||
if took > SLOW_REDRAW_SECONDS:
|
||||
report.warnings.append(
|
||||
f"redraw_vegas_element({element.key!r}) took {took * 1000:.1f}ms "
|
||||
f"(over {SLOW_REDRAW_SECONDS * 1000:.0f}ms); the ticker will "
|
||||
"slow its refresh")
|
||||
return report
|
||||
|
||||
|
||||
def _base_redraw():
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
return BasePlugin.redraw_vegas_element
|
||||
|
||||
|
||||
def render_vegas_strip(plugin: Any, plugin_id: str, display_manager: Any,
|
||||
live: bool = True) -> Any:
|
||||
"""The plugin's block of the Vegas strip, laid out exactly as the ticker would.
|
||||
|
||||
Fetched through the ticker's own adapter (trimming, pinning, width
|
||||
budget) and joined with its own spacing, so what this draws is what
|
||||
scrolls. ``live=False`` shows the ordinary get_vegas_content() instead.
|
||||
|
||||
Returns ``(block, layout)`` -- layout a list of ``(x, key, width)`` for the
|
||||
live elements in the block -- or ``(None, [])`` when there is nothing.
|
||||
"""
|
||||
_adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, live)
|
||||
return block, [(x, meta.key, width) for x, meta, width in layout]
|
||||
|
||||
|
||||
def _vegas_block(plugin: Any, plugin_id: str, display_manager: Any, live: bool) -> Any:
|
||||
"""(adapter, block, layout) for a plugin's Vegas block, through the ticker's own code."""
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.elements import LiveEpochs
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.render_pipeline import join_plugin_rows
|
||||
|
||||
config = VegasModeConfig()
|
||||
adapter = PluginAdapter(display_manager, config)
|
||||
adapter.live_elements_enabled = live
|
||||
adapter.live_epochs = LiveEpochs()
|
||||
images = adapter.get_content(plugin, plugin_id, offscreen_only=True)
|
||||
if not images:
|
||||
return adapter, None, []
|
||||
block, layout = join_plugin_rows(images, config)
|
||||
return adapter, block, layout
|
||||
|
||||
|
||||
def render_vegas_timeline(plugin: Any, plugin_id: str, display_manager: Any,
|
||||
steps: int = 8, step_seconds: float = 0.25,
|
||||
run_update: bool = False) -> Any:
|
||||
"""The plugin's Vegas block at successive moments, one row per step.
|
||||
|
||||
Row 0 is the block as placed (render_vegas_strip). Each later row is the
|
||||
same block ``step_seconds`` later, changed the way the ticker would change
|
||||
it in place: every live element with ``refresh_hz`` redrawn for that
|
||||
moment through redraw_vegas_element(), and -- with ``run_update`` -- the
|
||||
plugin's update() run first and every live element redrawn from the new
|
||||
data. A redraw of another width is left out, as the ticker refuses it.
|
||||
Rows are separated by a grey line.
|
||||
|
||||
Returns ``(image, rows)``, or ``(None, 0)`` when there is nothing.
|
||||
"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, True)
|
||||
if block is None:
|
||||
return None, 0
|
||||
base = np.array(block.convert('RGB'))
|
||||
rows = [base.copy()]
|
||||
height = base.shape[0]
|
||||
start = time.monotonic()
|
||||
for step in range(1, max(1, int(steps))):
|
||||
frame = rows[-1].copy()
|
||||
if run_update:
|
||||
plugin.update()
|
||||
adapter.live_epochs.bump(plugin_id)
|
||||
batch = adapter.render_live_elements(plugin, plugin_id, lock_timeout=5.0)
|
||||
rendered = batch[1] if batch is not None else {}
|
||||
for x, meta, width in layout:
|
||||
element = rendered.get(meta.key)
|
||||
if element is not None and element.width == width:
|
||||
frame[:, x:x + width] = element.pixels
|
||||
at = start + step * float(step_seconds)
|
||||
for x, meta, width in layout:
|
||||
if meta.refresh_hz > 0:
|
||||
element = adapter.redraw_live_element(plugin, plugin_id, meta.key,
|
||||
width, height, at)
|
||||
if element is not None and element.width == width:
|
||||
frame[:, x:x + width] = element.pixels
|
||||
rows.append(frame)
|
||||
divider = np.full((1, base.shape[1], 3), 60, dtype=np.uint8)
|
||||
stacked = [rows[0]]
|
||||
for row in rows[1:]:
|
||||
stacked.extend([divider, row])
|
||||
return Image.fromarray(np.concatenate(stacked, axis=0)), len(rows)
|
||||
|
||||
|
||||
def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict,
|
||||
mock_data: dict, width: int, height: int,
|
||||
run_update: bool = True) -> VegasElementReport:
|
||||
"""Load a plugin from its directory at one panel size and check its elements.
|
||||
|
||||
What ``scripts/check_plugin.py`` runs: the plugin gets the same mocked
|
||||
managers as the rendering harness, and its update() is run first (a
|
||||
network error there is tolerated, as in the harness) so the elements are
|
||||
drawn from data rather than from an empty start.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from src.plugin_system.testing.harness import _TOLERATED_UPDATE_ERRORS, _instantiate
|
||||
from src.plugin_system.testing.loading import load_manifest
|
||||
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager
|
||||
|
||||
plugin_dir = Path(plugin_dir)
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
try:
|
||||
plugin = _instantiate(plugin_id, load_manifest(plugin_dir), plugin_dir,
|
||||
config, mock_data, display_manager)
|
||||
except Exception as exc: # noqa: BLE001 - the matrix run reports load errors
|
||||
report = VegasElementReport(implemented=False)
|
||||
report.warnings.append(f"not checked: the plugin did not load ({exc!r})")
|
||||
return report
|
||||
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
|
||||
if not report.implemented:
|
||||
return report
|
||||
if run_update:
|
||||
try:
|
||||
plugin.update()
|
||||
except Exception as exc: # noqa: BLE001 - a plugin's update can raise anything
|
||||
if not isinstance(exc, _TOLERATED_UPDATE_ERRORS):
|
||||
report.errors.append(f"update() raised {exc!r}")
|
||||
return report
|
||||
report.warnings.append(f"update() had no network ({exc!r}); checked "
|
||||
"with whatever data the plugin starts with")
|
||||
checked = check_vegas_elements(plugin, display_manager)
|
||||
checked.warnings[:0] = report.warnings
|
||||
return checked
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Live elements: Vegas content that can change while it is on screen.
|
||||
|
||||
A plugin's ``get_vegas_content()`` hands the Vegas ticker pictures, and the
|
||||
ticker bakes them into its strip: a score drawn when the plugin's turn was
|
||||
prefetched scrolls past with that score, however many goals are scored while
|
||||
it crosses the panel. A plugin that returns **elements** instead gives each
|
||||
picture a name and a fixed width. The ticker then keeps track of where each
|
||||
one is in the strip, and when the plugin's data changes it asks for just the
|
||||
changed elements and swaps their pixels in place -- on screen included,
|
||||
between two frames, without anything next to them moving.
|
||||
|
||||
A plugin opts in by implementing ``BasePlugin.get_vegas_elements()``, and, for
|
||||
content that changes with time rather than with data (an aircraft moving
|
||||
between position reports), ``BasePlugin.redraw_vegas_element()``. See "Live
|
||||
Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
|
||||
|
||||
Added in LEDMatrix 3.8.0. Import it guarded, so the plugin still loads on an
|
||||
older core (which never calls the hooks)::
|
||||
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0
|
||||
VegasElement = None
|
||||
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
return [VegasElement(key=f"game:{g['id']}", image=self._card(g),
|
||||
version=self._fingerprint(g))
|
||||
for g in self._games]
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Hashable, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=False)
|
||||
class VegasElement:
|
||||
"""One named, fixed-width piece of a plugin's Vegas content.
|
||||
|
||||
Attributes:
|
||||
key: Names the element across redraws, unique within one list the
|
||||
plugin returns: ``"game:nfl:401547417"``, ``"sep:0:nfl"``,
|
||||
``"map"``. The ticker matches a redraw to the pixels already in
|
||||
its strip by this key, so it must stay the same for the same
|
||||
logical thing and must not be reused for a different one.
|
||||
image: The element as drawn now, at the display's height. For a
|
||||
``live`` element its width is fixed for as long as the key is on
|
||||
the strip: a redraw at a different width is never swapped in (it
|
||||
appears the next time the plugin comes round instead), because
|
||||
nothing on screen may move. Draw live elements at a width that
|
||||
does not depend on the data -- a fixed card width, not the
|
||||
width of the text.
|
||||
version: Anything hashable that changes exactly when the pixels
|
||||
would, such as the tuple of fields the element draws. The ticker
|
||||
skips work for an unchanged version. ``None`` means "compare the
|
||||
pixels", which is always correct and costs a checksum.
|
||||
live: False places the element exactly as plain content is placed
|
||||
(trimmed to its ink, never refreshed): separators, decoration.
|
||||
refresh_hz: More than 0 asks for ``redraw_vegas_element()`` about
|
||||
this often while the element is on or near the screen, for
|
||||
content that changes with time rather than with data. The ticker
|
||||
caps the rate (``vegas_scroll.live_max_hz``, 1 Hz on a display
|
||||
without the rebuilt rgbmatrix binding) and slows it for an
|
||||
element that is slow to draw.
|
||||
"""
|
||||
key: str
|
||||
image: Image.Image
|
||||
version: Optional[Hashable] = None
|
||||
live: bool = True
|
||||
refresh_hz: float = 0.0
|
||||
@@ -99,6 +99,25 @@ class VegasModeConfig:
|
||||
# overall from 0.90% to 0.60%. See src/common/render_gate.py.
|
||||
prefetch_gate: bool = True
|
||||
|
||||
# Live elements (src/plugin_system/vegas_elements.py): a plugin that hands
|
||||
# the ticker named, fixed-width elements has them redrawn when its data
|
||||
# changes, and the changed pixels are swapped into the strip in place --
|
||||
# on screen included -- instead of waiting for the plugin's next turn.
|
||||
# False restores the frozen-segment behaviour exactly. Also off, whatever
|
||||
# this says, under multi-display sync, in swap mode (continuous_scroll
|
||||
# false) and with offscreen_prefetch false.
|
||||
live_refresh: bool = True
|
||||
# Ceiling on how often an element that animates (refresh_hz) is redrawn,
|
||||
# in Hz. 0 turns animation off and keeps data-driven updates.
|
||||
live_max_hz: float = 5.0
|
||||
# Shortest time between two data redraws of one plugin, in seconds. A
|
||||
# plugin updating faster is redrawn at this rate, never skipped: the
|
||||
# latest data is always drawn eventually.
|
||||
live_min_interval: float = 2.0
|
||||
# How far ahead of the right edge, in screens, an animated element starts
|
||||
# being redrawn, so it is already moving when it scrolls in.
|
||||
live_lead_screens: float = 1.0
|
||||
|
||||
# Keep one continuous strip, extending it with the next group of plugins as
|
||||
# the scroll approaches the end, instead of composing a fresh strip and
|
||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
||||
@@ -161,10 +180,13 @@ class VegasModeConfig:
|
||||
|
||||
# --- Live content in the ticker -------------------------------------
|
||||
#
|
||||
# By default a live game preempts Vegas entirely: the display controller
|
||||
# refuses to run the ticker while any plugin reports live priority, and you
|
||||
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
|
||||
# marquee running and let live content take extra turns within it.
|
||||
# By default live content stays in the marquee and takes extra turns
|
||||
# within it, its cards updating while they scroll (live elements, below).
|
||||
# With live_in_ticker false a live game preempts Vegas entirely: the
|
||||
# display controller refuses to run the ticker while any plugin reports
|
||||
# live priority, and you get the full-screen scoreboard instead. (False
|
||||
# was the default until 3.8.0; ConfigManager turns it on once for configs
|
||||
# that still hold the old default.)
|
||||
#
|
||||
# The rotation is otherwise a strict round robin -- every plugin appears
|
||||
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
||||
@@ -174,7 +196,7 @@ class VegasModeConfig:
|
||||
# Weights are per plugin, not per game: a scoreboard showing four live
|
||||
# games still occupies one slot at a time, and rotates its own games within
|
||||
# that slot using its own favorite_live_boost.
|
||||
live_in_ticker: bool = False
|
||||
live_in_ticker: bool = True
|
||||
|
||||
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
||||
# and restores the plain round robin.
|
||||
@@ -235,6 +257,10 @@ class VegasModeConfig:
|
||||
offscreen_prefetch=bool(get('offscreen_prefetch', d.offscreen_prefetch)),
|
||||
switch_interval_ms=float(get('switch_interval_ms', d.switch_interval_ms) or 0.0),
|
||||
prefetch_gate=bool(get('prefetch_gate', d.prefetch_gate)),
|
||||
live_refresh=bool(get('live_refresh', d.live_refresh)),
|
||||
live_max_hz=float(get('live_max_hz', d.live_max_hz)),
|
||||
live_min_interval=float(get('live_min_interval', d.live_min_interval)),
|
||||
live_lead_screens=float(get('live_lead_screens', d.live_lead_screens)),
|
||||
extend_threshold_screens=float(
|
||||
get('extend_threshold_screens', d.extend_threshold_screens)),
|
||||
auto_trim=get('auto_trim', d.auto_trim),
|
||||
@@ -281,6 +307,10 @@ class VegasModeConfig:
|
||||
'offscreen_prefetch': self.offscreen_prefetch,
|
||||
'switch_interval_ms': self.switch_interval_ms,
|
||||
'prefetch_gate': self.prefetch_gate,
|
||||
'live_refresh': self.live_refresh,
|
||||
'live_max_hz': self.live_max_hz,
|
||||
'live_min_interval': self.live_min_interval,
|
||||
'live_lead_screens': self.live_lead_screens,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
@@ -377,6 +407,18 @@ class VegasModeConfig:
|
||||
"extend_threshold_screens must be between 1.0 and 10.0, "
|
||||
f"got {self.extend_threshold_screens}")
|
||||
|
||||
if not 0.0 <= self.live_max_hz <= 10.0:
|
||||
errors.append(
|
||||
f"live_max_hz must be between 0 and 10, got {self.live_max_hz}")
|
||||
if not 0.5 <= self.live_min_interval <= 60.0:
|
||||
errors.append(
|
||||
"live_min_interval must be between 0.5 and 60, "
|
||||
f"got {self.live_min_interval}")
|
||||
if not 0.0 <= self.live_lead_screens <= 5.0:
|
||||
errors.append(
|
||||
"live_lead_screens must be between 0 and 5, "
|
||||
f"got {self.live_lead_screens}")
|
||||
|
||||
if not 1 <= self.min_cut_gap <= 128:
|
||||
errors.append(
|
||||
"min_cut_gap must be between 1 and 128, "
|
||||
|
||||
@@ -5,10 +5,12 @@ Main orchestrator for Vegas-style continuous scroll mode. Coordinates between
|
||||
StreamManager, RenderPipeline, and the display system to provide smooth
|
||||
continuous scrolling of all enabled plugin content.
|
||||
|
||||
Supports three display modes per plugin:
|
||||
- SCROLL: Content scrolls continuously within the stream
|
||||
- FIXED_SEGMENT: Fixed block that scrolls by with other content
|
||||
- STATIC: Scroll pauses, plugin displays for its duration, then resumes
|
||||
Each plugin takes part in one of three ways (its Vegas participation, see
|
||||
BasePlugin.get_vegas_participation):
|
||||
- 'scroll': its content scrolls by within the stream
|
||||
- 'pause': the scroll pauses, the plugin displays for its duration, then
|
||||
the scroll resumes
|
||||
- 'exclude': left out
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -18,8 +20,10 @@ import time
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||
|
||||
from src import display_watchdog
|
||||
from src.common import render_gate
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.elements import LiveEpochs
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
@@ -83,6 +87,9 @@ class VegasModeCoordinator:
|
||||
|
||||
# Class-level so coordinators built without __init__ (tests) have it.
|
||||
_last_live_check: float = float('-inf')
|
||||
#: Whether live elements are on for this run (see _apply_live_state).
|
||||
live_active: bool = False
|
||||
_live_reason: Optional[str] = None
|
||||
# Set only while Vegas has changed the GIL switch interval; read with getattr.
|
||||
_saved_switch_interval: Optional[float]
|
||||
|
||||
@@ -121,6 +128,12 @@ class VegasModeCoordinator:
|
||||
self.stream_manager
|
||||
)
|
||||
|
||||
# Live elements: one data epoch per plugin, shared with the adapter,
|
||||
# which stamps every element it draws with it. Moved on by the plugin
|
||||
# manager's update listener while Vegas runs. See _apply_live_state.
|
||||
self.live_epochs = LiveEpochs()
|
||||
self.plugin_adapter.live_epochs = self.live_epochs
|
||||
|
||||
# State management
|
||||
self._is_active = False
|
||||
self._is_paused = False
|
||||
@@ -290,6 +303,9 @@ class VegasModeCoordinator:
|
||||
self._fps_was_degraded = False
|
||||
self._apply_switch_interval()
|
||||
self._install_render_gate()
|
||||
# Before the first background fetch below, which is the first
|
||||
# that may ask a plugin for live elements.
|
||||
self._apply_live_state()
|
||||
|
||||
# Line up the next group immediately, so the first extension is already
|
||||
# warm rather than stalling the scroll to fetch it.
|
||||
@@ -316,6 +332,7 @@ class VegasModeCoordinator:
|
||||
|
||||
self._restore_switch_interval()
|
||||
self._remove_render_gate()
|
||||
self._set_live(False, None)
|
||||
|
||||
# Cleanup components
|
||||
self.render_pipeline.reset()
|
||||
@@ -341,6 +358,62 @@ class VegasModeCoordinator:
|
||||
sys.setswitchinterval(saved)
|
||||
self._saved_switch_interval = None
|
||||
|
||||
# -- live elements ------------------------------------------------------
|
||||
|
||||
def _live_blocker(self) -> Optional[str]:
|
||||
"""Why live elements must stay off for this run, or None if they may run."""
|
||||
cfg = self.vegas_config
|
||||
if not getattr(cfg, 'live_refresh', False):
|
||||
return "switched off (vegas_scroll.live_refresh)"
|
||||
if getattr(self.render_pipeline, 'sync_manager', None) is not None:
|
||||
# The follower mirrors whole strips only; a patch would not reach it.
|
||||
return "multi-display sync is configured"
|
||||
if not cfg.continuous_scroll:
|
||||
return "swap mode (vegas_scroll.continuous_scroll is off)"
|
||||
if not cfg.offscreen_prefetch:
|
||||
return "vegas_scroll.offscreen_prefetch is off"
|
||||
if not hasattr(self.display_manager, 'offscreen'):
|
||||
return "the display manager has no off-screen canvas"
|
||||
return None
|
||||
|
||||
def _apply_live_state(self) -> None:
|
||||
"""Switch live elements on or off for this run, as the config allows."""
|
||||
blocker = self._live_blocker()
|
||||
self._set_live(blocker is None, blocker)
|
||||
|
||||
def _set_live(self, active: bool, reason: Optional[str]) -> None:
|
||||
was, self.live_active = self.live_active, active
|
||||
# getattr: tests build coordinators without every component.
|
||||
adapter = getattr(self, 'plugin_adapter', None)
|
||||
if adapter is not None:
|
||||
adapter.live_elements_enabled = active
|
||||
pipeline = getattr(self, 'render_pipeline', None)
|
||||
if pipeline is not None and hasattr(pipeline, 'set_live'):
|
||||
pipeline.set_live(active)
|
||||
plugin_manager = getattr(self, 'plugin_manager', None)
|
||||
add = getattr(plugin_manager, 'add_update_listener', None)
|
||||
remove = getattr(plugin_manager, 'remove_update_listener', None)
|
||||
if active and callable(add):
|
||||
add(self._on_plugin_data_changed)
|
||||
elif not active and callable(remove):
|
||||
remove(self._on_plugin_data_changed)
|
||||
if active != was or (reason is not None and reason != self._live_reason):
|
||||
if active:
|
||||
logger.info("Vegas live elements on")
|
||||
elif reason is not None:
|
||||
logger.info("Vegas live elements off: %s", reason)
|
||||
self._live_reason = reason
|
||||
|
||||
def _on_plugin_data_changed(self, plugin_id: str) -> None:
|
||||
"""Update listener: a plugin's data may have changed.
|
||||
|
||||
Runs on the update worker with the plugin's lock held, so it only
|
||||
moves the plugin's epoch on and wakes the live-element worker, which
|
||||
redraws once the lock is free.
|
||||
"""
|
||||
self.live_epochs.bump(plugin_id)
|
||||
self.render_pipeline.notify_live_data(plugin_id)
|
||||
|
||||
def _install_render_gate(self) -> None:
|
||||
"""Gate the prefetch thread on the render thread's swaps; see VegasModeConfig."""
|
||||
if not self.vegas_config.prefetch_gate:
|
||||
@@ -436,6 +509,12 @@ class VegasModeCoordinator:
|
||||
# game still shown as live the next morning.
|
||||
self.render_pipeline.refresh_updated_plugins()
|
||||
|
||||
# Copy any live-element redraws the worker has finished into the
|
||||
# strip, between this frame and the last. A deque check when there
|
||||
# are none.
|
||||
if self.live_active:
|
||||
self.render_pipeline.apply_live_patches()
|
||||
|
||||
# Extend the strip before the scroll can reach its end, so the next
|
||||
# group arrives from the right and motion never stops. No cycle
|
||||
# boundary, so no freeze, no substitution and no restart with the
|
||||
@@ -540,6 +619,9 @@ class VegasModeCoordinator:
|
||||
# the whole budget -- the render loop stalls for the size of the
|
||||
# correction. A forward jump inflates p99 and worst-frame instead.
|
||||
frame_started = time.monotonic()
|
||||
# An iteration runs for minutes (max_cycle_duration) without
|
||||
# returning to the display controller's loop.
|
||||
display_watchdog.beat()
|
||||
|
||||
# Check for STATIC mode plugin that should pause scroll
|
||||
static_plugin = self._check_static_plugin_trigger()
|
||||
@@ -644,7 +726,12 @@ class VegasModeCoordinator:
|
||||
# main loop's _tick_plugin_updates() finds all intervals already
|
||||
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
||||
# display never shows a frozen frame between iterations.
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.render_pipeline.target_fps * 4)) # every 4 s regardless of FPS
|
||||
# Every 4 s, or every 1 s while the strip holds live elements:
|
||||
# plugins are only scheduled on this tick, so its period is added
|
||||
# to how late a live update can be.
|
||||
tick_seconds = (1.0 if self.live_active
|
||||
and self.render_pipeline.has_live_records() else 4.0)
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.render_pipeline.target_fps * tick_seconds))
|
||||
if (self._update_callback and
|
||||
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
||||
not self._update_tick_running):
|
||||
@@ -768,6 +855,8 @@ class VegasModeCoordinator:
|
||||
# Cached segments were trimmed under the old settings, so drop them
|
||||
# or a changed trim/padding value would not visibly take effect.
|
||||
self.plugin_adapter.invalidate_cache()
|
||||
if self._is_active:
|
||||
self._apply_live_state()
|
||||
|
||||
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||
self.stream_manager._last_refresh = 0
|
||||
@@ -921,6 +1010,7 @@ class VegasModeCoordinator:
|
||||
|
||||
# Sleep in small increments to remain responsive
|
||||
time.sleep(0.1)
|
||||
display_watchdog.beat()
|
||||
|
||||
logger.info(
|
||||
"Static pause completed for %s after %.1fs",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Bookkeeping for live Vegas elements (see src/plugin_system/vegas_elements.py).
|
||||
|
||||
A live element travels through the same plumbing as any other Vegas content --
|
||||
the adapter's cache, a prefetched group, the pipeline's join -- as a PIL image.
|
||||
What makes it live rides along in the image's ``info`` dict (:data:`INFO_KEY`),
|
||||
which Pillow copies through ``copy()``, ``crop()``, ``convert()`` and
|
||||
``resize()``, so none of that plumbing has to change shape. The pipeline reads
|
||||
the tag back when it places the image in the strip and keeps an
|
||||
:class:`ElementRecord` of where it went.
|
||||
|
||||
Geometry is pinned: a live element is never trimmed to its ink. It is padded
|
||||
with ``content_padding`` black columns each side, the margin trimming would
|
||||
have left, so its width in the strip is its image width plus twice that, for
|
||||
as long as its key is there. That is what lets a redraw be swapped in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
import zlib
|
||||
from typing import Dict, NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
#: Where a live element's :class:`ElementMeta` rides in ``Image.info``.
|
||||
INFO_KEY = "ledmatrix.vegas_element"
|
||||
|
||||
|
||||
class ElementMeta(NamedTuple):
|
||||
"""What the pipeline needs to know about one live element's pixels."""
|
||||
plugin_id: str
|
||||
key: str
|
||||
#: The plugin's data epoch (LiveEpochs) the pixels were drawn from.
|
||||
epoch: int
|
||||
#: pixel_digest() of the pinned pixels.
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
#: time.monotonic() when drawn.
|
||||
rendered_at: float
|
||||
refresh_hz: float
|
||||
#: The plugin's own version for the pixels, or None.
|
||||
version: object = None
|
||||
|
||||
|
||||
class ElementRecord(NamedTuple):
|
||||
"""Where one live element sits in the strip.
|
||||
|
||||
``abs_x`` is in absolute strip columns: the strip's own column plus every
|
||||
column trimmed off its front since it was composed (the pipeline's
|
||||
``_strip_origin``). Trimming therefore never moves a record.
|
||||
"""
|
||||
seq: int
|
||||
plugin_id: str
|
||||
key: str
|
||||
abs_x: int
|
||||
width: int
|
||||
epoch: int
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
refresh_hz: float
|
||||
|
||||
|
||||
class RenderedElement(NamedTuple):
|
||||
"""One live element freshly redrawn by the worker, ready to compare and swap."""
|
||||
key: str
|
||||
#: The plugin's data epoch it was drawn from.
|
||||
epoch: int
|
||||
version: object
|
||||
#: Pinned pixels (see pin_element), read-only.
|
||||
pixels: np.ndarray
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
#: Pinned width, the width it would occupy in the strip.
|
||||
width: int
|
||||
|
||||
|
||||
class LivePatch(NamedTuple):
|
||||
"""A redraw handed from the worker to the render thread for one record."""
|
||||
seq: int
|
||||
#: The strip generation it was made against; a patch for an older strip
|
||||
#: is dropped.
|
||||
strip_gen: int
|
||||
epoch: int
|
||||
pixels: np.ndarray
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
made_at: float
|
||||
|
||||
|
||||
class LiveView(NamedTuple):
|
||||
"""Where the viewport is, in absolute strip columns, published every frame."""
|
||||
abs_left: int
|
||||
abs_right: int
|
||||
#: The end of the strip: how far ahead content exists.
|
||||
abs_end: int
|
||||
#: time.monotonic() when published. An old one means frames have stopped.
|
||||
t_mono: float
|
||||
|
||||
|
||||
def tag(image: Image.Image, meta: ElementMeta) -> Image.Image:
|
||||
"""Mark ``image`` as the live element ``meta`` describes. Returns it."""
|
||||
image.info[INFO_KEY] = meta
|
||||
return image
|
||||
|
||||
|
||||
def meta_of(image: object) -> Optional[ElementMeta]:
|
||||
"""The live-element tag on ``image``, or None for plain content."""
|
||||
info = getattr(image, 'info', None)
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
meta = info.get(INFO_KEY)
|
||||
return meta if isinstance(meta, ElementMeta) else None
|
||||
|
||||
|
||||
def untag(image: Image.Image) -> Image.Image:
|
||||
"""Make ``image`` plain content again (e.g. after cropping it). Returns it."""
|
||||
image.info.pop(INFO_KEY, None)
|
||||
return image
|
||||
|
||||
|
||||
def pin_element(image: Image.Image, padding: int) -> Tuple[Image.Image, np.ndarray]:
|
||||
"""An element's pixels as they will sit in the strip, as image and array.
|
||||
|
||||
RGB, with ``padding`` black columns each side. The array is what a live
|
||||
patch writes into the strip; it is read-only, so a patch in flight cannot
|
||||
be changed under the render thread.
|
||||
"""
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
pad = max(0, int(padding))
|
||||
if pad:
|
||||
pinned = Image.new('RGB', (image.width + 2 * pad, image.height), (0, 0, 0))
|
||||
pinned.paste(image, (pad, 0))
|
||||
else:
|
||||
pinned = image.copy()
|
||||
array = np.ascontiguousarray(np.asarray(pinned))
|
||||
array.setflags(write=False)
|
||||
return pinned, array
|
||||
|
||||
|
||||
def pixel_digest(array: np.ndarray) -> Tuple[Tuple[int, ...], int]:
|
||||
"""A cheap fingerprint of an element's pixels: its shape and a CRC.
|
||||
|
||||
Two redraws with the same digest are treated as the same pixels and the
|
||||
second is not swapped in. CRC-32 rather than Adler-32: a changed digit is
|
||||
a small, local change, which is exactly where Adler-32 is weakest.
|
||||
"""
|
||||
data = np.ascontiguousarray(array)
|
||||
return tuple(data.shape), zlib.crc32(memoryview(data).cast('B'))
|
||||
|
||||
|
||||
class LiveEpochs:
|
||||
"""A counter per plugin that moves on whenever its data may have changed.
|
||||
|
||||
Bumped when a plugin's update() completes (PluginManager's update
|
||||
listener) or when it calls notify_vegas_data_changed(). Every live element
|
||||
is tagged with the epoch it was drawn from; one drawn from an older epoch
|
||||
than the plugin's current one is due a redraw. Epochs are the truth and
|
||||
wake-ups only hints, so a missed wake-up delays a redraw but never loses
|
||||
one.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = itertools.count(1)
|
||||
self._epochs: Dict[str, int] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def bump(self, plugin_id: str) -> int:
|
||||
with self._lock:
|
||||
epoch = next(self._counter)
|
||||
self._epochs[plugin_id] = epoch
|
||||
return epoch
|
||||
|
||||
def get(self, plugin_id: str) -> int:
|
||||
return self._epochs.get(plugin_id, 0)
|
||||
@@ -0,0 +1,503 @@
|
||||
"""The one background worker behind live Vegas elements.
|
||||
|
||||
Vegas draws everything the strip shows off the render thread. Until live
|
||||
elements that was one short-lived prefetch thread per group; now, once the
|
||||
strip holds a live element, it is this worker, which does three kinds of job
|
||||
one at a time, most urgent first:
|
||||
|
||||
- **group steps**: fetching the next group of plugins for the strip, one
|
||||
plugin per step (what the prefetch thread did in one go);
|
||||
- **data refreshes**: when a plugin's data has moved on (its epoch, see
|
||||
elements.LiveEpochs) past what its elements in the strip were drawn from,
|
||||
redraw them and hand over the ones whose pixels changed;
|
||||
- **ticks**: redraw an element that animates (``refresh_hz``) while it is on
|
||||
or near the screen.
|
||||
|
||||
Nothing here touches the strip. A finished redraw becomes a
|
||||
:class:`~src.vegas_mode.elements.LivePatch` in the pipeline's slot for that
|
||||
element (one per element, the latest wins) and the render thread copies it
|
||||
into the strip between two frames (RenderPipeline.apply_live_patches). The
|
||||
hand-over is lock-free: a dict store and a deque append here, a deque popleft
|
||||
and a dict pop there, so the render thread never waits on this thread.
|
||||
|
||||
Every job runs inside the render gate when there is one (src/common/
|
||||
render_gate.py), so Python runs here only while the render thread is waiting
|
||||
for the panel. Without it (the stock rgbmatrix binding) animation is capped at
|
||||
:data:`UNGATED_MAX_HZ`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from src.vegas_mode.elements import ElementRecord, LivePatch, LiveView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: A view older than this means frames have stopped (a paused scroll, an
|
||||
#: interrupt): only group work runs, since nothing redrawn would be seen.
|
||||
VIEW_STALE_S = 0.5
|
||||
#: How long a data refresh waits for the plugin's lock before trying later.
|
||||
DATA_LOCK_TIMEOUT = 0.25
|
||||
#: ...and how much later.
|
||||
LOCK_BACKOFF_S = 1.0
|
||||
#: Animation ceiling without the render gate, where every redraw competes
|
||||
#: with the render thread for the GIL.
|
||||
UNGATED_MAX_HZ = 1.0
|
||||
#: An element whose redraws take longer than this on average is animated at
|
||||
#: half its rate, down to MIN_THROTTLED_HZ.
|
||||
SLOW_RENDER_S = 0.05
|
||||
MIN_THROTTLED_HZ = 0.5
|
||||
#: Longest the worker sleeps with nothing due, so a floor or a backoff that
|
||||
#: expires is noticed.
|
||||
IDLE_WAIT_S = 0.5
|
||||
#: How often the worker logs what it did, when it did anything.
|
||||
SUMMARY_INTERVAL_S = 300.0
|
||||
#: Weight of the newest sample in the per-element render time average.
|
||||
EWMA_ALPHA = 0.2
|
||||
#: How long the worker waits for a one-shot prefetch thread it takes over from.
|
||||
LEGACY_PREFETCH_JOIN_S = 15.0
|
||||
|
||||
|
||||
def _visible(record: ElementRecord, view: LiveView) -> bool:
|
||||
return record.abs_x < view.abs_right and record.abs_x + record.width > view.abs_left
|
||||
|
||||
|
||||
def _behind(record: ElementRecord, view: LiveView) -> bool:
|
||||
return record.abs_x + record.width <= view.abs_left
|
||||
|
||||
|
||||
class _GroupJob:
|
||||
"""A group fetch in progress, one member per step."""
|
||||
|
||||
def __init__(self, generation: int, plugin_ids: List[str]) -> None:
|
||||
self.generation = generation
|
||||
self.pending = list(plugin_ids)
|
||||
self.group: List[Tuple[str, Any]] = []
|
||||
|
||||
|
||||
class VegasWorker(threading.Thread):
|
||||
"""See the module docstring. Owned by the RenderPipeline that starts it."""
|
||||
|
||||
def __init__(self, pipeline: Any, clock: Callable[[], float] = time.monotonic) -> None:
|
||||
super().__init__(daemon=True, name="vegas-live-worker")
|
||||
self.pipeline = pipeline
|
||||
#: time.monotonic, or a fake one in tests; the pipeline's view is
|
||||
#: stamped with time.monotonic too.
|
||||
self._clock = clock
|
||||
self.inbox: "queue.SimpleQueue[Tuple[str, Any]]" = queue.SimpleQueue()
|
||||
self._stopping = False
|
||||
self._group_wanted = False
|
||||
self._group_job: Optional[_GroupJob] = None
|
||||
self._strip_gen = pipeline._strip_gen
|
||||
# Per element (record seq): the epoch this worker last handed over,
|
||||
# or found needed nothing; the digest of its latest hand-over; when
|
||||
# its next animation tick is due.
|
||||
self._handled_epoch: Dict[int, int] = {}
|
||||
self._handed_digest: Dict[int, Any] = {}
|
||||
self._next_tick: Dict[int, float] = {}
|
||||
# Per plugin: when its last data refresh ran, and a lock backoff.
|
||||
self._last_data_job: Dict[str, float] = {}
|
||||
self._backoff_until: Dict[str, float] = {}
|
||||
# Per (plugin, key): average redraw time, for throttling.
|
||||
self._render_ewma: Dict[Tuple[str, str], float] = {}
|
||||
self._refused: Set[Tuple[str, str, int]] = set()
|
||||
self.stats: collections.Counter = collections.Counter()
|
||||
self.busy_seconds = 0.0
|
||||
self._began = clock()
|
||||
self._last_summary = self._began
|
||||
self._jobs_since_prune = 0
|
||||
# The slowest redraw since the last summary: (seconds, (plugin, key)).
|
||||
self._slowest_redraw: Optional[Tuple[float, Tuple[str, str]]] = None
|
||||
|
||||
# -- control, from other threads ------------------------------------------
|
||||
|
||||
def request_group(self) -> None:
|
||||
"""Fetch the next group for the strip when nothing more urgent is due."""
|
||||
self._group_wanted = True
|
||||
self.inbox.put(("group", None))
|
||||
|
||||
def notify_data(self, plugin_id: str) -> None:
|
||||
"""A plugin's data moved on. Only a wake-up: its epoch is the truth."""
|
||||
self.inbox.put(("data", plugin_id))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop after the current job. Does not wait for it."""
|
||||
self._stopping = True
|
||||
self.inbox.put(("stop", None))
|
||||
|
||||
# -- the loop -------------------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
# Linux applies nice per thread: deprioritise against the render
|
||||
# loop, as the one-shot prefetch thread always did.
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
self._join_legacy_prefetch()
|
||||
while not self._should_stop():
|
||||
self._wait(self._next_wait(self._clock()))
|
||||
if self._should_stop():
|
||||
break
|
||||
job = self._pick(self._clock())
|
||||
if job is not None:
|
||||
self._run(job)
|
||||
self._maybe_summarise()
|
||||
self._hand_over_partial_group()
|
||||
logger.debug("Vegas live worker stopped")
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
# A method, not a bare attribute read: stop() sets it from another
|
||||
# thread between two reads in run().
|
||||
return self._stopping
|
||||
|
||||
def _join_legacy_prefetch(self) -> None:
|
||||
"""Let a one-shot prefetch thread, or a worker stopped earlier, finish first.
|
||||
|
||||
So that only one thread ever draws for the strip: measured on hdpi,
|
||||
each extra thread competing for the GIL made the render thread late
|
||||
more often, not less (src/common/render_gate.py).
|
||||
"""
|
||||
for name in ('_prefetch_thread', '_retired_worker'):
|
||||
thread = getattr(self.pipeline, name, None)
|
||||
if thread is not None and thread is not self \
|
||||
and thread is not threading.current_thread() and thread.is_alive():
|
||||
thread.join(LEGACY_PREFETCH_JOIN_S)
|
||||
|
||||
def _wait(self, timeout: float) -> None:
|
||||
try:
|
||||
message = self.inbox.get(timeout=max(0.0, timeout))
|
||||
except queue.Empty:
|
||||
return
|
||||
while True:
|
||||
if message[0] == "stop":
|
||||
self._stopping = True
|
||||
elif message[0] == "group":
|
||||
self._group_wanted = True
|
||||
try:
|
||||
message = self.inbox.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
|
||||
def _next_wait(self, now: float) -> float:
|
||||
"""Seconds until something may be due: the next tick, else IDLE_WAIT_S."""
|
||||
p = self.pipeline
|
||||
if self._group_job is not None or (
|
||||
self._group_wanted and p._prepared_group is None):
|
||||
return 0.0
|
||||
# Ticks only count while frames are flowing: during a pause _pick runs
|
||||
# none, and a past-due tick would otherwise make this 0 and spin.
|
||||
view = p._view
|
||||
if view is None or now - view.t_mono > VIEW_STALE_S:
|
||||
return IDLE_WAIT_S
|
||||
# Only elements _due_tick would run. A tick left behind by an element
|
||||
# trimmed away, or one no longer animated, is never run, and counting
|
||||
# it held this at its floor: a spin at 100 wake-ups a second.
|
||||
soonest = now + IDLE_WAIT_S
|
||||
lead = self._tick_lead()
|
||||
for record in p._elements:
|
||||
if self._tickable(record, view, lead):
|
||||
soonest = min(soonest, self._next_tick.get(record.seq, now))
|
||||
return max(0.01, soonest - now)
|
||||
|
||||
# -- choosing ---------------------------------------------------------------
|
||||
|
||||
def _pick(self, now: float) -> Optional[Tuple[str, Any]]:
|
||||
"""The most urgent job, or None. See the module docstring for the order."""
|
||||
p = self.pipeline
|
||||
if p._strip_gen != self._strip_gen:
|
||||
self._forget_everything(p._strip_gen)
|
||||
view = p._view
|
||||
fresh = view is not None and now - view.t_mono <= VIEW_STALE_S
|
||||
group_ready = self._group_job is not None or (
|
||||
self._group_wanted and p._prepared_group is None)
|
||||
records = p._elements
|
||||
|
||||
if group_ready and fresh and self._group_urgent(view):
|
||||
return ("group", None)
|
||||
if fresh and records:
|
||||
plugin_id = self._due_data(records, view, now, visible_only=True)
|
||||
if plugin_id is not None:
|
||||
return ("data", plugin_id)
|
||||
record = self._due_tick(records, view, now)
|
||||
if record is not None:
|
||||
return ("tick", record)
|
||||
if group_ready:
|
||||
return ("group", None)
|
||||
if fresh and records:
|
||||
plugin_id = self._due_data(records, view, now, visible_only=False)
|
||||
if plugin_id is not None:
|
||||
return ("data", plugin_id)
|
||||
return None
|
||||
|
||||
def _group_urgent(self, view: LiveView) -> bool:
|
||||
width = self.pipeline.display_width
|
||||
threshold = (self.pipeline.config.extend_threshold_screens + 1.0) * width
|
||||
return bool(view.abs_end - view.abs_right <= threshold)
|
||||
|
||||
def _epoch(self, plugin_id: str) -> int:
|
||||
epochs = getattr(self.pipeline.stream_manager.plugin_adapter, 'live_epochs', None)
|
||||
return int(epochs.get(plugin_id)) if epochs is not None else 0
|
||||
|
||||
def _done_epoch(self, record: ElementRecord) -> int:
|
||||
applied = self.pipeline._applied.get(record.seq)
|
||||
return max(applied[0] if applied is not None else record.epoch,
|
||||
self._handled_epoch.get(record.seq, -1))
|
||||
|
||||
def _due_data(self, records: Tuple[ElementRecord, ...], view: LiveView,
|
||||
now: float, visible_only: bool) -> Optional[str]:
|
||||
"""The plugin whose stale elements are nearest the screen, if any may redraw."""
|
||||
floor = self.pipeline.config.live_min_interval
|
||||
best: Optional[Tuple[int, str]] = None
|
||||
for record in records:
|
||||
if _behind(record, view):
|
||||
continue
|
||||
if visible_only and not _visible(record, view):
|
||||
continue
|
||||
plugin_id = record.plugin_id
|
||||
if self._epoch(plugin_id) <= self._done_epoch(record):
|
||||
continue
|
||||
if now < self._backoff_until.get(plugin_id, 0.0):
|
||||
continue
|
||||
if now - self._last_data_job.get(plugin_id, float('-inf')) < floor:
|
||||
continue
|
||||
distance = max(0, record.abs_x - view.abs_right)
|
||||
if best is None or distance < best[0]:
|
||||
best = (distance, plugin_id)
|
||||
return best[1] if best is not None else None
|
||||
|
||||
def _tick_hz(self, record: ElementRecord) -> float:
|
||||
cfg = self.pipeline.config
|
||||
hz = min(float(record.refresh_hz), float(cfg.live_max_hz))
|
||||
if getattr(self.pipeline.display_manager, 'render_gate', None) is None:
|
||||
hz = min(hz, UNGATED_MAX_HZ)
|
||||
ewma = self._render_ewma.get((record.plugin_id, record.key), 0.0)
|
||||
if hz > 0 and ewma > SLOW_RENDER_S:
|
||||
# Halved, but never below the floor -- nor raised to it, for an
|
||||
# element already asking for less.
|
||||
hz = min(hz, max(MIN_THROTTLED_HZ, hz / 2.0))
|
||||
return hz
|
||||
|
||||
def _tick_lead(self) -> float:
|
||||
return float(self.pipeline.config.live_lead_screens * self.pipeline.display_width)
|
||||
|
||||
def _tickable(self, record: ElementRecord, view: LiveView, lead: float) -> bool:
|
||||
"""Whether an element animates now: it has a rate, and is on or near the screen."""
|
||||
return (record.refresh_hz > 0 and self._tick_hz(record) > 0
|
||||
and not _behind(record, view) and record.abs_x < view.abs_right + lead)
|
||||
|
||||
def _due_tick(self, records: Tuple[ElementRecord, ...], view: LiveView,
|
||||
now: float) -> Optional[ElementRecord]:
|
||||
lead = self._tick_lead()
|
||||
best: Optional[ElementRecord] = None
|
||||
best_due = 0.0
|
||||
for record in records:
|
||||
if not self._tickable(record, view, lead):
|
||||
self._next_tick.pop(record.seq, None)
|
||||
continue
|
||||
due = self._next_tick.get(record.seq, now)
|
||||
if due <= now and (best is None or due < best_due):
|
||||
best, best_due = record, due
|
||||
return best
|
||||
|
||||
# -- running ----------------------------------------------------------------
|
||||
|
||||
def _run(self, job: Tuple[str, Any]) -> None:
|
||||
kind, arg = job
|
||||
gate = getattr(self.pipeline.display_manager, 'render_gate', None)
|
||||
started = self._clock()
|
||||
try:
|
||||
with gate.yielding() if gate is not None else nullcontext():
|
||||
if kind == "group":
|
||||
self._group_step()
|
||||
elif kind == "data":
|
||||
self._data_job(arg, started)
|
||||
else:
|
||||
self._tick_job(arg, started)
|
||||
self.stats[kind] += 1
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Plugin code runs in here; one bad job must not end the worker,
|
||||
# which also fetches every group for the strip.
|
||||
self.stats["errors"] += 1
|
||||
if self.stats["errors"] <= 3 or self.stats["errors"] % 100 == 0:
|
||||
logger.exception("Vegas live worker: %s job failed (%s)", kind, exc)
|
||||
finally:
|
||||
self.busy_seconds += self._clock() - started
|
||||
self._jobs_since_prune += 1
|
||||
if self._jobs_since_prune >= 64:
|
||||
self._prune()
|
||||
|
||||
def _hand_over_partial_group(self) -> None:
|
||||
"""On stopping: publish the members of a group already fetched.
|
||||
|
||||
Its plugins were taken from the rotation when it was planned, so a
|
||||
group dropped here would skip them until the next cycle. The rest of
|
||||
it is not fetched. Nothing is published over a group already waiting,
|
||||
or into a Vegas reset since.
|
||||
"""
|
||||
job, self._group_job = self._group_job, None
|
||||
if job is None or not job.group:
|
||||
return
|
||||
p = self.pipeline
|
||||
with p._prefetch_lock:
|
||||
if job.generation == p._prefetch_generation and p._prepared_group is None:
|
||||
p._prepared_group = job.group
|
||||
|
||||
def _group_step(self) -> None:
|
||||
p = self.pipeline
|
||||
job = self._group_job
|
||||
if job is None:
|
||||
with p._prefetch_lock:
|
||||
if p._prepared_group is not None:
|
||||
self._group_wanted = False
|
||||
return
|
||||
generation = p._prefetch_generation
|
||||
self._group_wanted = False
|
||||
job = self._group_job = _GroupJob(generation, p.stream_manager.plan_next_group())
|
||||
if job.generation != p._prefetch_generation:
|
||||
self._group_job = None # Vegas was reset meanwhile
|
||||
return
|
||||
if job.pending:
|
||||
member = p.stream_manager.fetch_group_member(
|
||||
job.pending.pop(0), offscreen_only=True)
|
||||
if member is not None:
|
||||
job.group.append(member)
|
||||
if not job.pending:
|
||||
self._group_job = None
|
||||
with p._prefetch_lock:
|
||||
if job.generation == p._prefetch_generation:
|
||||
p._prepared_group = job.group
|
||||
|
||||
def _data_job(self, plugin_id: str, now: float) -> None:
|
||||
p = self.pipeline
|
||||
self._last_data_job[plugin_id] = now
|
||||
plugin = getattr(p.stream_manager.plugin_manager, 'plugins', {}).get(plugin_id)
|
||||
if plugin is None:
|
||||
return
|
||||
gen = p._strip_gen
|
||||
batch = p.stream_manager.plugin_adapter.render_live_elements(
|
||||
plugin, plugin_id, lock_timeout=DATA_LOCK_TIMEOUT)
|
||||
if batch is None:
|
||||
self.stats["lock_busy"] += 1
|
||||
self._backoff_until[plugin_id] = now + LOCK_BACKOFF_S
|
||||
return
|
||||
epoch, rendered = batch
|
||||
view = p._view
|
||||
for record in p._elements:
|
||||
if record.plugin_id != plugin_id:
|
||||
continue
|
||||
if view is not None and _behind(record, view):
|
||||
continue
|
||||
element = rendered.get(record.key)
|
||||
if element is not None:
|
||||
self._hand_over(record, element, epoch, gen)
|
||||
# A key the plugin no longer has keeps its last pixels until it
|
||||
# scrolls off; either way this epoch is dealt with.
|
||||
self._handled_epoch[record.seq] = max(
|
||||
epoch, self._handled_epoch.get(record.seq, -1))
|
||||
|
||||
def _tick_job(self, record: ElementRecord, now: float) -> None:
|
||||
p = self.pipeline
|
||||
hz = self._tick_hz(record)
|
||||
self._next_tick[record.seq] = now + (1.0 / hz if hz > 0 else IDLE_WAIT_S)
|
||||
plugin = getattr(p.stream_manager.plugin_manager, 'plugins', {}).get(record.plugin_id)
|
||||
if plugin is None:
|
||||
return
|
||||
adapter = p.stream_manager.plugin_adapter
|
||||
key = (record.plugin_id, record.key)
|
||||
at = now + self._render_ewma.get(key, 0.0) + p.frame_interval
|
||||
started = time.perf_counter()
|
||||
if adapter.has_lock_free_redraw(plugin):
|
||||
# None from the plugin means nothing to redraw this time.
|
||||
element = adapter.redraw_live_element(
|
||||
plugin, record.plugin_id, record.key, record.width, p.display_height, at)
|
||||
else:
|
||||
# No lock-free redraw: redraw everything, but never wait for the
|
||||
# plugin's lock (update() may be doing network I/O under it).
|
||||
batch = adapter.render_live_elements(plugin, record.plugin_id, lock_timeout=0.0)
|
||||
element = batch[1].get(record.key) if batch is not None else None
|
||||
took = time.perf_counter() - started
|
||||
if self._slowest_redraw is None or took > self._slowest_redraw[0]:
|
||||
self._slowest_redraw = (took, key)
|
||||
previous = self._render_ewma.get(key)
|
||||
self._render_ewma[key] = took if previous is None else (
|
||||
EWMA_ALPHA * took + (1.0 - EWMA_ALPHA) * previous)
|
||||
if element is not None:
|
||||
self._hand_over(record, element, element.epoch, p._strip_gen)
|
||||
|
||||
def _hand_over(self, record: ElementRecord, element: Any, epoch: int, gen: int) -> None:
|
||||
"""Queue a redraw for the render thread, unless nothing would change."""
|
||||
p = self.pipeline
|
||||
if element.width != record.width:
|
||||
marker = (record.plugin_id, record.key, element.width)
|
||||
if marker not in self._refused:
|
||||
self._refused.add(marker)
|
||||
logger.info(
|
||||
"[%s] Live element %r redrawn %dpx wide, placed at %dpx; "
|
||||
"kept as it was (a live element's width must not change)",
|
||||
record.plugin_id, record.key, element.width, record.width)
|
||||
self.stats["refused"] += 1
|
||||
return
|
||||
last = self._handed_digest.get(record.seq)
|
||||
if last is None:
|
||||
applied = p._applied.get(record.seq)
|
||||
last = applied[1] if applied is not None else record.digest
|
||||
if element.digest == last:
|
||||
self.stats["unchanged"] += 1
|
||||
return
|
||||
p._live_slots[record.seq] = LivePatch(
|
||||
seq=record.seq, strip_gen=gen, epoch=epoch, pixels=element.pixels,
|
||||
digest=element.digest, made_at=self._clock())
|
||||
p._live_ready.append(record.seq)
|
||||
self._handed_digest[record.seq] = element.digest
|
||||
self.stats["patches"] += 1
|
||||
|
||||
# -- housekeeping -----------------------------------------------------------
|
||||
|
||||
def _forget_everything(self, gen: int) -> None:
|
||||
self._strip_gen = gen
|
||||
self._handled_epoch.clear()
|
||||
self._handed_digest.clear()
|
||||
self._next_tick.clear()
|
||||
|
||||
def _prune(self) -> None:
|
||||
self._jobs_since_prune = 0
|
||||
live = {record.seq for record in self.pipeline._elements}
|
||||
for table in (self._handled_epoch, self._handed_digest, self._next_tick):
|
||||
for seq in [s for s in table if s not in live]:
|
||||
del table[seq]
|
||||
|
||||
def _maybe_summarise(self) -> None:
|
||||
now = self._clock()
|
||||
if now - self._last_summary < SUMMARY_INTERVAL_S:
|
||||
return
|
||||
elapsed = now - self._last_summary
|
||||
self._last_summary = now
|
||||
stats, self.stats = self.stats, collections.Counter()
|
||||
busy, self.busy_seconds = self.busy_seconds, 0.0
|
||||
slowest, self._slowest_redraw = self._slowest_redraw, None
|
||||
if not stats:
|
||||
return
|
||||
redraws = ""
|
||||
if slowest is not None:
|
||||
# What a tick costs is the number that decides whether an
|
||||
# animated element can keep its rate: say it for the worst one.
|
||||
took, (plugin_id, key) = slowest
|
||||
average = self._render_ewma.get((plugin_id, key), took)
|
||||
redraws = "; slowest redraw %.1fms (%s %r, average %.1fms)" % (
|
||||
took * 1000.0, plugin_id, key, average * 1000.0)
|
||||
logger.info(
|
||||
"Vegas live: %d group step(s), %d data refresh(es), %d tick(s); "
|
||||
"%d patch(es) handed over, %d unchanged, %d refused, %d lock-busy, "
|
||||
"%d error(s); worker busy %.1f%%%s",
|
||||
stats["group"], stats["data"], stats["tick"], stats["patches"],
|
||||
stats["unchanged"], stats["refused"], stats["lock_busy"],
|
||||
stats["errors"], 100.0 * busy / elapsed if elapsed else 0.0, redraws)
|
||||
@@ -9,9 +9,22 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from typing import Dict, Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.plugin_system.base_plugin import BasePlugin as _BasePlugin
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
from src.vegas_mode.elements import (
|
||||
ElementMeta,
|
||||
LiveEpochs,
|
||||
RenderedElement,
|
||||
meta_of,
|
||||
pin_element,
|
||||
pixel_digest,
|
||||
tag,
|
||||
untag,
|
||||
)
|
||||
from src.vegas_mode.geometry import (
|
||||
blank_runs,
|
||||
separation_gap,
|
||||
@@ -87,6 +100,22 @@ class PluginAdapter:
|
||||
# into unrelated headlines once the strip refreshed to 9,505px.
|
||||
self._offset_shapes: dict = {}
|
||||
|
||||
# Live elements (src/vegas_mode/elements.py). Switched on by the
|
||||
# coordinator for a run in which live updates are active; while off,
|
||||
# no plugin is ever asked for elements and every path is as before.
|
||||
self.live_elements_enabled = False
|
||||
# Per-plugin data epochs, stamped on each element drawn. Set by the
|
||||
# coordinator; without it every element is drawn "from epoch 0".
|
||||
self.live_epochs: Optional[LiveEpochs] = None
|
||||
# Element problems already reported, so a plugin with a bad hook logs
|
||||
# once rather than on every fetch.
|
||||
self._element_warnings: set = set()
|
||||
# (plugin_id, key) -> (version, source image, (padding, height),
|
||||
# pinned pixels, digest) of the last conversion, so an element handed
|
||||
# back unchanged is not converted again. Only the live-element worker
|
||||
# reads or writes it; invalidate_cache() swaps in a fresh one.
|
||||
self._element_memo: Dict[Tuple[str, str], Tuple[Any, ...]] = {}
|
||||
|
||||
logger.debug(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
@@ -119,9 +148,24 @@ class PluginAdapter:
|
||||
plugin_id, plugin.__class__.__name__
|
||||
)
|
||||
|
||||
# Check cache first
|
||||
# The old contract, kept behind the switch: background callers may
|
||||
# not draw, so anything needing a canvas is left for the render thread.
|
||||
restricted = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
|
||||
# Live elements are asked for only on the background fetch, which
|
||||
# holds the plugin's lock and draws on a canvas of its own. The render
|
||||
# thread's fetches (the first compose, the inline fallback) take no
|
||||
# lock, so they keep to get_vegas_content().
|
||||
keyed = (offscreen_only and not restricted and self.live_elements_enabled
|
||||
and self.is_live_capable(plugin, plugin_id))
|
||||
|
||||
# Check cache first. A keyed fetch looks past legacy content cached
|
||||
# by a render-thread fetch, or the plugin would not become live until
|
||||
# that entry expired.
|
||||
cached = self._get_cached(plugin_id)
|
||||
if cached is not None:
|
||||
if cached is not None and not (
|
||||
keyed and not any(meta_of(img) for img in cached)):
|
||||
total_width = sum(img.width for img in cached)
|
||||
logger.debug(
|
||||
"[%s] Using cached content: %d images, %dpx total",
|
||||
@@ -129,10 +173,6 @@ class PluginAdapter:
|
||||
)
|
||||
return cached
|
||||
|
||||
# The old contract, kept behind the switch: background callers may
|
||||
# not draw, so anything needing a canvas is left for the render thread.
|
||||
restricted = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
if not offscreen_only or restricted:
|
||||
return self._fetch_content(plugin, plugin_id, restricted)
|
||||
|
||||
@@ -143,21 +183,41 @@ class PluginAdapter:
|
||||
"round", plugin_id, self.PLUGIN_LOCK_TIMEOUT
|
||||
)
|
||||
return None
|
||||
return self._fetch_content(plugin, plugin_id, restricted=False)
|
||||
return self._fetch_content(plugin, plugin_id, restricted=False,
|
||||
keyed=keyed)
|
||||
|
||||
def is_live_capable(self, plugin: 'BasePlugin', plugin_id: str) -> bool:
|
||||
"""Whether to ask this plugin for live elements rather than pictures.
|
||||
|
||||
It must implement get_vegas_elements() in its own class (a test double
|
||||
or a plugin that only inherits BasePlugin's does not count), and its
|
||||
config must not set ``vegas_live`` off.
|
||||
"""
|
||||
method = getattr(type(plugin), 'get_vegas_elements', None)
|
||||
if method is None or method is _BasePlugin.get_vegas_elements:
|
||||
return False
|
||||
raw = self._plugin_setting(plugin, 'vegas_live')
|
||||
if raw is None:
|
||||
return True
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() not in ('false', '0', 'off', 'no')
|
||||
return bool(raw)
|
||||
|
||||
@contextmanager
|
||||
def _plugin_lock(self, plugin_id: str):
|
||||
def _plugin_lock(self, plugin_id: str, timeout: Optional[float] = None):
|
||||
"""Hold the plugin's update/display lock, waiting a bounded time.
|
||||
|
||||
Yields whether it was acquired. Yields True, holding nothing, when
|
||||
there is no plugin manager to ask -- the behaviour before the lock was
|
||||
taken here at all.
|
||||
taken here at all. ``timeout`` defaults to PLUGIN_LOCK_TIMEOUT; 0
|
||||
does not wait at all.
|
||||
"""
|
||||
if not hasattr(self.plugin_manager, 'get_plugin_lock'):
|
||||
yield True
|
||||
return
|
||||
lock = self.plugin_manager.get_plugin_lock(plugin_id)
|
||||
acquired = lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT)
|
||||
wait = self.PLUGIN_LOCK_TIMEOUT if timeout is None else timeout
|
||||
acquired = lock.acquire(timeout=wait) if wait > 0 else lock.acquire(blocking=False)
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
@@ -188,13 +248,21 @@ class PluginAdapter:
|
||||
self.display_manager.image = original_image
|
||||
|
||||
def _fetch_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool,
|
||||
keyed: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""Every content path in order: native, scroll helper, display capture.
|
||||
"""Every content path in order: elements, native, scroll helper, capture.
|
||||
|
||||
``restricted`` is the pre-offscreen contract for background callers:
|
||||
skip every path that needs a canvas and return None instead.
|
||||
``keyed`` asks for live elements first (see get_content).
|
||||
"""
|
||||
if keyed:
|
||||
content = self._get_keyed_content(plugin, plugin_id)
|
||||
if content:
|
||||
return self._finalize(content, plugin_id, 'elements', plugin)
|
||||
logger.debug("[%s] No live elements; using its Vegas content", plugin_id)
|
||||
|
||||
# Try native Vegas content method first
|
||||
has_native = hasattr(plugin, 'get_vegas_content')
|
||||
logger.debug("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
||||
@@ -287,6 +355,12 @@ class PluginAdapter:
|
||||
dropped_blank = 0
|
||||
|
||||
for img in images:
|
||||
if meta_of(img) is not None:
|
||||
# A live element is pinned, not trimmed: it already carries
|
||||
# the margin trimming would leave, and its width must not
|
||||
# follow its ink, or a redraw could never be swapped in place.
|
||||
kept.append(img)
|
||||
continue
|
||||
result = trim_to_content(
|
||||
img,
|
||||
threshold=self.config.trim_threshold,
|
||||
@@ -389,18 +463,21 @@ class PluginAdapter:
|
||||
if isinstance(plugin_cfg, dict):
|
||||
raw = plugin_cfg.get('vegas_width_pct')
|
||||
if raw not in (None, ''):
|
||||
# Reported once per value: the live paths resolve the width on
|
||||
# every redraw, several times a second for an animated element.
|
||||
try:
|
||||
candidate = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
|
||||
self._warn_element_once(
|
||||
plugin_id, "Invalid vegas_width_pct %r, ignoring", raw,
|
||||
once_key=repr(raw))
|
||||
else:
|
||||
if 10 <= candidate <= 100:
|
||||
pct = candidate
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
|
||||
plugin_id, candidate)
|
||||
self._warn_element_once(
|
||||
plugin_id, "vegas_width_pct %d out of range 10-100, ignoring",
|
||||
candidate, once_key=repr(raw))
|
||||
|
||||
if pct >= 100:
|
||||
return self.display_width
|
||||
@@ -608,7 +685,16 @@ class PluginAdapter:
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
only = images[0]
|
||||
if meta_of(only) is not None and only.width - 2 * self._padding() <= budget:
|
||||
# A live element's pinned margins are not content. One whose
|
||||
# drawing fits the budget is kept whole, and live, rather than
|
||||
# cut for the sake of its own blank padding.
|
||||
self._clear_offset(plugin_id)
|
||||
return images
|
||||
# A cropped live element is only part of itself, so it can no
|
||||
# longer be swapped whole: it scrolls by as plain content.
|
||||
return [untag(self._crop_to_budget(only, budget, plugin_id, mode))]
|
||||
|
||||
shape = ('rows', len(images))
|
||||
if mode == 'truncate':
|
||||
@@ -692,7 +778,12 @@ class PluginAdapter:
|
||||
# cycle — a lone "y" from "Wednesday" floating between two unrelated
|
||||
# plugins. Overshooting the budget is the lesser evil.
|
||||
min_run = max(2, self.config.min_cut_gap)
|
||||
gaps = blank_runs(img, min_run, self.config.trim_threshold)
|
||||
# A run touching either edge is the image's margin -- the
|
||||
# content_padding trimming leaves, or a live element's pinned padding
|
||||
# -- not a gap between items. Cutting mid-margin gave a window of a few
|
||||
# blank columns, and a solid image with margins no continuous crop.
|
||||
gaps = [(a, b) for a, b in blank_runs(img, min_run, self.config.trim_threshold)
|
||||
if a > 0 and b < img.width]
|
||||
|
||||
if not gaps:
|
||||
# No internal gaps means continuous content — a map, a chart, a
|
||||
@@ -759,6 +850,285 @@ class PluginAdapter:
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _warn_element_once(self, plugin_id: str, problem: str, *args: Any,
|
||||
once_key: Optional[str] = None) -> None:
|
||||
"""Report a plugin's problem once per process, then quietly.
|
||||
|
||||
Once per ``problem`` (the format string), or per ``once_key`` within it
|
||||
when given, so a different bad value is still reported.
|
||||
"""
|
||||
key = (plugin_id, problem, once_key)
|
||||
if key in self._element_warnings:
|
||||
logger.debug("[%s] " + problem, plugin_id, *args)
|
||||
return
|
||||
self._element_warnings.add(key)
|
||||
logger.warning("[%s] " + problem, plugin_id, *args)
|
||||
|
||||
def _get_keyed_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""The plugin's live elements, as tagged images, or None.
|
||||
|
||||
Called with the plugin's lock held (get_content), so update() is not
|
||||
running and the plugin's data epoch cannot move while it draws. Drawn
|
||||
on a canvas of the plugin's own at its render width, like
|
||||
get_vegas_content(). Any failure returns None, and the caller falls
|
||||
back to the plugin's ordinary Vegas content.
|
||||
"""
|
||||
epochs = self.live_epochs
|
||||
epoch = epochs.get(plugin_id) if epochs is not None else 0
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
with self._isolated_canvas(render_width):
|
||||
result = plugin.get_vegas_elements()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# A plugin hook can raise anything; the legacy content still works.
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() raised %r; using its "
|
||||
"get_vegas_content() instead", exc)
|
||||
return None
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
try:
|
||||
return self._images_from_elements(result, plugin_id, epoch)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Converting is per element and guarded; this is the backstop, so
|
||||
# nothing a plugin hands back can cost it its ordinary content.
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned elements that could not "
|
||||
"be used (%r); using its get_vegas_content() instead", exc)
|
||||
return None
|
||||
|
||||
def render_live_elements(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, lock_timeout: float
|
||||
) -> Optional[Tuple[int, Dict[str, RenderedElement]]]:
|
||||
"""Redraw a plugin's live elements for the live-element worker.
|
||||
|
||||
Like the keyed fetch, but for a strip that already holds the elements:
|
||||
no cache (the caller knows the plugin's data moved on), and each live
|
||||
element comes back as a RenderedElement to compare with what the strip
|
||||
shows. An element whose ``version`` is the one already redrawn reuses
|
||||
its pinned pixels and digest, so an unchanged scoreboard costs the
|
||||
plugin's own version check and no conversion.
|
||||
|
||||
Returns ``(epoch, {key: element})``, the epoch read under the lock; an
|
||||
empty dict when the plugin had nothing (or failed, logged once). None
|
||||
only when the lock could not be had within ``lock_timeout`` -- the
|
||||
caller tries again later.
|
||||
"""
|
||||
with self._plugin_lock(plugin_id, timeout=lock_timeout) as acquired:
|
||||
if not acquired:
|
||||
return None
|
||||
epochs = self.live_epochs
|
||||
epoch = epochs.get(plugin_id) if epochs is not None else 0
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
with self._isolated_canvas(render_width):
|
||||
result = plugin.get_vegas_elements()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() raised %r while redrawing; "
|
||||
"its elements keep what they show", exc)
|
||||
return epoch, {}
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
return epoch, self._rendered_from_elements(result, plugin_id, epoch)
|
||||
|
||||
@staticmethod
|
||||
def has_lock_free_redraw(plugin: Any) -> bool:
|
||||
"""Whether the plugin's class overrides BasePlugin.redraw_vegas_element."""
|
||||
method = getattr(type(plugin), 'redraw_vegas_element', None)
|
||||
return method is not None and method is not _BasePlugin.redraw_vegas_element
|
||||
|
||||
def redraw_live_element(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, key: str, width: int,
|
||||
height: int, at: float
|
||||
) -> Optional[RenderedElement]:
|
||||
"""One element redrawn for a moment in time, without the plugin's lock.
|
||||
|
||||
``width`` is the element's width in the strip (pinned); the plugin is
|
||||
asked for that less its padding, exactly, and anything else is
|
||||
refused. None when the plugin has no lock-free redraw, returns None,
|
||||
or fails (logged once).
|
||||
"""
|
||||
if not self.has_lock_free_redraw(plugin):
|
||||
return None
|
||||
padding = self._padding()
|
||||
inner = width - 2 * padding
|
||||
if inner <= 0:
|
||||
return None
|
||||
epochs = self.live_epochs
|
||||
epoch = epochs.get(plugin_id) if epochs is not None else 0
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
with self._isolated_canvas(render_width):
|
||||
image = plugin.redraw_vegas_element(key, inner, height, at)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_element_once(
|
||||
plugin_id, "redraw_vegas_element(%r) raised %r", key, exc)
|
||||
return None
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
if image is None:
|
||||
return None
|
||||
if not isinstance(image, Image.Image) or image.size != (inner, height):
|
||||
self._warn_element_once(
|
||||
plugin_id, "redraw_vegas_element(%r) returned %s, expected an "
|
||||
"image of %dx%d; ignoring it", key,
|
||||
f"{image.width}x{image.height}" if isinstance(image, Image.Image)
|
||||
else type(image).__name__, inner, height)
|
||||
return None
|
||||
_pinned, pixels = pin_element(image, padding)
|
||||
return RenderedElement(key=key, epoch=epoch, version=None, pixels=pixels,
|
||||
digest=pixel_digest(pixels), width=pixels.shape[1])
|
||||
|
||||
def _valid_elements(self, result: Any, plugin_id: str) -> Optional[List[VegasElement]]:
|
||||
"""The usable elements in a get_vegas_elements() answer, in order.
|
||||
|
||||
None for no answer (the plugin wants its ordinary content). Anything
|
||||
that is not a VegasElement with a key and a non-empty image is
|
||||
dropped, and a duplicate key keeps its first element, each reported
|
||||
once.
|
||||
"""
|
||||
if result is None:
|
||||
return None
|
||||
if not isinstance(result, (list, tuple)):
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned %s, expected a list "
|
||||
"of VegasElement", type(result).__name__)
|
||||
return None
|
||||
seen = set()
|
||||
valid: List[VegasElement] = []
|
||||
for element in result:
|
||||
if not (isinstance(element, VegasElement)
|
||||
and isinstance(element.key, str) and element.key
|
||||
and isinstance(element.image, Image.Image)):
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned an item that is "
|
||||
"not a VegasElement with a key and an image (%s); skipping it",
|
||||
type(element).__name__)
|
||||
continue
|
||||
if element.image.width <= 0 or element.image.height <= 0:
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned an empty image for "
|
||||
"%r; skipping it", element.key)
|
||||
continue
|
||||
if element.key in seen:
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned key %r twice; "
|
||||
"keeping the first", element.key)
|
||||
continue
|
||||
seen.add(element.key)
|
||||
valid.append(element)
|
||||
return valid
|
||||
|
||||
def _element_image(self, element: VegasElement) -> Image.Image:
|
||||
"""An element's image at the display's height, in RGB."""
|
||||
image = element.image
|
||||
if image.height != self.display_height:
|
||||
image = image.resize((image.width, self.display_height),
|
||||
Image.Resampling.LANCZOS)
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
return image
|
||||
|
||||
def _padding(self) -> int:
|
||||
"""Black columns a live element carries each side: what trimming would leave."""
|
||||
return self.config.content_padding if self.config.auto_trim else 0
|
||||
|
||||
@staticmethod
|
||||
def _refresh_hz(element: VegasElement) -> float:
|
||||
try:
|
||||
return max(0.0, float(element.refresh_hz or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def _images_from_elements(
|
||||
self, result: Any, plugin_id: str, epoch: int
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""Turn get_vegas_elements()'s answer into images for the pipeline.
|
||||
|
||||
Live elements come out pinned (RGB, display height, content_padding
|
||||
black each side, never trimmed afterwards) and tagged with their
|
||||
ElementMeta; plain ones (``live=False``) come out as ordinary content.
|
||||
"""
|
||||
elements = self._valid_elements(result, plugin_id)
|
||||
if elements is None:
|
||||
return None
|
||||
padding = self._padding()
|
||||
now = time.monotonic()
|
||||
images: List[Image.Image] = []
|
||||
for element in elements:
|
||||
try:
|
||||
image = self._element_image(element)
|
||||
if not element.live:
|
||||
# Plain content; a tag copied from a reused image must not
|
||||
# make it live by accident.
|
||||
images.append(untag(image.copy()) if meta_of(image) else image)
|
||||
continue
|
||||
pinned, pixels = pin_element(image, padding)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# An image Pillow cannot resize or convert (an odd mode, a
|
||||
# closed file) costs that element, not its neighbours.
|
||||
self._warn_element_once(
|
||||
plugin_id, "element %r could not be converted (%r); skipping it",
|
||||
element.key, exc)
|
||||
continue
|
||||
images.append(tag(pinned, ElementMeta(
|
||||
plugin_id=plugin_id, key=element.key, epoch=epoch,
|
||||
digest=pixel_digest(pixels), rendered_at=now,
|
||||
refresh_hz=self._refresh_hz(element), version=element.version)))
|
||||
return images or None
|
||||
|
||||
def _rendered_from_elements(
|
||||
self, result: Any, plugin_id: str, epoch: int
|
||||
) -> Dict[str, RenderedElement]:
|
||||
"""RenderedElements for the live elements in a get_vegas_elements() answer.
|
||||
|
||||
An element handed back as the very image last converted for its key,
|
||||
with the same ``version``, reuses that conversion's pinned pixels and
|
||||
digest, so nothing is converted or checksummed. The image must be the
|
||||
same object: a plugin redrawn for a new config (new colours, a
|
||||
different font) can keep its data version, and must not keep its old
|
||||
pixels with it.
|
||||
"""
|
||||
elements = self._valid_elements(result, plugin_id) or []
|
||||
padding = self._padding()
|
||||
memo = self._element_memo
|
||||
rendered: Dict[str, RenderedElement] = {}
|
||||
for element in elements:
|
||||
if not element.live:
|
||||
continue
|
||||
memo_key = (plugin_id, element.key)
|
||||
cached = memo.get(memo_key)
|
||||
if cached is not None and element.version is not None \
|
||||
and cached[0] == element.version and cached[1] is element.image \
|
||||
and cached[2] == (padding, self.display_height):
|
||||
pixels, digest = cached[3], cached[4]
|
||||
else:
|
||||
try:
|
||||
_pinned, pixels = pin_element(self._element_image(element), padding)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_element_once(
|
||||
plugin_id, "element %r could not be converted (%r); it "
|
||||
"keeps what it shows", element.key, exc)
|
||||
continue
|
||||
digest = pixel_digest(pixels)
|
||||
memo[memo_key] = (element.version, element.image,
|
||||
(padding, self.display_height), pixels, digest)
|
||||
rendered[element.key] = RenderedElement(
|
||||
key=element.key, epoch=epoch, version=element.version,
|
||||
pixels=pixels, digest=digest, width=pixels.shape[1])
|
||||
# Forget keys the plugin no longer has, so the memo stays its size.
|
||||
stale = [k for k in list(memo)
|
||||
if k[0] == plugin_id and k[1] not in rendered]
|
||||
for memo_key in stale:
|
||||
memo.pop(memo_key, None)
|
||||
return rendered
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
@@ -1321,6 +1691,9 @@ class PluginAdapter:
|
||||
self._content_cache.pop(plugin_id, None)
|
||||
else:
|
||||
self._content_cache.clear()
|
||||
# A config change, most often. Swapped rather than cleared:
|
||||
# the live-element worker may be iterating the old one.
|
||||
self._element_memo = {}
|
||||
|
||||
def invalidate_plugin_scroll_cache(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
@@ -1354,7 +1727,13 @@ class PluginAdapter:
|
||||
if helper is None:
|
||||
continue
|
||||
try:
|
||||
if getattr(helper, 'cached_image', None) is not None:
|
||||
# has_strip() rather than reading cached_image, which would
|
||||
# build a deferred image only to throw it away.
|
||||
if isinstance(helper, ScrollHelper):
|
||||
has_image = helper.has_strip()
|
||||
else:
|
||||
has_image = getattr(helper, 'cached_image', None) is not None
|
||||
if has_image:
|
||||
helper.cached_image = None
|
||||
cleared = True
|
||||
if getattr(helper, 'cached_array', None) is not None:
|
||||
@@ -1369,29 +1748,6 @@ class PluginAdapter:
|
||||
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
|
||||
return cleared
|
||||
|
||||
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
Get the type of content a plugin provides.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
'multi' for multiple items, 'static' for single frame, 'none' for excluded
|
||||
"""
|
||||
if hasattr(plugin, 'get_vegas_content_type'):
|
||||
try:
|
||||
return plugin.get_vegas_content_type()
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
logger.exception(
|
||||
"Error calling get_vegas_content_type() on %s",
|
||||
plugin_id
|
||||
)
|
||||
|
||||
# Default to static for plugins without explicit type
|
||||
return 'static'
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
with self._cache_lock:
|
||||
|
||||
@@ -5,6 +5,7 @@ Composes plugin content into one wide strip and renders the visible window of
|
||||
it each frame, using ScrollHelper for the numpy-backed scroll.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -18,6 +19,8 @@ from src.common.scroll_config import solve_crisp
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.elements import ElementMeta, ElementRecord, LivePatch, LiveView, meta_of
|
||||
from src.vegas_mode.live_worker import LEGACY_PREFETCH_JOIN_S, VegasWorker
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
@@ -29,6 +32,51 @@ logger = logging.getLogger(__name__)
|
||||
SYNC_SEND_INTERVAL = 1.0 / 90
|
||||
|
||||
|
||||
def join_plugin_rows(
|
||||
images: List[Image.Image], config: VegasModeConfig
|
||||
) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]:
|
||||
"""Join one plugin's images into the block the strip will hold.
|
||||
|
||||
Returns ``(block, layout)``, layout being ``(x, meta, width)`` for every
|
||||
image tagged as a live element (src/vegas_mode/elements.py), x measured
|
||||
from the block's left edge. A single image is returned as it is.
|
||||
|
||||
A module function so tooling (scripts/render_plugin.py --vegas) lays a
|
||||
plugin out exactly as the ticker does.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
meta = meta_of(images[0])
|
||||
layout = [(0, meta, images[0].width)] if meta is not None else []
|
||||
return images[0], layout
|
||||
|
||||
floor = max(0, config.intra_plugin_gap)
|
||||
target = max(0, config.min_content_separation)
|
||||
threshold = config.trim_threshold
|
||||
|
||||
# Space by measured separation, not a flat gap. Rows drawn flush to their
|
||||
# own edges (sports score cards) would otherwise end up nearly touching,
|
||||
# while rows that already carry wide margins would be pushed needlessly
|
||||
# further apart.
|
||||
gaps = [
|
||||
separation_gap(images[i], images[i + 1], target, floor, threshold)
|
||||
for i in range(len(images) - 1)
|
||||
]
|
||||
|
||||
width = sum(img.width for img in images) + sum(gaps)
|
||||
height = max(img.height for img in images)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
layout: List[Tuple[int, ElementMeta, int]] = []
|
||||
x = 0
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
meta = meta_of(img)
|
||||
if meta is not None:
|
||||
layout.append((x, meta, img.width))
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block, layout
|
||||
|
||||
|
||||
class RenderPipeline:
|
||||
"""
|
||||
High-performance render pipeline for Vegas scroll mode.
|
||||
@@ -67,6 +115,35 @@ class RenderPipeline:
|
||||
# without __init__ (tests).
|
||||
_static_markers: Tuple[Tuple[int, str], ...] = ()
|
||||
|
||||
# Live elements in the strip (see "live element records" below). Replaced,
|
||||
# never mutated, like _static_markers, and class-level for the same reason.
|
||||
_elements: Tuple[ElementRecord, ...] = ()
|
||||
# Columns trimmed off the strip's front since it was composed.
|
||||
_strip_origin: int = 0
|
||||
# Bumped whenever a new strip replaces the old one (compose, reset), so
|
||||
# anything computed against the old strip can tell.
|
||||
_strip_gen: int = 0
|
||||
|
||||
# Live updates (see apply_live_patches and src/vegas_mode/live_worker.py).
|
||||
# Where the viewport is, published every frame for the worker.
|
||||
_view: Optional[LiveView] = None
|
||||
# Set by the coordinator for a run in which live elements are on.
|
||||
_live_enabled: bool = False
|
||||
_live_worker: Optional[VegasWorker] = None
|
||||
# The last worker stopped: it finishes its current job, and hands over
|
||||
# what it has of a group, before the one-shot prefetch fetches another.
|
||||
_retired_worker: Optional[VegasWorker] = None
|
||||
#: Patches applied between two frames at most, and the bytes they may
|
||||
#: copy, in screens of pixels (at least one patch is always applied).
|
||||
LIVE_PATCHES_PER_FRAME = 4
|
||||
LIVE_PATCH_BUDGET_SCREENS = 2
|
||||
#: A worker that dies this often in this many seconds is not restarted
|
||||
#: again this run; live updates stop and the one-shot prefetch returns.
|
||||
LIVE_WORKER_MAX_DEATHS = 3
|
||||
LIVE_WORKER_DEATH_WINDOW = 600.0
|
||||
#: Frames between checks that the worker is still alive.
|
||||
LIVE_SUPERVISE_FRAMES = 256
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -117,6 +194,17 @@ class RenderPipeline:
|
||||
# Render state
|
||||
self._cycle_complete = False
|
||||
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
||||
self._record_by_seq: Dict[int, ElementRecord] = {}
|
||||
# Live updates. _applied: per record, the (epoch, digest) of the
|
||||
# pixels the strip holds. _live_slots / _live_ready: the worker's
|
||||
# hand-over, one slot per record (latest wins) and the order they
|
||||
# arrived in. Written by the worker, consumed by the render thread;
|
||||
# single-key dict operations and deque append/popleft only.
|
||||
self._applied: Dict[int, Tuple[int, Any]] = {}
|
||||
self._live_slots: Dict[int, LivePatch] = {}
|
||||
self._live_ready: Deque[int] = deque()
|
||||
self._worker_deaths: Deque[float] = deque()
|
||||
self._live_frames = 0
|
||||
|
||||
# The sub-pixel path's pacing; the crisp path solves its own (frame_interval).
|
||||
self._frame_interval = config.get_frame_interval()
|
||||
@@ -292,6 +380,8 @@ class RenderPipeline:
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
self._static_markers = ()
|
||||
# A compose replaces the strip, and every record with it.
|
||||
self._reset_records()
|
||||
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
@@ -304,10 +394,13 @@ class RenderPipeline:
|
||||
# row". Without this, a per-row ticker such as the F1 scoreboard got
|
||||
# the full separator between each of its ~116 rows.
|
||||
blocks = []
|
||||
layouts = []
|
||||
total_rows = 0
|
||||
for plugin_id, images in grouped:
|
||||
for _plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
block, layout = self._join_plugin_rows_with_layout(images)
|
||||
blocks.append(block)
|
||||
layouts.append(layout)
|
||||
|
||||
# Create scrolling image via ScrollHelper.
|
||||
#
|
||||
@@ -323,11 +416,16 @@ class RenderPipeline:
|
||||
)
|
||||
|
||||
# Verify scroll image was created successfully
|
||||
if not self.scroll_helper.cached_image:
|
||||
if not self.scroll_helper.has_strip():
|
||||
logger.error("ScrollHelper failed to create cached image")
|
||||
return False
|
||||
|
||||
self._static_markers = self._markers_for_composition(blocks)
|
||||
self._register_elements(
|
||||
self._block_starts([b.width for b in blocks], 0, False,
|
||||
lead=self.config.lead_in_width),
|
||||
layouts)
|
||||
self._note_op('compose', self._strip_nbytes())
|
||||
|
||||
# Track which plugins are in this scroll (get safely via buffer status)
|
||||
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
|
||||
@@ -340,7 +438,7 @@ class RenderPipeline:
|
||||
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
|
||||
"separator=%dpx between plugins, rows spaced to %dpx of ink "
|
||||
"(min added %dpx)",
|
||||
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||
self.scroll_helper.total_scroll_width if self.scroll_helper.has_strip() else 0,
|
||||
self.display_height,
|
||||
len(blocks),
|
||||
total_rows,
|
||||
@@ -356,6 +454,27 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def _note_op(self, kind: str, nbytes: int = 0) -> None:
|
||||
"""Tag the next presented frame with render-thread work done for it.
|
||||
|
||||
See "Operations" in src/common/frame_timing.py: a soak can then say
|
||||
how often a frame straight after an extension or a patch was late,
|
||||
rather than only how often any frame was.
|
||||
"""
|
||||
timing = getattr(self.display_manager, 'frame_timing', None)
|
||||
note = getattr(timing, 'note_op', None)
|
||||
if note is not None:
|
||||
note(kind, nbytes)
|
||||
|
||||
def _copied_bytes(self) -> int:
|
||||
"""Bytes the scroll helper's last append or trim copied (the strip, if it cannot say)."""
|
||||
copied = getattr(self.scroll_helper, 'last_copy_bytes', None)
|
||||
return int(copied) if isinstance(copied, int) else self._strip_nbytes()
|
||||
|
||||
def _strip_nbytes(self) -> int:
|
||||
array = self.scroll_helper.cached_array
|
||||
return int(array.nbytes) if array is not None else 0
|
||||
|
||||
def _markers_for_composition(self, blocks: List[Image.Image]) -> Tuple[Tuple[int, str], ...]:
|
||||
"""Static markers for a strip just built by create_scrolling_image.
|
||||
|
||||
@@ -412,7 +531,7 @@ class RenderPipeline:
|
||||
|
||||
Cheap enough to call every frame: it is arithmetic over cached state.
|
||||
"""
|
||||
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
|
||||
if not self.config.continuous_scroll or not self.scroll_helper.has_strip():
|
||||
return False
|
||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
||||
return self.scroll_helper.remaining_unscrolled() <= threshold
|
||||
@@ -436,23 +555,43 @@ class RenderPipeline:
|
||||
if not self.config.continuous_scroll:
|
||||
return
|
||||
|
||||
# With live elements in the strip, the live-element worker fetches
|
||||
# groups too, one plugin at a time between its redraws, so that only
|
||||
# one thread ever draws for the strip.
|
||||
worker = self._live_worker
|
||||
if worker is not None and worker.is_alive():
|
||||
with self._prefetch_lock:
|
||||
if self._prepared_group is not None:
|
||||
return
|
||||
worker.request_group()
|
||||
return
|
||||
|
||||
with self._prefetch_lock:
|
||||
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
|
||||
return
|
||||
if self._prepared_group is not None:
|
||||
return # already have one waiting
|
||||
generation = self._prefetch_generation
|
||||
retired = self._retired_worker
|
||||
|
||||
def _work():
|
||||
# Deprioritise against the render loop. Linux applies nice
|
||||
# per-thread, and the heavy lifting here is PIL and numpy work
|
||||
# that releases the GIL, so the scheduler can actually act on
|
||||
# it — without this the prefetch competes for the same cores and
|
||||
# costs frames.
|
||||
# Deprioritise against the render loop for CPU time (Linux
|
||||
# applies nice per thread). Nice does nothing about the GIL,
|
||||
# which Pillow's drawing holds (docs/OFFSCREEN_RENDERING.md,
|
||||
# risk 5); the render gate below is what keeps this thread off
|
||||
# it while the render thread needs it.
|
||||
try:
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
# A live-element worker just stopped finishes its current job
|
||||
# and hands over what it has of a group. Wait for it, so only
|
||||
# one thread draws and a group it handed over is not replaced.
|
||||
if retired is not None and retired is not threading.current_thread() and retired.is_alive():
|
||||
retired.join(LEGACY_PREFETCH_JOIN_S)
|
||||
with self._prefetch_lock:
|
||||
if generation != self._prefetch_generation or self._prepared_group is not None:
|
||||
return
|
||||
# With vegas_scroll.prefetch_gate on, run only while the render
|
||||
# thread waits on vsync; see src/common/render_gate.py.
|
||||
gate = getattr(self.display_manager, 'render_gate', None)
|
||||
@@ -465,7 +604,8 @@ class RenderPipeline:
|
||||
with self._prefetch_lock:
|
||||
if generation != self._prefetch_generation:
|
||||
return # Vegas was reset while this was fetching
|
||||
self._prepared_group = group
|
||||
if self._prepared_group is None:
|
||||
self._prepared_group = group
|
||||
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_work, daemon=True, name="vegas-strip-prefetch")
|
||||
@@ -525,6 +665,7 @@ class RenderPipeline:
|
||||
element_gap=0,
|
||||
)
|
||||
if appended:
|
||||
self._note_op('extend', self._copied_bytes())
|
||||
logger.info(
|
||||
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
||||
plugin_id, self.scroll_helper.total_scroll_width,
|
||||
@@ -581,8 +722,10 @@ class RenderPipeline:
|
||||
else:
|
||||
content.append((pid, images))
|
||||
grouped = content
|
||||
strip_end = (self.scroll_helper.cached_image.width
|
||||
if self.scroll_helper.cached_image is not None else 0)
|
||||
# From the helper's own bookkeeping, never cached_image: reading
|
||||
# that would build the full PIL strip the helper now defers.
|
||||
strip_end = (self.scroll_helper.total_scroll_width
|
||||
if self.scroll_helper.has_strip() else 0)
|
||||
|
||||
# Plugins the background thread had to defer need the shared canvas,
|
||||
# so they can only be fetched here. Queue them rather than doing all
|
||||
@@ -613,12 +756,18 @@ class RenderPipeline:
|
||||
return bool(deferred)
|
||||
|
||||
blocks = []
|
||||
layouts = []
|
||||
total_rows = 0
|
||||
for _plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
block, layout = self._join_plugin_rows_with_layout(images)
|
||||
blocks.append(block)
|
||||
layouts.append(layout)
|
||||
|
||||
had_strip = self.scroll_helper.cached_image is not None
|
||||
had_strip = self.scroll_helper.has_strip()
|
||||
if not had_strip:
|
||||
# append_content is about to build a strip from scratch.
|
||||
self._reset_records()
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
@@ -626,17 +775,15 @@ class RenderPipeline:
|
||||
)
|
||||
if not appended:
|
||||
return False
|
||||
moved = self._copied_bytes()
|
||||
|
||||
# Where each block starts, laid out as append_content does: a
|
||||
# separator before every block, or -- when there was no strip to
|
||||
# extend -- as create_scrolling_image does with no lead-in.
|
||||
starts = self._block_starts([b.width for b in blocks], strip_end, had_strip)
|
||||
self._register_elements(starts, layouts)
|
||||
if statics:
|
||||
# Where each block ends, laid out as append_content does: a
|
||||
# separator before every block, or -- when there was no strip
|
||||
# to extend -- as create_scrolling_image does with no lead-in.
|
||||
gap = max(0, self.config.separator_width)
|
||||
ends = []
|
||||
x = strip_end if had_strip else -gap
|
||||
for block in blocks:
|
||||
x += gap + block.width
|
||||
ends.append(x)
|
||||
ends = [start + block.width for start, block in zip(starts, blocks)]
|
||||
self._add_static_markers([
|
||||
(ends[n - 1] if n > 0 else strip_end, pid) for n, pid in statics
|
||||
])
|
||||
@@ -646,6 +793,11 @@ class RenderPipeline:
|
||||
if cut and self._static_markers:
|
||||
self._static_markers = tuple(
|
||||
(max(0, x - cut), pid) for x, pid in self._static_markers)
|
||||
self._forget_trimmed_records(cut)
|
||||
# Both land in the frame after this one: the append's new columns
|
||||
# (the whole strip when its buffer had to be reallocated), and a
|
||||
# trim's copy, if it made one.
|
||||
self._note_op('extend', moved + (self._copied_bytes() if cut else 0))
|
||||
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
@@ -679,31 +831,248 @@ class RenderPipeline:
|
||||
``intra_plugin_gap``. Returned unchanged when there is only one row,
|
||||
which is the common case and avoids a pointless copy.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
return self._join_plugin_rows_with_layout(images)[0]
|
||||
|
||||
floor = max(0, self.config.intra_plugin_gap)
|
||||
target = max(0, self.config.min_content_separation)
|
||||
threshold = self.config.trim_threshold
|
||||
def _join_plugin_rows_with_layout(
|
||||
self, images: List[Image.Image]
|
||||
) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]:
|
||||
"""_join_plugin_rows, plus where each live element landed in the block.
|
||||
|
||||
# Space by measured separation, not a flat gap. Rows drawn flush to
|
||||
# their own edges (sports score cards) would otherwise end up nearly
|
||||
# touching, while rows that already carry wide margins would be pushed
|
||||
# needlessly further apart.
|
||||
gaps = [
|
||||
separation_gap(images[i], images[i + 1], target, floor, threshold)
|
||||
for i in range(len(images) - 1)
|
||||
]
|
||||
See join_plugin_rows.
|
||||
"""
|
||||
return join_plugin_rows(images, self.config)
|
||||
|
||||
width = sum(img.width for img in images) + sum(gaps)
|
||||
height = max(img.height for img in images)
|
||||
# -- live element records ---------------------------------------------
|
||||
#
|
||||
# Where each live element sits in the strip (ElementRecord), kept so a
|
||||
# redraw can later be swapped into exactly its columns. Coordinates are
|
||||
# absolute: a record's column in the strip is abs_x - _strip_origin, and
|
||||
# a trim moves the origin instead of every record. Only the render thread
|
||||
# changes any of this, at the points where it builds or trims the strip.
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block
|
||||
def _block_starts(self, widths: List[int], strip_end: int, had_strip: bool,
|
||||
lead: int = 0) -> List[int]:
|
||||
"""Strip columns where each of these blocks starts once placed.
|
||||
|
||||
Mirrors ScrollHelper exactly: append_content puts a separator before
|
||||
every block after an existing strip; create_scrolling_image (a compose,
|
||||
or an append with nothing to extend) puts ``lead`` columns first and a
|
||||
separator between blocks.
|
||||
"""
|
||||
gap = max(0, self.config.separator_width)
|
||||
starts = []
|
||||
if had_strip:
|
||||
x = strip_end
|
||||
for width in widths:
|
||||
x += gap
|
||||
starts.append(x)
|
||||
x += width
|
||||
else:
|
||||
x = max(0, int(lead))
|
||||
for width in widths:
|
||||
starts.append(x)
|
||||
x += width + gap
|
||||
return starts
|
||||
|
||||
def _next_record_seq(self) -> int:
|
||||
counter = self.__dict__.get('_record_counter')
|
||||
if counter is None:
|
||||
counter = self._record_counter = itertools.count(1)
|
||||
return next(counter)
|
||||
|
||||
def _register_elements(
|
||||
self, starts: List[int], layouts: List[List[Tuple[int, ElementMeta, int]]]
|
||||
) -> int:
|
||||
"""Record every live element in blocks just placed at ``starts``."""
|
||||
new = []
|
||||
for start, layout in zip(starts, layouts):
|
||||
for offset, meta, width in layout:
|
||||
new.append(ElementRecord(
|
||||
seq=self._next_record_seq(), plugin_id=meta.plugin_id,
|
||||
key=meta.key, abs_x=self._strip_origin + start + offset,
|
||||
width=width, epoch=meta.epoch, digest=meta.digest,
|
||||
refresh_hz=meta.refresh_hz))
|
||||
if new:
|
||||
self._elements = self._elements + tuple(new)
|
||||
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
||||
applied = self.__dict__.setdefault('_applied', {})
|
||||
for record in new:
|
||||
by_seq[record.seq] = record
|
||||
applied[record.seq] = (record.epoch, record.digest)
|
||||
if self._live_enabled:
|
||||
self._ensure_live_worker()
|
||||
return len(new)
|
||||
|
||||
def _forget_trimmed_records(self, cut: int) -> None:
|
||||
"""The strip lost ``cut`` columns off its front: move the origin on."""
|
||||
if cut <= 0:
|
||||
return
|
||||
self._strip_origin += cut
|
||||
origin = self._strip_origin
|
||||
records = self._elements
|
||||
if not records:
|
||||
return
|
||||
kept = tuple(r for r in records if r.abs_x + r.width > origin)
|
||||
if len(kept) != len(records):
|
||||
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
||||
applied = self.__dict__.setdefault('_applied', {})
|
||||
slots = self.__dict__.setdefault('_live_slots', {})
|
||||
for record in records:
|
||||
if record.abs_x + record.width <= origin:
|
||||
by_seq.pop(record.seq, None)
|
||||
applied.pop(record.seq, None)
|
||||
slots.pop(record.seq, None)
|
||||
self._elements = kept
|
||||
|
||||
def _reset_records(self) -> None:
|
||||
"""A new strip: nothing recorded, coordinates from zero, a new generation."""
|
||||
self._strip_gen += 1
|
||||
self._strip_origin = 0
|
||||
self._elements = ()
|
||||
self._record_by_seq = {}
|
||||
self._applied = {}
|
||||
# Patches still queued belong to the old strip; apply would drop them
|
||||
# on their generation anyway, but there is no reason to keep them.
|
||||
self._live_slots = {}
|
||||
self._live_ready = deque()
|
||||
self._view = None
|
||||
|
||||
def has_live_records(self) -> bool:
|
||||
"""Whether the strip holds any live element."""
|
||||
return bool(self._elements)
|
||||
|
||||
# -- live updates ---------------------------------------------------------
|
||||
|
||||
def set_live(self, enabled: bool) -> None:
|
||||
"""Switch live updates on or off for this run (the coordinator decides)."""
|
||||
self._live_enabled = enabled
|
||||
if enabled:
|
||||
if self._elements:
|
||||
self._ensure_live_worker()
|
||||
elif self._stop_live_worker():
|
||||
# The worker was fetching the strip's groups as well. Hand that
|
||||
# back to the one-shot prefetch now: nothing else asks for a group
|
||||
# until the next extension, which would find none prepared and
|
||||
# fetch inline, stalling the scroll.
|
||||
self.start_prefetch()
|
||||
|
||||
def notify_live_data(self, plugin_id: str) -> None:
|
||||
"""A plugin's data may have changed: wake the worker, if one runs."""
|
||||
worker = self._live_worker
|
||||
if worker is not None:
|
||||
worker.notify_data(plugin_id)
|
||||
|
||||
def _ensure_live_worker(self) -> None:
|
||||
"""Start the live-element worker, or restart one that died.
|
||||
|
||||
Started lazily, by the first live element placed: an install with no
|
||||
plugin that has live elements keeps the one-shot prefetch thread and
|
||||
never runs this worker at all. A worker that keeps dying is given up
|
||||
on for the run; live updates stop and the one-shot prefetch returns.
|
||||
"""
|
||||
if not self._live_enabled:
|
||||
return
|
||||
worker = self._live_worker
|
||||
if worker is not None and worker.is_alive():
|
||||
return
|
||||
if worker is None and not self._elements:
|
||||
# Nothing live in the strip: the one-shot prefetch does the work
|
||||
# until a live element is placed (_register_elements).
|
||||
return
|
||||
deaths = self.__dict__.setdefault('_worker_deaths', deque())
|
||||
now = time.monotonic()
|
||||
if worker is not None:
|
||||
deaths.append(now)
|
||||
while deaths and now - deaths[0] > self.LIVE_WORKER_DEATH_WINDOW:
|
||||
deaths.popleft()
|
||||
if len(deaths) >= self.LIVE_WORKER_MAX_DEATHS:
|
||||
logger.error(
|
||||
"Vegas live worker stopped %d times in %.0fs; live updates "
|
||||
"are off until Vegas restarts", len(deaths),
|
||||
self.LIVE_WORKER_DEATH_WINDOW)
|
||||
self._live_enabled = False
|
||||
self._live_worker = None
|
||||
# Whatever group the dead worker was fetching is lost.
|
||||
self.start_prefetch()
|
||||
return
|
||||
logger.warning("Vegas live worker was not running; restarting it")
|
||||
worker = VegasWorker(self)
|
||||
self._live_worker = worker
|
||||
worker.start()
|
||||
# Whatever the one-shot prefetch was asked for, the worker now does.
|
||||
with self._prefetch_lock:
|
||||
wanted = self._prepared_group is None
|
||||
if wanted and self.config.continuous_scroll:
|
||||
worker.request_group()
|
||||
|
||||
def _stop_live_worker(self) -> bool:
|
||||
"""Ask the worker to stop after its current job. Whether one was running."""
|
||||
worker, self._live_worker = self._live_worker, None
|
||||
if worker is None:
|
||||
return False
|
||||
self._retired_worker = worker
|
||||
worker.stop()
|
||||
return True
|
||||
|
||||
def apply_live_patches(self) -> int:
|
||||
"""Copy the worker's finished redraws into the strip. Render thread only.
|
||||
|
||||
Called between two frames (coordinator.run_frame). The only work here
|
||||
is popping prepared patches and a numpy slice copy per patch -- no
|
||||
drawing, no locks, no allocation -- bounded to LIVE_PATCHES_PER_FRAME
|
||||
patches or LIVE_PATCH_BUDGET_SCREENS screens of bytes, whichever comes
|
||||
first (always at least one). A patch is dropped when it no longer
|
||||
fits: made for an older strip, for an element trimmed away or already
|
||||
behind the screen, or older than what the strip already shows.
|
||||
|
||||
Returns:
|
||||
Patches applied.
|
||||
"""
|
||||
if self._live_enabled:
|
||||
self._live_frames = self.__dict__.get('_live_frames', 0) + 1
|
||||
if self._live_frames % self.LIVE_SUPERVISE_FRAMES == 0:
|
||||
self._ensure_live_worker()
|
||||
ready = self.__dict__.get('_live_ready')
|
||||
if not ready:
|
||||
return 0
|
||||
slots = self._live_slots
|
||||
if getattr(self, 'sync_manager', None) is not None:
|
||||
# Defensive: live elements are never on under sync, and the
|
||||
# follower would not see a patch.
|
||||
ready.clear()
|
||||
slots.clear()
|
||||
return 0
|
||||
budget = (self.LIVE_PATCH_BUDGET_SCREENS * self.display_width
|
||||
* self.display_height * 3)
|
||||
helper = self.scroll_helper
|
||||
left_edge = int(helper.scroll_position)
|
||||
applied = 0
|
||||
moved = 0
|
||||
while ready and applied < self.LIVE_PATCHES_PER_FRAME \
|
||||
and (applied == 0 or moved < budget):
|
||||
seq = ready.popleft()
|
||||
patch = slots.pop(seq, None)
|
||||
if patch is None:
|
||||
continue # a newer patch for this record already went
|
||||
record = self._record_by_seq.get(seq)
|
||||
if record is None or patch.strip_gen != self._strip_gen:
|
||||
continue
|
||||
previous = self._applied.get(seq)
|
||||
if previous is not None and patch.epoch < previous[0]:
|
||||
continue
|
||||
x = record.abs_x - self._strip_origin
|
||||
if x + record.width <= left_edge:
|
||||
continue # scrolled past; nobody will see it
|
||||
moved += helper.patch_columns(x, patch.pixels)
|
||||
self._applied[seq] = (patch.epoch, patch.digest)
|
||||
applied += 1
|
||||
if applied:
|
||||
self._note_op('patch', moved)
|
||||
return applied
|
||||
|
||||
def live_records(self) -> Tuple[ElementRecord, ...]:
|
||||
"""The live elements in the strip, in the order they were placed."""
|
||||
return self._elements
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
"""
|
||||
@@ -718,11 +1087,18 @@ class RenderPipeline:
|
||||
frame_start = time.time()
|
||||
|
||||
try:
|
||||
if not self.scroll_helper.cached_image:
|
||||
if not self.scroll_helper.has_strip():
|
||||
return False
|
||||
|
||||
# Update scroll position
|
||||
self.scroll_helper.update_scroll_position()
|
||||
# Where the viewport is now, for the live-element worker: one
|
||||
# tuple store, read by the worker without a lock.
|
||||
left = self._strip_origin + int(self.scroll_helper.scroll_position)
|
||||
self._view = LiveView(
|
||||
abs_left=left, abs_right=left + self.display_width,
|
||||
abs_end=self._strip_origin + self.scroll_helper.total_scroll_width,
|
||||
t_mono=time.monotonic())
|
||||
|
||||
# Determine if the cycle is done.
|
||||
#
|
||||
@@ -958,10 +1334,11 @@ class RenderPipeline:
|
||||
self.sync_manager.send_new_cycle()
|
||||
# Push the actual scroll image over TCP so follower has identical pixels.
|
||||
# Done in a background thread to not block the render loop (~15ms transfer).
|
||||
if self.scroll_helper.cached_image is not None:
|
||||
image = self.scroll_helper.cached_image
|
||||
if image is not None:
|
||||
threading.Thread(
|
||||
target=self.sync_manager.send_scroll_image,
|
||||
args=(self.scroll_helper.cached_image,),
|
||||
args=(image,),
|
||||
daemon=True, name="sync-image-push"
|
||||
).start()
|
||||
|
||||
@@ -1034,6 +1411,8 @@ class RenderPipeline:
|
||||
self._prepared_group = None
|
||||
self._deferred_queue = []
|
||||
self._static_markers = ()
|
||||
self._stop_live_worker()
|
||||
self._reset_records()
|
||||
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
|
||||
|
||||
@@ -5,23 +5,26 @@ Manages plugin content streaming with look-ahead buffering. Maintains a queue
|
||||
of plugin content that's ready to be rendered, prefetching 1-2 plugins ahead
|
||||
of the current scroll position.
|
||||
|
||||
Supports three display modes:
|
||||
- SCROLL: Continuous scrolling content
|
||||
- FIXED_SEGMENT: Fixed block that scrolls by
|
||||
- STATIC: Pause scroll to display (marked for coordinator handling)
|
||||
Each plugin takes part in one of three ways (its Vegas participation, see
|
||||
BasePlugin.get_vegas_participation):
|
||||
- 'scroll': its content joins the strip
|
||||
- 'pause': the scroll pauses for its turn (a STATIC segment, marked for the
|
||||
coordinator)
|
||||
- 'exclude': left out of the rotation
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING, cast
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
|
||||
from src import display_watchdog
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode, resolve_vegas_participation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
@@ -34,11 +37,12 @@ class ContentSegment:
|
||||
"""One plugin's content for a cycle.
|
||||
|
||||
A STATIC segment carries no images: it marks where the coordinator pauses
|
||||
the scroll to show the plugin full-screen.
|
||||
the scroll to show a plugin whose participation is ``'pause'``. Every
|
||||
other segment is SCROLL.
|
||||
"""
|
||||
plugin_id: str
|
||||
images: List[Image.Image]
|
||||
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT)
|
||||
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.SCROLL)
|
||||
|
||||
|
||||
class StreamManager:
|
||||
@@ -335,25 +339,14 @@ class StreamManager:
|
||||
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
|
||||
continue
|
||||
|
||||
# Content type 'none' is left out, except for STATIC plugins,
|
||||
# which pause the scroll rather than contributing to it.
|
||||
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
|
||||
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
||||
try:
|
||||
display_mode = plugin.get_vegas_display_mode()
|
||||
except Exception:
|
||||
# Plugin error should not abort refresh; use default mode
|
||||
logger.exception(
|
||||
"[%s] (%s) get_vegas_display_mode() failed, using default",
|
||||
plugin_id, plugin.__class__.__name__
|
||||
)
|
||||
|
||||
included = (content_type != 'none'
|
||||
or display_mode == VegasDisplayMode.STATIC)
|
||||
# 'pause' plugins stay in the rotation: they pause the scroll
|
||||
# for their turn rather than contributing to it.
|
||||
participation = resolve_vegas_participation(plugin, plugin_id)
|
||||
included = participation != 'exclude'
|
||||
logger.debug(
|
||||
"[%s] Vegas: %s (content_type=%s, display_mode=%s)",
|
||||
"[%s] Vegas: %s (participation=%s)",
|
||||
plugin_id, "included" if included else "excluded",
|
||||
content_type, display_mode.value
|
||||
participation
|
||||
)
|
||||
if included:
|
||||
available_plugins.append(plugin_id)
|
||||
@@ -570,6 +563,9 @@ class StreamManager:
|
||||
Returns:
|
||||
ContentSegment or None if fetch failed
|
||||
"""
|
||||
# Composing a cycle fetches plugin after plugin on the render thread
|
||||
# (on the prefetch thread this is ignored), so check in between.
|
||||
display_watchdog.beat()
|
||||
try:
|
||||
if not hasattr(self.plugin_manager, 'plugins'):
|
||||
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
|
||||
@@ -580,23 +576,13 @@ class StreamManager:
|
||||
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
|
||||
return None
|
||||
|
||||
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
||||
try:
|
||||
display_mode = plugin.get_vegas_display_mode()
|
||||
except (AttributeError, TypeError) as e:
|
||||
logger.debug(
|
||||
"[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)",
|
||||
plugin_id, e
|
||||
)
|
||||
|
||||
# For STATIC mode, we create a placeholder segment
|
||||
# The actual content will be displayed by coordinator during pause
|
||||
if display_mode == VegasDisplayMode.STATIC:
|
||||
# Create minimal placeholder - coordinator handles actual display
|
||||
# A 'pause' plugin gets a placeholder segment; the coordinator
|
||||
# draws it with display() when the scroll reaches its turn.
|
||||
if resolve_vegas_participation(plugin, plugin_id) == 'pause':
|
||||
segment = ContentSegment(
|
||||
plugin_id=plugin_id,
|
||||
images=[], # No images needed for static pause
|
||||
display_mode=display_mode
|
||||
display_mode=VegasDisplayMode.STATIC
|
||||
)
|
||||
self.stats['segments_fetched'] += 1
|
||||
logger.debug(
|
||||
@@ -605,7 +591,6 @@ class StreamManager:
|
||||
)
|
||||
return segment
|
||||
|
||||
# Get content via adapter for SCROLL/FIXED_SEGMENT modes
|
||||
images = self.plugin_adapter.get_content(plugin, plugin_id)
|
||||
if not images:
|
||||
# The adapter already warns when every content path failed;
|
||||
@@ -619,13 +604,13 @@ class StreamManager:
|
||||
segment = ContentSegment(
|
||||
plugin_id=plugin_id,
|
||||
images=images,
|
||||
display_mode=display_mode
|
||||
display_mode=VegasDisplayMode.SCROLL
|
||||
)
|
||||
|
||||
self.stats['segments_fetched'] += 1
|
||||
logger.debug(
|
||||
"[%s] Segment: %d image(s), %dpx, mode=%s",
|
||||
plugin_id, len(images), total_width, display_mode.value
|
||||
"[%s] Segment: %d image(s), %dpx",
|
||||
plugin_id, len(images), total_width
|
||||
)
|
||||
return segment
|
||||
|
||||
@@ -693,16 +678,16 @@ class StreamManager:
|
||||
return layout
|
||||
|
||||
def is_static_plugin(self, plugin_id: str) -> bool:
|
||||
"""Whether a loaded plugin asks Vegas to pause for it (STATIC mode)."""
|
||||
"""Whether a loaded plugin asks Vegas to pause for it (participation 'pause').
|
||||
|
||||
Only 'pause' is acted on here. A plugin whose participation has turned
|
||||
to 'exclude' since the rotation was built is still fetched this cycle,
|
||||
as it always was; the next refresh drops it.
|
||||
"""
|
||||
plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
|
||||
if plugin is None:
|
||||
return False
|
||||
try:
|
||||
return cast(bool, plugin.get_vegas_display_mode() == VegasDisplayMode.STATIC)
|
||||
except Exception:
|
||||
logger.debug("[%s] get_vegas_display_mode() failed; treating as not STATIC",
|
||||
plugin_id, exc_info=True)
|
||||
return False
|
||||
return resolve_vegas_participation(plugin, plugin_id) == 'pause'
|
||||
|
||||
def take_next_group(
|
||||
self, count: Optional[int] = None, offscreen_only: bool = False
|
||||
@@ -732,6 +717,20 @@ class StreamManager:
|
||||
A STATIC plugin is also returned with an empty list, unfetched: it
|
||||
pauses the scroll instead of adding to it (see is_static_plugin).
|
||||
"""
|
||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
for plugin_id in self.plan_next_group(count):
|
||||
member = self.fetch_group_member(plugin_id, offscreen_only=offscreen_only)
|
||||
if member is not None:
|
||||
group.append(member)
|
||||
return group
|
||||
|
||||
def plan_next_group(self, count: Optional[int] = None) -> List[str]:
|
||||
"""Which plugins the next group holds, advancing the rotation past them.
|
||||
|
||||
The first half of take_next_group(). The live-element worker fetches
|
||||
a group one plugin at a time (fetch_group_member) so it can fit more
|
||||
urgent redraws between them.
|
||||
"""
|
||||
if count is None:
|
||||
count = self.config.plugins_per_cycle
|
||||
|
||||
@@ -745,37 +744,38 @@ class StreamManager:
|
||||
for _ in range(min(max(1, count), total)):
|
||||
ids.append(self._ordered_plugins[self._prefetch_index])
|
||||
self._prefetch_index = (self._prefetch_index + 1) % total
|
||||
return ids
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
def fetch_group_member(
|
||||
self, plugin_id: str, offscreen_only: bool = False
|
||||
) -> Optional[Tuple[str, Optional[List[Image.Image]]]]:
|
||||
"""One plugin's entry in a group, as take_next_group() describes it.
|
||||
|
||||
None when the plugin is gone or its fetch raised: it is left out of
|
||||
the group.
|
||||
"""
|
||||
plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
|
||||
if not plugin:
|
||||
return None
|
||||
if self.is_static_plugin(plugin_id):
|
||||
# A STATIC plugin pauses the scroll rather than scrolling by, so it
|
||||
# contributes no columns. It keeps its place in the group (empty)
|
||||
# so the pipeline can mark where its turn falls.
|
||||
return (plugin_id, [])
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
return None
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
return (plugin_id, images)
|
||||
# Only the old contract hands anything back to the render thread.
|
||||
defer_empty = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
|
||||
for plugin_id in ids:
|
||||
plugin = plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
continue
|
||||
if self.is_static_plugin(plugin_id):
|
||||
# A STATIC plugin pauses the scroll rather than scrolling by,
|
||||
# so it contributes no columns. It keeps its place in the
|
||||
# group (empty) so the pipeline can mark where its turn falls.
|
||||
group.append((plugin_id, []))
|
||||
continue
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
continue
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
group.append((plugin_id, images))
|
||||
else:
|
||||
group.append((plugin_id, None if defer_empty else []))
|
||||
|
||||
return group
|
||||
return (plugin_id, None if defer_empty else [])
|
||||
|
||||
def advance_cycle(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,8 @@ from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
def success_response(
|
||||
data: Any = None,
|
||||
message: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None
|
||||
metadata: Optional[Dict] = None,
|
||||
extra: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Create a standardized success response.
|
||||
@@ -24,11 +25,15 @@ def success_response(
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
metadata: Optional metadata (timing, version, etc.)
|
||||
extra: Optional top-level fields beside ``status``/``data``, such as
|
||||
``restart_required``; they cannot replace the standard keys
|
||||
|
||||
Returns:
|
||||
Flask jsonify response
|
||||
"""
|
||||
response_data = create_success_response(data, message, metadata)
|
||||
for key, value in (extra or {}).items():
|
||||
response_data.setdefault(key, value)
|
||||
|
||||
# Timing is merged into whatever the caller passed, without inventing a
|
||||
# metadata block for responses that have neither.
|
||||
|
||||
@@ -8,6 +8,10 @@ This directory contains systemd service unit files for LEDMatrix services.
|
||||
- Runs the display controller (`run.py`)
|
||||
- Starts automatically on boot
|
||||
- Runs as root for hardware access
|
||||
- Restarted by systemd's watchdog (`WatchdogSec=120`) when its render loop
|
||||
stops checking in, e.g. stuck inside a plugin; the loop writes a heartbeat
|
||||
to `/run/ledmatrix/display-heartbeat.json` (`RuntimeDirectory=`) that the
|
||||
web interface's health check reads. See `src/display_watchdog.py`
|
||||
|
||||
- **`ledmatrix-web.service`** - Web interface service
|
||||
- Runs the web interface conditionally based on config
|
||||
|
||||
@@ -27,6 +27,49 @@ ExecStart=/usr/bin/python3 __PROJECT_ROOT_DIR__/run.py
|
||||
# that a successful outcome and never bringing it back.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Back off when it keeps failing: 10s after the first failure, growing to two
|
||||
# minutes by the fourth, so a plugin that crashes or hangs the display on
|
||||
# every start retries a couple of dozen times an hour instead of hundreds.
|
||||
# Deliberately not StartLimitBurst=: once that trips the unit stays failed --
|
||||
# the panel dark until someone reboots -- and every start is refused until the
|
||||
# interval passes, including the web UI's Start button and the automatic
|
||||
# update's rollback, neither of which may run "systemctl reset-failed".
|
||||
# systemd before 254 (Debian Bookworm has 252) ignores these two lines with an
|
||||
# "Unknown key name" warning and keeps the flat RestartSec.
|
||||
RestartSteps=4
|
||||
RestartMaxDelaySec=2min
|
||||
# Render-loop watchdog (src/display_watchdog.py). The render thread itself
|
||||
# pings systemd every few seconds, so a render loop stuck inside a plugin --
|
||||
# service still "active", panel frozen -- stops the pings, and systemd kills
|
||||
# the process (SIGABRT: faulthandler writes every thread's stack to the
|
||||
# journal) and restarts it.
|
||||
#
|
||||
# Type=simple, not Type=notify. Type=notify would hold "systemctl start" and
|
||||
# "restart" until READY=1, i.e. until plugins have loaded -- minutes on a slow
|
||||
# board -- and the web interface, the installer and the update health check all
|
||||
# call those with timeouts well short of that. The process still sends READY=1
|
||||
# (harmless here); NotifyAccess=main is what lets systemd hear it at all, and
|
||||
# only from run.py itself, not from pip or anything else it starts.
|
||||
#
|
||||
# 120s is the steady-state limit, four times the longest gap a healthy loop
|
||||
# has: a screen's first display() call runs under PluginExecutor's 30s
|
||||
# timeout. Everything else the loop blocks on checks in between steps (Vegas
|
||||
# frames, each plugin fetched for a Vegas cycle, each dwell second). Start-up
|
||||
# is longer than this and happens before the loop exists, so the process
|
||||
# widens the limit to 15 minutes as it starts and narrows it back to this
|
||||
# value after its first frame; loading a plugin enabled from the web UI, which
|
||||
# can run pip on the render thread, gets the same 15 minutes. Raise it with a
|
||||
# drop-in (systemctl edit ledmatrix) if a plugin legitimately needs longer;
|
||||
# WatchdogSec=0 turns it off.
|
||||
WatchdogSec=120
|
||||
NotifyAccess=main
|
||||
# /run/ledmatrix, for the render loop's heartbeat (display-heartbeat.json),
|
||||
# which /api/v3/health and the update health check read. /run is tmpfs, so a
|
||||
# write every few seconds never touches the SD card. 0755 and root-owned: the
|
||||
# web interface runs as another user and only needs to read it. Removed when
|
||||
# the service stops, so a stopped display leaves no stale heartbeat behind.
|
||||
RuntimeDirectory=ledmatrix
|
||||
RuntimeDirectoryMode=0755
|
||||
# Memory ceiling as a share of physical RAM, so one unit file suits a 512 MB
|
||||
# Pi Zero 2 W and an 8 GB Pi 5 alike. This is a backstop, not a tuning knob: it
|
||||
# turns "the board runs out of memory, stops being able to fork, and takes sshd
|
||||
|
||||
@@ -21,14 +21,32 @@ from flask import Flask
|
||||
# Every manager attribute the blueprint reads. Anything missing here keeps
|
||||
# whatever a previously-run test left on the singleton.
|
||||
API_V3_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
'health_tracker', 'resource_monitor',
|
||||
)
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
def mock_plugin_catalog():
|
||||
"""A MagicMock shaped like PluginCatalog, and only like it.
|
||||
|
||||
``spec`` makes anything a PluginCatalog lacks raise AttributeError --
|
||||
``get_plugin``, ``load_plugin``, ``plugins`` -- so a route that reached
|
||||
for a plugin instance in the web process fails the test that drives it
|
||||
instead of quietly calling a mock. The instance attributes the spec
|
||||
cannot see are set explicitly.
|
||||
"""
|
||||
from src.plugin_system.plugin_catalog import PluginCatalog
|
||||
catalog = MagicMock(spec=PluginCatalog)
|
||||
for name in ('plugins_dir', 'config_manager', 'schema_manager',
|
||||
'plugin_manifests', 'plugin_directories'):
|
||||
setattr(catalog, name, MagicMock())
|
||||
return catalog
|
||||
|
||||
|
||||
def build_app(blueprint):
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
@@ -52,7 +70,8 @@ def api_v3_module():
|
||||
for name in API_V3_MANAGER_ATTRS
|
||||
}
|
||||
for name in API_V3_MANAGER_ATTRS:
|
||||
setattr(module.api_v3, name, MagicMock())
|
||||
setattr(module.api_v3, name,
|
||||
mock_plugin_catalog() if name == 'plugin_catalog' else MagicMock())
|
||||
# Default to the direct path; queue tests opt in explicitly.
|
||||
module.api_v3.operation_queue = None
|
||||
|
||||
|
||||
+64
-1
@@ -16,7 +16,54 @@ if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
|
||||
class _DisarmStartupReconciliation:
|
||||
"""Import hook: every ``web_interface.app`` this process builds starts disarmed.
|
||||
|
||||
app.py wires itself to the checkout's real config/config.json and
|
||||
plugin-repos/ at import, and its before_request hook launches startup
|
||||
reconciliation on the first request any test sends. Reconciliation
|
||||
reinstalls every configured plugin missing on disk from the live store,
|
||||
so a full run downloaded basketball-scoreboard, calendar,
|
||||
football-scoreboard, leaderboard and ledmatrix-stocks into the real
|
||||
plugin-repos/ (not gitignored), minutes in, from a daemon thread no test
|
||||
waits on. Setting ``_reconciliation_started`` is the app's own run-once
|
||||
latch; doing it as the module finishes executing covers fixtures that
|
||||
import the app lazily and send a request at once, and ``importlib.reload``.
|
||||
StateReconciliation itself stays fully testable.
|
||||
"""
|
||||
|
||||
_MODULE = "web_interface.app"
|
||||
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
if fullname != self._MODULE:
|
||||
return None
|
||||
import importlib.machinery
|
||||
spec = importlib.machinery.PathFinder.find_spec(fullname, path, target)
|
||||
if spec is None or spec.loader is None:
|
||||
return spec
|
||||
exec_module = spec.loader.exec_module
|
||||
|
||||
def exec_disarmed(module):
|
||||
exec_module(module)
|
||||
module._reconciliation_started = True
|
||||
|
||||
spec.loader.exec_module = exec_disarmed
|
||||
return spec
|
||||
|
||||
|
||||
_DISARM_HOOK = _DisarmStartupReconciliation()
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
sys.meta_path.insert(0, _DISARM_HOOK)
|
||||
app_module = sys.modules.get(_DisarmStartupReconciliation._MODULE)
|
||||
if app_module is not None:
|
||||
app_module._reconciliation_started = True
|
||||
|
||||
_point_emulator_at_raw_adapter(config)
|
||||
|
||||
|
||||
def _point_emulator_at_raw_adapter(config):
|
||||
"""Point the emulator at a per-process config that binds no socket.
|
||||
|
||||
Six test modules set EMULATOR=true and build a real DisplayManager. The
|
||||
@@ -63,7 +110,9 @@ def pytest_configure(config):
|
||||
|
||||
|
||||
def pytest_unconfigure(config):
|
||||
"""Remove the throwaway emulator config written by pytest_configure."""
|
||||
"""Undo pytest_configure: the import hook and the throwaway emulator config."""
|
||||
if _DISARM_HOOK in sys.meta_path:
|
||||
sys.meta_path.remove(_DISARM_HOOK)
|
||||
tmp_dir = getattr(config, "_ledmatrix_emulator_tmp", None)
|
||||
if tmp_dir is not None:
|
||||
import shutil
|
||||
@@ -252,6 +301,20 @@ def emulator_mode(monkeypatch):
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic_display_watchdog(monkeypatch):
|
||||
"""Keep the render loop's watchdog off the host.
|
||||
|
||||
Every test that runs DisplayController.run() arms the process-wide
|
||||
watchdog, which would ping a real $NOTIFY_SOCKET and write a heartbeat
|
||||
into /run/ledmatrix -- the live display's, when the suite runs as root on
|
||||
a device. Each test gets a fresh instance that does neither.
|
||||
"""
|
||||
from src import display_watchdog
|
||||
monkeypatch.setattr(display_watchdog, 'watchdog',
|
||||
display_watchdog.RenderWatchdog(environ={}, heartbeat_dir=None))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_logging():
|
||||
"""Reset logging configuration before each test."""
|
||||
|
||||
Vendored
+67
@@ -1,4 +1,54 @@
|
||||
[
|
||||
[
|
||||
"/api/v3/auth/disable",
|
||||
"api_v3.disable_web_login",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/auth/password",
|
||||
"api_v3.set_web_password",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/auth/status",
|
||||
"api_v3.get_web_auth_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/auth/tokens",
|
||||
"api_v3.create_api_token",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/auth/tokens",
|
||||
"api_v3.list_api_tokens",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/auth/tokens/<token_id>",
|
||||
"api_v3.revoke_api_token",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/backup/<path:filename>",
|
||||
"api_v3.backup_delete",
|
||||
@@ -853,6 +903,23 @@
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/update-channel",
|
||||
"api_v3.get_update_channel",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/update-channel",
|
||||
"api_v3.set_update_channel",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/version",
|
||||
"api_v3.get_system_version",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the stub (test fixture only)."
|
||||
},
|
||||
"cards": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 32,
|
||||
"default": 6,
|
||||
"description": "How many keyed cards to return."
|
||||
},
|
||||
"card_width": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 512,
|
||||
"default": 0,
|
||||
"description": "Card width in px; 0 sizes cards from the render width."
|
||||
},
|
||||
"map_hz": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 30,
|
||||
"default": 4,
|
||||
"description": "refresh_hz of the full-width animated element; 0 leaves it out."
|
||||
},
|
||||
"dot_speed": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 200,
|
||||
"default": 20,
|
||||
"description": "How fast the animated element's dot moves, in px per second."
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Vegas live-element stub.
|
||||
|
||||
A fixture, not a product: it exercises every part of the live-element contract
|
||||
(src/plugin_system/vegas_elements.py) with content whose changes are easy to
|
||||
see and to assert on, and nothing else -- no fonts, no network.
|
||||
|
||||
- ``card:<n>`` -- fixed-width cards. Each draws the bits of ``tick + n`` as
|
||||
lit bars, so every update() changes every card's pixels but never its width.
|
||||
- ``sep`` -- a separator, ``live=False``: placed and trimmed like plain content.
|
||||
- ``map`` -- one full-render-width element with ``refresh_hz``: a dot that
|
||||
moves across it with time, drawn by redraw_vegas_element() from state
|
||||
published in a single attribute store, so it is safe to call without the
|
||||
plugin's lock.
|
||||
|
||||
update() only advances the tick. display() draws the tick's bars full screen
|
||||
so the plugin also passes the ordinary rendering harness.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0: the hooks are never called
|
||||
VegasElement = None
|
||||
|
||||
_COLOURS = [(255, 64, 64), (64, 255, 64), (64, 128, 255), (255, 200, 0),
|
||||
(255, 64, 255), (0, 220, 220)]
|
||||
|
||||
|
||||
class VegasLiveStub(BasePlugin):
|
||||
"""Keyed cards, a separator and an animated element for the Vegas ticker."""
|
||||
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.tick = 0
|
||||
# Everything the lock-free redraw reads, published in one store.
|
||||
self._snapshot: Tuple[int, float] = (0, time.monotonic())
|
||||
|
||||
# -- data -------------------------------------------------------------
|
||||
|
||||
def update(self) -> None:
|
||||
self.tick += 1
|
||||
self._snapshot = (self.tick, time.monotonic())
|
||||
|
||||
# -- drawing ----------------------------------------------------------
|
||||
|
||||
def _bars(self, image: Image.Image, value: int, colour, box) -> None:
|
||||
x0, y0, x1, y1 = box
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([x0, y0, x1, y1], outline=colour)
|
||||
bits = 8
|
||||
span = max(1, (x1 - x0 - 2) // bits)
|
||||
for bit in range(bits):
|
||||
if value >> bit & 1:
|
||||
left = x0 + 1 + bit * span
|
||||
draw.rectangle([left, y0 + 2, left + max(0, span - 2), y1 - 2],
|
||||
fill=colour)
|
||||
|
||||
def _card_width(self) -> int:
|
||||
configured = int(self.config.get('card_width', 0) or 0)
|
||||
if configured > 0:
|
||||
return configured
|
||||
return max(24, min(64, self.get_vegas_render_width() // 4))
|
||||
|
||||
def _card(self, index: int, tick: int) -> Image.Image:
|
||||
width, height = self._card_width(), self.display_manager.height
|
||||
image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
self._bars(image, tick + index, _COLOURS[index % len(_COLOURS)],
|
||||
(0, 0, width - 1, height - 1))
|
||||
return image
|
||||
|
||||
def _map(self, width: int, height: int, at: float) -> Image.Image:
|
||||
tick, _published = self._snapshot
|
||||
image = Image.new('RGB', (width, height), (0, 0, 16))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([0, 0, width - 1, height - 1], outline=(40, 40, 80))
|
||||
speed = float(self.config.get('dot_speed', 20) or 0)
|
||||
x = int(at * speed) % max(1, width - 4) + 2
|
||||
y = 2 + tick % max(1, height - 4)
|
||||
draw.rectangle([x - 1, y - 1, x + 1, y + 1], fill=(255, 255, 255))
|
||||
return image
|
||||
|
||||
def _dot_column(self, width: int, at: float) -> int:
|
||||
speed = float(self.config.get('dot_speed', 20) or 0)
|
||||
return int(at * speed) % max(1, width - 4) + 2
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
width, height = self.display_manager.width, self.display_manager.height
|
||||
self.display_manager.clear()
|
||||
self._bars(self.display_manager.image, self.tick, _COLOURS[0],
|
||||
(0, 0, width - 1, height - 1))
|
||||
self.display_manager.update_display()
|
||||
return True
|
||||
|
||||
# -- Vegas ------------------------------------------------------------
|
||||
|
||||
def get_vegas_content(self) -> Optional[List[Image.Image]]:
|
||||
cards = int(self.config.get('cards', 6))
|
||||
return [self._card(i, self.tick) for i in range(cards)] or None
|
||||
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
tick = self.tick
|
||||
elements = []
|
||||
for i in range(int(self.config.get('cards', 6))):
|
||||
elements.append(VegasElement(
|
||||
key=f"card:{i}", image=self._card(i, tick),
|
||||
version=(tick, i, self._card_width())))
|
||||
if i == 0:
|
||||
separator = Image.new('RGB', (4, self.display_manager.height), (0, 0, 0))
|
||||
ImageDraw.Draw(separator).rectangle(
|
||||
[1, 0, 2, self.display_manager.height - 1], fill=(90, 90, 90))
|
||||
elements.append(VegasElement(key="sep", image=separator, live=False))
|
||||
hz = float(self.config.get('map_hz', 4) or 0)
|
||||
if hz > 0:
|
||||
width, height = self.get_vegas_render_width(), self.display_manager.height
|
||||
now = time.monotonic()
|
||||
elements.append(VegasElement(
|
||||
key="map", image=self._map(width, height, now),
|
||||
version=(tick, width, self._dot_column(width, now)),
|
||||
refresh_hz=hz))
|
||||
return elements
|
||||
|
||||
def redraw_vegas_element(self, key, width, height, at):
|
||||
if key != "map":
|
||||
return None
|
||||
return self._map(width, height, at)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "vegas-live-stub",
|
||||
"name": "Vegas Live Stub",
|
||||
"version": "1.0.0",
|
||||
"description": "Test fixture for live Vegas elements: keyed cards whose content changes on every update, a separator, and a full-width element that animates with time. Drives the live-element tests and the hardware soaks. Not installable from the store and never shipped to devices.",
|
||||
"author": "LEDMatrix",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "VegasLiveStub",
|
||||
"display_modes": ["vegas-live-stub"],
|
||||
"update_interval": 2,
|
||||
"min_ledmatrix_version": "2.0.0",
|
||||
"compatible_versions": [">=2.0.0"]
|
||||
}
|
||||
@@ -50,6 +50,7 @@ server has none.
|
||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
|
||||
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
|
||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_style_editor_layout_leaf_columns.js',
|
||||
'unit/test_style_editor_layout_leaf_collision.js',
|
||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
|
||||
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js'];
|
||||
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
|
||||
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js'];
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// The "restart the display" banner is raised by the server's answer, not by
|
||||
// which URL was called.
|
||||
//
|
||||
// app.js used to show it after any successful POST to /api/v3/config/main and
|
||||
// nothing else. A plugin update, install or uninstall that the running display
|
||||
// cannot pick up live now answers `restart_required: true` (with the banner's
|
||||
// wording in `restart_message`), and so does a main-config save. The htmx
|
||||
// after-request handler and window.noteRestartRequired must both follow the
|
||||
// flag. Runs the shipped app.js in a vm with a minimal fake DOM -- no jsdom and
|
||||
// no server needed, so it runs under test/test_js_unit_suites.py too.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function load() {
|
||||
const handlers = {};
|
||||
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
|
||||
const banner = { style: { display: 'none' } };
|
||||
const text = { dataset: {}, textContent: ' Configuration saved — restart the display to apply the changes ' };
|
||||
const store = {};
|
||||
const document = {
|
||||
body: { addEventListener: listen('body') },
|
||||
addEventListener: listen('document'),
|
||||
getElementById: (id) => ({ 'restart-pending-banner': banner, 'restart-pending-text': text })[id] || null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const window = {
|
||||
addEventListener: listen('window'),
|
||||
getApp: () => null,
|
||||
};
|
||||
const context = {
|
||||
window, document, console,
|
||||
sessionStorage: {
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
},
|
||||
showNotification: () => {},
|
||||
setTimeout: () => 0,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
||||
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
|
||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
|
||||
const elt = { closest: () => (reportsItself ? {} : null) };
|
||||
afterRequest({
|
||||
target: { closest: () => null },
|
||||
detail: {
|
||||
xhr: { status, responseText: body === undefined ? '' : (typeof body === 'string' ? body : JSON.stringify(body)) },
|
||||
elt,
|
||||
requestConfig: { verb: 'post', path: reqPath },
|
||||
},
|
||||
});
|
||||
};
|
||||
return { window, banner, text, store, fire, afterRequest };
|
||||
}
|
||||
|
||||
console.log('\nwindow.noteRestartRequired');
|
||||
{
|
||||
const t = load();
|
||||
ok('app.js defines it', typeof t.window.noteRestartRequired === 'function');
|
||||
ok('no flag, no banner', t.window.noteRestartRequired({ status: 'success' }) === false
|
||||
&& t.banner.style.display === 'none');
|
||||
ok('a missing body is ignored', t.window.noteRestartRequired(null) === false);
|
||||
ok('restart_required: false is not a request',
|
||||
t.window.noteRestartRequired({ restart_required: false }) === false && t.banner.style.display === 'none');
|
||||
ok('restart_required: true shows the banner',
|
||||
t.window.noteRestartRequired({ restart_required: true }) === true && t.banner.style.display === 'block');
|
||||
ok('without a message the template wording stays',
|
||||
t.text.textContent === 'Configuration saved — restart the display to apply the changes', t.text.textContent);
|
||||
t.window.noteRestartRequired({ restart_required: true, restart_message: 'Plugin updated — restart the display to run the new version' });
|
||||
ok('restart_message becomes the wording',
|
||||
t.text.textContent === 'Plugin updated — restart the display to run the new version', t.text.textContent);
|
||||
ok('and survives a reload with the flag', t.store['ledmatrix-restart-pending'] === '1'
|
||||
&& t.store['ledmatrix-restart-pending-text'] === 'Plugin updated — restart the display to run the new version');
|
||||
}
|
||||
|
||||
console.log('\nhtmx after-request follows the flag, not the URL');
|
||||
{
|
||||
const t = load();
|
||||
ok('the handler is registered', typeof t.afterRequest === 'function');
|
||||
t.fire({ path: '/api/v3/config/main', body: { status: 'success', message: 'Configuration saved successfully' } });
|
||||
ok('a /config/main answer without the flag raises nothing', t.banner.style.display === 'none');
|
||||
t.fire({ path: '/api/v3/plugins/update', status: 500, body: { status: 'error', restart_required: true } });
|
||||
ok('an error answer never raises it', t.banner.style.display === 'none');
|
||||
t.fire({ path: '/api/v3/whatever', body: '<html>not json' });
|
||||
ok('a non-JSON answer is ignored', t.banner.style.display === 'none');
|
||||
// The main-config forms report their own result (hx-on after-request), so
|
||||
// the flag must be read even when the toast is not this handler's to show.
|
||||
t.fire({ path: '/api/v3/config/main', reportsItself: true,
|
||||
body: { status: 'success', message: 'Configuration saved successfully', restart_required: true } });
|
||||
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,105 @@
|
||||
// The store card shows the registry fields added after 3.7.0 -- the commit
|
||||
// that introduced the listed version and a warning when the plugin needs a
|
||||
// newer core -- and still renders a card from an older registry that has
|
||||
// neither. isStorePluginInstalled also answers to an entry's `aliases`.
|
||||
//
|
||||
// Rendered with the shipped functions (extracted from plugins_manager.js).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
|
||||
|
||||
function extract(opener) {
|
||||
const start = SRC.indexOf(opener);
|
||||
if (start < 0) { console.error('FAIL: cannot find ' + JSON.stringify(opener)); process.exit(1); }
|
||||
let depth = 0;
|
||||
for (let j = SRC.indexOf('{', start); j < SRC.length; j++) {
|
||||
if (SRC[j] === '{') depth++;
|
||||
else if (SRC[j] === '}' && --depth === 0) return SRC.slice(start, j + 1);
|
||||
}
|
||||
console.error('FAIL: unbalanced braces after ' + opener); process.exit(1);
|
||||
}
|
||||
|
||||
class FakeEl {
|
||||
constructor() { this.innerHTML = ''; this.value = ''; this.textContent = ''; }
|
||||
}
|
||||
class TextEl {
|
||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||
get innerHTML() {
|
||||
return (this._t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
const els = {};
|
||||
global.document = {
|
||||
getElementById: id => (els[id] ||= new FakeEl()),
|
||||
createElement: () => new TextEl(),
|
||||
};
|
||||
global.window = global;
|
||||
require('../led_escape').install(window);
|
||||
global.pluginLog = () => {};
|
||||
global.isNewPlugin = () => false;
|
||||
global.formatDate = () => '';
|
||||
global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html; };
|
||||
global.installedPlugins = [];
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval([
|
||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||
'function isStorePluginInstalled(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
||||
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
||||
+ '\nglobal.isStorePluginInstalled = isStorePluginInstalled;');
|
||||
|
||||
function render(plugin) {
|
||||
renderPluginStore([plugin]);
|
||||
return els['plugin-store-grid'].innerHTML;
|
||||
}
|
||||
|
||||
const SHA = '843588025a81197056f8d96779ccb2be19337ab8';
|
||||
const base = {
|
||||
id: 'weather', name: 'Weather', author: 'ChuckBuilds', category: 'weather',
|
||||
description: 'Forecasts', version: '2.1.0',
|
||||
repo: 'https://github.com/ChuckBuilds/ledmatrix-plugins', plugin_path: 'plugins/ledmatrix-weather',
|
||||
};
|
||||
|
||||
console.log('\n1. a registry with the new fields');
|
||||
let html = render({ ...base, commit: SHA, ledmatrix_min_version: '3.7.0', aliases: ['ledmatrix-weather'] });
|
||||
ok('shows the short commit', html.includes('>8435880<'), html.match(/v2\.1\.0[^\n]*/));
|
||||
ok('links it to the plugin at that commit',
|
||||
html.includes(`href="https://github.com/ChuckBuilds/ledmatrix-plugins/tree/${SHA}/plugins/ledmatrix-weather"`));
|
||||
ok('no compatibility warning when the core is new enough', !html.includes('Needs LEDMatrix'));
|
||||
|
||||
console.log('\n2. a plugin this core cannot run');
|
||||
html = render({ ...base, ledmatrix_min_version: '9.0.0',
|
||||
incompatible_reason: 'Weather requires LEDMatrix 9.0.0 or newer' });
|
||||
ok('warns with the floor', html.includes('Needs LEDMatrix 9.0.0+'));
|
||||
ok('and the reason as its title', html.includes('title="Weather requires LEDMatrix 9.0.0 or newer"'));
|
||||
|
||||
console.log('\n3. an older registry: no commit, floor or aliases');
|
||||
html = render({ ...base });
|
||||
ok('still renders the card and its version', html.includes('class="plugin-card"') && html.includes('v2.1.0'));
|
||||
ok('shows no commit and no warning', !html.includes('font-mono') && !html.includes('Needs LEDMatrix'));
|
||||
|
||||
console.log('\n4. a commit value that is not a SHA is not rendered');
|
||||
html = render({ ...base, commit: 'javascript:alert(1)' });
|
||||
ok('dropped', !html.includes('javascript:') && !html.includes('font-mono'));
|
||||
html = render({ ...base, commit: SHA, repo: 'javascript:alert(1)' });
|
||||
ok('without a web repo link it is plain text, not a link',
|
||||
html.includes('>8435880<') && !html.includes('/tree/'));
|
||||
|
||||
console.log('\n5. installed under an alias');
|
||||
global.installedPlugins = [{ id: 'ledmatrix-weather' }];
|
||||
ok('aliases count as installed',
|
||||
isStorePluginInstalled({ id: 'weather', plugin_path: '', aliases: ['ledmatrix-weather'] }));
|
||||
ok('plugin_path still does (older registry)',
|
||||
isStorePluginInstalled({ id: 'weather', plugin_path: 'plugins/ledmatrix-weather' }));
|
||||
ok('a different plugin is not installed', !isStorePluginInstalled({ id: 'stocks', plugin_path: '' }));
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -229,6 +229,78 @@ const noSleep = { sleep: async () => {} };
|
||||
allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop);
|
||||
}
|
||||
|
||||
console.log('\nrestart banner: driven by the server\'s restart_required');
|
||||
{
|
||||
const body = (restart_required, restart_message) => ({
|
||||
success: true,
|
||||
result: { status: 'success', data: { update_status: 'updated' }, restart_required, restart_message },
|
||||
});
|
||||
const needed = body(true, 'Plugin updated — restart the display to run the new version');
|
||||
ok('an update the display is running asks for the banner, with its wording',
|
||||
Manager.restartRequest([body(false), needed, body(true, 'second')]) === needed.result);
|
||||
ok('updates the display does not run need no restart',
|
||||
Manager.restartRequest([body(false), body(false)]) === null);
|
||||
ok('a failed request never raises the banner',
|
||||
Manager.restartRequest([{ success: false, error: { restart_required: true } }]) === null);
|
||||
ok('an older server that sends no flag raises nothing',
|
||||
Manager.restartRequest([{ success: true, result: { status: 'success' } }]) === null);
|
||||
ok('no results, no banner', Manager.restartRequest(undefined) === null);
|
||||
|
||||
// The first request's answer was lost; the re-sent one finds nothing to do.
|
||||
const lost = (enabled, update_status = 'up_to_date') => ({
|
||||
pluginId: 'clock', success: true, afterLostAnswer: true, enabled,
|
||||
result: { status: 'success', data: { update_status }, restart_required: false },
|
||||
});
|
||||
const maybe = Manager.restartRequest([body(false), lost(true)]);
|
||||
ok('an enabled plugin up to date after a lost answer may have been updated: banner',
|
||||
maybe && maybe.restart_required === true && /clock/.test(maybe.restart_message), maybe);
|
||||
ok('...but an explicit answer still wins, with its wording',
|
||||
Manager.restartRequest([lost(true), needed]) === needed.result);
|
||||
ok('a disabled one needs no restart (enabling it loads it)',
|
||||
Manager.restartRequest([lost(false)]) === null);
|
||||
ok('nor does one that was not retried',
|
||||
Manager.restartRequest([{ ...lost(true), afterLostAnswer: undefined }]) === null);
|
||||
}
|
||||
|
||||
console.log('\nupdateAll keeps what the banner needs');
|
||||
{
|
||||
// ledmatrix-flights (enabled) loses its first answer, then is up to date.
|
||||
const api = fakeApi({
|
||||
'ledmatrix-flights': (n) => {
|
||||
if (n === 1) throw netErr();
|
||||
return { status: 'success', data: { update_status: 'up_to_date' }, restart_required: false };
|
||||
},
|
||||
});
|
||||
setup(api, { windowList: INSTALLED });
|
||||
const results = await Manager.updateAll(null, noSleep);
|
||||
const flights = results.find(r => r.pluginId === 'ledmatrix-flights');
|
||||
ok('a retried entry is marked, with the plugin\'s enabled flag',
|
||||
flights.afterLostAnswer === true && flights.enabled === true, flights);
|
||||
ok('an entry answered first time is not marked',
|
||||
results.filter(r => r.afterLostAnswer).length === 1, results);
|
||||
ok('...so the run asks for a restart', Manager.restartRequest(results) !== null);
|
||||
}
|
||||
{
|
||||
const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true };
|
||||
const api = fakeApi({ 'ledmatrix-flights': () => answer });
|
||||
setup(api, { stateList: INSTALLED });
|
||||
window.PluginStateManager.loadInstalledPlugins = async () => { throw new Error('refresh failed'); };
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
let results;
|
||||
try {
|
||||
results = await Manager.updateAll(null, noSleep);
|
||||
} catch (e) {
|
||||
results = e;
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
ok('a failed list refresh still returns the results',
|
||||
Array.isArray(results) && results.length === EXPECTED.length, String(results));
|
||||
ok('...with the restart flag intact',
|
||||
Array.isArray(results) && Manager.restartRequest(results) === answer);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -52,7 +52,7 @@ class TestPluginToggle:
|
||||
class TestOnDemandStart:
|
||||
@pytest.fixture
|
||||
def service(self, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_manager = None
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
return_value={"active": True}), \
|
||||
|
||||
@@ -45,7 +45,7 @@ VALID_CREDENTIALS = {
|
||||
def plugin_dir(tmp_path, api_v3_module):
|
||||
directory = tmp_path / "plugins" / "calendar"
|
||||
directory.mkdir(parents=True)
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory)
|
||||
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(directory)
|
||||
return directory
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestRequestValidation:
|
||||
assert not (plugin_dir / "credentials.json").exists()
|
||||
|
||||
def test_missing_plugin_directory_is_a_404(self, api_v3_client, api_v3_module, tmp_path):
|
||||
api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(
|
||||
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(
|
||||
tmp_path / "not-installed")
|
||||
assert upload(api_v3_client, VALID_CREDENTIALS).status_code == 404
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ def display_page(monkeypatch):
|
||||
config_manager.get_config_path.return_value = 'config/config.json'
|
||||
config_manager.get_secrets_path.return_value = 'config/config_secrets.json'
|
||||
monkeypatch.setattr(pv.pages_v3, 'config_manager', config_manager, raising=False)
|
||||
monkeypatch.setattr(pv.pages_v3, 'plugin_manager', MagicMock(plugins={}), raising=False)
|
||||
monkeypatch.setattr(pv.pages_v3, 'plugin_catalog', MagicMock(), raising=False)
|
||||
app.register_blueprint(pv.pages_v3, url_prefix='/v3')
|
||||
response = app.test_client().get('/v3/partials/display')
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -34,7 +34,7 @@ CONFIG = {
|
||||
|
||||
@pytest.fixture
|
||||
def client(api_v3_module, api_v3_client):
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm = api_v3_module.api_v3.plugin_catalog
|
||||
pm.plugin_manifests = MANIFESTS
|
||||
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
||||
pm.get_plugin_display_modes = MagicMock(
|
||||
@@ -85,17 +85,17 @@ class TestItWorksForACallerThatNeverOpensTheDashboard:
|
||||
"""Discovery is lazy and normally runs because a person loaded the
|
||||
dashboard; a bridge or script would otherwise get an empty list."""
|
||||
client.get('/api/v3/display/modes')
|
||||
api_v3_module.api_v3.plugin_manager.discover_plugins.assert_called_once()
|
||||
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once()
|
||||
|
||||
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
|
||||
api_v3_module.api_v3.plugin_manager = None
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
response = api_v3_client.get('/api/v3/display/modes')
|
||||
assert response.status_code == 500
|
||||
assert response.get_json()['status'] == 'error'
|
||||
|
||||
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
|
||||
"""Its mode is its own id -- the same fallback the controller uses."""
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm = api_v3_module.api_v3.plugin_catalog
|
||||
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
|
||||
pm.get_plugin_display_modes = MagicMock(return_value=[])
|
||||
api_v3_module.api_v3.config_manager.load_config = MagicMock(
|
||||
@@ -116,7 +116,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_bad_section(self, api_v3_module, api_v3_client):
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm = api_v3_module.api_v3.plugin_catalog
|
||||
pm.plugin_manifests = MANIFESTS
|
||||
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
||||
pm.get_plugin_display_modes = MagicMock(
|
||||
@@ -143,7 +143,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
||||
self, api_v3_module, api_v3_client):
|
||||
"""describe_exception, per test_web_error_detail's contract -- an
|
||||
opaque "see logs for details" is what that test exists to prevent."""
|
||||
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
|
||||
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
||||
side_effect=RuntimeError("disk is gone"))
|
||||
resp = api_v3_client.get('/api/v3/display/modes')
|
||||
assert resp.status_code == 500
|
||||
@@ -151,7 +151,7 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
||||
|
||||
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
||||
"""describe_exception is what makes returning detail safe."""
|
||||
api_v3_module.api_v3.plugin_manager.discover_plugins = MagicMock(
|
||||
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
||||
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
||||
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
||||
assert 'SEC123' not in json.dumps(body)
|
||||
|
||||
@@ -5,8 +5,10 @@ PluginManager does not have; a hasattr guard turned that into a permanent 0.
|
||||
Each check that fails answers "see logs for details", so it has to log.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -25,6 +27,28 @@ def _no_systemctl(monkeypatch):
|
||||
lambda: {"active": True})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def heartbeat(tmp_path, monkeypatch):
|
||||
"""The display's heartbeat file, somewhere private; absent until written."""
|
||||
from src import display_watchdog
|
||||
path = tmp_path / "display-heartbeat.json"
|
||||
monkeypatch.setattr(display_watchdog, "HEARTBEAT_PATH", str(path))
|
||||
|
||||
def write(age):
|
||||
path.write_text(json.dumps({"pid": 1, "mono": time.monotonic() - age,
|
||||
"wall": time.time() - age}))
|
||||
return write
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_preview(tmp_path, monkeypatch):
|
||||
"""A just-written preview frame, so only the heartbeat decides the verdict."""
|
||||
from web_interface import display_preview
|
||||
snapshot = tmp_path / "preview.png"
|
||||
snapshot.write_bytes(b"png")
|
||||
monkeypatch.setattr(display_preview, "SNAPSHOT_PATH", str(snapshot))
|
||||
|
||||
|
||||
def _checks(client):
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
@@ -32,7 +56,7 @@ def _checks(client):
|
||||
|
||||
|
||||
def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_manager.plugin_manifests = {
|
||||
api_v3_module.api_v3.plugin_catalog.plugin_manifests = {
|
||||
"clock": {"id": "clock"}, "weather": {"id": "weather"}, "stocks": {"id": "stocks"},
|
||||
}
|
||||
|
||||
@@ -42,7 +66,7 @@ def test_plugin_count_is_the_number_of_discovered_plugins(api_v3_client, api_v3_
|
||||
|
||||
|
||||
def test_plugin_count_discovers_when_nothing_is_discovered_yet(api_v3_client, api_v3_module):
|
||||
pm = api_v3_module.api_v3.plugin_manager
|
||||
pm = api_v3_module.api_v3.plugin_catalog
|
||||
pm.plugin_manifests = {}
|
||||
|
||||
def discover():
|
||||
@@ -88,3 +112,59 @@ def test_a_failed_hardware_check_is_logged(api_v3_client, api_v3_module, caplog,
|
||||
assert check["status"] == "unknown"
|
||||
logged = [r for r in caplog.records if "snapshot" in r.getMessage()]
|
||||
assert logged and logged[0].exc_info
|
||||
|
||||
|
||||
# -- the display's render-loop heartbeat -----------------------------------------
|
||||
#
|
||||
# The preview frame's age said nothing about a panel frozen by a render thread
|
||||
# stuck in a plugin; the heartbeat is written by that thread itself.
|
||||
|
||||
def _health(client):
|
||||
response = client.get(URL)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
return response.get_json()["data"]
|
||||
|
||||
|
||||
def test_a_fresh_heartbeat_is_a_running_display_loop(api_v3_client, heartbeat, fresh_preview):
|
||||
heartbeat(age=3)
|
||||
|
||||
data = _health(api_v3_client)
|
||||
|
||||
assert data["checks"]["display_loop"]["status"] == "running"
|
||||
assert 2 <= data["checks"]["display_loop"]["heartbeat_age_seconds"] < 10
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
|
||||
def test_a_stale_heartbeat_is_a_stalled_display_loop(api_v3_client, heartbeat, fresh_preview):
|
||||
"""Service active, preview recent, and still the panel is frozen."""
|
||||
heartbeat(age=300)
|
||||
|
||||
data = _health(api_v3_client)
|
||||
|
||||
assert data["checks"]["display_loop"]["status"] == "stalled"
|
||||
assert data["checks"]["display_loop"]["heartbeat_age_seconds"] >= 299
|
||||
assert data["status"] == "degraded"
|
||||
|
||||
|
||||
def test_no_heartbeat_falls_back_to_the_older_checks(api_v3_client, fresh_preview):
|
||||
"""The dev server, the emulator, Windows, or a display without the
|
||||
feature: absence is not a failure, and the verdict is what it was."""
|
||||
data = _health(api_v3_client)
|
||||
|
||||
assert data["checks"]["display_loop"]["status"] == "not_reported"
|
||||
assert data["checks"]["hardware"]["status"] == "connected"
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
|
||||
def test_an_unreadable_heartbeat_is_reported_not_raised(api_v3_client, monkeypatch, caplog):
|
||||
from src import display_watchdog
|
||||
|
||||
def boom(_path):
|
||||
raise RuntimeError("bad heartbeat")
|
||||
monkeypatch.setattr(display_watchdog, "read_heartbeat", boom)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
check = _checks(api_v3_client)["display_loop"]
|
||||
|
||||
assert check["status"] == "unknown"
|
||||
assert any("heartbeat" in r.getMessage() for r in caplog.records)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user