mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-22 14:29:14 +00:00
Compare commits
92
Commits
08265c1135
...
v3.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3f184d81 | ||
|
|
869e36fb2f | ||
|
|
d01da3bd9f | ||
|
|
814c21de1c | ||
|
|
914bf2002f | ||
|
|
47afaaac2b | ||
|
|
6d1cbfb70b | ||
|
|
8e6d7c280f | ||
|
|
dac71afedc | ||
|
|
a6b3384032 | ||
|
|
11bf39cd66 | ||
|
|
bc60b41445 | ||
|
|
f9b1f87e8d | ||
|
|
7e580dc005 | ||
|
|
d51f7ada14 | ||
|
|
d1e821c625 | ||
|
|
69d408b321 | ||
|
|
92ac231138 | ||
|
|
772258f73e | ||
|
|
9ad7528c9b | ||
|
|
6b3028ad58 | ||
|
|
59997594ac | ||
|
|
f6367d63ae | ||
|
|
5137e86d16 | ||
|
|
a3d505384d | ||
|
|
bdb9a94033 | ||
|
|
0ab95586fb | ||
|
|
39f27d285d | ||
|
|
aba96e25b3 | ||
|
|
577f5501a6 | ||
|
|
dcd6e39c96 | ||
|
|
ad5bc4b819 | ||
|
|
fb3b293ace | ||
|
|
8da13f02f8 | ||
|
|
28bc79566f | ||
|
|
12f3790994 | ||
|
|
2df273ecfc | ||
|
|
a29c84208e | ||
|
|
1198615d19 | ||
|
|
f8e2e89edc | ||
|
|
4423ec33d5 | ||
|
|
26769ee37f | ||
|
|
968b953a51 | ||
|
|
e23f1f45d3 | ||
|
|
793b988d33 | ||
|
|
50258635a8 | ||
|
|
c9289e3a1d | ||
|
|
d12323e7f1 | ||
|
|
a0d3e64099 | ||
|
|
696acdbc7b | ||
|
|
91d15a8943 | ||
|
|
6bea1a7c21 | ||
|
|
0730d95200 | ||
|
|
32d637a446 | ||
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 | ||
|
|
300cdaa250 | ||
|
|
10da2f97fd | ||
|
|
92f9d06af9 | ||
|
|
6e361e05cc | ||
|
|
a686932c7e | ||
|
|
154525beb8 | ||
|
|
9b522d412c | ||
|
|
cbc540a679 | ||
|
|
4aeb0033e0 | ||
|
|
eae063700f | ||
|
|
af96bd5cb0 | ||
|
|
5e5979973e | ||
|
|
bdced206dc | ||
|
|
39e7f8cbe0 | ||
|
|
f90638a9ec | ||
|
|
333fd17d28 | ||
|
|
a4a55a23fc | ||
|
|
085fb93a87 | ||
|
|
c321b94085 | ||
|
|
5a1f121e6b | ||
|
|
6138a3cbef | ||
|
|
568cb6d77f | ||
|
|
6b74506695 | ||
|
|
5f29243e87 | ||
|
|
1fbe244e49 | ||
|
|
863e4a1ecd | ||
|
|
9c0c0dc851 | ||
|
|
71739d85d1 | ||
|
|
cc258aaffd | ||
|
|
fe5a3aa99d | ||
|
|
10e75b977f | ||
|
|
cf0a551f7b | ||
|
|
9018fa23cd | ||
|
|
0c5b9c57d3 | ||
|
|
9083df9f5c | ||
|
|
0901d044d3 |
@@ -72,4 +72,4 @@ jobs:
|
|||||||
--ignore=test/plugins \
|
--ignore=test/plugins \
|
||||||
--cov=src --cov=web_interface \
|
--cov=src --cov=web_interface \
|
||||||
--cov-report=term \
|
--cov-report=term \
|
||||||
--cov-fail-under=48
|
--cov-fail-under=52
|
||||||
|
|||||||
+40
@@ -5,6 +5,9 @@ __pycache__/
|
|||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
config/config_secrets.json
|
config/config_secrets.json
|
||||||
|
# Atomic writes leave these behind when a save or a test is interrupted;
|
||||||
|
# the suite drops several per run.
|
||||||
|
config/.config_secrets.json.tmp.*
|
||||||
config/config.json
|
config/config.json
|
||||||
config/config.json.backup
|
config/config.json.backup
|
||||||
config/wifi_config.json
|
config/wifi_config.json
|
||||||
@@ -49,3 +52,40 @@ config/backups/
|
|||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
skin_renders/
|
skin_renders/
|
||||||
|
|
||||||
|
# JS test deps (test/js)
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Team logos fetched at runtime.
|
||||||
|
#
|
||||||
|
# src/logo_downloader.py and LogoHelper write into assets/sports/<league>_logos/
|
||||||
|
# whenever a plugin meets a team whose logo is not on disk. Those directories are
|
||||||
|
# also tracked -- 209 NCAA logos and 153 soccer ones ship with the repo -- so
|
||||||
|
# every rig accumulated untracked files it was never meant to commit and
|
||||||
|
# `git status` was permanently dirty. That noise is not harmless: it trains
|
||||||
|
# everyone to ignore the one signal that says a checkout is not what you think
|
||||||
|
# it is, which is how a stale tree sat unnoticed on a rig until a restart
|
||||||
|
# surfaced four dead sports plugins.
|
||||||
|
#
|
||||||
|
# Ignoring a directory does not untrack what is already in it, so the logos that
|
||||||
|
# ship keep shipping. Only new downloads are hidden.
|
||||||
|
#
|
||||||
|
# Adding a logo on purpose is rare and deliberate -- the last time was #415, four
|
||||||
|
# named NCAA logos a plugin needed, and there has been no other in a year. Do it
|
||||||
|
# with an explicit override:
|
||||||
|
# git add -f assets/sports/ncaa_logos/DUKE.png
|
||||||
|
assets/sports/*_logos/
|
||||||
|
assets/stocks/ticker_icons/
|
||||||
|
assets/stocks/crypto_icons/
|
||||||
|
|
||||||
|
# Plugin operation state written at runtime.
|
||||||
|
#
|
||||||
|
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
|
||||||
|
# and data/operation_history.json as the web interface runs, into a directory that
|
||||||
|
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
|
||||||
|
# opened the web UI -- and every test run that constructs the app -- left three
|
||||||
|
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
||||||
|
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|||||||
+327
@@ -17,6 +17,333 @@ release that ships it.
|
|||||||
accepts both, but the store flags the old spelling as deprecated
|
accepts both, but the store flags the old spelling as deprecated
|
||||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## 3.4.0
|
||||||
|
|
||||||
|
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
|
||||||
|
|
||||||
|
- `BasePlugin.get_update_interval()` (#555) — return seconds to override the
|
||||||
|
manifest's `update_interval` at runtime (e.g. poll fast only while a game is
|
||||||
|
live), or `None` to keep it. Clamped to at least 5 seconds; a raising or
|
||||||
|
non-numeric return is ignored. Called every scheduling tick, so keep it
|
||||||
|
cheap. Older cores never call it. See `docs/PLUGIN_API_REFERENCE.md`.
|
||||||
|
- `src.common.scroll_config` (#523) — turns a plugin's scroll config into a
|
||||||
|
configured `ScrollHelper` in one place, replacing per-plugin resolution that
|
||||||
|
disagreed between tickers, and warns when a speed won't advance whole pixels
|
||||||
|
per panel refresh. Floor on 3.4.0 to import it.
|
||||||
|
- **Skins are marked unsupported.** No current scoreboard plugin builds on
|
||||||
|
`src.base_classes`, so the skin hook (`SportsCore._render_game`) never runs.
|
||||||
|
The web UI no longer shows the Visual Skin dropdown, the store hides and
|
||||||
|
refuses `"type": "skin"` entries, and `GET /api/v3/skins` reports
|
||||||
|
`"supported": false`. Saved `skin` config values still load and save.
|
||||||
|
`src/skin_system/` is unchanged.
|
||||||
|
- **Web preview size** now comes from `src/display_geometry.py`, the same
|
||||||
|
computation `DisplayManager` uses: double-sided setups preview one screen,
|
||||||
|
and a missing `chain_length` defaults to 2 everywhere (the Starlark magnify
|
||||||
|
default and the sync handshake used 1). The module is core-internal: plugins
|
||||||
|
keep reading `display_manager.width`/`height`.
|
||||||
|
- `src.common.font_layout` (#539, #565) — `load_truetype()` is
|
||||||
|
`ImageFont.truetype` with the layout engine pinned, so text lays out the same
|
||||||
|
whether or not the host's Pillow was built with libraqm; `crisp_size()` and
|
||||||
|
`FONT_PIXEL_GRID` give the size a bundled face renders on whole pixels at
|
||||||
|
(`sports_card` still re-exports them); `resolve_asset_path()` resolves
|
||||||
|
`assets/fonts/...` against the install root, not the working directory.
|
||||||
|
Floor on 3.4.0 to import it. Relatedly, `DisplayManager` now draws text
|
||||||
|
1-bit (#521), so golden images recorded against 3.3.x may need regenerating.
|
||||||
|
|
||||||
|
### Install and updates
|
||||||
|
|
||||||
|
**Weekly automatic updates (#581), off by default.** Switching on
|
||||||
|
*Automatically check for and install updates once a week* on the General tab
|
||||||
|
(or `first_time_install.sh --enable-auto-update` / `LEDMATRIX_AUTO_UPDATE=1`)
|
||||||
|
updates the core and then every installed plugin once a week, preferably 2–5 AM
|
||||||
|
local time. It follows the branch the checkout tracks — `main` on a standard
|
||||||
|
install — so a device gets whatever has merged there, not only tagged releases.
|
||||||
|
See `docs/WEB_INTERFACE_GUIDE.md`.
|
||||||
|
|
||||||
|
- The core step is skipped, with the reason shown, when the checkout has local
|
||||||
|
edits or commits, a rebase or merge is in progress, the branch has no
|
||||||
|
upstream, less than 300 MB is free, or that commit was already rolled back.
|
||||||
|
- After pulling, `ledmatrix-update-verify.service` restarts the services and
|
||||||
|
requires the web interface to answer and the display to stay up. If they
|
||||||
|
don't, or the new requirements fail to install, it resets to the previous
|
||||||
|
commit, reinstalls its requirements and restarts again. Anything but success
|
||||||
|
shows under the toggle and as a banner on Overview.
|
||||||
|
- Plugins update through the Plugin Store even when the core step is skipped,
|
||||||
|
fails or is rolled back. A plugin version whose `ledmatrix_min_version` is
|
||||||
|
above the device's core is held back, not installed. When the core did
|
||||||
|
update, plugins wait for its health check, and are left alone if that check
|
||||||
|
never reports or the rollback fails.
|
||||||
|
- No SSH is needed: switching the toggle on restarts the display service, which
|
||||||
|
installs the health-check units (`src/auto_update_setup.py`, core-internal
|
||||||
|
and not a plugin API).
|
||||||
|
|
||||||
|
Installer and service fixes:
|
||||||
|
|
||||||
|
- rgbmatrix builds on ARMv6 boards (Pi Zero, Pi 1); an existing checkout is
|
||||||
|
moved forward to the new pin and no longer left root-owned (#577).
|
||||||
|
- `first_time_install.sh` grants the web user `safe_pip_install.sh`, as
|
||||||
|
`configure_web_sudo.sh` already did, so plugin requirements install where
|
||||||
|
the display service can see them (#579).
|
||||||
|
- The web interface starts when `web_display_autostart` is missing or
|
||||||
|
`config.json` is unreadable; only an explicit `false` keeps it down (#556).
|
||||||
|
- Installers render every systemd unit from its `systemd/` template, so the
|
||||||
|
boot-time unit-drift warning can clear, non-root installs included (#547).
|
||||||
|
|
||||||
|
### Scrolling
|
||||||
|
|
||||||
|
- **Frame pacing (#523).** The loop waits only for the rest of each panel
|
||||||
|
refresh instead of a flat 8 ms: 44–46 fps → 100 fps, and slow frames 14% →
|
||||||
|
0.02%, on a 2×128×64 chain. Sub-pixel blending is off by default again (it
|
||||||
|
shimmered on pixel fonts; Vegas mode still opts in).
|
||||||
|
- **Whole-pixel steps (#545).** At a speed `scroll_config` can render in whole
|
||||||
|
pixels, every frame advances by exactly the same amount, removing about six
|
||||||
|
hitches a second. A loop that can't keep up now scrolls slightly slow rather
|
||||||
|
than jumping.
|
||||||
|
- The eight sports scoreboards scroll through `scroll_config` too (#542): the
|
||||||
|
default 50 px/s holds each frame for two refreshes instead of alternating
|
||||||
|
0 px and 1 px steps.
|
||||||
|
- **Frame stats ignore the pause between scrolls (#582).** The `Scroll frame
|
||||||
|
stats` log line counted the idle wait before each scroll as one frame,
|
||||||
|
inflating `max` and the stall rate. `docs/SCROLL_PERFORMANCE.md` now
|
||||||
|
describes the line actually logged.
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
- `FontManager` registers the bundled `tom_thumb` font, so plugins no longer
|
||||||
|
need a private loader (#534).
|
||||||
|
- The test harness's `set_scrolling_state()` accepts `frame_hold`, as
|
||||||
|
`DisplayManager`'s does (#534).
|
||||||
|
- A `display()` with nothing to draw should return `False`, the only value the
|
||||||
|
controller skips on; starlark-apps now does, rather than holding a black
|
||||||
|
panel (#534).
|
||||||
|
- Starlark apps may set `render_width`/`render_height` in their `config.json`
|
||||||
|
to render at their own canvas size instead of Pixlet's 64×32 (#552).
|
||||||
|
- `scripts/render_plugin.py --display-mode <mode>` renders one mode of a
|
||||||
|
multi-mode plugin; scoreboards previously rendered blank (#522).
|
||||||
|
- Scoreboards resolve their own directory under the real plugin loader
|
||||||
|
(declare `_PLUGIN_DIR`), so 4x6 text snaps to its 7px grid instead of
|
||||||
|
rendering a pixel narrow, and an unreadable schema is logged (#519, #520).
|
||||||
|
`DisplayManager` loads 4x6 on that grid too (#565).
|
||||||
|
- The 5x7 BDF face reports a real height, so rows stacked by
|
||||||
|
`get_font_height()` no longer overlap (#539).
|
||||||
|
- `LogoHelper` remembers a missing logo instead of warning every rotation
|
||||||
|
(#548), and the decoded sports logo cache is bounded (#559).
|
||||||
|
|
||||||
|
### Web interface
|
||||||
|
|
||||||
|
- Installed Plugins has search, All / Enabled / Disabled / Updates filters and
|
||||||
|
sort (#540).
|
||||||
|
- Hardened and polished per the September 2026 audit (#568): utility classes
|
||||||
|
such as `.hidden` actually exist, focus rings, labels and modal focus
|
||||||
|
trapping, dark theme throughout, no overflow at phone width, and background
|
||||||
|
streams pause when hidden, with first-load JS/CSS down from 1358 KB to 291 KB.
|
||||||
|
- WiFi Connect works from the LEDMatrix-Setup hotspot: the page is answered
|
||||||
|
before the hotspot drops, and reopening it shows why an attempt failed (#571).
|
||||||
|
- Pixlet install, the Starlark app store and app toggles work again (#535,
|
||||||
|
#537); the store uses the configured GitHub token and reports a rate limit
|
||||||
|
instead of drawing a blank grid (#541).
|
||||||
|
- Plugin config: geochron and news saves no longer always fail (#575), the page
|
||||||
|
survives stored values the schema outgrew (#578), the form uses the full page
|
||||||
|
height (#573), and file-manager widgets show the script's error (#574).
|
||||||
|
- The live status stream reports real disk usage and available memory (#558);
|
||||||
|
a system action refused for want of passwordless sudo says so and names
|
||||||
|
`configure_web_sudo.sh` (#560).
|
||||||
|
|
||||||
|
### Tools and security
|
||||||
|
|
||||||
|
- **CodeQL triage (#561):** 129 of 134 alerts fixed. Three were exploitable
|
||||||
|
path-handling flaws in the web interface and are closed; web UI escapers now
|
||||||
|
escape quotes, and URL fields refuse script schemes. Path checks share
|
||||||
|
`src/common/path_safety.py` (core-internal).
|
||||||
|
- **Home Assistant MQTT bridge** (`integrations/mqtt_bridge`, #538): mode
|
||||||
|
select, stop, power and brightness over MQTT Discovery.
|
||||||
|
- **Tools tab** manages the MQTT bridge and the Pixlet editor (#554); the
|
||||||
|
editor stays on loopback when `PIXLET_EDITOR_HOST` says so.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Updating a plugin whose directory is named for its manifest id (leaderboard,
|
||||||
|
music, stocks, weather) silently did nothing (#536).
|
||||||
|
- Plugin reconciliation no longer reports working plugins as stale or replaces
|
||||||
|
their config with a stub, and the Overview banner advises each case correctly
|
||||||
|
(#557).
|
||||||
|
- Two config saves in the same second no longer share one backup, so rollback
|
||||||
|
restores the version asked for (#564).
|
||||||
|
- On-demand: a second request is honoured without a restart (#534), a pinned
|
||||||
|
request stays on its mode, and restarting mid-session loads every plugin
|
||||||
|
again (#538).
|
||||||
|
- `/health` and `/display/current` report real state, and the preview no longer
|
||||||
|
freezes on a leftover snapshot temp file (#534).
|
||||||
|
|
||||||
|
### Per-element display customization
|
||||||
|
|
||||||
|
**Per-element display customization, and the last mile of it into the web UI.**
|
||||||
|
A user can set the font, size, colour, position, visibility and alignment of
|
||||||
|
individual display elements per plugin -- and, where a plugin has display
|
||||||
|
modes, separately per mode.
|
||||||
|
|
||||||
|
New public API a plugin may import via `src.*` (floor on the release that
|
||||||
|
ships this):
|
||||||
|
|
||||||
|
- `src.element_style.layout_offset(config, element, axis, default, mode)` and
|
||||||
|
`element_color(config, element, default, mode)` — the stateless reads the
|
||||||
|
scoreboard helpers share. There were three copies of the offset read and two
|
||||||
|
of the colour read; these are the one implementation, and they carry the
|
||||||
|
element-name aliasing and the per-mode lookup.
|
||||||
|
- `src.element_style.alias_keys(element)` — the names one element may be stored
|
||||||
|
under. The style block names elements `score_text` while the layout block
|
||||||
|
says `score`, and `records`/`record` and `status_text`/`status` split seven
|
||||||
|
to two across the published schemas. A lookup tries the exact name first, so
|
||||||
|
this is inert for a config that already matches.
|
||||||
|
- `src.element_style.element_visible(config, element, default, mode)`,
|
||||||
|
`element_align(...)` and `element_scale(...)` — the stateless reads for the
|
||||||
|
three knobs the resolver already understood but no draw path consumed, so an
|
||||||
|
element could be marked hidden in the web UI and still render.
|
||||||
|
- `SportsCoreSharedMixin._draw_text_with_outline(..., element="score_text")` —
|
||||||
|
naming the element resolves its colour by name and honours its visibility
|
||||||
|
toggle. Without a name the colour is inferred from font-object identity,
|
||||||
|
which cannot separate two elements sharing a face; that is the case every
|
||||||
|
bitmap font is in, because a `freetype.Face` cannot be re-instantiated, and
|
||||||
|
it is how a BDF-rendered element silently lost a configured colour. Shared
|
||||||
|
faces now resolve when exactly one sharer has a colour set.
|
||||||
|
- `LogoHelper.load_logo(..., scale=)` — applies a user's image scale, and keys
|
||||||
|
the cache on the scaled box so two elements scaled differently cannot be
|
||||||
|
served each other's image.
|
||||||
|
- `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font
|
||||||
|
can render at, or None for a scalable one. The web UI needs this to know
|
||||||
|
whether a size control can take effect at all.
|
||||||
|
- `ElementStyleResolver(config, defaults, mode=...)` plus `visible`, `align`
|
||||||
|
and `scale` on `ElementStyle`. The mode binds to the resolver rather than
|
||||||
|
being passed per call, so a plugin with one instance per mode makes every
|
||||||
|
existing lookup mode-aware by setting one class attribute.
|
||||||
|
- `BasePlugin.styles` / `styles_for(mode)` / `STYLE_MODE` — the accessor every
|
||||||
|
plugin inherits, so adopting this is no longer a guarded import plus schema
|
||||||
|
discovery plus resolver invalidation in each plugin.
|
||||||
|
- `SportsCoreSharedMixin._get_layout_offset` — promoted from the plugins'
|
||||||
|
bundled copies. Each still carries its own, which wins by MRO, so adopting
|
||||||
|
it is a deletion.
|
||||||
|
|
||||||
|
Schema and web UI:
|
||||||
|
|
||||||
|
- A `customization` block is now rendered by a composite style editor: one row
|
||||||
|
per element rather than nested accordions, with a tab per declared mode.
|
||||||
|
Plugins that hand-wrote their style blocks get it without a plugin release;
|
||||||
|
`x-style-elements` and `x-style-modes` declare it compactly.
|
||||||
|
- Font fields become a real picker rather than a hardcoded `enum`, so a font
|
||||||
|
the user uploads is selectable. Bitmap fonts taller than the element's
|
||||||
|
declared size ceiling are filtered out, because a bitmap font ignores
|
||||||
|
`font_size` and renders at its own size.
|
||||||
|
- `/static/plugin-widgets/<plugin>/<widget>.js` serves a plugin's own web-UI
|
||||||
|
widgets. The client half and the docs already existed; nothing served them.
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- A bitmap font asked for a size it has no strike for fell back to
|
||||||
|
*PressStart2P* — a different typeface — rather than to its own native size.
|
||||||
|
32 of the 35 shipped fonts are bitmap, so this was reachable for most font
|
||||||
|
choices.
|
||||||
|
- The plugin config form read `config_schema.json` directly while the save
|
||||||
|
route read it through `SchemaManager`. Only the latter expands a compact
|
||||||
|
`x-style-elements` declaration, so a plugin using that form had a
|
||||||
|
customization section that rendered as empty space.
|
||||||
|
- `unshare_element_fonts` rebuilt faces through bare `ImageFont.truetype`,
|
||||||
|
bypassing the layout engine `src/common/font_layout.py` pins. These were the
|
||||||
|
only two call sites in `src/` doing so.
|
||||||
|
- The form parser compared a schema type to a bare string, so a nullable field
|
||||||
|
(`["array", "null"]`) never had its indexed colour inputs recombined, and a
|
||||||
|
blank one became `[]` rather than null.
|
||||||
|
|
||||||
|
Removed:
|
||||||
|
|
||||||
|
- The Fonts tab's "Element Font Overrides" panel and its three endpoints. They
|
||||||
|
reported success and saved nothing, and the element keys the panel offered
|
||||||
|
(`nfl.live.score`, `clock.time`) are read by no plugin, so wiring them to the
|
||||||
|
real `FontManager` methods would still have changed nothing on the panel.
|
||||||
|
Per-element font choice now lives in each plugin's own config editor.
|
||||||
|
- "Detected Manager Fonts", which listed every installed font with a hardcoded
|
||||||
|
usage count.
|
||||||
|
- Two dead client-side config-form renderers in `app-shell.js` (~580 lines) and
|
||||||
|
the legacy `plugins/config_manager.js`, superseded by server-side rendering.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
Historical note: tags `v3.3.0` and `v3.3.1` both report `__version__` "3.3.0" and both ship `src/common/sports_shared.py`, so a "3.3.0" floor always means a core with `sports_shared`.
|
||||||
|
|
||||||
|
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||||
|
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||||
|
enforceable; this ships the last three shared modules and completes the store
|
||||||
|
gate, so a scoreboard can now floor here and carry no fallback at all.
|
||||||
|
|
||||||
|
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
|
||||||
|
|
||||||
|
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
|
||||||
|
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
|
||||||
|
explicitly, so nothing about the caller's class is assumed.
|
||||||
|
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
|
||||||
|
card geometry (centre gap, logo slot and cache key, layout offsets, the
|
||||||
|
upcoming-card date and time layout). No `__init__` and no state, so adoption
|
||||||
|
is one line on the class statement.
|
||||||
|
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
|
||||||
|
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
|
||||||
|
byte-identical in all eight lineage-sharing scoreboards.
|
||||||
|
|
||||||
|
All three sit under `src/common/` rather than `src/base_classes/sports/`,
|
||||||
|
deliberately: importing that package pulls `core.py` → `DisplayManager` →
|
||||||
|
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
|
||||||
|
hardware dependency.
|
||||||
|
|
||||||
|
Three notes for anyone adopting `sports_shared`:
|
||||||
|
|
||||||
|
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
|
||||||
|
mixin, so it reaches the host only when the mixin is listed **first** in the
|
||||||
|
bases. Reversing that order silently skips the host constructor.
|
||||||
|
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
|
||||||
|
which walks the MRO rather than reading `__file__` — `__file__` is now
|
||||||
|
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
|
||||||
|
`type(name, bases, ns)` reports `__module__` as `"abc"`.
|
||||||
|
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
|
||||||
|
across the eight but stay in the plugins. The first binds a per-plugin
|
||||||
|
timezone module whose contents differ; the other two are the abstract stubs
|
||||||
|
that define the sport.
|
||||||
|
|
||||||
|
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
|
||||||
|
gated `install_plugin`. This release gates the git-pull update path and
|
||||||
|
`install_from_url`, so a plugin whose floor the core cannot meet is refused
|
||||||
|
after download with no partial directory left behind.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
|
||||||
|
sent on 2026-08-04; every shared-data-source scoreboard returned
|
||||||
|
`403 Forbidden`. Requests now send an identifying token with a project URL —
|
||||||
|
browser-style strings and bare custom tokens are both refused.
|
||||||
|
- **Low-memory boards becoming unreachable under load** while still answering
|
||||||
|
pings and serving the web UI. Fetched payloads are released after delivery
|
||||||
|
rather than pinned on the completed request for up to an hour, malloc arenas
|
||||||
|
are capped, and log volume and SD writes are reduced. Available memory is now
|
||||||
|
reported in Tools diagnostics.
|
||||||
|
- **A failed logo download pinning a team to a grey box**: the placeholder was
|
||||||
|
written under the real logo's filename, so later attempts found a file and
|
||||||
|
reported success without retrying.
|
||||||
|
- **A plugin enabled but never loaded is retried** rather than staying absent
|
||||||
|
with `error = null`.
|
||||||
|
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
|
||||||
|
files; one cache-cleanup thread per directory rather than per manager.
|
||||||
|
- Odds are fetched for the games displayed, not the whole schedule window, and a
|
||||||
|
stalled ESPN no longer stalls the whole plugin update.
|
||||||
|
- Array-item secrets are no longer wiped or logged.
|
||||||
|
- A restored backup matches the device it was taken from.
|
||||||
|
- The web UI reports the real error instead of "unknown", rejects non-finite
|
||||||
|
JSON numbers, and stops checkbox groups posting back hidden options.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `display.hardware.orientation` for panels mounted upside down.
|
||||||
|
- Vegas keeps live content in the ticker rather than being preempted by it.
|
||||||
|
- Schemas can label enum dropdown options.
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
**The first release shipping the unified sports library.** This is the version
|
**The first release shipping the unified sports library.** This is the version
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||||
- `plugin-repos/` — **Default** plugin install directory used by the
|
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||||
Plugin Store, set by `plugin_system.plugins_directory` in
|
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||||
`config.json` (default per `config/config.template.json:167`).
|
`config.json` (default per `config/config.template.json`).
|
||||||
Not gitignored.
|
Not gitignored.
|
||||||
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||||
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
||||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||||
- Config schemas use JSON Schema Draft-7
|
- Config schemas use JSON Schema Draft-7
|
||||||
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
|
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
||||||
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
|
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
|
||||||
via `"x-secret": true` in the plugin's config schema, and deep-merged into
|
via `"x-secret": true` in the plugin's config schema, and deep-merged into
|
||||||
the plugin's config dict at load time — plugins read them with plain
|
the plugin's config dict at load time — plugins read them with plain
|
||||||
@@ -45,9 +45,12 @@
|
|||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- 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`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
## Skin System (visual overlays for sports scoreboards)
|
## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET
|
||||||
|
- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes`
|
||||||
|
- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py`
|
||||||
|
- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section)
|
||||||
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports/core.py`
|
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests
|
||||||
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||||
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||||
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
web
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
Designed novice-first, with power tools kept within reach.
|
||||||
|
|
||||||
|
- **Primary: hobbyist builders.** People who assembled an LED matrix panel on a Raspberry Pi, often by following the install video, and are frequently new to Linux and the Pi. They set the display up once (panel size, timezone, WiFi), install and enable a few plugins, then come back occasionally to tweak what the panel shows. They usually reach the control panel from a phone or laptop on their home network, sometimes as an installed home-screen app.
|
||||||
|
- **Secondary: tinkerers and plugin developers.** Comfortable with SSH, `config.json`, and GitHub. They lean on the Config Editor, Logs, Cache, Operation History, Tools, GitHub-repo installs, and per-plugin config while building or debugging. Their tools must stay reachable without sitting in the novice's path.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
LEDMatrix turns a Raspberry Pi and an RGB LED matrix panel into an information-rich display (clock, weather, calendar, sports scores, stocks, music, and more) through a plugin platform. The web control panel ("LED Matrix Control") is where the display gets configured, extended, and kept healthy.
|
||||||
|
|
||||||
|
Success means a builder gets from a freshly flashed Pi to a working, personalized display without needing a terminal, and can keep it running (updates, recovery, troubleshooting) the same way.
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
Four strengths define LEDMatrix, and future work must protect all of them:
|
||||||
|
|
||||||
|
1. **Plugin ecosystem.** The core ships only `starlark-apps` and `web-ui-info`; everything else comes from the built-in Plugin Store (the official `ledmatrix-plugins` monorepo), third-party GitHub repos, or Starlark (Tidbyt-style) apps. Each installed plugin gets its own configuration tab, generated from its schema.
|
||||||
|
2. **Runs on tiny Pis.** The UI is served by the same device that drives the matrix, on boards as small as the Pi Zero 2 W (512 MB), Pi 3/3B+, and the 1 GB Pi 4.
|
||||||
|
3. **Recovers without SSH.** WiFi access-point fallback with a captive setup page, backup & restore, in-UI updates, live logs, diagnostics, service control, and plugin health let users fix problems from the browser.
|
||||||
|
4. **Open and community-led.** GPL-3.0, a Discord community, and contributions welcome. The maintainer (ChuckBuilds) builds in public and openly relies on AI development tools.
|
||||||
|
|
||||||
|
## Operating Context
|
||||||
|
|
||||||
|
- **Access.** Served on the local network at `http://<pi-ip>:5000` by the `ledmatrix-web` service. It is installable as a PWA (`web_interface/static/v3/manifest.json`, short name "LEDMatrix").
|
||||||
|
- **First run.** When the Pi has no network it creates its own WiFi access point, so the captive setup page (`templates/v3/captive_setup.html`) may be the very first screen a user sees, on a phone, with no internet connection.
|
||||||
|
- **Navigation.**
|
||||||
|
- System tabs: Overview, General, WiFi, Schedule, Display, Rotation, Config Editor, Backup & Restore, Fonts, Logs, Cache, Operation History, Tools.
|
||||||
|
- A second row holds Plugin Manager (with the Plugin Store), Starlark Apps, and one tab per installed plugin.
|
||||||
|
- **Live data.** The Overview shows system stats (CPU, memory, temperature, power/throttling) and a live display preview, streamed over SSE.
|
||||||
|
- **Getting Started checklist.** The Overview's first-run checklist runs: set panel size → set timezone → install a plugin → enable it → configure it.
|
||||||
|
- **Development.** `python3 scripts/dev_server.py` gives a browser preview without the display loop; `python3 run.py -e` runs the full display in emulator mode.
|
||||||
|
|
||||||
|
## Capabilities and Constraints
|
||||||
|
|
||||||
|
- **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working.
|
||||||
|
- **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls.
|
||||||
|
- **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets.
|
||||||
|
- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode.
|
||||||
|
- **Open decisions** (offered during init, not adopted as constraints):
|
||||||
|
- Whether the UI must work fully offline, with no CDN fallbacks at runtime.
|
||||||
|
- Whether a Node/CSS build step is acceptable for contributors.
|
||||||
|
- Whether a formal accessibility standard (e.g. WCAG 2.2 AA) is a requirement.
|
||||||
|
|
||||||
|
## Brand Commitments
|
||||||
|
|
||||||
|
- **Names.** The product is "LEDMatrix" and the web UI is titled "LED Matrix Control". The maintainer brand is ChuckBuilds.
|
||||||
|
- **Voice.** Friendly, honest, and learning-in-public, as in the README.
|
||||||
|
- **App icons.** They live in `web_interface/static/v3/icons/`.
|
||||||
|
|
||||||
|
No other visual identity has been made binding.
|
||||||
|
|
||||||
|
## Evidence on Hand
|
||||||
|
|
||||||
|
- **Photos.** Real photographs of running displays are linked in `README.md` (clock, weather, calendar, NHL/MLB/NFL/NCAA, stocks, music).
|
||||||
|
- **Video.** YouTube install and walkthrough videos from ChuckBuilds.
|
||||||
|
- **Docs.** Extensive documentation in `docs/`, e.g. `WEB_INTERFACE_GUIDE.md`, `GETTING_STARTED.md`, `WIFI_NETWORK_SETUP.md`, `LOW_MEMORY_BOARDS.md`, `PLUGIN_STORE_GUIDE.md`.
|
||||||
|
- **Absences.** There are no testimonials, user counts, or benchmark figures. Do not fabricate them.
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
|
||||||
|
1. **Novice path first, power one click away.** Default views serve the first-time builder, while advanced tools stay discoverable for tinkerers.
|
||||||
|
2. **Never strand the user at a terminal.** Every setup, recovery, and troubleshooting task has a browser path, including from the AP-mode captive page.
|
||||||
|
3. **Respect the Pi.** Every feature is paid for in memory and CPU on a Pi Zero 2 W that is also driving the display.
|
||||||
|
4. **The ecosystem is the product.** Plugins, including third-party ones, must feel first-class and keep working across core UI changes.
|
||||||
|
5. **Honest and welcoming.** Plain language, truthful status, and no overstated claims, in keeping with an open, community-built project.
|
||||||
@@ -161,9 +161,11 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
### LED Matrix Panels
|
### LED Matrix Panels
|
||||||
(2x in a horizontal chain is recommended)
|
(2x in a horizontal chain is recommended)
|
||||||
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
|
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
|
||||||
|
**Warning: Lately the Waveshare Panels have had different variations - only some are compatible with this project. I hope to identify what is different to fix it but so far there is a decent chance you get a mis-matched set of panels if you don't buy them all at once! **
|
||||||
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
|
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
|
||||||
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
||||||
> Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases
|
- There are some Panels on Aliexpress that have worked fine for me, shop around! I think Adafruit is probably the "safest" but they do have some limitation on resolution and layout.
|
||||||
|
> Amazon Affiliate Links – ChuckBuilds receives a small commission on purchases
|
||||||
|
|
||||||
### Power Supply
|
### Power Supply
|
||||||
- [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more)
|
- [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more)
|
||||||
@@ -461,13 +463,12 @@ For plugin development, check out the [Hello World Plugin](https://github.com/Ch
|
|||||||
|
|
||||||
### Visual Skins for Scoreboards
|
### Visual Skins for Scoreboards
|
||||||
|
|
||||||
Want a different look for a sports scoreboard without forking the plugin?
|
**Not supported yet.** Skins are meant to restyle a sports scoreboard's
|
||||||
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
live/recent/upcoming screens without forking the plugin, but the current
|
||||||
handling data, scheduling, caching, and vegas mode. Install one with
|
scoreboard plugins don't render them: a selected skin has no effect. The web
|
||||||
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
UI doesn't offer skin install or selection for that reason. The skin system
|
||||||
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
and its docs stay in place for when scoreboards adopt it; see
|
||||||
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why.
|
||||||
including a ready-made Claude Code prompt).
|
|
||||||
|
|
||||||
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 467 B |
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB |
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"web_display_autostart": true,
|
"web_display_autostart": true,
|
||||||
|
"auto_update": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"mode": "per-day",
|
"mode": "per-day",
|
||||||
|
|||||||
@@ -223,8 +223,8 @@ The harness already renders every plugin at a spread of sizes (now
|
|||||||
including 96x48):
|
including 96x48):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
python scripts/check_plugin.py --plugin <plugin-id> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||||
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
python scripts/render_plugin.py --plugin <plugin-id> --width 96 --height 48
|
||||||
```
|
```
|
||||||
|
|
||||||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ tooling against it.
|
|||||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||||
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||||
| `location` | object | `city` / `state` / `country`, offered to plugins that need a location (weather, etc.) | plugins via merged config |
|
| `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. | `SchemaManager.apply_device_location()`, then plugins via merged config |
|
||||||
|
|
||||||
## `schedule` — display on/off hours
|
## `schedule` — display on/off hours
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -1,5 +1,14 @@
|
|||||||
# Creating Skins
|
# Creating Skins
|
||||||
|
|
||||||
|
> **Not supported yet: skins don't render with the current scoreboard
|
||||||
|
> plugins.** The only render hook is `SportsCore._render_game()` in
|
||||||
|
> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or
|
||||||
|
> third-party) builds on `src.base_classes`, so a skin you build here passes
|
||||||
|
> `validate_skin.py` but never appears on the matrix. The web UI and Plugin
|
||||||
|
> Store don't offer skins for that reason. Details:
|
||||||
|
> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below
|
||||||
|
> stays accurate for the skin API itself.
|
||||||
|
|
||||||
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||||||
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||||||
doing vegas mode; your skin only draws. Architecture background:
|
doing vegas mode; your skin only draws. Architecture background:
|
||||||
@@ -19,7 +28,9 @@ panel sizes with **no hardware, no network, no running service**, saves PNGs
|
|||||||
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
||||||
edit → validate → look at the PNGs.
|
edit → validate → look at the PNGs.
|
||||||
|
|
||||||
To see it on your matrix, add to your plugin's section in `config/config.json`:
|
To select it, add to your plugin's section in `config/config.json` (this is
|
||||||
|
stored and validated, but has no visible effect until a scoreboard uses the
|
||||||
|
skin hook — see the note at the top):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"baseball-scoreboard": {
|
"baseball-scoreboard": {
|
||||||
@@ -28,8 +39,8 @@ To see it on your matrix, add to your plugin's section in `config/config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
|
||||||
matching skin is installed). `"skin"` also accepts a per-mode mapping:
|
`"skin"` also accepts a per-mode mapping:
|
||||||
`{"live": "my-skin", "recent": "built-in"}`.
|
`{"live": "my-skin", "recent": "built-in"}`.
|
||||||
|
|
||||||
## The manifest (`skin.json`)
|
## The manifest (`skin.json`)
|
||||||
@@ -234,8 +245,9 @@ Tips that keep Claude (and you) out of trouble:
|
|||||||
dev machine
|
dev machine
|
||||||
|
|
||||||
Distribute by publishing the directory as a git repo (users
|
Distribute by publishing the directory as a git repo (users
|
||||||
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
|
`git clone <repo> skins/<id>`). Registry entries with `"type": "skin"` are
|
||||||
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
hidden and refused by the Plugin Store while skins are unsupported (see
|
||||||
|
[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||||
|
|
||||||
**Trust note:** a skin is Python running inside the display service — the
|
**Trust note:** a skin is Python running inside the display service — the
|
||||||
same trust level as a plugin. Review code before installing skins from
|
same trust level as a plugin. Review code before installing skins from
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Running on Low-Memory Boards
|
||||||
|
|
||||||
|
Applies to the Pi Zero 2 W (512 MB), Pi 3 / 3B+ (1 GB), and the 1 GB Pi 4.
|
||||||
|
If your board has 2 GB or more you can skip this document.
|
||||||
|
|
||||||
|
## The failure this prevents
|
||||||
|
|
||||||
|
The display process is the largest thing on the board. On a 1 GB Pi 3B+ with
|
||||||
|
around 20 plugins enabled it settles near **600 MB of 905 MB usable**, leaving
|
||||||
|
under 200 MB of headroom for everything else.
|
||||||
|
|
||||||
|
When that headroom runs out, the board does not crash cleanly. `fork()` starts
|
||||||
|
failing, and because a new process is needed to do almost anything, the
|
||||||
|
symptoms look nothing like "out of memory":
|
||||||
|
|
||||||
|
| What you see | Why |
|
||||||
|
|---|---|
|
||||||
|
| SSH accepts the connection then closes it instantly, before any banner | `sshd` forks a session per connection; the fork fails |
|
||||||
|
| The web UI still responds quickly | Already running, serves from existing threads, forks nothing |
|
||||||
|
| Ping is perfect, 0% loss | Handled entirely in the kernel |
|
||||||
|
| The panel is dark | The display process was killed and cannot be respawned |
|
||||||
|
| The clock is wrong after the next boot | `fake-hwclock`'s periodic save is a scheduled job, and it cannot fork either |
|
||||||
|
|
||||||
|
The board looks healthy from the outside and cannot be logged into. Only a
|
||||||
|
power cycle clears it. If you are here because SSH stopped working, also see
|
||||||
|
[SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md), which
|
||||||
|
covers the more common cause (AP mode).
|
||||||
|
|
||||||
|
## Check your headroom
|
||||||
|
|
||||||
|
```bash
|
||||||
|
free -m
|
||||||
|
ps -eo rss,comm --sort=-rss | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
If `MemAvailable` is under ~150 MB while the display is running, you are close
|
||||||
|
to the edge. To watch it over time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
watch -n 30 'free -m | head -2'
|
||||||
|
```
|
||||||
|
|
||||||
|
Available memory that falls steadily rather than holding flat means you will
|
||||||
|
reach the wall; it is a question of when.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
**1. Enable the memory cgroup controller.** Without it, the `MemoryMax=85%` in
|
||||||
|
`systemd/ledmatrix.service` is accepted by systemd and silently ignored, so the
|
||||||
|
service has no ceiling and a runaway takes the whole board down instead of just
|
||||||
|
restarting. Raspberry Pi firmware disables this controller by default.
|
||||||
|
|
||||||
|
`first_time_install.sh` does this for you. To check it took effect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep memory /sys/fs/cgroup/cgroup.controllers
|
||||||
|
```
|
||||||
|
|
||||||
|
If that prints nothing, add `cgroup_enable=memory cgroup_memory=1` to the
|
||||||
|
kernel command line and reboot. Edit whichever file your image uses —
|
||||||
|
`/boot/firmware/cmdline.txt` on current Raspberry Pi OS, `/boot/cmdline.txt` on
|
||||||
|
older layouts (the installer checks the first and falls back to the second).
|
||||||
|
Everything must stay on a single line.
|
||||||
|
|
||||||
|
This changes the failure mode from "the board becomes unreachable" to "the
|
||||||
|
display service restarts". It is a safety net, not a fix.
|
||||||
|
|
||||||
|
**2. Run fewer plugins.** This is the actual remedy. Every enabled plugin costs
|
||||||
|
memory permanently — its module, its parsed config, and its cached API
|
||||||
|
responses. On a 512 MB or 1 GB board, keep the enabled set small and prefer
|
||||||
|
plugins that poll infrequently.
|
||||||
|
|
||||||
|
**3. Lower the cache ceiling.** The in-memory cache is sized from total RAM
|
||||||
|
(150 entries at 1 GB and below, up to 1500 at 8 GB). To go lower still:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/ledmatrix.service.d/override.conf
|
||||||
|
[Service]
|
||||||
|
Environment=LEDMATRIX_CACHE_MAX_ENTRIES=75
|
||||||
|
```
|
||||||
|
|
||||||
|
Writing the file does not change the running service. Reload systemd and
|
||||||
|
restart it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart ledmatrix
|
||||||
|
```
|
||||||
|
|
||||||
|
Fewer entries means more API calls, so lower this only while you are actually
|
||||||
|
short of memory.
|
||||||
|
|
||||||
|
**4. Consider `MemoryHigh`.** `MemoryMax` kills and restarts. `MemoryHigh`
|
||||||
|
throttles and reclaims instead, which is gentler — but on a board where the
|
||||||
|
process genuinely wants more than the limit, sustained reclaim can stall the
|
||||||
|
render loop and show as visible stutter on the panel. Add it only if you prefer
|
||||||
|
degraded output to a restart:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
MemoryHigh=70%
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep your logs
|
||||||
|
|
||||||
|
These images default to volatile journald storage, so every reboot destroys the
|
||||||
|
logs — including the ones explaining why the board rebooted. `first_time_install.sh`
|
||||||
|
enables persistent storage capped at 64 MB. To confirm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl --list-boots
|
||||||
|
```
|
||||||
|
|
||||||
|
More than one boot listed means logs are surviving reboots. If only one is
|
||||||
|
listed, journald is still writing to `/run` (tmpfs).
|
||||||
@@ -36,7 +36,11 @@ self.enabled # Boolean enabled status
|
|||||||
|
|
||||||
#### `update() -> None`
|
#### `update() -> None`
|
||||||
|
|
||||||
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
|
Fetch/update data for this plugin. Called on the plugin's update interval:
|
||||||
|
the value `get_update_interval()` returns when it returns a number, otherwise
|
||||||
|
the static interval: the `update_interval` in the plugin's manifest, else
|
||||||
|
`update_interval` in the plugin's section of `config.json`, else 60 seconds
|
||||||
|
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
```python
|
```python
|
||||||
@@ -109,6 +113,46 @@ Called when plugin is enabled.
|
|||||||
|
|
||||||
Called when plugin is disabled.
|
Called when plugin is disabled.
|
||||||
|
|
||||||
|
#### `get_update_interval() -> Optional[float]`
|
||||||
|
|
||||||
|
How often this plugin wants `update()` called right now, in seconds. The
|
||||||
|
manifest's `update_interval` is one static number; override this when the
|
||||||
|
right cadence depends on state only the plugin knows, e.g. poll every 15s
|
||||||
|
while a game is live and fall back to the manifest value otherwise.
|
||||||
|
|
||||||
|
**Returns**: seconds as a number, or `None` (the default) for no opinion.
|
||||||
|
|
||||||
|
How `PluginManager` (`_get_plugin_update_interval` in
|
||||||
|
`src/plugin_system/plugin_manager.py`) resolves the interval on each
|
||||||
|
scheduling tick:
|
||||||
|
|
||||||
|
1. It calls `get_update_interval()`. A number wins over everything below.
|
||||||
|
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
|
||||||
|
raised to it.
|
||||||
|
2. If the hook returns `None`, raises, or returns something that isn't a
|
||||||
|
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
|
||||||
|
static interval applies: the manifest's `update_interval`, else
|
||||||
|
`update_interval` in the plugin's section of `config.json`, else 60
|
||||||
|
seconds.
|
||||||
|
|
||||||
|
The static value is cached per plugin until the plugin is loaded or
|
||||||
|
unloaded again, so editing `update_interval` in config takes effect on the
|
||||||
|
next reload. The hook's return value is never cached: it is called on every
|
||||||
|
tick of the display loop, so keep it to attribute reads (no config lookups,
|
||||||
|
no I/O, no locks a fetch might hold) and don't let it raise.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```python
|
||||||
|
def get_update_interval(self):
|
||||||
|
# Fast while something is live, manifest default otherwise.
|
||||||
|
if any(m.live_games for m in self._live_managers):
|
||||||
|
return self.config.get("live_update_interval", 15)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
Added in core 3.4.0; older cores never call it, so a plugin that relies on
|
||||||
|
it should floor `ledmatrix_min_version` at `3.4.0`.
|
||||||
|
|
||||||
#### `get_display_duration() -> float`
|
#### `get_display_duration() -> float`
|
||||||
|
|
||||||
Get display duration for this plugin. Can be overridden for dynamic durations.
|
Get display duration for this plugin. Can be overridden for dynamic durations.
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ class BasePlugin(ABC):
|
|||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""
|
"""
|
||||||
Fetch/update data for this plugin.
|
Fetch/update data for this plugin.
|
||||||
Called based on update_interval in manifest.
|
Called every get_update_interval() seconds when that returns a
|
||||||
|
number, otherwise at the static interval: the manifest's
|
||||||
|
update_interval, else the plugin config's update_interval, else 60s.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -204,6 +206,21 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def get_update_interval(self) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Seconds until update() should run again, decided at runtime.
|
||||||
|
Return None (the default) to use the static interval.
|
||||||
|
|
||||||
|
PluginManager._get_plugin_update_interval calls this on every
|
||||||
|
scheduling tick. A number overrides the manifest and is clamped up
|
||||||
|
to PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL (5s); None, a raise,
|
||||||
|
or a non-finite/non-numeric value falls back to the manifest's
|
||||||
|
update_interval, then the plugin config's update_interval, then
|
||||||
|
60s. The static value is cached until the plugin reloads; the hook
|
||||||
|
is not cached, so it must be cheap and must not raise.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
def get_display_duration(self) -> float:
|
def get_display_duration(self) -> float:
|
||||||
"""
|
"""
|
||||||
Get the display duration for this plugin instance.
|
Get the display duration for this plugin instance.
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ This guide explains how to set up a development workflow for plugins that are ma
|
|||||||
> scale. Existing plugins keep their classic rendering unless they adopt
|
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||||
> those APIs; nothing migrates automatically.
|
> those APIs; nothing migrates automatically.
|
||||||
|
|
||||||
> **Just want a different look for an existing sports scoreboard?** You may
|
> **Want a different look for an existing sports scoreboard?** Skins are
|
||||||
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
> meant for that, but they are **not supported yet**: the current scoreboard
|
||||||
> rendering while the plugin keeps handling data, scheduling, caching, and
|
> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)).
|
||||||
> vegas mode, in ~100 lines of drawing code. See
|
> For now, change the look through the plugin's own display settings or its
|
||||||
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
> code.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Per-element styling for plugin authors
|
||||||
|
|
||||||
|
Users want to change the font, size and colour of individual things on screen,
|
||||||
|
nudge them a few pixels, hide the ones they do not care about, and scale a logo
|
||||||
|
down. This is the one system that does that, and a plugin joins it by
|
||||||
|
**declaring elements in its `config_schema.json`** — not by writing a style
|
||||||
|
resolver, a font cache or a web form.
|
||||||
|
|
||||||
|
The short version:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"customization": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Display Customization",
|
||||||
|
"x-style-elements": {
|
||||||
|
"score_text": {
|
||||||
|
"title": "Score",
|
||||||
|
"font": { "default": "PressStart2P-Regular.ttf" },
|
||||||
|
"size": { "default": 10, "min": 4, "max": 16 },
|
||||||
|
"color": { "default": [255, 255, 255] },
|
||||||
|
"offsets": true,
|
||||||
|
"visible": true,
|
||||||
|
"align": true
|
||||||
|
},
|
||||||
|
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
|
||||||
|
},
|
||||||
|
"x-style-modes": ["live", "upcoming", "recent"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole declaration. The core expands it into a full JSON Schema, the
|
||||||
|
web UI renders a compact style editor with a row per element, and the values
|
||||||
|
land in `config.json` under the keys you named.
|
||||||
|
|
||||||
|
## What each key does
|
||||||
|
|
||||||
|
| Key | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `font` | Font picker listing every shipped **and user-uploaded** font. |
|
||||||
|
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
|
||||||
|
| `color` | Colour swatch; stored as `[r, g, b]`. |
|
||||||
|
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
|
||||||
|
| `visible` | Show/hide toggle. |
|
||||||
|
| `align` | `left` / `center` / `right`. |
|
||||||
|
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
|
||||||
|
|
||||||
|
`x-style-modes` is optional. Declare it and every element gains a per-mode
|
||||||
|
override tab — a scoreboard can then style its live, upcoming and recent cards
|
||||||
|
separately. **A mode field left blank means "inherit", not zero.**
|
||||||
|
|
||||||
|
## Reading the values
|
||||||
|
|
||||||
|
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
|
||||||
|
on its own:
|
||||||
|
|
||||||
|
```python
|
||||||
|
style = self.styles.style(
|
||||||
|
"score_text",
|
||||||
|
classic_font="PressStart2P-Regular.ttf", # what you shipped
|
||||||
|
classic_size=10,
|
||||||
|
classic_color=(255, 255, 255),
|
||||||
|
)
|
||||||
|
if style.visible:
|
||||||
|
draw.text((x + style.offset[0], y + style.offset[1]),
|
||||||
|
text, font=style.font, fill=style.color)
|
||||||
|
```
|
||||||
|
|
||||||
|
For a specific mode, use `self.styles_for("recent")`, or set
|
||||||
|
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
|
||||||
|
|
||||||
|
### The one rule that matters
|
||||||
|
|
||||||
|
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
|
||||||
|
them verbatim unless the user actually changed something, which is what keeps an
|
||||||
|
untouched install rendering byte-identically. It can tell the difference because
|
||||||
|
a value only counts as user-forced when it *differs from the schema default* —
|
||||||
|
the save path writes the full default object into `config.json` on every save,
|
||||||
|
so "present in config" proves nothing.
|
||||||
|
|
||||||
|
Never compare against the default yourself; that rule lives in exactly one place.
|
||||||
|
|
||||||
|
### Stateless readers
|
||||||
|
|
||||||
|
For helpers handed a config dict rather than a plugin instance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.element_style import (element_color, element_visible,
|
||||||
|
element_align, element_scale, layout_offset)
|
||||||
|
|
||||||
|
colour = element_color(config, "score_text", (255, 255, 255), mode)
|
||||||
|
shown = element_visible(config, "records", True, mode)
|
||||||
|
dy = layout_offset(config, "score", "y_offset", 0, mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sports scoreboards
|
||||||
|
|
||||||
|
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
|
||||||
|
|
||||||
|
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
|
||||||
|
resolves the colour by name *and* honours the visibility toggle. Without it
|
||||||
|
the colour has to be guessed from the identity of the font object, which
|
||||||
|
cannot tell two elements apart when they share a face — the case every
|
||||||
|
bitmap font is in.
|
||||||
|
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
|
||||||
|
`SKIN_MODE` on each is enough; no call site passes a mode.
|
||||||
|
|
||||||
|
## Adopting an existing hand-written block
|
||||||
|
|
||||||
|
If your schema already spells out `font` / `font_size` / `text_color` per
|
||||||
|
element longhand, **you do not need to change anything**. The core recognises
|
||||||
|
that shape and upgrades it in place: the style editor, the real font picker
|
||||||
|
(including uploaded fonts) and per-mode overrides all appear on a core update.
|
||||||
|
Add `x-style-modes` if you want the mode tabs.
|
||||||
|
|
||||||
|
## Fonts, and why size is sometimes locked
|
||||||
|
|
||||||
|
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
|
||||||
|
one pixel size and ignore `font_size`. The picker knows which, and the editor
|
||||||
|
locks the size field to the native size and labels it `fixed`. A font too tall
|
||||||
|
for the `max` you declared is not offered at all.
|
||||||
|
|
||||||
|
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
|
||||||
|
2. Read through `self.styles`, passing your shipped values as `classic_*`.
|
||||||
|
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
|
||||||
|
4. Confirm an untouched config renders identically:
|
||||||
|
`python scripts/check_plugin.py --plugin <id>`.
|
||||||
|
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
|
||||||
|
|
||||||
|
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
|
||||||
|
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
|
||||||
+5
-2
@@ -14,6 +14,7 @@ the one-shot installer. The pages here go deeper.
|
|||||||
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
||||||
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
||||||
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
||||||
|
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
||||||
|
|
||||||
## I want to write a plugin
|
## I want to write a plugin
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ Going deeper:
|
|||||||
|
|
||||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
||||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
||||||
|
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
|
||||||
|
move, hide and scale individual elements (per display mode, if you have them)
|
||||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
||||||
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
||||||
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
||||||
@@ -53,8 +56,8 @@ Going deeper:
|
|||||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||||
cache management, background services, permissions
|
cache management, background services, permissions
|
||||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards
|
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins)
|
||||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin
|
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat)
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ All endpoints return JSON responses with a standard format:
|
|||||||
|
|
||||||
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||||
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
||||||
> Flask app at `app.py:799-809`. There are 94 routes total — see
|
> Flask app at `app.py:799-809`. There are 111 routes total — see
|
||||||
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -223,6 +223,56 @@ Get the current display state and preview image.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### List Display Modes
|
||||||
|
|
||||||
|
**GET** `/api/v3/display/modes`
|
||||||
|
|
||||||
|
Every display mode that can be requested on-demand, with the plugin that owns
|
||||||
|
it. This is the list the force-display dialog offers.
|
||||||
|
|
||||||
|
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
||||||
|
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
||||||
|
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
||||||
|
manifest — a plugin whose modes are generated (each installed Starlark app is
|
||||||
|
one) returns 404 there.
|
||||||
|
|
||||||
|
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||||
|
opens the dashboard still gets the full list.
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `include_disabled` (optional): `1` to include modes belonging to disabled
|
||||||
|
plugins. They are still valid on-demand targets — the controller enables the
|
||||||
|
plugin for the duration of the request — and are reported with
|
||||||
|
`"enabled": false`.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"modes": [
|
||||||
|
{
|
||||||
|
"mode": "nfl_live",
|
||||||
|
"plugin_id": "football-scoreboard",
|
||||||
|
"plugin_name": "Football Scoreboard",
|
||||||
|
"name": "nfl_live",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode": "clock-simple",
|
||||||
|
"plugin_id": "clock-simple",
|
||||||
|
"plugin_name": "Simple Clock",
|
||||||
|
"name": "Simple Clock",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` is a label for a dropdown: a single-mode plugin's own name, or the raw
|
||||||
|
mode string for a multi-mode plugin, since there is no per-mode name anywhere.
|
||||||
|
|
||||||
### On-Demand Display Status
|
### On-Demand Display Status
|
||||||
|
|
||||||
**GET** `/api/v3/display/on-demand/status`
|
**GET** `/api/v3/display/on-demand/status`
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# Scroll Performance
|
||||||
|
|
||||||
|
How scrolling is paced on this hardware, what was wrong with it, and how to
|
||||||
|
configure a plugin so its marquee is smooth.
|
||||||
|
|
||||||
|
Measured on a Raspberry Pi 4 driving a 2×128×64 chain (256×64 logical) at
|
||||||
|
`limit_refresh_rate_hz: 100`. Numbers below come from that panel.
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| scroll frame rate | 44–46 fps | **100 fps, locked** |
|
||||||
|
| frames ≥ 45 ms | 14–17% | none observed |
|
||||||
|
| dominant frame time | 20 ms | **10 ms** |
|
||||||
|
| disk cache write (~1 MB) | 14.8 ms | **5.4 ms** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one rule that matters
|
||||||
|
|
||||||
|
**Motion is smooth when the strip advances a whole number of pixels per panel
|
||||||
|
refresh.**
|
||||||
|
|
||||||
|
Advancing one pixel per refresh on a 100 Hz panel gives 100 px/s. Slower crisp
|
||||||
|
speeds come from holding each frame for several refreshes -- 50 px/s is one
|
||||||
|
pixel every second refresh -- which is covered under *Choosing a speed* below.
|
||||||
|
A speed that lands on no such combination has to do one of two bad things:
|
||||||
|
|
||||||
|
- **blend** two adjacent columns to render a half-step — on pixel-font text
|
||||||
|
this alternates crisp and smeared frames and reads as shimmer, or as the
|
||||||
|
text jumping a pixel ahead of itself;
|
||||||
|
- **repeat** a frame — the strip stands still, then jumps, which reads as
|
||||||
|
judder.
|
||||||
|
|
||||||
|
Neither is tunable away. Pick a speed that divides evenly.
|
||||||
|
|
||||||
|
`src.common.scroll_config` solves this for you: `configure()` snaps a requested
|
||||||
|
speed to the nearest one the panel can actually show in whole pixels, and
|
||||||
|
`scripts/scroll_speeds.py` prints the full ladder for your hardware.
|
||||||
|
|
||||||
|
## Choosing a speed
|
||||||
|
|
||||||
|
The crisp speeds are not a fixed list -- they depend on how fast *your* panel
|
||||||
|
refreshes, which depends on its size, `pwm_bits`, `gpio_slowdown` and the Pi
|
||||||
|
model. A Pi Zero driving a long chain has a completely different set of good
|
||||||
|
speeds from a Pi 4 driving a short one.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# what can this panel do? (reads your configured refresh rate)
|
||||||
|
python3 scripts/scroll_speeds.py
|
||||||
|
|
||||||
|
# what does it ACTUALLY manage, rather than what is configured?
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
sudo python3 scripts/scroll_speeds.py --measure
|
||||||
|
sudo systemctl start ledmatrix
|
||||||
|
|
||||||
|
# highlight the closest option to the speed you want
|
||||||
|
python3 scripts/scroll_speeds.py --want 45
|
||||||
|
|
||||||
|
# try one on the panel
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
sudo python3 scripts/scroll_speeds.py --demo 50
|
||||||
|
sudo systemctl start ledmatrix
|
||||||
|
```
|
||||||
|
|
||||||
|
Sample ladder for a 100 Hz panel:
|
||||||
|
|
||||||
|
```
|
||||||
|
20.0 px/s (1px every 5 refreshes = 20.0 fps, slightly stepped)
|
||||||
|
25.0 px/s (1px every 4 refreshes = 25.0 fps, slightly stepped)
|
||||||
|
33.3 px/s (1px every 3 refreshes = 33.3 fps, smooth)
|
||||||
|
50.0 px/s (1px every 2 refreshes = 50.0 fps, smooth)
|
||||||
|
66.7 px/s (2px every 3 refreshes = 33.3 fps, smooth)
|
||||||
|
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How a slow speed stays crisp
|
||||||
|
|
||||||
|
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||||
|
refreshes. **The panel keeps refreshing at its full rate either way**, so
|
||||||
|
holding a frame costs nothing in flicker -- it only changes how often a *new*
|
||||||
|
image is presented. That is what allows 50 px/s to be one whole pixel every
|
||||||
|
second refresh, instead of half a pixel every refresh (which has no good
|
||||||
|
rendering, only a choice between blur and judder).
|
||||||
|
|
||||||
|
`scroll_config.configure()` snaps the requested speed to the nearest entry on
|
||||||
|
the ladder and reports the hold that speed needs. It does **not** apply the
|
||||||
|
hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
|
||||||
|
share one display manager -- one set at construction is reset the moment any
|
||||||
|
other plugin finishes scrolling. Apply it yourself when the scroll starts:
|
||||||
|
|
||||||
|
```python
|
||||||
|
settings = scroll_config.configure(
|
||||||
|
self.scroll_helper,
|
||||||
|
plugin_config=self.config,
|
||||||
|
global_config=self.global_config,
|
||||||
|
display_manager=self.display_manager, # supplies the panel refresh rate
|
||||||
|
)
|
||||||
|
|
||||||
|
# ...then, each time this plugin begins scrolling:
|
||||||
|
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||||
|
```
|
||||||
|
|
||||||
|
Passing `display_manager` only lets `configure` read the true refresh rate from
|
||||||
|
`display.hardware`, which a plugin config cannot see. Skipping the
|
||||||
|
`set_scrolling_state` call is the mistake that matters: the speed still
|
||||||
|
resolves, but the panel keeps presenting a new frame every refresh, so a slow
|
||||||
|
snapped speed falls back to fractional pixels. Pass `snap_to_crisp=False` to
|
||||||
|
keep an exact requested speed and accept the artefacts.
|
||||||
|
|
||||||
|
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
|
||||||
|
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
|
||||||
|
software limit; the only way to move in smaller increments is sub-pixel
|
||||||
|
blending, which this display does not tolerate (see above).
|
||||||
|
|
||||||
|
## Configuring a plugin
|
||||||
|
|
||||||
|
Use the shared resolver rather than reading config keys yourself:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.common import scroll_config
|
||||||
|
|
||||||
|
settings = scroll_config.configure(
|
||||||
|
self.scroll_helper,
|
||||||
|
plugin_config=self.config,
|
||||||
|
global_config=self.global_config,
|
||||||
|
refresh_hz=scroll_config.refresh_hz_from_config(self.global_config),
|
||||||
|
plugin_logger=self.logger,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
It resolves every config shape in one place, applies the speed, and returns
|
||||||
|
what it did. Precedence, highest first:
|
||||||
|
|
||||||
|
1. `display_options.scroll_speed` + `scroll_delay` — **the recommended form**
|
||||||
|
2. `display.scroll_speed` + `scroll_delay` — deprecated shape
|
||||||
|
3. `scroll_speed` + `scroll_delay` at the root — legacy flat
|
||||||
|
4. `scroll_pixels_per_second` — deprecated
|
||||||
|
5. the global `display` block
|
||||||
|
6. the built-in default (100 px/s)
|
||||||
|
|
||||||
|
`scroll_speed` is pixels per frame and `scroll_delay` is the frame period in
|
||||||
|
seconds, so the pair means `scroll_speed / scroll_delay` px/s. The recommended
|
||||||
|
config for a 100 Hz panel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"display_options": { "scroll_speed": 1.0, "scroll_delay": 0.01 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why the deprecated key ranks below the explicit pair
|
||||||
|
|
||||||
|
Because some plugins give `scroll_pixels_per_second` a **schema default**, and
|
||||||
|
schema defaults are merged into plugin config. Ranking it above the pair means
|
||||||
|
it is always present and always wins, so the documented settings become
|
||||||
|
unreachable. That is a real, shipped bug — see
|
||||||
|
[ledmatrix-plugins#408](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/408).
|
||||||
|
|
||||||
|
If you are writing a plugin: do not give a deprecated key a schema default.
|
||||||
|
|
||||||
|
## What was actually wrong
|
||||||
|
|
||||||
|
Four independent faults, each found by measurement.
|
||||||
|
|
||||||
|
### 1. The frame loop slept on top of a wait it had already done
|
||||||
|
|
||||||
|
`display_controller.py` ran the high-FPS loop as `render → SwapOnVSync (blocks
|
||||||
|
to the panel's refresh) → time.sleep(0.008) → plugin ticks`. The sleep was
|
||||||
|
unconditional and added to a wait that had already happened. Render work
|
||||||
|
measured ~4 ms, so each iteration cost ~12 ms against a 10 ms refresh grid —
|
||||||
|
every swap missed a refresh and landed on the next one. The loop settled at
|
||||||
|
exactly 50 fps while asking for 125, with no headroom, so ~14% of frames
|
||||||
|
slipped a further refresh.
|
||||||
|
|
||||||
|
Now the loop sleeps only the remainder of the frame budget, with a 1 ms floor
|
||||||
|
so plugin threads still get the GIL.
|
||||||
|
|
||||||
|
### 2. `SwapOnVSync` held the GIL while blocking
|
||||||
|
|
||||||
|
The rgbmatrix binding declares it without `nogil` (unlike `SetPixel`, `Clear`
|
||||||
|
and `Fill` immediately above it in `cppinc.pxd`), so the render thread held the
|
||||||
|
GIL for the entire vsync wait — most of every frame. Background threads were
|
||||||
|
starved into long uninterruptible bursts; a 1.5 MB API response costs ~17 ms to
|
||||||
|
parse and ~18 ms to re-encode for the cache, and `json.raw_decode` cannot be
|
||||||
|
preempted mid-document. Those bursts are what the render loop then waited on.
|
||||||
|
|
||||||
|
Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
||||||
|
|
||||||
|
### 3. Sub-pixel blending was wrong for this display
|
||||||
|
|
||||||
|
Enabling it made things worse, not better — see the rule at the top. It is off
|
||||||
|
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
||||||
|
|
||||||
|
### 4. Frame-based stepping raced the vsync clock
|
||||||
|
|
||||||
|
Frame-based mode gated motion on a wall clock at `1/scroll_delay` steps per
|
||||||
|
second. Plugins set `scroll_delay` to the frame period, which puts that
|
||||||
|
comparison exactly on its own threshold: a frame arriving a hair early moved
|
||||||
|
zero pixels and rendered an identical frame, which dirty-tracking skipped, so
|
||||||
|
it returned in ~2 ms and the beat repeated. No `scroll_delay` value tunes this
|
||||||
|
out — a shorter delay just trades stalled frames for periodic double-steps.
|
||||||
|
|
||||||
|
`ScrollHelper` now accumulates elapsed time in both modes at the same
|
||||||
|
configured speed, so position stays proportional to real time.
|
||||||
|
|
||||||
|
## Diagnosing a juddery scroller
|
||||||
|
|
||||||
|
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
|
||||||
|
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
|
||||||
|
healthy 100 fps. The stats line reports the tail for that reason — read the
|
||||||
|
percentiles, not the fps.
|
||||||
|
|
||||||
|
Every scroller emits one line every 5 seconds covering *every* frame in that
|
||||||
|
window, tagged with the plugin it came from:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[Plugin: news] Scroll frame stats - 100.0 fps over 501 frames | median 10.00ms
|
||||||
|
p95 10.11ms max 12.03ms min 7.98ms | stalls 0 (0.0%) skips 0 (0.0%)
|
||||||
|
```
|
||||||
|
|
||||||
|
Reading it, on a 100 Hz panel:
|
||||||
|
|
||||||
|
| you see | it means |
|
||||||
|
|---|---|
|
||||||
|
| median 10 ms, p95 within ~0.5 ms of it | healthy — locked to the panel |
|
||||||
|
| p95 or max at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
|
||||||
|
| non-zero **skips**, or a median *below* 10 ms | **duplicate frames** — the swap was skipped because the image did not change, so the frame never waited on vsync. The scroller is advancing less than one pixel per frame. |
|
||||||
|
| non-zero **stalls** | frames past 1.5× the median, which is the measure of judder that survives averaging |
|
||||||
|
|
||||||
|
`stalls` and `skips` are both counted against that window's own median, so they
|
||||||
|
stay meaningful on a panel running at any refresh rate.
|
||||||
|
|
||||||
|
To rank every scroller at once rather than reading lines one at a time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u ledmatrix --since "-3h" --no-pager | grep "Scroll frame stats" \
|
||||||
|
| sed -E 's/.*- (\S+) - (\[Plugin: [^]]+\] )?Scroll.*median ([0-9.]+)ms p95 ([0-9.]+)ms.*/\1 \3 \4/' \
|
||||||
|
| awk '$2 < 1000 {n[$1]++; m[$1]+=$2; p[$1]+=$3} END {for (k in n)
|
||||||
|
printf "%-28s %5d windows median %6.2fms p95 %6.2fms\n", k, n[k], m[k]/n[k], p[k]/n[k]}' \
|
||||||
|
| sort -k7 -rn
|
||||||
|
```
|
||||||
|
|
||||||
|
The `$2 < 1000` guard drops windows whose median is a whole second or more.
|
||||||
|
Those are not frames. Until the idle-gap fix in `log_frame_rate()`, the first
|
||||||
|
frame of every scroll was timed against the end of the *previous* scroll, so
|
||||||
|
the gap between them was recorded as one enormous sample — it landed in the
|
||||||
|
`max` field of otherwise healthy windows and counted as one stall per scroll,
|
||||||
|
roughly 0.2% at 500 frames to a window, which is the same order as the real
|
||||||
|
stall rates it sat beside. Current builds emit none, but the guard costs
|
||||||
|
nothing and keeps the command honest against older journals.
|
||||||
|
|
||||||
|
A scroller whose p95 sits several times its median is the one to fix, and it is
|
||||||
|
usually the one doing the most per-frame work rather than the one configured
|
||||||
|
worst. Measured over 20 minutes with two scrollers set identically at 100 px/s,
|
||||||
|
the leaderboard held 10 ms flat while the odds ticker spent ~20% of its frames
|
||||||
|
on duplicates. Same settings, different render cost: odds does more per-frame
|
||||||
|
work, and more variably, so it is first to land a frame that advances less than
|
||||||
|
a whole pixel. Check the render path before the config.
|
||||||
|
|
||||||
|
Then confirm what the plugin actually loaded — config edits do not always reach
|
||||||
|
the running code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
|
||||||
|
```
|
||||||
|
|
||||||
|
If a plugin logs its scroll config **twice** with different modes, the second
|
||||||
|
line is what is running.
|
||||||
|
|
||||||
|
## Rebuilding the binding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/build_rgbmatrix_nogil.sh # build into a scratch dir
|
||||||
|
sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
||||||
|
sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
The build never touches the installed module. `--install` backs up the original
|
||||||
|
to `~/rgbmatrix-core.so.ORIGINAL` first, and rolls back automatically if the
|
||||||
|
service does not come back healthy. Requires `build-essential`; Cython is
|
||||||
|
installed into a cached venv under `~/.cache/ledmatrix-cython`.
|
||||||
|
|
||||||
|
Re-run it after upgrading `rpi-rgb-led-matrix`, since a library upgrade
|
||||||
|
replaces the patched binding.
|
||||||
|
|
||||||
|
## Faster JSON
|
||||||
|
|
||||||
|
`src/cache/disk_cache.py` uses `orjson` when it is importable and falls back to
|
||||||
|
the stdlib otherwise, so it is optional:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pip3 install --break-system-packages orjson
|
||||||
|
```
|
||||||
|
|
||||||
|
Encoding is where it pays — about 7× on this hardware. Decoding gains far less
|
||||||
|
(~1.3× on large payloads) because the cost there is building Python objects,
|
||||||
|
not scanning text. That is also why moving parsing to a subprocess does not
|
||||||
|
help: `pickle.loads` of the same payload costs 8.1 ms against `json.loads` at
|
||||||
|
10.9 ms, so the work just moves rather than disappearing.
|
||||||
+48
-12
@@ -1,5 +1,35 @@
|
|||||||
# Skin System Architecture
|
# Skin System Architecture
|
||||||
|
|
||||||
|
## Status: not supported yet
|
||||||
|
|
||||||
|
**Skins don't render with the current scoreboard plugins.** The skin system
|
||||||
|
below works in isolation (it loads, validates and renders skins in
|
||||||
|
`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a
|
||||||
|
running display calls it:
|
||||||
|
|
||||||
|
- The only render hook is `SportsCore._render_game()` in
|
||||||
|
`src/base_classes/sports/core.py`.
|
||||||
|
- None of the current scoreboard plugins build on `src.base_classes`. The
|
||||||
|
official scoreboards in the `ledmatrix-plugins` monorepo, and the
|
||||||
|
third-party scoreboards in the plugin registry, carry their own sports and
|
||||||
|
rendering code (with the shared `src/common/sports_*` helpers) and never
|
||||||
|
reach `SportsCore._render_game()`.
|
||||||
|
|
||||||
|
So a skin can be dropped into `skins/` and named in a plugin's config, but the
|
||||||
|
scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the
|
||||||
|
hook, core does not offer skins to users:
|
||||||
|
|
||||||
|
- The plugin config page shows no **Visual Skin** dropdown.
|
||||||
|
- The Plugin Store hides registry entries with `"type": "skin"` and refuses
|
||||||
|
to install one (`POST /api/v3/plugins/install` answers 400 with the reason).
|
||||||
|
- `GET /api/v3/skins` still lists what is in `skins/`, with
|
||||||
|
`"supported": false` and a `message`.
|
||||||
|
- A config that already contains `"skin"` / `"skin_options"` still loads,
|
||||||
|
validates and saves unchanged; the value is simply unused.
|
||||||
|
|
||||||
|
The rest of this document describes the design as built, for whoever wires a
|
||||||
|
scoreboard to it.
|
||||||
|
|
||||||
Skins are user-installable **visual overlays** for the sports scoreboards.
|
Skins are user-installable **visual overlays** for the sports scoreboards.
|
||||||
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
||||||
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
||||||
@@ -32,8 +62,10 @@ crashing) simply restores the built-in look.
|
|||||||
|
|
||||||
## The render funnel
|
## The render funnel
|
||||||
|
|
||||||
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
A sports scoreboard built on the `src/base_classes/sports/` package
|
||||||
built on the `src/base_classes/sports/` package, `core.py`) renders through exactly one seam:
|
(`core.py`) renders through exactly one seam. No current scoreboard plugin is
|
||||||
|
built on it (see [Status](#status-not-supported-yet)), so for them this seam is
|
||||||
|
never reached:
|
||||||
`SportsCore._render_game(game, force_clear)`.
|
`SportsCore._render_game(game, force_clear)`.
|
||||||
|
|
||||||
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||||
@@ -135,22 +167,26 @@ Inside the plugin's own config section in `config/config.json`:
|
|||||||
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
||||||
section, it persists across plugin reinstalls like every other setting.
|
section, it persists across plugin reinstalls like every other setting.
|
||||||
|
|
||||||
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the
|
||||||
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
*served* schema for plugins with matching skins installed. While skins are
|
||||||
*served* schema only. Validation never sees the enum — so a config that
|
unsupported the plugin schema endpoint does not call it, so the dropdown is
|
||||||
references an uninstalled skin stays valid (rendering just falls back), and
|
not shown. Validation never sees the enum either way: the base schema allows
|
||||||
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
any `skin` value, so a config that references an uninstalled skin stays valid.
|
||||||
lists installed skins (optionally filtered by `?plugin_id=`).
|
`GET /api/v3/skins` lists installed skins (optionally filtered by
|
||||||
|
`?plugin_id=`) and reports `"supported": false`.
|
||||||
|
|
||||||
## Distribution
|
## Distribution
|
||||||
|
|
||||||
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
||||||
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
||||||
plugins.
|
plugins.
|
||||||
- **Store:** registry entries with `"type": "skin"` install through the same
|
- **Store (disabled while unsupported):** registry entries with
|
||||||
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
`"type": "skin"` are hidden from the store list and refused on install.
|
||||||
validates `skin.json` (including the API major version) instead of
|
`PluginStoreManager._install_skin_from_info` is kept: once
|
||||||
`manifest.json`, and never installs dependencies — skins are render-only
|
`SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such
|
||||||
|
entries install through the same `plugins.json` pipeline, land in `skins/`,
|
||||||
|
are validated against `skin.json` (including the API major version) instead
|
||||||
|
of `manifest.json`, and never install dependencies — skins are render-only
|
||||||
(stdlib + PIL + the provided context, no third-party packages in v1).
|
(stdlib + PIL + the provided context, no third-party packages in v1).
|
||||||
|
|
||||||
## Trust model
|
## Trust model
|
||||||
|
|||||||
+169
-37
@@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes.
|
|||||||
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||||
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||||
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
|
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. |
|
||||||
|
|
||||||
The core API is **additive-only**. A method the plugins call is never removed or
|
The core API is **additive-only**. A method the plugins call is never removed or
|
||||||
given a new required parameter; new behavior arrives as new methods with
|
given a new required parameter; new behavior arrives as new methods with
|
||||||
@@ -204,7 +204,7 @@ legacy compatibility rather than the mechanism.
|
|||||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
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.
|
**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
|
The original plan folded the last two together; they are separated here because
|
||||||
one of them is safe by construction and the other is not.
|
one of them cannot break a user on an old core and the other can.
|
||||||
|
|
||||||
| Phase | Scope | Status | Gate |
|
| Phase | Scope | Status | Gate |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -212,9 +212,9 @@ one of them is safe by construction and the other is not.
|
|||||||
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
||||||
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||||
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
||||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
|
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
|
||||||
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
|
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
|
||||||
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
|
| **B6** | Sunset — delete the bundled copies | ✅ | Ran 2026-09-01, all eight. Floors at 3.2.0; the store refuses on all three routes in (#431/#433, #508, #510). See "B6 — what actually happened" |
|
||||||
|
|
||||||
### B4 — what "ship 3.2.0" actually requires
|
### B4 — what "ship 3.2.0" actually requires
|
||||||
|
|
||||||
@@ -234,6 +234,20 @@ a floor can be trusted against, and today it is not:
|
|||||||
compares the plugin's manifest version against the registry's
|
compares the plugin's manifest version against the registry's
|
||||||
`latest_version` and nothing else.
|
`latest_version` and nothing else.
|
||||||
|
|
||||||
|
*Fixed, in two parts.* `install_plugin` gained the gate in #431/#433, which
|
||||||
|
covers every registry-managed install and, through `_reinstall_with_rollback`,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
the floor "at install/update time", and B6 rests on that being true rather
|
||||||
|
than merely written down. `install_from_url` — sideloading a plugin from a
|
||||||
|
URL — is still ungated.
|
||||||
|
|
||||||
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
||||||
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
||||||
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
||||||
@@ -262,13 +276,21 @@ default is the more restrictive.
|
|||||||
`compatible_versions`. No manifest still carries it, so there is nothing to
|
`compatible_versions`. No manifest still carries it, so there is nothing to
|
||||||
migrate there.)
|
migrate there.)
|
||||||
|
|
||||||
### B5 — adoption is safe by construction
|
### B5 — the *fallback* is safe by construction; the modern path is not
|
||||||
|
|
||||||
|
The heading matters, because the unqualified version of this claim is false and
|
||||||
|
this document proves it two sections down: four of the eight adopted plugins
|
||||||
|
shipped with scroll mode broken on a 3.2.0 core. What is safe by construction is
|
||||||
|
narrower than "adoption".
|
||||||
|
|
||||||
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
||||||
guarded import (see the Upgradability table above). On a core that ships the
|
guarded import (see the Upgradability table above). On a core that doesn't ship
|
||||||
module the plugin uses core code; on one that doesn't it falls back and behaves
|
the module the plugin falls back and behaves exactly as it does today. That
|
||||||
exactly as it does today. There is no version of this step that breaks a user,
|
fallback compatibility — and only that — is safe by construction. On a core that
|
||||||
which is why it does not wait for B6's gate.
|
*does* ship the module, correctness is not automatic — object-level and scroll-mode validation
|
||||||
|
(building both classes and comparing, per the retrospective below) is required
|
||||||
|
to prove full behavior. There is no version of this step that breaks a user *on
|
||||||
|
an old core*, which is why it does not wait for B6's gate.
|
||||||
|
|
||||||
The hockey scroll-display pilot is **already validated**: adopted against a core
|
The hockey scroll-display pilot is **already validated**: adopted against a core
|
||||||
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
||||||
@@ -297,7 +319,8 @@ been in users' hands long enough that the population running a core without it
|
|||||||
is small.** The bundled copies cost disk space; deleting them early costs
|
is small.** The bundled copies cost disk space; deleting them early costs
|
||||||
scoreboards, silently. That trade is not close.
|
scoreboards, silently. That trade is not close.
|
||||||
|
|
||||||
Before the first sunset, add a **compatibility regression test**. It has to
|
Before the first sunset, add a **compatibility regression test**. **Built:**
|
||||||
|
core `test/test_sports_sunset_matrix.py` (#505). It has to
|
||||||
cover four cases, not one — B5's safety claim and B6's failure mode are
|
cover four cases, not one — B5's safety claim and B6's failure mode are
|
||||||
different propositions and only the second is obvious:
|
different propositions and only the second is obvious:
|
||||||
|
|
||||||
@@ -321,35 +344,144 @@ gate rather than trusting the failure to be noticed.
|
|||||||
The same suite should exercise the install/update gate, since it is the other
|
The same suite should exercise the install/update gate, since it is the other
|
||||||
half of the guarantee.
|
half of the guarantee.
|
||||||
|
|
||||||
|
### B6 — what actually happened
|
||||||
|
|
||||||
|
**Ran 2026-09-01, across all eight scoreboards.** Held from 2026-08-05 to
|
||||||
|
2026-09-01 on the argument below, which is kept because the reasoning applies to
|
||||||
|
the next module, not because it is still in force.
|
||||||
|
|
||||||
|
**The hold, and why it lifted.** The stated gate was evidence of 3.2.0 uptake —
|
||||||
|
"a few months of it being the default download, or store-side install data".
|
||||||
|
That evidence never arrived and could not: the core updates by
|
||||||
|
`git pull --rebase`, so release-asset counts cannot measure uptake, and no
|
||||||
|
store-side telemetry exists. What changed instead is that the *risk* the gate
|
||||||
|
protected against was closed directly. The store now refuses a plugin whose
|
||||||
|
floor exceeds the running core on **all three** routes in:
|
||||||
|
|
||||||
|
| route | gated by |
|
||||||
|
|---|---|
|
||||||
|
| `install_plugin` — every path that re-downloads, `_reinstall_with_rollback` included | #431, #433 |
|
||||||
|
| `update_plugin`'s git branch — pulls in place, re-downloads nothing | #508 |
|
||||||
|
| `install_from_url` — sideloading | #510 |
|
||||||
|
|
||||||
|
With all three closed a pre-3.2.0 user cannot receive a sunset plugin at all;
|
||||||
|
they keep the version they already run. The population the hold existed to
|
||||||
|
protect is protected by refusal rather than by a bundled copy — which is what
|
||||||
|
the copy was standing in for.
|
||||||
|
|
||||||
|
**What shipped.** Eight plugins, ~5,800 lines of frozen fallback deleted. Each:
|
||||||
|
copy removed, guarded import collapsed to a plain one, floor raised to 3.2.0,
|
||||||
|
`test_core_fallback.py` rewritten as `test_core_scroll.py` asserting the sunset
|
||||||
|
rather than the fallback. `scripts/check_scroll_adoption.py` gained
|
||||||
|
`sunset_violations` and a `SUNSET_PLUGINS` set naming all eight, so a
|
||||||
|
resurrected copy or a returned guard fails CI.
|
||||||
|
|
||||||
|
Delivered as plugins #346 (hockey, later folded into #351), #349 (football),
|
||||||
|
#350 (baseball), #351 (the remaining six).
|
||||||
|
|
||||||
|
**Two things found by doing it, both worth carrying forward:**
|
||||||
|
|
||||||
|
- **Only one fallback held orchestration logic the core lacked.** baseball's
|
||||||
|
`_configure_scroll_helper` reinterpreted `scroll_speed` as pixels-per-*frame*
|
||||||
|
when `speed × delay` fell outside the 0.1–5.0 window — measured, 10–20×
|
||||||
|
faster than configured for a speed between 1.0 and 5.0. Standardised onto the
|
||||||
|
core's behaviour (honour the documented px/sec, clamp) rather than preserved.
|
||||||
|
Every other difference across the eight was a docstring, an unreachable
|
||||||
|
`scroll_helper is None` guard, or an equivalent diagnostic.
|
||||||
|
- **Two tests had been leaning on the guard without anyone knowing.**
|
||||||
|
`soccer/test_live_screens.py` stubbed `src` in a way that shadowed the core,
|
||||||
|
so its guarded import fell back and the test had been exercising the *frozen
|
||||||
|
copy* rather than the shipping class since B5. Before sunsetting anything else
|
||||||
|
that carries a guarded core import, grep for tests that stub `src`.
|
||||||
|
|
||||||
|
**The floor-raising traps still apply** to any future sunset: four plugins
|
||||||
|
declare their floor top-level, where editing `versions[0]` is a silent no-op,
|
||||||
|
and the floor has three live spellings (`min_ledmatrix_version`,
|
||||||
|
`requires.min_ledmatrix_version`, `versions[].ledmatrix_min_version`, plus
|
||||||
|
deprecated `ledmatrix_min`). See
|
||||||
|
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
|
||||||
|
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.
|
||||||
|
|
||||||
|
### B5 retrospective — what the adoption actually cost
|
||||||
|
|
||||||
|
Recorded because it is the evidence behind the two decisions above, and because
|
||||||
|
"the adoption went fine" is not what happened.
|
||||||
|
|
||||||
|
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
|
||||||
|
repaired in plugins-repo #251. The restructure lifted the content methods
|
||||||
|
verbatim but left the state they read off `self` behind: separator-icon
|
||||||
|
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
|
||||||
|
hockey/basketball/lacrosse could not construct the scroll display at all; afl
|
||||||
|
raised inside `prepare_scroll_content`, which the core base *catches*, so its
|
||||||
|
only symptom was scroll mode silently drawing nothing.
|
||||||
|
|
||||||
|
Three things are worth carrying forward:
|
||||||
|
|
||||||
|
- **The bundled fallback did not protect anyone from this.** The break was on
|
||||||
|
the modern path, which the fallback never touches. Carrying the second copy
|
||||||
|
bought nothing against the actual defect while creating the divergence that
|
||||||
|
produced it. That is an argument *for* B6, not against it.
|
||||||
|
- **Every gate was green.** The safety harness renders the scoreboard screens,
|
||||||
|
not scroll mode; `test_core_fallback.py` checked that methods existed and that
|
||||||
|
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
|
||||||
|
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
|
||||||
|
source and **build the object**: construct both classes on both paths, compare
|
||||||
|
the separator icons they end up with, and assert the adopted class ends up
|
||||||
|
with every instance attribute the bundled one sets.
|
||||||
|
- **Test what the change touches, not what is convenient to render.** Scroll
|
||||||
|
mode had no coverage because the harness could not reach it. A comparison
|
||||||
|
harness that renders the same games through both paths and diffs the pixels
|
||||||
|
needs no per-sport knowledge of the right answer, only that adopting core code
|
||||||
|
did not change it.
|
||||||
|
|
||||||
|
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
|
||||||
|
After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||||
|
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||||
|
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||||
|
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||||
|
and its one delivered user-visible gain is that adopted plugins honour the
|
||||||
|
global `target_fps` instead of hardcoding ~100 FPS.
|
||||||
|
|
||||||
|
### Decision: stop adopting further modules until B6 closes
|
||||||
|
|
||||||
|
`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
|
## What's next
|
||||||
|
|
||||||
In order. Each step is independently useful and independently revertible.
|
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).
|
||||||
|
|
||||||
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
|
What actually remains, smallest first:
|
||||||
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
|
|
||||||
"3.2.0"` refer to something real.
|
1. **Soak the adoptions on hardware.** football and hockey have been run on a
|
||||||
2. **Make the version number honest.** Have the release process assert that the
|
live rig through real games; baseball was watched through one earlier. The
|
||||||
tag, the GitHub release, and `src.__version__` agree — a check in CI is
|
rest are proven by harness, unit tests and pixel comparison. Out-of-season
|
||||||
cheaper than the confusion of the last two releases. Then revisit the
|
sports cannot be soaked until their season starts. When you do, **check the
|
||||||
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
|
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
|
||||||
warning for the users who most need it.
|
sunset plugin and tell you nothing about the scroll code the sunset changed.
|
||||||
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
|
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
|
||||||
`.update_plugin`: refuse a plugin whose declared floor exceeds
|
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
|
||||||
`src.__version__`, and surface the reason in the store UI rather than only
|
that landed after 3.2.0, so it is un-installable until the release exists.
|
||||||
the log. This is the single change that turns the floor from documentation
|
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||||
into a guarantee, and B6 depends on it.
|
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||||
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
|
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||||
`compatible_versions` (see above — that field is the required, canonical one,
|
~36,500 more in the eight `sports.py`. Note that core already ships
|
||||||
and the gate does not read it yet). Currently 28 plugins spell the floor both
|
`src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin
|
||||||
ways across their `versions[]` entries, 12 use only the old spelling, and 2
|
imports** — check whether it has drifted before treating it as the target.
|
||||||
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
|
|
||||||
version-bump wave isn't worth it — but the `compatible_versions` half has to
|
|
||||||
cover every manifest the gate can refuse, or define explicit legacy handling,
|
|
||||||
before the gate is allowed to block anything.
|
|
||||||
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
|
|
||||||
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
|
|
||||||
6. **Only then plan B6**, with the compatibility regression test described above
|
|
||||||
in CI first.
|
|
||||||
|
|
||||||
## How to keep this project healthy
|
## How to keep this project healthy
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,22 @@ The installation script:
|
|||||||
- Installs and configures `dnsmasq` (DHCP server for AP mode)
|
- Installs and configures `dnsmasq` (DHCP server for AP mode)
|
||||||
- These services can interfere with normal WiFi client mode
|
- These services can interfere with normal WiFi client mode
|
||||||
|
|
||||||
### 3. Reboot After Installation
|
### 3. The Board Ran Out of Memory
|
||||||
|
|
||||||
|
On a 512MB or 1GB board, memory exhaustion stops `sshd` being able to fork a
|
||||||
|
session process. The connection is accepted and then closed immediately, before
|
||||||
|
any banner:
|
||||||
|
|
||||||
|
```text
|
||||||
|
kex_exchange_identification: Connection closed by remote host
|
||||||
|
```
|
||||||
|
|
||||||
|
The giveaway is that the board is otherwise healthy — ping is clean and the web
|
||||||
|
UI still responds — but nothing that needs to start a new process works, and
|
||||||
|
the panel is usually dark. Only a power cycle clears it. See
|
||||||
|
[LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md).
|
||||||
|
|
||||||
|
### 4. Reboot After Installation
|
||||||
|
|
||||||
If the script reboots the Pi (which it recommends), network services may restart in a different state, potentially triggering AP mode.
|
If the script reboots the Pi (which it recommends), network services may restart in a different state, potentially triggering AP mode.
|
||||||
|
|
||||||
@@ -190,11 +205,23 @@ The web interface allows you to:
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
**SSH becomes unavailable because**:
|
**SSH becomes unavailable because** — two unrelated causes, and they need
|
||||||
|
different responses:
|
||||||
|
|
||||||
|
*AP mode (most common):*
|
||||||
- WiFi monitor service enables AP mode when WiFi disconnects
|
- WiFi monitor service enables AP mode when WiFi disconnects
|
||||||
- AP mode switches WiFi from client to access point mode
|
- AP mode switches WiFi from client to access point mode
|
||||||
- Pi loses connection to your original network
|
- Pi loses connection to your original network
|
||||||
|
|
||||||
|
*Memory exhaustion (low-memory boards):*
|
||||||
|
- The board runs out of memory, so `sshd` cannot fork a session process
|
||||||
|
- The connection is accepted and closed before any banner
|
||||||
|
- Ping still answers and the web UI still responds, so it looks healthy
|
||||||
|
- The panel is usually dark and the service cannot restart
|
||||||
|
- **Only a power cycle clears this** — there is no remote recovery, because
|
||||||
|
every remote route needs a new process
|
||||||
|
- Prevention and tuning: [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md)
|
||||||
|
|
||||||
**To regain SSH**:
|
**To regain SSH**:
|
||||||
1. Connect to **LEDMatrix-Setup** AP network (password: `ledmatrix123`)
|
1. Connect to **LEDMatrix-Setup** AP network (password: `ledmatrix123`)
|
||||||
2. SSH to `192.168.4.1`
|
2. SSH to `192.168.4.1`
|
||||||
|
|||||||
@@ -96,6 +96,33 @@ Configure basic system settings:
|
|||||||
- **Plugin System Settings** — including the `plugins_directory` (default
|
- **Plugin System Settings** — including the `plugins_directory` (default
|
||||||
`plugin-repos/`) used by the plugin loader
|
`plugin-repos/`) used by the plugin loader
|
||||||
- **Autostart** options for the display service
|
- **Autostart** options for the display service
|
||||||
|
- **Automatic updates** — once a week, update LEDMatrix and every installed
|
||||||
|
plugin with a newer version. Off by default. Runs 2–5 AM local time when
|
||||||
|
possible, otherwise within a day of being due. The last result and next
|
||||||
|
check are shown under the toggle, and anything other than success raises a
|
||||||
|
banner on **Overview**.
|
||||||
|
- *Checks first:* the code update is skipped, with the reason shown, if
|
||||||
|
tracked files were edited locally, the checkout has local commits, a
|
||||||
|
rebase/merge is in progress, the branch has no upstream, less than 300 MB
|
||||||
|
is free, or the newest version already failed once. A failed fetch is
|
||||||
|
retried the next day.
|
||||||
|
- *Health check and rollback:* after pulling, `ledmatrix-update-verify.service`
|
||||||
|
restarts the services and checks that the web interface responds and the
|
||||||
|
display (if it was running) stays up. If not — or if the new dependencies
|
||||||
|
failed to install — it resets to the previous commit, reinstalls the
|
||||||
|
previous dependencies and restarts again. A running display is restarted;
|
||||||
|
a stopped one stays stopped.
|
||||||
|
- *Plugins* update through the Plugin Store, which refuses versions that need
|
||||||
|
a newer LEDMatrix and restores the old copy when an install fails. When the
|
||||||
|
code changed, plugins wait until it passes its health check. Plugins are
|
||||||
|
not health-checked after updating.
|
||||||
|
- *Setup needs no SSH.* Turning the toggle on restarts the display service,
|
||||||
|
which installs the health check (`ledmatrix-update-verify.path` and
|
||||||
|
`.service`); the General tab shows when it is ready, or why setup failed.
|
||||||
|
Until then only plugins update. New installs set it up during
|
||||||
|
installation and can switch updates on with
|
||||||
|
`first_time_install.sh --enable-auto-update` (or `LEDMATRIX_AUTO_UPDATE=1`,
|
||||||
|
which `one-shot-install.sh` passes through), or at the installer's prompt.
|
||||||
|
|
||||||
Click **Save** to write changes to `config/config.json`. Most changes
|
Click **Save** to write changes to `config/config.json`. Most changes
|
||||||
require a display service restart from **Overview**.
|
require a display service restart from **Overview**.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Web UI Technical Audit — September 2026
|
||||||
|
|
||||||
|
Scope: `web_interface/` (Flask + HTMX + Alpine, `templates/v3/`, `static/v3/`).
|
||||||
|
Method: Impeccable design detector, code review (accessibility; performance,
|
||||||
|
theming, responsive), and a live pass on the running app at desktop and
|
||||||
|
375px mobile widths in light and dark themes. Severe claims were verified
|
||||||
|
against the live page; one was rejected (see below). No code was changed.
|
||||||
|
|
||||||
|
Product context: see [`PRODUCT.md`](../../PRODUCT.md).
|
||||||
|
|
||||||
|
## Health score: 8/20 (Poor)
|
||||||
|
|
||||||
|
| # | Dimension | Score | Key finding |
|
||||||
|
|---|-----------|-------|-------------|
|
||||||
|
| 1 | Accessibility | 2 | Focus rings never render; modals have no dialog semantics or focus management |
|
||||||
|
| 2 | Performance | 2 | ~1.2 MB JS (≈250 KB gzip) on every page; SSE streams and polling never pause |
|
||||||
|
| 3 | Responsive | 2 | Mobile drawer works; header title wraps to 3 lines; many ~24px touch targets |
|
||||||
|
| 4 | Theming | 1 | Tokens exist but hex dominates; dark mode is a class-by-class patch with leaks |
|
||||||
|
| 5 | Implementation integrity | 1 | Templates use Tailwind classes that don't exist in the stylesheet |
|
||||||
|
|
||||||
|
## Implementation integrity verdict: fail
|
||||||
|
|
||||||
|
There is no Tailwind build. `static/v3/app.css` is a hand-written subset of
|
||||||
|
Tailwind, while templates and JS are authored as if full Tailwind were loaded.
|
||||||
|
|
||||||
|
- **333 of 516 utility class names used have no CSS rule** (2,582 uses),
|
||||||
|
confirmed against the live stylesheets. Top offenders: `border` (250),
|
||||||
|
`text-gray-700` (183), `block` (148), `mr-1` (127), `hidden` (79),
|
||||||
|
`py-1`, `text-blue-600`, `px-2`, `text-center`, `hover:bg-blue-700`,
|
||||||
|
`divide-y`, `uppercase`, `font-mono`.
|
||||||
|
- **`.hidden` has never existed in `app.css`**, so the 145
|
||||||
|
`classList.add/remove/toggle('hidden')` calls across 26 files do nothing.
|
||||||
|
Visible proof: the header shows both the moon and sun theme icons.
|
||||||
|
- **15 classes are defined only under `[data-theme="dark"]`** (e.g.
|
||||||
|
`bg-blue-50`, `bg-red-50`, `bg-yellow-50`, `border-blue-200`,
|
||||||
|
`text-red-700`), so tinted notice boxes are unstyled in light mode.
|
||||||
|
- Visible damage: the Getting Started checklist (`partials/overview.html:96-117`)
|
||||||
|
renders native gray outset buttons in both themes; 32 visible buttons on
|
||||||
|
the Plugin Manager page render with default browser chrome; search icons
|
||||||
|
overlap inputs; error/diff modal backdrops are transparent
|
||||||
|
(`bg-gray-500 bg-opacity-75` undefined).
|
||||||
|
|
||||||
|
Other drift:
|
||||||
|
- Four competing `showNotification` definitions (`app.js:6`,
|
||||||
|
`app-shell.js:2464`, `widgets/notification.js:298`, `partials/fonts.html:249`)
|
||||||
|
— the winner depends on load order — plus 53 `alert()`/`confirm()` calls.
|
||||||
|
- At least four modal implementations (on-demand modal in `base.html:1032`,
|
||||||
|
Tailwind-UI style in `error_handler.js`/`diff_viewer.js`, `.jfm-*`/`.pfm-*`
|
||||||
|
with injected CSS, ad-hoc modals in `plugins_manager.js`).
|
||||||
|
- `.btn` mixed with ~90 hand-assembled color-utility button combos.
|
||||||
|
- SSE wiring duplicated in `app-shell.js:5-60` and `app.js:186-205`.
|
||||||
|
|
||||||
|
## Findings by severity
|
||||||
|
|
||||||
|
### P0
|
||||||
|
|
||||||
|
**Undefined utility layer** (above). Every show/hide toggle and every layout
|
||||||
|
built from missing classes silently fails; root cause of most visual bugs.
|
||||||
|
Fix: replace the hand-rolled subset with a real, purged Tailwind build
|
||||||
|
(with dark-mode variants), or at minimum define the high-use missing classes
|
||||||
|
(`hidden`, `border`, `block`, spacing/text utilities) and a button reset.
|
||||||
|
→ `/impeccable harden`
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
- **Focus rings never render.** `focus:ring-2` (`app.css:289-291`) references
|
||||||
|
`--tw-ring-inset` and `--tw-ring-offset-width`, which are never defined, so
|
||||||
|
the `box-shadow` is invalid. `focus:outline-none` (18 uses) does remove the
|
||||||
|
outline. `peer-focus:ring-4` has no rule, so the plugin enable toggle
|
||||||
|
(`plugins_manager.js:1586-1593`, `sr-only` checkbox) shows no focus.
|
||||||
|
WCAG 2.4.7.
|
||||||
|
- **Modals lack dialog semantics.** Only `json-file-manager.js` has
|
||||||
|
`role="dialog"`/`aria-modal`/Escape/initial focus; none trap focus or
|
||||||
|
return it. On-demand (`base.html:1032`), error (`error_handler.js:164-205`),
|
||||||
|
diff (`diff_viewer.js:211-214`), plugin file manager
|
||||||
|
(`plugin-file-manager.js:372-392, 576-599`), array-table editor
|
||||||
|
(`array-table.js:460-467`) have none of it. WCAG 2.1.2 / 4.1.2.
|
||||||
|
- **Unnamed controls.** ~16 icon-only buttons with no accessible name, e.g.
|
||||||
|
`base.html:1036`, `plugins.html:173,210`, `plugin_config.html:485,656`,
|
||||||
|
`number-input.js:102,129`, `text-input.js:120`, `date-picker.js:95`,
|
||||||
|
`time-picker.js:100`, `password-input.js:141`. ~115 of 245 form fields have
|
||||||
|
no label (hotspots: `plugin_config.html` 19, `starlark_config.html` 14,
|
||||||
|
`plugins.html` 11); confirmed live on the 11 store search/sort/filter inputs.
|
||||||
|
- **Captive WiFi setup page** (first-run surface): `#msg` status has no live
|
||||||
|
region, and `outline:none` is replaced by a 15%-alpha shadow
|
||||||
|
(`captive_setup.html:16,48`).
|
||||||
|
- **Background traffic never stops.** `/stream/stats` and `/stream/display`
|
||||||
|
SSE stay open on every tab (display frames push with no preview visible).
|
||||||
|
Tab timers keep running after leaving the tab (`display.html:1046` 5s,
|
||||||
|
`logs.html:222` 5s, `tools.html:987` 15s, `plugins_manager.js:1882` 15s,
|
||||||
|
update check `base.html:1196` 30min). Only `tools.html:999` checks
|
||||||
|
`visibilitychange`. Costly on a Pi Zero 2 W.
|
||||||
|
- **Page weight.** 47 script tags on every page, including all 33 widgets
|
||||||
|
(`base.html:984-1018`). `app-shell.js` (177 KB) is render-blocking
|
||||||
|
(`base.html:956`); `plugins_manager.js` is 277 KB.
|
||||||
|
- **Dark mode leaks.** `plugin-file-manager.js` (53 hex) and
|
||||||
|
`json-file-manager.js` (63 hex, e.g. `.jfm-modal-box{background:#fff}`)
|
||||||
|
inject CSS that ignores `data-theme`; `.form-control` hard-codes
|
||||||
|
`#fff`/`#111827` (`app.css:668-671`). `app.css` has 186 hex + 46 rgb
|
||||||
|
literals vs 94 `var(--…)` uses.
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
- Toasts: `role="alert"` inside an `aria-live="polite"` container
|
||||||
|
(`notification.js:78,156`) → double/assertive announcements; auto-dismiss 4s.
|
||||||
|
- `prefers-reduced-motion` covers 3 animations; ~106 `animate-pulse`/`fa-spin`
|
||||||
|
uses, `modalSlideIn`, and toast slides ignore it.
|
||||||
|
- Mobile: header title wraps to three lines and spills out of the header;
|
||||||
|
~33 plugin-card buttons are `text-xs px-2 py-1` (~24px); `#logs-container`
|
||||||
|
forced to 400/350px with `!important` (`app.css:399-411`).
|
||||||
|
- Logs panel contrast: `text-gray-400` on `bg-gray-900` ≈ 3.9:1
|
||||||
|
(`logs.html:75,86`).
|
||||||
|
- Three unnamed nested `<nav>` landmarks (`base.html:474,477,548`); no skip link.
|
||||||
|
- Plugin lists fully rebuilt via `innerHTML` on every filter change
|
||||||
|
(`plugins_manager.js:1554, 3784, 3993, 4389, 5905`); `logs.html:225` adds a
|
||||||
|
reflow-forcing resize listener on every partial load.
|
||||||
|
|
||||||
|
### P3
|
||||||
|
|
||||||
|
- No `loading="lazy"` on images; Font Awesome `font-display:block`.
|
||||||
|
- Unpinned `alpinejs@3.x.x` unpkg fallback (`base.html:241`).
|
||||||
|
- `widgets/example-color-picker.js` is not loaded anywhere.
|
||||||
|
- Detector: 3px accent stripe on `.plugin-card::before` (`app.css:721`).
|
||||||
|
|
||||||
|
## Verified and rejected
|
||||||
|
|
||||||
|
- **"Static assets are never cache-busted" (raised as P0): false.**
|
||||||
|
`app.py:491` (`@app.url_defaults add_static_version`) appends file mtime as
|
||||||
|
`?v=` to every static URL; the live HTML confirms it. The manual
|
||||||
|
`?v=20260307` on two script tags is merely redundant.
|
||||||
|
- Light-mode gray text contrast is mostly fine: `app.css` remaps grays darker
|
||||||
|
(4.8–10:1).
|
||||||
|
- Detector `gray-on-color` hits at `app.css:84,285` and `broken-image` hits
|
||||||
|
(JS-populated `src`) are not real rendered issues.
|
||||||
|
|
||||||
|
## What works
|
||||||
|
|
||||||
|
- Theme set before first paint, follows OS preference, `data-theme` + tokens.
|
||||||
|
- Mobile drawer: Escape closes it, focus returns to the hamburger, 44px rows.
|
||||||
|
- `aria-current="page"` on nav tabs; real `<header>` and `<main>`.
|
||||||
|
- Status colors always paired with text; nearly all images have alt text.
|
||||||
|
- `toggle-switch.js` uses `role="switch"`; vendor assets self-hosted.
|
||||||
|
|
||||||
|
## Open decisions (block the P0 fix approach)
|
||||||
|
|
||||||
|
Recorded as undecided in `PRODUCT.md`:
|
||||||
|
- Must the UI work fully offline (no CDN fallbacks)?
|
||||||
|
- Is a Node/CSS build step acceptable for contributors?
|
||||||
|
- Is WCAG 2.2 AA a formal requirement?
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
1. **[P0] `/impeccable harden`** — fix the utility layer (real Tailwind build
|
||||||
|
or define missing classes + button reset).
|
||||||
|
2. **[P1] `/impeccable harden`** — focus-ring variables and `peer-focus`;
|
||||||
|
one shared accessible modal helper; name icon buttons and label fields;
|
||||||
|
live region on the captive page.
|
||||||
|
3. **[P1] `/impeccable optimize`** — pause SSE/timers on hidden tab or page;
|
||||||
|
load widget scripts on demand.
|
||||||
|
4. **[P1] `/impeccable colorize`** — move file-manager CSS and `.form-control`
|
||||||
|
onto theme tokens.
|
||||||
|
5. **[P2] `/impeccable adapt`** — header wrap, touch targets, log height.
|
||||||
|
6. **[P2] `/impeccable animate`** — reduced-motion alternatives.
|
||||||
|
7. **`/impeccable polish`** — final pass.
|
||||||
+51
-11
@@ -285,7 +285,9 @@ Guidelines:
|
|||||||
|
|
||||||
### Step 1: Create Widget File
|
### Step 1: Create Widget File
|
||||||
|
|
||||||
Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`:
|
Create a JavaScript file in your plugin's `widgets/` directory, named
|
||||||
|
`widgets/[widget-name].js`. The directory is not optional: it is the only
|
||||||
|
place the core will serve a widget from.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Ensure LEDMatrixWidgets registry is available
|
// Ensure LEDMatrixWidgets registry is available
|
||||||
@@ -366,7 +368,29 @@ window.LEDMatrixWidgets.register('my-custom-widget', {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Reference Widget in Schema
|
### Step 2: Declare the Widget in `manifest.json`
|
||||||
|
|
||||||
|
The manifest is the allowlist. A widget is served only if the plugin declares
|
||||||
|
it, so shipping a file under `widgets/` does not by itself publish it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"name": "my-custom-widget",
|
||||||
|
"script": "my-custom-widget.js",
|
||||||
|
"description": "What this widget is for"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` is what you use in `x-widget` and in the URL. `script` is optional and
|
||||||
|
defaults to `[name].js`; it must be a plain filename directly inside
|
||||||
|
`widgets/` (no paths). Both are validated against
|
||||||
|
`schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
### Step 3: Reference Widget in Schema
|
||||||
|
|
||||||
In your plugin's `config_schema.json`:
|
In your plugin's `config_schema.json`:
|
||||||
|
|
||||||
@@ -383,15 +407,30 @@ In your plugin's `config_schema.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Widget Loading
|
### Step 4: Widget Loading
|
||||||
|
|
||||||
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
|
The widget is loaded on demand when the plugin's configuration form renders a
|
||||||
|
field that references it. The system will:
|
||||||
|
|
||||||
1. Check if widget is registered in the core registry
|
1. Check whether the widget is already registered in the core registry.
|
||||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
|
||||||
3. Render the widget using the registered `render` function
|
That route serves the declared `script` from your plugin's `widgets/`
|
||||||
|
directory, as `text/javascript`.
|
||||||
|
3. Render it by calling the `render` function your script registered.
|
||||||
|
|
||||||
**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement.
|
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
|
||||||
|
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
|
||||||
|
|
||||||
|
**If the widget fails to load** (not declared, file missing, script throws, or
|
||||||
|
it never calls `register`), the field falls back to a plain text input holding
|
||||||
|
the current value. This is deliberate: a broken widget costs the user an
|
||||||
|
editor, not their configured value.
|
||||||
|
|
||||||
|
**Limitation:** the on-demand path applies to `string`-typed fields (the
|
||||||
|
default branch of the config-form renderer). Fields typed `object`, `array`,
|
||||||
|
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
|
||||||
|
dispatched by the server-side template to its own built-in renderers, so a
|
||||||
|
plugin-supplied `x-widget` on one of those is ignored today.
|
||||||
|
|
||||||
## Widget API Reference
|
## Widget API Reference
|
||||||
|
|
||||||
@@ -497,10 +536,11 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf
|
|||||||
- ✅ Plugin widget loading system implemented
|
- ✅ Plugin widget loading system implemented
|
||||||
|
|
||||||
**Current Behavior:**
|
**Current Behavior:**
|
||||||
- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||||
- Widget handlers are registered and available globally
|
- Widget handlers are registered and available globally
|
||||||
- Custom widgets can be created and registered
|
- Custom widgets can be created, declared in `manifest.json`, and are served
|
||||||
- Full client-side rendering is a future enhancement
|
and rendered on demand for `string`-typed fields
|
||||||
|
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
|
||||||
|
|
||||||
**Backwards Compatibility:**
|
**Backwards Compatibility:**
|
||||||
- All existing plugins using widgets continue to work without changes
|
- All existing plugins using widgets continue to work without changes
|
||||||
|
|||||||
Regular → Executable
+243
-9
@@ -125,6 +125,72 @@ fi
|
|||||||
# Get the home directory of the actual user
|
# Get the home directory of the actual user
|
||||||
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||||
|
|
||||||
|
# --- rpi-rgb-led-matrix checkout helpers -------------------------------------
|
||||||
|
# Run git as whoever owns the project directory. Run as root against a
|
||||||
|
# user-owned repo, git refuses it ("dubious ownership"), and anything it does
|
||||||
|
# create — such as .git/modules/<submodule> — ends up root-owned, locking the
|
||||||
|
# user out of their own checkout. A root-owned install keeps running as root.
|
||||||
|
_rgb_repo_owner() {
|
||||||
|
stat -c %U "$PROJECT_ROOT_DIR" 2>/dev/null || echo root
|
||||||
|
}
|
||||||
|
|
||||||
|
_git_as_repo_owner() {
|
||||||
|
local owner
|
||||||
|
owner=$(_rgb_repo_owner)
|
||||||
|
if [ "$(id -u)" = "0" ] && [ "$owner" != "root" ] && command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo -u "$owner" -H git "$@"
|
||||||
|
else
|
||||||
|
git "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Earlier installer versions ran the submodule git commands as root, leaving
|
||||||
|
# root-owned files the repo owner (and so _git_as_repo_owner) cannot write.
|
||||||
|
_reclaim_rgb_checkout() {
|
||||||
|
local owner path
|
||||||
|
owner=$(_rgb_repo_owner)
|
||||||
|
if [ "$(id -u)" != "0" ] || [ "$owner" = "root" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for path in "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" "$PROJECT_ROOT_DIR/.git/modules/rpi-rgb-led-matrix-master"; do
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
chown -R "$owner:" "$path" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# `git pull` on the main repo never moves an existing submodule checkout, so a
|
||||||
|
# submodule bump (e.g. the ARMv6 build fix for Pi Zero/1) would never reach a
|
||||||
|
# device installed before it. Move the checkout forward to the pinned commit —
|
||||||
|
# but never backward or sideways: a user who ran `git submodule update --remote`
|
||||||
|
# is newer than the pin and is left alone. Never fatal.
|
||||||
|
_sync_rgb_submodule() {
|
||||||
|
local sub="$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" pinned current
|
||||||
|
if [ ! -f "$PROJECT_ROOT_DIR/.gitmodules" ] || ! grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules" \
|
||||||
|
|| [ ! -e "$sub/.git" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! pinned=$(_git_as_repo_owner -C "$PROJECT_ROOT_DIR" rev-parse "HEAD:rpi-rgb-led-matrix-master" 2>/dev/null) \
|
||||||
|
|| [ -z "$pinned" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
current=$(_git_as_repo_owner -C "$sub" rev-parse HEAD 2>/dev/null) || current=""
|
||||||
|
if [ "$current" = "$pinned" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -n "$current" ] && _git_as_repo_owner -C "$sub" cat-file -e "${pinned}^{commit}" 2>/dev/null \
|
||||||
|
&& ! _git_as_repo_owner -C "$sub" merge-base --is-ancestor "$current" "$pinned" 2>/dev/null; then
|
||||||
|
echo "rpi-rgb-led-matrix-master is at ${current:0:7}, not behind the pinned ${pinned:0:7}; leaving it as is"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Updating rpi-rgb-led-matrix-master to the pinned commit ${pinned:0:7}..."
|
||||||
|
if ! _git_as_repo_owner -C "$PROJECT_ROOT_DIR" submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||||
|
echo "⚠ Could not update rpi-rgb-led-matrix-master to the pinned commit; building the existing checkout"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
# --- end rpi-rgb-led-matrix checkout helpers ---------------------------------
|
||||||
|
|
||||||
# Determine the Project Root Directory (where this script is located)
|
# Determine the Project Root Directory (where this script is located)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
|
||||||
@@ -154,6 +220,8 @@ SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0}
|
|||||||
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
|
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
|
||||||
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
|
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
|
||||||
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
||||||
|
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
|
||||||
|
AUTO_UPDATE=${LEDMATRIX_AUTO_UPDATE:-}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<USAGE
|
cat <<USAGE
|
||||||
@@ -168,12 +236,15 @@ Options:
|
|||||||
--skip-swap Never add temporary swap for the C++ build
|
--skip-swap Never add temporary swap for the C++ build
|
||||||
--build-jobs N Compile the C++ library with N parallel jobs
|
--build-jobs N Compile the C++ library with N parallel jobs
|
||||||
(default: scaled to available RAM)
|
(default: scaled to available RAM)
|
||||||
|
--enable-auto-update Turn on weekly automatic updates (with health
|
||||||
|
check and automatic rollback)
|
||||||
|
--no-auto-update Leave weekly automatic updates off
|
||||||
-h, --help Show this help message and exit
|
-h, --help Show this help message and exit
|
||||||
|
|
||||||
Environment variables (same effect as flags):
|
Environment variables (same effect as flags):
|
||||||
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
||||||
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
|
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
|
||||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N
|
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N, LEDMATRIX_AUTO_UPDATE=1|0
|
||||||
|
|
||||||
Low-memory devices:
|
Low-memory devices:
|
||||||
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
||||||
@@ -191,6 +262,8 @@ while [ $# -gt 0 ]; do
|
|||||||
--skip-perf) SKIP_PERF=1 ;;
|
--skip-perf) SKIP_PERF=1 ;;
|
||||||
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
|
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
|
||||||
--skip-swap) SKIP_SWAP=1 ;;
|
--skip-swap) SKIP_SWAP=1 ;;
|
||||||
|
--enable-auto-update) AUTO_UPDATE=1 ;;
|
||||||
|
--no-auto-update) AUTO_UPDATE=0 ;;
|
||||||
--build-jobs)
|
--build-jobs)
|
||||||
shift
|
shift
|
||||||
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
||||||
@@ -797,6 +870,50 @@ else
|
|||||||
echo "✓ Main config file already exists"
|
echo "✓ Main config file already exists"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Weekly automatic updates (General tab -> Automatic Updates). Off unless asked
|
||||||
|
# for: --enable-auto-update / LEDMATRIX_AUTO_UPDATE=1, or "y" at the prompt when
|
||||||
|
# installing interactively. Only an explicit choice changes the setting, so
|
||||||
|
# re-running the installer with -y never switches it silently.
|
||||||
|
if [ -z "$AUTO_UPDATE" ] && [ "$ASSUME_YES" != "1" ] && [ -t 0 ]; then
|
||||||
|
read -p "Automatically check for and install LEDMatrix updates once a week, with automatic rollback if an update breaks something? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
|
||||||
|
fi
|
||||||
|
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ]; then
|
||||||
|
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" <<'PY'
|
||||||
|
import json, os, sys, tempfile
|
||||||
|
path, enabled = sys.argv[1], sys.argv[2] == "1"
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
if not isinstance(config.get("auto_update"), dict):
|
||||||
|
config["auto_update"] = {}
|
||||||
|
config["auto_update"]["enabled"] = enabled
|
||||||
|
# Written beside the original and swapped in whole: the display service's
|
||||||
|
# config watcher may be running and must never read a half-written file.
|
||||||
|
original = os.stat(path)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)), prefix=".config.")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=4)
|
||||||
|
f.write("\n")
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.chmod(tmp, original.st_mode & 0o777)
|
||||||
|
if hasattr(os, "chown"):
|
||||||
|
os.chown(tmp, original.st_uid, original.st_gid)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
if [ "$AUTO_UPDATE" = "1" ]; then echo "✓ Weekly automatic updates enabled"; else echo "✓ Weekly automatic updates off"; fi
|
||||||
|
else
|
||||||
|
echo "⚠ Could not set auto_update in config/config.json; turn it on from the General tab instead"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Create config_secrets.json from template if missing
|
# Create config_secrets.json from template if missing
|
||||||
if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||||
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.template.json" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.template.json" ]; then
|
||||||
@@ -1038,8 +1155,9 @@ else
|
|||||||
# so git clone doesn't fail with "destination path already exists".
|
# so git clone doesn't fail with "destination path already exists".
|
||||||
_clone_rpi_rgb() {
|
_clone_rpi_rgb() {
|
||||||
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
_git_as_repo_owner clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
}
|
}
|
||||||
|
_reclaim_rgb_checkout
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
@@ -1047,7 +1165,7 @@ else
|
|||||||
# Try to initialize submodule if .gitmodules exists
|
# Try to initialize submodule if .gitmodules exists
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
if ! retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||||
retry _clone_rpi_rgb
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
@@ -1066,12 +1184,14 @@ else
|
|||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
rm -rf rpi-rgb-led-matrix-master
|
rm -rf rpi-rgb-led-matrix-master
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||||
else
|
else
|
||||||
retry _clone_rpi_rgb
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
_sync_rgb_submodule
|
||||||
|
|
||||||
# Add temporary swap on low-memory devices so the compiler survives.
|
# Add temporary swap on low-memory devices so the compiler survives.
|
||||||
CURRENT_STEP="Prepare the low-memory build environment"
|
CURRENT_STEP="Prepare the low-memory build environment"
|
||||||
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
|
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
|
||||||
@@ -1272,7 +1392,7 @@ if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ "$NEEDS_UPDATE" = true ]; then
|
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ ! -f "/etc/systemd/system/ledmatrix-update-verify.path" ] || [ "$NEEDS_UPDATE" = true ]; then
|
||||||
bash "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"
|
bash "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"
|
||||||
# Ensure systemd sees any new/changed unit files
|
# Ensure systemd sees any new/changed unit files
|
||||||
systemctl daemon-reload || true
|
systemctl daemon-reload || true
|
||||||
@@ -1288,7 +1408,7 @@ echo ""
|
|||||||
CURRENT_STEP="Harden systemd unit file permissions"
|
CURRENT_STEP="Harden systemd unit file permissions"
|
||||||
echo "Step 8.1: Setting systemd unit file permissions..."
|
echo "Step 8.1: Setting systemd unit file permissions..."
|
||||||
echo "-----------------------------------------------"
|
echo "-----------------------------------------------"
|
||||||
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service"; do
|
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service" "/etc/systemd/system/ledmatrix-update-verify.service" "/etc/systemd/system/ledmatrix-update-verify.path"; do
|
||||||
if [ -f "$unit" ]; then
|
if [ -f "$unit" ]; then
|
||||||
chown root:root "$unit" || true
|
chown root:root "$unit" || true
|
||||||
chmod 644 "$unit" || true
|
chmod 644 "$unit" || true
|
||||||
@@ -1416,12 +1536,22 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controll
|
|||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||||
|
# Install a requirements.txt as root via vetted helper, so packages are visible
|
||||||
|
# to root-run ledmatrix.service (not just the web interface's own user).
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
|
||||||
EOF
|
EOF
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
# shell -- the standard journalctl escalation. The web interface always passes
|
||||||
|
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
|
||||||
|
# sits in the middle of the command line. NOEXEC stops the command executing
|
||||||
|
# another program at all, which closes the hole without depending on wildcard
|
||||||
|
# matching subtleties.
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1616,6 +1746,19 @@ chmod 755 "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" "$PROJECT_ROOT_
|
|||||||
# Re-apply special permissions for config directory (lost during normalization)
|
# Re-apply special permissions for config directory (lost during normalization)
|
||||||
chmod 2775 "$PROJECT_ROOT_DIR/config" || true
|
chmod 2775 "$PROJECT_ROOT_DIR/config" || true
|
||||||
|
|
||||||
|
# Harden the sudo-granted helper scripts: root-owned, not writable by the web
|
||||||
|
# user (matches scripts/install/configure_web_sudo.sh). The sudoers rules in
|
||||||
|
# Step 10 run these as root, so a user-owned copy is a root shell for whoever
|
||||||
|
# can edit it. This must come after Step 11's project-wide chown to
|
||||||
|
# $ACTUAL_USER, which would otherwise hand them straight back.
|
||||||
|
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
|
||||||
|
HELPER_PATH="$PROJECT_ROOT_DIR/scripts/fix_perms/$helper"
|
||||||
|
if [ -f "$HELPER_PATH" ]; then
|
||||||
|
chown root:root "$HELPER_PATH" || echo "⚠ Could not set ownership on $HELPER_PATH"
|
||||||
|
chmod 755 "$HELPER_PATH" || echo "⚠ Could not set permissions on $HELPER_PATH"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "✓ Project file permissions normalized"
|
echo "✓ Project file permissions normalized"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -1688,6 +1831,97 @@ else
|
|||||||
echo "✗ $CMDLINE_FILE not found; skipping isolcpus optimization"
|
echo "✗ $CMDLINE_FILE not found; skipping isolcpus optimization"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Enable the memory cgroup controller (idempotent).
|
||||||
|
# The Pi firmware boots with cgroup_disable=memory, so systemd's MemoryMax= is
|
||||||
|
# accepted and silently ignored — the display service then has no ceiling, and
|
||||||
|
# a runaway takes the whole board down (sshd can no longer fork, the panel goes
|
||||||
|
# dark) rather than just restarting the one service.
|
||||||
|
if [ "$SKIP_PERF" != "1" ] && [ -f "$CMDLINE_FILE" ]; then
|
||||||
|
# Both parameters are required for the memory controller, and they can get
|
||||||
|
# separated -- an image, another tool or a half-applied earlier run can
|
||||||
|
# leave one without the other. Checking only cgroup_enable=memory would
|
||||||
|
# report success while MemoryMax= silently does nothing, so each is checked
|
||||||
|
# and appended independently.
|
||||||
|
cgroup_missing=""
|
||||||
|
for cgroup_param in cgroup_enable=memory cgroup_memory=1; do
|
||||||
|
if ! grep -qw "$cgroup_param" "$CMDLINE_FILE"; then
|
||||||
|
cgroup_missing="$cgroup_missing $cgroup_param"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$cgroup_missing" ]; then
|
||||||
|
echo "cgroup memory parameters already present in $CMDLINE_FILE"
|
||||||
|
else
|
||||||
|
echo "Adding${cgroup_missing} to $CMDLINE_FILE..."
|
||||||
|
cp "$CMDLINE_FILE" "$CMDLINE_FILE.bak" 2>/dev/null || true
|
||||||
|
# The kernel command line must stay on one line.
|
||||||
|
sed -i "1 s|\$|${cgroup_missing}|" "$CMDLINE_FILE"
|
||||||
|
echo " Takes effect after reboot. Verify with:"
|
||||||
|
echo " grep memory /sys/fs/cgroup/cgroup.controllers"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persist the journal (idempotent).
|
||||||
|
# These images default to volatile storage: journald keeps everything in /run
|
||||||
|
# (tmpfs), so every reboot destroys the logs — including the ones that would
|
||||||
|
# explain why the board rebooted. Capped so an SD card is not worn out by logs.
|
||||||
|
# A non-empty /var/log/journal does not prove journald is configured the way
|
||||||
|
# this needs: the directory survives a switch back to volatile storage, and it
|
||||||
|
# says nothing about whether a size cap is set. Read the effective
|
||||||
|
# configuration instead, and only write the keys the user has not set
|
||||||
|
# themselves so an explicit local limit is preserved.
|
||||||
|
journald_effective() {
|
||||||
|
# systemd-analyze merges journald.conf with every drop-in; grep is the
|
||||||
|
# fallback for images that ship without it.
|
||||||
|
if command -v systemd-analyze >/dev/null 2>&1 &&
|
||||||
|
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
||||||
|
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
||||||
|
else
|
||||||
|
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
journald_conf="$(journald_effective)"
|
||||||
|
# Both settings are optional, and stock images ship them commented out. grep
|
||||||
|
# exits 1 on no match, which pipefail turns fatal under set -e — an absent
|
||||||
|
# setting must read as empty, not abort the install.
|
||||||
|
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
|
||||||
|
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
||||||
|
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
||||||
|
else
|
||||||
|
echo "Enabling persistent journald storage..."
|
||||||
|
mkdir -p /etc/systemd/journald.conf.d
|
||||||
|
{
|
||||||
|
echo "# Installed by LEDMatrix first_time_install.sh"
|
||||||
|
echo "[Journal]"
|
||||||
|
echo "Storage=persistent"
|
||||||
|
if [ -n "$journald_cap" ]; then
|
||||||
|
echo "# SystemMaxUse left to your existing setting ($journald_cap)"
|
||||||
|
else
|
||||||
|
# Capped so logs cannot wear out or fill an SD card.
|
||||||
|
echo "SystemMaxUse=64M"
|
||||||
|
fi
|
||||||
|
} > /etc/systemd/journald.conf.d/ledmatrix-persistent.conf
|
||||||
|
mkdir -p /var/log/journal
|
||||||
|
systemd-tmpfiles --create --prefix /var/log/journal >/dev/null 2>&1 || true
|
||||||
|
systemctl restart systemd-journald >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Drop-ins are applied in lexical order, so a locally added file that sorts
|
||||||
|
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
||||||
|
# Writing the file is not evidence it took effect -- re-read and say so
|
||||||
|
# plainly rather than reporting success we cannot confirm.
|
||||||
|
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||||
|
if [ "$journald_now" = "persistent" ]; then
|
||||||
|
echo " Persistent journald storage active"
|
||||||
|
else
|
||||||
|
echo " WARNING: journald storage is still '${journald_now:-unset}' after"
|
||||||
|
echo " writing /etc/systemd/journald.conf.d/ledmatrix-persistent.conf."
|
||||||
|
echo " Another drop-in that sorts later is overriding it. Check:"
|
||||||
|
echo " systemd-analyze cat-config systemd/journald.conf | grep -n Storage="
|
||||||
|
echo " Logs will not survive a reboot until that is resolved."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure dtparam=audio=off in config.txt (idempotent)
|
# Ensure dtparam=audio=off in config.txt (idempotent)
|
||||||
if [ "$SKIP_PERF" = "1" ]; then
|
if [ "$SKIP_PERF" = "1" ]; then
|
||||||
: # skipped
|
: # skipped
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
bridge_config.json
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Home Assistant MQTT Bridge
|
||||||
|
|
||||||
|
Control the matrix from Home Assistant: force any plugin or mode on demand,
|
||||||
|
turn the display on and off, and set brightness — as real HA entities, not
|
||||||
|
hand-written `mqtt.publish` calls.
|
||||||
|
|
||||||
|
The bridge owns no display logic. It subscribes to one command topic and
|
||||||
|
turns each message into a call against the same `api_v3` routes the web UI
|
||||||
|
uses, so behaviour lives in one place. It talks to the API over HTTP only —
|
||||||
|
no filesystem access — so it can run on the Pi or anywhere that can reach
|
||||||
|
the web interface.
|
||||||
|
|
||||||
|
## What appears in Home Assistant
|
||||||
|
|
||||||
|
On connect the bridge publishes [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
|
||||||
|
config, so the matrix shows up under **Settings → Devices & Services → MQTT**
|
||||||
|
with no YAML:
|
||||||
|
|
||||||
|
| Entity | Does |
|
||||||
|
|---|---|
|
||||||
|
| `select.ledmatrix_display_mode` | Every mode across enabled plugins. Choosing one force-displays it. |
|
||||||
|
| `button.ledmatrix_stop_display` | Back to normal rotation. |
|
||||||
|
| `switch.ledmatrix_power` | Starts/stops the display service. |
|
||||||
|
| `number.ledmatrix_brightness` | 0–100. |
|
||||||
|
|
||||||
|
State is read back from the API every 30 seconds, so the entities also track
|
||||||
|
changes made from the web UI or an on-demand window expiring on its own.
|
||||||
|
All four share an availability topic that is the bridge's MQTT last will:
|
||||||
|
if the bridge dies, HA greys the controls out rather than leaving them
|
||||||
|
looking live but inert.
|
||||||
|
|
||||||
|
## Raw commands
|
||||||
|
|
||||||
|
For anything the entities do not cover, publish JSON to the command topic
|
||||||
|
(`ledmatrix/command` by default):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Force a mode. plugin_id is optional — the bridge fills it in from
|
||||||
|
// /api/v3/display/modes.
|
||||||
|
{"action": "display", "mode": "nfl_live"}
|
||||||
|
|
||||||
|
// duration is seconds; pinned holds this one mode instead of rotating
|
||||||
|
// through every mode the plugin owns. Pin Starlark apps, where each mode
|
||||||
|
// is an unrelated widget; leave a sports plugin unpinned so live/recent/
|
||||||
|
// upcoming still cycle.
|
||||||
|
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||||
|
"duration": 300, "pinned": true}
|
||||||
|
|
||||||
|
{"action": "stop_display"}
|
||||||
|
{"action": "power", "state": "on"}
|
||||||
|
{"action": "brightness", "value": 75}
|
||||||
|
|
||||||
|
// Re-read the mode list and re-publish discovery, after installing a plugin
|
||||||
|
{"action": "refresh"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every command publishes its outcome to `<command_topic>/status`, and current
|
||||||
|
state to `<command_topic>/state`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A LEDMatrix install with its web interface reachable (default `http://localhost:5000`)
|
||||||
|
- An MQTT broker that Home Assistant is also connected to
|
||||||
|
- Python 3 with `paho-mqtt` 2.x and `requests`
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/install/install_mqtt_bridge.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That copies `bridge_config.example.json` to `bridge_config.json` on first
|
||||||
|
run, installs the dependencies, and enables `ledmatrix-mqtt-bridge.service`.
|
||||||
|
Edit the config with your broker details and re-run it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mqtt_host": "192.168.1.10",
|
||||||
|
"mqtt_port": 8883,
|
||||||
|
"mqtt_username": "ledmatrix",
|
||||||
|
"mqtt_password": null,
|
||||||
|
"mqtt_topic": "ledmatrix/command",
|
||||||
|
"mqtt_tls": true,
|
||||||
|
"ledmatrix_api_base": "http://localhost:5000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TLS is on by default.** Without it the broker password and every display
|
||||||
|
command cross the network in cleartext. If your broker only listens on plain
|
||||||
|
1883 — which the Mosquitto add-on does out of the box — set `"mqtt_tls": false`
|
||||||
|
and `"mqtt_port": 1883`. The bridge logs a warning at startup when a password
|
||||||
|
is configured without TLS.
|
||||||
|
|
||||||
|
`bridge_config.json` is gitignored. Any key can also be supplied through the
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
To run it in the foreground while setting things up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/mqtt_bridge/bridge_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Only one thing can be on-demand at a time — the same constraint the web UI has.
|
||||||
|
- Forcing a mode restarts the display service, so the panel blanks for a moment.
|
||||||
|
- The mode list comes from `/api/v3/display/modes`, which triggers plugin
|
||||||
|
discovery itself. Discovery is lazy and normally happens because somebody
|
||||||
|
opened the dashboard; without that endpoint a bridge that never does would
|
||||||
|
see an empty list.
|
||||||
|
- Brightness writes `display.hardware.brightness` through `/api/v3/config/main`.
|
||||||
|
The display service picks it up on its next restart, not instantly.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mqtt_host": "192.168.1.10",
|
||||||
|
"mqtt_port": 8883,
|
||||||
|
"mqtt_username": "ledmatrix",
|
||||||
|
"mqtt_password": null,
|
||||||
|
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||||
|
"mqtt_topic": "ledmatrix/command",
|
||||||
|
"mqtt_tls": true,
|
||||||
|
"ledmatrix_api_base": "http://localhost:5000",
|
||||||
|
"request_timeout": 15,
|
||||||
|
"on_demand_duration": null,
|
||||||
|
"log_level": "INFO"
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Control a LEDMatrix display from Home Assistant over MQTT.
|
||||||
|
|
||||||
|
The bridge owns no display logic. It subscribes to one command topic and
|
||||||
|
turns each message into a call against the same api_v3 routes the web UI
|
||||||
|
uses, so behaviour stays in one place and this stays a translation layer.
|
||||||
|
|
||||||
|
On connect it publishes Home Assistant MQTT Discovery config, so a matrix
|
||||||
|
appears in HA as real entities rather than something you drive with
|
||||||
|
`mqtt.publish` by hand:
|
||||||
|
|
||||||
|
select.ledmatrix_display_mode every mode across enabled plugins;
|
||||||
|
choosing one force-displays it
|
||||||
|
button.ledmatrix_stop_display back to normal rotation
|
||||||
|
switch.ledmatrix_power the display service, on or off
|
||||||
|
number.ledmatrix_brightness 0-100
|
||||||
|
|
||||||
|
Anything the entities do not cover is still reachable by publishing JSON
|
||||||
|
to the command topic:
|
||||||
|
|
||||||
|
{"action": "display", "mode": "nfl_live"}
|
||||||
|
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||||
|
"duration": 300, "pinned": true}
|
||||||
|
{"action": "stop_display"}
|
||||||
|
{"action": "power", "state": "on" | "off"}
|
||||||
|
{"action": "brightness", "value": 75}
|
||||||
|
{"action": "refresh"} re-publish discovery after installing a plugin
|
||||||
|
|
||||||
|
Every command publishes its result to <command_topic>/status.
|
||||||
|
|
||||||
|
Run it with `python3 ledmatrix_mqtt_bridge.py [--config PATH]`, or install
|
||||||
|
ledmatrix-mqtt-bridge.service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger("ledmatrix-mqtt-bridge")
|
||||||
|
|
||||||
|
DISCOVERY_PREFIX = "homeassistant"
|
||||||
|
DEVICE_ID = "ledmatrix"
|
||||||
|
DEVICE_INFO = {
|
||||||
|
"identifiers": [DEVICE_ID],
|
||||||
|
"name": "LEDMatrix",
|
||||||
|
"manufacturer": "ChuckBuilds",
|
||||||
|
"model": "LEDMatrix Display",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_username": None,
|
||||||
|
"mqtt_password": None, # nosec B105 - "no password configured", not a credential
|
||||||
|
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||||
|
"mqtt_topic": "ledmatrix/command",
|
||||||
|
"mqtt_tls": False,
|
||||||
|
"mqtt_tls_insecure": False,
|
||||||
|
"ledmatrix_api_base": "http://localhost:5000",
|
||||||
|
"request_timeout": 15,
|
||||||
|
"on_demand_duration": None,
|
||||||
|
"log_level": "INFO",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""The bridge cannot start with the configuration it was given."""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str) -> Dict[str, Any]:
|
||||||
|
"""Read bridge_config.json, overlaid on DEFAULTS.
|
||||||
|
|
||||||
|
Every value may also come from the environment as LEDMATRIX_MQTT_<KEY>,
|
||||||
|
which is how a password stays out of a file that has to be world-readable
|
||||||
|
for the service user.
|
||||||
|
"""
|
||||||
|
config = dict(DEFAULTS)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
try:
|
||||||
|
loaded = json.load(handle)
|
||||||
|
except json.JSONDecodeError as err:
|
||||||
|
raise ConfigError(f"{path} is not valid JSON: {err}") from err
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise ConfigError(f"{path} must contain a JSON object")
|
||||||
|
config.update(loaded)
|
||||||
|
else:
|
||||||
|
logger.warning("No config file at %s - using defaults and environment", path)
|
||||||
|
|
||||||
|
for key in DEFAULTS:
|
||||||
|
env_value = os.environ.get(f"LEDMATRIX_MQTT_{key.upper()}")
|
||||||
|
if env_value is not None:
|
||||||
|
config[key] = env_value
|
||||||
|
|
||||||
|
for key in ("mqtt_port", "request_timeout"):
|
||||||
|
try:
|
||||||
|
config[key] = int(config[key])
|
||||||
|
except (TypeError, ValueError) as err:
|
||||||
|
raise ConfigError(f"{key} must be a whole number, got {config[key]!r}") from err
|
||||||
|
for key in ("mqtt_tls", "mqtt_tls_insecure"):
|
||||||
|
config[key] = str(config[key]).lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
if config.get("mqtt_password") == "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD":
|
||||||
|
raise ConfigError(
|
||||||
|
"mqtt_password is still the example placeholder - set a real password, "
|
||||||
|
"or remove the key if your broker allows anonymous connections")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class LEDMatrixClient:
|
||||||
|
"""The api_v3 calls the bridge needs, and nothing else.
|
||||||
|
|
||||||
|
Everything goes through the HTTP API rather than the filesystem, so the
|
||||||
|
bridge does not have to live on the Pi, does not need read access to
|
||||||
|
config.json, and cannot drift from the web UI's own behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_base: str, timeout: int = 15,
|
||||||
|
session: Optional[requests.Session] = None):
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = session or requests.Session()
|
||||||
|
|
||||||
|
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||||
|
url = f"{self.api_base}/api/v3{path}"
|
||||||
|
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = {}
|
||||||
|
if response.status_code >= 400 or body.get("status") == "error":
|
||||||
|
message = body.get("message") or f"HTTP {response.status_code}"
|
||||||
|
raise RuntimeError(f"{method} {path} failed: {message}")
|
||||||
|
return body.get("data", body)
|
||||||
|
|
||||||
|
def list_modes(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Every display mode that can be force-displayed, newest discovery.
|
||||||
|
|
||||||
|
/display/modes triggers plugin discovery itself, which matters because
|
||||||
|
discovery is lazy: a bridge that never opens the dashboard would
|
||||||
|
otherwise see nothing at all.
|
||||||
|
"""
|
||||||
|
return self._call("GET", "/display/modes").get("modes", [])
|
||||||
|
|
||||||
|
def display_status(self) -> Dict[str, Any]:
|
||||||
|
return self._call("GET", "/display/on-demand/status")
|
||||||
|
|
||||||
|
def start_on_demand(self, mode: str, plugin_id: Optional[str] = None,
|
||||||
|
duration: Optional[int] = None, pinned: bool = False) -> Dict[str, Any]:
|
||||||
|
payload: Dict[str, Any] = {"mode": mode, "pinned": pinned}
|
||||||
|
if plugin_id:
|
||||||
|
# find_plugin_for_mode only sees modes declared in a static
|
||||||
|
# manifest, so a plugin whose modes are generated -- each installed
|
||||||
|
# Starlark app is one -- 404s when plugin_id is omitted. Sending it
|
||||||
|
# skips that lookup. /display/modes reports it for every mode.
|
||||||
|
payload["plugin_id"] = plugin_id
|
||||||
|
if duration:
|
||||||
|
payload["duration"] = int(duration)
|
||||||
|
return self._call("POST", "/display/on-demand/start", json=payload)
|
||||||
|
|
||||||
|
def stop_on_demand(self) -> Dict[str, Any]:
|
||||||
|
return self._call("POST", "/display/on-demand/stop", json={})
|
||||||
|
|
||||||
|
def set_power(self, on: bool) -> Dict[str, Any]:
|
||||||
|
action = "start_display" if on else "stop_display"
|
||||||
|
return self._call("POST", "/system/action", json={"action": action})
|
||||||
|
|
||||||
|
def get_brightness(self) -> Optional[int]:
|
||||||
|
config = self._call("GET", "/config/main")
|
||||||
|
value = config.get("display", {}).get("hardware", {}).get("brightness")
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_brightness(self, value: int) -> Dict[str, Any]:
|
||||||
|
return self._call("POST", "/config/main", json={"brightness": int(value)})
|
||||||
|
|
||||||
|
|
||||||
|
class CommandHandler:
|
||||||
|
"""Turns one decoded MQTT payload into one API call.
|
||||||
|
|
||||||
|
Kept free of MQTT so it can be tested against a fake client: the failure
|
||||||
|
modes worth pinning are all in here (an unknown mode, an out-of-range
|
||||||
|
brightness, a mode name that needs its plugin_id attached).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: LEDMatrixClient, default_duration: Optional[int] = None):
|
||||||
|
self.client = client
|
||||||
|
self.default_duration = default_duration
|
||||||
|
self._modes_by_name: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def refresh_modes(self) -> List[Dict[str, Any]]:
|
||||||
|
modes = self.client.list_modes()
|
||||||
|
self._modes_by_name = {m["mode"]: m for m in modes}
|
||||||
|
# Home Assistant's select shows labels, so accept them back as well --
|
||||||
|
# otherwise picking "Simple Clock" in a dashboard is not a mode name.
|
||||||
|
for entry in modes:
|
||||||
|
self._modes_by_name.setdefault(entry.get("name") or entry["mode"], entry)
|
||||||
|
return modes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def known_modes(self) -> List[Dict[str, Any]]:
|
||||||
|
return list({id(v): v for v in self._modes_by_name.values()}.values())
|
||||||
|
|
||||||
|
def handle(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
action = payload.get("action")
|
||||||
|
handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||||
|
"display": self._display,
|
||||||
|
"stop_display": lambda _p: self._ok(self.client.stop_on_demand()),
|
||||||
|
"power": self._power,
|
||||||
|
"brightness": self._brightness,
|
||||||
|
"refresh": lambda _p: self._ok({"modes": len(self.refresh_modes())}),
|
||||||
|
}
|
||||||
|
handler = handlers.get(action)
|
||||||
|
if handler is None:
|
||||||
|
return self._error(f"Unknown action {action!r}; expected one of "
|
||||||
|
f"{', '.join(sorted(handlers))}")
|
||||||
|
try:
|
||||||
|
return handler(payload)
|
||||||
|
except (requests.RequestException, RuntimeError) as err:
|
||||||
|
logger.error("Command %s failed: %s", action, err)
|
||||||
|
return self._error(str(err))
|
||||||
|
|
||||||
|
def _display(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
mode = payload.get("mode")
|
||||||
|
plugin_id = payload.get("plugin_id")
|
||||||
|
if not mode and not plugin_id:
|
||||||
|
return self._error("display requires 'mode' or 'plugin_id'")
|
||||||
|
|
||||||
|
known = self._modes_by_name.get(mode) if mode else None
|
||||||
|
if known is None and mode and not plugin_id:
|
||||||
|
# One retry against a fresh listing: a plugin installed since the
|
||||||
|
# last refresh is the common reason a valid mode looks unknown.
|
||||||
|
self.refresh_modes()
|
||||||
|
known = self._modes_by_name.get(mode)
|
||||||
|
if known is not None:
|
||||||
|
mode = known["mode"]
|
||||||
|
plugin_id = plugin_id or known.get("plugin_id")
|
||||||
|
|
||||||
|
duration = payload.get("duration", self.default_duration)
|
||||||
|
pinned = bool(payload.get("pinned", False))
|
||||||
|
result = self.client.start_on_demand(
|
||||||
|
mode=mode, plugin_id=plugin_id, duration=duration, pinned=pinned)
|
||||||
|
return self._ok(result, mode=mode, plugin_id=plugin_id)
|
||||||
|
|
||||||
|
def _power(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
state = str(payload.get("state", "")).strip().lower()
|
||||||
|
if state not in ("on", "off"):
|
||||||
|
return self._error("power requires 'state' of 'on' or 'off'")
|
||||||
|
return self._ok(self.client.set_power(state == "on"), state=state)
|
||||||
|
|
||||||
|
def _brightness(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
raw = payload.get("value")
|
||||||
|
try:
|
||||||
|
value = int(float(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return self._error(f"brightness requires a number, got {raw!r}")
|
||||||
|
if not 0 <= value <= 100:
|
||||||
|
return self._error(f"brightness must be between 0 and 100, got {value}")
|
||||||
|
return self._ok(self.client.set_brightness(value), value=value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ok(result: Any, **extra) -> Dict[str, Any]:
|
||||||
|
return {"status": "success", "result": result, **extra}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _error(message: str) -> Dict[str, Any]:
|
||||||
|
return {"status": "error", "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_messages(command_topic: str, state_topic: str, availability_topic: str,
|
||||||
|
mode_labels: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""The retained MQTT Discovery configs, as {topic, payload} pairs.
|
||||||
|
|
||||||
|
Pure, so the entity shapes can be asserted without a broker. Every entity
|
||||||
|
shares one availability topic, which is also the bridge's last will -- HA
|
||||||
|
then shows the matrix as unavailable when the bridge dies, instead of
|
||||||
|
leaving stale controls that silently do nothing.
|
||||||
|
"""
|
||||||
|
common = {
|
||||||
|
"device": DEVICE_INFO,
|
||||||
|
"availability_topic": availability_topic,
|
||||||
|
"payload_available": "online",
|
||||||
|
"payload_not_available": "offline",
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"topic": f"{DISCOVERY_PREFIX}/select/{DEVICE_ID}/display_mode/config",
|
||||||
|
"payload": {
|
||||||
|
**common,
|
||||||
|
"name": "Display Mode",
|
||||||
|
"unique_id": f"{DEVICE_ID}_display_mode",
|
||||||
|
"command_topic": command_topic,
|
||||||
|
"command_template": '{"action": "display", "mode": "{{ value }}"}',
|
||||||
|
"state_topic": state_topic,
|
||||||
|
"value_template": "{{ value_json.mode }}",
|
||||||
|
"options": mode_labels,
|
||||||
|
"icon": "mdi:view-dashboard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": f"{DISCOVERY_PREFIX}/button/{DEVICE_ID}/stop_display/config",
|
||||||
|
"payload": {
|
||||||
|
**common,
|
||||||
|
"name": "Stop Display",
|
||||||
|
"unique_id": f"{DEVICE_ID}_stop_display",
|
||||||
|
"command_topic": command_topic,
|
||||||
|
"payload_press": '{"action": "stop_display"}',
|
||||||
|
"icon": "mdi:stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": f"{DISCOVERY_PREFIX}/switch/{DEVICE_ID}/power/config",
|
||||||
|
"payload": {
|
||||||
|
**common,
|
||||||
|
"name": "Power",
|
||||||
|
"unique_id": f"{DEVICE_ID}_power",
|
||||||
|
"command_topic": command_topic,
|
||||||
|
"payload_on": '{"action": "power", "state": "on"}',
|
||||||
|
"payload_off": '{"action": "power", "state": "off"}',
|
||||||
|
"state_topic": state_topic,
|
||||||
|
"value_template": "{{ 'ON' if value_json.power else 'OFF' }}",
|
||||||
|
"state_on": "ON",
|
||||||
|
"state_off": "OFF",
|
||||||
|
"icon": "mdi:power",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": f"{DISCOVERY_PREFIX}/number/{DEVICE_ID}/brightness/config",
|
||||||
|
"payload": {
|
||||||
|
**common,
|
||||||
|
"name": "Brightness",
|
||||||
|
"unique_id": f"{DEVICE_ID}_brightness",
|
||||||
|
"command_topic": command_topic,
|
||||||
|
"command_template": '{"action": "brightness", "value": {{ value }}}',
|
||||||
|
"state_topic": state_topic,
|
||||||
|
"value_template": "{{ value_json.brightness }}",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"step": 1,
|
||||||
|
"icon": "mdi:brightness-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def warn_if_cleartext(config: Dict[str, Any]) -> bool:
|
||||||
|
"""Say so, once, when a broker password is going over an unencrypted link.
|
||||||
|
|
||||||
|
The shipped example has TLS on, so reaching here means somebody turned it
|
||||||
|
off deliberately -- which is legitimate (the Mosquitto add-on is plaintext
|
||||||
|
on 1883) but should not be silent when there is a password to lose. Returns
|
||||||
|
whether it warned, so the decision is testable without a broker.
|
||||||
|
"""
|
||||||
|
if config.get("mqtt_tls") or not config.get("mqtt_password"):
|
||||||
|
return False
|
||||||
|
logger.warning(
|
||||||
|
'mqtt_tls is off and a password is set: the broker password and every '
|
||||||
|
'command are sent unencrypted. Set "mqtt_tls": true (port 8883 on most '
|
||||||
|
'brokers) unless this is a trusted, isolated network.')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def read_state(client: LEDMatrixClient) -> Dict[str, Any]:
|
||||||
|
"""The state every entity reads, so HA opens on real values.
|
||||||
|
|
||||||
|
Each field is fetched independently: a matrix with its display service
|
||||||
|
stopped still has a brightness worth showing, and one unreachable field
|
||||||
|
should not blank the rest.
|
||||||
|
"""
|
||||||
|
state: Dict[str, Any] = {"power": False, "mode": None, "brightness": None}
|
||||||
|
try:
|
||||||
|
status = client.display_status()
|
||||||
|
state["power"] = bool(status.get("service", {}).get("active"))
|
||||||
|
on_demand = status.get("state", {})
|
||||||
|
if on_demand.get("active"):
|
||||||
|
state["mode"] = on_demand.get("mode")
|
||||||
|
except (requests.RequestException, RuntimeError) as err:
|
||||||
|
logger.debug("Could not read display status: %s", err)
|
||||||
|
try:
|
||||||
|
state["brightness"] = client.get_brightness()
|
||||||
|
except (requests.RequestException, RuntimeError) as err:
|
||||||
|
logger.debug("Could not read brightness: %s", err)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
class Bridge:
|
||||||
|
"""MQTT wiring around CommandHandler."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
self.config = config
|
||||||
|
self.command_topic = config["mqtt_topic"]
|
||||||
|
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.handler = CommandHandler(self.client, config.get("on_demand_duration"))
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._mqtt = None
|
||||||
|
|
||||||
|
# -- MQTT callbacks (paho-mqtt 2.x VERSION2 signatures) ------------------
|
||||||
|
|
||||||
|
def _on_connect(self, client, _userdata, _flags, reason_code, _properties=None):
|
||||||
|
if getattr(reason_code, "is_failure", reason_code != 0):
|
||||||
|
logger.error("MQTT connection refused: %s", reason_code)
|
||||||
|
return
|
||||||
|
logger.info("Connected to MQTT broker; subscribing to %s", self.command_topic)
|
||||||
|
client.subscribe(self.command_topic, qos=1)
|
||||||
|
client.publish(self.availability_topic, "online", qos=1, retain=True)
|
||||||
|
# Re-publish on every reconnect, not just the first connect: a broker
|
||||||
|
# restart drops retained discovery configs, and HA would otherwise be
|
||||||
|
# left with entities it can no longer describe.
|
||||||
|
self.publish_discovery()
|
||||||
|
self.publish_state()
|
||||||
|
|
||||||
|
def _on_message(self, _client, _userdata, message):
|
||||||
|
try:
|
||||||
|
payload = json.loads(message.payload.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||||
|
logger.warning("Ignoring unparseable message on %s: %s", message.topic, err)
|
||||||
|
self._publish(self.status_topic, {"status": "error", "message": f"bad payload: {err}"})
|
||||||
|
return
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
self._publish(self.status_topic,
|
||||||
|
{"status": "error", "message": "payload must be a JSON object"})
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Command: %s", payload)
|
||||||
|
result = self.handler.handle(payload)
|
||||||
|
self._publish(self.status_topic, result)
|
||||||
|
# The API applies changes asynchronously (the controller polls its
|
||||||
|
# mailbox), so read state back rather than assuming the command took.
|
||||||
|
self.publish_state()
|
||||||
|
|
||||||
|
# -- publishing ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
|
if self._mqtt is None:
|
||||||
|
return
|
||||||
|
body = payload if isinstance(payload, str) else json.dumps(payload)
|
||||||
|
self._mqtt.publish(topic, body, qos=1, retain=retain)
|
||||||
|
|
||||||
|
def publish_discovery(self) -> None:
|
||||||
|
try:
|
||||||
|
modes = self.handler.refresh_modes()
|
||||||
|
except (requests.RequestException, RuntimeError) as err:
|
||||||
|
logger.error("Could not list display modes: %s", err)
|
||||||
|
modes = self.handler.known_modes
|
||||||
|
labels = sorted({m.get("name") or m["mode"] for m in modes})
|
||||||
|
for message in discovery_messages(self.command_topic, self.state_topic,
|
||||||
|
self.availability_topic, labels):
|
||||||
|
self._publish(message["topic"], message["payload"], retain=True)
|
||||||
|
logger.info("Published discovery for %d display mode(s)", len(labels))
|
||||||
|
|
||||||
|
def publish_state(self) -> None:
|
||||||
|
self._publish(self.state_topic, read_state(self.client), retain=True)
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------
|
||||||
|
|
||||||
|
def run(self) -> int:
|
||||||
|
try:
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
except ImportError:
|
||||||
|
logger.error("paho-mqtt is not installed: pip install -r requirements.txt")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# VERSION2 is the current callback API. The compatibility note in
|
||||||
|
# CLAUDE.md is about code written against the v1 signatures; this file
|
||||||
|
# is written against v2 and requires paho-mqtt >= 2.0.
|
||||||
|
self._mqtt = mqtt.Client(
|
||||||
|
mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=self.config["mqtt_client_id"])
|
||||||
|
if self.config.get("mqtt_username"):
|
||||||
|
self._mqtt.username_pw_set(self.config["mqtt_username"],
|
||||||
|
self.config.get("mqtt_password"))
|
||||||
|
if self.config.get("mqtt_tls"):
|
||||||
|
self._mqtt.tls_set()
|
||||||
|
if self.config.get("mqtt_tls_insecure"):
|
||||||
|
logger.warning("TLS certificate verification is disabled (mqtt_tls_insecure)")
|
||||||
|
self._mqtt.tls_insecure_set(True)
|
||||||
|
else:
|
||||||
|
warn_if_cleartext(self.config)
|
||||||
|
|
||||||
|
self._mqtt.will_set(self.availability_topic, "offline", qos=1, retain=True)
|
||||||
|
self._mqtt.on_connect = self._on_connect
|
||||||
|
self._mqtt.on_message = self._on_message
|
||||||
|
|
||||||
|
logger.info("Connecting to %s:%s", self.config["mqtt_host"], self.config["mqtt_port"])
|
||||||
|
try:
|
||||||
|
self._mqtt.connect(self.config["mqtt_host"], self.config["mqtt_port"], keepalive=60)
|
||||||
|
except OSError as err:
|
||||||
|
logger.error("Could not reach the MQTT broker: %s", err)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
self._mqtt.loop_start()
|
||||||
|
try:
|
||||||
|
while not self._stop.wait(30):
|
||||||
|
# HA is told the truth about state that changed outside the
|
||||||
|
# bridge -- somebody using the web UI, or an on-demand window
|
||||||
|
# expiring on its own.
|
||||||
|
self.publish_state()
|
||||||
|
finally:
|
||||||
|
self._publish(self.availability_topic, "offline", retain=True)
|
||||||
|
self._mqtt.loop_stop()
|
||||||
|
self._mqtt.disconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def stop(self, *_args) -> None:
|
||||||
|
logger.info("Shutting down")
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge_config.json"),
|
||||||
|
help="Path to bridge_config.json (default: alongside this script)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
|
||||||
|
try:
|
||||||
|
config = load_config(args.config)
|
||||||
|
except ConfigError as err:
|
||||||
|
logger.error("%s", err)
|
||||||
|
return 1
|
||||||
|
logging.getLogger().setLevel(str(config.get("log_level", "INFO")).upper())
|
||||||
|
|
||||||
|
bridge = Bridge(config)
|
||||||
|
signal.signal(signal.SIGTERM, bridge.stop)
|
||||||
|
signal.signal(signal.SIGINT, bridge.stop)
|
||||||
|
return bridge.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Floors are security floors, not API floors. requests < 2.33.0 carries
|
||||||
|
# CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645; matches the pin in the
|
||||||
|
# project's own requirements.txt.
|
||||||
|
paho-mqtt>=2.0.0,<3.0.0
|
||||||
|
requests>=2.33.0,<3.0.0
|
||||||
@@ -194,6 +194,15 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
Each installed app becomes a dynamic display mode.
|
Each installed app becomes a dynamic display mode.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
#: Starlark apps are animations: a .webp render carries per-frame delays
|
||||||
|
#: and _display_frame advances at most one frame per call. The controller
|
||||||
|
#: reads this attribute to decide whether a mode needs its high-FPS loop;
|
||||||
|
#: without it display() was called once per rotation slot, so a multi-frame
|
||||||
|
#: app showed a single frame and never moved. static-image is force-run at
|
||||||
|
#: high FPS for the same reason (GIFs), but that plugin is special-cased by
|
||||||
|
#: name in the controller and this one has to declare it.
|
||||||
|
enable_scrolling = True
|
||||||
|
|
||||||
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
||||||
display_manager, cache_manager, plugin_manager):
|
display_manager, cache_manager, plugin_manager):
|
||||||
"""Initialize the Starlark Apps plugin."""
|
"""Initialize the Starlark Apps plugin."""
|
||||||
@@ -211,6 +220,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
# App storage
|
# App storage
|
||||||
self.apps_dir = self._get_apps_directory()
|
self.apps_dir = self._get_apps_directory()
|
||||||
self.manifest_file = self.apps_dir / "manifest.json"
|
self.manifest_file = self.apps_dir / "manifest.json"
|
||||||
|
# A dedicated, never-replaced file to flock -- see _update_manifest_safe.
|
||||||
|
self.manifest_lock_file = self.apps_dir / "manifest.json.lock"
|
||||||
self.apps: Dict[str, StarlarkApp] = {}
|
self.apps: Dict[str, StarlarkApp] = {}
|
||||||
|
|
||||||
# Display state
|
# Display state
|
||||||
@@ -555,7 +566,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
Save apps manifest to file with file locking to prevent race conditions.
|
Save apps manifest to file with file locking to prevent race conditions.
|
||||||
Acquires exclusive lock on manifest file before writing to prevent concurrent modifications.
|
Acquires exclusive lock on the manifest lock sidecar before writing to
|
||||||
|
prevent concurrent modifications.
|
||||||
"""
|
"""
|
||||||
temp_file = None
|
temp_file = None
|
||||||
lock_fd = None
|
lock_fd = None
|
||||||
@@ -563,9 +575,14 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
# Create parent directory if needed
|
# Create parent directory if needed
|
||||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
# Lock the sidecar file, not manifest_file itself: manifest_file is
|
||||||
# Use os.open with O_CREAT | O_RDWR to create if missing, but don't truncate
|
# replaced by an atomic rename below, which swaps in a fresh inode
|
||||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
# a second locker's fresh os.open() would pick up unguarded. The
|
||||||
|
# sidecar is never written to or renamed over, so it always
|
||||||
|
# resolves to the same inode for every locker (see
|
||||||
|
# _update_manifest_safe and web_interface's _starlark_manifest_lock,
|
||||||
|
# which must lock this same file for the guarantee to hold).
|
||||||
|
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
|
||||||
# Acquire exclusive lock on manifest file BEFORE creating temp file
|
# Acquire exclusive lock on manifest file BEFORE creating temp file
|
||||||
# This serializes all writers and prevents concurrent races
|
# This serializes all writers and prevents concurrent races
|
||||||
@@ -621,8 +638,9 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
# Create parent directory if needed
|
# Create parent directory if needed
|
||||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
# Lock the sidecar file, not manifest_file itself -- see the
|
||||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
# comment in _save_manifest for why.
|
||||||
|
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
|
||||||
# Acquire exclusive lock for entire read-modify-write cycle
|
# Acquire exclusive lock for entire read-modify-write cycle
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
@@ -681,38 +699,62 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
if app.is_enabled() and app.should_render(current_time):
|
if app.is_enabled() and app.should_render(current_time):
|
||||||
self._render_app(app, force=False)
|
self._render_app(app, force=False)
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> None:
|
def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool:
|
||||||
"""
|
"""
|
||||||
Display current Starlark app.
|
Display current Starlark app.
|
||||||
|
|
||||||
This method is called during the display rotation.
|
This method is called during the display rotation.
|
||||||
Displays frames from the currently active app.
|
Displays frames from the currently active app.
|
||||||
|
|
||||||
|
`display_mode` names the app to show when it matches an installed
|
||||||
|
app_id. The controller passes the mode it is rotating to and inspects
|
||||||
|
this signature to decide whether to, so accepting it is what lets a
|
||||||
|
specific app be addressed -- including by an on-demand request pinned
|
||||||
|
to one app. Anything else (the plugin id itself, when the plugin
|
||||||
|
exposes no per-app modes) falls through to normal rotation.
|
||||||
|
|
||||||
|
Returns False when there is no app to show -- which is the state of
|
||||||
|
every install without Pixlet, and of a fresh one before any app is
|
||||||
|
added. The display controller only skips a mode on a boolean False
|
||||||
|
(it checks isinstance(result, bool)), so returning None held a black
|
||||||
|
panel for the full display_duration instead of rotating on.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if force_clear:
|
if force_clear:
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
|
|
||||||
# If no current app, try to select one
|
if display_mode and display_mode in self.apps:
|
||||||
if not self.current_app:
|
self.current_app = self.apps[display_mode]
|
||||||
|
elif force_clear or not self.current_app:
|
||||||
|
# Advance on entry to the mode. _select_next_app only ran when
|
||||||
|
# current_app was unset, so the first enabled app was picked
|
||||||
|
# once and then shown forever -- every other installed app was
|
||||||
|
# rendered on schedule and never displayed. force_clear is the
|
||||||
|
# controller's "we just switched to you" signal (it is reset
|
||||||
|
# immediately after this call), so one app gets each turn.
|
||||||
self._select_next_app()
|
self._select_next_app()
|
||||||
|
|
||||||
if not self.current_app:
|
if not self.current_app:
|
||||||
# No apps available
|
# No apps available
|
||||||
self.logger.debug("No Starlark apps to display")
|
self.logger.debug("No Starlark apps to display")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Render app if needed
|
# Render app if needed
|
||||||
if not self.current_app.frames:
|
if not self.current_app.frames:
|
||||||
success = self._render_app(self.current_app, force=True)
|
success = self._render_app(self.current_app, force=True)
|
||||||
if not success:
|
if not success:
|
||||||
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Display current frame
|
# Display current frame. The result is propagated: a failed frame
|
||||||
self._display_frame()
|
# update is not a displayed frame, and returning True regardless
|
||||||
|
# told the controller the mode had rendered, so it held the dead
|
||||||
|
# frame for the whole display_duration instead of rotating on.
|
||||||
|
return self._display_frame()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error displaying Starlark app: {e}")
|
self.logger.error(f"Error displaying Starlark app: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _select_next_app(self) -> None:
|
def _select_next_app(self) -> None:
|
||||||
"""Select the next enabled app for display."""
|
"""Select the next enabled app for display."""
|
||||||
@@ -763,15 +805,25 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
magnify = self._get_effective_magnify()
|
magnify = self._get_effective_magnify()
|
||||||
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
||||||
|
|
||||||
# Filter out LEDMatrix-internal timing keys before passing to pixlet
|
# Optional native render size for an app whose own declared canvas
|
||||||
INTERNAL_KEYS = {'render_interval', 'display_duration'}
|
# differs from Pixlet's 64x32 default -- without this an app
|
||||||
|
# declaring a wider native canvas got half its own content
|
||||||
|
# clipped at render time, before magnify ever got a chance to
|
||||||
|
# scale anything.
|
||||||
|
render_width = app.config.get("render_width")
|
||||||
|
render_height = app.config.get("render_height")
|
||||||
|
|
||||||
|
# Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet
|
||||||
|
INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'}
|
||||||
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
||||||
|
|
||||||
success, error = self.pixlet.render(
|
success, error = self.pixlet.render(
|
||||||
star_file=str(app.star_file),
|
star_file=str(app.star_file),
|
||||||
output_path=str(app.cache_file),
|
output_path=str(app.cache_file),
|
||||||
config=pixlet_config,
|
config=pixlet_config,
|
||||||
magnify=magnify
|
magnify=magnify,
|
||||||
|
width=render_width,
|
||||||
|
height=render_height
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@@ -835,10 +887,13 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
self.logger.error(f"Error loading frames for {app.app_id}: {e}")
|
self.logger.error(f"Error loading frames for {app.app_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _display_frame(self) -> None:
|
def _display_frame(self) -> bool:
|
||||||
"""Display the current frame of the current app."""
|
"""Display the current frame of the current app.
|
||||||
|
|
||||||
|
:returns: whether a frame actually reached the display manager.
|
||||||
|
"""
|
||||||
if not self.current_app or not self.current_app.frames:
|
if not self.current_app or not self.current_app.frames:
|
||||||
return
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
@@ -856,8 +911,11 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
)
|
)
|
||||||
self.current_app.last_frame_time = current_time
|
self.current_app.last_frame_time = current_time
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error displaying frame: {e}")
|
self.logger.error(f"Error displaying frame: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
|
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -218,7 +218,9 @@ class PixletRenderer:
|
|||||||
star_file: str,
|
star_file: str,
|
||||||
output_path: str,
|
output_path: str,
|
||||||
config: Optional[Dict[str, Any]] = None,
|
config: Optional[Dict[str, Any]] = None,
|
||||||
magnify: int = 1
|
magnify: int = 1,
|
||||||
|
width: Optional[int] = None,
|
||||||
|
height: Optional[int] = None
|
||||||
) -> Tuple[bool, Optional[str]]:
|
) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Render a .star file to WebP output.
|
Render a .star file to WebP output.
|
||||||
@@ -228,6 +230,21 @@ class PixletRenderer:
|
|||||||
output_path: Where to save WebP output
|
output_path: Where to save WebP output
|
||||||
config: Configuration dictionary to pass to app
|
config: Configuration dictionary to pass to app
|
||||||
magnify: Magnification factor (default 1)
|
magnify: Magnification factor (default 1)
|
||||||
|
width: Optional native render width in pixels. Previously
|
||||||
|
there was no way to tell Pixlet to render at anything
|
||||||
|
other than its own default (64), relying entirely on
|
||||||
|
magnify to scale up afterward -- fine for apps designed
|
||||||
|
at that native size, but wrong for an app whose own
|
||||||
|
declared canvas size is genuinely different (confirmed
|
||||||
|
on real hardware, 2026-09-06, with an imported app
|
||||||
|
declaring width=128: rendering at the default 64 and
|
||||||
|
then magnifying silently clipped half the app's own
|
||||||
|
content before scaling ever happened, rather than
|
||||||
|
producing a correctly-sized image). Passed through as
|
||||||
|
Pixlet's own -w flag when provided; omitted (Pixlet's
|
||||||
|
default) otherwise, preserving existing behavior for
|
||||||
|
every other app.
|
||||||
|
height: Same as width, for Pixlet's -t flag.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success: bool, error_message: Optional[str])
|
Tuple of (success: bool, error_message: Optional[str])
|
||||||
@@ -264,10 +281,18 @@ class PixletRenderer:
|
|||||||
else:
|
else:
|
||||||
value_str = str(value)
|
value_str = str(value)
|
||||||
|
|
||||||
# Validate value doesn't contain dangerous shell metacharacters
|
# Validate value doesn't contain dangerous shell metacharacters.
|
||||||
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
|
# Kept as defence in depth only: cmd is a list and there is no
|
||||||
# Allow: most printable chars including spaces, quotes, brackets, braces
|
# shell=True below, so nothing here is ever interpreted by a
|
||||||
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
|
# shell. That made the list worth trimming rather than growing
|
||||||
|
# -- "|" is a normal character inside a config value, and apps
|
||||||
|
# do use it as a separator (a PennDOT sign id is
|
||||||
|
# "I-476 North|175659"). Blocking it dropped the whole key
|
||||||
|
# silently, and the app then rendered its own "not configured"
|
||||||
|
# screen with nothing to say why.
|
||||||
|
# Block: backticks, $(), redirects, semicolons, ampersands, null bytes
|
||||||
|
# Allow: most printable chars including spaces, quotes, brackets, braces, pipes
|
||||||
|
if re.search(r'[`$<>&;\x00]|\$\(', value_str):
|
||||||
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -279,6 +304,10 @@ class PixletRenderer:
|
|||||||
"-o", output_path,
|
"-o", output_path,
|
||||||
"-m", str(magnify)
|
"-m", str(magnify)
|
||||||
])
|
])
|
||||||
|
if width is not None:
|
||||||
|
cmd.extend(["-w", str(width)])
|
||||||
|
if height is not None:
|
||||||
|
cmd.extend(["-t", str(height)])
|
||||||
|
|
||||||
# Build sanitized command for logging (redact sensitive values)
|
# Build sanitized command for logging (redact sensitive values)
|
||||||
sanitized_cmd = [self.pixlet_binary, "render", star_file]
|
sanitized_cmd = [self.pixlet_binary, "render", star_file]
|
||||||
@@ -286,6 +315,10 @@ class PixletRenderer:
|
|||||||
config_keys = list(config.keys())
|
config_keys = list(config.keys())
|
||||||
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
|
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
|
||||||
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
|
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
|
||||||
|
if width is not None:
|
||||||
|
sanitized_cmd.extend(["-w", str(width)])
|
||||||
|
if height is not None:
|
||||||
|
sanitized_cmd.extend(["-t", str(height)])
|
||||||
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
|
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
|
||||||
|
|
||||||
# Execute rendering
|
# Execute rendering
|
||||||
@@ -299,13 +332,21 @@ class PixletRenderer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
if os.path.isfile(output_path):
|
if not os.path.isfile(output_path):
|
||||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
|
||||||
return True, None
|
|
||||||
else:
|
|
||||||
error = "Rendering succeeded but output file not found"
|
error = "Rendering succeeded but output file not found"
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
return False, error
|
return False, error
|
||||||
|
# Pixlet exits 0 and writes a 0-byte file when the app renders
|
||||||
|
# nothing -- an app whose config leaves it with no content to
|
||||||
|
# show does exactly that. Treating existence alone as success
|
||||||
|
# handed the caller a file with no frames in it, which reads
|
||||||
|
# downstream as a working app that draws a black panel.
|
||||||
|
if os.path.getsize(output_path) == 0:
|
||||||
|
error = "Rendering produced an empty (0-byte) file - the app rendered no content"
|
||||||
|
logger.error(error)
|
||||||
|
return False, error
|
||||||
|
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||||
|
return True, None
|
||||||
else:
|
else:
|
||||||
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
|
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
@@ -319,11 +360,76 @@ class PixletRenderer:
|
|||||||
logger.exception("Rendering exception")
|
logger.exception("Rendering exception")
|
||||||
return False, "Rendering failed - see logs for details"
|
return False, "Rendering failed - see logs for details"
|
||||||
|
|
||||||
|
#: Schema extraction runs an app's own get_schema(), which may make a
|
||||||
|
#: network call. Short enough that a hung app does not stall an upload,
|
||||||
|
#: long enough for a real API round trip on a slow connection.
|
||||||
|
SCHEMA_TIMEOUT = 20
|
||||||
|
|
||||||
|
def extract_schema_via_pixlet(self, star_file: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Ask Pixlet itself for the app's schema, or None if it cannot say.
|
||||||
|
|
||||||
|
`pixlet schema` executes get_schema() instead of reading it, which is
|
||||||
|
the only way to see options an app computes at runtime -- a dropdown
|
||||||
|
whose choices come from a live API call has no option list anywhere in
|
||||||
|
the source for the regex parser below to find, so that parser reports
|
||||||
|
an empty dropdown and the config form offers nothing to pick.
|
||||||
|
|
||||||
|
Pixlet's own field keys are remapped to the ones the rest of this
|
||||||
|
plugin and the config UI already use ("typeOf"/"desc"), so the two
|
||||||
|
extractors return the same shape and callers cannot tell them apart.
|
||||||
|
"""
|
||||||
|
if not self.pixlet_binary:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[self.pixlet_binary, "schema", star_file],
|
||||||
|
capture_output=True, text=True, timeout=self.SCHEMA_TIMEOUT,
|
||||||
|
cwd=self._get_safe_working_directory(star_file),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning(
|
||||||
|
"pixlet schema timed out after %ss for %s - get_schema() may be "
|
||||||
|
"making a slow network call", self.SCHEMA_TIMEOUT, star_file)
|
||||||
|
return None
|
||||||
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
|
logger.warning(f"Could not run pixlet schema for {star_file}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
# Not an error worth failing on: older Pixlet builds have no
|
||||||
|
# `schema` subcommand at all, and the source parser still works.
|
||||||
|
logger.debug(
|
||||||
|
"pixlet schema exited %d for %s: %s",
|
||||||
|
result.returncode, star_file, (result.stderr or '').strip()[:300])
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
schema = json.loads(result.stdout)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning(f"pixlet schema returned unparseable output for {star_file}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(schema, dict) or not isinstance(schema.get("schema"), list):
|
||||||
|
logger.warning(f"pixlet schema returned an unexpected shape for {star_file}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for field in schema["schema"]:
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
continue
|
||||||
|
if "type" in field and "typeOf" not in field:
|
||||||
|
field["typeOf"] = field.pop("type")
|
||||||
|
if "description" in field and "desc" not in field:
|
||||||
|
field["desc"] = field.pop("description")
|
||||||
|
return schema
|
||||||
|
|
||||||
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Extract configuration schema from a .star file by parsing source code.
|
Extract configuration schema from a .star file.
|
||||||
|
|
||||||
Supports:
|
Prefers `pixlet schema`, which runs the app and therefore sees options
|
||||||
|
it computes at runtime. Falls back to parsing the source when Pixlet is
|
||||||
|
unavailable, too old to have the subcommand, or the app fails to run --
|
||||||
|
that parser handles:
|
||||||
- Static field definitions (location, text, toggle, dropdown, color, datetime)
|
- Static field definitions (location, text, toggle, dropdown, color, datetime)
|
||||||
- Variable-referenced dropdown options
|
- Variable-referenced dropdown options
|
||||||
- Graceful degradation for unsupported field types
|
- Graceful degradation for unsupported field types
|
||||||
@@ -337,6 +443,13 @@ class PixletRenderer:
|
|||||||
if not os.path.isfile(star_file):
|
if not os.path.isfile(star_file):
|
||||||
return False, None, f"Star file not found: {star_file}"
|
return False, None, f"Star file not found: {star_file}"
|
||||||
|
|
||||||
|
schema = self.extract_schema_via_pixlet(star_file)
|
||||||
|
if schema is not None:
|
||||||
|
logger.debug(
|
||||||
|
"Extracted schema with %d field(s) from %s via pixlet schema",
|
||||||
|
len(schema.get('schema', [])), star_file)
|
||||||
|
return True, schema, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read .star file
|
# Read .star file
|
||||||
with open(star_file, 'r', encoding='utf-8') as f:
|
with open(star_file, 'r', encoding='utf-8') as f:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Handles interaction with the Tronbyte apps repository on GitHub.
|
|||||||
Fetches app listings, metadata, and downloads .star files.
|
Fetches app listings, metadata, and downloads .star files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
@@ -49,6 +50,13 @@ class TronbyteRepository:
|
|||||||
self.base_url = "https://api.github.com"
|
self.base_url = "https://api.github.com"
|
||||||
self.raw_url = "https://raw.githubusercontent.com"
|
self.raw_url = "https://raw.githubusercontent.com"
|
||||||
|
|
||||||
|
# Why the last GitHub API call failed, in words a user can act on.
|
||||||
|
# _make_request used to log the reason and return a bare None, so
|
||||||
|
# every caller up the stack knew only that "something" went wrong --
|
||||||
|
# which is how an exhausted rate limit reached the store page as an
|
||||||
|
# empty grid with no explanation.
|
||||||
|
self.last_error: Optional[str] = None
|
||||||
|
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
if github_token:
|
if github_token:
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
@@ -70,29 +78,50 @@ class TronbyteRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON response or None on error
|
JSON response or None on error
|
||||||
"""
|
"""
|
||||||
|
self.last_error = None
|
||||||
try:
|
try:
|
||||||
response = self.session.get(url, timeout=timeout)
|
response = self.session.get(url, timeout=timeout)
|
||||||
|
|
||||||
if response.status_code == 403:
|
if response.status_code in (403, 429):
|
||||||
# Rate limit exceeded
|
# 403 is both "rate limited" and "forbidden"; the remaining
|
||||||
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
|
# counter is what tells them apart, and the difference matters
|
||||||
|
# to whoever reads the message -- one is fixed by waiting or
|
||||||
|
# adding a token, the other is not.
|
||||||
|
remaining = response.headers.get('X-RateLimit-Remaining')
|
||||||
|
if remaining == '0':
|
||||||
|
self.last_error = (
|
||||||
|
"GitHub API rate limit exceeded"
|
||||||
|
f" ({response.headers.get('X-RateLimit-Limit', '?')} requests/hour"
|
||||||
|
f"{'' if self.github_token else ', unauthenticated'})."
|
||||||
|
" Add a GitHub token in settings, or wait for the limit to reset."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.last_error = f"GitHub refused the request ({response.status_code})"
|
||||||
|
logger.warning(f"[Tronbyte Repo] {self.last_error}")
|
||||||
return None
|
return None
|
||||||
elif response.status_code == 404:
|
elif response.status_code == 404:
|
||||||
|
self.last_error = "Not found on GitHub"
|
||||||
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
||||||
return None
|
return None
|
||||||
elif response.status_code != 200:
|
elif response.status_code != 200:
|
||||||
|
self.last_error = f"GitHub API error {response.status_code}"
|
||||||
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
|
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
|
self.last_error = "Timed out reaching GitHub"
|
||||||
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
||||||
return None
|
return None
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
|
self.last_error = f"Network error reaching GitHub: {e.__class__.__name__}"
|
||||||
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
|
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
# Reachable whenever something on the path answers with HTML --
|
||||||
|
# a captive portal, a proxy error page, a DNS-hijacking router.
|
||||||
|
self.last_error = "GitHub returned a response that was not JSON"
|
||||||
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
|
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -125,6 +154,61 @@ class TronbyteRepository:
|
|||||||
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _list_app_dirs_via_trees(self) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""App directories via the git trees API, or None on failure.
|
||||||
|
|
||||||
|
The contents API caps a directory listing at 1000 entries and says
|
||||||
|
nothing about having truncated it, so the store showed the first 1000
|
||||||
|
apps of a repository that has more and looked complete while doing it.
|
||||||
|
The trees API caps far higher and sets `truncated` when it does, at
|
||||||
|
the cost of one extra call to resolve the `apps` tree.
|
||||||
|
"""
|
||||||
|
repo = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}"
|
||||||
|
|
||||||
|
root = self._make_request(f"{repo}/git/trees/{self.DEFAULT_BRANCH}")
|
||||||
|
if not isinstance(root, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
apps_sha = next(
|
||||||
|
(e.get('sha') for e in root.get('tree', []) or []
|
||||||
|
if e.get('path') == self.APPS_PATH and e.get('type') == 'tree'),
|
||||||
|
None)
|
||||||
|
if not apps_sha:
|
||||||
|
self.last_error = f"No '{self.APPS_PATH}' directory in the repository"
|
||||||
|
return None
|
||||||
|
|
||||||
|
tree = self._make_request(f"{repo}/git/trees/{apps_sha}")
|
||||||
|
if not isinstance(tree, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tree.get('truncated'):
|
||||||
|
logger.warning(
|
||||||
|
"[Tronbyte Repo] GitHub truncated the app tree; the listing is incomplete")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{'id': e['path'], 'path': f"{self.APPS_PATH}/{e['path']}", 'url': None}
|
||||||
|
for e in tree.get('tree', []) or []
|
||||||
|
if e.get('type') == 'tree' and e.get('path') and not e['path'].startswith('.')
|
||||||
|
]
|
||||||
|
|
||||||
|
def _list_app_dirs_via_contents(self) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""App directories via the contents API. Capped at 1000 entries."""
|
||||||
|
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
||||||
|
|
||||||
|
data = self._make_request(url)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, list):
|
||||||
|
self.last_error = "GitHub returned an unexpected listing format"
|
||||||
|
return None
|
||||||
|
|
||||||
|
return [
|
||||||
|
{'id': item.get('name'), 'path': item.get('path'), 'url': item.get('url')}
|
||||||
|
for item in data
|
||||||
|
if item.get('type') == 'dir' and item.get('name')
|
||||||
|
and not item['name'].startswith('.')
|
||||||
|
]
|
||||||
|
|
||||||
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
|
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
List all available apps in the repository.
|
List all available apps in the repository.
|
||||||
@@ -132,26 +216,17 @@ class TronbyteRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, apps_list, error_message)
|
Tuple of (success, apps_list, error_message)
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
apps = self._list_app_dirs_via_trees()
|
||||||
|
if apps is None:
|
||||||
data = self._make_request(url)
|
# Fall back rather than fail: the contents API was what shipped,
|
||||||
if data is None:
|
# so a trees-only outage should not take the store down with it.
|
||||||
return False, None, "Failed to fetch repository contents"
|
trees_error = self.last_error
|
||||||
|
logger.warning(
|
||||||
if not isinstance(data, list):
|
f"[Tronbyte Repo] Trees listing failed ({trees_error}); "
|
||||||
return False, None, "Invalid response format"
|
"falling back to the contents API")
|
||||||
|
apps = self._list_app_dirs_via_contents()
|
||||||
# Filter directories (apps)
|
if apps is None:
|
||||||
apps = []
|
return False, None, self.last_error or trees_error or "Failed to fetch repository contents"
|
||||||
for item in data:
|
|
||||||
if item.get('type') == 'dir':
|
|
||||||
app_id = item.get('name')
|
|
||||||
if app_id and not app_id.startswith('.'):
|
|
||||||
apps.append({
|
|
||||||
'id': app_id,
|
|
||||||
'path': item.get('path'),
|
|
||||||
'url': item.get('url')
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Found {len(apps)} apps in repository")
|
logger.info(f"Found {len(apps)} apps in repository")
|
||||||
return True, apps, None
|
return True, apps, None
|
||||||
@@ -267,14 +342,22 @@ class TronbyteRepository:
|
|||||||
'categories': _apps_cache['categories'],
|
'categories': _apps_cache['categories'],
|
||||||
'authors': _apps_cache['authors'],
|
'authors': _apps_cache['authors'],
|
||||||
'count': len(_apps_cache['data']),
|
'count': len(_apps_cache['data']),
|
||||||
'cached': True
|
'cached': True,
|
||||||
|
'error': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fetch directory listing (1 GitHub API call)
|
# Fetch directory listing (a small number of GitHub API calls)
|
||||||
success, app_dirs, error = self.list_apps()
|
success, app_dirs, error = self.list_apps()
|
||||||
if not success or not app_dirs:
|
if not success or not app_dirs:
|
||||||
logger.error(f"Failed to list apps for bulk fetch: {error}")
|
# Returning an empty list here used to read downstream as "the
|
||||||
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
|
# repository has no apps", and the route reported that as a
|
||||||
|
# success -- so a rate limit, a DNS failure and an empty
|
||||||
|
# repository were all drawn as the same blank grid. Hand the
|
||||||
|
# reason back instead and let the caller surface it.
|
||||||
|
reason = error or "No apps found in the repository"
|
||||||
|
logger.error(f"Failed to list apps for bulk fetch: {reason}")
|
||||||
|
return {'apps': [], 'categories': [], 'authors': [],
|
||||||
|
'count': 0, 'cached': False, 'error': reason}
|
||||||
|
|
||||||
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
|
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
|
||||||
|
|
||||||
@@ -341,7 +424,8 @@ class TronbyteRepository:
|
|||||||
'categories': categories,
|
'categories': categories,
|
||||||
'authors': authors,
|
'authors': authors,
|
||||||
'count': len(apps_with_metadata),
|
'count': len(apps_with_metadata),
|
||||||
'cached': False
|
'cached': False,
|
||||||
|
'error': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
|
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ freezegun>=1.2,<2 # deterministic time for golden-image tests
|
|||||||
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
|
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
|
||||||
# /system/status endpoint's real path is exercised
|
# /system/status endpoint's real path is exercised
|
||||||
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
||||||
|
PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
|
||||||
|
# plugin-repos/starlark-apps/tronbyte_repository.py the way
|
||||||
|
# the blueprint does, and that plugin imports yaml. The
|
||||||
|
# plugin declares it in its own requirements.txt, which the
|
||||||
|
# store installs on a real rig but CI never does.
|
||||||
|
|||||||
+20
-4
@@ -43,10 +43,14 @@ packaging>=23.0,<27.0
|
|||||||
# full feature set, or skip them for a minimal install.
|
# full feature set, or skip them for a minimal install.
|
||||||
# ───────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# scipy — sub-pixel interpolation in
|
# scipy — nothing, as of #570. It was listed for the sub-pixel
|
||||||
# src/common/scroll_helper.py for smoother
|
# interpolation path in src/common/scroll_helper.py, but
|
||||||
# scrolling. Falls back to a simpler shift algorithm.
|
# get_visible_portion never consulted HAS_SCIPY, so that
|
||||||
# pip install 'scipy>=1.10.0,<2.0.0'
|
# path was dead before it was deleted. The blend that
|
||||||
|
# replaced it is numpy-only. Do not install it expecting
|
||||||
|
# smoother scrolling: sub-pixel blending is off by default
|
||||||
|
# because it reads worse on a coarse panel, not because it
|
||||||
|
# is missing a library. See docs/SCROLL_PERFORMANCE.md.
|
||||||
#
|
#
|
||||||
# psutil — per-plugin resource monitoring in
|
# psutil — per-plugin resource monitoring in
|
||||||
# src/plugin_system/resource_monitor.py. The monitor
|
# src/plugin_system/resource_monitor.py. The monitor
|
||||||
@@ -55,6 +59,18 @@ packaging>=23.0,<27.0
|
|||||||
# range as a hard dependency — keep the two in sync.
|
# range as a hard dependency — keep the two in sync.
|
||||||
# pip install 'psutil>=6.0.0,<7.0.0'
|
# pip install 'psutil>=6.0.0,<7.0.0'
|
||||||
#
|
#
|
||||||
|
# orjson — faster JSON for the disk cache
|
||||||
|
# (src/cache/disk_cache.py). Encoding a ~1MB cache
|
||||||
|
# record drops from ~12ms to ~1.6ms on a Pi 4, which
|
||||||
|
# matters because that work holds the GIL and stalls
|
||||||
|
# the render thread mid-scroll. Falls back to the
|
||||||
|
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
|
||||||
|
# The 3.11.6 floor is CVE-2025-67221: orjson.dumps did not
|
||||||
|
# limit recursion on deeply nested documents, and the disk
|
||||||
|
# cache encodes payloads parsed straight from third-party
|
||||||
|
# APIs. 3.11.6 covers the Python range above.
|
||||||
|
# pip install 'orjson>=3.11.6,<4.0'
|
||||||
|
#
|
||||||
# Flask-Limiter — request rate limiting in web_interface/app.py
|
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||||
# (accidental-abuse protection, not security). The
|
# (accidental-abuse protection, not security). The
|
||||||
# web interface starts without rate limiting when
|
# web interface starts without rate limiting when
|
||||||
|
|||||||
Submodule rpi-rgb-led-matrix-master updated: 8907235630...1ee4f76f2b
@@ -257,6 +257,30 @@
|
|||||||
},
|
},
|
||||||
"description": "Web UI action definitions"
|
"description": "Web UI action definitions"
|
||||||
},
|
},
|
||||||
|
"widgets": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z0-9_-]{1,64}$",
|
||||||
|
"description": "Widget name, as used in x-widget and in the URL."
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$",
|
||||||
|
"description": "Filename inside widgets/. Defaults to <name>.js."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable summary shown to plugin authors."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledmatrix_version": {
|
"ledmatrix_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find blocking work reachable from a plugin's render path.
|
||||||
|
|
||||||
|
`display()` runs on the render thread. Anything slow reached from it stalls the
|
||||||
|
panel for its whole duration, and on a vsync-paced loop that is immediately
|
||||||
|
visible: a single 15ms call on a 100Hz panel drops a frame, and a network round
|
||||||
|
trip freezes the marquee outright.
|
||||||
|
|
||||||
|
This has bitten twice already. odds-ticker called `_has_live_games()` every
|
||||||
|
frame, whose slow path read the scoreboard cache from disk and parsed JSON per
|
||||||
|
enabled league -- one stalled frame every few minutes. soccer-scoreboard timed
|
||||||
|
out inside `update()` during a cache refresh. Both were found by staring at
|
||||||
|
frame-time histograms, which is a slow way to find a bug that is visible in the
|
||||||
|
source.
|
||||||
|
|
||||||
|
The audit walks the call graph from `display()` through same-class `self.*`
|
||||||
|
methods and reports anything that reaches a known-blocking API. It is a
|
||||||
|
heuristic, not a proof: it cannot see through indirection, and a hit is not
|
||||||
|
automatically a bug -- a call guarded by an interval check may be fine. It is a
|
||||||
|
list of places worth a human look.
|
||||||
|
|
||||||
|
python3 scripts/audit_render_path.py # all plugins
|
||||||
|
python3 scripts/audit_render_path.py --dir plugin-repos # a specific tree
|
||||||
|
python3 scripts/audit_render_path.py --plugin odds-ticker
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
#: Calls that can block for longer than a frame. Matched on the attribute or
|
||||||
|
#: function name, so `requests.get`, `self.session.get` and a bare `get` on a
|
||||||
|
#: requests-ish object all register.
|
||||||
|
BLOCKING = {
|
||||||
|
"get": "network or cache read",
|
||||||
|
"post": "network",
|
||||||
|
"put": "network",
|
||||||
|
"request": "network",
|
||||||
|
"urlopen": "network",
|
||||||
|
"read": "I/O",
|
||||||
|
"open": "file I/O",
|
||||||
|
"load": "JSON/file parse",
|
||||||
|
"loads": "JSON parse",
|
||||||
|
"dump": "file write",
|
||||||
|
"dumps": "serialise",
|
||||||
|
"sleep": "sleep on the render thread",
|
||||||
|
"run": "subprocess",
|
||||||
|
"check_output": "subprocess",
|
||||||
|
"connect": "network",
|
||||||
|
"download_logo": "network",
|
||||||
|
"_fetch": "fetch",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Names that make a hit far more likely to matter.
|
||||||
|
HIGH_SIGNAL = ("requests", "urllib", "session", "cache_manager", "subprocess",
|
||||||
|
"socket", "http")
|
||||||
|
|
||||||
|
RENDER_ENTRY = "display"
|
||||||
|
|
||||||
|
|
||||||
|
class Analyzer:
|
||||||
|
def __init__(self, tree: ast.AST):
|
||||||
|
self.methods: dict[str, ast.FunctionDef] = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
self.methods.setdefault(node.name, node)
|
||||||
|
|
||||||
|
def calls_in(self, fn: ast.AST):
|
||||||
|
"""(self-method names called, blocking hits) inside one function."""
|
||||||
|
self_calls, hits = set(), []
|
||||||
|
for node in ast.walk(fn):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
if isinstance(func, ast.Attribute):
|
||||||
|
name = func.attr
|
||||||
|
base = ast.unparse(func.value) if hasattr(ast, "unparse") else ""
|
||||||
|
if base == "self" and name in self.methods:
|
||||||
|
self_calls.add(name)
|
||||||
|
continue
|
||||||
|
if name in BLOCKING:
|
||||||
|
hits.append((name, base, BLOCKING[name], node.lineno))
|
||||||
|
elif isinstance(func, ast.Name) and func.id in BLOCKING:
|
||||||
|
hits.append((func.id, "", BLOCKING[func.id], node.lineno))
|
||||||
|
return self_calls, hits
|
||||||
|
|
||||||
|
def reachable_from(self, entry: str, max_depth: int = 3):
|
||||||
|
"""Blocking hits reachable from `entry`, with the path that reaches them."""
|
||||||
|
if entry not in self.methods:
|
||||||
|
return []
|
||||||
|
found, seen = [], set()
|
||||||
|
stack = [(entry, [entry], 0)]
|
||||||
|
while stack:
|
||||||
|
name, path, depth = stack.pop()
|
||||||
|
if name in seen or depth > max_depth:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
self_calls, hits = self.calls_in(self.methods[name])
|
||||||
|
for hit in hits:
|
||||||
|
found.append((path, hit))
|
||||||
|
for callee in sorted(self_calls):
|
||||||
|
stack.append((callee, path + [callee], depth + 1))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def audit_file(path: Path):
|
||||||
|
try:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, SyntaxError):
|
||||||
|
return []
|
||||||
|
analyzer = Analyzer(tree)
|
||||||
|
return analyzer.reachable_from(RENDER_ENTRY)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
ap.add_argument("--dir", default=str(root / "plugin-repos"))
|
||||||
|
ap.add_argument("--plugin", help="only this plugin directory")
|
||||||
|
ap.add_argument("--all-hits", action="store_true",
|
||||||
|
help="include low-signal hits (open/read/load on locals)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
base = Path(args.dir)
|
||||||
|
if not base.is_dir():
|
||||||
|
sys.exit("not a directory: %s" % base)
|
||||||
|
|
||||||
|
plugins = [base / args.plugin] if args.plugin else sorted(
|
||||||
|
d for d in base.iterdir() if d.is_dir())
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for plugin in plugins:
|
||||||
|
rows = []
|
||||||
|
for src in sorted(plugin.glob("*.py")):
|
||||||
|
if src.name.startswith("test_"):
|
||||||
|
continue
|
||||||
|
for path, (name, s_base, why, lineno) in audit_file(src):
|
||||||
|
signal = any(h in s_base.lower() for h in HIGH_SIGNAL)
|
||||||
|
if not signal and not args.all_hits:
|
||||||
|
continue
|
||||||
|
rows.append((src.name, lineno, "->".join(path),
|
||||||
|
("%s.%s" % (s_base, name)) if s_base else name, why))
|
||||||
|
if rows:
|
||||||
|
total += len(rows)
|
||||||
|
print("\n%s" % plugin.name)
|
||||||
|
for fname, lineno, chain, call, why in sorted(rows):
|
||||||
|
print(" %s:%-5d %-34s via %s" % (fname, lineno, call + " (" + why + ")", chain))
|
||||||
|
|
||||||
|
print("\n%d blocking call(s) reachable from display() across %d plugin(s)"
|
||||||
|
% (total, len(plugins)))
|
||||||
|
print("Heuristic: a hit guarded by an interval check may be fine. Look, do "
|
||||||
|
"not assume.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+330
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Rebuild the rgbmatrix Python binding so it releases the GIL.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# The upstream binding declares FrameCanvas::SwapOnVSync WITHOUT `nogil`
|
||||||
|
# (cppinc.pxd), unlike SetPixel/Clear/Fill on the lines just above it.
|
||||||
|
# SwapOnVSync blocks until the panel's next vertical sync -- up to a full
|
||||||
|
# refresh period on every frame -- so the render thread was holding the GIL
|
||||||
|
# for most of every frame. Background threads (API fetches, JSON parsing,
|
||||||
|
# image decode) were starved into long uninterruptible bursts, which in turn
|
||||||
|
# made the render loop miss refreshes.
|
||||||
|
#
|
||||||
|
# Measured on a Pi 4 driving a 2x128x64 chain at limit_refresh_rate_hz=100:
|
||||||
|
#
|
||||||
|
# before ~44 fps average, 14-17% of frames 41-53ms
|
||||||
|
# after 100 fps, median 10.00ms, p95 10.05ms, 0% stalls
|
||||||
|
#
|
||||||
|
# The per-pixel blit (SetPixelsPillow) can also release the GIL and walk the
|
||||||
|
# Pillow buffer row-major, but that is OFF by default and you almost certainly
|
||||||
|
# want to leave it that way. Row-major changes what a partially-written frame
|
||||||
|
# looks like: column-major tearing shows as a vertical seam, row-major tearing
|
||||||
|
# shows as a horizontal split between the panel's upper and lower halves. On a
|
||||||
|
# 1/32 scan panel that reads as a one-pixel "fold" across the middle of every
|
||||||
|
# panel -- reported on hardware, and it went away when the blit was reverted.
|
||||||
|
# Enable with RGB_PATCH_BLIT=1 only if you have measured that you need it;
|
||||||
|
# essentially all of the gain above comes from the SwapOnVSync change alone.
|
||||||
|
#
|
||||||
|
# SAFETY
|
||||||
|
# ------
|
||||||
|
# Builds into a scratch directory; touches the installed module only in the
|
||||||
|
# --install step, and backs up the original first. Roll back at any time with:
|
||||||
|
#
|
||||||
|
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# bash scripts/build_rgbmatrix_nogil.sh # build only
|
||||||
|
# sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
||||||
|
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||||
|
#
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Resolve the invoking user's home, not root's. --install runs under sudo,
|
||||||
|
# where $HOME is /root, so every default path below pointed somewhere the
|
||||||
|
# build had never written and the install died with "no built module found".
|
||||||
|
if [ -n "${SUDO_USER:-}" ]; then
|
||||||
|
OWNER_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
|
||||||
|
fi
|
||||||
|
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
||||||
|
|
||||||
|
SRC_TREE="${RGB_SRC_TREE:-$OWNER_HOME/LEDMatrix/rpi-rgb-led-matrix-master}"
|
||||||
|
BUILD_DIR="${RGB_BUILD_DIR:-$OWNER_HOME/rgbmatrix-nogil-build}"
|
||||||
|
VENV="${RGB_CYTHON_VENV:-$OWNER_HOME/.cache/ledmatrix-cython}"
|
||||||
|
BACKUP="${RGB_BACKUP:-$OWNER_HOME/rgbmatrix-core.so.ORIGINAL}"
|
||||||
|
PATCH_BLIT="${RGB_PATCH_BLIT:-0}"
|
||||||
|
|
||||||
|
die() { echo "FATAL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# This script runs under `set -uo pipefail` -- no -e -- so an unchecked
|
||||||
|
# systemctl failure is silently ignored. That matters most for `stop`: leaving
|
||||||
|
# the old service running means cp overwrites a module the running process has
|
||||||
|
# mapped, the following `start` succeeds as a no-op, and the health check sees
|
||||||
|
# an active unit and reports SUCCESS for a binding that was never loaded.
|
||||||
|
# A machine with no ledmatrix.service at all is a normal build host, so that
|
||||||
|
# case is skipped rather than treated as a failure.
|
||||||
|
service_present() { systemctl cat ledmatrix.service >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
service_do() {
|
||||||
|
local verb="$1"
|
||||||
|
if ! service_present; then
|
||||||
|
echo " (no ledmatrix.service installed - skipping $verb)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
systemctl "$verb" ledmatrix || die "systemctl $verb ledmatrix failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
py_site() {
|
||||||
|
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# The extension filename the interpreter that builds -- and then loads -- this
|
||||||
|
# module actually uses, e.g. core.cpython-313-aarch64-linux-gnu.so. The build
|
||||||
|
# venv is made with --system-site-packages from python3, so the two agree;
|
||||||
|
# falling back keeps --install working when the venv has been cleaned up.
|
||||||
|
abi_name() {
|
||||||
|
local py="$VENV/bin/python"
|
||||||
|
[ -x "$py" ] || py=python3
|
||||||
|
"$py" -c \
|
||||||
|
'import sysconfig; print("core" + sysconfig.get_config_var("EXT_SUFFIX"))' \
|
||||||
|
2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exactly the current interpreter's artifact, never merely the first one that
|
||||||
|
# sorts. Staging copies $SRC_TREE wholesale, so a core.cpython-*.so left in the
|
||||||
|
# source tree by an earlier build comes along for the ride; build_ext --inplace
|
||||||
|
# only ever overwrites the current ABI's name, and a glob piped to `head -1`
|
||||||
|
# sorts cpython-311 ahead of cpython-313. That installed a stale, unpatched
|
||||||
|
# module as core.so while the GIL check below -- which reads the freshly
|
||||||
|
# generated core.cpp, not the .so -- still reported success.
|
||||||
|
abi_so() {
|
||||||
|
local name path
|
||||||
|
name="$(abi_name)" || return 1
|
||||||
|
[ -n "$name" ] || return 1
|
||||||
|
path="$BUILD_DIR/bindings/python/rgbmatrix/$name"
|
||||||
|
[ -f "$path" ] || return 1
|
||||||
|
printf '%s\n' "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_rollback() {
|
||||||
|
local dst; dst="$(py_site)"
|
||||||
|
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||||
|
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
||||||
|
service_do stop
|
||||||
|
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
||||||
|
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||||
|
service_do start
|
||||||
|
echo "rolled back to the original core.so"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
do_install() {
|
||||||
|
local so dst
|
||||||
|
so="$(abi_so)"; [ -n "$so" ] || die "no built module found - run the build first"
|
||||||
|
dst="$(py_site)"; [ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP" ]; then
|
||||||
|
cp -a "$dst/core.so" "$BACKUP" || die "could not back up the original"
|
||||||
|
echo "backed up original core.so -> $BACKUP"
|
||||||
|
else
|
||||||
|
echo "backup already present at $BACKUP (keeping the true original)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
service_do stop
|
||||||
|
cp "$so" "$dst/core.so" || die "install failed"
|
||||||
|
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||||
|
service_do start
|
||||||
|
|
||||||
|
echo "waiting 25s for the display to come back..."
|
||||||
|
sleep 25
|
||||||
|
local healthy=1
|
||||||
|
systemctl is-active --quiet ledmatrix || healthy=0
|
||||||
|
if journalctl -u ledmatrix --since "40 sec ago" --no-pager \
|
||||||
|
| grep -qiE "Traceback|ImportError|Segmentation fault|undefined symbol"; then
|
||||||
|
healthy=0
|
||||||
|
fi
|
||||||
|
if [ "$healthy" = "1" ]; then
|
||||||
|
echo "SUCCESS - running on the rebuilt binding"
|
||||||
|
else
|
||||||
|
echo "UNHEALTHY - rolling back"
|
||||||
|
cp -a "$BACKUP" "$dst/core.so" \
|
||||||
|
|| echo "ROLLBACK FAILED: could not restore $BACKUP -> $dst/core.so" >&2
|
||||||
|
if service_present && ! systemctl restart ledmatrix; then
|
||||||
|
echo "ROLLBACK FAILED: ledmatrix did not restart - the display is" \
|
||||||
|
"down; restore manually with 'sudo bash $0 --rollback'" >&2
|
||||||
|
fi
|
||||||
|
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--rollback) do_rollback ;;
|
||||||
|
--install) do_install ;;
|
||||||
|
"" ) ;;
|
||||||
|
*) die "unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- build ----
|
||||||
|
[ -d "$SRC_TREE" ] || die "matrix source tree not found at $SRC_TREE (set RGB_SRC_TREE)"
|
||||||
|
command -v g++ >/dev/null || die "g++ not installed (apt install build-essential)"
|
||||||
|
|
||||||
|
echo "==> staging a scratch copy at $BUILD_DIR"
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
||||||
|
|
||||||
|
# Drop any extension artifacts that came across from the source tree. Nothing
|
||||||
|
# downstream should be able to pick one up, and build_ext --inplace can decide
|
||||||
|
# a copied .so is already up to date and skip the compile entirely.
|
||||||
|
find "$BUILD_DIR/bindings/python/rgbmatrix" -maxdepth 1 \
|
||||||
|
-name 'core*.so' -delete 2>/dev/null
|
||||||
|
|
||||||
|
echo "==> patching the bindings to release the GIL"
|
||||||
|
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
base = sys.argv[1] + "/bindings/python/rgbmatrix/"
|
||||||
|
patch_blit = len(sys.argv) > 2 and sys.argv[2] == "1"
|
||||||
|
|
||||||
|
# --- declare SwapOnVSync as nogil ---------------------------------------
|
||||||
|
p = base + "cppinc.pxd"
|
||||||
|
s = io.open(p, encoding="utf-8").read()
|
||||||
|
OLD_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)\n"
|
||||||
|
NEW_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t) nogil\n"
|
||||||
|
if OLD_DECL in s:
|
||||||
|
io.open(p, "w", encoding="utf-8", newline="\n").write(s.replace(OLD_DECL, NEW_DECL, 1))
|
||||||
|
print(" cppinc.pxd: SwapOnVSync declared nogil")
|
||||||
|
elif NEW_DECL in s:
|
||||||
|
print(" cppinc.pxd: already nogil")
|
||||||
|
else:
|
||||||
|
sys.exit("could not find the SwapOnVSync declaration")
|
||||||
|
|
||||||
|
# --- release the GIL across the vsync wait ------------------------------
|
||||||
|
p = base + "core.pyx"
|
||||||
|
s = io.open(p, encoding="utf-8").read()
|
||||||
|
|
||||||
|
OLD_SWAP = (
|
||||||
|
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
||||||
|
" return __createFrameCanvas("
|
||||||
|
"self.__matrix.SwapOnVSync(newFrame.__canvas, framerate_fraction))\n"
|
||||||
|
)
|
||||||
|
NEW_SWAP = (
|
||||||
|
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
||||||
|
" # Blocks until the panel's next vertical sync. Holding the GIL\n"
|
||||||
|
" # across that wait starves every other Python thread for most of\n"
|
||||||
|
" # each frame. Pointers are hoisted into C locals so the blocking\n"
|
||||||
|
" # call itself needs no Python state.\n"
|
||||||
|
" cdef cppinc.RGBMatrix* matrix = self.__matrix\n"
|
||||||
|
" cdef cppinc.FrameCanvas* frame = newFrame.__canvas\n"
|
||||||
|
" cdef uint8_t fraction = framerate_fraction\n"
|
||||||
|
" cdef cppinc.FrameCanvas* swapped\n"
|
||||||
|
" with nogil:\n"
|
||||||
|
" swapped = matrix.SwapOnVSync(frame, fraction)\n"
|
||||||
|
" return __createFrameCanvas(swapped)\n"
|
||||||
|
)
|
||||||
|
if OLD_SWAP in s:
|
||||||
|
s = s.replace(OLD_SWAP, NEW_SWAP, 1)
|
||||||
|
print(" core.pyx: SwapOnVSync releases the GIL")
|
||||||
|
elif "swapped = matrix.SwapOnVSync(frame, fraction)" in s:
|
||||||
|
print(" core.pyx: SwapOnVSync already patched")
|
||||||
|
else:
|
||||||
|
sys.exit("could not find the SwapOnVSync body")
|
||||||
|
|
||||||
|
# --- optional: release the GIL across the blit --------------------------
|
||||||
|
OLD_BLIT = (
|
||||||
|
" buffer = get_pillow_buffer(image_capsule)\n"
|
||||||
|
"\n"
|
||||||
|
" for col in range(max(0, -xstart), min(width, frame_width - xstart)):\n"
|
||||||
|
" for row in range(max(0, -ystart), min(height, frame_height - ystart)):\n"
|
||||||
|
" pixel = buffer[row][col]\n"
|
||||||
|
" r = (pixel ) & 0xFF\n"
|
||||||
|
" g = (pixel >> 8) & 0xFF\n"
|
||||||
|
" b = (pixel >> 16) & 0xFF\n"
|
||||||
|
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
||||||
|
)
|
||||||
|
NEW_BLIT = (
|
||||||
|
" buffer = get_pillow_buffer(image_capsule)\n"
|
||||||
|
"\n"
|
||||||
|
" # Bounds hoisted so the blit needs no Python state and can run\n"
|
||||||
|
" # without the GIL: it touches only a C buffer and a C++ canvas.\n"
|
||||||
|
" # NOTE: row-major order makes a torn frame show as a horizontal\n"
|
||||||
|
" # split across the panel's halves. See the header before enabling.\n"
|
||||||
|
" cdef int col_start = max(0, -xstart)\n"
|
||||||
|
" cdef int col_end = min(width, frame_width - xstart)\n"
|
||||||
|
" cdef int row_start = max(0, -ystart)\n"
|
||||||
|
" cdef int row_end = min(height, frame_height - ystart)\n"
|
||||||
|
"\n"
|
||||||
|
" with nogil:\n"
|
||||||
|
" for row in range(row_start, row_end):\n"
|
||||||
|
" for col in range(col_start, col_end):\n"
|
||||||
|
" pixel = buffer[row][col]\n"
|
||||||
|
" r = (pixel ) & 0xFF\n"
|
||||||
|
" g = (pixel >> 8) & 0xFF\n"
|
||||||
|
" b = (pixel >> 16) & 0xFF\n"
|
||||||
|
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
||||||
|
)
|
||||||
|
if patch_blit:
|
||||||
|
if OLD_BLIT in s:
|
||||||
|
s = s.replace(OLD_BLIT, NEW_BLIT, 1)
|
||||||
|
print(" core.pyx: pixel blit releases the GIL, row-major")
|
||||||
|
elif "for row in range(row_start, row_end):" in s:
|
||||||
|
print(" core.pyx: blit already patched")
|
||||||
|
else:
|
||||||
|
sys.exit("could not find the SetPixelsPillow loop")
|
||||||
|
else:
|
||||||
|
print(" core.pyx: blit left unpatched (RGB_PATCH_BLIT=1 to enable)")
|
||||||
|
|
||||||
|
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
echo "==> building librgbmatrix.a (this takes a few minutes)"
|
||||||
|
nice -n 10 make -C "$BUILD_DIR/lib" -j2 >/dev/null 2>&1 \
|
||||||
|
|| die "library build failed - rerun 'make -C $BUILD_DIR/lib' to see why"
|
||||||
|
[ -f "$BUILD_DIR/lib/librgbmatrix.a" ] || die "librgbmatrix.a was not produced"
|
||||||
|
|
||||||
|
echo "==> preparing Cython"
|
||||||
|
[ -d "$VENV" ] || python3 -m venv --system-site-packages "$VENV" || die "venv failed"
|
||||||
|
"$VENV/bin/pip" install --quiet cython || die "cython install failed"
|
||||||
|
|
||||||
|
cat > "$BUILD_DIR/bindings/python/setup.py" <<'EOF'
|
||||||
|
from setuptools import setup, Extension
|
||||||
|
from Cython.Build import cythonize
|
||||||
|
|
||||||
|
core = Extension(
|
||||||
|
"rgbmatrix.core",
|
||||||
|
sources=["rgbmatrix/core.pyx", "rgbmatrix/shims/pillow.c"],
|
||||||
|
include_dirs=["../../include", "rgbmatrix/shims"],
|
||||||
|
extra_objects=["../../lib/librgbmatrix.a"],
|
||||||
|
language="c++",
|
||||||
|
extra_compile_args=["-O3", "-Wall", "-fno-exceptions", "-std=c++11"],
|
||||||
|
extra_link_args=["-lrt", "-lm", "-lpthread"],
|
||||||
|
)
|
||||||
|
|
||||||
|
setup(name="rgbmatrix",
|
||||||
|
ext_modules=cythonize([core], language_level="3str",
|
||||||
|
compiler_directives={"binding": False}))
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> compiling the extension"
|
||||||
|
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
||||||
|
>/dev/null 2>&1 || die "extension build failed"
|
||||||
|
|
||||||
|
SO="$(abi_so)" || true
|
||||||
|
[ -n "$SO" ] || die "no .so produced - expected $(abi_name) in $BUILD_DIR/bindings/python/rgbmatrix"
|
||||||
|
|
||||||
|
# Verify the GIL really is released before anyone installs this.
|
||||||
|
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
|
||||||
|
PAIRS=$(grep -c "PyEval_SaveThread\|Py_UNBLOCK_THREADS" \
|
||||||
|
"$BUILD_DIR/bindings/python/rgbmatrix/core.cpp")
|
||||||
|
[ "$PAIRS" -ge "$EXPECTED" ] \
|
||||||
|
|| die "generated C++ has $PAIRS GIL-release sites, expected >= $EXPECTED"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "BUILT: $SO"
|
||||||
|
echo " ($PAIRS GIL-release site(s) in the generated C++)"
|
||||||
|
echo
|
||||||
|
echo "Install with: sudo bash $0 --install"
|
||||||
|
echo "Roll back with: sudo bash $0 --rollback"
|
||||||
+30
-2
@@ -35,6 +35,31 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
|
|
||||||
os.environ['EMULATOR'] = 'true'
|
os.environ['EMULATOR'] = 'true'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_output_encoding_safe() -> None:
|
||||||
|
"""Stop an unencodable character from killing the run.
|
||||||
|
|
||||||
|
This script's own report is ASCII, but it echoes text it does not control
|
||||||
|
-- plugin ids, mode names and exception messages -- and a Windows console
|
||||||
|
is cp1252, which cannot encode most of what a plugin might put there. The
|
||||||
|
default 'strict' error handler turns that into a UnicodeEncodeError from
|
||||||
|
inside `print`, so a rendering run that had already succeeded exited
|
||||||
|
non-zero with a traceback instead of printing its results.
|
||||||
|
|
||||||
|
'replace' degrades the offending character to '?' and keeps going; the
|
||||||
|
encoding itself is left alone so output still matches the terminal.
|
||||||
|
"""
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
stream.reconfigure(errors='replace')
|
||||||
|
except (AttributeError, ValueError, OSError):
|
||||||
|
# Not a reconfigurable TextIOWrapper (redirected, wrapped by a
|
||||||
|
# test harness). Nothing to do -- this is best-effort hardening.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_make_output_encoding_safe()
|
||||||
|
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
||||||
@@ -53,6 +78,9 @@ logger = get_logger("[Check Plugin]")
|
|||||||
DEFAULT_SEARCH_DIRS = [
|
DEFAULT_SEARCH_DIRS = [
|
||||||
str(PROJECT_ROOT / 'plugins'),
|
str(PROJECT_ROOT / 'plugins'),
|
||||||
str(PROJECT_ROOT / 'plugin-repos'),
|
str(PROJECT_ROOT / 'plugin-repos'),
|
||||||
|
# The scoreboards live in the sibling ledmatrix-plugins checkout, not
|
||||||
|
# in this repo. Without this, --all silently skips every one of them.
|
||||||
|
str(PROJECT_ROOT.parent / 'ledmatrix-plugins' / 'plugins'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -178,7 +206,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
|||||||
status = "PASS"
|
status = "PASS"
|
||||||
detail = ""
|
detail = ""
|
||||||
if r.golden_checked:
|
if r.golden_checked:
|
||||||
detail = " (golden ✓)"
|
detail = " (golden ok)"
|
||||||
if r.update_error is not None:
|
if r.update_error is not None:
|
||||||
detail += f" (update warn: {r.update_error})"
|
detail += f" (update warn: {r.update_error})"
|
||||||
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||||
@@ -196,7 +224,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
|||||||
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
||||||
elif r.golden_ok is False:
|
elif r.golden_ok is False:
|
||||||
status = "FAIL"
|
status = "FAIL"
|
||||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
detail = f" golden drift: {r.golden_diff_pixels}px (max delta={r.golden_max_delta})"
|
||||||
elif r.fill_ok is False:
|
elif r.fill_ok is False:
|
||||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||||
status = "FAIL"
|
status = "FAIL"
|
||||||
|
|||||||
@@ -72,12 +72,8 @@ def load_main_config(path: Path) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||||
"""Derive the logical ticker size the way DisplayManager does."""
|
"""Derive the logical ticker size the way DisplayManager does."""
|
||||||
hw = config.get('display', {}).get('hardware', {})
|
from src.display_geometry import logical_size
|
||||||
cols = int(hw.get('cols', 64))
|
return logical_size(config)
|
||||||
chain = int(hw.get('chain_length', 1))
|
|
||||||
rows = int(hw.get('rows', 32))
|
|
||||||
parallel = int(hw.get('parallel', 1))
|
|
||||||
return cols * chain, rows * parallel
|
|
||||||
|
|
||||||
|
|
||||||
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
from flask import Flask, render_template, request, jsonify
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
|
||||||
|
from src.common.path_safety import resolve_under, safe_path_component
|
||||||
|
|
||||||
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -118,7 +120,8 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
|||||||
one of the plugin search dirs, so a crafted id can never name a path
|
one of the plugin search dirs, so a crafted id can never name a path
|
||||||
outside them.
|
outside them.
|
||||||
"""
|
"""
|
||||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
plugin_id = safe_path_component(plugin_id)
|
||||||
|
if not plugin_id or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||||
return None
|
return None
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
loader = PluginLoader()
|
loader = PluginLoader()
|
||||||
@@ -140,8 +143,8 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
|||||||
|
|
||||||
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
||||||
"""Extract default values from config_schema.json."""
|
"""Extract default values from config_schema.json."""
|
||||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||||
if not schema_path.exists():
|
if schema_path is None or not schema_path.exists():
|
||||||
return {}
|
return {}
|
||||||
with open(schema_path, 'r') as f:
|
with open(schema_path, 'r') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
@@ -175,8 +178,8 @@ def api_plugin_schema(plugin_id):
|
|||||||
if not plugin_dir:
|
if not plugin_dir:
|
||||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||||
|
|
||||||
schema_path = plugin_dir / 'config_schema.json'
|
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||||
if not schema_path.exists():
|
if schema_path is None or not schema_path.exists():
|
||||||
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
||||||
|
|
||||||
with open(schema_path, 'r') as f:
|
with open(schema_path, 'r') as f:
|
||||||
|
|||||||
Regular → Executable
@@ -24,9 +24,29 @@ echo "========================================"
|
|||||||
# Auto-detect latest version if needed
|
# Auto-detect latest version if needed
|
||||||
if [ "$PIXLET_VERSION" = "latest" ]; then
|
if [ "$PIXLET_VERSION" = "latest" ]; then
|
||||||
echo "Detecting latest version..."
|
echo "Detecting latest version..."
|
||||||
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
# When this response arrives on a single line -- as it did on the device
|
||||||
if [ -z "$PIXLET_VERSION" ]; then
|
# where Starlark apps were failing -- `grep '"tag_name"'` matches the whole
|
||||||
echo "Failed to detect latest version, using fallback"
|
# document and a greedy `sed 's/.*"([^"]+)".*/\1/'` captures the LAST
|
||||||
|
# quoted token in it rather than the tag. That resolved to
|
||||||
|
# "mentions_count", which built a download URL for a release that does not
|
||||||
|
# exist. (The API is pretty-printed by default, which is why the old
|
||||||
|
# command looks correct when you try it by hand -- but the formatting is
|
||||||
|
# not something to depend on.) Match the field itself and take the value
|
||||||
|
# after it, which is right for either shape.
|
||||||
|
PIXLET_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
|
||||||
|
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||||
|
| head -n1 \
|
||||||
|
| sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')
|
||||||
|
|
||||||
|
# A wrong-but-non-empty value is what made the old bug silent, so check the
|
||||||
|
# shape rather than just that something came back. Anchored at both ends: a
|
||||||
|
# partial match would accept "v0.53garbage" or "0.53" and build a URL for a
|
||||||
|
# release that cannot exist, which is the failure this check is here to
|
||||||
|
# stop. Every tronbyt/pixlet release to date is vX.Y.Z; the optional suffix
|
||||||
|
# leaves room for a future -rc.1 or +build tag.
|
||||||
|
if ! printf '%s' "$PIXLET_VERSION" \
|
||||||
|
| grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "Could not detect the latest version (got: '${PIXLET_VERSION:-<empty>}'), using fallback"
|
||||||
PIXLET_VERSION="v0.50.2"
|
PIXLET_VERSION="v0.50.2"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -67,8 +87,26 @@ download_binary() {
|
|||||||
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
||||||
local temp_file="$temp_dir/$archive_name"
|
local temp_file="$temp_dir/$archive_name"
|
||||||
|
|
||||||
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
|
# -f so an HTTP error is a failure. Without it curl writes the 404 body
|
||||||
echo "✗ Failed to download $arch"
|
# to the file and exits 0, and the first sign of trouble is tar saying
|
||||||
|
# "not in gzip format" about what is actually a page of HTML.
|
||||||
|
if ! curl -fL -o "$temp_file" "$url" 2>/dev/null; then
|
||||||
|
echo "✗ Failed to download $arch from $url"
|
||||||
|
rm -rf "$temp_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Belt and braces: a mirror or proxy can return 200 with an error page.
|
||||||
|
if ! gzip -t "$temp_file" 2>/dev/null; then
|
||||||
|
echo "✗ Downloaded file is not a gzip archive: $url"
|
||||||
|
# These bytes come from whatever answered the request, so strip
|
||||||
|
# everything non-printable before echoing them: an error page carrying
|
||||||
|
# terminal escapes would otherwise be able to rewrite this output or
|
||||||
|
# bury it in a CI log. Printable characters are kept rather than
|
||||||
|
# hex-encoding the lot, because "<!DOCTYPE html>" is the diagnostic.
|
||||||
|
local first_bytes
|
||||||
|
first_bytes=$(head -c 60 "$temp_file" | tr -cd '[:print:]')
|
||||||
|
printf ' (first bytes: %s)\n' "$first_bytes"
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Regular → Executable
+8
-3
@@ -100,10 +100,15 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
|||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||||
|
|
||||||
# Optional: journalctl (non-critical — skip if not found)
|
# Optional: journalctl (non-critical — skip if not found)
|
||||||
|
#
|
||||||
|
# NOEXEC, matching first_time_install.sh. These rules end in a wildcard and
|
||||||
|
# journalctl starts a pager, so without it the caller can reach a shell:
|
||||||
|
# less runs "!command" as the user the pager belongs to, which here is
|
||||||
|
# root. NOEXEC stops the granted command executing anything of its own.
|
||||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *"
|
||||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *"
|
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Required: python3, bash
|
# Required: python3, bash
|
||||||
|
|||||||
@@ -25,9 +25,44 @@ if [ "$EUID" -eq 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Resolve command paths against a fixed PATH, and check what we resolved.
|
||||||
|
#
|
||||||
|
# Every path found here is written into a sudoers file as a NOPASSWD grant, so
|
||||||
|
# whoever controls the binary at that path controls root. first_time_install.sh
|
||||||
|
# re-execs itself with `sudo -E`, which preserves the invoking user's
|
||||||
|
# environment -- PATH included -- so without pinning it, `which nmcli` can
|
||||||
|
# resolve to anything on that PATH: a writable directory early in it turns a
|
||||||
|
# compromise of the low-privilege web user into permanent root.
|
||||||
|
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
# A binary named in a sudoers rule must be root-owned and writable by nobody
|
||||||
|
# else, or the grant hands root to whoever can rewrite it.
|
||||||
|
require_trusted_binary() {
|
||||||
|
local label="$1" path="$2"
|
||||||
|
if [ ! -x "$path" ]; then
|
||||||
|
echo "✗ $label: $path is not an executable file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local owner perms
|
||||||
|
owner=$(stat -c '%u' "$path") || exit 1
|
||||||
|
perms=$(stat -c '%a' "$path") || exit 1
|
||||||
|
if [ "$owner" != "0" ]; then
|
||||||
|
echo "✗ $label: $path is not owned by root (uid $owner); refusing to"
|
||||||
|
echo " grant it NOPASSWD sudo."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Group- or world-writable means someone other than root can replace it.
|
||||||
|
case "$perms" in
|
||||||
|
*[2367]) echo "✗ $label: $path is writable by group or other ($perms);"
|
||||||
|
echo " refusing to grant it NOPASSWD sudo."
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# Get the full paths to commands
|
# Get the full paths to commands
|
||||||
NMCLI_PATH=$(which nmcli || echo "/usr/bin/nmcli")
|
NMCLI_PATH=$(command -v nmcli || echo "/usr/bin/nmcli")
|
||||||
SYSTEMCTL_PATH=$(which systemctl)
|
SYSTEMCTL_PATH=$(command -v systemctl)
|
||||||
|
|
||||||
echo "Command paths:"
|
echo "Command paths:"
|
||||||
echo " nmcli: $NMCLI_PATH"
|
echo " nmcli: $NMCLI_PATH"
|
||||||
@@ -37,6 +72,18 @@ echo " systemctl: $SYSTEMCTL_PATH"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Step 1: Configuring sudo permissions for nmcli..."
|
echo "Step 1: Configuring sudo permissions for nmcli..."
|
||||||
SUDOERS_FILE="/etc/sudoers.d/ledmatrix_wifi"
|
SUDOERS_FILE="/etc/sudoers.d/ledmatrix_wifi"
|
||||||
|
SYSCTL_PATH=$(command -v sysctl || echo /usr/sbin/sysctl)
|
||||||
|
NFT_PATH=$(command -v nft || echo /usr/sbin/nft)
|
||||||
|
RFKILL_PATH=$(command -v rfkill || echo /usr/sbin/rfkill)
|
||||||
|
MKDIR_PATH=$(command -v mkdir || echo /usr/bin/mkdir)
|
||||||
|
|
||||||
|
# Checked before any of them reaches the sudoers file.
|
||||||
|
require_trusted_binary "nmcli" "$NMCLI_PATH"
|
||||||
|
require_trusted_binary "systemctl" "$SYSTEMCTL_PATH"
|
||||||
|
require_trusted_binary "sysctl" "$SYSCTL_PATH"
|
||||||
|
require_trusted_binary "nft" "$NFT_PATH"
|
||||||
|
require_trusted_binary "rfkill" "$RFKILL_PATH"
|
||||||
|
require_trusted_binary "mkdir" "$MKDIR_PATH"
|
||||||
|
|
||||||
# Create a temporary sudoers file using mktemp (handles permissions better)
|
# Create a temporary sudoers file using mktemp (handles permissions better)
|
||||||
TEMP_SUDOERS=$(mktemp) || {
|
TEMP_SUDOERS=$(mktemp) || {
|
||||||
@@ -62,6 +109,36 @@ $WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start dnsmasq
|
|||||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop dnsmasq
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop dnsmasq
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
|
||||||
|
# The captive portal turns IP forwarding on while the access point is up and
|
||||||
|
# restores the previous value when it comes down (wifi_manager._setup_iptables_
|
||||||
|
# redirect / _teardown_iptables_redirect). Without this rule that sudo call
|
||||||
|
# needs a password, so forwarding stays off and clients associate to the AP but
|
||||||
|
# cannot route. It goes unnoticed on a stock Raspberry Pi image, where
|
||||||
|
# /etc/sudoers.d/010_pi-nopasswd grants the default user blanket NOPASSWD and
|
||||||
|
# masks every gap in this file -- it only bites once that blanket rule is
|
||||||
|
# removed.
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSCTL_PATH -w net.ipv4.ip_forward=0
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSCTL_PATH -w net.ipv4.ip_forward=1
|
||||||
|
# The portal's redirect lives in its own nftables table, created when the AP
|
||||||
|
# comes up and deleted when it goes down, and the radio has to be unblocked
|
||||||
|
# before the AP can start at all. Same story as the sysctl rules above: called
|
||||||
|
# with sudo, never granted here, and invisible on a stock Pi image.
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $NFT_PATH add table ip ledmatrix
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $NFT_PATH delete table ip ledmatrix
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $RFKILL_PATH unblock wifi
|
||||||
|
# NetworkManager's dnsmasq drop-in directory, exact path.
|
||||||
|
$WEB_USER ALL=(ALL) NOPASSWD: $MKDIR_PATH -p /etc/NetworkManager/dnsmasq-shared.d
|
||||||
|
#
|
||||||
|
# iptables is deliberately NOT granted here. Its rules are built from the live
|
||||||
|
# interface name and port, so a rule covering them needs a trailing wildcard --
|
||||||
|
# and `iptables --modprobe=/path/to/anything` runs that path as root, so
|
||||||
|
# `NOPASSWD: iptables *` is a root shell for the web user by another name. That
|
||||||
|
# is a worse outcome than the gap it would close, which today is masked anyway
|
||||||
|
# by the blanket NOPASSWD rule on stock Pi images.
|
||||||
|
#
|
||||||
|
# Closing it safely means a wrapper script that builds the rules itself and
|
||||||
|
# takes only an interface and a port, granted the way safe_plugin_rm.sh already
|
||||||
|
# is. That belongs in its own change rather than being smuggled into this one.
|
||||||
|
|
||||||
# Allow copying hostapd and dnsmasq config files into place
|
# Allow copying hostapd and dnsmasq config files into place
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
||||||
|
|||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# DNS single-request fix installation script.
|
||||||
|
#
|
||||||
|
# Optional. Install this only if plugins that call external APIs (Starlark
|
||||||
|
# apps, weather, sports, music) are timing out or feel slow to first paint
|
||||||
|
# while the network is otherwise fine. See the header of
|
||||||
|
# scripts/utils/apply_dns_single_request.sh for what it changes and why.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
SERVICE_NAME="ledmatrix-dns-fix"
|
||||||
|
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||||
|
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||||
|
DROPIN_DIR="/etc/systemd/system/ledmatrix.service.d"
|
||||||
|
|
||||||
|
if [ "$EUID" -eq 0 ]; then
|
||||||
|
SYSTEMCTL_CMD="systemctl"
|
||||||
|
SUDO=""
|
||||||
|
else
|
||||||
|
SYSTEMCTL_CMD="sudo systemctl"
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing LED Matrix DNS fix service"
|
||||||
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$UNIT_SRC" ]; then
|
||||||
|
echo "✗ Missing unit file: $UNIT_SRC"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
|
||||||
|
|
||||||
|
echo "Installing $UNIT_DEST..."
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||||
|
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||||
|
|
||||||
|
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
||||||
|
# orders units already in the same transaction, so a plain
|
||||||
|
# `systemctl restart ledmatrix` would not wait for it -- and since this fix is
|
||||||
|
# opt-in, ledmatrix.service cannot carry the dependency in the repo.
|
||||||
|
# Wants=, not Requires=: a DNS workaround failing should not stop the display.
|
||||||
|
echo "Installing the ledmatrix.service ordering drop-in..."
|
||||||
|
$SUDO mkdir -p "$DROPIN_DIR"
|
||||||
|
printf '[Unit]\nWants=%s.service\nAfter=%s.service\n' "$SERVICE_NAME" "$SERVICE_NAME" \
|
||||||
|
| $SUDO tee "$DROPIN_DIR/10-dns-fix.conf" > /dev/null
|
||||||
|
|
||||||
|
$SYSTEMCTL_CMD daemon-reload
|
||||||
|
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
||||||
|
|
||||||
|
# Do not mask a failure here. The unit exits non-zero when it could not apply
|
||||||
|
# the option -- a systemd-resolved host, an unwritable resolv.conf, a failed
|
||||||
|
# `resolvconf -u` -- and reporting "installation complete" over that would
|
||||||
|
# leave the operator believing a workaround is active when it is not.
|
||||||
|
START_STATUS=0
|
||||||
|
$SYSTEMCTL_CMD start "$SERVICE_NAME.service" || START_STATUS=$?
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if grep -qs "^options single-request$" /etc/resolv.conf; then
|
||||||
|
echo "✓ 'options single-request' is active in /etc/resolv.conf"
|
||||||
|
elif [ "$START_STATUS" -ne 0 ]; then
|
||||||
|
echo "✗ The DNS fix could not be applied on this host."
|
||||||
|
echo " The service reported why:"
|
||||||
|
echo " journalctl -u $SERVICE_NAME -n 20"
|
||||||
|
echo ""
|
||||||
|
echo " The unit is installed and will try again on the next boot. Nothing"
|
||||||
|
echo " else about your install has changed."
|
||||||
|
exit "$START_STATUS"
|
||||||
|
else
|
||||||
|
echo "⚠ 'options single-request' is not in /etc/resolv.conf yet."
|
||||||
|
echo " Check what the service reported:"
|
||||||
|
echo " journalctl -u $SERVICE_NAME -n 20"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "DNS fix installation complete."
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " sudo systemctl status $SERVICE_NAME # Check status"
|
||||||
|
echo " sudo journalctl -u $SERVICE_NAME -n 50 # View logs"
|
||||||
|
echo " sudo systemctl disable --now $SERVICE_NAME # Undo the service"
|
||||||
|
echo " sudo rm $DROPIN_DIR/10-dns-fix.conf # Undo the ordering drop-in"
|
||||||
|
echo " # then remove the 'options single-request' line from /etc/resolv.conf"
|
||||||
|
echo ""
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Home Assistant MQTT bridge installation script.
|
||||||
|
#
|
||||||
|
# Optional. Installs integrations/mqtt_bridge as a service so Home Assistant
|
||||||
|
# can force display modes, toggle power and set brightness over MQTT.
|
||||||
|
# See integrations/mqtt_bridge/README.md.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
||||||
|
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
||||||
|
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||||
|
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||||
|
|
||||||
|
if [ "$EUID" -eq 0 ]; then
|
||||||
|
SYSTEMCTL_CMD="systemctl"
|
||||||
|
SUDO=""
|
||||||
|
else
|
||||||
|
SYSTEMCTL_CMD="sudo systemctl"
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing LED Matrix MQTT bridge"
|
||||||
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$BRIDGE_DIR/bridge_config.json" ]; then
|
||||||
|
cp "$BRIDGE_DIR/bridge_config.example.json" "$BRIDGE_DIR/bridge_config.json"
|
||||||
|
chmod 600 "$BRIDGE_DIR/bridge_config.json"
|
||||||
|
echo ""
|
||||||
|
echo "⚠ Created $BRIDGE_DIR/bridge_config.json from the example."
|
||||||
|
echo " Edit it with your broker details, then re-run this script."
|
||||||
|
echo " The service will refuse to start until the placeholder password is replaced."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing Python dependencies..."
|
||||||
|
python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|
||||||
|
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
|
||||||
|
|
||||||
|
echo "Installing $UNIT_DEST..."
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||||
|
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||||
|
|
||||||
|
$SYSTEMCTL_CMD daemon-reload
|
||||||
|
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
||||||
|
$SYSTEMCTL_CMD restart "$SERVICE_NAME.service" || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if $SYSTEMCTL_CMD is-active --quiet "$SERVICE_NAME.service" 2>/dev/null; then
|
||||||
|
echo "✓ MQTT bridge is running"
|
||||||
|
echo " The matrix should appear in Home Assistant under Settings > Devices > MQTT."
|
||||||
|
else
|
||||||
|
echo "⚠ MQTT bridge is not running. Check the logs:"
|
||||||
|
echo " sudo journalctl -u $SERVICE_NAME -n 50"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " sudo systemctl status $SERVICE_NAME"
|
||||||
|
echo " sudo journalctl -u $SERVICE_NAME -f"
|
||||||
|
echo " sudo systemctl disable --now $SERVICE_NAME # Undo"
|
||||||
|
echo ""
|
||||||
Regular → Executable
+73
-29
@@ -16,20 +16,39 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
|
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
|
||||||
echo "Using home directory: $USER_HOME"
|
echo "Using home directory: $USER_HOME"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
# Create a temporary service file for the main display with the correct paths
|
# Render the main display unit from its template. The display service runs as
|
||||||
# Assuming ledmatrix.service template exists and uses /home/ledpi as a placeholder for user home
|
# root (it needs GPIO), so __USER__ is always root here -- unlike the web unit
|
||||||
|
# below, which runs as whoever installed it.
|
||||||
|
#
|
||||||
|
# A missing template or a failed render is fatal: falling through would leave
|
||||||
|
# whatever unit already sits at /etc/systemd/system/ledmatrix.service (from a
|
||||||
|
# previous install) untouched, and the enable/start step below would then
|
||||||
|
# silently reuse that stale unit instead of the one this run was asked to
|
||||||
|
# install.
|
||||||
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
||||||
sed "s|/home/ledpi|$USER_HOME|g; s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > /tmp/ledmatrix.service.tmp
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
MAIN_UNIT_TMP=$(mktemp)
|
||||||
|
trap 'rm -f "$MAIN_UNIT_TMP"' EXIT
|
||||||
|
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" \
|
||||||
|
"$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > "$MAIN_UNIT_TMP"; then
|
||||||
|
echo "ERROR: failed to render ledmatrix.service from its template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# Copy the service file to the systemd directory
|
# Copy the service file to the systemd directory
|
||||||
sudo cp /tmp/ledmatrix.service.tmp /etc/systemd/system/ledmatrix.service
|
sudo cp "$MAIN_UNIT_TMP" /etc/systemd/system/ledmatrix.service
|
||||||
# Clean up
|
# Clean up
|
||||||
rm /tmp/ledmatrix.service.tmp
|
rm -f "$MAIN_UNIT_TMP"
|
||||||
|
trap - EXIT
|
||||||
else
|
else
|
||||||
echo "WARNING: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service. Main display service not configured."
|
echo "ERROR: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
@@ -48,42 +67,67 @@ fi
|
|||||||
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
|
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
|
||||||
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
|
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
|
||||||
|
|
||||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
# Rendered from systemd/ledmatrix-web.service, the same template
|
||||||
[Unit]
|
# install_web_service.sh uses. This was an inline heredoc until it drifted from
|
||||||
Description=LED Matrix Web Interface (Conditional Start)
|
# the template: it had lost Wants=network-online.target, RestartSec,
|
||||||
After=network.target
|
# SyslogIdentifier, CacheDirectory and Environment=USE_THREADING. Because
|
||||||
# Wants=ledmatrix.service
|
# src/startup_validator.py compares the installed unit against the template,
|
||||||
# After=network.target ledmatrix.service
|
# every boot warned "re-run install_service.sh" -- and doing so reinstalled the
|
||||||
|
# same stale copy, so the warning could never clear.
|
||||||
|
#
|
||||||
|
# As with the main unit above, a missing template or a failed render is
|
||||||
|
# fatal -- otherwise the enable/start check below would fall back to
|
||||||
|
# whatever unit (possibly stale) already exists at the destination path.
|
||||||
|
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||||
|
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||||
|
WEB_UNIT_TMP=$(mktemp)
|
||||||
|
trap 'rm -f "$WEB_UNIT_TMP"' EXIT
|
||||||
|
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||||
|
"$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" > "$WEB_UNIT_TMP"; then
|
||||||
|
echo "ERROR: failed to render ledmatrix-web.service from its template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo cp "$WEB_UNIT_TMP" /etc/systemd/system/ledmatrix-web.service
|
||||||
|
rm -f "$WEB_UNIT_TMP"
|
||||||
|
trap - EXIT
|
||||||
|
else
|
||||||
|
echo "ERROR: ledmatrix-web.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix-web.service." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
[Service]
|
# Health check / rollback units for automatic updates; see install_web_service.sh.
|
||||||
Type=simple
|
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
|
||||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
if [ -f "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" ]; then
|
||||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
VERIFY_UNIT_TMP=$(mktemp)
|
||||||
StandardOutput=journal
|
if sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" > "$VERIFY_UNIT_TMP"; then
|
||||||
StandardError=journal
|
sudo cp "$VERIFY_UNIT_TMP" "/etc/systemd/system/$VERIFY_UNIT"
|
||||||
User=${ACTUAL_USER}
|
else
|
||||||
Restart=on-failure
|
echo "WARNING: failed to render $VERIFY_UNIT; automatic code updates will stay paused." >&2
|
||||||
# Environment="PYTHONUNBUFFERED=1"
|
fi
|
||||||
|
rm -f "$VERIFY_UNIT_TMP"
|
||||||
[Install]
|
fi
|
||||||
WantedBy=multi-user.target
|
done
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write the new service file
|
|
||||||
echo "$WEB_SERVICE_FILE_CONTENT" | sudo tee /etc/systemd/system/ledmatrix-web.service > /dev/null
|
|
||||||
|
|
||||||
echo "Reloading systemd daemon for web service..."
|
echo "Reloading systemd daemon for web service..."
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||||
echo "Enabling ledmatrix-web.service to start on boot..."
|
echo "Enabling ledmatrix-web.service to start on boot..."
|
||||||
sudo systemctl enable ledmatrix-web.service
|
sudo systemctl enable ledmatrix-web.service
|
||||||
|
|
||||||
|
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
|
||||||
|
echo "Enabling ledmatrix-update-verify.path (automatic update health check)..."
|
||||||
|
sudo systemctl enable --now ledmatrix-update-verify.path || echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting ledmatrix-web.service..."
|
echo "Starting ledmatrix-web.service..."
|
||||||
sudo systemctl start ledmatrix-web.service
|
sudo systemctl start ledmatrix-web.service
|
||||||
|
|
||||||
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
||||||
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
||||||
|
else
|
||||||
|
echo "Skipping enable/start for ledmatrix-web.service as it was not configured."
|
||||||
|
fi
|
||||||
# === End of LEDMatrix Web Interface service ===
|
# === End of LEDMatrix Web Interface service ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+40
-29
@@ -17,6 +17,9 @@ fi
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing for user: $ACTUAL_USER"
|
echo "Installing for user: $ACTUAL_USER"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
@@ -26,37 +29,38 @@ if [ "$EUID" -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate the service file dynamically with the correct paths
|
# Render the unit from systemd/ledmatrix-web.service. That template is the
|
||||||
echo "Generating service file with dynamic paths..."
|
# only description of the unit; this script used to carry its own heredoc copy,
|
||||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
# and install_service.sh a third, which is how the installed unit on real rigs
|
||||||
[Unit]
|
# ended up missing RestartSec, SyslogIdentifier and CacheDirectory while
|
||||||
Description=LED Matrix Web Interface Service
|
# src/startup_validator.py warned about drift on every boot.
|
||||||
After=network-online.target
|
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"
|
||||||
Wants=network-online.target
|
if [ ! -f "$TEMPLATE" ]; then
|
||||||
|
echo "ERROR: unit template not found at $TEMPLATE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${ACTUAL_USER}
|
|
||||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
|
||||||
Environment=USE_THREADING=1
|
|
||||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=ledmatrix-web
|
|
||||||
# Automatically create and manage cache directory
|
|
||||||
CacheDirectory=ledmatrix
|
|
||||||
CacheDirectoryMode=0775
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write the service file to systemd directory
|
|
||||||
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
|
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
|
||||||
echo "$WEB_SERVICE_FILE_CONTENT" > /etc/systemd/system/ledmatrix-web.service
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
|
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||||
|
"$TEMPLATE" > /etc/systemd/system/ledmatrix-web.service
|
||||||
|
|
||||||
|
# Health check and rollback for the web UI's automatic updates. Its own unit so
|
||||||
|
# it survives the web service restart it performs; never enabled -- the web
|
||||||
|
# interface starts it after an update. Without it, automatic code updates
|
||||||
|
# stay paused rather than running with nothing to undo them.
|
||||||
|
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
|
||||||
|
VERIFY_TEMPLATE="$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT"
|
||||||
|
if [ -f "$VERIFY_TEMPLATE" ]; then
|
||||||
|
echo "Writing unit file to /etc/systemd/system/$VERIFY_UNIT"
|
||||||
|
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||||
|
"$VERIFY_TEMPLATE" > "/etc/systemd/system/$VERIFY_UNIT"
|
||||||
|
chmod 644 "/etc/systemd/system/$VERIFY_UNIT"
|
||||||
|
else
|
||||||
|
echo "WARNING: $VERIFY_TEMPLATE not found; automatic code updates will stay paused"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Ensure cache directory exists with proper permissions
|
# Ensure cache directory exists with proper permissions
|
||||||
# This is a fallback for older systemd versions that don't support CacheDirectory
|
# This is a fallback for older systemd versions that don't support CacheDirectory
|
||||||
@@ -92,6 +96,13 @@ systemctl daemon-reload
|
|||||||
echo "Enabling ledmatrix-web.service..."
|
echo "Enabling ledmatrix-web.service..."
|
||||||
systemctl enable ledmatrix-web.service
|
systemctl enable ledmatrix-web.service
|
||||||
|
|
||||||
|
# The path unit is what starts the health check after an automatic update.
|
||||||
|
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
|
||||||
|
echo "Enabling ledmatrix-update-verify.path..."
|
||||||
|
systemctl enable --now ledmatrix-update-verify.path || \
|
||||||
|
echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused"
|
||||||
|
fi
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
echo "Starting ledmatrix-web.service..."
|
echo "Starting ledmatrix-web.service..."
|
||||||
systemctl start ledmatrix-web.service
|
systemctl start ledmatrix-web.service
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
|||||||
# Determine the Project Root Directory (parent of scripts/install/)
|
# Determine the Project Root Directory (parent of scripts/install/)
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
|
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
|
||||||
echo "Using home directory: $USER_HOME"
|
echo "Using home directory: $USER_HOME"
|
||||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||||
@@ -64,30 +67,19 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
|||||||
echo "✓ Package installation completed"
|
echo "✓ Package installation completed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create service file with correct paths
|
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
||||||
|
# inlining a second copy here. The copy this replaced had already drifted --
|
||||||
|
# it wrote StandardOutput/StandardError=syslog where the template says journal.
|
||||||
echo ""
|
echo ""
|
||||||
echo "Creating systemd service file..."
|
echo "Creating systemd service file..."
|
||||||
SERVICE_FILE_CONTENT=$(cat <<EOF
|
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-wifi-monitor.service"
|
||||||
[Unit]
|
if [ ! -f "$TEMPLATE" ]; then
|
||||||
Description=LED Matrix WiFi Monitor Daemon
|
echo "ERROR: unit template not found at $TEMPLATE"
|
||||||
After=network.target
|
exit 1
|
||||||
Wants=network.target
|
fi
|
||||||
|
|
||||||
[Service]
|
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||||
Type=simple
|
SERVICE_FILE_CONTENT=$(sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$TEMPLATE")
|
||||||
User=root
|
|
||||||
WorkingDirectory=$PROJECT_ROOT_DIR
|
|
||||||
ExecStart=/usr/bin/python3 $PROJECT_ROOT_DIR/scripts/utils/wifi_monitor_daemon.py --interval 30
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=ledmatrix-wifi-monitor
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "$EUID" -eq 0 ]; then
|
if [ "$EUID" -eq 0 ]; then
|
||||||
echo "$SERVICE_FILE_CONTENT" | tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
|
echo "$SERVICE_FILE_CONTENT" | tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Shared helper for rendering systemd unit templates via sed.
|
||||||
|
#
|
||||||
|
# Sourced by install_service.sh, install_web_service.sh and
|
||||||
|
# install_wifi_monitor.sh so all three escape sed replacement text the same
|
||||||
|
# way instead of carrying three copies of the same fix.
|
||||||
|
|
||||||
|
# sed_escape_replacement VALUE
|
||||||
|
#
|
||||||
|
# Print VALUE escaped for safe use as the replacement side of `sed
|
||||||
|
# s|pattern|replacement|`. Every one of these scripts builds its sed
|
||||||
|
# expression by interpolating a shell variable (a path, a username, ...)
|
||||||
|
# straight into the replacement text. sed gives three characters special
|
||||||
|
# meaning there: backslash (escape character), & (whole match) and the
|
||||||
|
# delimiter itself (here `|`). A value containing any of them -- e.g. a
|
||||||
|
# username or path with an `&`, a literal backslash, or a `|` -- would
|
||||||
|
# otherwise corrupt the rendered unit file instead of being substituted
|
||||||
|
# literally. Escape the backslash first so the later escapes aren't
|
||||||
|
# double-escaped.
|
||||||
|
sed_escape_replacement() {
|
||||||
|
local value="$1"
|
||||||
|
value="${value//\\/\\\\}"
|
||||||
|
value="${value//&/\\&}"
|
||||||
|
value="${value//|/\\|}"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
Regular → Executable
@@ -408,6 +408,7 @@ main() {
|
|||||||
# which would silently reinstate the duplicate apt update.
|
# which would silently reinstate the duplicate apt update.
|
||||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
|
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
|
||||||
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
|
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
|
||||||
|
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
|
||||||
bash ./first_time_install.sh -y </dev/null
|
bash ./first_time_install.sh -y </dev/null
|
||||||
fi
|
fi
|
||||||
INSTALL_EXIT_CODE=$?
|
INSTALL_EXIT_CODE=$?
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
@@ -52,6 +52,10 @@ def main() -> int:
|
|||||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||||
parser.add_argument('--skip-update', action='store_true',
|
parser.add_argument('--skip-update', action='store_true',
|
||||||
help='Skip calling update() (render display only)')
|
help='Skip calling update() (render display only)')
|
||||||
|
parser.add_argument('--display-mode', default=None,
|
||||||
|
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.')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -141,7 +145,25 @@ def main() -> int:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("update() raised: %s — continuing to display()", e)
|
logger.warning("update() raised: %s — continuing to display()", e)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# Only pass the argument when asked for, so the many plugins whose display()
|
||||||
|
# takes no display_mode keep working untouched.
|
||||||
try:
|
try:
|
||||||
|
if args.display_mode:
|
||||||
|
try:
|
||||||
|
plugin_instance.display(display_mode=args.display_mode,
|
||||||
|
force_clear=True)
|
||||||
|
except TypeError as error:
|
||||||
|
if ("unexpected keyword argument" not in str(error)
|
||||||
|
or "display_mode" not in str(error)):
|
||||||
|
raise
|
||||||
|
logger.warning(
|
||||||
|
"%s.display() does not accept display_mode; rendering its "
|
||||||
|
"default screen instead", args.plugin)
|
||||||
|
plugin_instance.display(force_clear=True)
|
||||||
|
else:
|
||||||
plugin_instance.display(force_clear=True)
|
plugin_instance.display(force_clear=True)
|
||||||
logger.debug("display() completed")
|
logger.debug("display() completed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ Discovers and runs tests for LEDMatrix plugins.
|
|||||||
Supports both unittest and pytest.
|
Supports both unittest and pytest.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -68,6 +71,79 @@ def _find_tests_in_dir(directory: Path) -> list:
|
|||||||
return sorted(set(test_files))
|
return sorted(set(test_files))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_script_style(path) -> bool:
|
||||||
|
"""True when a test file is a standalone script, not a pytest module.
|
||||||
|
|
||||||
|
Most plugin tests are written as `def main()` plus an `if __name__ ==
|
||||||
|
"__main__"` guard and signal through an exit code. pytest collects zero
|
||||||
|
items from those, so handing them to pytest printed "no tests ran" and this
|
||||||
|
runner reported success over work it had not done -- 151 of 248 files on a
|
||||||
|
fully populated rig.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
src = Path(path).read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
has_pytest_items = re.search(r"^\s*(def test_|class Test|async def test_)", src, re.M)
|
||||||
|
has_main_guard = "__main__" in src and "__name__" in src
|
||||||
|
return bool(has_main_guard and not has_pytest_items)
|
||||||
|
|
||||||
|
|
||||||
|
def run_script_tests(test_files: list, verbose: bool = False) -> int:
|
||||||
|
"""Run standalone test scripts, honouring the 0 pass / 2 skip / 1 fail
|
||||||
|
convention that ledmatrix-plugins' own runner established.
|
||||||
|
|
||||||
|
Scripts opt into skipping by printing "SKIP: <reason>" and exiting 2 --
|
||||||
|
a script that needs a tty or an LED matrix is not a regression.
|
||||||
|
"""
|
||||||
|
env = dict(os.environ)
|
||||||
|
# Prepend rather than setdefault. An inherited PYTHONPATH -- a developer's
|
||||||
|
# shell, a tox run, another checkout -- otherwise wins outright, and the
|
||||||
|
# subprocess imports a different copy of the core than the one under test.
|
||||||
|
# That is exactly the failure ledmatrix-plugins#467 describes, and it is
|
||||||
|
# invisible: the tests pass or fail against a tree nobody meant to test.
|
||||||
|
inherited = env.get("PYTHONPATH")
|
||||||
|
env["PYTHONPATH"] = (f"{PROJECT_ROOT}{os.pathsep}{inherited}"
|
||||||
|
if inherited else str(PROJECT_ROOT))
|
||||||
|
env["LEDMATRIX_CORE"] = str(PROJECT_ROOT)
|
||||||
|
|
||||||
|
passed = skipped = failed = 0
|
||||||
|
failures = []
|
||||||
|
for path in test_files:
|
||||||
|
try:
|
||||||
|
# Fixed interpreter (sys.executable) plus a test path this script
|
||||||
|
# discovered by globbing the repo; argument list, no shell, so
|
||||||
|
# nothing is word-split or expanded. Same suppression pair the
|
||||||
|
# rest of the repo uses for this shape (see permission_utils.py).
|
||||||
|
proc = subprocess.run( # noqa: S603 # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||||
|
[sys.executable, str(path)], # nosemgrep
|
||||||
|
cwd=str(Path(path).parent),
|
||||||
|
capture_output=True, text=True, env=env,
|
||||||
|
stdin=subprocess.DEVNULL, timeout=300,
|
||||||
|
)
|
||||||
|
rc = proc.returncode
|
||||||
|
tail = " | ".join((proc.stdout or proc.stderr or "").strip().splitlines()[-2:])[:200]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rc, tail = 1, "timed out after 300s"
|
||||||
|
if rc == 0:
|
||||||
|
passed += 1
|
||||||
|
label = "pass"
|
||||||
|
elif rc == 2:
|
||||||
|
skipped += 1
|
||||||
|
label = "SKIP"
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
label = "FAIL"
|
||||||
|
failures.append(f"{Path(path).name}: exit {rc} | {tail}")
|
||||||
|
if verbose or rc != 0:
|
||||||
|
print(f" [{label}] {Path(path).name}" + (f" -- {tail}" if rc != 0 else ""))
|
||||||
|
|
||||||
|
print(f"\n{passed} passed, {skipped} skipped, {failed} failed (scripts)")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
||||||
"""
|
"""
|
||||||
Run tests using unittest.
|
Run tests using unittest.
|
||||||
@@ -186,7 +262,12 @@ def main():
|
|||||||
print("No test files found in plugins directory")
|
print("No test files found in plugins directory")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"Found {len(test_files)} test file(s)")
|
scripts = [f for f in test_files if _is_script_style(f)]
|
||||||
|
modules = [f for f in test_files if f not in scripts]
|
||||||
|
|
||||||
|
print(f"Found {len(test_files)} test file(s)"
|
||||||
|
+ (f" -- {len(modules)} collectable, {len(scripts)} standalone script(s)"
|
||||||
|
if scripts else ""))
|
||||||
for test_file in test_files:
|
for test_file in test_files:
|
||||||
print(f" - {test_file}")
|
print(f" - {test_file}")
|
||||||
print()
|
print()
|
||||||
@@ -200,11 +281,20 @@ def main():
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
runner = 'unittest'
|
runner = 'unittest'
|
||||||
|
|
||||||
# Run tests
|
# Standalone scripts cannot be collected by pytest or unittest -- run them
|
||||||
|
# as the scripts they are. Doing this rather than silently collecting zero
|
||||||
|
# items is the whole point: this runner used to report success having
|
||||||
|
# executed nothing.
|
||||||
|
rc = 0
|
||||||
|
if scripts:
|
||||||
|
rc |= run_script_tests(scripts, args.verbose)
|
||||||
|
|
||||||
|
if modules:
|
||||||
if runner == 'pytest':
|
if runner == 'pytest':
|
||||||
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
rc |= run_pytest_tests(modules, args.verbose, args.coverage)
|
||||||
else:
|
else:
|
||||||
return run_unittest_tests(test_files, args.verbose)
|
rc |= run_unittest_tests(modules, args.verbose)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Show and try the scroll speeds your panel can display cleanly.
|
||||||
|
|
||||||
|
Motion looks smooth when the strip advances a WHOLE number of pixels per panel
|
||||||
|
refresh. Anything else has to blend two columns (which on pixel-font text reads
|
||||||
|
as shimmer) or repeat frames unevenly (which reads as judder). So the speeds
|
||||||
|
worth using are not arbitrary -- they are
|
||||||
|
|
||||||
|
refresh_hz / frame_hold * pixels_per_frame
|
||||||
|
|
||||||
|
for whole numbers of frame_hold and pixels_per_frame, and that ladder depends
|
||||||
|
on how fast YOUR panel actually refreshes. A Pi Zero driving a big chain will
|
||||||
|
have a completely different set of good speeds from a Pi 4 driving a small one.
|
||||||
|
|
||||||
|
# what can this panel do? (no hardware needed, uses your configured rate)
|
||||||
|
python3 scripts/scroll_speeds.py
|
||||||
|
|
||||||
|
# measure what the panel ACTUALLY manages, rather than what is configured
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
sudo python3 scripts/scroll_speeds.py --measure
|
||||||
|
sudo systemctl start ledmatrix
|
||||||
|
|
||||||
|
# what would a 60Hz panel offer?
|
||||||
|
python3 scripts/scroll_speeds.py --hz 60
|
||||||
|
|
||||||
|
# try one on the panel
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
sudo python3 scripts/scroll_speeds.py --demo 50
|
||||||
|
sudo systemctl start ledmatrix
|
||||||
|
|
||||||
|
This script never starts or stops the display service itself -- that is left to
|
||||||
|
you, so a crash here can never leave the panel dark.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.common import scroll_config # noqa: E402
|
||||||
|
|
||||||
|
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_hardware():
|
||||||
|
try:
|
||||||
|
with open(CONFIG, encoding="utf-8") as handle:
|
||||||
|
return (json.load(handle).get("display") or {}).get("hardware") or {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def build_options(hardware, refresh_override=None):
|
||||||
|
from rgbmatrix import RGBMatrixOptions
|
||||||
|
|
||||||
|
o = RGBMatrixOptions()
|
||||||
|
from src.display_geometry import (
|
||||||
|
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||||
|
)
|
||||||
|
o.rows = int(hardware.get("rows", DEFAULT_ROWS))
|
||||||
|
o.cols = int(hardware.get("cols", DEFAULT_COLS))
|
||||||
|
o.chain_length = int(hardware.get("chain_length", DEFAULT_CHAIN_LENGTH))
|
||||||
|
o.parallel = int(hardware.get("parallel", DEFAULT_PARALLEL))
|
||||||
|
o.brightness = int(hardware.get("brightness", 80))
|
||||||
|
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
|
||||||
|
o.pwm_bits = int(hardware.get("pwm_bits", 11))
|
||||||
|
o.pwm_dither_bits = int(hardware.get("pwm_dither_bits", 0))
|
||||||
|
o.pwm_lsb_nanoseconds = int(hardware.get("pwm_lsb_nanoseconds", 130))
|
||||||
|
o.led_rgb_sequence = hardware.get("led_rgb_sequence", "RGB")
|
||||||
|
o.scan_mode = int(hardware.get("scan_mode", 0))
|
||||||
|
o.row_address_type = int(hardware.get("row_address_type", 0))
|
||||||
|
o.multiplexing = int(hardware.get("multiplexing", 0))
|
||||||
|
o.gpio_slowdown = int(hardware.get("gpio_slowdown", 2))
|
||||||
|
o.limit_refresh_rate_hz = (
|
||||||
|
int(refresh_override) if refresh_override is not None
|
||||||
|
else int(hardware.get("limit_refresh_rate_hz", 0))
|
||||||
|
)
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
def open_matrix(hardware, refresh_override=None):
|
||||||
|
"""Construct the matrix, or explain why it will not open."""
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
sys.exit("this needs root for GPIO access - rerun with sudo")
|
||||||
|
try:
|
||||||
|
from rgbmatrix import RGBMatrix
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("rgbmatrix is not installed on this machine")
|
||||||
|
try:
|
||||||
|
return RGBMatrix(options=build_options(hardware, refresh_override))
|
||||||
|
except Exception as exc: # pragma: no cover - hardware dependent
|
||||||
|
sys.exit(
|
||||||
|
"could not open the panel ({}).\n"
|
||||||
|
"If the display service is running it owns the GPIO - stop it first:\n"
|
||||||
|
" sudo systemctl stop ledmatrix".format(exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def measure_refresh(hardware, seconds=6.0):
|
||||||
|
"""Actual refresh rate, by running uncapped and timing the swaps.
|
||||||
|
|
||||||
|
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
|
||||||
|
runs at exactly the panel's rate. This is what an older Pi or a longer
|
||||||
|
chain will really give you, as opposed to whatever limit_refresh_rate_hz
|
||||||
|
optimistically asks for.
|
||||||
|
"""
|
||||||
|
matrix = open_matrix(hardware, refresh_override=0)
|
||||||
|
canvas = matrix.CreateFrameCanvas()
|
||||||
|
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
|
||||||
|
frames = 0
|
||||||
|
started = time.perf_counter()
|
||||||
|
while time.perf_counter() - started < seconds:
|
||||||
|
canvas = matrix.SwapOnVSync(canvas)
|
||||||
|
frames += 1
|
||||||
|
measured = frames / (time.perf_counter() - started)
|
||||||
|
matrix.Clear()
|
||||||
|
return measured
|
||||||
|
|
||||||
|
|
||||||
|
def demo(hardware, target, seconds):
|
||||||
|
"""Scroll text at the crisp speed nearest `target`."""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from src.common.font_layout import load_truetype
|
||||||
|
|
||||||
|
hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
|
||||||
|
choice = scroll_config.solve_crisp(target, hz)
|
||||||
|
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
|
||||||
|
|
||||||
|
matrix = open_matrix(hardware)
|
||||||
|
canvas = matrix.CreateFrameCanvas()
|
||||||
|
W, H = canvas.width, canvas.height
|
||||||
|
|
||||||
|
font = None
|
||||||
|
for path, size in (
|
||||||
|
(str(Path(__file__).resolve().parent.parent / "assets/fonts/PressStart2P-Regular.ttf"), 16),
|
||||||
|
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
font = load_truetype(path, size)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if font is None:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
text = " {:.0f} px/s *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG ***".format(
|
||||||
|
choice.pixels_per_second)
|
||||||
|
box = ImageDraw.Draw(Image.new("RGB", (8, 8))).textbbox((0, 0), text, font=font)
|
||||||
|
tw, th = box[2] - box[0], box[3] - box[1]
|
||||||
|
reps = max(2, (W * 3) // max(tw, 1) + 1)
|
||||||
|
strip = Image.new("RGB", (tw * reps, H), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(strip)
|
||||||
|
for i in range(reps):
|
||||||
|
draw.text((i * tw, (H - th) // 2 - box[1]), text, font=font, fill=(255, 210, 60))
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
frames = 0
|
||||||
|
started = time.time()
|
||||||
|
while time.time() - started < seconds:
|
||||||
|
window = strip.crop((offset, 0, offset + W, H))
|
||||||
|
if window.width < W:
|
||||||
|
whole = Image.new("RGB", (W, H), (0, 0, 0))
|
||||||
|
head = strip.crop((offset, 0, strip.width, H))
|
||||||
|
whole.paste(head, (0, 0))
|
||||||
|
whole.paste(strip.crop((0, 0, W - head.width, H)), (head.width, 0))
|
||||||
|
window = whole
|
||||||
|
canvas.SetImage(window)
|
||||||
|
canvas = matrix.SwapOnVSync(canvas, choice.frame_hold)
|
||||||
|
offset = (offset + choice.pixels_per_frame) % strip.width
|
||||||
|
frames += 1
|
||||||
|
elapsed = time.time() - started
|
||||||
|
print(" {} frames in {:.1f}s = {:.1f} fps = {:.1f} px/s actual".format(
|
||||||
|
frames, elapsed, frames / elapsed, frames * choice.pixels_per_frame / elapsed))
|
||||||
|
matrix.Clear()
|
||||||
|
|
||||||
|
|
||||||
|
def print_ladder(hz, highlight=None):
|
||||||
|
print("")
|
||||||
|
print("Whole-pixel scroll speeds at {:.1f}Hz refresh".format(hz))
|
||||||
|
print("(the panel refreshes at {:.0f}Hz for every one of these - holding a "
|
||||||
|
"frame costs no flicker)".format(hz))
|
||||||
|
print("")
|
||||||
|
for entry in scroll_config.crisp_ladder(hz):
|
||||||
|
if entry.pixels_per_second > hz * 3:
|
||||||
|
break
|
||||||
|
mark = " <-- nearest to {:.0f}".format(highlight) if (
|
||||||
|
highlight is not None
|
||||||
|
and entry.pixels_per_second == scroll_config.solve_crisp(highlight, hz).pixels_per_second
|
||||||
|
) else ""
|
||||||
|
print(" " + entry.describe() + mark)
|
||||||
|
print("")
|
||||||
|
print("Set one in config.json as pixels per second, e.g.")
|
||||||
|
print(' "display_options": {{"scroll_pixels_per_second": {:.0f}}}'.format(
|
||||||
|
scroll_config.solve_crisp(highlight if highlight else hz / 2, hz).pixels_per_second))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--hz", type=float,
|
||||||
|
help="refresh rate to compute the ladder for (default: your config)")
|
||||||
|
ap.add_argument("--measure", action="store_true",
|
||||||
|
help="measure the panel's real refresh rate (needs root, service stopped)")
|
||||||
|
ap.add_argument("--demo", type=float, metavar="PXPS",
|
||||||
|
help="scroll text at the crisp speed nearest this (needs root)")
|
||||||
|
ap.add_argument("--seconds", type=float, default=15.0, help="demo duration")
|
||||||
|
ap.add_argument("--want", type=float, metavar="PXPS",
|
||||||
|
help="highlight the entry nearest this speed")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
hardware = load_hardware()
|
||||||
|
configured = float(hardware.get("limit_refresh_rate_hz") or 0)
|
||||||
|
|
||||||
|
if args.demo is not None:
|
||||||
|
demo(hardware, args.demo, args.seconds)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.measure:
|
||||||
|
measured = measure_refresh(hardware)
|
||||||
|
print("measured panel refresh: {:.1f}Hz".format(measured))
|
||||||
|
if configured:
|
||||||
|
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
|
||||||
|
if measured < configured * 0.95:
|
||||||
|
print(" -> the panel cannot reach the configured rate; the ladder")
|
||||||
|
print(" below uses what it actually manages")
|
||||||
|
print_ladder(measured, args.want)
|
||||||
|
return
|
||||||
|
|
||||||
|
hz = args.hz or configured or scroll_config.DEFAULT_REFRESH_HZ
|
||||||
|
if not args.hz and not configured:
|
||||||
|
print("no limit_refresh_rate_hz in config; assuming {:.0f}Hz".format(hz))
|
||||||
|
print("run with --measure to find your panel's real rate")
|
||||||
|
print_ladder(hz, args.want)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Drive a sports scoreboard scroll on the panel and report what it did.
|
||||||
|
|
||||||
|
The eight sports scoreboards scroll through ``src/common/sports_scroll.py``,
|
||||||
|
and that path is per-league opt-in: a rig showing static game cards never
|
||||||
|
constructs a SportsScrollDisplay at all, so nothing about its pacing can be
|
||||||
|
observed from a normal run. This drives it directly, with synthetic games, so
|
||||||
|
the pacing can be measured without changing anyone's configuration.
|
||||||
|
|
||||||
|
What it checks is what the shared resolver is supposed to buy:
|
||||||
|
|
||||||
|
* the requested speed lands on a whole number of pixels per refresh
|
||||||
|
* the frame hold that makes that true is published to the display manager
|
||||||
|
* frames actually arrive at the interval the hold implies
|
||||||
|
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
sudo python3 scripts/sports_scroll_check.py --seconds 20
|
||||||
|
sudo systemctl start ledmatrix
|
||||||
|
|
||||||
|
Like scripts/scroll_speeds.py, this never starts or stops the display service
|
||||||
|
itself -- that is left to the caller, so a crash here cannot leave the panel
|
||||||
|
dark.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
from src.common.sports_scroll import SportsScrollDisplay # noqa: E402
|
||||||
|
from src.display_manager import DisplayManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _Check(SportsScrollDisplay):
|
||||||
|
"""A scoreboard whose cards are plain blocks -- pacing is what matters."""
|
||||||
|
|
||||||
|
SCROLL_LEAGUE_KEYS = ("nfl",)
|
||||||
|
|
||||||
|
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
|
||||||
|
width = self.display_height * 2
|
||||||
|
cards = []
|
||||||
|
for i, _ in enumerate(games):
|
||||||
|
card = Image.new("RGB", (width, self.display_height), (0, 0, 0))
|
||||||
|
shade = 40 + (i * 37) % 180
|
||||||
|
for x in range(2, width - 2):
|
||||||
|
for y in range(2, self.display_height - 2):
|
||||||
|
card.putpixel((x, y), (shade, 90, 220 - shade // 2))
|
||||||
|
cards.append(card)
|
||||||
|
self._current_games = list(games)
|
||||||
|
self._current_game_type = game_type
|
||||||
|
self._current_leagues = list(leagues)
|
||||||
|
self.scroll_helper.create_scrolling_image(content_items=cards, item_gap=24)
|
||||||
|
return bool(cards)
|
||||||
|
|
||||||
|
|
||||||
|
class _HoldSpy:
|
||||||
|
"""Records what the scroll publishes, without changing what it does."""
|
||||||
|
|
||||||
|
def __init__(self, display_manager):
|
||||||
|
self.dm = display_manager
|
||||||
|
self.calls = []
|
||||||
|
self._real = display_manager.set_scrolling_state
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
def spy(is_scrolling, frame_hold=1):
|
||||||
|
self.calls.append((is_scrolling, frame_hold))
|
||||||
|
return self._real(is_scrolling, frame_hold=frame_hold)
|
||||||
|
self.dm.set_scrolling_state = spy
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.dm.set_scrolling_state = self._real
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGE = """ledmatrix is running and owns the panel's GPIO.
|
||||||
|
|
||||||
|
Stop it first, or this run can leave the display dark:
|
||||||
|
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
sudo python3 scripts/sports_scroll_check.py
|
||||||
|
sudo systemctl start ledmatrix
|
||||||
|
|
||||||
|
Use --fallback to check the pacing logic without the panel, or --force if
|
||||||
|
you really mean it."""
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse_if_the_service_is_running(force):
|
||||||
|
"""Refuse to touch the panel while ledmatrix has it.
|
||||||
|
|
||||||
|
rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside
|
||||||
|
RGBMatrix(), and on the root check it calls exit() from C -- no cleanup.
|
||||||
|
Do that while the service is driving those same pins and the panel goes
|
||||||
|
dark while the service carries on rendering happily: fresh framebuffer,
|
||||||
|
every pixel lit, "RGB Matrix initialized successfully", nothing in the log.
|
||||||
|
A restart brings it back, but only once you work out that is what happened.
|
||||||
|
|
||||||
|
The module docstring says to stop the service first. This makes it true.
|
||||||
|
"""
|
||||||
|
if force:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
active = subprocess.run( # nosec B603 B607 - hardcoded systemctl args # nosemgrep
|
||||||
|
["systemctl", "is-active", "ledmatrix"],
|
||||||
|
capture_output=True, text=True).stdout.strip()
|
||||||
|
except OSError:
|
||||||
|
return # not a systemd box; nothing to protect
|
||||||
|
if active == "active":
|
||||||
|
sys.exit(MESSAGE)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--seconds", type=float, default=20.0)
|
||||||
|
ap.add_argument("--speed", type=float, default=None,
|
||||||
|
help="px/s to request; default is the module's own")
|
||||||
|
ap.add_argument("--games", type=int, default=6)
|
||||||
|
ap.add_argument("--force", action="store_true",
|
||||||
|
help="run even though the display service is up. It owns "
|
||||||
|
"the GPIO; expect a dark panel until you restart it.")
|
||||||
|
ap.add_argument("--fallback", action="store_true",
|
||||||
|
help="run without the panel. Driving the real matrix needs "
|
||||||
|
"root; this checks everything except the vsync pacing "
|
||||||
|
"-- what speed resolves to, that the hold is published, "
|
||||||
|
"and that it is released afterwards.")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.fallback:
|
||||||
|
_refuse_if_the_service_is_running(args.force)
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
config = json.loads((root / "config" / "config.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
display_manager = DisplayManager(config, force_fallback=args.fallback)
|
||||||
|
settings = {} if args.speed is None else {
|
||||||
|
"nfl": {"scroll_settings": {"scroll_speed": args.speed}}}
|
||||||
|
|
||||||
|
display = _Check(display_manager, settings, global_config=config)
|
||||||
|
resolved = display._scroll_settings
|
||||||
|
print("resolved: %s" % resolved.describe())
|
||||||
|
print("frame hold: %d refresh(es) per frame" % resolved.frame_hold)
|
||||||
|
if resolved.warning:
|
||||||
|
print("warning: %s" % resolved.warning)
|
||||||
|
|
||||||
|
display.prepare_scroll_content(
|
||||||
|
[{"id": "g%d" % i} for i in range(args.games)], "live", ["nfl"])
|
||||||
|
|
||||||
|
gaps, drawn = [], 0
|
||||||
|
last = None
|
||||||
|
with _HoldSpy(display_manager) as spy:
|
||||||
|
started = time.perf_counter()
|
||||||
|
while time.perf_counter() - started < args.seconds:
|
||||||
|
if not display.display_scroll_frame():
|
||||||
|
break
|
||||||
|
now = time.perf_counter()
|
||||||
|
if last is not None:
|
||||||
|
gaps.append((now - last) * 1000.0)
|
||||||
|
last = now
|
||||||
|
drawn += 1
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
if not gaps:
|
||||||
|
sys.exit("no frames were drawn -- the scroll never started")
|
||||||
|
|
||||||
|
gaps.sort()
|
||||||
|
expected = 1000.0 * resolved.frame_hold / (resolved.crisp.refresh_hz
|
||||||
|
if resolved.crisp else 100.0)
|
||||||
|
print("\n%d frames in %.1fs -> %.1f fps" % (
|
||||||
|
drawn, args.seconds, drawn / args.seconds))
|
||||||
|
print("frame gap median %.2fms p95 %.2fms max %.2fms (hold implies %.2fms)"
|
||||||
|
% (statistics.median(gaps), gaps[int(len(gaps) * 0.95)], gaps[-1], expected))
|
||||||
|
|
||||||
|
holds = {h for on, h in spy.calls if on}
|
||||||
|
print("published while scrolling: frame_hold=%s" % (sorted(holds) or "NOTHING"))
|
||||||
|
print("released on clear: %s" % any(not on for on, _ in spy.calls))
|
||||||
|
print("display manager hold now: %d (1 means released)"
|
||||||
|
% getattr(display_manager, "_frame_hold", -1))
|
||||||
|
|
||||||
|
if not holds:
|
||||||
|
sys.exit("FAIL: the scroll never told the core it was scrolling")
|
||||||
|
if holds != {resolved.frame_hold}:
|
||||||
|
sys.exit("FAIL: published %s but resolved %d" % (holds, resolved.frame_hold))
|
||||||
|
print("\nOK: the resolved hold reached the panel and was released after")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -9,6 +9,8 @@ This directory contains utility scripts for maintenance and system operations.
|
|||||||
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
|
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
|
||||||
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
|
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
|
||||||
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
|
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
|
||||||
|
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
|
||||||
|
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -25,3 +27,31 @@ This script is typically called by the systemd service (`ledmatrix-web.service`)
|
|||||||
### WiFi Monitor Daemon
|
### WiFi Monitor Daemon
|
||||||
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.
|
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.
|
||||||
|
|
||||||
|
|
||||||
|
### Pixlet Config Editor
|
||||||
|
Run it when you want Pixlet's own config form for a Starlark app -- live
|
||||||
|
render preview, cascading dropdowns -- rather than the LEDMatrix one.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||||
|
./scripts/utils/pixlet_config_editor.sh penndot_signs # edit, on localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Deliberately not a service. It stops the display for the length of the
|
||||||
|
session and `pixlet serve` listens with no authentication, so it should only
|
||||||
|
be running while you are actually editing. It backs the config up first and
|
||||||
|
restarts the display on exit, however it exits.
|
||||||
|
|
||||||
|
It binds loopback only, with no flag to change that: anything that can reach
|
||||||
|
`pixlet serve` can rewrite the app's config, and a printed warning is not
|
||||||
|
access control. To edit from another machine, forward the port -- SSH does the
|
||||||
|
authenticating and nothing is left listening on the LAN:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply DNS Single-Request Fix
|
||||||
|
Installed and run by `ledmatrix-dns-fix.service`; see `systemd/README.md`.
|
||||||
|
Safe to run by hand (`sudo ./scripts/utils/apply_dns_single_request.sh`) and
|
||||||
|
idempotent.
|
||||||
|
|||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Add `options single-request` to the system resolver configuration.
|
||||||
|
#
|
||||||
|
# glibc's getaddrinfo() sends the A and AAAA queries for a name in
|
||||||
|
# parallel on one socket. Some routers answer the A query and drop the
|
||||||
|
# AAAA one, so the resolver waits out its full timeout -- about five
|
||||||
|
# seconds -- before returning an address that was already available.
|
||||||
|
# Disabling IPv6 in the kernel does not help: the resolver still asks.
|
||||||
|
#
|
||||||
|
# `single-request` makes it send the two queries one after the other,
|
||||||
|
# which those routers answer correctly. Anything on the matrix that
|
||||||
|
# calls an external API pays that five seconds per lookup otherwise, and
|
||||||
|
# a Starlark app with a render timeout will simply fail instead.
|
||||||
|
#
|
||||||
|
# Idempotent, and safe to run on a machine that does not need it. Run by
|
||||||
|
# ledmatrix-dns-fix.service on every boot, because whatever manages
|
||||||
|
# resolv.conf regenerates it and drops the option again.
|
||||||
|
#
|
||||||
|
# Usage: sudo ./scripts/utils/apply_dns_single_request.sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
OPTION="options single-request"
|
||||||
|
RESOLVCONF_TAIL="/etc/resolvconf/resolv.conf.d/tail"
|
||||||
|
RESOLV_CONF="/etc/resolv.conf"
|
||||||
|
|
||||||
|
log() { echo "[dns-single-request] $*"; }
|
||||||
|
|
||||||
|
already_applied() {
|
||||||
|
grep -qs "^${OPTION}\$" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# resolvconf regenerates /etc/resolv.conf from these fragments, so the
|
||||||
|
# tail file is the only place an addition survives. Prefer it when the
|
||||||
|
# directory exists, whether or not resolvconf has run yet.
|
||||||
|
if [ -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
||||||
|
if already_applied "$RESOLVCONF_TAIL"; then
|
||||||
|
log "already present in $RESOLVCONF_TAIL"
|
||||||
|
else
|
||||||
|
echo "$OPTION" >> "$RESOLVCONF_TAIL"
|
||||||
|
log "added to $RESOLVCONF_TAIL"
|
||||||
|
fi
|
||||||
|
# Only a missing resolvconf is ignorable. If it is present and the
|
||||||
|
# regeneration fails, /etc/resolv.conf still lacks the option, and
|
||||||
|
# reporting success would be a lie.
|
||||||
|
if command -v resolvconf >/dev/null 2>&1; then
|
||||||
|
if ! resolvconf -u; then
|
||||||
|
log "resolvconf -u failed; $RESOLV_CONF was not regenerated"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# systemd-resolved owns its stub file and rewrites anything appended to it,
|
||||||
|
# and `single-request` is a glibc resolv.conf option with no resolved.conf
|
||||||
|
# equivalent -- so there is nothing this script can do here. Exit non-zero:
|
||||||
|
# the unit would otherwise record success while the workaround is inactive,
|
||||||
|
# which is the failure mode this whole script exists to avoid.
|
||||||
|
if [ -L "$RESOLV_CONF" ] && readlink -f "$RESOLV_CONF" | grep -q "systemd"; then
|
||||||
|
log "$RESOLV_CONF is managed by systemd-resolved."
|
||||||
|
log "'options single-request' is a glibc resolv.conf option and has no"
|
||||||
|
log "resolved.conf equivalent, so it cannot be applied on this host."
|
||||||
|
log "If external API calls are slow, the workaround is to stop using the"
|
||||||
|
log "systemd-resolved stub (see 'man systemd-resolved', NSS/resolv.conf modes)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if already_applied "$RESOLV_CONF"; then
|
||||||
|
log "already present in $RESOLV_CONF"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -w "$RESOLV_CONF" ] && [ -e "$RESOLV_CONF" ]; then
|
||||||
|
log "cannot write $RESOLV_CONF (run with sudo?)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A NetworkManager-generated resolv.conf is regenerated on every connection
|
||||||
|
# change, not only at boot -- and this unit is oneshot with RemainAfterExit,
|
||||||
|
# so it will not re-run within the same boot to put the option back. Say so
|
||||||
|
# rather than implying the fix is permanent. Nothing is silently swallowed:
|
||||||
|
# the append below still happens and still works until the next renewal.
|
||||||
|
if grep -qs "Generated by NetworkManager" "$RESOLV_CONF" \
|
||||||
|
&& [ ! -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
||||||
|
log "NOTE: $RESOLV_CONF is generated by NetworkManager and has no"
|
||||||
|
log "resolvconf tail directory to write to. The option is being added, but"
|
||||||
|
log "NetworkManager will drop it on the next connection renewal, and this"
|
||||||
|
log "unit does not run again until the next boot. If lookups go slow again"
|
||||||
|
log "before a reboot, re-run this script."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$OPTION" >> "$RESOLV_CONF"
|
||||||
|
log "added to $RESOLV_CONF"
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check that an automatic LEDMatrix update left the device working; roll it back if not.
|
||||||
|
|
||||||
|
Started by the web interface's weekly updater (web_interface/auto_update.py)
|
||||||
|
through ledmatrix-update-verify.service, right after it pulls new code. It has
|
||||||
|
to run outside the web service: checking the update means restarting that
|
||||||
|
service, and a check running inside it would be killed by its own restart.
|
||||||
|
|
||||||
|
It must not be the code it is checking, either. The updater copies this file
|
||||||
|
to data/auto_update_verifier.py *before* pulling and the unit runs that copy,
|
||||||
|
so a broken update cannot break its own rollback. Standard library only for
|
||||||
|
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": ...,
|
||||||
|
"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.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||||
|
import sys
|
||||||
|
from collections import namedtuple
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
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'
|
||||||
|
#: 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
|
||||||
|
#: loop look healthy between attempts, so a single "is-active" proves nothing.
|
||||||
|
STABLE_SECONDS = 45
|
||||||
|
POLL_SECONDS = 5
|
||||||
|
PIP_TIMEOUT_SECONDS = 600
|
||||||
|
#: sudoers matches the exact command line, so bash is named by path, the same
|
||||||
|
#: candidates src/common/permission_utils.install_requirements_file tries.
|
||||||
|
BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
|
||||||
|
|
||||||
|
#: What a command that could not run at all reports: its callers only read
|
||||||
|
#: these three fields, the same ones a completed subprocess has.
|
||||||
|
_Failed = namedtuple('_Failed', 'returncode stdout stderr')
|
||||||
|
|
||||||
|
|
||||||
|
def pending_path(project_root):
|
||||||
|
return Path(project_root) / 'data' / PENDING_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def read_pending(path):
|
||||||
|
try:
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_pending(path, data):
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.auto_update_pending_')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _web_responds(url=WEB_HEALTH_URL):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 - fixed loopback URL
|
||||||
|
return resp.status == 200
|
||||||
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _short(sha):
|
||||||
|
return (sha or 'unknown')[:7]
|
||||||
|
|
||||||
|
|
||||||
|
class Verifier:
|
||||||
|
def __init__(self, project_root, run=subprocess.run, sleep=time.sleep,
|
||||||
|
clock=time.monotonic, web_responds=_web_responds, log=None):
|
||||||
|
self.project_root = Path(project_root)
|
||||||
|
self.pending_file = pending_path(project_root)
|
||||||
|
self.run = run
|
||||||
|
self.sleep = sleep
|
||||||
|
self.clock = clock
|
||||||
|
self.web_responds = web_responds
|
||||||
|
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
|
||||||
|
|
||||||
|
def _run(self, args, timeout=60):
|
||||||
|
try:
|
||||||
|
return self.run(args, cwd=str(self.project_root), capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
except (subprocess.SubprocessError, OSError) as e:
|
||||||
|
return _Failed(returncode=1, stdout='', stderr=str(e))
|
||||||
|
|
||||||
|
# -- services ---------------------------------------------------------
|
||||||
|
|
||||||
|
def service_active(self, unit):
|
||||||
|
return self._run(['systemctl', 'is-active', unit], timeout=10).stdout.strip() == 'active'
|
||||||
|
|
||||||
|
def restart_count(self, unit):
|
||||||
|
out = self._run(['systemctl', 'show', '-p', 'NRestarts', '--value', unit],
|
||||||
|
timeout=10).stdout.strip()
|
||||||
|
return int(out) if out.isdigit() else None
|
||||||
|
|
||||||
|
def restart(self, unit):
|
||||||
|
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'], timeout=90)
|
||||||
|
if result.returncode != 0:
|
||||||
|
self.log(f'restarting {unit} failed: {(result.stderr or "").strip()}')
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
def restart_services(self, display):
|
||||||
|
"""Restart what should be running. False if any restart command failed."""
|
||||||
|
ok = True
|
||||||
|
# A display the user had stopped stays stopped.
|
||||||
|
if display:
|
||||||
|
ok = self.restart('ledmatrix') and ok
|
||||||
|
return self.restart('ledmatrix-web') and ok
|
||||||
|
|
||||||
|
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
|
||||||
|
count_known = True
|
||||||
|
while self.clock() < deadline:
|
||||||
|
web = self.web_responds()
|
||||||
|
disp = self.service_active('ledmatrix') if display else True
|
||||||
|
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.
|
||||||
|
count_known = not display or restarts is not None
|
||||||
|
if web and disp and count_known and (healthy_since is None or restarts == baseline):
|
||||||
|
if healthy_since is None:
|
||||||
|
healthy_since, baseline = self.clock(), restarts
|
||||||
|
elif self.clock() - healthy_since >= STABLE_SECONDS:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
healthy_since = None
|
||||||
|
self.sleep(POLL_SECONDS)
|
||||||
|
problems = []
|
||||||
|
if not web:
|
||||||
|
problems.append('the web interface did not respond')
|
||||||
|
if not disp:
|
||||||
|
problems.append('the display service did not stay running')
|
||||||
|
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'
|
||||||
|
|
||||||
|
# -- rollback ---------------------------------------------------------
|
||||||
|
|
||||||
|
def changed_requirements(self, old, new):
|
||||||
|
result = self._run(['git', 'diff', '--name-only', old, new])
|
||||||
|
# If the diff is unavailable, reinstall both rather than guess.
|
||||||
|
changed = set(result.stdout.split()) if result.returncode == 0 else set(REQUIREMENT_FILES)
|
||||||
|
return [rel for rel in REQUIREMENT_FILES if rel in changed]
|
||||||
|
|
||||||
|
def install_requirements(self, rel):
|
||||||
|
wrapper = self.project_root / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
|
||||||
|
req = self.project_root / rel
|
||||||
|
if not req.exists():
|
||||||
|
return True
|
||||||
|
for bash in BASH_CANDIDATES:
|
||||||
|
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)],
|
||||||
|
timeout=PIP_TIMEOUT_SECONDS)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def rollback(self, pending):
|
||||||
|
"""Reset to the previous commit and its dependencies. Returns (ok, detail)."""
|
||||||
|
old, new = pending.get('old_head'), pending.get('new_head')
|
||||||
|
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)
|
||||||
|
# Safe to --hard: the updater refuses to run with local edits to
|
||||||
|
# tracked files, so the only thing this discards is the update.
|
||||||
|
result = self._run(['git', 'reset', '--hard', old], timeout=120)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, (f'"git reset --hard {old}" failed: '
|
||||||
|
f'{(result.stderr or result.stdout or "").strip()}')
|
||||||
|
failed = [rel for rel in requirements if not self.install_requirements(rel)]
|
||||||
|
if failed:
|
||||||
|
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
|
||||||
|
+ ' failed; run Install Base Requirements from the Tools tab')
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
# -- the check itself -------------------------------------------------
|
||||||
|
|
||||||
|
def _finish(self, pending, status, reason=None, detail=None):
|
||||||
|
pending.update({'status': status, 'reason': reason, 'detail': detail or None,
|
||||||
|
'finished_at': time.time()})
|
||||||
|
write_pending(self.pending_file, pending)
|
||||||
|
self.log(' '.join(p for p in (status, reason or '', detail or '') if p))
|
||||||
|
|
||||||
|
def verify(self):
|
||||||
|
pending = read_pending(self.pending_file)
|
||||||
|
if not pending or pending.get('status') != 'pending':
|
||||||
|
self.log('no update is waiting to be verified')
|
||||||
|
return 0
|
||||||
|
pending['status'] = 'verifying'
|
||||||
|
write_pending(self.pending_file, pending)
|
||||||
|
|
||||||
|
display = bool(pending.get('display_was_active'))
|
||||||
|
dependency_failures = pending.get('dependency_failures') or []
|
||||||
|
if dependency_failures:
|
||||||
|
# Never restart onto code whose packages did not install.
|
||||||
|
reason = 'installing its dependencies failed (' + ', '.join(dependency_failures) + ')'
|
||||||
|
elif not self.restart_services(display):
|
||||||
|
# The old process may still be answering; checking it would pass
|
||||||
|
# an update that never started.
|
||||||
|
reason = 'restarting the services failed'
|
||||||
|
else:
|
||||||
|
reason = self.wait_healthy(display)
|
||||||
|
if reason is None:
|
||||||
|
self._finish(pending, 'success')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.log(f'update to {_short(pending.get("new_head"))} is unhealthy ({reason}); '
|
||||||
|
f'rolling back to {_short(pending.get("old_head"))}')
|
||||||
|
ok, detail = self.rollback(pending)
|
||||||
|
if not ok:
|
||||||
|
self._finish(pending, 'rollback_failed', reason, detail)
|
||||||
|
return 1
|
||||||
|
still = (self.wait_healthy(display) if self.restart_services(display)
|
||||||
|
else 'restarting the services failed')
|
||||||
|
if still:
|
||||||
|
self._finish(pending, 'rollback_failed', reason,
|
||||||
|
f'still unhealthy after rolling back: {still}'
|
||||||
|
+ (f'; {detail}' if detail else ''))
|
||||||
|
return 1
|
||||||
|
self._finish(pending, 'rolled_back', reason, detail)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if len(argv) != 2:
|
||||||
|
print('usage: auto_update_verify.py PROJECT_ROOT', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
verifier = Verifier(Path(argv[1]))
|
||||||
|
try:
|
||||||
|
return verifier.verify()
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
# Whatever happened, the web interface must not be left thinking the
|
||||||
|
# check is still running.
|
||||||
|
try:
|
||||||
|
pending = read_pending(verifier.pending_file) or {}
|
||||||
|
if pending.get('status') in ('pending', 'verifying'):
|
||||||
|
pending.update({'status': 'rollback_failed', 'reason': 'the health check crashed',
|
||||||
|
'detail': str(e), 'finished_at': time.time()})
|
||||||
|
write_pending(verifier.pending_file, pending)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
Regular → Executable
Regular → Executable
Executable
+223
@@ -0,0 +1,223 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Edit an installed Starlark app's config in Pixlet's own config UI.
|
||||||
|
#
|
||||||
|
# `pixlet serve` runs the app for real, so its form has working cascading
|
||||||
|
# dropdowns and option lists fetched live -- useful for an app whose choices
|
||||||
|
# only exist at runtime, or when you want to see the render change as you
|
||||||
|
# type. The LEDMatrix config form now reads the same runtime schema (see
|
||||||
|
# PixletRenderer.extract_schema_via_pixlet), so reach for this when you want
|
||||||
|
# Pixlet's live preview, not because the normal form is missing options.
|
||||||
|
#
|
||||||
|
# Deliberately a script you run and then Ctrl+C, not a service: it stops the
|
||||||
|
# display for the length of the session, and `pixlet serve` listens on a port
|
||||||
|
# with no authentication. Nothing here should be listening when you are not
|
||||||
|
# actually editing.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||||
|
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
||||||
|
#
|
||||||
|
# Binds the LAN by default, matching the web interface, which already serves
|
||||||
|
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
|
||||||
|
# already reconfigure the display there. `pixlet serve` has no authentication
|
||||||
|
# either, so treat both the same way: fine on a home network, not on an open
|
||||||
|
# one. Override the bind and the session length with:
|
||||||
|
#
|
||||||
|
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||||
|
#
|
||||||
|
# For loopback-only editing from another machine, forward the port instead:
|
||||||
|
#
|
||||||
|
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||||
|
#
|
||||||
|
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
|
||||||
|
# (default 30 minutes). The display is stopped while editing, so a session
|
||||||
|
# left open would otherwise leave the panel dark indefinitely -- the timeout
|
||||||
|
# is what makes it safe to start one from the web interface.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
||||||
|
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
||||||
|
# LAN by default; see the header for why, and how to force loopback.
|
||||||
|
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
|
||||||
|
# Hard stop, so the display cannot be left off by a forgotten session.
|
||||||
|
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"
|
||||||
|
|
||||||
|
APP_ID="${1:-}"
|
||||||
|
|
||||||
|
list_apps() {
|
||||||
|
if [ -d "$APPS_DIR" ]; then
|
||||||
|
find "$APPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf ' %f\n' 2>/dev/null | sort
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$APP_ID" ]; then
|
||||||
|
echo "Usage: $0 <app_id>"
|
||||||
|
echo ""
|
||||||
|
echo "Installed apps:"
|
||||||
|
list_apps || true
|
||||||
|
[ -n "$(list_apps)" ] || echo " (none found in $APPS_DIR)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_DIR="$APPS_DIR/$APP_ID"
|
||||||
|
if [ ! -d "$APP_DIR" ]; then
|
||||||
|
echo "No such app: $APP_ID"
|
||||||
|
echo ""
|
||||||
|
echo "Installed apps:"
|
||||||
|
list_apps
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAR_FILE=$(find "$APP_DIR" -maxdepth 1 -iname "*.star" | head -1)
|
||||||
|
if [ -z "$STAR_FILE" ]; then
|
||||||
|
echo "No .star file found in $APP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Same search order the plugin itself uses: the bundled binary for this
|
||||||
|
# architecture first, then PATH -- so this works on an install that never put
|
||||||
|
# pixlet on PATH.
|
||||||
|
find_pixlet() {
|
||||||
|
local arch bundled
|
||||||
|
case "$(uname -s)-$(uname -m)" in
|
||||||
|
Linux-aarch64|Linux-arm64) arch="pixlet-linux-arm64" ;;
|
||||||
|
Linux-x86_64|Linux-amd64) arch="pixlet-linux-amd64" ;;
|
||||||
|
Darwin-arm64) arch="pixlet-darwin-arm64" ;;
|
||||||
|
Darwin-x86_64) arch="pixlet-darwin-amd64" ;;
|
||||||
|
*) arch="" ;;
|
||||||
|
esac
|
||||||
|
bundled="$PROJECT_ROOT_DIR/bin/pixlet/$arch"
|
||||||
|
if [ -n "$arch" ] && [ -x "$bundled" ]; then
|
||||||
|
echo "$bundled"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
command -v pixlet 2>/dev/null || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
PIXLET_BIN=$(find_pixlet) || {
|
||||||
|
echo "Pixlet not found. Install it with:"
|
||||||
|
echo " ./scripts/download_pixlet.sh"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# find_pixlet supports Darwin, so this script has to as well. macOS ships no
|
||||||
|
# timeout(1); GNU coreutils installs it as gtimeout. Resolve whichever exists
|
||||||
|
# and fail here with instructions rather than at the invocation far below,
|
||||||
|
# where the failure would land after the display has already been stopped.
|
||||||
|
find_timeout() {
|
||||||
|
local candidate
|
||||||
|
for candidate in timeout gtimeout; do
|
||||||
|
if command -v "$candidate" >/dev/null 2>&1; then
|
||||||
|
command -v "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TIMEOUT_BIN=$(find_timeout) || {
|
||||||
|
echo "Neither 'timeout' nor 'gtimeout' was found on PATH."
|
||||||
|
echo "This script needs one to bound the editing session."
|
||||||
|
echo "On macOS, install GNU coreutils:"
|
||||||
|
echo " brew install coreutils"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_FILE="$APP_DIR/config.json"
|
||||||
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
|
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
|
||||||
|
echo "Backed up existing config to $CONFIG_FILE.backup"
|
||||||
|
else
|
||||||
|
echo "{}" > "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DISPLAY_WAS_RUNNING=false
|
||||||
|
if systemctl is-active --quiet ledmatrix 2>/dev/null; then
|
||||||
|
DISPLAY_WAS_RUNNING=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart the display however this exits -- Ctrl+C, an error, or pixlet
|
||||||
|
# dying on its own. Leaving the panel dark because the editor crashed is the
|
||||||
|
# failure worth guarding against.
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
# Kill the serve child explicitly. `timeout` is started with --foreground so
|
||||||
|
# it shares this script's process group (without that it makes its own, and
|
||||||
|
# a group signal aimed at this script would orphan pixlet with the port
|
||||||
|
# still bound). Belt and braces: signal the recorded pid too, because a
|
||||||
|
# group signal only reaches it while the group is shared.
|
||||||
|
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
|
||||||
|
kill -TERM "$SERVE_PID" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
kill -0 "$SERVE_PID" 2>/dev/null || break
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
kill -KILL "$SERVE_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||||
|
echo "Restarting the display service..."
|
||||||
|
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
||||||
|
fi
|
||||||
|
echo "Your config as it was before this session: $CONFIG_FILE.backup"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||||
|
echo "Stopping the display service so it does not read config.json mid-write..."
|
||||||
|
sudo systemctl stop ledmatrix
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wildcard, loopback and an explicit interface address are three different
|
||||||
|
# cases. Collapsing the last two into "localhost" printed a URL pointing at the
|
||||||
|
# user's own machine whenever PIXLET_EDITOR_HOST named a LAN address.
|
||||||
|
case "$BIND_HOST" in
|
||||||
|
0.0.0.0|::|"") REACH_HOST="$(hostname).local" ;;
|
||||||
|
127.0.0.1|::1|localhost) REACH_HOST="localhost" ;;
|
||||||
|
*) REACH_HOST="$BIND_HOST" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Editing: $APP_ID"
|
||||||
|
echo "App file: $STAR_FILE"
|
||||||
|
echo "URL: http://$REACH_HOST:$PORT/"
|
||||||
|
echo ""
|
||||||
|
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
||||||
|
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
|
||||||
|
echo "same footing as the web interface on port 5000. Set"
|
||||||
|
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
|
||||||
|
else
|
||||||
|
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
|
||||||
|
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Changes save straight to the real config as you make them."
|
||||||
|
echo "Press Ctrl+C when finished - the display restarts automatically."
|
||||||
|
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
# `timeout` owns the hard stop rather than the caller: the trap above restarts
|
||||||
|
# the display however this exits, so a session that outlives the person who
|
||||||
|
# started it still gives the panel back. Exit 124 is timeout's own code for
|
||||||
|
# "expired", which is a normal end here, not a failure.
|
||||||
|
# --foreground: stay in this script's process group so one signal reaches the
|
||||||
|
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
|
||||||
|
# is still alive; a foreground child would leave bash waiting on it instead.
|
||||||
|
"$TIMEOUT_BIN" --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
||||||
|
--host "$BIND_HOST" \
|
||||||
|
--port "$PORT" \
|
||||||
|
--no-browser \
|
||||||
|
--saveconfig "$CONFIG_FILE" &
|
||||||
|
SERVE_PID=$!
|
||||||
|
|
||||||
|
status=0
|
||||||
|
wait "$SERVE_PID" || status=$?
|
||||||
|
if [ "$status" -eq 124 ]; then
|
||||||
|
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
|
||||||
|
status=0
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
@@ -74,23 +74,41 @@ def install_dependencies():
|
|||||||
print(f"Failed to install dependencies: {e}")
|
print(f"Failed to install dependencies: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
#: String spellings that turn autostart OFF. Anything else -- including the key
|
||||||
|
#: being absent entirely -- leaves it on.
|
||||||
|
DISABLED_STRINGS = ("off", "false", "no", "0")
|
||||||
|
|
||||||
|
|
||||||
|
def autostart_enabled(config_data):
|
||||||
|
"""Whether to bring the web interface up. Defaults to True.
|
||||||
|
|
||||||
|
config.template.json and first_time_install.sh both ship
|
||||||
|
``web_display_autostart`` as true, so a config that lacks the key is an
|
||||||
|
older or hand-edited one rather than a request to stay down. Defaulting to
|
||||||
|
False meant any such config silently got no web interface -- and because
|
||||||
|
the "not starting" path exits 0, systemd reported the unit as successfully
|
||||||
|
started while nothing was listening. Only an explicit false/off disables it.
|
||||||
|
"""
|
||||||
|
value = config_data.get("web_display_autostart", True)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() not in DISABLED_STRINGS
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
with open(CONFIG_FILE, 'r') as f:
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
config_data = json.load(f)
|
config_data = json.load(f)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(f"Config file {CONFIG_FILE} not found. Web interface will not start.")
|
# The web interface is how a config gets created and repaired, so a
|
||||||
sys.exit(0) # Exit gracefully, don't start
|
# missing one is the case where the user needs it most.
|
||||||
except Exception as e:
|
print(f"Config file {CONFIG_FILE} not found. Starting the web interface so it can be configured.")
|
||||||
print(f"Error reading config file {CONFIG_FILE}: {e}. Web interface will not start.")
|
config_data = {}
|
||||||
sys.exit(1) # Exit with error, service might restart depending on config
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
print(f"Error reading config file {CONFIG_FILE}: {e}. Starting the web interface anyway so the config can be repaired.")
|
||||||
|
config_data = {}
|
||||||
|
|
||||||
autostart_enabled = config_data.get("web_display_autostart", False)
|
if autostart_enabled(config_data):
|
||||||
|
|
||||||
# Handle both boolean True and string "on"/"true" values
|
|
||||||
is_enabled = (autostart_enabled is True) or (isinstance(autostart_enabled, str) and autostart_enabled.lower() in ("on", "true", "yes", "1"))
|
|
||||||
|
|
||||||
if is_enabled:
|
|
||||||
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
|
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
|
||||||
|
|
||||||
# Only install dependencies if not already done during first-time setup
|
# Only install dependencies if not already done during first-time setup
|
||||||
@@ -116,7 +134,7 @@ def main():
|
|||||||
print(f"Failed to exec web interface: {e}")
|
print(f"Failed to exec web interface: {e}")
|
||||||
sys.exit(1) # Failed to start
|
sys.exit(1) # Failed to start
|
||||||
else:
|
else:
|
||||||
print("Configuration 'web_display_autostart' is false or not set. Web interface will not be started.")
|
print("Configuration 'web_display_autostart' is explicitly disabled. Web interface will not be started.")
|
||||||
sys.exit(0) # Exit gracefully, service considered successful
|
sys.exit(0) # Exit gracefully, service considered successful
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||||
|
|
||||||
|
from src.common.font_layout import load_truetype # noqa: E402
|
||||||
|
|
||||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||||
MODES = ("live", "recent", "upcoming")
|
MODES = ("live", "recent", "upcoming")
|
||||||
SPORTS = ("baseball", "basketball", "football", "hockey")
|
SPORTS = ("baseball", "basketball", "football", "hockey")
|
||||||
@@ -52,12 +54,12 @@ class FixtureHost:
|
|||||||
try:
|
try:
|
||||||
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
||||||
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
||||||
fonts['score'] = ImageFont.truetype(press, 10)
|
fonts['score'] = load_truetype(press, 10)
|
||||||
fonts['time'] = ImageFont.truetype(press, 8)
|
fonts['time'] = load_truetype(press, 8)
|
||||||
fonts['team'] = ImageFont.truetype(press, 8)
|
fonts['team'] = load_truetype(press, 8)
|
||||||
fonts['status'] = ImageFont.truetype(small, 6)
|
fonts['status'] = load_truetype(small, 6)
|
||||||
fonts['detail'] = ImageFont.truetype(small, 6)
|
fonts['detail'] = load_truetype(small, 6)
|
||||||
fonts['rank'] = ImageFont.truetype(press, 10)
|
fonts['rank'] = load_truetype(press, 10)
|
||||||
except IOError:
|
except IOError:
|
||||||
default = ImageFont.load_default()
|
default = ImageFont.load_default()
|
||||||
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
||||||
|
|||||||
+8
-3
@@ -1,5 +1,10 @@
|
|||||||
# skins/
|
# skins/
|
||||||
|
|
||||||
|
> **Not supported yet.** The current scoreboard plugins don't render skins,
|
||||||
|
> so a skin placed here and selected in config has no effect, and the web UI
|
||||||
|
> and Plugin Store don't offer them. See
|
||||||
|
> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet).
|
||||||
|
|
||||||
User-installable **visual skins** for the sports scoreboards. Each
|
User-installable **visual skins** for the sports scoreboards. Each
|
||||||
subdirectory is one skin:
|
subdirectory is one skin:
|
||||||
|
|
||||||
@@ -10,10 +15,10 @@ skins/<skin-id>/
|
|||||||
preview.png # optional
|
preview.png # optional
|
||||||
```
|
```
|
||||||
|
|
||||||
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
|
- Install a skin: `git clone <skin repo> skins/<skin-id>`. The Plugin Store
|
||||||
Store for registry entries with `"type": "skin"`).
|
refuses registry entries with `"type": "skin"` while skins don't render.
|
||||||
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
||||||
`config/config.json`, or use the web UI's Visual Skin dropdown.
|
`config/config.json`. The web UI no longer shows a Visual Skin dropdown.
|
||||||
- Build one: start from `example-classic-baseball/` and read
|
- Build one: start from `example-classic-baseball/` and read
|
||||||
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
||||||
`python scripts/validate_skin.py --skin <skin-id>`.
|
`python scripts/validate_skin.py --skin <skin-id>`.
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.2.0"
|
__version__ = "3.4.0"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Install the automatic-update health check, from the display service.
|
||||||
|
|
||||||
|
The weekly updater (web_interface/auto_update.py) will not update LEDMatrix
|
||||||
|
code unless ledmatrix-update-verify.path and .service are installed: they
|
||||||
|
restart the services after an update and roll it back if the device is
|
||||||
|
unhealthy. Installing units takes root and the web interface is not root, and
|
||||||
|
"SSH in and run an installer" means most people never get updates with a
|
||||||
|
safety net.
|
||||||
|
|
||||||
|
The display service already runs this repository's code as root, so it
|
||||||
|
installs them -- but only while the user has automatic updates turned on, only
|
||||||
|
these two units, rendered from the repository's templates for the web
|
||||||
|
interface's own user, and it reports what happened in
|
||||||
|
data/auto_update_setup.json for the General tab. It grants nothing new: the
|
||||||
|
units run as the web user, who can already change the code this process runs.
|
||||||
|
|
||||||
|
Called at display startup; the web interface restarts the display service
|
||||||
|
when the toggle is switched on, so setup happens straight away. Refreshing a
|
||||||
|
unit whose template changed happens the same way, which is why this compares
|
||||||
|
content rather than only checking that the files exist.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SYSTEMD_DIR = Path('/etc/systemd/system')
|
||||||
|
SERVICE_UNIT = 'ledmatrix-update-verify.service'
|
||||||
|
PATH_UNIT = 'ledmatrix-update-verify.path'
|
||||||
|
UNITS = (SERVICE_UNIT, PATH_UNIT)
|
||||||
|
WEB_UNIT = 'ledmatrix-web.service'
|
||||||
|
RESULT_REL = Path('data') / 'auto_update_setup.json'
|
||||||
|
_USER_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
|
||||||
|
|
||||||
|
|
||||||
|
class SetupError(Exception):
|
||||||
|
"""A reason setup cannot proceed, worded for the General tab."""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_root():
|
||||||
|
return hasattr(os, 'geteuid') and os.geteuid() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_ids(user):
|
||||||
|
try:
|
||||||
|
import pwd
|
||||||
|
entry = pwd.getpwnam(user)
|
||||||
|
return entry.pw_uid, entry.pw_gid
|
||||||
|
except (ImportError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _directive(text, key):
|
||||||
|
match = re.search(rf'^{key}=(.*)$', text or '', re.M)
|
||||||
|
return match.group(1).strip() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path):
|
||||||
|
try:
|
||||||
|
return Path(path).read_text(encoding='utf-8')
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled(config):
|
||||||
|
return bool((config.get('auto_update') or {}).get('enabled', False))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateHelperSetup:
|
||||||
|
def __init__(self, project_root=PROJECT_ROOT, systemd_dir=SYSTEMD_DIR, run=subprocess.run,
|
||||||
|
is_root=_is_root, lookup_ids=_lookup_ids, clock=time.time):
|
||||||
|
self.project_root = Path(project_root)
|
||||||
|
self.systemd_dir = Path(systemd_dir)
|
||||||
|
self.run = run
|
||||||
|
self.is_root = is_root
|
||||||
|
self.lookup_ids = lookup_ids
|
||||||
|
self.clock = clock
|
||||||
|
self.result_file = self.project_root / RESULT_REL
|
||||||
|
self._web_ids = None
|
||||||
|
|
||||||
|
def _systemctl(self, *args):
|
||||||
|
return self.run(['systemctl', *args], capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
def _check(self, result, what):
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SetupError(f'"{what}" failed: {(result.stderr or result.stdout or "").strip()}')
|
||||||
|
|
||||||
|
def path_active(self):
|
||||||
|
try:
|
||||||
|
return self._systemctl('is-active', PATH_UNIT).stdout.strip() == 'active'
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure(self, config):
|
||||||
|
"""Install or refresh the units while automatic updates are on.
|
||||||
|
|
||||||
|
Returns the result recorded for the General tab, or None when there
|
||||||
|
was nothing to do (updates off, or not a systemd host at all).
|
||||||
|
"""
|
||||||
|
if not is_enabled(config) or not self.systemd_dir.is_dir():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
changed = self._install()
|
||||||
|
except SetupError as e:
|
||||||
|
return self._report('failed', str(e))
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
return self._report('failed', f'Could not install the update health check: {e}')
|
||||||
|
if changed:
|
||||||
|
return self._report('installed', 'Installed the update health check.')
|
||||||
|
return self._report('installed', 'The update health check is installed.', quiet=True)
|
||||||
|
|
||||||
|
def _install(self):
|
||||||
|
if not self.is_root():
|
||||||
|
raise SetupError('The display service is not running as root, so it cannot install the '
|
||||||
|
'update health check. Run "sudo ./scripts/install/install_web_service.sh" once.')
|
||||||
|
|
||||||
|
web_text = _read(self.systemd_dir / WEB_UNIT)
|
||||||
|
if web_text is None:
|
||||||
|
raise SetupError('The web interface service (ledmatrix-web.service) is not installed.')
|
||||||
|
user = _directive(web_text, 'User') or 'root'
|
||||||
|
ids = self.lookup_ids(user) if _USER_RE.match(user) else None
|
||||||
|
if ids is None:
|
||||||
|
raise SetupError(f'The web interface runs as "{user}", which is not a usable account.')
|
||||||
|
self._web_ids = ids
|
||||||
|
workdir = _directive(web_text, 'WorkingDirectory')
|
||||||
|
if not workdir or Path(workdir).resolve() != self.project_root.resolve():
|
||||||
|
raise SetupError(f'The web interface service runs from {workdir or "an unknown folder"}, '
|
||||||
|
f'not {self.project_root}.')
|
||||||
|
# Spaces are fine -- the templates quote every command-line path --
|
||||||
|
# but systemd expands % specifiers, and a quote, backslash or line
|
||||||
|
# break would be reinterpreted in a unit file. (On Windows, where the
|
||||||
|
# tests also run, a backslash is the path separator, not a name.)
|
||||||
|
root_text = str(self.project_root)
|
||||||
|
unsafe = set('%"') | ({'\\'} if os.sep == '/' else set())
|
||||||
|
if any(ch in unsafe or ord(ch) < 32 for ch in root_text):
|
||||||
|
raise SetupError(f'LEDMatrix is installed in {root_text!r}, a folder name systemd cannot use '
|
||||||
|
'in a unit file. Move it to a path without %, quotes, backslashes or '
|
||||||
|
'control characters.')
|
||||||
|
|
||||||
|
rendered = {}
|
||||||
|
for name in UNITS:
|
||||||
|
template = _read(self.project_root / 'systemd' / name)
|
||||||
|
if template is None:
|
||||||
|
raise SetupError(f'The unit template systemd/{name} is missing.')
|
||||||
|
rendered[name] = (template.replace('__PROJECT_ROOT_DIR__', str(self.project_root))
|
||||||
|
.replace('__USER__', user))
|
||||||
|
# The templates are ordinary repository files. Whatever they say,
|
||||||
|
# this root process only installs a service that runs as the web user
|
||||||
|
# and a path unit that starts exactly that service.
|
||||||
|
if _directive(rendered[SERVICE_UNIT], 'User') != user:
|
||||||
|
raise SetupError(f'systemd/{SERVICE_UNIT} does not run as the web interface user; '
|
||||||
|
'refusing to install it.')
|
||||||
|
if _directive(rendered[PATH_UNIT], 'Unit') != SERVICE_UNIT:
|
||||||
|
raise SetupError(f'systemd/{PATH_UNIT} does not start {SERVICE_UNIT}; refusing to install it.')
|
||||||
|
|
||||||
|
changed = [name for name in UNITS if _read(self.systemd_dir / name) != rendered[name]]
|
||||||
|
for name in changed:
|
||||||
|
self._write_unit(self.systemd_dir / name, rendered[name])
|
||||||
|
if changed:
|
||||||
|
self._check(self._systemctl('daemon-reload'), 'systemctl daemon-reload')
|
||||||
|
self._check(self._systemctl('enable', PATH_UNIT), f'systemctl enable {PATH_UNIT}')
|
||||||
|
self._check(self._systemctl('restart', PATH_UNIT), f'systemctl restart {PATH_UNIT}')
|
||||||
|
elif not self.path_active():
|
||||||
|
self._check(self._systemctl('enable', '--now', PATH_UNIT), f'systemctl enable --now {PATH_UNIT}')
|
||||||
|
changed = [PATH_UNIT]
|
||||||
|
if not self.path_active():
|
||||||
|
raise SetupError(f'{PATH_UNIT} did not start; see "journalctl -u {PATH_UNIT}".')
|
||||||
|
return bool(changed)
|
||||||
|
|
||||||
|
def _write_unit(self, path, text):
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f'.{path.name}.')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(text)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _report(self, status, message, quiet=False):
|
||||||
|
previous = None
|
||||||
|
try:
|
||||||
|
previous = json.loads(_read(self.result_file) or 'null')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if quiet and isinstance(previous, dict) and previous.get('status') == status:
|
||||||
|
return previous # nothing new; don't rewrite it on every boot
|
||||||
|
result = {'status': status, 'message': message, 'at': self.clock()}
|
||||||
|
(logger.info if status == 'installed' else logger.warning)("Automatic update setup: %s", message)
|
||||||
|
try:
|
||||||
|
self.result_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
if self._web_ids and hasattr(os, 'chown'):
|
||||||
|
# Only root can give the file away; the result is readable
|
||||||
|
# (0644) either way, so a failed chown must not lose it.
|
||||||
|
try:
|
||||||
|
os.chown(tmp, *self._web_ids)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(tmp, self.result_file)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Could not record automatic update setup result: %s", e)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_update_helper(config):
|
||||||
|
return UpdateHelperSetup().ensure(config)
|
||||||
@@ -14,11 +14,12 @@ Key Features:
|
|||||||
- Memory-efficient data storage
|
- Memory-efficient data storage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import itertools
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any, Optional, Callable
|
from typing import Dict, Any, Optional, Callable, List
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import queue
|
import queue
|
||||||
@@ -50,14 +51,33 @@ class FetchRequest:
|
|||||||
max_retries: int = 3
|
max_retries: int = 3
|
||||||
priority: int = 1 # Higher number = higher priority
|
priority: int = 1 # Higher number = higher priority
|
||||||
callback: Optional[Callable] = None
|
callback: Optional[Callable] = None
|
||||||
|
# Callbacks from submitters that JOINED this fetch instead of starting a
|
||||||
|
# duplicate one. The primary `callback` above belongs to whoever created
|
||||||
|
# the request; these belong to everyone who asked for the same cache_key
|
||||||
|
# while it was still in flight.
|
||||||
|
extra_callbacks: List[Callable] = field(default_factory=list)
|
||||||
created_at: float = field(default_factory=time.time)
|
created_at: float = field(default_factory=time.time)
|
||||||
status: FetchStatus = FetchStatus.PENDING
|
status: FetchStatus = FetchStatus.PENDING
|
||||||
|
# Set once the worker has decided this response will be cached, while it
|
||||||
|
# still holds the lock. From that point cancelling is refused: the write
|
||||||
|
# is already authorised, and abandoning it here would put the payload in
|
||||||
|
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
||||||
|
# fetch that did, in fact, succeed.
|
||||||
|
commit_claimed: bool = False
|
||||||
result: Optional[Any] = None
|
result: Optional[Any] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FetchResult:
|
class FetchResult:
|
||||||
"""Result of a background fetch operation."""
|
"""Result of a background fetch operation.
|
||||||
|
|
||||||
|
``data`` survives on the stored result only for requests submitted without
|
||||||
|
a ``callback``, where polling ``get_result()`` is the sole way to collect
|
||||||
|
it. When a callback was given, the payload has already been delivered and
|
||||||
|
the service releases it -- see :meth:`BackgroundDataService._release_payload`.
|
||||||
|
Either way the data remains in the cache under the request's ``cache_key``,
|
||||||
|
which is where consumers read it from.
|
||||||
|
"""
|
||||||
request_id: str
|
request_id: str
|
||||||
success: bool
|
success: bool
|
||||||
data: Optional[Any] = None
|
data: Optional[Any] = None
|
||||||
@@ -66,6 +86,10 @@ class FetchResult:
|
|||||||
fetch_time: float = 0.0
|
fetch_time: float = 0.0
|
||||||
retry_count: int = 0
|
retry_count: int = 0
|
||||||
completed_at: float = field(default_factory=time.time) # Timestamp when request completed
|
completed_at: float = field(default_factory=time.time) # Timestamp when request completed
|
||||||
|
# The request's final status, recorded so a finished request can still be
|
||||||
|
# reported accurately. Without it a caller can only be told COMPLETED or
|
||||||
|
# FAILED, which turns "you cancelled this" into "this errored".
|
||||||
|
final_status: Optional[FetchStatus] = None
|
||||||
|
|
||||||
class BackgroundDataService:
|
class BackgroundDataService:
|
||||||
"""
|
"""
|
||||||
@@ -90,6 +114,20 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
# Thread management
|
# Thread management
|
||||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
||||||
|
# cache_key -> request_id for fetches currently in flight. Submitting
|
||||||
|
# the same key twice used to start two identical fetches: request_id
|
||||||
|
# carries a millisecond timestamp, so every submit looked new, and
|
||||||
|
# active_requests is keyed by it rather than by what is being fetched.
|
||||||
|
# On a real board the season-schedule key is requested by both the
|
||||||
|
# Recent and the Upcoming manager, which miss the cache in the same
|
||||||
|
# millisecond and each download and parse the same payload.
|
||||||
|
self._inflight_by_cache_key: Dict[str, str] = {}
|
||||||
|
# request_id was sport_year_milliseconds, which is not unique: two
|
||||||
|
# submits inside the same millisecond produced the SAME id, so one
|
||||||
|
# silently replaced the other in active_requests and completed_requests.
|
||||||
|
# Rare before, but dedupe hands this id back to every joiner as their
|
||||||
|
# handle for get_result(), so it has to be unique. A counter is enough.
|
||||||
|
self._request_seq = itertools.count()
|
||||||
self.active_requests: Dict[str, FetchRequest] = {}
|
self.active_requests: Dict[str, FetchRequest] = {}
|
||||||
self.completed_requests: Dict[str, FetchResult] = {}
|
self.completed_requests: Dict[str, FetchResult] = {}
|
||||||
self.request_queue = queue.PriorityQueue()
|
self.request_queue = queue.PriorityQueue()
|
||||||
@@ -177,7 +215,9 @@ class BackgroundDataService:
|
|||||||
if cache_key is None:
|
if cache_key is None:
|
||||||
cache_key = self.get_sport_cache_key(sport)
|
cache_key = self.get_sport_cache_key(sport)
|
||||||
|
|
||||||
request_id = f"{sport}_{year}_{int(time.time() * 1000)}"
|
with self._lock:
|
||||||
|
request_id = (f"{sport}_{year}_{int(time.time() * 1000)}"
|
||||||
|
f"_{next(self._request_seq)}")
|
||||||
|
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cached_data = self.cache_manager.get(cache_key)
|
cached_data = self.cache_manager.get(cache_key)
|
||||||
@@ -191,6 +231,10 @@ class BackgroundDataService:
|
|||||||
cached=True,
|
cached=True,
|
||||||
fetch_time=0.0
|
fetch_time=0.0
|
||||||
)
|
)
|
||||||
|
# Filed before the callback runs, as it always was: a callback
|
||||||
|
# that queries get_result()/is_request_complete() for its own
|
||||||
|
# request must still find it. Releasing afterwards mutates the
|
||||||
|
# same object the dict holds.
|
||||||
self.completed_requests[request_id] = result
|
self.completed_requests[request_id] = result
|
||||||
|
|
||||||
if callback:
|
if callback:
|
||||||
@@ -198,6 +242,7 @@ class BackgroundDataService:
|
|||||||
callback(result)
|
callback(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request_id}: {e}")
|
logger.error(f"Error in callback for request {request_id}: {e}")
|
||||||
|
self._release_payload(result)
|
||||||
|
|
||||||
logger.debug(f"Cache hit for {sport} {year} data")
|
logger.debug(f"Cache hit for {sport} {year} data")
|
||||||
return request_id
|
return request_id
|
||||||
@@ -218,7 +263,29 @@ class BackgroundDataService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
existing_id = self._inflight_by_cache_key.get(cache_key)
|
||||||
|
existing = self.active_requests.get(existing_id) if existing_id else None
|
||||||
|
if existing_id and existing is None:
|
||||||
|
# Stranded index entry: the request it names is gone. Drop it and
|
||||||
|
# fetch normally. Looking the request up rather than trusting the
|
||||||
|
# id is what stops a stale entry wedging a key forever.
|
||||||
|
del self._inflight_by_cache_key[cache_key]
|
||||||
|
if existing is not None:
|
||||||
|
# Someone is already fetching this key. Ride along rather than
|
||||||
|
# duplicating the download, the parse and the resident copy.
|
||||||
|
if callback:
|
||||||
|
existing.extra_callbacks.append(callback)
|
||||||
|
self.stats['deduplicated_requests'] = (
|
||||||
|
self.stats.get('deduplicated_requests', 0) + 1
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Joined in-flight fetch %s for %s (cache_key=%s) instead of "
|
||||||
|
"starting a duplicate", existing_id, sport, cache_key
|
||||||
|
)
|
||||||
|
return existing_id
|
||||||
|
|
||||||
self.active_requests[request_id] = request
|
self.active_requests[request_id] = request
|
||||||
|
self._inflight_by_cache_key[cache_key] = request_id
|
||||||
self.stats['total_requests'] += 1
|
self.stats['total_requests'] += 1
|
||||||
self.stats['cache_misses'] += 1
|
self.stats['cache_misses'] += 1
|
||||||
|
|
||||||
@@ -243,7 +310,31 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
# A request cancelled while it sat in the executor queue must
|
||||||
|
# stay cancelled. Overwriting the status here undid the cancel
|
||||||
|
# outright: the worker went on to download, cache and call back
|
||||||
|
# for work the caller had already withdrawn.
|
||||||
|
if request.status == FetchStatus.CANCELLED:
|
||||||
|
cancelled_before_start = True
|
||||||
|
else:
|
||||||
|
cancelled_before_start = False
|
||||||
request.status = FetchStatus.IN_PROGRESS
|
request.status = FetchStatus.IN_PROGRESS
|
||||||
|
if cancelled_before_start:
|
||||||
|
logger.info(
|
||||||
|
"Request %s was cancelled before its worker started; "
|
||||||
|
"skipping the fetch entirely", request.id
|
||||||
|
)
|
||||||
|
# Assign before returning: the finally block stores `result`
|
||||||
|
# in completed_requests, so building a fresh one here would
|
||||||
|
# file the untouched placeholder instead of this outcome.
|
||||||
|
result = FetchResult(
|
||||||
|
request_id=request.id,
|
||||||
|
success=False,
|
||||||
|
error="cancelled",
|
||||||
|
fetch_time=time.time() - start_time,
|
||||||
|
retry_count=request.retry_count
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
||||||
|
|
||||||
@@ -269,6 +360,37 @@ class BackgroundDataService:
|
|||||||
# Log data validation
|
# Log data validation
|
||||||
logger.debug(f"Validated {len(events)} events for {request.sport} {request.year}")
|
logger.debug(f"Validated {len(events)} events for {request.sport} {request.year}")
|
||||||
|
|
||||||
|
# A cancelled request must not commit anything. Cancelling
|
||||||
|
# releases the cache_key, so a replacement fetch for the same key
|
||||||
|
# may already be in flight or finished -- writing this response to
|
||||||
|
# the cache now would overwrite fresher data with the response
|
||||||
|
# nobody wanted. The worker has no way to abort the HTTP call, so
|
||||||
|
# this is where the work gets discarded.
|
||||||
|
with self._lock:
|
||||||
|
cancelled = request.status == FetchStatus.CANCELLED
|
||||||
|
if not cancelled:
|
||||||
|
# Claim the commit in the same critical section that read
|
||||||
|
# the status, so a cancel cannot slip in between the check
|
||||||
|
# and the cache write below. The write itself stays outside
|
||||||
|
# the lock: it serialises a multi-megabyte payload to the
|
||||||
|
# SD card, and holding the service lock across that would
|
||||||
|
# stall every submit, status query and cancel behind it.
|
||||||
|
request.commit_claimed = True
|
||||||
|
if cancelled:
|
||||||
|
logger.info(
|
||||||
|
"Discarding response for cancelled request %s; %s may "
|
||||||
|
"already belong to a replacement fetch",
|
||||||
|
request.id, request.cache_key
|
||||||
|
)
|
||||||
|
result = FetchResult(
|
||||||
|
request_id=request.id,
|
||||||
|
success=False,
|
||||||
|
error="cancelled",
|
||||||
|
fetch_time=time.time() - start_time,
|
||||||
|
retry_count=request.retry_count
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
# Cache the data
|
# Cache the data
|
||||||
self.cache_manager.set(request.cache_key, data)
|
self.cache_manager.set(request.cache_key, data)
|
||||||
|
|
||||||
@@ -294,6 +416,11 @@ class BackgroundDataService:
|
|||||||
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
# Don't relabel a cancelled request. The callback gate in the
|
||||||
|
# finally block only suppresses CANCELLED, so promoting it to
|
||||||
|
# FAILED here delivered an error callback for a fetch nobody
|
||||||
|
# was waiting on any more.
|
||||||
|
if request.status != FetchStatus.CANCELLED:
|
||||||
request.status = FetchStatus.FAILED
|
request.status = FetchStatus.FAILED
|
||||||
request.error = error_msg
|
request.error = error_msg
|
||||||
|
|
||||||
@@ -308,9 +435,26 @@ class BackgroundDataService:
|
|||||||
finally:
|
finally:
|
||||||
# Store result and clean up
|
# Store result and clean up
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
result.final_status = request.status
|
||||||
self.completed_requests[request.id] = result
|
self.completed_requests[request.id] = result
|
||||||
if request.id in self.active_requests:
|
if request.id in self.active_requests:
|
||||||
del self.active_requests[request.id]
|
del self.active_requests[request.id]
|
||||||
|
# Stop accepting joiners and take the callback list in the same
|
||||||
|
# critical section. A submitter that arrives after this point
|
||||||
|
# finds no in-flight entry and either hits the cache (written
|
||||||
|
# above, before the result was built) or starts a fresh fetch --
|
||||||
|
# what it must never do is join a fetch whose callbacks have
|
||||||
|
# already run and then never be called.
|
||||||
|
if self._inflight_by_cache_key.get(request.cache_key) == request.id:
|
||||||
|
del self._inflight_by_cache_key[request.cache_key]
|
||||||
|
# A cancelled request delivers nothing: its joiners were told
|
||||||
|
# about a fetch that has been abandoned, and a replacement will
|
||||||
|
# call them via its own request.
|
||||||
|
if request.status == FetchStatus.CANCELLED:
|
||||||
|
callbacks = []
|
||||||
|
else:
|
||||||
|
callbacks = ([request.callback] if request.callback else [])
|
||||||
|
callbacks.extend(request.extra_callbacks)
|
||||||
|
|
||||||
# Update statistics
|
# Update statistics
|
||||||
if result.success:
|
if result.success:
|
||||||
@@ -327,15 +471,54 @@ class BackgroundDataService:
|
|||||||
# Periodic cleanup after storing result
|
# Periodic cleanup after storing result
|
||||||
self._cleanup_completed_requests()
|
self._cleanup_completed_requests()
|
||||||
|
|
||||||
# Call callback if provided
|
# Call every callback: the original submitter's and any that joined
|
||||||
if request.callback:
|
# this fetch. One raising must not stop the others being delivered.
|
||||||
|
for cb in callbacks:
|
||||||
try:
|
try:
|
||||||
request.callback(result)
|
cb(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request.id}: {e}")
|
logger.error(f"Error in callback for request {request.id}: {e}")
|
||||||
|
|
||||||
|
# Released AFTER the loop, not inside it. Every callback here holds
|
||||||
|
# the same FetchResult, so releasing per-delivery handed the first
|
||||||
|
# one the data and every joiner `result.data is None` -- which is
|
||||||
|
# not a quiet degradation: they read `result.data.get('events')` and
|
||||||
|
# raise AttributeError, which this very loop catches and logs, so
|
||||||
|
# the symptom was one ERROR line and a manager that silently never
|
||||||
|
# got its schedule. Deduplication is the normal case, not a corner:
|
||||||
|
# a sport's recent, upcoming and live managers all ride one season
|
||||||
|
# fetch.
|
||||||
|
#
|
||||||
|
# Guarded on `callbacks`, because a request submitted without one
|
||||||
|
# has no other way to collect its payload than polling get_result().
|
||||||
|
# The old per-delivery release got that right by accident: an empty
|
||||||
|
# list never entered the loop body.
|
||||||
|
if callbacks:
|
||||||
|
self._release_payload(result)
|
||||||
|
request.result = None
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _release_payload(result: FetchResult) -> None:
|
||||||
|
"""Drop a delivered payload, keeping the result's status and timings.
|
||||||
|
|
||||||
|
Only called once EVERY callback has been handed the data -- callers
|
||||||
|
that joined an in-flight fetch share this object, so releasing between
|
||||||
|
deliveries strips the payload out from under the ones still queued.
|
||||||
|
Consumers read fetched data back from the cache under ``cache_key``;
|
||||||
|
the copy carried
|
||||||
|
here was pinning a parsed season schedule -- 946 games for NCAA
|
||||||
|
football, roughly a tenth of total RAM on a 1GB Pi -- in memory until
|
||||||
|
the hourly sweep.
|
||||||
|
|
||||||
|
The cache-hit path matters most: it runs once per update interval per
|
||||||
|
sport, mints a fresh request_id each time, and a memory-tier miss
|
||||||
|
re-parses the payload from disk. Those were genuinely separate copies
|
||||||
|
accumulating toward the 500-entry cap, not shared references.
|
||||||
|
"""
|
||||||
|
result.data = None
|
||||||
|
|
||||||
def _make_request_with_retry(self, request: FetchRequest) -> requests.Response:
|
def _make_request_with_retry(self, request: FetchRequest) -> requests.Response:
|
||||||
"""
|
"""
|
||||||
Make HTTP request with retry logic and exponential backoff.
|
Make HTTP request with retry logic and exponential backoff.
|
||||||
@@ -422,6 +605,8 @@ class BackgroundDataService:
|
|||||||
return self.active_requests[request_id].status
|
return self.active_requests[request_id].status
|
||||||
elif request_id in self.completed_requests:
|
elif request_id in self.completed_requests:
|
||||||
result = self.completed_requests[request_id]
|
result = self.completed_requests[request_id]
|
||||||
|
if result.final_status is not None:
|
||||||
|
return result.final_status
|
||||||
return FetchStatus.COMPLETED if result.success else FetchStatus.FAILED
|
return FetchStatus.COMPLETED if result.success else FetchStatus.FAILED
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -438,8 +623,22 @@ class BackgroundDataService:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if request_id in self.active_requests:
|
if request_id in self.active_requests:
|
||||||
request = self.active_requests[request_id]
|
request = self.active_requests[request_id]
|
||||||
|
if request.commit_claimed:
|
||||||
|
# Too late: the worker holds an authorised commit. Report
|
||||||
|
# the failure rather than half-cancelling a request whose
|
||||||
|
# data is about to land in the cache.
|
||||||
|
logger.debug(
|
||||||
|
"Not cancelling %s: its response is already being "
|
||||||
|
"committed", request_id
|
||||||
|
)
|
||||||
|
return False
|
||||||
request.status = FetchStatus.CANCELLED
|
request.status = FetchStatus.CANCELLED
|
||||||
del self.active_requests[request_id]
|
del self.active_requests[request_id]
|
||||||
|
# Cancelling is the other way a request leaves active_requests,
|
||||||
|
# so the in-flight index has to be released here too or the key
|
||||||
|
# stays pointed at a request that no longer exists.
|
||||||
|
if self._inflight_by_cache_key.get(request.cache_key) == request_id:
|
||||||
|
del self._inflight_by_cache_key[request.cache_key]
|
||||||
logger.info(f"Cancelled request {request_id}")
|
logger.info(f"Cancelled request {request_id}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -37,51 +37,6 @@ class Baseball(SportsCore):
|
|||||||
self.data_source = ESPNDataSource(logger)
|
self.data_source = ESPNDataSource(logger)
|
||||||
self.sport = "baseball"
|
self.sport = "baseball"
|
||||||
|
|
||||||
def _get_baseball_display_text(self, game: Dict) -> str:
|
|
||||||
"""Get baseball-specific display text."""
|
|
||||||
try:
|
|
||||||
display_parts = []
|
|
||||||
|
|
||||||
# Inning information
|
|
||||||
if self.show_innings:
|
|
||||||
inning = game.get("inning", "")
|
|
||||||
if inning:
|
|
||||||
display_parts.append(f"Inning: {inning}")
|
|
||||||
|
|
||||||
# Outs information
|
|
||||||
if self.show_outs:
|
|
||||||
outs = game.get("outs", 0)
|
|
||||||
if outs is not None:
|
|
||||||
display_parts.append(f"Outs: {outs}")
|
|
||||||
|
|
||||||
# Bases information
|
|
||||||
if self.show_bases:
|
|
||||||
bases = game.get("bases", "")
|
|
||||||
if bases:
|
|
||||||
display_parts.append(f"Bases: {bases}")
|
|
||||||
|
|
||||||
# Count information
|
|
||||||
if self.show_count:
|
|
||||||
strikes = game.get("strikes", 0)
|
|
||||||
balls = game.get("balls", 0)
|
|
||||||
if strikes is not None and balls is not None:
|
|
||||||
display_parts.append(f"Count: {balls}-{strikes}")
|
|
||||||
|
|
||||||
# Pitcher/Batter information
|
|
||||||
if self.show_pitcher_batter:
|
|
||||||
pitcher = game.get("pitcher", "")
|
|
||||||
batter = game.get("batter", "")
|
|
||||||
if pitcher:
|
|
||||||
display_parts.append(f"Pitcher: {pitcher}")
|
|
||||||
if batter:
|
|
||||||
display_parts.append(f"Batter: {batter}")
|
|
||||||
|
|
||||||
return " | ".join(display_parts) if display_parts else ""
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error getting baseball display text: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _is_baseball_game_live(self, game: Dict) -> bool:
|
def _is_baseball_game_live(self, game: Dict) -> bool:
|
||||||
"""Check if a baseball game is currently live."""
|
"""Check if a baseball game is currently live."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import OrderedDict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -15,6 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from src.common.font_layout import load_truetype
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
@@ -166,7 +168,14 @@ class SportsCore(ABC):
|
|||||||
self.session.mount("https://", adapter)
|
self.session.mount("https://", adapter)
|
||||||
self.session.mount("http://", adapter)
|
self.session.mount("http://", adapter)
|
||||||
|
|
||||||
self._logo_cache = {}
|
# LRU-bounded: entries are decoded RGBA thumbnails, not file bytes.
|
||||||
|
# Each is up to display_width*1.5 x display_height*1.5 -- about 36KB on
|
||||||
|
# a 256x64 panel, more for wide wordmarks. The key is a team
|
||||||
|
# abbreviation and assets/sports/ncaa_logos alone ships 307 of them, so
|
||||||
|
# an unbounded dict here held the whole league: ~11-18MB per manager
|
||||||
|
# instance, and a league runs three (live/recent/upcoming) each with
|
||||||
|
# its own cache. That is real money on a 1GB Pi.
|
||||||
|
self._logo_cache: "OrderedDict[str, Image.Image]" = OrderedDict()
|
||||||
|
|
||||||
# Font caches for _load_custom_font_from_element_config: per-frame
|
# Font caches for _load_custom_font_from_element_config: per-frame
|
||||||
# callers (font-ladder walks) resolve the same (name, size) over and
|
# callers (font-ladder walks) resolve the same (name, size) over and
|
||||||
@@ -449,12 +458,12 @@ class SportsCore(ABC):
|
|||||||
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
|
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
|
||||||
four_by_six = self._resolve_font_path("4x6-font.ttf")
|
four_by_six = self._resolve_font_path("4x6-font.ttf")
|
||||||
try:
|
try:
|
||||||
fonts['score'] = ImageFont.truetype(press_start, 10)
|
fonts['score'] = load_truetype(press_start, 10)
|
||||||
fonts['time'] = ImageFont.truetype(press_start, 8)
|
fonts['time'] = load_truetype(press_start, 8)
|
||||||
fonts['team'] = ImageFont.truetype(press_start, 8)
|
fonts['team'] = load_truetype(press_start, 8)
|
||||||
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
|
fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status
|
||||||
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
|
fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font
|
||||||
fonts['rank'] = ImageFont.truetype(press_start, 10)
|
fonts['rank'] = load_truetype(press_start, 10)
|
||||||
self.logger.info("Successfully loaded fonts")
|
self.logger.info("Successfully loaded fonts")
|
||||||
except OSError:
|
except OSError:
|
||||||
# Name the directory we searched: the usual cause is an install
|
# Name the directory we searched: the usual cause is an install
|
||||||
@@ -559,11 +568,17 @@ class SportsCore(ABC):
|
|||||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
#: Decoded logos to keep. A scroll of "other games" shows on the order of
|
||||||
|
#: 20 games (40 teams), so this holds a full cycle without thrashing while
|
||||||
|
#: capping the cache well below a 307-team league.
|
||||||
|
_LOGO_CACHE_MAX = 64
|
||||||
|
|
||||||
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
||||||
"""Load and resize a team logo, with caching and automatic download if missing."""
|
"""Load and resize a team logo, with caching and automatic download if missing."""
|
||||||
self.logger.debug(f"Logo path: {logo_path}")
|
self.logger.debug(f"Logo path: {logo_path}")
|
||||||
if team_abbrev in self._logo_cache:
|
if team_abbrev in self._logo_cache:
|
||||||
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
||||||
|
self._logo_cache.move_to_end(team_abbrev)
|
||||||
return self._logo_cache[team_abbrev]
|
return self._logo_cache[team_abbrev]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -603,6 +618,8 @@ class SportsCore(ABC):
|
|||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * 1.5)
|
||||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||||
self._logo_cache[team_abbrev] = logo
|
self._logo_cache[team_abbrev] = logo
|
||||||
|
while len(self._logo_cache) > self._LOGO_CACHE_MAX:
|
||||||
|
self._logo_cache.popitem(last=False)
|
||||||
return logo
|
return logo
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1031,7 +1048,7 @@ class SportsCore(ABC):
|
|||||||
if os.path.exists(font_path):
|
if os.path.exists(font_path):
|
||||||
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
|
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
|
||||||
if font_path.lower().endswith('.ttf'):
|
if font_path.lower().endswith('.ttf'):
|
||||||
font = ImageFont.truetype(font_path, font_size)
|
font = load_truetype(font_path, font_size)
|
||||||
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
|
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
|
||||||
self._font_cache[cache_key] = font
|
self._font_cache[cache_key] = font
|
||||||
return font
|
return font
|
||||||
@@ -1049,7 +1066,7 @@ class SportsCore(ABC):
|
|||||||
# correct one: the newer copies call truetype() on a BDF at
|
# correct one: the newer copies call truetype() on a BDF at
|
||||||
# any size (which simply fails) or refuse BDF outright.
|
# any size (which simply fails) or refuse BDF outright.
|
||||||
try:
|
try:
|
||||||
font = ImageFont.truetype(font_path, font_size)
|
font = load_truetype(font_path, font_size)
|
||||||
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
|
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
|
||||||
self._font_cache[cache_key] = font
|
self._font_cache[cache_key] = font
|
||||||
return font
|
return font
|
||||||
@@ -1061,7 +1078,7 @@ class SportsCore(ABC):
|
|||||||
self._bdf_native_size_cache[font_path] = native_size
|
self._bdf_native_size_cache[font_path] = native_size
|
||||||
if native_size and native_size != font_size:
|
if native_size and native_size != font_size:
|
||||||
try:
|
try:
|
||||||
font = ImageFont.truetype(font_path, native_size)
|
font = load_truetype(font_path, native_size)
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"Loaded BDF font: {font_name} at its native size {native_size} "
|
f"Loaded BDF font: {font_name} at its native size {native_size} "
|
||||||
f"(requested {font_size} isn't a valid strike for this file)"
|
f"(requested {font_size} isn't a valid strike for this file)"
|
||||||
@@ -1089,7 +1106,7 @@ class SportsCore(ABC):
|
|||||||
_resolve_font_family_alias(base_default))
|
_resolve_font_family_alias(base_default))
|
||||||
try:
|
try:
|
||||||
if os.path.exists(default_font_path):
|
if os.path.exists(default_font_path):
|
||||||
font = ImageFont.truetype(default_font_path, font_size)
|
font = load_truetype(default_font_path, font_size)
|
||||||
else:
|
else:
|
||||||
self.logger.warning("Default font not found, using PIL default")
|
self.logger.warning("Default font not found, using PIL default")
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
|||||||
@@ -145,8 +145,7 @@ class SportsUpcoming(SportsCore):
|
|||||||
if (game['home_abbr'] in self.favorite_teams or
|
if (game['home_abbr'] in self.favorite_teams or
|
||||||
game['away_abbr'] in self.favorite_teams):
|
game['away_abbr'] in self.favorite_teams):
|
||||||
favorite_games_found += 1
|
favorite_games_found += 1
|
||||||
if self.show_odds:
|
# Odds are NOT fetched here -- see after selection below.
|
||||||
self._fetch_odds(game)
|
|
||||||
|
|
||||||
# Enhanced logging for debugging
|
# Enhanced logging for debugging
|
||||||
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
|
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
|
||||||
@@ -190,6 +189,20 @@ class SportsUpcoming(SportsCore):
|
|||||||
# Limit to the specified number of upcoming games
|
# Limit to the specified number of upcoming games
|
||||||
team_games = team_games[:self.upcoming_games_to_show]
|
team_games = team_games[:self.upcoming_games_to_show]
|
||||||
|
|
||||||
|
# Odds are fetched here, for the games that survived selection,
|
||||||
|
# rather than in the loop that collects them. That loop walks every
|
||||||
|
# upcoming game in the schedule window, and for a college league
|
||||||
|
# the window is enormous -- a live rig logged 946 upcoming games in
|
||||||
|
# one cycle and displayed 1 of them. The comment up there claimed
|
||||||
|
# odds were fetched "only for games that will be displayed", but
|
||||||
|
# the only narrowing it applied was show_favorite_teams_only, which
|
||||||
|
# is not the default; in the usual case nothing narrowed it at all
|
||||||
|
# and every game cost a separate ESPN request on a Pi that is also
|
||||||
|
# driving the panel.
|
||||||
|
if self.show_odds:
|
||||||
|
for game in team_games:
|
||||||
|
self._fetch_odds(game)
|
||||||
|
|
||||||
# Log changes or periodically
|
# Log changes or periodically
|
||||||
should_log = (
|
should_log = (
|
||||||
current_time - self.last_log_time >= self.log_interval or
|
current_time - self.last_log_time >= self.log_interval or
|
||||||
|
|||||||
Vendored
+116
-10
@@ -5,6 +5,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -14,6 +15,13 @@ import zlib
|
|||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.common.path_safety import safe_path_component
|
||||||
|
|
||||||
|
try: # optional: large speedup on the cache write path, see _dumps below
|
||||||
|
import orjson
|
||||||
|
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
||||||
|
orjson = None
|
||||||
|
|
||||||
# How old an abandoned write's temp file must be before the sweep removes it.
|
# How old an abandoned write's temp file must be before the sweep removes it.
|
||||||
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
||||||
# any in-flight write while still clearing the same day's debris. Deliberately
|
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||||
@@ -40,13 +48,97 @@ class CacheStrategyProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class DateTimeEncoder(json.JSONEncoder):
|
class DateTimeEncoder(json.JSONEncoder):
|
||||||
"""JSON encoder that handles datetime objects."""
|
"""JSON encoder that handles datetime objects.
|
||||||
|
|
||||||
|
Retained for the stdlib fallback path and for any caller importing it.
|
||||||
|
"""
|
||||||
def default(self, obj: Any) -> Any:
|
def default(self, obj: Any) -> Any:
|
||||||
if isinstance(obj, datetime):
|
if isinstance(obj, datetime):
|
||||||
return obj.isoformat()
|
return obj.isoformat()
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_default(obj: Any) -> Any:
|
||||||
|
"""Serialise datetimes exactly as DateTimeEncoder did."""
|
||||||
|
if isinstance(obj, datetime):
|
||||||
|
return obj.isoformat()
|
||||||
|
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_nonfinite(obj: Any) -> Any:
|
||||||
|
"""Non-finite floats -> None, matching what ``orjson.dumps`` writes.
|
||||||
|
|
||||||
|
Only reached once a strict pass has proved there is something to replace,
|
||||||
|
so the ordinary write path never pays for this walk.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, float):
|
||||||
|
return obj if math.isfinite(obj) else None
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _replace_nonfinite(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [_replace_nonfinite(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# NON-FINITE FLOATS
|
||||||
|
# -----------------
|
||||||
|
# JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
|
||||||
|
# orjson refuses to and writes null. That divergence is not acceptable in a
|
||||||
|
# cache whose files outlive the decision of which encoder is installed, so the
|
||||||
|
# policy here is one behaviour on both paths:
|
||||||
|
#
|
||||||
|
# writing non-finite floats become null, whichever encoder is in use
|
||||||
|
# reading files already on disk that carry the stdlib's NaN/Infinity
|
||||||
|
# tokens stay readable, whichever encoder is in use
|
||||||
|
#
|
||||||
|
# Without the write half, installing orjson silently changed cached values.
|
||||||
|
# Without the read half, installing orjson turned every legacy record holding a
|
||||||
|
# NaN into a "corrupted cache file" that DiskCache.get logged as an error and
|
||||||
|
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
|
||||||
|
|
||||||
|
|
||||||
|
if orjson is not None:
|
||||||
|
# Encoding the cache record dominated the background fetch worker: on a
|
||||||
|
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
|
||||||
|
# it, which stalls the render thread mid-scroll. orjson measures ~7x
|
||||||
|
# faster on the same payloads (11.9ms -> 1.6ms for 985KB). Decoding gains
|
||||||
|
# far less (~1.3x on large payloads) because the cost there is building
|
||||||
|
# the Python objects, not scanning the text, but it is still free to take.
|
||||||
|
#
|
||||||
|
# OPT_NON_STR_KEYS: stdlib json coerces int/float dict keys to strings;
|
||||||
|
# orjson raises without this, and cache records do carry numeric keys.
|
||||||
|
# OPT_PASSTHROUGH_DATETIME: orjson would otherwise emit its own RFC 3339
|
||||||
|
# form for datetimes instead of calling default(). Routing them through
|
||||||
|
# _datetime_default keeps byte-for-byte parity with the records already
|
||||||
|
# on disk.
|
||||||
|
_DUMPS_OPTS = orjson.OPT_NON_STR_KEYS | orjson.OPT_PASSTHROUGH_DATETIME
|
||||||
|
|
||||||
|
def _dumps(data: Any) -> bytes:
|
||||||
|
return orjson.dumps(data, default=_datetime_default, option=_DUMPS_OPTS)
|
||||||
|
|
||||||
|
def _loads(raw: bytes) -> Any:
|
||||||
|
try:
|
||||||
|
return orjson.loads(raw)
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
# Legacy record written by the stdlib path, carrying NaN or
|
||||||
|
# Infinity. Genuinely malformed files raise again from here, as
|
||||||
|
# json.JSONDecodeError, which is what DiskCache.get expects.
|
||||||
|
return json.loads(raw)
|
||||||
|
else:
|
||||||
|
def _dumps(data: Any) -> bytes:
|
||||||
|
try:
|
||||||
|
return json.dumps(data, cls=DateTimeEncoder,
|
||||||
|
allow_nan=False).encode("utf-8")
|
||||||
|
except ValueError:
|
||||||
|
# allow_nan=False is what detects the non-finite values; the walk
|
||||||
|
# runs only now that we know there is one to replace.
|
||||||
|
return json.dumps(_replace_nonfinite(data), cls=DateTimeEncoder,
|
||||||
|
allow_nan=False).encode("utf-8")
|
||||||
|
|
||||||
|
def _loads(raw: bytes) -> Any:
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
class DiskCache:
|
class DiskCache:
|
||||||
"""Manages persistent disk-based cache."""
|
"""Manages persistent disk-based cache."""
|
||||||
|
|
||||||
@@ -71,15 +163,29 @@ class DiskCache:
|
|||||||
"""
|
"""
|
||||||
Get the path for a cache file.
|
Get the path for a cache file.
|
||||||
|
|
||||||
|
The key becomes a filename, so it has to be one. Keys reach this
|
||||||
|
method from the web API -- POST /api/v3/cache/delete passes the
|
||||||
|
request body's ``key`` straight through CacheManager.clear_cache to
|
||||||
|
os.remove -- and a key of ``../../../../etc/whatever`` named a file
|
||||||
|
well outside the cache directory. Every real key is the stem of a
|
||||||
|
file already sitting flat in cache_dir (that is how list_cache_files
|
||||||
|
derives them), so rejecting anything with a path component turns
|
||||||
|
away only inputs that could never have been written here.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to cache file or None if cache is disabled
|
Path to cache file, or None if cache is disabled or the key is
|
||||||
|
not a usable filename
|
||||||
"""
|
"""
|
||||||
if not self.cache_dir:
|
if not self.cache_dir:
|
||||||
return None
|
return None
|
||||||
return os.path.join(self.cache_dir, f"{key}.json")
|
safe_key = safe_path_component(key)
|
||||||
|
if safe_key is None:
|
||||||
|
self.logger.warning("Rejected unsafe cache key %r", key)
|
||||||
|
return None
|
||||||
|
return os.path.join(self.cache_dir, f"{safe_key}.json")
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -99,8 +205,8 @@ class DiskCache:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
with open(cache_path, 'rb') as f:
|
||||||
record = json.load(f)
|
record = _loads(f.read())
|
||||||
|
|
||||||
# Determine record timestamp (prefer embedded, else file mtime)
|
# Determine record timestamp (prefer embedded, else file mtime)
|
||||||
record_ts = None
|
record_ts = None
|
||||||
@@ -189,12 +295,12 @@ class DiskCache:
|
|||||||
# write path below, and cache files are machine-read only — indenting
|
# write path below, and cache files are machine-read only — indenting
|
||||||
# them just multiplied the bytes written to the SD card.
|
# them just multiplied the bytes written to the SD card.
|
||||||
try:
|
try:
|
||||||
payload = json.dumps(data, cls=DateTimeEncoder)
|
payload = _dumps(data)
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
digest = zlib.adler32(payload.encode('utf-8'))
|
digest = zlib.adler32(payload)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Atomic write to avoid partial/corrupt files
|
# Atomic write to avoid partial/corrupt files
|
||||||
@@ -242,7 +348,7 @@ class DiskCache:
|
|||||||
# wear source (dozens of fsyncs/min on API-heavy
|
# wear source (dozens of fsyncs/min on API-heavy
|
||||||
# installs) for data that can be re-downloaded.
|
# installs) for data that can be re-downloaded.
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
with os.fdopen(fd, 'wb') as tmp_file:
|
||||||
tmp_file.write(payload)
|
tmp_file.write(payload)
|
||||||
os.replace(tmp_path, cache_path)
|
os.replace(tmp_path, cache_path)
|
||||||
self._write_digests[key] = digest
|
self._write_digests[key] = digest
|
||||||
@@ -260,7 +366,7 @@ class DiskCache:
|
|||||||
else:
|
else:
|
||||||
# Fallback: direct write (not atomic, but better than failing)
|
# Fallback: direct write (not atomic, but better than failing)
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
with open(cache_path, 'wb') as cache_file:
|
||||||
cache_file.write(payload)
|
cache_file.write(payload)
|
||||||
self._write_digests[key] = digest
|
self._write_digests[key] = digest
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
@@ -290,7 +396,7 @@ class DiskCache:
|
|||||||
# is a different path, so future sets must keep
|
# is a different path, so future sets must keep
|
||||||
# retrying the primary location.
|
# retrying the primary location.
|
||||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||||
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
with open(fallback_path, 'wb') as tmp_file:
|
||||||
tmp_file.write(payload)
|
tmp_file.write(payload)
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
|
|||||||
Vendored
+75
-16
@@ -4,11 +4,58 @@ Memory Cache
|
|||||||
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
Handles in-memory caching with TTL support, size limits, and automatic cleanup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
# Historical fixed ceiling, kept as the fallback when RAM cannot be read.
|
||||||
|
DEFAULT_MAX_SIZE = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _total_memory_mb() -> Optional[float]:
|
||||||
|
"""Physical RAM in MB, or None where /proc/meminfo is unavailable."""
|
||||||
|
try:
|
||||||
|
with open('/proc/meminfo', 'r', encoding='utf-8') as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.startswith('MemTotal:'):
|
||||||
|
return int(line.split()[1]) / 1024
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def default_max_size() -> int:
|
||||||
|
"""Entry ceiling scaled to this machine's RAM.
|
||||||
|
|
||||||
|
One fixed ceiling cannot serve both a 512 MB Pi Zero 2 W and an 8 GB Pi 5.
|
||||||
|
Entries here are parsed API payloads that routinely run tens of kilobytes
|
||||||
|
each, so a thousand of them is a comfortable cache on a large board and a
|
||||||
|
substantial fraction of total RAM on a small one — where the process
|
||||||
|
competing for that RAM is also driving the panel. Set
|
||||||
|
LEDMATRIX_CACHE_MAX_ENTRIES to override.
|
||||||
|
"""
|
||||||
|
override = os.environ.get('LEDMATRIX_CACHE_MAX_ENTRIES')
|
||||||
|
if override:
|
||||||
|
try:
|
||||||
|
value = int(override)
|
||||||
|
if value > 0:
|
||||||
|
return value
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
total_mb = _total_memory_mb()
|
||||||
|
if total_mb is None:
|
||||||
|
return DEFAULT_MAX_SIZE
|
||||||
|
if total_mb < 1536: # 512 MB and 1 GB boards
|
||||||
|
return 150
|
||||||
|
if total_mb < 3072: # 2 GB
|
||||||
|
return 400
|
||||||
|
if total_mb < 6144: # 4 GB
|
||||||
|
return 800
|
||||||
|
return 1500 # 8 GB and up
|
||||||
|
|
||||||
|
|
||||||
class MemoryCache:
|
class MemoryCache:
|
||||||
"""Manages in-memory cache with TTL and size limits."""
|
"""Manages in-memory cache with TTL and size limits."""
|
||||||
@@ -87,6 +134,32 @@ class MemoryCache:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._cache[key] = value
|
self._cache[key] = value
|
||||||
self._timestamps[key] = time.time()
|
self._timestamps[key] = time.time()
|
||||||
|
# Enforce the ceiling here rather than leaving it to the periodic
|
||||||
|
# cleanup, which only runs every cleanup_interval seconds (300 by
|
||||||
|
# default). A burst of inserts between two sweeps could otherwise
|
||||||
|
# take the cache far past _max_size, which is the memory growth this
|
||||||
|
# limit exists to prevent -- and on a 1GB board that is the
|
||||||
|
# difference between a bounded cache and an unreachable Pi.
|
||||||
|
self._evict_over_limit_locked()
|
||||||
|
|
||||||
|
def _evict_over_limit_locked(self) -> int:
|
||||||
|
"""Drop oldest entries until the cache is within _max_size.
|
||||||
|
|
||||||
|
Caller must hold self._lock. Returns the number of entries removed.
|
||||||
|
"""
|
||||||
|
excess = len(self._cache) - self._max_size
|
||||||
|
if excess <= 0:
|
||||||
|
return 0
|
||||||
|
oldest = sorted(
|
||||||
|
self._timestamps.items(),
|
||||||
|
key=lambda item: float(item[1]) if isinstance(item[1], (int, float)) else 0.0
|
||||||
|
)
|
||||||
|
removed = 0
|
||||||
|
for key, _ in oldest[:excess]:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
self._timestamps.pop(key, None)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
def clear(self, key: Optional[str] = None) -> None:
|
def clear(self, key: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -143,22 +216,8 @@ class MemoryCache:
|
|||||||
self._timestamps.pop(key, None)
|
self._timestamps.pop(key, None)
|
||||||
removed_count += 1
|
removed_count += 1
|
||||||
|
|
||||||
# Enforce size limit by removing oldest entries if cache is too large
|
# Same ceiling enforcement set() uses, so the two cannot drift.
|
||||||
if len(self._cache) > self._max_size:
|
removed_count += self._evict_over_limit_locked()
|
||||||
# Sort by timestamp (oldest first)
|
|
||||||
sorted_entries = sorted(
|
|
||||||
self._timestamps.items(),
|
|
||||||
key=lambda x: float(x[1]) if isinstance(x[1], (int, float)) else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove oldest entries until we're under the limit
|
|
||||||
excess_count = len(self._cache) - self._max_size
|
|
||||||
for i in range(excess_count):
|
|
||||||
if i < len(sorted_entries):
|
|
||||||
key = sorted_entries[i][0]
|
|
||||||
self._cache.pop(key, None)
|
|
||||||
self._timestamps.pop(key, None)
|
|
||||||
removed_count += 1
|
|
||||||
|
|
||||||
self._last_cleanup = current_time
|
self._last_cleanup = current_time
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
from src.exceptions import CacheError
|
from src.exceptions import CacheError
|
||||||
from src.cache.memory_cache import MemoryCache
|
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||||
from src.cache.disk_cache import DiskCache
|
from src.cache.disk_cache import DiskCache
|
||||||
from src.cache.cache_strategy import CacheStrategy
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
from src.cache.cache_metrics import CacheMetrics
|
from src.cache.cache_metrics import CacheMetrics
|
||||||
@@ -84,7 +84,9 @@ class CacheManager:
|
|||||||
self.logger.warning("ConfigManager not available, using default cache intervals")
|
self.logger.warning("ConfigManager not available, using default cache intervals")
|
||||||
|
|
||||||
# Initialize cache components using composition
|
# Initialize cache components using composition
|
||||||
self._memory_cache_component = MemoryCache(max_size=1000, cleanup_interval=300.0)
|
self._memory_cache_component = MemoryCache(
|
||||||
|
max_size=default_max_size(), cleanup_interval=300.0
|
||||||
|
)
|
||||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||||
self._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
self._strategy_component = CacheStrategy(config_manager=self.config_manager, logger=self.logger)
|
||||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
self._metrics_component = CacheMetrics(logger=self.logger)
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ from src.common.error_handler import (
|
|||||||
)
|
)
|
||||||
from src.common.api_helper import APIHelper
|
from src.common.api_helper import APIHelper
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
from src.common import scroll_config
|
||||||
|
from src.common.scroll_config import (
|
||||||
|
ScrollSettings,
|
||||||
|
configure as configure_scroll,
|
||||||
|
resolve as resolve_scroll_settings,
|
||||||
|
refresh_hz_from_config,
|
||||||
|
)
|
||||||
from src.common.logo_helper import LogoHelper
|
from src.common.logo_helper import LogoHelper
|
||||||
from src.common.text_helper import TextHelper
|
from src.common.text_helper import TextHelper
|
||||||
|
|
||||||
@@ -60,6 +67,11 @@ __all__ = [
|
|||||||
'log_and_raise',
|
'log_and_raise',
|
||||||
'APIHelper',
|
'APIHelper',
|
||||||
'ScrollHelper',
|
'ScrollHelper',
|
||||||
|
'scroll_config',
|
||||||
|
'ScrollSettings',
|
||||||
|
'configure_scroll',
|
||||||
|
'resolve_scroll_settings',
|
||||||
|
'refresh_hz_from_config',
|
||||||
'LogoHelper',
|
'LogoHelper',
|
||||||
'TextHelper',
|
'TextHelper',
|
||||||
# adaptive layout & images
|
# adaptive layout & images
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""One text layout engine, everywhere.
|
||||||
|
|
||||||
|
``PIL.ImageFont.truetype`` picks its layout engine at load time: Raqm when the
|
||||||
|
host Pillow was built with libraqm, Basic otherwise. The two disagree about
|
||||||
|
fractional glyph advances, so the *same* Pillow version renders the *same*
|
||||||
|
string differently depending on a build option of the host.
|
||||||
|
|
||||||
|
That is invisible for ``PressStart2P-Regular.ttf`` at 8px, whose advances are
|
||||||
|
whole pixels either way — which is why most of the fleet's golden images
|
||||||
|
matched on every machine. It is not invisible for ``4x6-font.ttf`` at 6px,
|
||||||
|
where the advances are fractional: glyph positions drift cumulatively along a
|
||||||
|
run, and the committed goldens for geochron, of-the-day, christmas-countdown
|
||||||
|
and ledmatrix-weather's almanac passed on the machine that generated them and
|
||||||
|
failed everywhere else (ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391).
|
||||||
|
|
||||||
|
Pinning the Basic engine makes a render depend on the font file and the size,
|
||||||
|
and nothing else. Basic gives up complex-script shaping (Arabic, Indic) and
|
||||||
|
kerning pairs; neither applies to the bitmap-grid faces this project draws
|
||||||
|
with on an LED panel.
|
||||||
|
|
||||||
|
Use :func:`load_truetype` in place of ``ImageFont.truetype`` anywhere the
|
||||||
|
result is drawn to a panel or compared against a golden image.
|
||||||
|
|
||||||
|
The module also owns the other two things that decide whether a bundled face
|
||||||
|
renders reproducibly, for the same reason — they are properties of the font
|
||||||
|
file, not of whoever is drawing with it:
|
||||||
|
|
||||||
|
* :func:`crisp_size` and :data:`FONT_PIXEL_GRID` — the size each face renders
|
||||||
|
on whole pixels at. ``4x6-font.ttf`` has a 7px grid, which is why the 6 that
|
||||||
|
reads as its natural size is the wrong number everywhere it appears.
|
||||||
|
* :func:`resolve_asset_path` — ``assets/fonts/...`` resolved against the
|
||||||
|
install root rather than the process cwd.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
#: The engine every core font load pins. Named once so the reason above has a
|
||||||
|
#: single referent, and so a future change is one line.
|
||||||
|
LAYOUT_ENGINE = ImageFont.Layout.BASIC
|
||||||
|
|
||||||
|
|
||||||
|
def load_truetype(font: Union[str, Any], size: int, **kwargs: Any) -> ImageFont.FreeTypeFont:
|
||||||
|
"""``ImageFont.truetype`` with the layout engine pinned.
|
||||||
|
|
||||||
|
Same signature and same exceptions as the PIL call it replaces, so it is a
|
||||||
|
drop-in at every call site.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault("layout_engine", LAYOUT_ENGINE)
|
||||||
|
return ImageFont.truetype(font, size, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Bundled-asset path resolution
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: The install root, derived from this module's own location
|
||||||
|
#: (``<root>/src/common/font_layout.py``) rather than from the process cwd.
|
||||||
|
_INSTALL_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_asset_path(relative_path: str) -> str:
|
||||||
|
"""Resolve a repo-relative asset path independently of the process cwd.
|
||||||
|
|
||||||
|
Prefers the path as given — so an absolute path is returned untouched and
|
||||||
|
behaviour is unchanged wherever the cwd already happened to be the install
|
||||||
|
root — then the install root derived above, then the original string so a
|
||||||
|
caller that wants to raise and fall back still can.
|
||||||
|
|
||||||
|
Without the fallback, any process started outside the install root (the
|
||||||
|
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
|
||||||
|
file written without ``WorkingDirectory``) silently loses every font and
|
||||||
|
degrades to PIL's default face.
|
||||||
|
"""
|
||||||
|
if os.path.isabs(relative_path) and os.path.exists(relative_path):
|
||||||
|
return relative_path
|
||||||
|
candidate = _INSTALL_ROOT / relative_path
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Pixel-grid snapping
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Family aliases the web UI may write, mapped to the shipped filename.
|
||||||
|
FONT_NAME_ALIASES: Dict[str, str] = {
|
||||||
|
"press_start": "PressStart2P-Regular.ttf",
|
||||||
|
"four_by_six": "4x6-font.ttf",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
|
||||||
|
#: on an LED matrix is a dim lamp rather than a soft edge — and worse under
|
||||||
|
#: ``draw.fontmode = "1"``, where the mono rasteriser thresholds each glyph at
|
||||||
|
#: 50% coverage: an off-grid 4x6 glyph renders 3px wide instead of 4, so W/M
|
||||||
|
#: and 0/8 stop being distinguishable. Off-grid sizes also make ``getlength``
|
||||||
|
#: return a FreeType-dependent fractional advance, which is how two panels on
|
||||||
|
#: one config centre the same string differently.
|
||||||
|
FONT_PIXEL_GRID: Dict[str, int] = {
|
||||||
|
"PressStart2P-Regular.ttf": 8,
|
||||||
|
"4x6-font.ttf": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def crisp_size(font_file, desired, aliases=None, grid_table=None):
|
||||||
|
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
||||||
|
|
||||||
|
A face with no known grid is returned unchanged, so a user-supplied font is
|
||||||
|
never second-guessed.
|
||||||
|
|
||||||
|
``aliases`` and ``grid_table`` default to the shared tables; a plugin that
|
||||||
|
ships an extra face can pass its own without forking this.
|
||||||
|
"""
|
||||||
|
aliases = FONT_NAME_ALIASES if aliases is None else aliases
|
||||||
|
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
|
||||||
|
font_file = aliases.get(font_file, font_file)
|
||||||
|
grid = grid_table.get(font_file)
|
||||||
|
if not grid or not desired or desired <= 0:
|
||||||
|
return desired
|
||||||
|
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||||
+209
-16
@@ -6,6 +6,9 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
@@ -18,6 +21,48 @@ from src.common.permission_utils import (
|
|||||||
get_assets_file_mode
|
get_assets_file_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# How long a missing logo stays remembered as missing.
|
||||||
|
#
|
||||||
|
# This was 600s, and measured on a live rig that turned out to suppress nothing:
|
||||||
|
# the display rotation is ~618s, so every recheck landed just as the plugin came
|
||||||
|
# round again and the warning rate was unchanged at ~6/hour. A TTL has to be long
|
||||||
|
# relative to the loop that does the asking, not merely "a while".
|
||||||
|
#
|
||||||
|
# An hour is safe because the TTL is not the main way an entry clears. A download
|
||||||
|
# through load_logo_with_download() drops it immediately, and clear_cache() drops
|
||||||
|
# all of them; the TTL only covers a file that appeared some other way -- someone
|
||||||
|
# copying one in by hand. Waiting up to an hour for that, or restarting, is a fair
|
||||||
|
# trade for not re-warning about a file nobody is going to add.
|
||||||
|
MISSING_LOGO_RECHECK_SECONDS = 3600.0
|
||||||
|
|
||||||
|
#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed
|
||||||
|
#: enough that a typo cannot ask for a 4000px image on a 64px panel.
|
||||||
|
MIN_LOGO_SCALE = 0.05
|
||||||
|
MAX_LOGO_SCALE = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def _usable_scale(scale) -> float:
|
||||||
|
"""A scale that can be applied, or 1.0.
|
||||||
|
|
||||||
|
Anything unusable -- None, a string, zero, a negative, NaN, infinity --
|
||||||
|
means "as shipped", because the alternative is a blank panel from a
|
||||||
|
mistyped number.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = float(scale)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1.0
|
||||||
|
if value != value or value in (float('inf'), float('-inf')):
|
||||||
|
return 1.0
|
||||||
|
if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE:
|
||||||
|
return 1.0
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Well above any real team logo; bounds what a remote URL can write to disk.
|
||||||
|
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class LogoHelper:
|
class LogoHelper:
|
||||||
"""
|
"""
|
||||||
@@ -51,6 +96,14 @@ class LogoHelper:
|
|||||||
self._logo_cache: Dict[str, Image.Image] = {}
|
self._logo_cache: Dict[str, Image.Image] = {}
|
||||||
self._cache_order: List[str] = [] # For LRU cache management
|
self._cache_order: List[str] = [] # For LRU cache management
|
||||||
|
|
||||||
|
# Misses, so an absent file is stat'd and warned about once rather than
|
||||||
|
# on every call. Without this a permanently missing logo produced a
|
||||||
|
# warning per rotation forever -- measured at 114 lines in 24 hours for
|
||||||
|
# a single missing ticker icon, for a file nobody was going to add.
|
||||||
|
# Time-bounded rather than permanent so a logo that appears later (the
|
||||||
|
# downloader writes them at runtime) is still picked up.
|
||||||
|
self._missing_logos: Dict[str, float] = {}
|
||||||
|
|
||||||
# Session for HTTP requests
|
# Session for HTTP requests
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
@@ -60,7 +113,8 @@ class LogoHelper:
|
|||||||
|
|
||||||
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
|
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
|
||||||
max_width: Optional[int] = None,
|
max_width: Optional[int] = None,
|
||||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
max_height: Optional[int] = None,
|
||||||
|
scale: float = 1.0) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Load and resize a team logo.
|
Load and resize a team logo.
|
||||||
|
|
||||||
@@ -69,6 +123,10 @@ class LogoHelper:
|
|||||||
logo_path: Path to the logo file
|
logo_path: Path to the logo file
|
||||||
max_width: Maximum width (defaults to display_width * 1.5)
|
max_width: Maximum width (defaults to display_width * 1.5)
|
||||||
max_height: Maximum height (defaults to display_height * 1.5)
|
max_height: Maximum height (defaults to display_height * 1.5)
|
||||||
|
scale: User's size multiplier for this image, from
|
||||||
|
``customization.layout.<element>.scale``. 1.0 is untouched and
|
||||||
|
takes exactly the path it always did. Callers hold the config,
|
||||||
|
so they resolve the element name; this only applies the number.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image object or None if loading fails
|
PIL Image object or None if loading fails
|
||||||
@@ -85,6 +143,12 @@ class LogoHelper:
|
|||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * 1.5)
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * 1.5)
|
||||||
|
scale = _usable_scale(scale)
|
||||||
|
if scale != 1.0:
|
||||||
|
max_width = max(1, int(round(max_width * scale)))
|
||||||
|
max_height = max(1, int(round(max_height * scale)))
|
||||||
|
# The key carries the scaled box, so two elements scaled differently
|
||||||
|
# cannot be served each other's image.
|
||||||
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
||||||
if cache_key in self._logo_cache:
|
if cache_key in self._logo_cache:
|
||||||
self.logger.debug(f"Using cached logo for {team_abbr}")
|
self.logger.debug(f"Using cached logo for {team_abbr}")
|
||||||
@@ -94,9 +158,19 @@ class LogoHelper:
|
|||||||
self._cache_order.append(cache_key)
|
self._cache_order.append(cache_key)
|
||||||
return self._logo_cache[cache_key]
|
return self._logo_cache[cache_key]
|
||||||
|
|
||||||
|
# A known-missing file: skip the stat and stay quiet until the entry
|
||||||
|
# ages out. Checked after the positive cache so a logo that has since
|
||||||
|
# been loaded always wins.
|
||||||
|
missed_at = self._missing_logos.get(cache_key)
|
||||||
|
if missed_at is not None:
|
||||||
|
if time.time() - missed_at < MISSING_LOGO_RECHECK_SECONDS:
|
||||||
|
return None
|
||||||
|
del self._missing_logos[cache_key]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logo_path = Path(logo_path)
|
logo_path = Path(logo_path)
|
||||||
if not logo_path.exists():
|
if not logo_path.exists():
|
||||||
|
self._missing_logos[cache_key] = time.time()
|
||||||
self.logger.warning(f"Logo not found for {team_abbr} at {logo_path}")
|
self.logger.warning(f"Logo not found for {team_abbr} at {logo_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -106,7 +180,8 @@ class LogoHelper:
|
|||||||
logo = logo.convert('RGBA')
|
logo = logo.convert('RGBA')
|
||||||
|
|
||||||
# Resize if needed
|
# Resize if needed
|
||||||
logo = self._resize_logo(logo, max_width, max_height)
|
logo = self._resize_logo(logo, max_width, max_height,
|
||||||
|
allow_upscale=scale > 1.0)
|
||||||
|
|
||||||
# Cache the logo
|
# Cache the logo
|
||||||
self._cache_logo(cache_key, logo)
|
self._cache_logo(cache_key, logo)
|
||||||
@@ -121,7 +196,8 @@ class LogoHelper:
|
|||||||
def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path],
|
def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path],
|
||||||
logo_url: Optional[str] = None,
|
logo_url: Optional[str] = None,
|
||||||
max_width: Optional[int] = None,
|
max_width: Optional[int] = None,
|
||||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
max_height: Optional[int] = None,
|
||||||
|
scale: float = 1.0) -> Optional[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Load logo with automatic download if missing.
|
Load logo with automatic download if missing.
|
||||||
|
|
||||||
@@ -137,22 +213,79 @@ class LogoHelper:
|
|||||||
"""
|
"""
|
||||||
logo_path = Path(logo_path)
|
logo_path = Path(logo_path)
|
||||||
|
|
||||||
# Try to load existing logo first
|
# Try to load existing logo first. A placeholder written by a previous
|
||||||
if logo_path.exists():
|
# failed download does not count: it wears the real logo's filename, so
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
# trusting the file's existence is what left teams as grey boxes.
|
||||||
|
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
|
||||||
|
return self.load_logo(team_abbr, logo_path, max_width, max_height,
|
||||||
|
scale)
|
||||||
|
|
||||||
# Download if URL provided and file doesn't exist
|
# Download if URL provided and file doesn't exist
|
||||||
if logo_url:
|
if logo_url:
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||||
self._download_logo(logo_url, logo_path)
|
self._download_logo(logo_url, logo_path)
|
||||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
# The file on disk just changed. Any cached image for it is the
|
||||||
|
# placeholder we came here to replace, and load_logo() answers
|
||||||
|
# from the cache before touching the disk -- so without this the
|
||||||
|
# real logo would not appear until the process restarted.
|
||||||
|
self._invalidate_cached_logo(team_abbr, logo_path)
|
||||||
|
return self.load_logo(team_abbr, logo_path, max_width, max_height,
|
||||||
|
scale)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||||
|
# The retry failed, so restart the back-off. The stale
|
||||||
|
# placeholder is still on disk with its old timestamp, and
|
||||||
|
# leaving it there means the next call retries immediately --
|
||||||
|
# a download attempt per call, which is what the back-off
|
||||||
|
# exists to prevent.
|
||||||
|
self._refresh_stale_placeholder(logo_path)
|
||||||
|
|
||||||
# Create placeholder if all else fails
|
# Create placeholder if all else fails
|
||||||
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
||||||
|
|
||||||
|
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
|
||||||
|
"""Drop every cached size of one logo after its file changed on disk."""
|
||||||
|
prefix = f"{team_abbr}_{logo_path}_"
|
||||||
|
for key in [k for k in self._logo_cache if k.startswith(prefix)]:
|
||||||
|
self._logo_cache.pop(key, None)
|
||||||
|
if key in self._cache_order:
|
||||||
|
self._cache_order.remove(key)
|
||||||
|
# The file exists now, so any record of it being missing is wrong --
|
||||||
|
# and load_logo() consults that record before it stats the disk, so
|
||||||
|
# leaving it would hide a logo we just downloaded.
|
||||||
|
for key in [k for k in self._missing_logos if k.startswith(prefix)]:
|
||||||
|
del self._missing_logos[key]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||||
|
"""Restart the retry back-off after a failed download attempt."""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import refresh_placeholder_timestamp
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
refresh_placeholder_timestamp(logo_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_stale_placeholder(logo_path: Path) -> bool:
|
||||||
|
"""True if the file is a placeholder old enough to be worth retrying.
|
||||||
|
|
||||||
|
Imported lazily so this module keeps working against a core build whose
|
||||||
|
logo_downloader predates placeholder marking.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.logo_downloader import (
|
||||||
|
PLACEHOLDER_RETRY_SECONDS,
|
||||||
|
is_placeholder_logo,
|
||||||
|
placeholder_age_seconds,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
if not is_placeholder_logo(logo_path):
|
||||||
|
return False
|
||||||
|
age = placeholder_age_seconds(logo_path)
|
||||||
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||||
|
|
||||||
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get possible filename variations for a team abbreviation.
|
Get possible filename variations for a team abbreviation.
|
||||||
@@ -214,6 +347,7 @@ class LogoHelper:
|
|||||||
"""Clear the logo cache."""
|
"""Clear the logo cache."""
|
||||||
self._logo_cache.clear()
|
self._logo_cache.clear()
|
||||||
self._cache_order.clear()
|
self._cache_order.clear()
|
||||||
|
self._missing_logos.clear()
|
||||||
self.logger.debug("Logo cache cleared")
|
self.logger.debug("Logo cache cleared")
|
||||||
|
|
||||||
def get_cache_stats(self) -> Dict[str, int]:
|
def get_cache_stats(self) -> Dict[str, int]:
|
||||||
@@ -226,12 +360,21 @@ class LogoHelper:
|
|||||||
return {
|
return {
|
||||||
'cached_logos': len(self._logo_cache),
|
'cached_logos': len(self._logo_cache),
|
||||||
'cache_size_limit': self.cache_size,
|
'cache_size_limit': self.cache_size,
|
||||||
'cache_usage_percent': (len(self._logo_cache) / self.cache_size) * 100
|
'cache_usage_percent': (
|
||||||
|
(len(self._logo_cache) / self.cache_size) * 100
|
||||||
|
if self.cache_size else 0
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
||||||
max_height: Optional[int] = None) -> Image.Image:
|
max_height: Optional[int] = None,
|
||||||
"""Resize logo to fit display dimensions."""
|
allow_upscale: bool = False) -> Image.Image:
|
||||||
|
"""Resize logo to fit display dimensions.
|
||||||
|
|
||||||
|
``allow_upscale`` is only set when the user asked for a scale above 1:
|
||||||
|
the fit rule is "never larger than the box", and growing an image
|
||||||
|
nobody asked to grow would change every existing render.
|
||||||
|
"""
|
||||||
if max_width is None:
|
if max_width is None:
|
||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * 1.5)
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
@@ -239,7 +382,14 @@ class LogoHelper:
|
|||||||
|
|
||||||
# Only resize if necessary
|
# Only resize if necessary
|
||||||
if logo.width <= max_width and logo.height <= max_height:
|
if logo.width <= max_width and logo.height <= max_height:
|
||||||
|
if not allow_upscale or not logo.width or not logo.height:
|
||||||
return logo
|
return logo
|
||||||
|
ratio = min(max_width / logo.width, max_height / logo.height)
|
||||||
|
if ratio <= 1:
|
||||||
|
return logo
|
||||||
|
return logo.resize((max(1, int(logo.width * ratio)),
|
||||||
|
max(1, int(logo.height * ratio))),
|
||||||
|
Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Maintain aspect ratio
|
# Maintain aspect ratio
|
||||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||||
@@ -258,17 +408,60 @@ class LogoHelper:
|
|||||||
self._cache_order.append(cache_key)
|
self._cache_order.append(cache_key)
|
||||||
|
|
||||||
def _download_logo(self, url: str, file_path: Path) -> None:
|
def _download_logo(self, url: str, file_path: Path) -> None:
|
||||||
"""Download logo from URL."""
|
"""Download logo from URL.
|
||||||
|
|
||||||
|
The response size is capped and the saved file is verified as a
|
||||||
|
decodable image before it is left on disk: a logo URL is remote
|
||||||
|
input, and without this an oversized or malformed response would
|
||||||
|
be cached for every later load_logo() call to trip over.
|
||||||
|
|
||||||
|
The body is streamed and counted as it arrives rather than read
|
||||||
|
through response.content, which buffers the whole thing first —
|
||||||
|
a server that omits Content-Length and never stops sending would
|
||||||
|
exhaust memory before any size check could run. Nothing lands at
|
||||||
|
file_path until the download completes and decodes, so a failed
|
||||||
|
download cannot leave a truncated logo behind either.
|
||||||
|
"""
|
||||||
# Ensure directory exists with proper permissions
|
# Ensure directory exists with proper permissions
|
||||||
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
ensure_directory_permissions(file_path.parent, get_assets_dir_mode())
|
||||||
|
|
||||||
# Download with timeout
|
# A unique temp name, not a fixed "<name>.part": two plugins can
|
||||||
response = self.session.get(url, timeout=30)
|
# ask for the same logo at once, and a shared name would let them
|
||||||
|
# interleave writes into one file, publish the mixture, or delete
|
||||||
|
# each other's partial. Same directory, so os.replace stays atomic.
|
||||||
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
dir=str(file_path.parent), prefix=file_path.name + '.', suffix='.part')
|
||||||
|
tmp_path = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
# fdopen outermost so the descriptor mkstemp handed back is
|
||||||
|
# always adopted and closed, including when the request itself
|
||||||
|
# raises — load_logo_with_download swallows that, so a leak
|
||||||
|
# here would accumulate quietly on a URL that keeps failing.
|
||||||
|
with os.fdopen(fd, 'wb') as f:
|
||||||
|
with self.session.get(url, timeout=30, stream=True) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
downloaded = 0
|
||||||
|
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded > MAX_LOGO_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Logo at {url} exceeds the "
|
||||||
|
f"{MAX_LOGO_BYTES}-byte limit; not saved")
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
# Save to file
|
# Verify it decodes before it becomes the cached logo. PIL
|
||||||
with open(file_path, 'wb') as f:
|
# raises DecompressionBombError past its own pixel limit; a
|
||||||
f.write(response.content)
|
# partial or non-image response raises UnidentifiedImageError
|
||||||
|
# (an OSError subclass).
|
||||||
|
with Image.open(tmp_path) as probe:
|
||||||
|
probe.load()
|
||||||
|
|
||||||
|
os.replace(tmp_path, file_path)
|
||||||
|
except BaseException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
ensure_file_permissions(file_path, get_assets_file_mode())
|
ensure_file_permissions(file_path, get_assets_file_mode())
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""One place to turn a request-supplied name into a path you can open.
|
||||||
|
|
||||||
|
Every web handler that opens a file under a fixed directory had grown its own
|
||||||
|
version of this: a regex here, an ``os.path.basename`` there, a
|
||||||
|
``str(x).startswith(str(base))`` somewhere else. They were not equivalent.
|
||||||
|
``startswith`` says ``plugin-repos/foo-evil`` is inside ``plugin-repos/foo``;
|
||||||
|
validating a name in one place and rebuilding the path from the *raw* value in
|
||||||
|
another leaves the guard checking something the filesystem never sees.
|
||||||
|
|
||||||
|
Two functions, used the same way everywhere:
|
||||||
|
|
||||||
|
``safe_path_component(value)``
|
||||||
|
``value`` if it is one harmless path segment, otherwise ``None``.
|
||||||
|
|
||||||
|
``resolve_under(base, *parts)``
|
||||||
|
the resolved path, or ``None`` if any part is unsafe or the result would
|
||||||
|
land outside ``base``.
|
||||||
|
|
||||||
|
Both *return the sanitised value* rather than a boolean, so a caller cannot
|
||||||
|
validate one string and then open another -- and so a scanner can follow what
|
||||||
|
actually reaches ``open()``. ``os.path.basename`` does the stripping because it
|
||||||
|
is the sanitiser CodeQL's path-injection query recognises; the equality check
|
||||||
|
after it means an input with a directory part is rejected outright instead of
|
||||||
|
being silently truncated to something the caller did not ask for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, Optional, Union
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'safe_path_component',
|
||||||
|
'safe_relative_parts',
|
||||||
|
'resolve_under',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Names that are a path component syntactically but never name a real entry a
|
||||||
|
# caller means to reach.
|
||||||
|
_RESERVED_COMPONENTS = frozenset({'', '.', '..'})
|
||||||
|
|
||||||
|
|
||||||
|
def safe_path_component(value: Any) -> Optional[str]:
|
||||||
|
"""Return ``value`` when it is a single, harmless path segment.
|
||||||
|
|
||||||
|
Returns ``None`` for anything else: a non-string, an empty string, ``.`` or
|
||||||
|
``..``, a value carrying a directory separator (either platform's), a drive
|
||||||
|
letter, or an embedded NUL.
|
||||||
|
|
||||||
|
The return value is what callers must join -- not the argument.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if '\x00' in value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# basename strips any directory component, so what a caller joins cannot
|
||||||
|
# carry one. Comparing the result against the input rejects rather than
|
||||||
|
# truncates: "../etc/passwd" is an error, not a request for "passwd".
|
||||||
|
name = os.path.basename(value)
|
||||||
|
if name != value or name in _RESERVED_COMPONENTS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# basename only knows the host platform's separator. On POSIX a backslash
|
||||||
|
# is an ordinary character, and "C:" is a plausible-looking name that
|
||||||
|
# os.path.join would treat as a drive on Windows. Rule both out everywhere
|
||||||
|
# so behaviour does not depend on where the service happens to run.
|
||||||
|
if '/' in name or '\\' in name or os.sep in name or (os.altsep and os.altsep in name):
|
||||||
|
return None
|
||||||
|
if ':' in name and len(name) >= 2 and name[1] == ':':
|
||||||
|
return None
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def safe_relative_parts(value: Any) -> Optional[List[str]]:
|
||||||
|
"""Split a multi-segment relative path into safe components.
|
||||||
|
|
||||||
|
For Flask's ``<path:...>`` converter, where ``a/b/c.json`` is legitimate but
|
||||||
|
``../../config/config_secrets.json`` is not. Returns the component list, or
|
||||||
|
``None`` if any component fails :func:`safe_path_component`.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if value.startswith('/') or value.startswith('\\'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts: List[str] = []
|
||||||
|
for raw in value.replace('\\', '/').split('/'):
|
||||||
|
if raw == '':
|
||||||
|
# A trailing or doubled slash names nothing; skip it rather than
|
||||||
|
# rejecting a path a browser may well send.
|
||||||
|
continue
|
||||||
|
part = safe_path_component(raw)
|
||||||
|
if part is None:
|
||||||
|
return None
|
||||||
|
parts.append(part)
|
||||||
|
|
||||||
|
return parts or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_under(base: Union[str, Path], *parts: Any) -> Optional[Path]:
|
||||||
|
"""Resolve ``base/parts...``, or ``None`` if that would escape ``base``.
|
||||||
|
|
||||||
|
Each part is validated with :func:`safe_path_component` first, so the value
|
||||||
|
that reaches the filesystem is the sanitised one. The containment check is
|
||||||
|
kept as well: it is what catches a symlink inside ``base`` pointing out of
|
||||||
|
it, which no amount of name validation can see.
|
||||||
|
"""
|
||||||
|
safe_parts: List[str] = []
|
||||||
|
for part in parts:
|
||||||
|
component = safe_path_component(part)
|
||||||
|
if component is None:
|
||||||
|
return None
|
||||||
|
safe_parts.append(component)
|
||||||
|
|
||||||
|
try:
|
||||||
|
base_resolved = Path(base).resolve()
|
||||||
|
candidate = base_resolved.joinpath(*safe_parts).resolve()
|
||||||
|
candidate.relative_to(base_resolved)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return candidate
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
"""One place that turns plugin config into a configured ScrollHelper.
|
||||||
|
|
||||||
|
Five ticker plugins each hand-rolled this resolution (odds-ticker, news and
|
||||||
|
ledmatrix-leaderboard reference the deprecated ``scroll_pixels_per_second``
|
||||||
|
key 16-18 times apiece), and they disagreed in ways that were invisible until
|
||||||
|
someone watched the panel:
|
||||||
|
|
||||||
|
* odds-ticker read ``scroll_pixels_per_second`` on the *recommended* config
|
||||||
|
path and let it override ``scroll_speed``/``scroll_delay``. Because that key
|
||||||
|
carries a schema default, the documented settings were dead for every user
|
||||||
|
-- see ChuckBuilds/ledmatrix-plugins#408.
|
||||||
|
* ledmatrix-leaderboard read the same key only as a fallback, so identical
|
||||||
|
config produced different speeds in the two plugins.
|
||||||
|
* stock-news derived px/frame from it via its own arithmetic.
|
||||||
|
|
||||||
|
What matters on the hardware
|
||||||
|
----------------------------
|
||||||
|
Motion is smooth when the strip advances a **whole number of pixels per panel
|
||||||
|
refresh**. On a 100Hz panel that means 100 px/s, 200 px/s, and so on. Anything
|
||||||
|
else has to either blend adjacent columns (which on pixel-font text reads as
|
||||||
|
shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
|
||||||
|
the requested speed will not divide evenly, because that is a real display
|
||||||
|
artefact and not a rounding detail.
|
||||||
|
|
||||||
|
Speed is always expressed to the helper as pixels per second and applied in
|
||||||
|
time-based mode. Frame-based stepping gates motion on a wall clock at
|
||||||
|
``1/scroll_delay`` steps per second; plugins set ``scroll_delay`` to the frame
|
||||||
|
period, which puts that comparison exactly on its own threshold and makes the
|
||||||
|
step count flip on sub-millisecond jitter. Accumulating elapsed time keeps
|
||||||
|
position proportional to real time instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Speed used when a plugin supplies nothing usable. One pixel per refresh on a
|
||||||
|
#: 100Hz panel, which is the slowest crisp scroll that hardware can show.
|
||||||
|
DEFAULT_PIXELS_PER_SECOND = 100.0
|
||||||
|
|
||||||
|
#: Bounds accepted from config. Below the floor a marquee appears frozen;
|
||||||
|
#: above the ceiling it outruns any panel's refresh and tears.
|
||||||
|
MIN_PIXELS_PER_SECOND = 1.0
|
||||||
|
MAX_PIXELS_PER_SECOND = 500.0
|
||||||
|
|
||||||
|
#: Assumed refresh when the caller does not say. Matches the usual
|
||||||
|
#: ``display.hardware.limit_refresh_rate_hz``.
|
||||||
|
DEFAULT_REFRESH_HZ = 100.0
|
||||||
|
|
||||||
|
#: How far px/s may sit from a whole number of pixels per refresh before it is
|
||||||
|
#: worth warning about. 0.05px per frame is invisible; a third of a pixel is not.
|
||||||
|
_WHOLE_PIXEL_TOLERANCE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
#: Longest a frame may be held before motion reads as a slideshow rather than
|
||||||
|
#: a scroll. 6 refreshes at 100Hz is ~17px/s, already visibly stepped.
|
||||||
|
MAX_FRAME_HOLD = 8
|
||||||
|
|
||||||
|
#: Largest whole-pixel jump per presented frame before motion looks like it is
|
||||||
|
#: teleporting rather than sliding.
|
||||||
|
MAX_PIXELS_PER_FRAME = 6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CrispSpeed:
|
||||||
|
"""A speed the panel can show with whole-pixel motion.
|
||||||
|
|
||||||
|
``pixels_per_second`` is always ``refresh_hz / frame_hold * pixels_per_frame``
|
||||||
|
exactly -- no rounding, no fractional pixel positions, so nothing has to be
|
||||||
|
blended or repeated unevenly.
|
||||||
|
|
||||||
|
:param frame_hold: refreshes each frame is held for. This is rgbmatrix's
|
||||||
|
``SwapOnVSync(canvas, framerate_fraction)``. The panel keeps refreshing
|
||||||
|
at full rate either way, so holding a frame costs nothing in flicker.
|
||||||
|
:param pixels_per_frame: whole pixels advanced per presented frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pixels_per_second: float
|
||||||
|
frame_hold: int
|
||||||
|
pixels_per_frame: int
|
||||||
|
refresh_hz: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frames_per_second(self) -> float:
|
||||||
|
"""Distinct frames per second: the refresh divided by the hold."""
|
||||||
|
return self.refresh_hz / self.frame_hold
|
||||||
|
|
||||||
|
@property
|
||||||
|
def steppiness(self) -> str:
|
||||||
|
"""Rough readability hint for this combination."""
|
||||||
|
if self.pixels_per_frame > 2:
|
||||||
|
return "jumpy"
|
||||||
|
if self.frames_per_second < 20:
|
||||||
|
return "stepped"
|
||||||
|
if self.frames_per_second < 30:
|
||||||
|
return "slightly stepped"
|
||||||
|
return "smooth"
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
"""This speed as a line for the speed ladder, aligned for a column."""
|
||||||
|
return (
|
||||||
|
f"{self.pixels_per_second:6.1f} px/s "
|
||||||
|
f"({self.pixels_per_frame}px every {self.frame_hold} refresh"
|
||||||
|
f"{'es' if self.frame_hold != 1 else ' '} = "
|
||||||
|
f"{self.frames_per_second:5.1f} fps, {self.steppiness})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def crisp_ladder(
|
||||||
|
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
||||||
|
max_frame_hold: int = MAX_FRAME_HOLD,
|
||||||
|
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
||||||
|
):
|
||||||
|
"""Every whole-pixel speed this panel can show, slowest first.
|
||||||
|
|
||||||
|
Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable
|
||||||
|
as 1px every refresh or 2px every 2nd refresh, and the former moves in
|
||||||
|
smaller increments, so that is the one worth offering.
|
||||||
|
"""
|
||||||
|
best = {}
|
||||||
|
for hold in range(1, max_frame_hold + 1):
|
||||||
|
for ppf in range(1, max_pixels_per_frame + 1):
|
||||||
|
pps = refresh_hz / hold * ppf
|
||||||
|
key = round(pps, 3)
|
||||||
|
candidate = CrispSpeed(pps, hold, ppf, refresh_hz)
|
||||||
|
incumbent = best.get(key)
|
||||||
|
if incumbent is None or ppf < incumbent.pixels_per_frame:
|
||||||
|
best[key] = candidate
|
||||||
|
return [best[k] for k in sorted(best)]
|
||||||
|
|
||||||
|
|
||||||
|
#: How much a bigger pixel step costs, as a fraction of the target speed.
|
||||||
|
#: Tuned so 66.7px/s (2px at 33fps) beats 50px/s (1px at 50fps) when 60 was
|
||||||
|
#: asked for, but 33.3px/s (1px, smooth) still beats 28.6px/s (2px at 14fps)
|
||||||
|
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
|
||||||
|
_STEP_PENALTY = 0.05
|
||||||
|
_SLOW_FPS_PENALTY = 0.25 # below 20fps
|
||||||
|
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
||||||
|
"""Lower is better. Numeric closeness alone picks bad-looking speeds.
|
||||||
|
|
||||||
|
Nearest-by-value would answer "30 px/s" with 28.6 px/s -- which is 2px
|
||||||
|
jumps at 14fps -- over 33.3 px/s, which is single-pixel motion at 33fps and
|
||||||
|
obviously better on the panel. Proximity has to be traded against how the
|
||||||
|
motion actually reads.
|
||||||
|
"""
|
||||||
|
error = abs(candidate.pixels_per_second - target) / max(target, 1e-6)
|
||||||
|
cost = error + _STEP_PENALTY * (candidate.pixels_per_frame - 1)
|
||||||
|
fps = candidate.frames_per_second
|
||||||
|
if fps < 20:
|
||||||
|
cost += _SLOW_FPS_PENALTY
|
||||||
|
elif fps < 25:
|
||||||
|
cost += _LOWISH_FPS_PENALTY
|
||||||
|
return cost
|
||||||
|
|
||||||
|
|
||||||
|
def solve_crisp(
|
||||||
|
target_pixels_per_second: float,
|
||||||
|
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
||||||
|
max_frame_hold: int = MAX_FRAME_HOLD,
|
||||||
|
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
||||||
|
) -> CrispSpeed:
|
||||||
|
"""The whole-pixel speed that will look best for what was asked for.
|
||||||
|
|
||||||
|
Not simply the nearest -- see :func:`_quality_cost`. Ties break toward the
|
||||||
|
smaller pixel step and the shorter hold.
|
||||||
|
"""
|
||||||
|
ladder = crisp_ladder(refresh_hz, max_frame_hold, max_pixels_per_frame)
|
||||||
|
# Clamp into the ladder's range first. Relative error saturates near 1.0
|
||||||
|
# for a target far outside it, so the quality penalty would dominate and
|
||||||
|
# answer "10000 px/s" with the *slowest* entry -- smooth, and useless.
|
||||||
|
target = min(max(target_pixels_per_second, ladder[0].pixels_per_second),
|
||||||
|
ladder[-1].pixels_per_second)
|
||||||
|
return min(
|
||||||
|
ladder,
|
||||||
|
key=lambda c: (round(_quality_cost(c, target), 6),
|
||||||
|
c.pixels_per_frame, c.frame_hold),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScrollSettings:
|
||||||
|
"""The resolved outcome, and which config key produced it."""
|
||||||
|
|
||||||
|
pixels_per_second: float
|
||||||
|
source: str
|
||||||
|
target_fps: Optional[float] = None
|
||||||
|
pixels_per_frame: Optional[float] = None
|
||||||
|
warning: Optional[str] = None
|
||||||
|
#: The whole-pixel speed actually applied, when snapping was enabled.
|
||||||
|
crisp: Optional[CrispSpeed] = None
|
||||||
|
#: What the config asked for, before snapping.
|
||||||
|
requested_pixels_per_second: Optional[float] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_hold(self) -> int:
|
||||||
|
"""Refreshes to hold each frame for; pass to set_scrolling_state()."""
|
||||||
|
return self.crisp.frame_hold if self.crisp else 1
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
"""One log line: the speed applied, and which config key produced it."""
|
||||||
|
text = f"{self.pixels_per_second:.1f} px/s (from {self.source})"
|
||||||
|
if self.pixels_per_frame is not None:
|
||||||
|
text += f" = {self.pixels_per_frame:.2f} px/frame"
|
||||||
|
if self.target_fps:
|
||||||
|
text += f" at {self.target_fps:.0f} fps"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(value: Any) -> Optional[float]:
|
||||||
|
"""A positive float, or None. Config reaches us with nulls and strings."""
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _from_speed_and_delay(block: Any) -> Optional[float]:
|
||||||
|
"""px/s from a ``scroll_speed`` (px/frame) + ``scroll_delay`` (s) pair."""
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return None
|
||||||
|
speed = _coerce(block.get("scroll_speed"))
|
||||||
|
delay = _coerce(block.get("scroll_delay"))
|
||||||
|
if speed is None or delay is None:
|
||||||
|
return None
|
||||||
|
return speed / delay
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
plugin_config: Optional[Dict[str, Any]] = None,
|
||||||
|
global_config: Optional[Dict[str, Any]] = None,
|
||||||
|
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
||||||
|
refresh_hz: Optional[float] = None,
|
||||||
|
) -> ScrollSettings:
|
||||||
|
"""Resolve one scroll speed from the several shapes plugins accept.
|
||||||
|
|
||||||
|
Precedence, highest first. The deprecated flat key sits *below* the
|
||||||
|
explicit pairs deliberately: it carries schema defaults in some plugins, so
|
||||||
|
ranking it above them silently disables the documented settings.
|
||||||
|
|
||||||
|
1. ``display_options.scroll_speed`` + ``scroll_delay`` (current)
|
||||||
|
2. ``display.scroll_speed`` + ``scroll_delay`` (deprecated shape)
|
||||||
|
3. ``scroll_speed`` + ``scroll_delay`` at the root (legacy flat)
|
||||||
|
4. ``scroll_pixels_per_second``, nested or flat (deprecated)
|
||||||
|
5. the global ``display`` block
|
||||||
|
6. ``default_pixels_per_second``
|
||||||
|
|
||||||
|
:param refresh_hz: panel refresh, used only to check whether the resolved
|
||||||
|
speed lands on whole pixels per frame and to fill in ``target_fps``.
|
||||||
|
"""
|
||||||
|
plugin_config = plugin_config or {}
|
||||||
|
global_config = global_config or {}
|
||||||
|
refresh = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
|
||||||
|
|
||||||
|
display_options = plugin_config.get("display_options")
|
||||||
|
display_block = plugin_config.get("display")
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
(_from_speed_and_delay(display_options), "display_options.scroll_speed/delay"),
|
||||||
|
(_from_speed_and_delay(display_block), "display.scroll_speed/delay"),
|
||||||
|
(_from_speed_and_delay(plugin_config), "scroll_speed/delay (root)"),
|
||||||
|
]
|
||||||
|
for block, label in (
|
||||||
|
(display_options, "display_options.scroll_pixels_per_second"),
|
||||||
|
(display_block, "display.scroll_pixels_per_second"),
|
||||||
|
(plugin_config, "scroll_pixels_per_second"),
|
||||||
|
):
|
||||||
|
if isinstance(block, dict):
|
||||||
|
candidates.append((_coerce(block.get("scroll_pixels_per_second")), label))
|
||||||
|
|
||||||
|
global_display = global_config.get("display")
|
||||||
|
candidates.append((_from_speed_and_delay(global_display), "global display.scroll_speed/delay"))
|
||||||
|
|
||||||
|
pixels_per_second = None
|
||||||
|
source = "default"
|
||||||
|
for value, label in candidates:
|
||||||
|
if value is not None:
|
||||||
|
pixels_per_second, source = value, label
|
||||||
|
break
|
||||||
|
if pixels_per_second is None:
|
||||||
|
pixels_per_second = default_pixels_per_second
|
||||||
|
|
||||||
|
clamped = max(MIN_PIXELS_PER_SECOND, min(MAX_PIXELS_PER_SECOND, pixels_per_second))
|
||||||
|
warning = None
|
||||||
|
if clamped != pixels_per_second:
|
||||||
|
warning = (
|
||||||
|
f"scroll speed {pixels_per_second:.1f} px/s out of range, "
|
||||||
|
f"clamped to {clamped:.1f}"
|
||||||
|
)
|
||||||
|
pixels_per_second = clamped
|
||||||
|
|
||||||
|
pixels_per_frame = pixels_per_second / refresh if refresh > 0 else None
|
||||||
|
if warning is None and pixels_per_frame is not None:
|
||||||
|
offset = abs(pixels_per_frame - round(pixels_per_frame))
|
||||||
|
if pixels_per_frame < 1.0 - _WHOLE_PIXEL_TOLERANCE or offset > _WHOLE_PIXEL_TOLERANCE:
|
||||||
|
suggestion = max(1.0, round(pixels_per_frame)) * refresh
|
||||||
|
warning = (
|
||||||
|
f"{pixels_per_second:.1f} px/s is {pixels_per_frame:.2f} px per "
|
||||||
|
f"refresh at {refresh:.0f}Hz, so some frames repeat and the "
|
||||||
|
f"scroll will judder; {suggestion:.0f} px/s divides evenly"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScrollSettings(
|
||||||
|
pixels_per_second=pixels_per_second,
|
||||||
|
source=source,
|
||||||
|
target_fps=refresh,
|
||||||
|
pixels_per_frame=pixels_per_frame,
|
||||||
|
warning=warning,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
scroll_helper: Any,
|
||||||
|
plugin_config: Optional[Dict[str, Any]] = None,
|
||||||
|
global_config: Optional[Dict[str, Any]] = None,
|
||||||
|
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
||||||
|
refresh_hz: Optional[float] = None,
|
||||||
|
plugin_logger: Optional[logging.Logger] = None,
|
||||||
|
display_manager: Any = None,
|
||||||
|
snap_to_crisp: bool = True,
|
||||||
|
) -> ScrollSettings:
|
||||||
|
"""Resolve the config and apply it to ``scroll_helper``.
|
||||||
|
|
||||||
|
Applied in time-based mode: see the module docstring for why frame-based
|
||||||
|
stepping is not used. ``hasattr`` guards keep this usable against older
|
||||||
|
ScrollHelper builds that a plugin may be running on.
|
||||||
|
|
||||||
|
:param display_manager: consulted for the panel's refresh rate only (it can
|
||||||
|
see display.hardware; a plugin cannot). The frame hold is NOT applied
|
||||||
|
here -- see the note in the body. The caller must pass
|
||||||
|
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
|
||||||
|
...)`` when it starts scrolling, or a sub-refresh speed still presents
|
||||||
|
a new frame every refresh and the motion falls back to fractional
|
||||||
|
pixels.
|
||||||
|
:param snap_to_crisp: move the requested speed to the nearest speed the
|
||||||
|
panel can show in whole pixels. On by default because a speed that does
|
||||||
|
not divide evenly has no good rendering, only a choice of artefacts.
|
||||||
|
|
||||||
|
:returns: the settings applied, so the caller can log or assert on them.
|
||||||
|
"""
|
||||||
|
log = plugin_logger or logger
|
||||||
|
|
||||||
|
# Refresh rate, most authoritative first: what the caller passed, then the
|
||||||
|
# display manager (which can see display.hardware; a plugin cannot), then
|
||||||
|
# the global config, then the default.
|
||||||
|
#
|
||||||
|
# This has to be settled BEFORE resolve(), not after. resolve() uses the
|
||||||
|
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
|
||||||
|
# so deriving it afterwards described a 100Hz panel to everyone running at
|
||||||
|
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
|
||||||
|
# set_target_fps() paced the helper to 100 FPS on a 60Hz panel.
|
||||||
|
hz = _coerce(refresh_hz)
|
||||||
|
if hz is None and display_manager is not None:
|
||||||
|
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
||||||
|
if hz is None:
|
||||||
|
hz = refresh_hz_from_config(global_config)
|
||||||
|
|
||||||
|
settings = resolve(
|
||||||
|
plugin_config,
|
||||||
|
global_config,
|
||||||
|
default_pixels_per_second=default_pixels_per_second,
|
||||||
|
refresh_hz=hz,
|
||||||
|
)
|
||||||
|
applied = settings.pixels_per_second
|
||||||
|
choice = None
|
||||||
|
|
||||||
|
if snap_to_crisp:
|
||||||
|
choice = solve_crisp(settings.pixels_per_second, hz)
|
||||||
|
applied = choice.pixels_per_second
|
||||||
|
settings = replace(
|
||||||
|
settings,
|
||||||
|
pixels_per_second=applied,
|
||||||
|
requested_pixels_per_second=settings.pixels_per_second,
|
||||||
|
crisp=choice,
|
||||||
|
pixels_per_frame=float(choice.pixels_per_frame),
|
||||||
|
# Snapping resolves the whole-pixel problem the warning describes.
|
||||||
|
warning=None if settings.warning and "judder" in settings.warning
|
||||||
|
else settings.warning,
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(scroll_helper, "set_frame_based_scrolling"):
|
||||||
|
scroll_helper.set_frame_based_scrolling(False)
|
||||||
|
scroll_helper.set_scroll_speed(applied)
|
||||||
|
|
||||||
|
# A crisp speed is a whole number of pixels per presented frame, so step by
|
||||||
|
# that number rather than by speed * elapsed time. Snapping alone only
|
||||||
|
# fixes the average: the wall clock puts the accumulator back on an integer
|
||||||
|
# boundary every frame, where jitter of a fraction of a millisecond decides
|
||||||
|
# whether the pixel moves. That is what the ladder was bought to prevent.
|
||||||
|
if hasattr(scroll_helper, "set_pixels_per_frame"):
|
||||||
|
scroll_helper.set_pixels_per_frame(
|
||||||
|
choice.pixels_per_frame if choice else None)
|
||||||
|
if choice and hasattr(scroll_helper, "set_target_fps"):
|
||||||
|
scroll_helper.set_target_fps(choice.frames_per_second)
|
||||||
|
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
|
||||||
|
scroll_helper.set_target_fps(settings.target_fps)
|
||||||
|
|
||||||
|
# Deliberately NOT applied here. The hold belongs to a scroll, not to a
|
||||||
|
# plugin's lifetime: plugins share one display manager, and one left set at
|
||||||
|
# construction is reset the moment any other plugin finishes scrolling.
|
||||||
|
# Callers pass settings.frame_hold to set_scrolling_state(True, ...) when
|
||||||
|
# they start scrolling. configure() only reports what is needed.
|
||||||
|
|
||||||
|
if choice:
|
||||||
|
requested = settings.requested_pixels_per_second
|
||||||
|
if abs(requested - applied) > 0.05:
|
||||||
|
log.info(
|
||||||
|
"Scroll configured: %s (asked for %.1f px/s from %s; "
|
||||||
|
"nearest whole-pixel speed on a %.0fHz panel)",
|
||||||
|
choice.describe(), requested, settings.source, hz,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info("Scroll configured: %s (from %s)",
|
||||||
|
choice.describe(), settings.source)
|
||||||
|
if choice.frame_hold > 1:
|
||||||
|
log.debug(
|
||||||
|
"Scroll needs a frame hold of %d - pass settings.frame_hold to "
|
||||||
|
"display_manager.set_scrolling_state(True, ...) each scroll",
|
||||||
|
choice.frame_hold,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info("Scroll configured: %s", settings.describe())
|
||||||
|
|
||||||
|
if settings.warning:
|
||||||
|
log.warning("Scroll speed: %s", settings.warning)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
|
||||||
|
"""The panel's refresh cap from the global config, or the default."""
|
||||||
|
if not isinstance(global_config, dict):
|
||||||
|
return DEFAULT_REFRESH_HZ
|
||||||
|
# Each level is checked for being a mapping rather than merely truthy: a
|
||||||
|
# malformed config where display or display.hardware is a string or a list
|
||||||
|
# raised AttributeError out of what is meant to be a total function with a
|
||||||
|
# default, taking down every caller that asked for the refresh rate.
|
||||||
|
display = global_config.get("display")
|
||||||
|
if not isinstance(display, dict):
|
||||||
|
return DEFAULT_REFRESH_HZ
|
||||||
|
hardware = display.get("hardware")
|
||||||
|
if not isinstance(hardware, dict):
|
||||||
|
return DEFAULT_REFRESH_HZ
|
||||||
|
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
||||||
+195
-189
@@ -16,6 +16,7 @@ Features:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -29,6 +30,55 @@ except ImportError:
|
|||||||
HAS_SCIPY = False
|
HAS_SCIPY = False
|
||||||
|
|
||||||
|
|
||||||
|
# How often the frame-stats line is emitted, and therefore also the ceiling
|
||||||
|
# on a believable frame time: a scroll that renders at all cannot take this
|
||||||
|
# long over one frame, so a sample this large is an idle gap between scrolls.
|
||||||
|
FPS_LOG_INTERVAL = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def frame_stats(frame_times: list) -> Dict[str, Any]:
|
||||||
|
"""Summary statistics over one window of frame durations (seconds).
|
||||||
|
|
||||||
|
Split out of log_frame_rate() so the arithmetic can be tested without a
|
||||||
|
clock. Median and p95 are the real ones: the median takes both middle
|
||||||
|
samples on an even window, and p95 is nearest-rank, so a 100-frame window
|
||||||
|
reports the 95th sorted sample rather than the 96th. That matters twice
|
||||||
|
over, because the median is also the threshold the stall and skip counts
|
||||||
|
are measured against.
|
||||||
|
"""
|
||||||
|
window = sorted(frame_times)
|
||||||
|
n = len(window)
|
||||||
|
median = (window[n // 2] if n % 2
|
||||||
|
else (window[n // 2 - 1] + window[n // 2]) / 2.0)
|
||||||
|
mean = sum(window) / n
|
||||||
|
# Anything past 1.5x the median missed a panel refresh; anything under
|
||||||
|
# half of it never reached the panel at all (dirty tracking skipped the
|
||||||
|
# swap, so the frame did not wait for vsync).
|
||||||
|
return {
|
||||||
|
"frames": n,
|
||||||
|
"fps": (1.0 / mean) if mean > 0 else 0.0,
|
||||||
|
"median": median,
|
||||||
|
"p95": window[max(0, math.ceil(0.95 * n) - 1)],
|
||||||
|
"max": window[-1],
|
||||||
|
"min": window[0],
|
||||||
|
"stalls": sum(1 for f in window if f > median * 1.5),
|
||||||
|
"skips": sum(1 for f in window if f < median * 0.5),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_frame_stats(frame_times: list) -> str:
|
||||||
|
"""The one-line rendering of frame_stats(), in milliseconds."""
|
||||||
|
s = frame_stats(frame_times)
|
||||||
|
n = s["frames"]
|
||||||
|
return (
|
||||||
|
f"{s['fps']:.1f} fps over {n} frames | "
|
||||||
|
f"median {s['median'] * 1000:.2f}ms p95 {s['p95'] * 1000:.2f}ms "
|
||||||
|
f"max {s['max'] * 1000:.2f}ms min {s['min'] * 1000:.2f}ms | "
|
||||||
|
f"stalls {s['stalls']} ({100.0 * s['stalls'] / n:.1f}%) "
|
||||||
|
f"skips {s['skips']} ({100.0 * s['skips'] / n:.1f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScrollHelper:
|
class ScrollHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for scrolling text and image content on LED displays.
|
Helper class for scrolling text and image content on LED displays.
|
||||||
@@ -75,12 +125,26 @@ class ScrollHelper:
|
|||||||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||||||
self._frame_buffer: Optional[np.ndarray] = None
|
self._frame_buffer: Optional[np.ndarray] = None
|
||||||
|
|
||||||
# Sub-pixel scrolling settings (disabled - using high FPS integer scrolling instead)
|
# Sub-pixel scrolling: OFF by default, and that is deliberate.
|
||||||
self.sub_pixel_scrolling = False # Disabled - use high frame rate for smoothness
|
# Blending renders a half-step by mixing two adjacent columns 50/50.
|
||||||
|
# On a high-resolution screen that reads as smooth motion; on a coarse
|
||||||
|
# LED matrix showing pixel-font text it does not. A one-pixel stroke
|
||||||
|
# becomes two half-brightness pixels, so frames alternate between crisp
|
||||||
|
# and smeared and the text appears to shimmer and jump a pixel ahead --
|
||||||
|
# tested on a 2x128x64 panel and clearly worse than integer stepping.
|
||||||
|
#
|
||||||
|
# The rule this display obeys: motion is smooth when it advances a
|
||||||
|
# whole number of pixels per refresh. Anything slower must either
|
||||||
|
# blend (blur) or repeat frames (judder); blending is the worse of the
|
||||||
|
# two here. Vegas mode still opts in via set_sub_pixel_scrolling().
|
||||||
|
self.sub_pixel_scrolling = False
|
||||||
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
||||||
|
|
||||||
# Frame-based scrolling settings
|
# Frame-based scrolling settings
|
||||||
self.frame_based_scrolling = False # If True, use scroll_delay to throttle and move scroll_speed pixels
|
self.frame_based_scrolling = False
|
||||||
|
#: Whole pixels to advance per presented frame, or None to pace
|
||||||
|
#: off elapsed time. See set_pixels_per_frame.
|
||||||
|
self.fixed_pixels_per_frame = None # If True, use scroll_delay to throttle and move scroll_speed pixels
|
||||||
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
||||||
|
|
||||||
# Time tracking for scroll updates
|
# Time tracking for scroll updates
|
||||||
@@ -100,11 +164,16 @@ class ScrollHelper:
|
|||||||
self.last_progress_log_time: Optional[float] = None
|
self.last_progress_log_time: Optional[float] = None
|
||||||
self.progress_log_interval = 5.0 # seconds
|
self.progress_log_interval = 5.0 # seconds
|
||||||
|
|
||||||
# Frame rate tracking
|
# Frame rate tracking. last_frame_time is None until the first frame
|
||||||
|
# of a scroll is rendered -- see log_frame_rate() for why timing from
|
||||||
|
# construction (or from the end of the previous scroll) is wrong.
|
||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
self.last_frame_time = time.time()
|
self.last_frame_time: Optional[float] = None
|
||||||
self.last_fps_log_time = time.time()
|
self.last_fps_log_time = time.time()
|
||||||
self.frame_times = []
|
self.frame_times = []
|
||||||
|
# Every frame time since the last stats line, so the 5s summary can
|
||||||
|
# report the tail rather than one arbitrary sample. Cleared on log.
|
||||||
|
self._window: list = []
|
||||||
|
|
||||||
# Scrolling state management
|
# Scrolling state management
|
||||||
self.is_scrolling = False
|
self.is_scrolling = False
|
||||||
@@ -237,26 +306,44 @@ class ScrollHelper:
|
|||||||
self.last_progress_log_time = current_time
|
self.last_progress_log_time = current_time
|
||||||
|
|
||||||
# Update scroll position
|
# Update scroll position
|
||||||
if self.frame_based_scrolling:
|
if self.fixed_pixels_per_frame:
|
||||||
|
# One presented frame, one fixed whole-pixel step. No clock is
|
||||||
|
# consulted, so no jitter reaches the position and every frame
|
||||||
|
# moves the eye by the same amount. See set_pixels_per_frame.
|
||||||
|
pixels_to_move = self.fixed_pixels_per_frame
|
||||||
|
self.last_step_time = current_time
|
||||||
|
elif self.frame_based_scrolling:
|
||||||
# Frame-based: move fixed amount when scroll_delay has passed
|
# Frame-based: move fixed amount when scroll_delay has passed
|
||||||
# This matches stock ticker behavior: move pixels, then wait scroll_delay
|
# This matches stock ticker behavior: move pixels, then wait scroll_delay
|
||||||
# Initialize last_step_time on first call to prevent huge initial jump
|
# Initialize last_step_time on first call to prevent huge initial jump
|
||||||
if self.last_step_time == 0.0:
|
if self.last_step_time == 0.0:
|
||||||
self.last_step_time = current_time
|
self.last_step_time = current_time
|
||||||
|
|
||||||
# Check if scroll_delay has passed
|
# Frame-based mode advances by elapsed time, exactly like the
|
||||||
time_since_last_step = current_time - self.last_step_time
|
# time-based branch below, at the same configured speed
|
||||||
if time_since_last_step >= self.scroll_delay:
|
# (scroll_speed px per scroll_delay seconds).
|
||||||
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
|
#
|
||||||
steps = int(time_since_last_step / self.scroll_delay)
|
# It used to step discretely: 0, 1 or 2 whole pixels depending on
|
||||||
# Cap at reasonable number to prevent huge jumps from lag
|
# whether a wall clock had passed scroll_delay. Plugins set
|
||||||
max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
|
# scroll_delay to the target frame period, so that comparison sits
|
||||||
steps = min(steps, max_steps)
|
# exactly on its own threshold and the decision flips on sub-
|
||||||
pixels_to_move = self.scroll_speed * steps
|
# millisecond jitter -- a frame a hair early moved nothing and
|
||||||
# Update last_step_time, preserving fractional delay for smooth timing
|
# rendered an identical frame, a frame a hair late moved two
|
||||||
self.last_step_time = current_time - (time_since_last_step % self.scroll_delay)
|
# pixels. Rounding the step count fixed the stalls but still
|
||||||
|
# discarded the remainder, so the error never corrected.
|
||||||
|
#
|
||||||
|
# Accumulating elapsed time keeps position exactly proportional to
|
||||||
|
# real time: jitter shifts a pixel boundary by a fraction of a
|
||||||
|
# frame instead of flipping a whole step, and nothing is lost or
|
||||||
|
# gained. This is what the one visibly smooth scroller on the
|
||||||
|
# hardware (the stock ticker) was already doing by virtue of never
|
||||||
|
# enabling frame-based mode.
|
||||||
|
if self.scroll_delay > 0:
|
||||||
|
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||||
else:
|
else:
|
||||||
pixels_to_move = 0.0
|
pixels_per_second = self.scroll_speed * 100.0
|
||||||
|
pixels_to_move = pixels_per_second * delta_time
|
||||||
|
self.last_step_time = current_time
|
||||||
else:
|
else:
|
||||||
# Time-based: move based on time delta (correct speed over time)
|
# Time-based: move based on time delta (correct speed over time)
|
||||||
# scroll_speed is pixels per second
|
# scroll_speed is pixels per second
|
||||||
@@ -328,7 +415,11 @@ class ScrollHelper:
|
|||||||
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
||||||
# The image already includes display_width padding, so we only need total_scroll_width
|
# The image already includes display_width padding, so we only need total_scroll_width
|
||||||
required_total_distance = self.total_scroll_width
|
required_total_distance = self.total_scroll_width
|
||||||
self.logger.info(
|
# Progress telemetry, emitted every few seconds for the whole of
|
||||||
|
# every scroll. It says how far along a marquee is, which is what
|
||||||
|
# you turn debug on to watch and not something an operator needs
|
||||||
|
# in the journal on a device that scrolls all day.
|
||||||
|
self.logger.debug(
|
||||||
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
||||||
elapsed_time,
|
elapsed_time,
|
||||||
self.calculated_duration,
|
self.calculated_duration,
|
||||||
@@ -451,168 +542,6 @@ class ScrollHelper:
|
|||||||
|
|
||||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||||
|
|
||||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Get visible portion with sub-pixel interpolation for smooth scrolling.
|
|
||||||
Uses bilinear interpolation to blend between pixels.
|
|
||||||
"""
|
|
||||||
# We need to extract a region that's 1 pixel wider to allow for interpolation
|
|
||||||
start_x = start_x_int
|
|
||||||
end_x = start_x_int + self.display_width + 1
|
|
||||||
|
|
||||||
# Check if we need wrap-around
|
|
||||||
if end_x <= self.cached_image.width:
|
|
||||||
# Normal case: extract region with 1 extra pixel for interpolation
|
|
||||||
source_region = self.cached_array[:, start_x:end_x]
|
|
||||||
|
|
||||||
# Use bilinear interpolation for sub-pixel shifting
|
|
||||||
if HAS_SCIPY:
|
|
||||||
# Use scipy for high-quality sub-pixel shifting
|
|
||||||
shifted = shift(source_region, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
|
||||||
# Extract the display_width portion
|
|
||||||
frame_array = shifted[:, :self.display_width].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
# Fallback: simple linear interpolation using numpy
|
|
||||||
# Blend between current and next pixel based on fractional part
|
|
||||||
frame_array = self._interpolate_subpixel(source_region, fractional)
|
|
||||||
|
|
||||||
return Image.fromarray(frame_array)
|
|
||||||
else:
|
|
||||||
# Wrap-around case with sub-pixel
|
|
||||||
# Use pre-allocated buffer
|
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
|
||||||
|
|
||||||
width1 = self.cached_image.width - start_x
|
|
||||||
if width1 > 0:
|
|
||||||
# First part from end of image
|
|
||||||
# Need width1 + 1 pixels for interpolation
|
|
||||||
source1_width = min(width1 + 1, self.cached_image.width - start_x)
|
|
||||||
source1 = self.cached_array[:, start_x:start_x + source1_width]
|
|
||||||
if HAS_SCIPY:
|
|
||||||
shifted1 = shift(source1, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
|
||||||
# Ensure we get exactly width1 pixels, padding if necessary
|
|
||||||
if shifted1.shape[1] >= width1:
|
|
||||||
self._frame_buffer[:, :width1] = shifted1[:, :width1].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
# Shifted array is smaller - pad with zeros or repeat last pixel
|
|
||||||
actual_width = shifted1.shape[1]
|
|
||||||
self._frame_buffer[:, :actual_width] = shifted1.astype(np.uint8)
|
|
||||||
if actual_width < width1:
|
|
||||||
# Pad with last pixel
|
|
||||||
self._frame_buffer[:, actual_width:width1] = shifted1[:, -1:].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
interpolated1 = self._interpolate_subpixel(source1, fractional, output_width=width1)
|
|
||||||
# Ensure exact width match
|
|
||||||
if interpolated1.shape[1] == width1:
|
|
||||||
self._frame_buffer[:, :width1] = interpolated1
|
|
||||||
else:
|
|
||||||
# Handle size mismatch
|
|
||||||
copy_width = min(width1, interpolated1.shape[1])
|
|
||||||
self._frame_buffer[:, :copy_width] = interpolated1[:, :copy_width]
|
|
||||||
if copy_width < width1:
|
|
||||||
self._frame_buffer[:, copy_width:width1] = interpolated1[:, -1:]
|
|
||||||
|
|
||||||
# Second part from beginning
|
|
||||||
remaining_width = self.display_width - width1
|
|
||||||
if remaining_width > 0:
|
|
||||||
source2 = self.cached_array[:, :remaining_width + 1]
|
|
||||||
if HAS_SCIPY:
|
|
||||||
shifted2 = shift(source2, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
|
||||||
# Ensure we get exactly remaining_width pixels
|
|
||||||
if shifted2.shape[1] >= remaining_width:
|
|
||||||
self._frame_buffer[:, width1:width1 + remaining_width] = shifted2[:, :remaining_width].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
# Shifted array is smaller - pad if necessary
|
|
||||||
actual_width = shifted2.shape[1]
|
|
||||||
self._frame_buffer[:, width1:width1 + actual_width] = shifted2.astype(np.uint8)
|
|
||||||
if actual_width < remaining_width:
|
|
||||||
self._frame_buffer[:, width1 + actual_width:width1 + remaining_width] = shifted2[:, -1:].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
interpolated2 = self._interpolate_subpixel(source2, fractional, output_width=remaining_width)
|
|
||||||
# Ensure exact width match
|
|
||||||
if interpolated2.shape[1] == remaining_width:
|
|
||||||
self._frame_buffer[:, width1:] = interpolated2
|
|
||||||
else:
|
|
||||||
copy_width = min(remaining_width, interpolated2.shape[1])
|
|
||||||
self._frame_buffer[:, width1:width1 + copy_width] = interpolated2[:, :copy_width]
|
|
||||||
if copy_width < remaining_width:
|
|
||||||
self._frame_buffer[:, width1 + copy_width:width1 + remaining_width] = interpolated2[:, -1:]
|
|
||||||
else:
|
|
||||||
# Edge case: wrap to beginning
|
|
||||||
source = self.cached_array[:, :self.display_width + 1]
|
|
||||||
if HAS_SCIPY:
|
|
||||||
shifted = shift(source, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
|
||||||
# Ensure we get exactly display_width pixels
|
|
||||||
if shifted.shape[1] >= self.display_width:
|
|
||||||
self._frame_buffer = shifted[:, :self.display_width].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
# Shifted array is smaller - pad if necessary
|
|
||||||
actual_width = shifted.shape[1]
|
|
||||||
self._frame_buffer[:, :actual_width] = shifted.astype(np.uint8)
|
|
||||||
if actual_width < self.display_width:
|
|
||||||
self._frame_buffer[:, actual_width:] = shifted[:, -1:].astype(np.uint8)
|
|
||||||
else:
|
|
||||||
interpolated = self._interpolate_subpixel(source, fractional, output_width=self.display_width)
|
|
||||||
# _interpolate_subpixel now always returns exact width, so this should work
|
|
||||||
self._frame_buffer = interpolated
|
|
||||||
|
|
||||||
return Image.fromarray(self._frame_buffer)
|
|
||||||
|
|
||||||
def _interpolate_subpixel(self, source: np.ndarray, fractional: float, output_width: Optional[int] = None) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
Simple linear interpolation for sub-pixel positioning.
|
|
||||||
Blends between adjacent pixels based on fractional offset.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source: Source array to interpolate (width should be at least output_width + 1)
|
|
||||||
fractional: Fractional part of scroll position (0.0-1.0)
|
|
||||||
output_width: Desired output width (defaults to display_width)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Interpolated array of shape (height, output_width, 3) - ALWAYS exactly output_width
|
|
||||||
"""
|
|
||||||
if output_width is None:
|
|
||||||
output_width = self.display_width
|
|
||||||
|
|
||||||
# Always return exactly output_width pixels, padding if necessary
|
|
||||||
result = np.zeros((source.shape[0], output_width, 3), dtype=np.uint8)
|
|
||||||
|
|
||||||
# Ensure we have enough source pixels for interpolation
|
|
||||||
if source.shape[1] < 2:
|
|
||||||
# Very small source - just copy what we have and pad
|
|
||||||
copy_width = min(source.shape[1], output_width)
|
|
||||||
result[:, :copy_width] = source[:, :copy_width].astype(np.uint8)
|
|
||||||
if copy_width < output_width:
|
|
||||||
# Pad with last pixel
|
|
||||||
result[:, copy_width:] = source[:, -1:].astype(np.uint8)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Calculate how many pixels we can actually interpolate
|
|
||||||
# Need at least 2 pixels to interpolate, so max output is source.shape[1] - 1
|
|
||||||
max_interpolated_width = source.shape[1] - 1
|
|
||||||
interpolated_width = min(output_width, max_interpolated_width)
|
|
||||||
|
|
||||||
if interpolated_width > 0:
|
|
||||||
# Extract pixels at x and x+1 for interpolation
|
|
||||||
pixels_x = source[:, :interpolated_width].astype(np.float32)
|
|
||||||
pixels_x1 = source[:, 1:interpolated_width + 1].astype(np.float32)
|
|
||||||
|
|
||||||
# Linear interpolation
|
|
||||||
interpolated = pixels_x * (1.0 - fractional) + pixels_x1 * fractional
|
|
||||||
|
|
||||||
# Clip and convert back to uint8
|
|
||||||
interpolated = np.clip(interpolated, 0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
# Copy interpolated portion to result
|
|
||||||
result[:, :interpolated_width] = interpolated
|
|
||||||
|
|
||||||
# If we need more pixels than we can interpolate, pad with last pixel
|
|
||||||
if interpolated_width < output_width:
|
|
||||||
result[:, interpolated_width:] = source[:, -1:].astype(np.uint8)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def calculate_dynamic_duration(self) -> int:
|
def calculate_dynamic_duration(self) -> int:
|
||||||
"""
|
"""
|
||||||
Calculate display duration based on content width and scroll settings.
|
Calculate display duration based on content width and scroll settings.
|
||||||
@@ -843,6 +772,10 @@ class ScrollHelper:
|
|||||||
# Reset last_update_time to prevent large delta_time on next update
|
# Reset last_update_time to prevent large delta_time on next update
|
||||||
# This ensures smooth scrolling after reset without jumping ahead
|
# This ensures smooth scrolling after reset without jumping ahead
|
||||||
self.last_update_time = now
|
self.last_update_time = now
|
||||||
|
# Same reasoning for the frame-rate clock: the first frame after a
|
||||||
|
# reset has no predecessor in this scroll, and timing it against the
|
||||||
|
# last frame of the previous one measures the idle gap between them.
|
||||||
|
self.last_frame_time = None
|
||||||
self.logger.debug("Scroll position reset")
|
self.logger.debug("Scroll position reset")
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
@@ -905,6 +838,13 @@ class ScrollHelper:
|
|||||||
Args:
|
Args:
|
||||||
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
||||||
"""
|
"""
|
||||||
|
# A speed set directly is a request to pace off that speed, so drop any
|
||||||
|
# fixed per-frame step left by an earlier configure(). scroll_config
|
||||||
|
# calls this first and set_pixels_per_frame second, so the crisp path
|
||||||
|
# is unaffected; what this protects is a legacy caller changing speed
|
||||||
|
# on a helper that scroll_config had already put in fixed-step mode,
|
||||||
|
# where the new speed would otherwise be silently ignored.
|
||||||
|
self.fixed_pixels_per_frame = None
|
||||||
if self.frame_based_scrolling:
|
if self.frame_based_scrolling:
|
||||||
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5)
|
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5)
|
||||||
# Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
|
# Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
|
||||||
@@ -925,6 +865,35 @@ class ScrollHelper:
|
|||||||
self.scroll_delay = max(0.001, min(1.0, delay))
|
self.scroll_delay = max(0.001, min(1.0, delay))
|
||||||
self.logger.debug(f"Scroll delay set to: {self.scroll_delay}")
|
self.logger.debug(f"Scroll delay set to: {self.scroll_delay}")
|
||||||
|
|
||||||
|
def set_pixels_per_frame(self, pixels) -> None:
|
||||||
|
"""Advance exactly `pixels` per presented frame, ignoring the clock.
|
||||||
|
|
||||||
|
Pass None to go back to pacing off elapsed time.
|
||||||
|
|
||||||
|
Smooth motion is not a frame-rate property. The strip has to advance
|
||||||
|
the same number of WHOLE pixels every frame, and deriving that from a
|
||||||
|
wall clock cannot deliver it: the position accumulates
|
||||||
|
``speed * delta_time`` and is then truncated to a pixel, so any jitter
|
||||||
|
in delta_time lands either side of an integer boundary. Measured on
|
||||||
|
hardware at a rock-steady 100.0 fps, individual frames still ranged
|
||||||
|
5.6ms to 15.2ms -- 0.57px to 1.44px of movement -- and 53% of frames
|
||||||
|
advanced by something other than one pixel: about half moved nothing
|
||||||
|
at all and then jumped two. That is the micro-stutter, and it survived
|
||||||
|
every frame-timing fix because frame timing was never the problem.
|
||||||
|
|
||||||
|
It is worst precisely at a crisp speed. At 100 px/s on a 100Hz panel
|
||||||
|
the accumulator sits exactly on integer boundaries, so sub-millisecond
|
||||||
|
jitter flips it either way and the motion beats at around 50Hz.
|
||||||
|
|
||||||
|
Stepping per frame is only correct because SwapOnVSync blocks until
|
||||||
|
the panel has taken the frame, which makes the frame count a truer
|
||||||
|
clock than time.time(). Before the swap was locked to vsync this would
|
||||||
|
have run at whatever speed the loop happened to spin at.
|
||||||
|
"""
|
||||||
|
self.fixed_pixels_per_frame = int(pixels) if pixels else None
|
||||||
|
self.logger.debug("Fixed step set to: %s px/frame",
|
||||||
|
self.fixed_pixels_per_frame)
|
||||||
|
|
||||||
def set_target_fps(self, fps: float) -> None:
|
def set_target_fps(self, fps: float) -> None:
|
||||||
"""
|
"""
|
||||||
Set the target frames per second for scrolling.
|
Set the target frames per second for scrolling.
|
||||||
@@ -1006,25 +975,62 @@ class ScrollHelper:
|
|||||||
"""
|
"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
|
# The first frame of a scroll has no predecessor, so it has no frame
|
||||||
|
# time. Measuring one anyway records the whole idle gap since the last
|
||||||
|
# scroll as a single frame: on hardware that produced windows reading
|
||||||
|
# "0.0 fps over 1 frames | median 136776.02ms", and -- worse, because
|
||||||
|
# it is not obviously wrong -- put that gap in the max field of
|
||||||
|
# otherwise healthy windows and counted it as one stall per scroll.
|
||||||
|
# At ~500 frames to a window that is ~0.2%, which is the same order as
|
||||||
|
# the real stall rates being measured, so the number could not be
|
||||||
|
# trusted at all. Seed the clock and take no sample.
|
||||||
|
if self.last_frame_time is None:
|
||||||
|
self.last_frame_time = current_time
|
||||||
|
# Restart the window with the scroll. Otherwise the boundary is
|
||||||
|
# already long overdue when the second frame arrives, and the new
|
||||||
|
# scroll opens by reporting a window of exactly one frame.
|
||||||
|
self.last_fps_log_time = current_time
|
||||||
|
return
|
||||||
|
|
||||||
# Calculate instantaneous frame time
|
# Calculate instantaneous frame time
|
||||||
frame_time = current_time - self.last_frame_time
|
frame_time = current_time - self.last_frame_time
|
||||||
|
|
||||||
|
# A caller that scrolls without ever calling reset_scroll() never arms
|
||||||
|
# the sentinel above, so catch the same gap by its size. Nothing that
|
||||||
|
# renders a scroll produces a frame longer than the log interval; a
|
||||||
|
# sample that large is an idle period, not a frame.
|
||||||
|
if frame_time >= FPS_LOG_INTERVAL:
|
||||||
|
self.last_frame_time = current_time
|
||||||
|
return
|
||||||
|
|
||||||
self.frame_times.append(frame_time)
|
self.frame_times.append(frame_time)
|
||||||
|
|
||||||
# Keep only last 100 frames for average
|
# Keep only last 100 frames for average
|
||||||
if len(self.frame_times) > 100:
|
if len(self.frame_times) > 100:
|
||||||
self.frame_times.pop(0)
|
self.frame_times.pop(0)
|
||||||
|
|
||||||
# Log FPS every 5 seconds to avoid spam
|
# Every frame since the last log, not just the last 100 and not just
|
||||||
if current_time - self.last_fps_log_time >= 5.0:
|
# the one that happens to land on the 5s boundary. The old line
|
||||||
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
# reported a single instantaneous sample -- roughly 1 frame in 500 --
|
||||||
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
# which cannot see a stall that hits 1% of frames, and reported it
|
||||||
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
# next to an average that hides the same stall by construction (a 2ms
|
||||||
|
# duplicate and a 21ms double-wait mean exactly 10ms). Chasing scroll
|
||||||
|
# judder needs the tail, so keep the window and report percentiles.
|
||||||
|
self._window.append(frame_time)
|
||||||
|
|
||||||
self.logger.info(f"Scroll frame stats - Avg FPS: {avg_fps:.1f}, "
|
# Log FPS every 5 seconds to avoid spam
|
||||||
f"Current FPS: {instant_fps:.1f}, "
|
if current_time - self.last_fps_log_time >= FPS_LOG_INTERVAL:
|
||||||
f"Frame time: {frame_time*1000:.2f}ms")
|
# An empty window means every sample in this interval was dropped
|
||||||
|
# as an idle gap. There is nothing to report, and reporting the
|
||||||
|
# gap itself is the bug above.
|
||||||
|
if self._window:
|
||||||
|
self.logger.info(
|
||||||
|
"Scroll frame stats - %s",
|
||||||
|
format_frame_stats(self._window),
|
||||||
|
)
|
||||||
self.last_fps_log_time = current_time
|
self.last_fps_log_time = current_time
|
||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
|
self._window = []
|
||||||
|
|
||||||
self.last_frame_time = current_time
|
self.last_frame_time = current_time
|
||||||
self.frame_count += 1
|
self.frame_count += 1
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
||||||
|
|
||||||
|
The eight scoreboards each carried byte-identical copies of the functions
|
||||||
|
below: the colour pickers, the settings lookup, the date and time formatting,
|
||||||
|
the favourite-team rules and the font-size grid snapping. One fix had to be
|
||||||
|
made eight times, and a new scoreboard started by copying them a ninth.
|
||||||
|
|
||||||
|
Everything here is a **free function taking explicit arguments**, not a base
|
||||||
|
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
||||||
|
method and delegates the body, so the call sites and the override points are
|
||||||
|
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
||||||
|
rather than attributes -- the helper never reaches back into the caller.
|
||||||
|
|
||||||
|
The bodies are the plugins' own code, moved rather than rewritten. The one
|
||||||
|
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
||||||
|
(`not desired`) instead of football's: they agree on every real input, and
|
||||||
|
the extra guard only stops a None size raising TypeError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from src.common.font_layout import ( # noqa: F401 - re-exported, see below
|
||||||
|
FONT_NAME_ALIASES, FONT_PIXEL_GRID, crisp_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||||
|
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
||||||
|
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
||||||
|
"score_color_for", "recent_score_color", "favorite_teams_for",
|
||||||
|
"side_is_favorite", "side_score", "favorite_result",
|
||||||
|
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
||||||
|
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
||||||
|
"resolve_font_size", "unshare_element_fonts",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Which customization element owns each font key, for colour resolution.
|
||||||
|
ELEMENT_FOR_FONT: Dict[str, str] = {
|
||||||
|
"score": "score_text",
|
||||||
|
"time": "period_text",
|
||||||
|
"team": "team_name",
|
||||||
|
"status": "status_text",
|
||||||
|
"detail": "detail_text",
|
||||||
|
"rank": "rank_text",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
||||||
|
"win": (0, 255, 0),
|
||||||
|
"loss": (255, 0, 0),
|
||||||
|
"tie": (255, 200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-exported rather than defined: the grid tables and the snapping rule are
|
||||||
|
# properties of the font files, which the display core needs too (it loads the
|
||||||
|
# same two faces in DisplayManager._load_fonts). They live in
|
||||||
|
# src/common/font_layout.py so there is one definition; they stay in this
|
||||||
|
# module's namespace and __all__ so the eight scoreboards that delegate to
|
||||||
|
# `sports_card.crisp_size` are untouched.
|
||||||
|
|
||||||
|
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
||||||
|
default: Any = None) -> Any:
|
||||||
|
"""Read one key from the scroll_card config block."""
|
||||||
|
block = (config or {}).get("scroll_card")
|
||||||
|
if isinstance(block, dict) and block.get(key) is not None:
|
||||||
|
return block.get(key)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||||
|
return str(scroll_card_option(config, "vs_text", "VS"))
|
||||||
|
|
||||||
|
|
||||||
|
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
||||||
|
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
||||||
|
return mode if mode in ("vs", "date_time", "none") else "vs"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||||
|
default: Tuple[int, int, int] = (255, 255, 255),
|
||||||
|
mode: Optional[str] = None):
|
||||||
|
"""Per-element text colour from customization.<element>.text_color.
|
||||||
|
|
||||||
|
Delegated rather than reimplemented: there were two copies of this
|
||||||
|
read and three of the offset read, and the shared one also resolves
|
||||||
|
the element under the names plugins actually use (the layout block
|
||||||
|
says `score` where the style block says `score_text`) and honours a
|
||||||
|
per-mode override. Hex strings are still accepted.
|
||||||
|
"""
|
||||||
|
from src.element_style import element_color as _shared
|
||||||
|
return _shared(config, element, default, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_font_color(config: Optional[Dict[str, Any]],
|
||||||
|
fonts: Optional[Dict[str, Any]], font,
|
||||||
|
default: Tuple[int, int, int],
|
||||||
|
element_for_font: Dict[str, str],
|
||||||
|
mode: Optional[str] = None):
|
||||||
|
"""Colour for whichever element owns this face.
|
||||||
|
|
||||||
|
Identity matching is a stand-in for the element name, used where the draw
|
||||||
|
site only ever received a font. Prefer ``element=`` on the draw call; this
|
||||||
|
is the fallback for the sites that have not been annotated yet.
|
||||||
|
|
||||||
|
One object can legitimately belong to several elements -- a size resolver
|
||||||
|
can land two of them on the same face, and a BDF face cannot be un-shared
|
||||||
|
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
|
||||||
|
Those draws used to go out white, which is how an element rendered in any
|
||||||
|
of the 32 shipped bitmap fonts could silently lose a colour the user had
|
||||||
|
set. So ambiguity is now narrowed before it is given up on: among the
|
||||||
|
elements sharing a face, a single configured colour is the only thing the
|
||||||
|
user can have meant, and several that agree mean the same thing. Only a
|
||||||
|
genuine disagreement falls back to *default*.
|
||||||
|
|
||||||
|
The element vocabulary is a parameter because the two callers disagree
|
||||||
|
about it -- the mixin's map says ``team_text`` where this module's says
|
||||||
|
``team_name`` -- and quietly re-pointing either at the other's names would
|
||||||
|
change which colour setting a live install honours.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fonts = fonts or {}
|
||||||
|
matches = [element for key, element in element_for_font.items()
|
||||||
|
if fonts.get(key) is font]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return element_color(config, matches[0], default, mode)
|
||||||
|
if len(matches) > 1:
|
||||||
|
configured = []
|
||||||
|
for element in matches:
|
||||||
|
colour = element_color(config, element, None, mode)
|
||||||
|
if colour is not None and colour not in configured:
|
||||||
|
configured.append(colour)
|
||||||
|
if len(configured) == 1:
|
||||||
|
return configured[0]
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||||
|
font, default: Tuple[int, int, int] = (255, 255, 255),
|
||||||
|
mode: Optional[str] = None):
|
||||||
|
"""Colour for whichever element owns this face, by this module's map."""
|
||||||
|
return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT,
|
||||||
|
mode)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_rgb(value, fallback):
|
||||||
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||||
|
# Checked before unpacking: a 3-character string ("123") would otherwise
|
||||||
|
# iterate into three digits and yield a colour rather than the fallback.
|
||||||
|
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
return (r, g, b)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Favourite teams
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
||||||
|
"""Favorite teams that apply to this game.
|
||||||
|
|
||||||
|
Both sources are used. Games carry the league manager's *resolved*
|
||||||
|
favorites, which is the only place dynamic groups such as AP_TOP_25
|
||||||
|
appear expanded; the config is read as well so an edit takes effect on
|
||||||
|
already-fetched games, and so hand-built game dicts (tests, other
|
||||||
|
callers) still work.
|
||||||
|
"""
|
||||||
|
favorites = list(game.get("favorite_teams") or [])
|
||||||
|
league_config = config.get(str(game.get("league", "") or ""))
|
||||||
|
if isinstance(league_config, dict):
|
||||||
|
favorites += list(league_config.get("favorite_teams") or [])
|
||||||
|
else:
|
||||||
|
favorites += list(config.get("favorite_teams") or [])
|
||||||
|
return favorites
|
||||||
|
|
||||||
|
|
||||||
|
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||||
|
"""Is the home/away side of this game a favorite team?
|
||||||
|
|
||||||
|
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
||||||
|
payload shapes, and matches on the ESPN id too, because a couple of
|
||||||
|
leagues (NRL) key favorites by id where abbreviations collide.
|
||||||
|
"""
|
||||||
|
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict):
|
||||||
|
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
||||||
|
for value in candidates:
|
||||||
|
if value is not None and str(value).strip().upper() in favorites:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||||
|
"""Numeric score for one side, from either payload shape."""
|
||||||
|
raw = None
|
||||||
|
team = game.get(f"{side}_team")
|
||||||
|
if isinstance(team, dict) and team.get("score") is not None:
|
||||||
|
raw = team.get("score")
|
||||||
|
if raw is None:
|
||||||
|
raw = game.get(f"{side}_score")
|
||||||
|
try:
|
||||||
|
return int(float(str(raw).strip()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Say how the favorite team did in a finished game.
|
||||||
|
|
||||||
|
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
||||||
|
to root for: no favorites configured, neither side is a favorite, or
|
||||||
|
*both* are -- a favorite-vs-favorite game has no losing side worth
|
||||||
|
flagging in red. Also None when the scores are not usable numbers.
|
||||||
|
"""
|
||||||
|
favorites = {
|
||||||
|
str(team).strip().upper()
|
||||||
|
for team in favorite_teams_for(config, game)
|
||||||
|
if str(team).strip()
|
||||||
|
}
|
||||||
|
if not favorites:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_fav = side_is_favorite(game, "home", favorites)
|
||||||
|
away_fav = side_is_favorite(game, "away", favorites)
|
||||||
|
if home_fav == away_fav:
|
||||||
|
return None
|
||||||
|
|
||||||
|
home_score = side_score(game, "home")
|
||||||
|
away_score = side_score(game, "away")
|
||||||
|
if home_score is None or away_score is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if home_score == away_score:
|
||||||
|
return "tie"
|
||||||
|
favorite_score, other_score = (
|
||||||
|
(home_score, away_score) if home_fav else (away_score, home_score)
|
||||||
|
)
|
||||||
|
return "win" if favorite_score > other_score else "loss"
|
||||||
|
|
||||||
|
|
||||||
|
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
||||||
|
"""Fill color for a finished game's score, per favorite_result_colors."""
|
||||||
|
try:
|
||||||
|
settings = (config.get("customization") or {}).get(
|
||||||
|
"favorite_result_colors"
|
||||||
|
) or {}
|
||||||
|
if not settings.get("enabled", False):
|
||||||
|
return default
|
||||||
|
result = favorite_result(config, game)
|
||||||
|
if result is None:
|
||||||
|
return default
|
||||||
|
return coerce_rgb(
|
||||||
|
settings.get(f"{result}_color"),
|
||||||
|
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve favorite result color", exc_info=True)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
||||||
|
game_type: str, default=None):
|
||||||
|
"""Fill color for a game card's score. Only finished games are tinted.
|
||||||
|
|
||||||
|
The default is the configured score colour rather than a flat white,
|
||||||
|
so customization.score_text.text_color shows on games the favourite
|
||||||
|
tint does not apply to. The tint still wins where it applies.
|
||||||
|
"""
|
||||||
|
if default is None:
|
||||||
|
default = element_color(config, 'score_text')
|
||||||
|
if game_type != "recent":
|
||||||
|
return default
|
||||||
|
return recent_score_color(config, logger, game, default)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Date and time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
||||||
|
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
||||||
|
configured = (config or {}).get("timezone")
|
||||||
|
if configured:
|
||||||
|
try:
|
||||||
|
return ZoneInfo(configured)
|
||||||
|
except (KeyError, ValueError, TypeError, OSError) as exc:
|
||||||
|
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
||||||
|
# config should fall back to UTC, not blank the card.
|
||||||
|
logger.debug("Unusable timezone %r: %s", configured, exc)
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
||||||
|
game: Optional[Dict]) -> str:
|
||||||
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
|
if not game:
|
||||||
|
return ""
|
||||||
|
raw = game.get("start_time_utc") or game.get("start_time")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||||
|
str(raw).replace("Z", "+00:00"))
|
||||||
|
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||||
|
game: Optional[Dict] = None) -> str:
|
||||||
|
"""Format an upcoming card's date per scroll_card.date_format."""
|
||||||
|
raw = str(date_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||||
|
if fmt == "numeric":
|
||||||
|
return raw
|
||||||
|
parts = raw.replace("-", "/").split("/")
|
||||||
|
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
||||||
|
return raw
|
||||||
|
month, day = int(parts[0]), int(parts[1])
|
||||||
|
if not 1 <= month <= 12:
|
||||||
|
return raw
|
||||||
|
name = MONTH_ABBR[month - 1]
|
||||||
|
if fmt == "numeric_day_first":
|
||||||
|
return f"{day}/{month}"
|
||||||
|
if fmt == "day_first":
|
||||||
|
return f"{day} {name}"
|
||||||
|
if fmt == "weekday":
|
||||||
|
weekday = weekday_for(config, logger, game)
|
||||||
|
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||||
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
||||||
|
"""Return the time as-is (12h) or converted to 24h."""
|
||||||
|
raw = str(time_text or "").strip()
|
||||||
|
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
|
||||||
|
return raw
|
||||||
|
cleaned = raw.upper().replace(" ", "")
|
||||||
|
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
||||||
|
if not meridiem:
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
hh, _, mm = cleaned[:-2].partition(":")
|
||||||
|
hour, minute = int(hh), int(mm or 0)
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
||||||
|
return raw
|
||||||
|
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
||||||
|
return f"{hour:02d}:{minute:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font sizing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
||||||
|
#: global because each plugin declares its own defaults; keyed rather than
|
||||||
|
#: per-class because the helper has no class to hang it on.
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||||
|
"""The font_size this plugin's config_schema.json declares, or None.
|
||||||
|
|
||||||
|
Cached per schema path. The plugins cached this on their own class; the
|
||||||
|
path is the same distinction expressed without one, so two plugins never
|
||||||
|
share an entry.
|
||||||
|
"""
|
||||||
|
if not element_key:
|
||||||
|
return None
|
||||||
|
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
||||||
|
if cache is None:
|
||||||
|
cache = {}
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open(schema_path) as fh:
|
||||||
|
schema = json.load(fh)
|
||||||
|
props = (schema.get('properties', {})
|
||||||
|
.get('customization', {})
|
||||||
|
.get('properties', {}))
|
||||||
|
for key, spec in props.items():
|
||||||
|
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||||
|
if size is not None:
|
||||||
|
cache[key] = int(size)
|
||||||
|
except Exception as exc:
|
||||||
|
# See sports_shared._schema_font_size: an unreadable schema
|
||||||
|
# silently disables the pixel-grid snap for every element.
|
||||||
|
# Built once per schema path, so this cannot repeat per frame.
|
||||||
|
logger.warning(
|
||||||
|
"could not read %s (%s: %s); font sizes will skip their "
|
||||||
|
"pixel grid snap and may render a pixel narrow",
|
||||||
|
schema_path, type(exc).__name__, exc)
|
||||||
|
cache = {}
|
||||||
|
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||||
|
return cache.get(element_key)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_font_size(schema_path: str, element_config, element_key,
|
||||||
|
default_size, font_name, aliases=None, grid_table=None):
|
||||||
|
"""Size to render at: the user's choice, or a grid-snapped default.
|
||||||
|
|
||||||
|
A configured size counts as a real choice only when it differs from
|
||||||
|
the schema default. The web UI writes the whole schema default block
|
||||||
|
on every save, so "font_size == schema default" carries no intent and
|
||||||
|
would otherwise pin every install to an anti-aliased size forever.
|
||||||
|
"""
|
||||||
|
configured = (element_config or {}).get('font_size')
|
||||||
|
if configured is not None:
|
||||||
|
try:
|
||||||
|
configured = int(configured)
|
||||||
|
if configured != schema_font_size(schema_path, element_key):
|
||||||
|
return configured
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||||
|
|
||||||
|
|
||||||
|
def unshare_element_fonts(logger, fonts):
|
||||||
|
"""Give each colourable element its own face object.
|
||||||
|
|
||||||
|
The colour a draw gets is resolved from the face it was handed, and
|
||||||
|
several of these loaders legitimately hand one object to more than one
|
||||||
|
element -- a size resolver that lands two elements on the same face, a
|
||||||
|
fallback that fills every key from one default, football's narrowing
|
||||||
|
step that deliberately shrinks the clock along with the score. Sharing
|
||||||
|
the object makes the element ambiguous and the colour unresolvable.
|
||||||
|
|
||||||
|
Re-instantiating from the same path and size gives a distinct object
|
||||||
|
with identical metrics, so nothing about the rendering changes; only
|
||||||
|
the ability to tell two elements apart does. Faces that cannot be
|
||||||
|
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||||
|
path) are left shared, and their draws stay white as before.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.common.font_layout import load_truetype as _load
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
return fonts
|
||||||
|
seen = {}
|
||||||
|
for key in ELEMENT_FOR_FONT:
|
||||||
|
font = fonts.get(key)
|
||||||
|
if font is None:
|
||||||
|
continue
|
||||||
|
if id(font) not in seen:
|
||||||
|
seen[id(font)] = key
|
||||||
|
continue
|
||||||
|
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
||||||
|
if not path or not size:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fonts[key] = _load(path, size)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
logger.debug(
|
||||||
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
return fonts
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""The scroll/Vegas card geometry the sports scoreboards all share.
|
||||||
|
|
||||||
|
Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse,
|
||||||
|
nrl and soccer -- each carried their own ``game_renderer.py``. After the card
|
||||||
|
helpers moved to ``src/common/sports_card.py`` the settings lookups were
|
||||||
|
shared, but the *geometry* was not: nine methods that decide how wide the
|
||||||
|
centre gap is, how much room a logo gets, and where an upcoming card's date
|
||||||
|
and time land were still eight separate copies. Five were byte-identical
|
||||||
|
across all eight plugins; the other four were identical in seven, with a
|
||||||
|
different single outlier each time.
|
||||||
|
|
||||||
|
That last detail is why this is a mixin rather than free functions. There is
|
||||||
|
no per-sport branching to write -- baseball needs its own
|
||||||
|
``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own
|
||||||
|
``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and
|
||||||
|
every one of those is an ordinary override. The seven that agree inherit and
|
||||||
|
say nothing.
|
||||||
|
|
||||||
|
It is deliberately *only* a mixin: no ``__init__``, no state of its own. The
|
||||||
|
plugins' constructors differ in six ways and none of that difference is worth
|
||||||
|
unifying, so adoption is one line on the class statement plus deleting the
|
||||||
|
methods that now come from here.
|
||||||
|
|
||||||
|
What a host class must provide
|
||||||
|
------------------------------
|
||||||
|
Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``,
|
||||||
|
``logger``, ``_team_rankings_cache``.
|
||||||
|
|
||||||
|
Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None,
|
||||||
|
outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus
|
||||||
|
the ``sports_card`` delegations ``_scroll_card_option``,
|
||||||
|
``_upcoming_center_mode``, ``_vs_text``, ``_element_color``,
|
||||||
|
``_format_game_date`` and ``_format_game_time``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import ClassVar, Dict, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
class SportsGameRendererMixin:
|
||||||
|
"""Shared card geometry for the sports scoreboards. See module docstring."""
|
||||||
|
|
||||||
|
#: Centre strip as a fraction of card width, before clamping.
|
||||||
|
CENTER_GAP_RATIO: ClassVar[float] = 0.28
|
||||||
|
#: Clamp floor for the derived centre gap.
|
||||||
|
CENTER_GAP_MIN_PX: ClassVar[int] = 22
|
||||||
|
#: Clamp ceiling for the derived centre gap.
|
||||||
|
CENTER_GAP_MAX_PX: ClassVar[int] = 40
|
||||||
|
#: Breathing room kept between the score and each logo.
|
||||||
|
_SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4
|
||||||
|
#: Widest score the centre strip must fit. Leagues that can reach three
|
||||||
|
#: digits a side override this with "000-000".
|
||||||
|
_SCORE_PROBE: ClassVar[str] = "00-00"
|
||||||
|
|
||||||
|
# ---- geometry ------------------------------------------------------
|
||||||
|
|
||||||
|
# Non-finite settings are rejected before any int()/round(): "inf" reaches
|
||||||
|
# these from config as a float or a string, passes an `isinstance` plus
|
||||||
|
# `>= 0` check unharmed, and then raises OverflowError out of int() --
|
||||||
|
# which the old `except (TypeError, ValueError)` did not catch, so it
|
||||||
|
# aborted the whole card render. Present in all eight plugins before this
|
||||||
|
# moved to the core; fixing it here fixes it in all eight.
|
||||||
|
|
||||||
|
def _score_reserve_width(self) -> int:
|
||||||
|
"""Centre strip the score actually needs, measured rather than assumed.
|
||||||
|
|
||||||
|
The gap was derived from the card width alone (width x
|
||||||
|
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
||||||
|
comes from config and the element-style resolver. Nothing compared the
|
||||||
|
two, so any score wider than the clamp was drawn over the logos.
|
||||||
|
Measuring it keeps the strip wide enough for whatever font is in play.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||||
|
width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score'])
|
||||||
|
return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Score reserve measurement failed", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _center_gap_width(self) -> int:
|
||||||
|
"""Width of the middle strip kept clear of logos.
|
||||||
|
|
||||||
|
``scroll_card.center_gap`` pins it outright; otherwise it scales with
|
||||||
|
the card width between the configurable min and max. 0 restores
|
||||||
|
edge-to-edge logos.
|
||||||
|
"""
|
||||||
|
configured = self._scroll_card_option("center_gap")
|
||||||
|
if (isinstance(configured, (int, float))
|
||||||
|
and math.isfinite(configured) and configured >= 0):
|
||||||
|
return int(configured)
|
||||||
|
ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO)
|
||||||
|
low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX)
|
||||||
|
high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX)
|
||||||
|
try:
|
||||||
|
ratio, low, high = float(ratio), float(low), float(high)
|
||||||
|
if not (math.isfinite(ratio) and math.isfinite(low)
|
||||||
|
and math.isfinite(high)):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
scaled = round(self.display_width * ratio)
|
||||||
|
derived = int(max(int(low), min(int(high), scaled)))
|
||||||
|
# A strip narrower than the score is the bug, not a style choice.
|
||||||
|
# An explicit ``center_gap`` is still honoured above, including 0.
|
||||||
|
return max(derived, self._score_reserve_width())
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return self.CENTER_GAP_MIN_PX
|
||||||
|
|
||||||
|
def _logo_slot_width(self) -> int:
|
||||||
|
"""Per-side logo slot, leaving the center gap clear.
|
||||||
|
|
||||||
|
No longer capped at display_height: the card is sized as two
|
||||||
|
full-height logos plus the measured gap, so what is left after the gap
|
||||||
|
is exactly the logo's share. The cap was what froze the logos at 46px
|
||||||
|
on the old flat 128px card.
|
||||||
|
"""
|
||||||
|
available = (self.display_width - self._center_gap_width()) // 2
|
||||||
|
return max(8, available)
|
||||||
|
|
||||||
|
def _logo_cache_key(self, name: str) -> str:
|
||||||
|
"""Cache key scoped to the logo slot.
|
||||||
|
|
||||||
|
One cache dict is shared by renderers built for different card widths,
|
||||||
|
so a logo sized for a wide slot must not be handed to a narrow one.
|
||||||
|
"""
|
||||||
|
return f"{name}@{self._logo_slot_width()}x{self.display_height}"
|
||||||
|
|
||||||
|
def _layout_offset(self, element: str, axis: str, default: int = 0) -> int:
|
||||||
|
"""X/Y nudge for one element, from customization.layout.
|
||||||
|
|
||||||
|
Same block the full-screen scorebug reads (sports.py
|
||||||
|
_get_layout_offset), so a nudge configured in the web UI now moves
|
||||||
|
the element on the scroll/Vegas card too -- previously the schema
|
||||||
|
advertised these offsets but this renderer ignored them.
|
||||||
|
"""
|
||||||
|
from src.element_style import layout_offset
|
||||||
|
return layout_offset(self.config, element, axis, default,
|
||||||
|
getattr(self, "SKIN_MODE", None))
|
||||||
|
|
||||||
|
# ---- upcoming cards ------------------------------------------------
|
||||||
|
|
||||||
|
def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]:
|
||||||
|
"""(date, time) for an upcoming card, from the extractor's flat keys."""
|
||||||
|
return (
|
||||||
|
str(game.get("game_date", "") or ""),
|
||||||
|
str(game.get("game_time", "") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the middle of an upcoming card.
|
||||||
|
|
||||||
|
Never a score: an upcoming game has not started, so the extractor's
|
||||||
|
0-0 is noise. Either the VS text (default), the date and time stacked,
|
||||||
|
or nothing at all.
|
||||||
|
"""
|
||||||
|
mode = self._upcoming_center_mode()
|
||||||
|
if mode == "none":
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode == "vs":
|
||||||
|
vs_text = self._vs_text()
|
||||||
|
if not vs_text:
|
||||||
|
return
|
||||||
|
vs_width = draw.textlength(vs_text, font=self.fonts['score'])
|
||||||
|
vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, vs_text, (vs_x, vs_y), self.fonts['score'],
|
||||||
|
fill=self._element_color('score_text')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
date_text, time_text = self._upcoming_date_and_time(game)
|
||||||
|
lines = []
|
||||||
|
if self._scroll_card_option("show_date", True):
|
||||||
|
lines.append(self._format_game_date(date_text, game))
|
||||||
|
if self._scroll_card_option("show_time", True):
|
||||||
|
lines.append(self._format_game_time(time_text))
|
||||||
|
lines = [t for t in lines if t]
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
line_h = 7
|
||||||
|
top = (self.display_height // 2) - (len(lines) * line_h) // 2
|
||||||
|
top += self._layout_offset('score', 'y_offset')
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
width = draw.textlength(line, font=font)
|
||||||
|
x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, line, (x, top + i * line_h), font,
|
||||||
|
fill=self._element_color('detail_text')
|
||||||
|
)
|
||||||
|
|
||||||
|
def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||||
|
"""Draw the date and time around an upcoming card.
|
||||||
|
|
||||||
|
Time top and date bottom by default; scroll_card.swap_date_time puts
|
||||||
|
the date on top instead. Skipped when the pair is stacked in the
|
||||||
|
middle, which would otherwise print them twice.
|
||||||
|
"""
|
||||||
|
if self._upcoming_center_mode() == "date_time":
|
||||||
|
return
|
||||||
|
|
||||||
|
date_raw, time_raw = self._upcoming_date_and_time(game)
|
||||||
|
date_text = (self._format_game_date(date_raw, game)
|
||||||
|
if self._scroll_card_option("show_date", True) else "")
|
||||||
|
time_text = (self._format_game_time(time_raw)
|
||||||
|
if self._scroll_card_option("show_time", True) else "")
|
||||||
|
|
||||||
|
if self._scroll_card_option("swap_date_time", False):
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
date_text, 'date', time_text, 'time')
|
||||||
|
top_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
bottom_font = self.fonts['time']
|
||||||
|
top_color, bottom_color = 'detail_text', 'period_text'
|
||||||
|
else:
|
||||||
|
top_text, top_el, bottom_text, bottom_el = (
|
||||||
|
time_text, 'time', date_text, 'date')
|
||||||
|
top_font = self.fonts['time']
|
||||||
|
bottom_font = self.fonts.get('detail') or self.fonts['time']
|
||||||
|
top_color, bottom_color = 'period_text', 'detail_text'
|
||||||
|
|
||||||
|
if top_text:
|
||||||
|
top_width = draw.textlength(top_text, font=top_font)
|
||||||
|
top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset')
|
||||||
|
top_y = 1 + self._layout_offset(top_el, 'y_offset')
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, top_text, (top_x, top_y), top_font,
|
||||||
|
fill=self._element_color(top_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
if bottom_text:
|
||||||
|
bottom_width = draw.textlength(bottom_text, font=bottom_font)
|
||||||
|
bottom_x = ((self.display_width - bottom_width) // 2
|
||||||
|
+ self._layout_offset(bottom_el, 'x_offset'))
|
||||||
|
# Measured, not a fixed -7: the detail font is 6px in most plugins
|
||||||
|
# but 10px in soccer and nrl, where "Sep 19" ran past the card.
|
||||||
|
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
|
||||||
|
bottom_y = (max(0, self.display_height - ink_bottom - 1)
|
||||||
|
+ self._layout_offset(bottom_el, 'y_offset'))
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, bottom_text, (bottom_x, bottom_y), bottom_font,
|
||||||
|
fill=self._element_color(bottom_color)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- rankings ------------------------------------------------------
|
||||||
|
|
||||||
|
def set_rankings_cache(self, rankings: Dict[str, int]) -> None:
|
||||||
|
"""Set the team rankings cache for display."""
|
||||||
|
self._team_rankings_cache = rankings
|
||||||
+83
-39
@@ -43,6 +43,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common import scroll_config
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,13 +59,9 @@ DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = {
|
|||||||
"dynamic_duration": True,
|
"dynamic_duration": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Bounds on the px/second -> px/frame conversion, applied before the helper
|
|
||||||
#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps
|
|
||||||
#: already does that, and a second copy of the range would drift from it.
|
|
||||||
MIN_PIXELS_PER_FRAME = 0.1
|
|
||||||
MAX_PIXELS_PER_FRAME = 5.0
|
|
||||||
|
|
||||||
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
|
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
|
||||||
|
#: Only used to interpret this module's own px/frame config shape; the speed
|
||||||
|
#: bounds and the px/s -> px/frame conversion belong to scroll_config now.
|
||||||
ASSUMED_FPS_WHEN_UNPACED = 100.0
|
ASSUMED_FPS_WHEN_UNPACED = 100.0
|
||||||
|
|
||||||
|
|
||||||
@@ -236,51 +233,75 @@ class SportsScrollDisplay:
|
|||||||
"""Apply config to the scroll helper. Safe to call again after a change."""
|
"""Apply config to the scroll helper. Safe to call again after a change."""
|
||||||
settings = self._get_scroll_settings()
|
settings = self._get_scroll_settings()
|
||||||
|
|
||||||
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
|
||||||
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
|
||||||
dynamic_duration = bool(settings.get("dynamic_duration", True))
|
dynamic_duration = bool(settings.get("dynamic_duration", True))
|
||||||
|
|
||||||
self.scroll_helper.set_scroll_delay(scroll_delay)
|
|
||||||
self.scroll_helper.set_dynamic_duration_settings(
|
self.scroll_helper.set_dynamic_duration_settings(
|
||||||
enabled=dynamic_duration,
|
enabled=dynamic_duration,
|
||||||
min_duration=settings.get("min_duration", 30),
|
min_duration=settings.get("min_duration", 30),
|
||||||
max_duration=settings.get("max_duration", 600),
|
max_duration=settings.get("max_duration", 600),
|
||||||
buffer=0.2, # ensure the strip clears the panel completely
|
buffer=0.2, # ensure the strip clears the panel completely
|
||||||
)
|
)
|
||||||
# Frame-based scrolling: motion advances per rendered frame rather than
|
|
||||||
# per wall-clock second, which is what makes the pacing stable.
|
|
||||||
self.scroll_helper.set_frame_based_scrolling(True)
|
|
||||||
|
|
||||||
# Config states speed in px/second; frame-based mode wants px/frame.
|
# Speed goes through scroll_config, which every other scrolling plugin
|
||||||
if scroll_delay > 0:
|
# already uses. What must NOT happen is handing it this module's
|
||||||
pixels_per_frame = scroll_speed * scroll_delay
|
# settings dict: the two read the same key names with different
|
||||||
else:
|
# meanings, and the collision is a factor of 1/scroll_delay.
|
||||||
pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED
|
#
|
||||||
pixels_per_frame = max(
|
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the
|
||||||
MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame)
|
# frame period used to reach px/frame.
|
||||||
)
|
# scroll_config: scroll_speed is px per STEP, so px/s = speed/delay.
|
||||||
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
#
|
||||||
|
# Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through
|
||||||
|
# resolves to 5000 px/s (clamped to 500) instead of 50. So this module
|
||||||
|
# keeps ownership of reading its own config -- _get_scroll_settings
|
||||||
|
# merges the league overrides -- and hands the resolver a plain px/s.
|
||||||
|
pixels_per_second = self._resolve_pixels_per_second(settings)
|
||||||
|
|
||||||
effective_pps = (
|
resolved = scroll_config.configure(
|
||||||
pixels_per_frame / scroll_delay
|
self.scroll_helper,
|
||||||
if scroll_delay > 0
|
plugin_config=None,
|
||||||
else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED
|
global_config=self.global_config,
|
||||||
|
default_pixels_per_second=pixels_per_second,
|
||||||
|
display_manager=self.display_manager,
|
||||||
|
plugin_logger=self.logger,
|
||||||
|
refresh_hz=self._resolve_refresh_hz(),
|
||||||
)
|
)
|
||||||
|
self._scroll_settings = resolved
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, "
|
"ScrollHelper configured: %s (requested %.1f px/s), "
|
||||||
f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from "
|
"dynamic_duration=%s",
|
||||||
f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
|
resolved.describe(),
|
||||||
|
pixels_per_second, dynamic_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The reason this module exists upstream: the bundled copies hardcode
|
def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float:
|
||||||
# ~100 FPS via scroll_delay and never consult the global target.
|
"""This module's config shape, expressed as plain pixels per second.
|
||||||
# No hasattr guard here, unlike the plugin copies: they probe because
|
|
||||||
# they may run against an older core, whereas this module ships in the
|
``scroll_speed`` is already px/s here. ``scroll_delay`` only matters
|
||||||
# same release as the ScrollHelper it calls. The helper clamps.
|
when a caller supplied px/frame instead, which the 0 case covers.
|
||||||
target_fps = self._resolve_target_fps()
|
"""
|
||||||
if target_fps:
|
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
||||||
self.scroll_helper.set_target_fps(target_fps)
|
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
||||||
self.logger.info(f"Target FPS set to {target_fps}")
|
if scroll_delay <= 0:
|
||||||
|
return scroll_speed * ASSUMED_FPS_WHEN_UNPACED
|
||||||
|
return scroll_speed
|
||||||
|
|
||||||
|
def _resolve_refresh_hz(self) -> Optional[float]:
|
||||||
|
"""The panel refresh the crisp ladder should be computed against.
|
||||||
|
|
||||||
|
Prefers the configured hardware refresh. Falls back to the global
|
||||||
|
``target_fps``/``scroll_target_fps`` this module has always honoured:
|
||||||
|
under the old model that key *was* the rate frames were presented at,
|
||||||
|
so it is the faithful translation for anyone who set it. Returning
|
||||||
|
None lets scroll_config apply its own default.
|
||||||
|
"""
|
||||||
|
hardware = scroll_config.refresh_hz_from_config(self.global_config)
|
||||||
|
if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ:
|
||||||
|
return hardware
|
||||||
|
return self._resolve_target_fps() or hardware or None
|
||||||
|
|
||||||
|
def _scroll_frame_hold(self) -> int:
|
||||||
|
"""Refreshes to hold each frame for, from the resolved settings."""
|
||||||
|
return getattr(getattr(self, "_scroll_settings", None), "frame_hold", 1)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Frame pumping
|
# Frame pumping
|
||||||
@@ -305,6 +326,16 @@ class SportsScrollDisplay:
|
|||||||
if not visible:
|
if not visible:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Tell the core the panel is scrolling, and for how many
|
||||||
|
# refreshes to hold each frame. Without this the frame hold is
|
||||||
|
# never applied -- so a speed the ladder made crisp still presents
|
||||||
|
# a new frame every refresh and judders -- and, because deferred
|
||||||
|
# updates only run while nothing is scrolling, core would run
|
||||||
|
# blocking work in the middle of this scroll.
|
||||||
|
if hasattr(self.display_manager, "set_scrolling_state"):
|
||||||
|
self.display_manager.set_scrolling_state(
|
||||||
|
True, frame_hold=self._scroll_frame_hold())
|
||||||
|
|
||||||
self.display_manager.image = visible
|
self.display_manager.image = visible
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
self._frame_count += 1
|
self._frame_count += 1
|
||||||
@@ -331,7 +362,19 @@ class SportsScrollDisplay:
|
|||||||
|
|
||||||
def is_scroll_complete(self) -> bool:
|
def is_scroll_complete(self) -> bool:
|
||||||
"""True when the strip has scrolled fully past the panel."""
|
"""True when the strip has scrolled fully past the panel."""
|
||||||
return self.scroll_helper.is_scroll_complete()
|
complete = self.scroll_helper.is_scroll_complete()
|
||||||
|
if complete:
|
||||||
|
self._release_scrolling_state()
|
||||||
|
return complete
|
||||||
|
|
||||||
|
def _release_scrolling_state(self) -> None:
|
||||||
|
"""Tell the core this display is no longer scrolling.
|
||||||
|
|
||||||
|
The scrolling flag and the frame hold are global to the display
|
||||||
|
manager, so leaving them set holds every other plugin's frames too.
|
||||||
|
"""
|
||||||
|
if hasattr(self.display_manager, "set_scrolling_state"):
|
||||||
|
self.display_manager.set_scrolling_state(False)
|
||||||
|
|
||||||
def reset_scroll(self) -> None:
|
def reset_scroll(self) -> None:
|
||||||
"""Return the strip to its starting position, keeping the content."""
|
"""Return the strip to its starting position, keeping the content."""
|
||||||
@@ -349,6 +392,7 @@ class SportsScrollDisplay:
|
|||||||
self._vegas_content_items = []
|
self._vegas_content_items = []
|
||||||
self._is_scrolling = False
|
self._is_scrolling = False
|
||||||
self._scroll_start_time = None
|
self._scroll_start_time = None
|
||||||
|
self._release_scrolling_state()
|
||||||
self.logger.debug("Scroll display cleared")
|
self.logger.debug("Scroll display cleared")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+93
-33
@@ -19,6 +19,7 @@ Port default: 5765 (UDP). Open this port on both Pis if ufw is active:
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
@@ -31,12 +32,21 @@ from typing import Callable, Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.display_geometry import DEFAULT_CHAIN_LENGTH
|
||||||
|
|
||||||
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||||
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
||||||
_RAW_MAGIC = b'SYNC_RAW'
|
_RAW_MAGIC = b'SYNC_RAW'
|
||||||
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
||||||
|
|
||||||
|
|
||||||
|
# Upper bound on a decoded frame/scroll image. Generous for any real scroll
|
||||||
|
# image (a leader's full cycle is long but only panel-height tall), and low
|
||||||
|
# enough that a crafted image from any host on the LAN cannot force a large
|
||||||
|
# allocation on the render thread. Applied on both receive paths — the TCP
|
||||||
|
# image server and the follower's legacy-PNG UDP fallback.
|
||||||
|
_MAX_FRAME_W, _MAX_FRAME_H = 100_000, 256
|
||||||
|
|
||||||
SYNC_PORT = 5765
|
SYNC_PORT = 5765
|
||||||
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
||||||
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
||||||
@@ -101,6 +111,7 @@ class DisplaySyncManager:
|
|||||||
self._peer_chain: int = 0
|
self._peer_chain: int = 0
|
||||||
self._last_heartbeat_time: float = 0.0
|
self._last_heartbeat_time: float = 0.0
|
||||||
self._leader_width: int = 0 # set by display_controller after init
|
self._leader_width: int = 0 # set by display_controller after init
|
||||||
|
self._oversized_frame_warned: bool = False
|
||||||
|
|
||||||
# Follower state
|
# Follower state
|
||||||
self._follower_state = FollowerState.STANDALONE
|
self._follower_state = FollowerState.STANDALONE
|
||||||
@@ -174,6 +185,10 @@ class DisplaySyncManager:
|
|||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync leader recv error: %s", exc)
|
self.logger.debug("Sync leader recv error: %s", exc)
|
||||||
|
# Brief backoff: a socket left in a bad state raises
|
||||||
|
# immediately, which would otherwise spin this thread at
|
||||||
|
# 100% CPU logging the same error.
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
@@ -181,7 +196,7 @@ class DisplaySyncManager:
|
|||||||
local_cols = hw.get("cols", 64)
|
local_cols = hw.get("cols", 64)
|
||||||
peer_rows = int(msg.get("rows", 0))
|
peer_rows = int(msg.get("rows", 0))
|
||||||
peer_cols = int(msg.get("cols", 0))
|
peer_cols = int(msg.get("cols", 0))
|
||||||
peer_chain = int(msg.get("chain", 1))
|
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
|
||||||
|
|
||||||
compatible = peer_rows == local_rows and peer_cols == local_cols
|
compatible = peer_rows == local_rows and peer_cols == local_cols
|
||||||
|
|
||||||
@@ -273,11 +288,10 @@ class DisplaySyncManager:
|
|||||||
break
|
break
|
||||||
data.extend(chunk)
|
data.extend(chunk)
|
||||||
img = Image.open(io.BytesIO(data))
|
img = Image.open(io.BytesIO(data))
|
||||||
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
|
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||||
if img.width > _MAX_W or img.height > _MAX_H:
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
||||||
img.width, img.height, _MAX_W, _MAX_H, addr,
|
img.width, img.height, _MAX_FRAME_W, _MAX_FRAME_H, addr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -396,7 +410,7 @@ class DisplaySyncManager:
|
|||||||
data = header + arr.tobytes()
|
data = header + arr.tobytes()
|
||||||
if len(data) <= 65000:
|
if len(data) <= 65000:
|
||||||
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
||||||
elif not getattr(self, '_oversized_frame_warned', False):
|
elif not self._oversized_frame_warned:
|
||||||
self._oversized_frame_warned = True
|
self._oversized_frame_warned = True
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
||||||
@@ -451,25 +465,8 @@ class DisplaySyncManager:
|
|||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
|
|
||||||
def _follower_recv_loop(self) -> None:
|
def _handle_received_frame(self, img: Image.Image, sender_ip: str) -> None:
|
||||||
while self._running:
|
"""Record a decoded leader frame and enter follower mode if needed."""
|
||||||
try:
|
|
||||||
data, addr = self._recv_sock.recvfrom(65535)
|
|
||||||
sender_ip = addr[0]
|
|
||||||
|
|
||||||
if data[:8] == _RAW_MAGIC or len(data) > 512:
|
|
||||||
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
|
|
||||||
try:
|
|
||||||
if data[:8] == _RAW_MAGIC:
|
|
||||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
|
||||||
raw = data[12:]
|
|
||||||
img = Image.frombuffer(
|
|
||||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback: try legacy PNG
|
|
||||||
img = Image.open(io.BytesIO(data))
|
|
||||||
img.load()
|
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
self._latest_frame = img
|
self._latest_frame = img
|
||||||
self._last_leader_frame_time = time.time()
|
self._last_leader_frame_time = time.time()
|
||||||
@@ -482,12 +479,62 @@ class DisplaySyncManager:
|
|||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
|
|
||||||
|
def _follower_recv_loop(self) -> None:
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
data, addr = self._recv_sock.recvfrom(65535)
|
||||||
|
sender_ip = addr[0]
|
||||||
|
|
||||||
|
if data[:8] == _RAW_MAGIC:
|
||||||
|
# Magic-tagged raw RGB frame — self-describing, no guessing.
|
||||||
|
try:
|
||||||
|
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||||
|
raw = data[12:]
|
||||||
|
img = Image.frombuffer(
|
||||||
|
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||||
|
)
|
||||||
|
self._handle_received_frame(img, sender_ip)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||||
else:
|
else:
|
||||||
# Control message
|
# No magic prefix. Whether the payload parses as JSON
|
||||||
|
# decides between a control message and a legacy
|
||||||
|
# (pre-magic) PNG frame — both wire formats are
|
||||||
|
# self-describing, so no size heuristic is needed. A
|
||||||
|
# >512-byte control message used to be misrouted into
|
||||||
|
# image decode and silently dropped.
|
||||||
try:
|
try:
|
||||||
msg = json.loads(data.decode("utf-8"))
|
msg = json.loads(data.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
# Not JSON — try a legacy PNG frame.
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
if img.width > _MAX_FRAME_W or img.height > _MAX_FRAME_H:
|
||||||
|
# Same cap the TCP image path applies: decode
|
||||||
|
# is deferred until load(), so check first.
|
||||||
|
self.logger.debug(
|
||||||
|
"Sync: rejected oversized legacy frame %dx%d from %s",
|
||||||
|
img.width, img.height, sender_ip,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
img.load()
|
||||||
|
self._handle_received_frame(img, sender_ip)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# It parsed, so it is a control message and never a
|
||||||
|
# frame. Read and validate its fields under a guard —
|
||||||
|
# a UDP payload is attacker-shaped, so a non-object
|
||||||
|
# body makes .get() raise AttributeError and an "sx"
|
||||||
|
# carrying a non-numeric x raises ValueError/TypeError
|
||||||
|
# — but dispatch the callback *outside* it. Running
|
||||||
|
# the callback in here would let a fault in someone
|
||||||
|
# else's code read as a malformed packet and be
|
||||||
|
# logged as one.
|
||||||
|
fire_new_cycle = False
|
||||||
|
try:
|
||||||
t = msg.get("t")
|
t = msg.get("t")
|
||||||
if t == "hello_ack":
|
if t == "hello_ack":
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
@@ -501,7 +548,17 @@ class DisplaySyncManager:
|
|||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
elif t == "sx":
|
elif t == "sx":
|
||||||
# Vegas scroll-position sync — tiny message, renders locally
|
# Vegas scroll-position sync — tiny message, renders locally
|
||||||
self._latest_scroll_x = float(msg["x"])
|
scroll_x = float(msg["x"])
|
||||||
|
if not math.isfinite(scroll_x):
|
||||||
|
# json.loads accepts the NaN/Infinity literals,
|
||||||
|
# and float("nan") accepts the strings, so a
|
||||||
|
# non-finite x reaches here intact. Left alone
|
||||||
|
# it poisons every offset computed from it —
|
||||||
|
# NaN comparisons are all false, so the
|
||||||
|
# follower renders a frame it can never scroll
|
||||||
|
# back from. Treat it as malformed.
|
||||||
|
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||||
|
self._latest_scroll_x = scroll_x
|
||||||
self._last_leader_frame_time = time.time()
|
self._last_leader_frame_time = time.time()
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
if self._follower_state == FollowerState.STANDALONE:
|
||||||
@@ -511,19 +568,22 @@ class DisplaySyncManager:
|
|||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
if self._on_new_cycle:
|
fire_new_cycle = True # build initial scroll image
|
||||||
self._on_new_cycle() # build initial scroll image
|
|
||||||
elif t == "nc":
|
elif t == "nc":
|
||||||
# Leader started a new scroll cycle — rebuild local image
|
# Leader started a new scroll cycle — rebuild local image
|
||||||
if self._on_new_cycle:
|
fire_new_cycle = True
|
||||||
|
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||||
|
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if fire_new_cycle and self._on_new_cycle:
|
||||||
self._on_new_cycle()
|
self._on_new_cycle()
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.debug("Sync follower recv error: %s", exc)
|
self.logger.debug("Sync follower recv error: %s", exc)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
def _follower_announce_loop(self) -> None:
|
def _follower_announce_loop(self) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
@@ -531,7 +591,7 @@ class DisplaySyncManager:
|
|||||||
"t": "hello",
|
"t": "hello",
|
||||||
"rows": hw.get("rows", 32),
|
"rows": hw.get("rows", 32),
|
||||||
"cols": hw.get("cols", 64),
|
"cols": hw.get("cols", 64),
|
||||||
"chain": hw.get("chain_length", 1),
|
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||||
}).encode("utf-8")
|
}).encode("utf-8")
|
||||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||||
dest = ("<broadcast>", self.port)
|
dest = ("<broadcast>", self.port)
|
||||||
@@ -602,7 +662,7 @@ class DisplaySyncManager:
|
|||||||
"port": self.port,
|
"port": self.port,
|
||||||
"local_rows": hw.get("rows", 32),
|
"local_rows": hw.get("rows", 32),
|
||||||
"local_cols": hw.get("cols", 64),
|
"local_cols": hw.get("cols", 64),
|
||||||
"local_chain": hw.get("chain_length", 1),
|
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.role == SyncRole.STANDALONE:
|
if self.role == SyncRole.STANDALONE:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from src.common.font_layout import load_truetype
|
||||||
|
|
||||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
@@ -60,7 +61,7 @@ class TextHelper:
|
|||||||
size = config['size']
|
size = config['size']
|
||||||
|
|
||||||
if font_path.exists():
|
if font_path.exists():
|
||||||
font = ImageFont.truetype(str(font_path), size)
|
font = load_truetype(str(font_path), size)
|
||||||
fonts[font_name] = font
|
fonts[font_name] = font
|
||||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+108
-18
@@ -7,9 +7,10 @@ and enable recovery from failed saves.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -19,6 +20,19 @@ from src.exceptions import ConfigError
|
|||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.common.permission_utils import ensure_shared_group_ownership
|
from src.common.permission_utils import ensure_shared_group_ownership
|
||||||
|
|
||||||
|
# Version stamp in a backup's filename: config.json.backup.<version>.
|
||||||
|
BACKUP_VERSION_FORMAT = "%Y%m%d_%H%M%S_%f"
|
||||||
|
|
||||||
|
# Backups written before the format gained microseconds. Still read, never
|
||||||
|
# written, so existing restore points on a rig stay usable after an upgrade.
|
||||||
|
LEGACY_BACKUP_VERSION_FORMAT = "%Y%m%d_%H%M%S"
|
||||||
|
|
||||||
|
# The numeric collision suffix _create_backup() appends to break a same-tick
|
||||||
|
# tie: config.json.backup.<version>-<N>. Only digits count as this suffix, so
|
||||||
|
# a hand-copied or renamed backup that happens to end in "-something" isn't
|
||||||
|
# mistaken for one and silently mis-parsed.
|
||||||
|
_BACKUP_COLLISION_SUFFIX_RE = re.compile(r"^(?P<base>.+)-(?P<collision>\d+)$")
|
||||||
|
|
||||||
|
|
||||||
class SaveResultStatus(Enum):
|
class SaveResultStatus(Enum):
|
||||||
"""Status of a save operation."""
|
"""Status of a save operation."""
|
||||||
@@ -246,22 +260,33 @@ class AtomicConfigManager:
|
|||||||
if not self.backup_dir.exists():
|
if not self.backup_dir.exists():
|
||||||
return backups
|
return backups
|
||||||
|
|
||||||
# Look for backup files (format: config.json.backup.YYYYMMDD_HHMMSS)
|
# Look for backup files (format: config.json.backup.<version>)
|
||||||
config_name = self.config_path.name
|
config_name = self.config_path.name
|
||||||
backup_pattern = f"{config_name}.backup.*"
|
backup_pattern = f"{config_name}.backup.*"
|
||||||
|
|
||||||
for backup_file in self.backup_dir.glob(backup_pattern):
|
for backup_file in self.backup_dir.glob(backup_pattern):
|
||||||
try:
|
try:
|
||||||
# Extract timestamp from filename
|
# The version reported here is what rollback_config() matches
|
||||||
# Format: config.json.backup.20240101_120000
|
# against, so it has to be the exact string in the filename.
|
||||||
parts = backup_file.stem.split('.')
|
#
|
||||||
if len(parts) >= 3 and parts[-2] == 'backup':
|
# It did not used to be. This read .stem, which drops only the
|
||||||
timestamp_str = parts[-1]
|
# last dot-component, so for config.json.backup.20240101_120000
|
||||||
timestamp = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S")
|
# parts was ['config', 'json', 'backup'] and parts[-2] was
|
||||||
else:
|
# 'json' -- never 'backup'. The filename branch could not be
|
||||||
# Fallback: use file modification time
|
# reached, every backup fell through to the mtime fallback, and
|
||||||
|
# the version was a second-granularity restamp of the mtime
|
||||||
|
# rather than the name on disk. Two backups a second apart could
|
||||||
|
# therefore report the same version, and rollback would pick
|
||||||
|
# whichever the glob happened to yield first.
|
||||||
|
# Strip the exact prefix the glob just matched, so a config
|
||||||
|
# whose own name contains '.backup.' can't shift the split.
|
||||||
|
timestamp_str = backup_file.name[len(f"{config_name}.backup."):]
|
||||||
|
timestamp = self._parse_backup_version(timestamp_str)
|
||||||
|
if timestamp is None:
|
||||||
|
# Not a version this code wrote (hand-copied, renamed).
|
||||||
|
# Order it by mtime, but keep the on-disk version string so
|
||||||
|
# it can still be named in a rollback.
|
||||||
timestamp = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
timestamp = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||||
timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
|
||||||
|
|
||||||
# Validate backup file
|
# Validate backup file
|
||||||
is_valid = self._validate_backup_file(backup_file)
|
is_valid = self._validate_backup_file(backup_file)
|
||||||
@@ -283,6 +308,35 @@ class AtomicConfigManager:
|
|||||||
|
|
||||||
return backups
|
return backups
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_backup_version(version: str) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Parse the ``<version>`` of a ``config.json.backup.<version>`` filename
|
||||||
|
into the time the backup was taken, or None if it is not a version this
|
||||||
|
class wrote.
|
||||||
|
|
||||||
|
Accepts the current microsecond format and the legacy second-granularity
|
||||||
|
one, with or without the ``-N`` suffix _create_backup() appends to break
|
||||||
|
a collision. When that suffix is present, N is folded into the result
|
||||||
|
as extra microseconds so same-tick collisions still sort in the order
|
||||||
|
they were created rather than tying.
|
||||||
|
"""
|
||||||
|
if not version:
|
||||||
|
return None
|
||||||
|
base = version
|
||||||
|
collision = 0
|
||||||
|
match = _BACKUP_COLLISION_SUFFIX_RE.match(version)
|
||||||
|
if match:
|
||||||
|
base = match.group('base')
|
||||||
|
collision = int(match.group('collision'))
|
||||||
|
for fmt in (BACKUP_VERSION_FORMAT, LEGACY_BACKUP_VERSION_FORMAT):
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(base, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return parsed + timedelta(microseconds=collision) if collision else parsed
|
||||||
|
return None
|
||||||
|
|
||||||
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
||||||
"""
|
"""
|
||||||
Validate a configuration file.
|
Validate a configuration file.
|
||||||
@@ -303,19 +357,55 @@ class AtomicConfigManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate backup filename with timestamp
|
# Generate backup filename with timestamp.
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
#
|
||||||
|
# This id is the backup's identity: save_config_atomic() returns the
|
||||||
|
# path, rollback_config(backup_version=...) looks the version up, and
|
||||||
|
# the paired secrets backup is found by reusing the same string. At
|
||||||
|
# second granularity two saves inside the same second produced the
|
||||||
|
# same filename, so the second copy2() below silently overwrote the
|
||||||
|
# first backup -- the path a caller was still holding then pointed at
|
||||||
|
# different content, and rolling back to it restored the wrong
|
||||||
|
# config. Microseconds make that collision vanishingly unlikely.
|
||||||
config_name = self.config_path.name
|
config_name = self.config_path.name
|
||||||
backup_filename = f"{config_name}.backup.{timestamp}"
|
backup_secrets = bool(self.secrets_path and self.secrets_path.exists())
|
||||||
backup_path = self.backup_dir / backup_filename
|
|
||||||
|
# exists() then copy2() is two steps: two concurrent callers can
|
||||||
|
# both see the path as free and pick the same one, so the second
|
||||||
|
# copy2() silently destroys the first call's restore point.
|
||||||
|
# Reserve the filename(s) with exclusive creation instead -- that
|
||||||
|
# is atomic, so only one caller can ever win a given timestamp.
|
||||||
|
# Each retry bumps the collision suffix, so this always
|
||||||
|
# terminates and stays compatible with _parse_backup_version().
|
||||||
|
collision = 0
|
||||||
|
while True:
|
||||||
|
timestamp = datetime.now().strftime(BACKUP_VERSION_FORMAT)
|
||||||
|
if collision:
|
||||||
|
timestamp = f"{timestamp}-{collision}"
|
||||||
|
backup_path = self.backup_dir / f"{config_name}.backup.{timestamp}"
|
||||||
|
secrets_backup_path = (
|
||||||
|
self.backup_dir / f"{self.secrets_path.name}.backup.{timestamp}"
|
||||||
|
if backup_secrets else None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
backup_path.touch(exist_ok=False)
|
||||||
|
except FileExistsError:
|
||||||
|
collision += 1
|
||||||
|
continue
|
||||||
|
if secrets_backup_path is not None:
|
||||||
|
try:
|
||||||
|
secrets_backup_path.touch(exist_ok=False)
|
||||||
|
except FileExistsError:
|
||||||
|
backup_path.unlink(missing_ok=True)
|
||||||
|
collision += 1
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
# Copy config file to backup
|
# Copy config file to backup
|
||||||
shutil.copy2(self.config_path, backup_path)
|
shutil.copy2(self.config_path, backup_path)
|
||||||
|
|
||||||
# Also backup secrets file if it exists
|
# Also backup secrets file if it exists
|
||||||
if self.secrets_path and self.secrets_path.exists():
|
if secrets_backup_path is not None:
|
||||||
secrets_backup_filename = f"{self.secrets_path.name}.backup.{timestamp}"
|
|
||||||
secrets_backup_path = self.backup_dir / secrets_backup_filename
|
|
||||||
shutil.copy2(self.secrets_path, secrets_backup_path)
|
shutil.copy2(self.secrets_path, secrets_backup_path)
|
||||||
|
|
||||||
# Rotate old backups
|
# Rotate old backups
|
||||||
|
|||||||
+305
-91
@@ -120,6 +120,15 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Startup validation could not be completed: {e}")
|
logger.warning(f"Startup validation could not be completed: {e}")
|
||||||
|
|
||||||
|
# Automatic updates need their health-check units, and this is the
|
||||||
|
# one root process running project code, so it installs them while
|
||||||
|
# automatic updates are on. See src/auto_update_setup.py.
|
||||||
|
try:
|
||||||
|
from src.auto_update_setup import ensure_update_helper
|
||||||
|
ensure_update_helper(self.config)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Automatic update setup could not be completed: %s", e)
|
||||||
|
|
||||||
config_time = time.time()
|
config_time = time.time()
|
||||||
self.display_manager = DisplayManager(self.config)
|
self.display_manager = DisplayManager(self.config)
|
||||||
logger.info("DisplayManager initialized in %.3f seconds", time.time() - config_time)
|
logger.info("DisplayManager initialized in %.3f seconds", time.time() - config_time)
|
||||||
@@ -181,6 +190,16 @@ class DisplayController:
|
|||||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||||
|
# plugin_display_modes is mutated only by _register_loaded_plugin /
|
||||||
|
# _unregister_plugin on the render thread, but the config-watcher
|
||||||
|
# thread reads it in _enabled_plugin_not_running. Both mutation sites
|
||||||
|
# run during reconcile (rare), so this lock never touches the per-frame
|
||||||
|
# path -- the hot-path reads are same-thread as the writes.
|
||||||
|
self._plugin_modes_lock = threading.Lock()
|
||||||
|
# Guards the consume-and-clear of _pending_plugin_reconcile. Only taken
|
||||||
|
# when a reconcile is actually pending or a config change arrives, both
|
||||||
|
# rare -- the per-frame path just reads the bool.
|
||||||
|
self._reconcile_flag_lock = threading.Lock()
|
||||||
# Per-plugin config-change callbacks, kept so we can unsubscribe a
|
# Per-plugin config-change callbacks, kept so we can unsubscribe a
|
||||||
# plugin when it is disabled live.
|
# plugin when it is disabled live.
|
||||||
self._plugin_config_callbacks: Dict[str, Callable] = {}
|
self._plugin_config_callbacks: Dict[str, Callable] = {}
|
||||||
@@ -188,6 +207,10 @@ class DisplayController:
|
|||||||
# the main run loop reconciles (loads/unloads) on its own thread so
|
# the main run loop reconciles (loads/unloads) on its own thread so
|
||||||
# mutating available_modes never races with rendering.
|
# mutating available_modes never races with rendering.
|
||||||
self._pending_plugin_reconcile = False
|
self._pending_plugin_reconcile = False
|
||||||
|
# Monotonic stamp of the last mailbox disk read; see
|
||||||
|
# _poll_on_demand_requests. None means "never polled", so the first
|
||||||
|
# call always goes through.
|
||||||
|
self._last_on_demand_poll: Optional[float] = None
|
||||||
self.on_demand_active = False
|
self.on_demand_active = False
|
||||||
self.on_demand_mode: Optional[str] = None
|
self.on_demand_mode: Optional[str] = None
|
||||||
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
||||||
@@ -293,39 +316,7 @@ class DisplayController:
|
|||||||
|
|
||||||
# Check for on-demand plugin filter from cache
|
# Check for on-demand plugin filter from cache
|
||||||
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
|
||||||
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
|
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
|
||||||
|
|
||||||
if on_demand_plugin_id:
|
|
||||||
logger.info("On-demand mode detected during initialization: filtering to plugin '%s' only", on_demand_plugin_id)
|
|
||||||
# Only load the on-demand plugin, but ensure it's enabled
|
|
||||||
if on_demand_plugin_id not in discovered_plugins:
|
|
||||||
error_msg = f"On-demand plugin '{on_demand_plugin_id}' not found in discovered plugins"
|
|
||||||
logger.error(error_msg)
|
|
||||||
logger.warning("Falling back to normal mode (all enabled plugins)")
|
|
||||||
on_demand_plugin_id = None
|
|
||||||
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
|
||||||
else:
|
|
||||||
plugin_config = self.config.get(on_demand_plugin_id, {})
|
|
||||||
was_disabled = not plugin_config.get('enabled', False)
|
|
||||||
if was_disabled:
|
|
||||||
logger.info("Temporarily enabling plugin '%s' for on-demand mode", on_demand_plugin_id)
|
|
||||||
if on_demand_plugin_id not in self.config:
|
|
||||||
self.config[on_demand_plugin_id] = {}
|
|
||||||
self.config[on_demand_plugin_id]['enabled'] = True
|
|
||||||
enabled_plugins = [on_demand_plugin_id]
|
|
||||||
# Set on-demand state from cached config
|
|
||||||
self.on_demand_active = True
|
|
||||||
self.on_demand_plugin_id = on_demand_plugin_id
|
|
||||||
self.on_demand_mode = on_demand_config.get('mode')
|
|
||||||
self.on_demand_duration = on_demand_config.get('duration')
|
|
||||||
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
|
||||||
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
|
||||||
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
|
||||||
self.on_demand_status = 'active'
|
|
||||||
self.on_demand_schedule_override = True
|
|
||||||
logger.info("On-demand mode: loading only plugin '%s'", on_demand_plugin_id)
|
|
||||||
else:
|
|
||||||
enabled_plugins = [p for p in discovered_plugins if self.config.get(p, {}).get('enabled', False)]
|
|
||||||
|
|
||||||
# Count enabled plugins for progress tracking
|
# Count enabled plugins for progress tracking
|
||||||
enabled_count = len(enabled_plugins)
|
enabled_count = len(enabled_plugins)
|
||||||
@@ -463,7 +454,9 @@ class DisplayController:
|
|||||||
self._refresh_config_cache(new_config)
|
self._refresh_config_cache(new_config)
|
||||||
# If a plugin was enabled/disabled, flag a reconcile for the main
|
# If a plugin was enabled/disabled, flag a reconcile for the main
|
||||||
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
||||||
if self._enabled_set_changed(old_config, new_config):
|
if (self._enabled_set_changed(old_config, new_config)
|
||||||
|
or self._enabled_plugin_not_running(new_config)):
|
||||||
|
with self._reconcile_flag_lock:
|
||||||
self._pending_plugin_reconcile = True
|
self._pending_plugin_reconcile = True
|
||||||
|
|
||||||
self.config_service.subscribe(_controller_config_change)
|
self.config_service.subscribe(_controller_config_change)
|
||||||
@@ -1255,12 +1248,119 @@ class DisplayController:
|
|||||||
self.on_demand_schedule_override = False
|
self.on_demand_schedule_override = False
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
|
|
||||||
|
#: Shortest gap between mailbox disk reads. This is called after every
|
||||||
|
#: frame -- about 125 times a second on a scrolling mode -- and the read
|
||||||
|
#: below is deliberately uncached, so without a floor it was 125 disk reads
|
||||||
|
#: per second to find nothing. An on-demand request comes from a person
|
||||||
|
#: clicking in the web UI, so a quarter second of latency is not
|
||||||
|
#: perceptible, and it cuts the read rate by 30x.
|
||||||
|
ON_DEMAND_POLL_INTERVAL = 0.25
|
||||||
|
|
||||||
|
def _select_startup_plugins(self, discovered_plugins: List[str],
|
||||||
|
on_demand_config: Optional[Dict[str, Any]]) -> List[str]:
|
||||||
|
"""Which plugins to load at startup, restoring on-demand state if any.
|
||||||
|
|
||||||
|
Every normally-enabled plugin loads, on-demand or not. Loading only the
|
||||||
|
on-demand plugin left every other plugin unavailable for the rest of
|
||||||
|
the process's life whenever the service was restarted while on-demand
|
||||||
|
was still active -- and a restart during an on-demand session is
|
||||||
|
routine, since that is how updates and config changes are applied. The
|
||||||
|
panel came back cycling that one plugin's modes and nothing else, with
|
||||||
|
no way out but clearing the on-demand cache by hand.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
enabled_plugins = [p for p in discovered_plugins
|
||||||
|
if self.config.get(p, {}).get('enabled', False)]
|
||||||
|
|
||||||
|
on_demand_plugin_id = on_demand_config.get('plugin_id') if on_demand_config else None
|
||||||
|
if not on_demand_plugin_id:
|
||||||
|
return enabled_plugins
|
||||||
|
|
||||||
|
if on_demand_plugin_id not in discovered_plugins:
|
||||||
|
logger.error("On-demand plugin '%s' not found in discovered plugins",
|
||||||
|
on_demand_plugin_id)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Restore on-demand state from the cached request so it resumes.
|
||||||
|
self.on_demand_active = True
|
||||||
|
self.on_demand_plugin_id = on_demand_plugin_id
|
||||||
|
self.on_demand_mode = on_demand_config.get('mode')
|
||||||
|
self.on_demand_duration = on_demand_config.get('duration')
|
||||||
|
self.on_demand_pinned = on_demand_config.get('pinned', False)
|
||||||
|
self.on_demand_requested_at = on_demand_config.get('requested_at')
|
||||||
|
self.on_demand_expires_at = on_demand_config.get('expires_at')
|
||||||
|
self.on_demand_status = 'active'
|
||||||
|
self.on_demand_schedule_override = True
|
||||||
|
logger.info("On-demand mode detected during initialization: resuming on plugin '%s'; "
|
||||||
|
"all %d enabled plugin(s) still load normally",
|
||||||
|
on_demand_plugin_id, len(enabled_plugins))
|
||||||
|
return enabled_plugins
|
||||||
|
|
||||||
|
def _consume_on_demand_request(self, request_id: str) -> None:
|
||||||
|
"""Remove the request we just handled from the mailbox.
|
||||||
|
|
||||||
|
Leaving it on disk meant a restart replayed the previous request: the
|
||||||
|
fresh controller read it, activated it and cached it, so the request
|
||||||
|
the caller had just made was ignored and the panel silently showed the
|
||||||
|
earlier plugin.
|
||||||
|
|
||||||
|
Compare before deleting. The web process can post a newer request
|
||||||
|
between the read and this delete; an unconditional delete threw that
|
||||||
|
one away and it was never processed -- the user's second click did
|
||||||
|
nothing. Re-reading uncached and only deleting our own request_id
|
||||||
|
leaves a newer request in the mailbox for the next poll instead.
|
||||||
|
|
||||||
|
This narrows the window rather than closing it: a request landing
|
||||||
|
between the re-read and the delete is still lost. Closing it properly
|
||||||
|
needs an atomic claim (a rename, or a compare-and-delete primitive)
|
||||||
|
that the cache layer does not currently offer, so the honest fix is a
|
||||||
|
smaller window plus this note, not a bigger lock. For start requests
|
||||||
|
processed_id still guards against reprocessing if the delete fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
current = self.cache_manager.get('display_on_demand_request',
|
||||||
|
max_age=3600, memory_ttl=0)
|
||||||
|
if not current or current.get('request_id') == request_id:
|
||||||
|
self.cache_manager.delete('display_on_demand_request')
|
||||||
|
else:
|
||||||
|
logger.debug("Newer on-demand request %s arrived while processing "
|
||||||
|
"%s; leaving it in the mailbox",
|
||||||
|
current.get('request_id'), request_id)
|
||||||
|
except (OSError, AttributeError, KeyError) as err:
|
||||||
|
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
||||||
|
|
||||||
def _poll_on_demand_requests(self) -> None:
|
def _poll_on_demand_requests(self) -> None:
|
||||||
"""Poll cache for new on-demand requests from external controllers."""
|
"""Poll cache for new on-demand requests from external controllers."""
|
||||||
|
now = time.monotonic()
|
||||||
|
if (self._last_on_demand_poll is not None
|
||||||
|
and now - self._last_on_demand_poll < self.ON_DEMAND_POLL_INTERVAL):
|
||||||
|
return
|
||||||
|
self._last_on_demand_poll = now
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
||||||
# The request_id check prevents duplicate processing
|
# The request_id check prevents duplicate processing.
|
||||||
request = self.cache_manager.get('display_on_demand_request', max_age=3600)
|
#
|
||||||
|
# memory_ttl=0 is required, not optional: this key is a mailbox the
|
||||||
|
# web process writes and this process reads. get() defaults the
|
||||||
|
# in-memory TTL to max_age, so without it the first request read was
|
||||||
|
# pinned in memory for the full hour and every later poll returned
|
||||||
|
# that stale copy -- meaning no second on-demand request was honoured
|
||||||
|
# for an hour, while the API still reported success.
|
||||||
|
request = self.cache_manager.get('display_on_demand_request',
|
||||||
|
max_age=3600, memory_ttl=0)
|
||||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||||
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
||||||
return
|
return
|
||||||
@@ -1287,6 +1387,13 @@ class DisplayController:
|
|||||||
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
logger.debug("Stop request %s received but on-demand is not active", request_id)
|
||||||
# Still update request_id to acknowledge the request
|
# Still update request_id to acknowledge the request
|
||||||
self.on_demand_request_id = request_id
|
self.on_demand_request_id = request_id
|
||||||
|
# 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
|
||||||
|
# only thing that ends the request: without it the same stop was
|
||||||
|
# re-read and re-processed on every poll, forever, logging at
|
||||||
|
# ON_DEMAND_POLL_INTERVAL for the life of the process.
|
||||||
|
self._consume_on_demand_request(request_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# For start requests, check if already processed
|
# For start requests, check if already processed
|
||||||
@@ -1306,6 +1413,7 @@ class DisplayController:
|
|||||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||||
self.on_demand_request_id = request_id
|
self.on_demand_request_id = request_id
|
||||||
|
self._consume_on_demand_request(request_id)
|
||||||
|
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||||
@@ -1348,17 +1456,14 @@ class DisplayController:
|
|||||||
return modes[0]
|
return modes[0]
|
||||||
return plugin_id
|
return plugin_id
|
||||||
|
|
||||||
def _populate_on_demand_modes_from_plugin(self) -> None:
|
def _on_demand_modes_for_plugin(self, plugin_id: str) -> List[str]:
|
||||||
"""
|
"""Every loaded display mode belonging to `plugin_id`, in rotation order.
|
||||||
Populate on_demand_modes from the on-demand plugin's display modes.
|
|
||||||
Called after plugin loading completes when on-demand state is restored from cache.
|
|
||||||
"""
|
|
||||||
if not self.on_demand_active or not self.on_demand_plugin_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
plugin_id = self.on_demand_plugin_id
|
Live modes that actually have content lead, then the rest, then live
|
||||||
|
modes with nothing to show -- so an on-demand request for a sports
|
||||||
# Get all modes for this plugin
|
plugin opens on a game in progress rather than an empty live screen.
|
||||||
|
Returns an empty list when the plugin has no loaded modes.
|
||||||
|
"""
|
||||||
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
|
plugin_modes = self.plugin_display_modes.get(plugin_id, [])
|
||||||
if not plugin_modes:
|
if not plugin_modes:
|
||||||
# Fallback: find all modes that belong to this plugin
|
# Fallback: find all modes that belong to this plugin
|
||||||
@@ -1366,11 +1471,8 @@ class DisplayController:
|
|||||||
|
|
||||||
# Filter to only include modes that exist in plugin_modes
|
# Filter to only include modes that exist in plugin_modes
|
||||||
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
||||||
|
|
||||||
if not available_plugin_modes:
|
if not available_plugin_modes:
|
||||||
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
return []
|
||||||
self.on_demand_modes = []
|
|
||||||
return
|
|
||||||
|
|
||||||
# Prioritize live modes if they exist and have content
|
# Prioritize live modes if they exist and have content
|
||||||
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
||||||
@@ -1398,6 +1500,45 @@ class DisplayController:
|
|||||||
# Only live modes available but no content - use them anyway
|
# Only live modes available but no content - use them anyway
|
||||||
ordered_modes = live_modes
|
ordered_modes = live_modes
|
||||||
|
|
||||||
|
return ordered_modes
|
||||||
|
|
||||||
|
def _apply_on_demand_pin(self, ordered_modes: List[str], resolved_mode: Optional[str],
|
||||||
|
pinned: bool) -> List[str]:
|
||||||
|
"""Narrow an on-demand rotation to the single requested mode when pinned.
|
||||||
|
|
||||||
|
`pinned` reaches the controller from the API and was stored and
|
||||||
|
republished but never acted on, so a pinned request still rotated
|
||||||
|
through every mode the resolved plugin owns. That is the right default
|
||||||
|
for a sports plugin, whose modes are views of one subject
|
||||||
|
(nfl_live/nfl_recent/nfl_upcoming), and the wrong one for a plugin
|
||||||
|
whose modes are unrelated -- each Starlark app is its own widget, so
|
||||||
|
asking for one and getting all of them is not what was requested.
|
||||||
|
"""
|
||||||
|
if not pinned or not resolved_mode or resolved_mode not in ordered_modes:
|
||||||
|
return ordered_modes
|
||||||
|
return [resolved_mode]
|
||||||
|
|
||||||
|
def _populate_on_demand_modes_from_plugin(self) -> None:
|
||||||
|
"""
|
||||||
|
Populate on_demand_modes from the on-demand plugin's display modes.
|
||||||
|
Called after plugin loading completes when on-demand state is restored from cache.
|
||||||
|
"""
|
||||||
|
if not self.on_demand_active or not self.on_demand_plugin_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin_id = self.on_demand_plugin_id
|
||||||
|
|
||||||
|
ordered_modes = self._on_demand_modes_for_plugin(plugin_id)
|
||||||
|
if not ordered_modes:
|
||||||
|
logger.warning("No valid display modes found for on-demand plugin '%s' after restoration", plugin_id)
|
||||||
|
self.on_demand_modes = []
|
||||||
|
return
|
||||||
|
|
||||||
|
# A restart must not silently un-pin: the pin is part of the request
|
||||||
|
# being resumed, and it is restored from the same cached config above.
|
||||||
|
ordered_modes = self._apply_on_demand_pin(
|
||||||
|
ordered_modes, self.on_demand_mode, self.on_demand_pinned)
|
||||||
|
|
||||||
self.on_demand_modes = ordered_modes
|
self.on_demand_modes = ordered_modes
|
||||||
# Set index to match the restored mode if available, otherwise start at 0
|
# Set index to match the restored mode if available, otherwise start at 0
|
||||||
if self.on_demand_mode and self.on_demand_mode in ordered_modes:
|
if self.on_demand_mode and self.on_demand_mode in ordered_modes:
|
||||||
@@ -1452,45 +1593,13 @@ class DisplayController:
|
|||||||
if resolved_mode in self.available_modes:
|
if resolved_mode in self.available_modes:
|
||||||
self.current_mode_index = self.available_modes.index(resolved_mode)
|
self.current_mode_index = self.available_modes.index(resolved_mode)
|
||||||
|
|
||||||
# Get all modes for this plugin
|
ordered_modes = self._on_demand_modes_for_plugin(resolved_plugin_id)
|
||||||
plugin_modes = self.plugin_display_modes.get(resolved_plugin_id, [])
|
if not ordered_modes:
|
||||||
if not plugin_modes:
|
|
||||||
# Fallback: find all modes that belong to this plugin
|
|
||||||
plugin_modes = [mode for mode, pid in self.mode_to_plugin_id.items() if pid == resolved_plugin_id]
|
|
||||||
|
|
||||||
# Filter to only include modes that exist in plugin_modes
|
|
||||||
available_plugin_modes = [m for m in plugin_modes if m in self.plugin_modes]
|
|
||||||
|
|
||||||
if not available_plugin_modes:
|
|
||||||
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
|
logger.error("No valid display modes found for plugin '%s'", resolved_plugin_id)
|
||||||
self._set_on_demand_error("no-modes")
|
self._set_on_demand_error("no-modes")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Prioritize live modes if they exist and have content
|
ordered_modes = self._apply_on_demand_pin(ordered_modes, resolved_mode, pinned)
|
||||||
live_modes = [m for m in available_plugin_modes if m.endswith('_live')]
|
|
||||||
other_modes = [m for m in available_plugin_modes if not m.endswith('_live')]
|
|
||||||
|
|
||||||
# Check if live modes have content
|
|
||||||
live_with_content = []
|
|
||||||
for live_mode in live_modes:
|
|
||||||
plugin_instance = self.plugin_modes.get(live_mode)
|
|
||||||
if plugin_instance and hasattr(plugin_instance, 'has_live_content'):
|
|
||||||
try:
|
|
||||||
if plugin_instance.has_live_content():
|
|
||||||
live_with_content.append(live_mode)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Build mode list: live modes with content first, then other modes, then live modes without content
|
|
||||||
if live_with_content:
|
|
||||||
ordered_modes = live_with_content + other_modes + [m for m in live_modes if m not in live_with_content]
|
|
||||||
else:
|
|
||||||
# No live content, skip live modes
|
|
||||||
ordered_modes = other_modes
|
|
||||||
|
|
||||||
if not ordered_modes:
|
|
||||||
# Only live modes available but no content - use them anyway
|
|
||||||
ordered_modes = live_modes
|
|
||||||
|
|
||||||
self.on_demand_active = True
|
self.on_demand_active = True
|
||||||
self.on_demand_mode = resolved_mode # Keep for backward compatibility
|
self.on_demand_mode = resolved_mode # Keep for backward compatibility
|
||||||
@@ -1749,11 +1858,12 @@ class DisplayController:
|
|||||||
# rebuilding available_modes happens here on the render thread so
|
# rebuilding available_modes happens here on the render thread so
|
||||||
# it can't race with rendering. Deferred while on-demand is active
|
# it can't race with rendering. Deferred while on-demand is active
|
||||||
# (the flag stays set) so we don't fight its temporary-enable.
|
# (the flag stays set) so we don't fight its temporary-enable.
|
||||||
|
# The lock-free read is a fast path only; it can be a false
|
||||||
|
# negative (the watcher setting the flag just after it is read
|
||||||
|
# is seen next iteration), never a false positive that loses a
|
||||||
|
# request.
|
||||||
if self._pending_plugin_reconcile and not self.on_demand_active:
|
if self._pending_plugin_reconcile and not self.on_demand_active:
|
||||||
# Only clear the flag on success -- a retryable failure
|
self._service_pending_reconcile()
|
||||||
# (e.g. discovery) leaves it set so the request isn't lost.
|
|
||||||
if self._reconcile_enabled_plugins():
|
|
||||||
self._pending_plugin_reconcile = False
|
|
||||||
|
|
||||||
if not self.available_modes:
|
if not self.available_modes:
|
||||||
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
||||||
@@ -2068,7 +2178,14 @@ class DisplayController:
|
|||||||
types.SimpleNamespace(display=_display_target),
|
types.SimpleNamespace(display=_display_target),
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force_clear=self.force_change,
|
force_clear=self.force_change,
|
||||||
display_mode=active_mode if _accepts_display_mode else None
|
display_mode=active_mode if _accepts_display_mode else None,
|
||||||
|
# Already resolved and cached above.
|
||||||
|
# Without this the executor re-derives
|
||||||
|
# it with inspect.signature() against
|
||||||
|
# the SimpleNamespace built two lines
|
||||||
|
# up -- a fresh callable every call, so
|
||||||
|
# nothing there can ever cache.
|
||||||
|
accepts_display_mode=_accepts_display_mode
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover - defensive;
|
except Exception: # pragma: no cover - defensive;
|
||||||
# execute_display catches everything
|
# execute_display catches everything
|
||||||
@@ -2391,7 +2508,17 @@ class DisplayController:
|
|||||||
1.0 / display_interval
|
1.0 / display_interval
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Deliberate: frames after the first call
|
||||||
|
# display() directly rather than through
|
||||||
|
# PluginExecutor. The executor spawns a thread per
|
||||||
|
# call, which at this loop's frame rate would cost
|
||||||
|
# more than the advisory timeout it buys -- and
|
||||||
|
# that timeout cannot cancel a hung plugin anyway
|
||||||
|
# (see execute_with_timeout). The first dispatch
|
||||||
|
# above still goes through it, so load-time
|
||||||
|
# failures are still caught and recorded.
|
||||||
while True:
|
while True:
|
||||||
|
_frame_start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||||
if can_display:
|
if can_display:
|
||||||
@@ -2412,11 +2539,26 @@ class DisplayController:
|
|||||||
# Multi-display sync: send follower frame after each render
|
# Multi-display sync: send follower frame after each render
|
||||||
self._send_follower_frame(manager_to_display)
|
self._send_follower_frame(manager_to_display)
|
||||||
|
|
||||||
time.sleep(display_interval)
|
|
||||||
self._tick_plugin_updates()
|
self._tick_plugin_updates()
|
||||||
self._poll_on_demand_requests()
|
self._poll_on_demand_requests()
|
||||||
self._check_on_demand_expiration()
|
self._check_on_demand_expiration()
|
||||||
|
|
||||||
|
# Pace to the frame deadline rather than sleeping a flat
|
||||||
|
# interval on top of the work. display() has already
|
||||||
|
# blocked on the panel's vsync by this point, so an
|
||||||
|
# unconditional sleep is added to a wait that already
|
||||||
|
# happened. Measured on a 2x128x64 chain at
|
||||||
|
# limit_refresh_rate_hz=100: ~4ms of render plus a flat
|
||||||
|
# 8ms put each iteration at ~12ms against a 10ms refresh
|
||||||
|
# grid, so every swap missed a refresh and the loop
|
||||||
|
# settled at 50fps where display_interval asks for 125 --
|
||||||
|
# and with zero headroom, ~14% of frames slipped a
|
||||||
|
# further refresh, which is what reads as scroll stutter.
|
||||||
|
_remaining = display_interval - (time.perf_counter() - _frame_start)
|
||||||
|
# Yield even when the frame overran its budget, so plugin
|
||||||
|
# update threads and the web UI are not starved of the GIL.
|
||||||
|
time.sleep(_remaining if _remaining > 0 else 0.001)
|
||||||
|
|
||||||
if self.current_display_mode != active_mode:
|
if self.current_display_mode != active_mode:
|
||||||
logger.debug("Mode changed during high-FPS loop, breaking early")
|
logger.debug("Mode changed during high-FPS loop, breaking early")
|
||||||
break
|
break
|
||||||
@@ -2447,6 +2589,15 @@ class DisplayController:
|
|||||||
display_interval
|
display_interval
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Deliberate: frames after the first call
|
||||||
|
# display() directly rather than through
|
||||||
|
# PluginExecutor. The executor spawns a thread per
|
||||||
|
# call, which at this loop's frame rate would cost
|
||||||
|
# more than the advisory timeout it buys -- and
|
||||||
|
# that timeout cannot cancel a hung plugin anyway
|
||||||
|
# (see execute_with_timeout). The first dispatch
|
||||||
|
# above still goes through it, so load-time
|
||||||
|
# failures are still caught and recorded.
|
||||||
while True:
|
while True:
|
||||||
time.sleep(display_interval)
|
time.sleep(display_interval)
|
||||||
self._tick_plugin_updates()
|
self._tick_plugin_updates()
|
||||||
@@ -2813,6 +2964,7 @@ class DisplayController:
|
|||||||
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
||||||
if not (isinstance(display_modes, list) and display_modes):
|
if not (isinstance(display_modes, list) and display_modes):
|
||||||
display_modes = [plugin_id]
|
display_modes = [plugin_id]
|
||||||
|
with self._plugin_modes_lock:
|
||||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||||
|
|
||||||
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
||||||
@@ -2847,6 +2999,7 @@ class DisplayController:
|
|||||||
def _unregister_plugin(self, plugin_id: str) -> None:
|
def _unregister_plugin(self, plugin_id: str) -> None:
|
||||||
"""Remove a plugin's modes, config subscription and instance, then
|
"""Remove a plugin's modes, config subscription and instance, then
|
||||||
unload it. Used by live disable hot-reload."""
|
unload it. Used by live disable hot-reload."""
|
||||||
|
with self._plugin_modes_lock:
|
||||||
modes = self.plugin_display_modes.pop(plugin_id, [])
|
modes = self.plugin_display_modes.pop(plugin_id, [])
|
||||||
for mode in modes:
|
for mode in modes:
|
||||||
if mode in self.available_modes:
|
if mode in self.available_modes:
|
||||||
@@ -2892,6 +3045,67 @@ class DisplayController:
|
|||||||
}
|
}
|
||||||
return enabled_map(old_config) != enabled_map(new_config)
|
return enabled_map(old_config) != enabled_map(new_config)
|
||||||
|
|
||||||
|
def _service_pending_reconcile(self) -> None:
|
||||||
|
"""Consume a pending reconcile request and run it.
|
||||||
|
|
||||||
|
The request is consumed BEFORE reconciling, not cleared after. Clearing
|
||||||
|
after would drop any config change that lands while reconcile is
|
||||||
|
running: reconcile has already read its config by then, so the clear
|
||||||
|
erases a request it never served and the newest config never
|
||||||
|
reconciles -- the same "your save did nothing" failure this whole path
|
||||||
|
exists to prevent. Consuming first means such a request stays set and
|
||||||
|
is picked up on the next pass.
|
||||||
|
|
||||||
|
A retryable failure (e.g. discovery) re-arms the flag.
|
||||||
|
"""
|
||||||
|
with self._reconcile_flag_lock:
|
||||||
|
pending = self._pending_plugin_reconcile
|
||||||
|
self._pending_plugin_reconcile = False
|
||||||
|
if pending and not self._reconcile_enabled_plugins():
|
||||||
|
with self._reconcile_flag_lock:
|
||||||
|
self._pending_plugin_reconcile = True
|
||||||
|
|
||||||
|
def _enabled_plugin_not_running(self, new_config: Dict[str, Any]) -> bool:
|
||||||
|
"""True when a discovered plugin is enabled in config but not running.
|
||||||
|
|
||||||
|
``_enabled_set_changed`` compares only top-level ``enabled`` flags, which
|
||||||
|
misses the case that strands a plugin: one whose ``validate_config()``
|
||||||
|
returned False is absent from the running set, and the edit that fixes it
|
||||||
|
(enabling a league, filling in an API key) lives *nested* inside that
|
||||||
|
plugin's own section. No top-level flag changes, so no reconcile is
|
||||||
|
queued, and the save that should have fixed it appears to do nothing --
|
||||||
|
only toggling some unrelated plugin recovers it. hockey-scoreboard sat
|
||||||
|
enabled-but-absent on a live rig for four days this way.
|
||||||
|
|
||||||
|
Deliberately narrow: it fires only for ids the plugin manager has
|
||||||
|
actually discovered, so non-plugin sections that carry their own
|
||||||
|
``enabled`` flag (``schedule``, ``display``, ...) don't queue a reconcile
|
||||||
|
on every save. In the steady state -- everything enabled is loaded --
|
||||||
|
this is False and costs nothing. That matters because reconcile runs
|
||||||
|
``discover_plugins()`` on the render thread, where a needless
|
||||||
|
filesystem scan per config save would show up as a frame hitch.
|
||||||
|
|
||||||
|
Runs on the config-watcher thread, so both mappings it reads are
|
||||||
|
snapshotted under the lock that guards their writes.
|
||||||
|
"""
|
||||||
|
if self.plugin_manager is None:
|
||||||
|
return False
|
||||||
|
# Two snapshots, each taken under its own lock and never nested, so a
|
||||||
|
# half-written mapping is never observed and this can't deadlock
|
||||||
|
# against discovery (which holds the discovery lock while rebuilding).
|
||||||
|
try:
|
||||||
|
known = self.plugin_manager.discovered_plugin_ids()
|
||||||
|
except AttributeError:
|
||||||
|
# Older manager without the accessor: fall back to a plain read.
|
||||||
|
known = set(getattr(self.plugin_manager, 'plugin_manifests', ()) or ())
|
||||||
|
with self._plugin_modes_lock:
|
||||||
|
running = set(self.plugin_display_modes)
|
||||||
|
for key, value in new_config.items():
|
||||||
|
if (key in known and isinstance(value, dict)
|
||||||
|
and value.get('enabled', False) and key not in running):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _reconcile_enabled_plugins(self) -> bool:
|
def _reconcile_enabled_plugins(self) -> bool:
|
||||||
"""Load/unload plugins so the running set matches the enabled set in
|
"""Load/unload plugins so the running set matches the enabled set in
|
||||||
config. Runs on the main display thread (never the config-watcher
|
config. Runs on the main display thread (never the config-watcher
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Display size from config: the one computation every caller shares.
|
||||||
|
|
||||||
|
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
|
||||||
|
``display.double_sided``. The web preview, the Starlark magnify default and
|
||||||
|
the multi-display sync handshake used to re-derive that size themselves,
|
||||||
|
each with its own defaults (``chain_length`` fell back to 2 in one place and
|
||||||
|
1 in three others) and none of them applying double-sided mode. They now all
|
||||||
|
call this module.
|
||||||
|
|
||||||
|
Kept free of hardware imports on purpose: the web interface imports it, and
|
||||||
|
``display_manager`` pulls in ``rgbmatrix``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Match config/config.template.json's display.hardware block.
|
||||||
|
DEFAULT_ROWS = 32
|
||||||
|
DEFAULT_COLS = 64
|
||||||
|
DEFAULT_CHAIN_LENGTH = 2
|
||||||
|
DEFAULT_PARALLEL = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||||
|
# A hand-edited config.json can hold anything here; treat a non-mapping
|
||||||
|
# like a missing block so callers get the defaults, not AttributeError.
|
||||||
|
display = (config or {}).get('display')
|
||||||
|
return display if isinstance(display, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||||
|
hw = _display(config).get('hardware')
|
||||||
|
return hw if isinstance(hw, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
|
||||||
|
"""Width and height of the whole panel chain, in pixels.
|
||||||
|
|
||||||
|
``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
|
||||||
|
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
|
||||||
|
decide their own fallback.
|
||||||
|
|
||||||
|
A non-finite value (``Infinity``, which Python's JSON parser accepts in a
|
||||||
|
hand-edited config.json) raises ``ValueError`` too, not ``OverflowError``,
|
||||||
|
so every caller's existing fallback catches it.
|
||||||
|
"""
|
||||||
|
hw = _hardware(config)
|
||||||
|
try:
|
||||||
|
rows = int(hw.get('rows', DEFAULT_ROWS))
|
||||||
|
cols = int(hw.get('cols', DEFAULT_COLS))
|
||||||
|
chain_length = int(hw.get('chain_length', DEFAULT_CHAIN_LENGTH))
|
||||||
|
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
|
||||||
|
except OverflowError as e:
|
||||||
|
raise ValueError(f"display.hardware size is not finite: {e}") from e
|
||||||
|
return max(1, cols * chain_length), max(1, rows * parallel)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_double_sided(physical_width: int, physical_height: int,
|
||||||
|
ds_config: Dict[str, Any],
|
||||||
|
quiet: bool = False) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Validate the ``display.double_sided`` config against the physical size.
|
||||||
|
|
||||||
|
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
||||||
|
feature is enabled and the physical panel divides evenly into ``copies``
|
||||||
|
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
||||||
|
config is logged and disabled rather than raised — a misconfigured panel
|
||||||
|
should still light up.
|
||||||
|
|
||||||
|
Only pixels are checked, not whole panels: ``chain_length`` and
|
||||||
|
``parallel`` don't say which axis a panel lies on once an orientation
|
||||||
|
``Rotate:`` or U-mapper ``pixel_mapper_config`` rearranges the chain.
|
||||||
|
|
||||||
|
``quiet`` suppresses the log lines, for callers that run on every web
|
||||||
|
request and would otherwise repeat them on each poll.
|
||||||
|
"""
|
||||||
|
def _log(level, *args):
|
||||||
|
if not quiet:
|
||||||
|
logger.log(level, *args)
|
||||||
|
|
||||||
|
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
copies = ds_config.get('copies', 2)
|
||||||
|
if not isinstance(copies, int) or copies < 2:
|
||||||
|
_log(logging.WARNING,
|
||||||
|
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
||||||
|
"disabling double-sided mode", copies)
|
||||||
|
return None
|
||||||
|
|
||||||
|
axis = ds_config.get('axis', 'horizontal')
|
||||||
|
if axis not in ('horizontal', 'vertical'):
|
||||||
|
_log(logging.WARNING,
|
||||||
|
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
||||||
|
"(got %r); defaulting to 'horizontal'", axis)
|
||||||
|
axis = 'horizontal'
|
||||||
|
|
||||||
|
# Horizontal splits the chain (panels side by side); vertical splits the
|
||||||
|
# parallel outputs (panels stacked). The split axis must divide evenly.
|
||||||
|
if axis == 'horizontal':
|
||||||
|
if physical_width % copies != 0:
|
||||||
|
_log(logging.WARNING,
|
||||||
|
"double_sided: physical width %d is not divisible by copies "
|
||||||
|
"%d; disabling double-sided mode", physical_width, copies)
|
||||||
|
return None
|
||||||
|
logical_width = physical_width // copies
|
||||||
|
logical_height = physical_height
|
||||||
|
else:
|
||||||
|
if physical_height % copies != 0:
|
||||||
|
_log(logging.WARNING,
|
||||||
|
"double_sided: physical height %d is not divisible by copies "
|
||||||
|
"%d; disabling double-sided mode", physical_height, copies)
|
||||||
|
return None
|
||||||
|
logical_width = physical_width
|
||||||
|
logical_height = physical_height // copies
|
||||||
|
|
||||||
|
_log(logging.INFO,
|
||||||
|
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
||||||
|
"tiled across physical %dx%d", copies, axis, logical_width,
|
||||||
|
logical_height, physical_width, physical_height)
|
||||||
|
return {
|
||||||
|
'copies': copies,
|
||||||
|
'axis': axis,
|
||||||
|
'logical_width': logical_width,
|
||||||
|
'logical_height': logical_height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def logical_size(config: Optional[Mapping[str, Any]],
|
||||||
|
quiet: bool = True) -> Tuple[int, int]:
|
||||||
|
"""The size plugins draw at and the web preview shows.
|
||||||
|
|
||||||
|
The physical size, divided by ``double_sided.copies`` along its axis when
|
||||||
|
double-sided mode is enabled and valid — the same answer
|
||||||
|
``DisplayManager.width``/``height`` give.
|
||||||
|
"""
|
||||||
|
width, height = physical_size(config)
|
||||||
|
ds = resolve_double_sided(width, height,
|
||||||
|
_display(config).get('double_sided') or {},
|
||||||
|
quiet=quiet)
|
||||||
|
if ds is not None:
|
||||||
|
return ds['logical_width'], ds['logical_height']
|
||||||
|
return width, height
|
||||||
+269
-94
@@ -34,6 +34,11 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
||||||
|
from src.display_geometry import (
|
||||||
|
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||||
|
physical_size, resolve_double_sided,
|
||||||
|
)
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -55,6 +60,31 @@ from src.common.permission_utils import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
|
|
||||||
|
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
|
||||||
|
#: size regardless, but a Face needs an active size before its metrics --
|
||||||
|
#: and therefore get_font_height() -- report anything but 0.
|
||||||
|
_CALENDAR_FONT_PX = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _bdf_native_size(face) -> int:
|
||||||
|
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
||||||
|
|
||||||
|
Used only to rescue a Face that was built without ``set_char_size``, so a
|
||||||
|
zero line height never reaches layout code.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sizes = getattr(face, "available_sizes", None) or []
|
||||||
|
if sizes:
|
||||||
|
return int(getattr(sizes[0], "height", 0) or 0)
|
||||||
|
except (AttributeError, IndexError, TypeError, ValueError) as exc:
|
||||||
|
# This runs on the measurement path for a face the caller already
|
||||||
|
# holds, so a malformed strike table must degrade to "unknown" rather
|
||||||
|
# than take the display down. Say which face, so a font that is
|
||||||
|
# actually broken is diagnosable rather than silently 8px.
|
||||||
|
logger.debug("Could not read BDF strike size from %r: %s", face, exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class _LogicalMatrix:
|
class _LogicalMatrix:
|
||||||
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
||||||
@@ -97,62 +127,10 @@ class _LogicalMatrix:
|
|||||||
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_double_sided(physical_width: int, physical_height: int,
|
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
|
||||||
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
# sync handshake compute the display size exactly as DisplayManager does
|
||||||
"""Validate the ``display.double_sided`` config against the physical size.
|
# without importing rgbmatrix. Aliased here for existing callers.
|
||||||
|
_resolve_double_sided = resolve_double_sided
|
||||||
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
|
||||||
feature is enabled and the physical panel divides evenly into ``copies``
|
|
||||||
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
|
||||||
config is logged and disabled rather than raised — a misconfigured panel
|
|
||||||
should still light up.
|
|
||||||
"""
|
|
||||||
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
|
||||||
return None
|
|
||||||
|
|
||||||
copies = ds_config.get('copies', 2)
|
|
||||||
if not isinstance(copies, int) or copies < 2:
|
|
||||||
logger.warning(
|
|
||||||
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
|
||||||
"disabling double-sided mode", copies)
|
|
||||||
return None
|
|
||||||
|
|
||||||
axis = ds_config.get('axis', 'horizontal')
|
|
||||||
if axis not in ('horizontal', 'vertical'):
|
|
||||||
logger.warning(
|
|
||||||
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
|
||||||
"(got %r); defaulting to 'horizontal'", axis)
|
|
||||||
axis = 'horizontal'
|
|
||||||
|
|
||||||
# Horizontal splits the chain (panels side by side); vertical splits the
|
|
||||||
# parallel outputs (panels stacked). The split axis must divide evenly.
|
|
||||||
if axis == 'horizontal':
|
|
||||||
if physical_width % copies != 0:
|
|
||||||
logger.warning(
|
|
||||||
"double_sided: physical width %d is not divisible by copies "
|
|
||||||
"%d; disabling double-sided mode", physical_width, copies)
|
|
||||||
return None
|
|
||||||
logical_width = physical_width // copies
|
|
||||||
logical_height = physical_height
|
|
||||||
else:
|
|
||||||
if physical_height % copies != 0:
|
|
||||||
logger.warning(
|
|
||||||
"double_sided: physical height %d is not divisible by copies "
|
|
||||||
"%d; disabling double-sided mode", physical_height, copies)
|
|
||||||
return None
|
|
||||||
logical_width = physical_width
|
|
||||||
logical_height = physical_height // copies
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
|
||||||
"tiled across physical %dx%d", copies, axis, logical_width,
|
|
||||||
logical_height, physical_width, physical_height)
|
|
||||||
return {
|
|
||||||
'copies': copies,
|
|
||||||
'axis': axis,
|
|
||||||
'logical_width': logical_width,
|
|
||||||
'logical_height': logical_height,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
@@ -240,6 +218,14 @@ class DisplayManager:
|
|||||||
self._update_lock = threading.RLock()
|
self._update_lock = threading.RLock()
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
|
# How many panel refreshes each pushed frame is held for. 1 means a new
|
||||||
|
# frame every refresh. Higher values are how a scroll runs slower than
|
||||||
|
# one pixel per refresh WITHOUT fractional pixel positions: the panel
|
||||||
|
# keeps refreshing at full rate (so flicker is unchanged) but motion
|
||||||
|
# advances a whole pixel every Nth refresh instead of every one.
|
||||||
|
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
||||||
|
self._frame_hold = 1
|
||||||
|
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
'last_scroll_activity': 0,
|
'last_scroll_activity': 0,
|
||||||
@@ -293,10 +279,10 @@ class DisplayManager:
|
|||||||
runtime_config = self.config.get('display', {}).get('runtime', {})
|
runtime_config = self.config.get('display', {}).get('runtime', {})
|
||||||
|
|
||||||
# Basic hardware settings
|
# Basic hardware settings
|
||||||
options.rows = hardware_config.get('rows', 32)
|
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
|
||||||
options.cols = hardware_config.get('cols', 64)
|
options.cols = hardware_config.get('cols', DEFAULT_COLS)
|
||||||
options.chain_length = hardware_config.get('chain_length', 2)
|
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
|
||||||
options.parallel = hardware_config.get('parallel', 1)
|
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
|
||||||
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
|
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
|
||||||
|
|
||||||
# Performance and stability settings
|
# Performance and stability settings
|
||||||
@@ -364,11 +350,14 @@ class DisplayManager:
|
|||||||
# Create image with the (logical) display dimensions
|
# Create image with the (logical) display dimensions
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||||
|
|
||||||
# Initialize font with Press Start 2P
|
# Initialize font with Press Start 2P
|
||||||
try:
|
try:
|
||||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.font = load_truetype(
|
||||||
|
self._font_asset(self._PRESS_START),
|
||||||
|
crisp_size(self._PRESS_START, 8))
|
||||||
logger.info("Initial Press Start 2P font loaded successfully")
|
logger.info("Initial Press Start 2P font loaded successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load initial font: {e}")
|
logger.error(f"Failed to load initial font: {e}")
|
||||||
@@ -384,13 +373,7 @@ class DisplayManager:
|
|||||||
# Create a fallback image for web preview using configured dimensions when available
|
# Create a fallback image for web preview using configured dimensions when available
|
||||||
self.matrix = None
|
self.matrix = None
|
||||||
try:
|
try:
|
||||||
hardware_config = self.config.get('display', {}).get('hardware', {}) if self.config else {}
|
fallback_width, fallback_height = physical_size(self.config)
|
||||||
rows = int(hardware_config.get('rows', 32))
|
|
||||||
cols = int(hardware_config.get('cols', 64))
|
|
||||||
chain_length = int(hardware_config.get('chain_length', 2))
|
|
||||||
parallel = int(hardware_config.get('parallel', 1))
|
|
||||||
fallback_width = max(1, cols * chain_length)
|
|
||||||
fallback_height = max(1, rows * parallel)
|
|
||||||
# Mirror double-sided in fallback so the preview shows one screen.
|
# Mirror double-sided in fallback so the preview shows one screen.
|
||||||
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
||||||
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||||
@@ -403,6 +386,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
# Simple fallback visualization so web UI shows a realistic canvas
|
# Simple fallback visualization so web UI shows a realistic canvas
|
||||||
try:
|
try:
|
||||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||||
@@ -547,19 +531,43 @@ class DisplayManager:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _fitting_font(self, lines, width):
|
def _fitting_font(self, lines, width):
|
||||||
"""The largest font from the usual ladder that fits every line."""
|
"""The largest font from the usual ladder that fits every line.
|
||||||
|
|
||||||
|
The ladder ends at 4x6 at 5px because a full dotted-quad address --
|
||||||
|
"255.255.255.255", the widest this screen ever shows -- is 66px at
|
||||||
|
6px and a 64px panel has 62 to give it. That used to squeak in only
|
||||||
|
because the measurement depended on which text layout engine the host
|
||||||
|
Pillow had; with the engine pinned it does not, so the rung the
|
||||||
|
worst case actually needs is here rather than implied.
|
||||||
|
"""
|
||||||
|
# The middle rung is on the 7px grid; the bottom one is deliberately
|
||||||
|
# not. 4x6 advances the same whether it is asked for 6 or 7 -- the
|
||||||
|
# dotted quad is 66px at both -- so the middle rung costs no width and
|
||||||
|
# gains the fourth column in every glyph, which is the difference
|
||||||
|
# between reading an address off a wall and guessing at it. The 5 rung
|
||||||
|
# is the exception this screen needs: it drops the advance to 4px and
|
||||||
|
# the quad to 51px, the only rung that fits a 64px panel, and no
|
||||||
|
# on-grid size does that. It is the one place in the core that draws
|
||||||
|
# 4x6 off-grid on purpose.
|
||||||
candidates = [self.font,
|
candidates = [self.font,
|
||||||
("assets/fonts/4x6-font.ttf", 6)]
|
(self._font_asset(self._FOUR_BY_SIX),
|
||||||
|
crisp_size(self._FOUR_BY_SIX, 6)),
|
||||||
|
(self._font_asset(self._FOUR_BY_SIX), 5)]
|
||||||
|
narrowest = None
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
try:
|
try:
|
||||||
font = candidate
|
font = candidate
|
||||||
if isinstance(candidate, tuple):
|
if isinstance(candidate, tuple):
|
||||||
font = ImageFont.truetype(candidate[0], candidate[1])
|
font = load_truetype(candidate[0], candidate[1])
|
||||||
|
narrowest = font
|
||||||
if all(self.draw.textlength(t, font=font) <= width for t in lines):
|
if all(self.draw.textlength(t, font=font) <= width for t in lines):
|
||||||
return font
|
return font
|
||||||
except (OSError, ValueError, AttributeError):
|
except (OSError, ValueError, AttributeError):
|
||||||
continue
|
continue
|
||||||
return self.font
|
# Nothing fit. Return the smallest face that loaded, not self.font --
|
||||||
|
# falling back to the widest option is how "Initializing" ran off the
|
||||||
|
# side of a 64px panel in the first place.
|
||||||
|
return narrowest or self.font
|
||||||
|
|
||||||
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
|
def _draw_startup_banner(self, lines, width: int, height: int) -> None:
|
||||||
"""Centre `lines` over whatever the test pattern already drew.
|
"""Centre `lines` over whatever the test pattern already drew.
|
||||||
@@ -705,6 +713,7 @@ class DisplayManager:
|
|||||||
# self.image, so swapping the buffer below is enough on its own.
|
# self.image, so swapping the buffer below is enough on its own.
|
||||||
self.image = Image.new('RGB', (target_w, target_h))
|
self.image = Image.new('RGB', (target_w, target_h))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self.matrix = real_matrix
|
self.matrix = real_matrix
|
||||||
@@ -768,16 +777,33 @@ class DisplayManager:
|
|||||||
return # Skip hardware write — content is being captured off-screen
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
digest = None
|
digest = None
|
||||||
|
frame_checksum = None
|
||||||
if self._dirty_tracking_enabled:
|
if self._dirty_tracking_enabled:
|
||||||
try:
|
try:
|
||||||
brightness = getattr(self.matrix, 'brightness', None)
|
brightness = getattr(self.matrix, 'brightness', None)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
brightness = None
|
brightness = None
|
||||||
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
frame_checksum = zlib.adler32(self.image.tobytes())
|
||||||
if digest == self._last_pushed_digest:
|
digest = (frame_checksum, brightness)
|
||||||
|
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
|
||||||
# Nothing changed since the last push — the panel is
|
# Nothing changed since the last push — the panel is
|
||||||
# already showing exactly this frame.
|
# already showing exactly this frame.
|
||||||
self._write_snapshot_if_due()
|
#
|
||||||
|
# Never taken mid-scroll, and that exception is the
|
||||||
|
# point. SwapOnVSync is what paces the render loop, so
|
||||||
|
# skipping it also skips the wait: a duplicate frame
|
||||||
|
# returns in ~8ms instead of ~10ms on a 100Hz panel,
|
||||||
|
# advances only 0.8px instead of 1.0px, and so makes
|
||||||
|
# the *next* frame more likely to repeat as well. That
|
||||||
|
# is self-sustaining -- measured at ~20% duplicate
|
||||||
|
# frames mid-scroll on the odds ticker, against
|
||||||
|
# essentially zero on a lighter plugin with identical
|
||||||
|
# scroll settings. Swapping an identical frame costs
|
||||||
|
# one canvas copy and keeps the loop locked to the
|
||||||
|
# panel; falling out of that lock costs smooth motion.
|
||||||
|
# Static content is unaffected: is_currently_scrolling()
|
||||||
|
# expires on its own inactivity threshold.
|
||||||
|
self._write_snapshot_if_due(frame_checksum)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas. In double-sided
|
# Copy the current image to the offscreen canvas. In double-sided
|
||||||
@@ -787,8 +813,10 @@ class DisplayManager:
|
|||||||
else:
|
else:
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
self.offscreen_canvas.SetImage(self.image)
|
||||||
|
|
||||||
# Swap buffers immediately
|
# Swap buffers immediately. framerate_fraction holds the frame
|
||||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
||||||
|
# what paces the render loop to the chosen frame rate.
|
||||||
|
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
||||||
|
|
||||||
# Swap our canvas references
|
# Swap our canvas references
|
||||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||||
@@ -796,7 +824,7 @@ class DisplayManager:
|
|||||||
self._last_pushed_digest = digest
|
self._last_pushed_digest = digest
|
||||||
|
|
||||||
# Write a snapshot for the web preview (throttled)
|
# Write a snapshot for the web preview (throttled)
|
||||||
self._write_snapshot_if_due()
|
self._write_snapshot_if_due(frame_checksum)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating display: {e}")
|
logger.error(f"Error updating display: {e}")
|
||||||
|
|
||||||
@@ -814,6 +842,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
self.image = Image.new('RGB', (width, height))
|
self.image = Image.new('RGB', (width, height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
logger.debug("Cleared display in fallback mode")
|
logger.debug("Cleared display in fallback mode")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -825,6 +854,7 @@ class DisplayManager:
|
|||||||
# Create a new black image
|
# Create a new black image
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
|
|
||||||
if not self._capture_mode_active:
|
if not self._capture_mode_active:
|
||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
@@ -894,6 +924,28 @@ class DisplayManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
|
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
|
||||||
|
|
||||||
|
#: The bundled faces, and the size each is *asked* for. Every size here is
|
||||||
|
#: run through `crisp_size`, so a number that drifts off the face's pixel
|
||||||
|
#: grid is snapped rather than rendered anti-aliased -- see the note on
|
||||||
|
#: `extra_small_font` below.
|
||||||
|
_FONT_DIR = "assets/fonts"
|
||||||
|
_PRESS_START = "PressStart2P-Regular.ttf"
|
||||||
|
_FOUR_BY_SIX = "4x6-font.ttf"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _font_asset(cls, filename: str) -> str:
|
||||||
|
"""Install-root-relative path to a bundled face.
|
||||||
|
|
||||||
|
`_load_fonts` named these relative to the process cwd, which holds
|
||||||
|
under the packaged systemd unit (WorkingDirectory is the install root)
|
||||||
|
and nowhere else: the plugin safety harness, `python run.py` from
|
||||||
|
$HOME, or a unit file written without WorkingDirectory all loaded
|
||||||
|
nothing and fell through to `ImageFont.load_default()`. That failure is
|
||||||
|
silent -- the panel just renders in PIL's default face at whatever size
|
||||||
|
the layout was computed for.
|
||||||
|
"""
|
||||||
|
return resolve_asset_path(f"{cls._FONT_DIR}/{filename}")
|
||||||
|
|
||||||
def _load_fonts(self):
|
def _load_fonts(self):
|
||||||
"""Load fonts with proper error handling."""
|
"""Load fonts with proper error handling."""
|
||||||
# Font objects get new id()s after reload, so the text-width cache would
|
# Font objects get new id()s after reload, so the text-width cache would
|
||||||
@@ -901,16 +953,17 @@ class DisplayManager:
|
|||||||
self._text_width_cache.clear()
|
self._text_width_cache.clear()
|
||||||
try:
|
try:
|
||||||
# Load Press Start 2P font
|
# Load Press Start 2P font
|
||||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
press_start = self._font_asset(self._PRESS_START)
|
||||||
|
self.regular_font = load_truetype(press_start, crisp_size(self._PRESS_START, 8))
|
||||||
logger.info("Press Start 2P font loaded successfully")
|
logger.info("Press Start 2P font loaded successfully")
|
||||||
|
|
||||||
# Use the same font for small text (currently same size; adjust size here if needed)
|
# Use the same font for small text (currently same size; adjust size here if needed)
|
||||||
self.small_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
self.small_font = load_truetype(press_start, crisp_size(self._PRESS_START, 8))
|
||||||
logger.info("Press Start 2P small font loaded successfully")
|
logger.info("Press Start 2P small font loaded successfully")
|
||||||
|
|
||||||
# Load 5x7 BDF font for calendar events
|
# Load 5x7 BDF font for calendar events
|
||||||
try:
|
try:
|
||||||
self.calendar_font_path = "assets/fonts/5x7.bdf"
|
self.calendar_font_path = self._font_asset("5x7.bdf")
|
||||||
logger.info(f"Attempting to load 5x7 font from: {self.calendar_font_path}")
|
logger.info(f"Attempting to load 5x7 font from: {self.calendar_font_path}")
|
||||||
|
|
||||||
if not os.path.exists(self.calendar_font_path):
|
if not os.path.exists(self.calendar_font_path):
|
||||||
@@ -918,6 +971,17 @@ class DisplayManager:
|
|||||||
|
|
||||||
# Load with freetype for proper BDF handling
|
# Load with freetype for proper BDF handling
|
||||||
face = freetype.Face(self.calendar_font_path)
|
face = freetype.Face(self.calendar_font_path)
|
||||||
|
# A freshly constructed Face has no active size, so
|
||||||
|
# face.size.height is 0 until set_char_size is called -- and
|
||||||
|
# get_font_height() reads exactly that. Without this, every
|
||||||
|
# caller measuring the 5x7 face got 0 and stacked rows on top
|
||||||
|
# of one another; the "Calendar font size: 0 pixels" line
|
||||||
|
# below has been printing the symptom on every start-up.
|
||||||
|
# font_manager._load_bdf_font already does this; the two paths
|
||||||
|
# disagreed about whether a Face was usable for measurement.
|
||||||
|
# 5x7.bdf is a fixed strike, so FreeType renders 7px whatever
|
||||||
|
# is asked for -- this sets the metrics, not the raster.
|
||||||
|
face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72)
|
||||||
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
||||||
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
||||||
|
|
||||||
@@ -934,11 +998,26 @@ class DisplayManager:
|
|||||||
self.bdf_5x7_font = self.calendar_font
|
self.bdf_5x7_font = self.calendar_font
|
||||||
logger.info(f"Assigned calendar_font (type: {type(self.bdf_5x7_font).__name__}) to bdf_5x7_font.")
|
logger.info(f"Assigned calendar_font (type: {type(self.bdf_5x7_font).__name__}) to bdf_5x7_font.")
|
||||||
|
|
||||||
# Load 4x6 font as extra_small_font
|
# Load 4x6 font as extra_small_font.
|
||||||
|
#
|
||||||
|
# Asked for 6 -- the size the face's name suggests -- for years,
|
||||||
|
# and 6 is off its 7px pixel grid. Plugins draw this face with
|
||||||
|
# `draw.fontmode = "1"`, and the mono rasteriser thresholds each
|
||||||
|
# glyph at 50% coverage, so off-grid every glyph came out 3px wide
|
||||||
|
# instead of 4. The lost column deforms the letterforms rather than
|
||||||
|
# merely thinning them: christmas-countdown rendered "UNTIL" as
|
||||||
|
# "VM1JL" and "CHRISTMAS" as "CHAJS1MAS", and zero loses the left
|
||||||
|
# half of its bowl. Those renders were committed as golden images.
|
||||||
|
#
|
||||||
|
# `crisp_size` snaps it to 7. The advance is unchanged -- 5px per
|
||||||
|
# glyph at either size -- so nothing reflows and no layout gets
|
||||||
|
# tighter; a string is at most a pixel or two wider because the
|
||||||
|
# last glyph finally occupies the width it was always given.
|
||||||
try:
|
try:
|
||||||
font_path = "assets/fonts/4x6-font.ttf"
|
font_path = self._font_asset(self._FOUR_BY_SIX)
|
||||||
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size 6")
|
size = crisp_size(self._FOUR_BY_SIX, 6)
|
||||||
self.extra_small_font = ImageFont.truetype(font_path, 6)
|
logger.info(f"Attempting to load 4x6 TTF font from: {font_path} at size {size}")
|
||||||
|
self.extra_small_font = load_truetype(font_path, size)
|
||||||
logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
|
logger.info(f"4x6 TTF extra small font loaded successfully from {font_path}")
|
||||||
except Exception as font_err:
|
except Exception as font_err:
|
||||||
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
|
logger.error(f"Failed to load 4x6 TTF font: {font_err}. Falling back.")
|
||||||
@@ -996,7 +1075,13 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing.
|
# For FreeType faces (BDF), the 'height' metric gives the recommended line spacing.
|
||||||
return font.size.height >> 6
|
height = font.size.height >> 6
|
||||||
|
if height:
|
||||||
|
return height
|
||||||
|
# A Face constructed without set_char_size reports 0, and a
|
||||||
|
# zero line height collapses every stacked row onto one line.
|
||||||
|
# Fall back to the strike the file declares.
|
||||||
|
return _bdf_native_size(font) or 8
|
||||||
else:
|
else:
|
||||||
# For PIL TTF fonts, getmetrics() provides ascent and descent.
|
# For PIL TTF fonts, getmetrics() provides ascent and descent.
|
||||||
# The line height is the sum of ascent and descent.
|
# The line height is the sum of ascent and descent.
|
||||||
@@ -1258,6 +1343,7 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
self.image = Image.new('RGB', (self.width, self.height))
|
self.image = Image.new('RGB', (self.width, self.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||||
# Reset the singleton state when cleaning up
|
# Reset the singleton state when cleaning up
|
||||||
@@ -1274,12 +1360,65 @@ class DisplayManager:
|
|||||||
|
|
||||||
return dt.strftime(f"%b %-d{suffix}")
|
return dt.strftime(f"%b %-d{suffix}")
|
||||||
|
|
||||||
def set_scrolling_state(self, is_scrolling: bool):
|
@property
|
||||||
"""Set the current scrolling state. Call this when a display starts/stops scrolling."""
|
def refresh_hz(self) -> float:
|
||||||
|
"""The panel's refresh rate in Hz, from the hardware config.
|
||||||
|
|
||||||
|
The authoritative place to ask, because a plugin only receives its own
|
||||||
|
config section and cannot see display.hardware. Scroll pacing needs
|
||||||
|
this: the speeds a panel can show in whole pixels are refresh_hz
|
||||||
|
divided by the frame hold, so getting it wrong silently produces
|
||||||
|
fractional-pixel motion. See src/common/scroll_config.py.
|
||||||
|
|
||||||
|
Note this is the configured *cap*, not necessarily what the panel
|
||||||
|
achieves -- scripts/scroll_speeds.py --measure reports the real rate.
|
||||||
|
"""
|
||||||
|
hardware = (self.config.get('display') or {}).get('hardware') or {}
|
||||||
|
try:
|
||||||
|
value = float(hardware.get('limit_refresh_rate_hz') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
value = 0.0
|
||||||
|
return value if value > 0 else 100.0
|
||||||
|
|
||||||
|
def set_frame_hold(self, refreshes: int) -> None:
|
||||||
|
"""Hold each pushed frame for this many panel refreshes (>=1).
|
||||||
|
|
||||||
|
Set by the scroll configuration so a plugin can run at, say, 50px/s on
|
||||||
|
a 100Hz panel as one whole pixel every second refresh, rather than half
|
||||||
|
a pixel every refresh (which has to be blended or repeated unevenly).
|
||||||
|
|
||||||
|
Reset to 1 whenever scrolling stops, so one plugin's pacing cannot
|
||||||
|
leak into the next thing on screen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = int(refreshes)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("Ignoring unusable frame hold: %r", refreshes)
|
||||||
|
return
|
||||||
|
self._frame_hold = max(1, min(255, value))
|
||||||
|
|
||||||
|
def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1):
|
||||||
|
"""Set the current scrolling state, and this scroll's frame pacing.
|
||||||
|
|
||||||
|
Call this when a display starts or stops scrolling. ``frame_hold`` is
|
||||||
|
how many panel refreshes each frame is held for -- 2 gives one whole
|
||||||
|
pixel every second refresh, which is how a scroll runs at half the
|
||||||
|
refresh rate without fractional pixel positions.
|
||||||
|
|
||||||
|
The hold is set here rather than once at plugin construction because
|
||||||
|
it must not outlive the scroll that asked for it: plugins share one
|
||||||
|
display manager, so a hold left set by whoever scrolled last would
|
||||||
|
silently re-pace the next plugin. Passing it alongside the state makes
|
||||||
|
the lifetime exactly the scroll, and the default of 1 means any caller
|
||||||
|
that does not care gets a new frame every refresh.
|
||||||
|
"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||||
if is_scrolling:
|
if is_scrolling:
|
||||||
self._scrolling_state['last_scroll_activity'] = current_time
|
self._scrolling_state['last_scroll_activity'] = current_time
|
||||||
|
self.set_frame_hold(frame_hold)
|
||||||
|
else:
|
||||||
|
self._frame_hold = 1
|
||||||
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
||||||
|
|
||||||
def is_currently_scrolling(self) -> bool:
|
def is_currently_scrolling(self) -> bool:
|
||||||
@@ -1293,6 +1432,14 @@ class DisplayManager:
|
|||||||
# If we've been inactive for the threshold period, consider it not scrolling
|
# If we've been inactive for the threshold period, consider it not scrolling
|
||||||
if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']:
|
if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']:
|
||||||
self._scrolling_state['is_scrolling'] = False
|
self._scrolling_state['is_scrolling'] = False
|
||||||
|
# Drop the hold with the state, exactly as set_scrolling_state(False)
|
||||||
|
# does. This path is the one a scroll takes when it ends without
|
||||||
|
# saying so -- the rotation moves on mid-scroll, or the plugin is
|
||||||
|
# torn down -- and leaving the hold set there means every later
|
||||||
|
# plugin, scrolling or static, is presented at refresh/N until
|
||||||
|
# somebody calls set_scrolling_state(False). The hold must not
|
||||||
|
# outlive the scroll that asked for it, however that scroll ends.
|
||||||
|
self._frame_hold = 1
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -1410,11 +1557,20 @@ class DisplayManager:
|
|||||||
self._viewer_fresh = False
|
self._viewer_fresh = False
|
||||||
return self._viewer_fresh
|
return self._viewer_fresh
|
||||||
|
|
||||||
def _write_snapshot_if_due(self) -> None:
|
def _write_snapshot_if_due(self, frame_checksum: Optional[int] = None) -> None:
|
||||||
"""Mirror the current frame to the preview snapshot when the policy
|
"""Mirror the current frame to the preview snapshot when the policy
|
||||||
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
||||||
frames are never re-encoded; without viewers the cadence drops to
|
frames are never re-encoded; without viewers the cadence drops to
|
||||||
the idle keepalive."""
|
the idle keepalive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame_checksum: adler32 of the current frame, when the caller has
|
||||||
|
already computed one. Dirty tracking checksums every frame a
|
||||||
|
few lines above the call site, and re-deriving it here meant a
|
||||||
|
second tobytes() plus a second pass over the whole framebuffer
|
||||||
|
on every single frame — ~0.17ms per frame of the two combined
|
||||||
|
at 256x64, paid 100 times a second to reach the same number.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
viewer_fresh = self._viewer_is_fresh(now)
|
viewer_fresh = self._viewer_is_fresh(now)
|
||||||
@@ -1424,7 +1580,8 @@ class DisplayManager:
|
|||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
self._viewer_was_fresh = viewer_fresh
|
self._viewer_was_fresh = viewer_fresh
|
||||||
|
|
||||||
digest = zlib.adler32(self.image.tobytes())
|
digest = (frame_checksum if frame_checksum is not None
|
||||||
|
else zlib.adler32(self.image.tobytes()))
|
||||||
action = snapshot_policy.decide(
|
action = snapshot_policy.decide(
|
||||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||||
viewer_fresh, digest != self._last_snapshot_digest)
|
viewer_fresh, digest != self._last_snapshot_digest)
|
||||||
@@ -1447,12 +1604,30 @@ class DisplayManager:
|
|||||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||||
self._snapshot_dir_prepared = True
|
self._snapshot_dir_prepared = True
|
||||||
# Write atomically: temp then replace
|
# Write atomically: temp then replace. The temp name must be
|
||||||
tmp_path = f"{self._snapshot_path}.tmp"
|
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
||||||
self.image.save(tmp_path, format='PNG')
|
# and this file is written by whichever user the display service
|
||||||
|
# runs as while tests and tooling run as someone else. A leftover
|
||||||
|
# fixed-name temp owned by another user is then unopenable even by
|
||||||
|
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
||||||
|
# sticky dir), which froze the preview and the health check's
|
||||||
|
# liveness proxy until somebody deleted it by hand. Same pattern as
|
||||||
|
# the hardware-status write above.
|
||||||
|
_fd, tmp_path = tempfile.mkstemp(
|
||||||
|
dir=str(snapshot_path_obj.parent),
|
||||||
|
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||||
try:
|
try:
|
||||||
|
with os.fdopen(_fd, "wb") as _f:
|
||||||
|
self.image.save(_f, format='PNG')
|
||||||
|
os.chmod(tmp_path, 0o644)
|
||||||
os.replace(tmp_path, self._snapshot_path)
|
os.replace(tmp_path, self._snapshot_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Never leave the temp behind -- that is what made the failure
|
||||||
|
# permanent rather than transient.
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
# Fallback to direct save if replace not supported
|
# Fallback to direct save if replace not supported
|
||||||
self.image.save(self._snapshot_path, format='PNG')
|
self.image.save(self._snapshot_path, format='PNG')
|
||||||
# Set proper file permissions after saving
|
# Set proper file permissions after saving
|
||||||
|
|||||||
+1013
-78
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user