mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-12 10:06:35 +00:00
Compare commits
64
Commits
8163104581
...
489c9e2fd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489c9e2fd3 | ||
|
|
865d62f67b | ||
|
|
3471ebe845 | ||
|
|
8ad9d191a7 | ||
|
|
7f9c73e9aa | ||
|
|
c3bc09a3ae | ||
|
|
ab39552cdb | ||
|
|
a95b9bd96e | ||
|
|
98ebc859e0 | ||
|
|
0bd63f8c20 | ||
|
|
ae828e1454 | ||
|
|
37203e635c | ||
|
|
00e14ea55f | ||
|
|
ab38c5f320 | ||
|
|
ffdd0efd43 | ||
|
|
892d33e638 | ||
|
|
01b82edc23 | ||
|
|
9c81e6b42f | ||
|
|
4c17e18786 | ||
|
|
c43c10bd78 | ||
|
|
8482af7814 | ||
|
|
b9416ef803 | ||
|
|
478516dbd1 | ||
|
|
690407860e | ||
|
|
f6afbdbb15 | ||
|
|
99a3608516 | ||
|
|
42ff3825b4 | ||
|
|
edfcd9e2a1 | ||
|
|
3a81f38f09 | ||
|
|
19ee84a760 | ||
|
|
9a705c2ca4 | ||
|
|
7b90759252 | ||
|
|
b11bcfa204 | ||
|
|
3967a6cffc | ||
|
|
e39f65fce0 | ||
|
|
d37a3a712a | ||
|
|
409b63bc17 | ||
|
|
84043468ca | ||
|
|
a6648ec991 | ||
|
|
1df6def8f7 | ||
|
|
6279530c16 | ||
|
|
52bc520335 | ||
|
|
1afb2383cd | ||
|
|
4e61d7248a | ||
|
|
ece416c4e5 | ||
|
|
13bbb537f3 | ||
|
|
afe9001aed | ||
|
|
abedc46104 | ||
|
|
1fe7237799 | ||
|
|
ddf5f085a5 | ||
|
|
5baf983fe0 | ||
|
|
82f3a3a3e4 | ||
|
|
c1ce0b7b04 | ||
|
|
14c38a3189 | ||
|
|
de54fc879a | ||
|
|
b67818d5c5 | ||
|
|
4f30bc62ca | ||
|
|
55a2760892 | ||
|
|
f79618d4f7 | ||
|
|
d56ec2ab3a | ||
|
|
c883a2fd1e | ||
|
|
ac841f4583 | ||
|
|
98728d3b81 | ||
|
|
3eb7a2e349 |
@@ -3,21 +3,9 @@ name: Claude Code Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -45,6 +33,4 @@ jobs:
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -40,11 +40,3 @@ jobs:
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
|
||||
+4
-4
@@ -81,10 +81,10 @@ 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
|
||||
# web_interface/app.py writes data/plugin_state.json and data/operation_history.json
|
||||
# (older releases also data/plugin_operations.json) as the web interface runs, into
|
||||
# a directory that ships tracked (data/.gitkeep). Unignored, every rig that ever
|
||||
# opened the web UI -- and every test run that constructs the app -- would leave
|
||||
# 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/*
|
||||
|
||||
+136
@@ -19,6 +19,93 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Scripts and installer:
|
||||
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
|
||||
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
|
||||
- `configure_web_sudo.sh` uses a random temp file and installs its rules with mode 440.
|
||||
- The installer prints its completion summary before the `-y` reboot, and describes the setup access point as an open network (it was shown with a password it doesn't have).
|
||||
- `fix_cache_permissions.sh` applies `setup_cache.sh`'s `ledmatrix`-group model instead of setting 777.
|
||||
- `check_system_compatibility.sh` reports anything but Debian 13 (Trixie) as unsupported, and reaches its summary.
|
||||
- New `scripts/README.md` lists every script.
|
||||
- Docs:
|
||||
- New `docs/ARCHITECTURE.md` (processes, shared state, display loop, plugin system, web UI) and `docs/PERMISSIONS.md` (owners, modes, both sudoers files, repair scripts).
|
||||
- Deprecated plugin APIs are marked in the plugin docs.
|
||||
- `src/common/README.md` covers every module.
|
||||
- Stale setup, service and troubleshooting claims are corrected.
|
||||
|
||||
- Plugin store and plugin manager fixes:
|
||||
- Updating a plugin that was installed from a ZIP no longer tries to reinstall it from the LEDMatrix repository's own URL.
|
||||
- Repository URLs with `.git` in the middle are no longer mangled. The URL helpers now live in `src/plugin_system/repo_urls.py`.
|
||||
- Installing from a URL works when the repository's only branch isn't `main` or `master`.
|
||||
- A missing required config field is reported once, by name.
|
||||
- A plugin that went over `max_memory_mb` once is no longer refused on every call after that.
|
||||
- `reload_plugin` reads the manifest from the plugin's discovered directory.
|
||||
- Removed: `last_display` from plugin state info and `get_last_display()` (nothing recorded them); `PluginOperationQueue`'s `history_file` and `lazy_load` arguments; and `data/plugin_operations.json`, which nothing read.
|
||||
|
||||
- Core service fixes:
|
||||
- `/api/v3/errors` shows each exception's real stack trace instead of `NoneType: None`.
|
||||
- Wi-Fi disconnect takes the saved connection profile down.
|
||||
- `wifi_config.json` is written atomically, and a save that fails now gets a 500.
|
||||
- `plugin://` fonts load from the plugin's own install directory. `FontManager.register_plugin_fonts()` takes an optional `plugin_dir`.
|
||||
- `APIHelper` keeps cached responses for the `cache_ttl` it was given, instead of always 300 s.
|
||||
- Logo scales from 0.1 to 10 are honoured everywhere; values outside that range are clamped.
|
||||
- `LogoHelper` and `logo_downloader`: an empty ESPN logo list counts as a failed download, and the placeholder is written at the requested path.
|
||||
- Bundled font paths no longer depend on the directory the process was started from.
|
||||
- Backups record `src.__version__`.
|
||||
- Removed: `BackgroundDataService`'s `queue_size` stat and `clear_completed_requests()`.
|
||||
|
||||
- Web API fixes:
|
||||
- A plugin save drops repeated entries in lists whose schema says `uniqueItems`, instead of failing validation.
|
||||
- `/api/v3/health` reports the real plugin count.
|
||||
- A malformed `vegas_plugin_order` or `vegas_excluded_plugins` is refused with a 400 and nothing is saved. It used to wipe the saved list.
|
||||
- The per-plugin health and metrics routes return the display service's latest state.
|
||||
- Resetting a plugin's config takes a backup first and reports a failed save.
|
||||
- System metrics that can't be read are `null` everywhere: `cpu_temp` off a Pi, and every metric without psutil, where `/system/status` now answers 200 instead of 503.
|
||||
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
|
||||
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
|
||||
|
||||
- Web UI:
|
||||
- Cache tab errors no longer show up in the Logs tab.
|
||||
- A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
|
||||
- Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
|
||||
- The image schedule button works on uploaded images, and the editor stays open while you edit.
|
||||
- A failed plugin toggle moves the switch back.
|
||||
- Each save shows one notification; a failed Durations save says it failed.
|
||||
- Stats the server can't read show `--`.
|
||||
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
|
||||
|
||||
- Display and Vegas:
|
||||
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
|
||||
- The display controller stops Vegas mode on shutdown.
|
||||
- Startup validation warnings are logged once, not twice.
|
||||
- Vegas logs one INFO line per plugin-list refresh.
|
||||
- `run.py -d` shows `display_manager` debug output.
|
||||
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
|
||||
|
||||
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
||||
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
||||
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
|
||||
INFO; 4xx at WARNING, 5xx at ERROR. `LEDMATRIX_DEBUG=true` shows them again.
|
||||
`web_interface/logging_config.py` is removed. The web cache
|
||||
(`web_interface/cache.py`) now honours the TTL a value was stored with and is
|
||||
thread-safe.
|
||||
|
||||
- One plugin-directory resolver, `src/plugin_system/plugin_dirs.py`, behind
|
||||
discovery, `PluginManager.get_plugin_directory`, `PluginLoader`, the store and
|
||||
state reconciliation. A manifest's `id` wins over a directory merely named for
|
||||
the id; hidden and `.standalone-backup-` directories are never treated as
|
||||
plugins (auto-update could previously try to update a backup); ids like
|
||||
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
|
||||
named for its manifest id, the installer's layout, behave as before.
|
||||
|
||||
- `/api/v3` routes answer an exception they don't handle themselves from one
|
||||
blueprint error handler, with the same `{status, message, details}` body the
|
||||
53 removed per-route catch-alls returned. `ErrorCategory` and the
|
||||
`error_category` key are removed from `src.web_interface.errors` (nothing read
|
||||
them); `exception_error_response()` replaces the `from_exception` +
|
||||
`error_response` pairs. A failing plugin action script's error now names the
|
||||
real failure instead of `UnboundLocalError`.
|
||||
|
||||
- `FontManager.get_font()` returns a BDF font at its native size when asked for
|
||||
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
|
||||
return PIL's default font, a different typeface, so a plugin that relied on
|
||||
@@ -33,6 +120,16 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
its own default (a failed lookup is retried after 30 minutes). Clearing an
|
||||
app's location in the web UI now actually clears it; the save used to drop
|
||||
the blank field, so the old value stayed.
|
||||
- `src.common.bdf_font` — `load_bdf_face(path, size)` (a cached
|
||||
`freetype.Face` plus the pixel size it really renders at, falling back to
|
||||
the file's native strike) and `draw_bdf_text(draw, text, x, y, face, color)`.
|
||||
`DisplayManager`, `FontManager`, `element_style` and the plugin test harness
|
||||
now all load and draw BDF text through it; the panel's pixels are unchanged
|
||||
and BDF text draws 10-250x faster. The plugin test harness's
|
||||
`calendar_font` / `bdf_5x7_font` now has the panel's 7px size set: it used
|
||||
to be an unsized face, so in golden images and `check_plugin` /
|
||||
`dev_server` previews its text sat 6px above where the panel draws it (off
|
||||
the canvas entirely near the top) and `get_font_height()` returned 0.
|
||||
|
||||
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
|
||||
registered each font with `FontManager.register_manager_font()`, published
|
||||
@@ -81,6 +178,23 @@ floor on the release that ships them):
|
||||
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
|
||||
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
|
||||
`shared_downloader`.
|
||||
- `src.common.sports_card.unshare_element_fonts` takes an optional third
|
||||
argument, `element_for_font` (default: the module's `ELEMENT_FOR_FONT`, so
|
||||
existing calls are unchanged).
|
||||
|
||||
### Sports twins
|
||||
|
||||
- The `SportsCoreSharedMixin` helpers that behave identically to their
|
||||
`sports_card` twins (`_card_option`, `_vs_text`, `_format_game_time`,
|
||||
`_coerce_rgb`, `_crisp_size`, `_unshare_element_fonts`, the colour/month/
|
||||
weekday/font-grid tables) are now thin wrappers over the `sports_card`
|
||||
functions, and `_format_game_date` / `_schema_font_size` share its
|
||||
formatting body and schema parser. No method was removed or renamed and
|
||||
nothing renders differently: `test/test_sports_twins.py` checks each pair
|
||||
against the same inputs, and the old and new mixin agree on every input
|
||||
there. The pairs that do differ -- favourite-result colours on nested
|
||||
payloads, the weekday's timezone, the element-name map, per-mode colours --
|
||||
are left as they are and pinned in that test.
|
||||
|
||||
### Logo downloads
|
||||
|
||||
@@ -120,6 +234,13 @@ floor on the release that ships them):
|
||||
when the count is only known to the display service.
|
||||
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
|
||||
errors and a Clear button.
|
||||
- Credential redaction in exception text (`src/redaction.py`) takes time
|
||||
proportional to the text, not its square. Two patterns were quadratic: URL
|
||||
`user:password@`, on a long unbroken run of letters or digits (a hex digest,
|
||||
an ID), and `Authorization:` followed by a long run of whitespace. Either
|
||||
used to stall every thread of the display service for up to seconds each
|
||||
time the snapshot was published: about 0.5s for 20k characters of hex, 8s
|
||||
for 20k spaces. What gets redacted is unchanged.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -136,6 +257,21 @@ floor on the release that ships them):
|
||||
`api_extractors`). No known plugin imports it. A plugin that does must use
|
||||
`src.common` or its own copy of the code.
|
||||
|
||||
- `src.common.frame_timing` -- times every frame the display presents, whoever
|
||||
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
|
||||
`scripts/frame_soak.py` judges a running service (late frames, freezes,
|
||||
where the time goes), and `scripts/render_bench.py` judges the hardware and
|
||||
render path alone on a synthetic strip. Both fail a run above 0.1% late
|
||||
frames, and both call a loop that never waited for the panel NOT LOCKED. A
|
||||
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
|
||||
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
|
||||
|
||||
- `display.scan_order_compensation` (`"auto"` by default): while something
|
||||
scrolls at one pixel per refresh, one half of each panel is shown a refresh
|
||||
behind the other, which removes the 1px step a 1:N-scan panel shows across
|
||||
its middle. Only for layouts whose row order is known; `"off"` disables it.
|
||||
See `docs/SCROLL_PERFORMANCE.md`, "A tear across the middle on fast scrolls".
|
||||
|
||||
## 3.5.0
|
||||
|
||||
New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
|
||||
@@ -13,14 +13,17 @@
|
||||
loader does NOT fall back to it — `PluginManager.discover_plugins()`
|
||||
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
||||
directory. Fallbacks exist in two narrower places: store operations
|
||||
(`StoreManager._find_plugin_path()` in `store_manager.py`) and schema
|
||||
lookup (`SchemaManager.get_schema_path()` in `schema_manager.py`,
|
||||
which probes `plugins/` *before* `plugin-repos/`).
|
||||
(`PluginStoreManager._find_plugin_path()` in `store_manager.py`, which
|
||||
searches `store_search_dirs()` from `plugin_dirs.py`) and schema lookup
|
||||
(`SchemaManager.get_schema_path()` in `schema_manager.py`, which probes
|
||||
`plugins/` *before* `plugin-repos/`).
|
||||
- `src/plugin_system/plugin_dirs.py` — the one resolver for "which directory
|
||||
holds plugin X" (manifest `id` first, then `<id>` / `ledmatrix-<id>`)
|
||||
|
||||
## Plugin System
|
||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||
- Required abstract methods: `update()`, `display(force_clear=False)`
|
||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
||||
- Each plugin needs: `manifest.json`, `config_schema.json`, and the entry point (`manager.py` by default); `requirements.txt` if it has dependencies. Required manifest fields: `docs/PLUGIN_API_REFERENCE.md#manifest-required-fields`
|
||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||
- Config schemas use JSON Schema Draft-7
|
||||
- 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)
|
||||
@@ -34,19 +37,20 @@
|
||||
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
|
||||
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
|
||||
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
|
||||
- Soak a rig for frame timing (on the Pi, service running): `python3 scripts/frame_soak.py --preview` — late-frame rate across every scroller; see `docs/SCROLL_PERFORMANCE.md`
|
||||
|
||||
## Plugin Store Architecture
|
||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
|
||||
- `plugins.json` registry at `https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json`
|
||||
- Store manager (`src/plugin_system/store_manager.py`) handles install/update/uninstall
|
||||
- Monorepo plugins are installed via ZIP extraction (no `.git` directory)
|
||||
- Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
|
||||
- Monorepo plugins are installed without a `.git` directory: GitHub Trees API + raw downloads, falling back to ZIP extraction
|
||||
- Update detection for monorepo plugins uses version comparison (manifest version vs registry latest_version)
|
||||
- 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`
|
||||
|
||||
## Common Pitfalls
|
||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
||||
- paho-mqtt 2.x requires a `CallbackAPIVersion` argument: `VERSION1` for code written against v1 callback signatures (the MQTT bridge uses `VERSION2`)
|
||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||
|
||||
@@ -328,6 +328,7 @@ This one-shot installer will automatically:
|
||||
- Install required system packages (git, python3, build tools, etc.)
|
||||
- Clone or update the LEDMatrix repository
|
||||
- Run the complete first-time installation script
|
||||
- Print the web interface address, then **reboot the Pi automatically** (your SSH session will disconnect; give it a few minutes to come back)
|
||||
|
||||
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
|
||||
|
||||
@@ -689,9 +690,10 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
||||
### Display Format Settings
|
||||
|
||||
- **`use_short_date_format`** (boolean, default: true)
|
||||
- Use short date format (e.g., "Jan 15") instead of long format (e.g., "January 15th")
|
||||
- Set to `false` for longer, more readable dates
|
||||
- Set to `true` to save space and show more information
|
||||
- Currently has no effect. The web UI still saves it, but no core code
|
||||
reads it. Scoreboard plugins that offer a short date format read the
|
||||
setting from their own plugin config instead. See
|
||||
[CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md#display--other-keys).
|
||||
|
||||
### Dynamic Duration Settings (`display.dynamic_duration`)
|
||||
|
||||
@@ -779,15 +781,21 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
||||
<details>
|
||||
<summary>Manual SSH Commands (for reference)</summary>
|
||||
|
||||
The quick actions essentially just execute the following commands on the Pi.
|
||||
The web interface's quick actions (Start/Stop/Restart Display) call
|
||||
`sudo systemctl start|stop|restart ledmatrix.service` — see
|
||||
`execute_system_action()` in
|
||||
[`web_interface/blueprints/api_v3/system.py`](web_interface/blueprints/api_v3/system.py).
|
||||
The service runs [`run.py`](run.py) as root.
|
||||
|
||||
From the project root directory (ex: /home/ledpi/LEDMatrix):
|
||||
To run the display in the foreground instead (for debugging), stop the service
|
||||
first, then from the project root (e.g. `/home/ledpi/LEDMatrix`):
|
||||
|
||||
```bash
|
||||
sudo python3 display_controller.py
|
||||
sudo systemctl stop ledmatrix.service
|
||||
sudo python3 run.py # add -d for debug logging
|
||||
```
|
||||
|
||||
This will start the display cycle but only stays active as long as your ssh session is active.
|
||||
This only runs as long as your SSH session stays open.
|
||||
|
||||
### Convenience Scripts
|
||||
|
||||
@@ -957,9 +965,10 @@ sudo systemctl enable ledmatrix-web.service
|
||||
3. Check if another service is using port 5000
|
||||
|
||||
**Service Fails to Start:**
|
||||
1. Check Python dependencies are installed
|
||||
2. Verify the virtual environment is set up correctly
|
||||
3. Check file permissions and ownership
|
||||
1. Check Python dependencies are installed. The installer puts them in the
|
||||
system Python with `pip install --break-system-packages` (there is no
|
||||
virtual environment), so `python3 -c "import flask"` should succeed.
|
||||
2. Check file permissions and ownership
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+55
-74
@@ -185,63 +185,53 @@ their config section to control how oversized content is handled (see
|
||||
|
||||
### Plugin Integration (Developer Guide)
|
||||
|
||||
All of these have defaults in
|
||||
[`BasePlugin`](../src/plugin_system/base_plugin.py); override only what you
|
||||
need.
|
||||
|
||||
**1. Implement Content Method:**
|
||||
|
||||
```python
|
||||
def get_vegas_content(self):
|
||||
"""
|
||||
Return PIL Image or list of Images for Vegas mode.
|
||||
|
||||
Returns:
|
||||
PIL.Image or list[PIL.Image]: Content to display
|
||||
- Single image: fixed-width content
|
||||
- List of images: multiple segments
|
||||
- None: skip this cycle
|
||||
"""
|
||||
# Example: Return single wide image
|
||||
img = Image.new('RGB', (256, 32))
|
||||
# ... render your content ...
|
||||
return img
|
||||
|
||||
# Example: Return multiple segments
|
||||
return [image1, image2, image3]
|
||||
# Return a PIL Image, a list of Images, or None.
|
||||
# A single image is one block; a list becomes one item per image.
|
||||
return [self._render_game(game) for game in self.games]
|
||||
```
|
||||
|
||||
If it returns `None` (the default), Vegas falls back to the plugin's
|
||||
`scroll_helper` image, then to capturing `display()` output
|
||||
(`PluginAdapter.get_content()` in
|
||||
[`src/vegas_mode/plugin_adapter.py`](../src/vegas_mode/plugin_adapter.py)).
|
||||
|
||||
**2. Specify Content Type:**
|
||||
|
||||
```python
|
||||
def get_vegas_content_type(self):
|
||||
"""
|
||||
Specify how content should be handled.
|
||||
|
||||
Returns:
|
||||
str: 'multi' | 'static' | 'none'
|
||||
"""
|
||||
return 'multi' # Default for most plugins
|
||||
# 'multi' | 'static' | 'none' -- default is 'static'
|
||||
return 'multi'
|
||||
```
|
||||
|
||||
`'none'` excludes the plugin from Vegas mode.
|
||||
|
||||
**3. Optionally Specify Display Mode:**
|
||||
|
||||
```python
|
||||
def get_vegas_display_mode(self):
|
||||
"""
|
||||
Preferred display mode for this plugin.
|
||||
These return `VegasDisplayMode` members, not strings:
|
||||
|
||||
Returns:
|
||||
str: 'scroll' | 'fixed' | 'static'
|
||||
"""
|
||||
return 'scroll'
|
||||
```python
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
|
||||
def get_vegas_display_mode(self):
|
||||
return VegasDisplayMode.SCROLL
|
||||
|
||||
def get_supported_vegas_modes(self):
|
||||
"""
|
||||
List of supported modes.
|
||||
|
||||
Returns:
|
||||
list: ['scroll', 'fixed', 'static']
|
||||
"""
|
||||
return ['scroll', 'static']
|
||||
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
|
||||
```
|
||||
|
||||
`VegasDisplayMode` has `SCROLL` (`"scroll"`), `FIXED_SEGMENT` (`"fixed"`) and
|
||||
`STATIC` (`"static"`). The default `get_vegas_display_mode()` uses the
|
||||
plugin's `vegas_mode` config value if set, otherwise maps the content type
|
||||
(`multi` to `SCROLL`, anything else to `FIXED_SEGMENT`).
|
||||
|
||||
### Content Rendering Guidelines
|
||||
|
||||
**Image Dimensions:**
|
||||
@@ -966,11 +956,16 @@ from src.cache_manager import CacheManager
|
||||
|
||||
service = get_background_service(CacheManager())
|
||||
stats = service.get_statistics()
|
||||
print(f"Active tasks: {stats['active_tasks']}")
|
||||
print(f"Completed: {stats['completed']}")
|
||||
print(f"Failed: {stats['failed']}")
|
||||
print(f"Active: {stats['active_requests']}")
|
||||
print(f"Completed: {stats['completed_requests']}")
|
||||
print(f"Failed: {stats['failed_requests']}")
|
||||
```
|
||||
|
||||
Other keys: `total_requests`, `cached_hits`, `cache_misses`,
|
||||
`average_fetch_time`, `completed_requests_count` (results currently held in
|
||||
memory) — see `BackgroundDataService.get_statistics()` in
|
||||
[`src/background_data_service.py`](../src/background_data_service.py).
|
||||
|
||||
**Enable Debug Logging:**
|
||||
```python
|
||||
import logging
|
||||
@@ -981,6 +976,10 @@ logging.getLogger('src.background_data_service').setLevel(logging.DEBUG)
|
||||
|
||||
## 5. Permission Management
|
||||
|
||||
Ownership, modes, sudo rules and the repair scripts are listed in
|
||||
[PERMISSIONS.md](PERMISSIONS.md). This section covers the helpers code uses
|
||||
to keep files shareable.
|
||||
|
||||
### Overview
|
||||
|
||||
LEDMatrix uses a dual-user architecture: the display service runs as root (hardware access), while the web interface runs as a non-privileged user. Centralized permission management ensures both can access necessary files.
|
||||
@@ -1044,7 +1043,7 @@ ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
|
||||
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||
| Plugins | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||
| Cache files | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||
| Cache files | `rw-rw----` | `0o660` | Owner/group write, no world access (`_CACHE_FILE_MODE` in `src/cache/disk_cache.py`) |
|
||||
|
||||
**Directory Permissions:**
|
||||
|
||||
@@ -1115,40 +1114,22 @@ These core utilities **already handle permissions** - you don't need to call per
|
||||
|
||||
### Manual Fixes
|
||||
|
||||
If you encounter permission issues:
|
||||
[PERMISSIONS.md](PERMISSIONS.md) lists who owns what on an installed system,
|
||||
the expected modes, and which `scripts/fix_perms/` script to run as which
|
||||
user. In short:
|
||||
|
||||
```bash
|
||||
# Targeted permission fixes (see scripts/fix_perms/README.md)
|
||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts)
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh # all cache directories
|
||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh # plugin directories
|
||||
sudo ./scripts/fix_perms/fix_web_permissions.sh # web interface files
|
||||
- `fix_assets_permissions.sh`, `fix_cache_permissions.sh` and
|
||||
`fix_plugin_permissions.sh` are run with `sudo`.
|
||||
- `fix_web_permissions.sh` is run as the web interface user, without
|
||||
`sudo` (it refuses to run as root and calls `sudo` itself where needed).
|
||||
It resets project file ownership for that user, then makes the two
|
||||
helper scripts the web user may run as root (`safe_plugin_rm.sh`,
|
||||
`safe_pip_install.sh`) root-owned again and restores `config_secrets.json`
|
||||
to its owner, the `ledmatrix` group and mode `640`. It does not write
|
||||
sudoers rules; `scripts/install/configure_web_sudo.sh` does that.
|
||||
|
||||
# Fix specific directory
|
||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
|
||||
sudo chmod -R 2775 /home/ledpi/LEDMatrix/config
|
||||
sudo find /home/ledpi/LEDMatrix/config -type f -exec chmod 664 {} \;
|
||||
|
||||
# Verify permissions
|
||||
ls -la config/
|
||||
ls -la assets/
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Check directory has setgid bit
|
||||
ls -ld assets/
|
||||
# Should show: drwxrwsr-x (note the 's')
|
||||
|
||||
# Check file has correct group
|
||||
ls -l assets/logo.png
|
||||
# Should show group 'ledpi'
|
||||
|
||||
# Check file permissions
|
||||
stat -c "%a %n" config/config.json
|
||||
# Should show: 644 config/config.json
|
||||
```
|
||||
Do not `chmod` the whole `config/` directory: `config_secrets.json` must stay
|
||||
`640`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
||||
- [Using Weather Icons](#using-weather-icons)
|
||||
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
||||
- [Cache Strategy Patterns](#cache-strategy-patterns)
|
||||
- [Font Management and Overrides](#font-management-and-overrides)
|
||||
- [Font Management](#font-management)
|
||||
- [Error Handling Best Practices](#error-handling-best-practices)
|
||||
- [Performance Optimization](#performance-optimization)
|
||||
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
|
||||
@@ -25,69 +25,12 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
||||
|
||||
## Using Weather Icons
|
||||
|
||||
The Display Manager provides built-in weather icon drawing methods for easy visual representation of weather conditions.
|
||||
|
||||
### Basic Weather Icon Usage
|
||||
|
||||
```python
|
||||
def display(self, force_clear=False):
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Draw weather icon based on condition
|
||||
condition = self.data.get('condition', 'clear')
|
||||
self.display_manager.draw_weather_icon(condition, x=5, y=5, size=16)
|
||||
|
||||
# Draw temperature next to icon
|
||||
temp = self.data.get('temp', 72)
|
||||
self.display_manager.draw_text(
|
||||
f"{temp}°F",
|
||||
x=25, y=10,
|
||||
color=(255, 255, 255)
|
||||
)
|
||||
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
### Supported Weather Conditions
|
||||
|
||||
The `draw_weather_icon()` method automatically maps condition strings to appropriate icons:
|
||||
|
||||
- `"clear"`, `"sunny"` → Sun icon
|
||||
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
||||
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
||||
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
||||
- `"thunderstorm"`, `"storm"` → Storm icon
|
||||
|
||||
### Custom Weather Icons
|
||||
|
||||
For more control, use individual icon methods:
|
||||
|
||||
```python
|
||||
# Draw specific icons
|
||||
self.display_manager.draw_sun(x=10, y=10, size=16)
|
||||
self.display_manager.draw_cloud(x=10, y=10, size=16, color=(150, 150, 150))
|
||||
self.display_manager.draw_rain(x=10, y=10, size=16)
|
||||
self.display_manager.draw_snow(x=10, y=10, size=16)
|
||||
```
|
||||
|
||||
### Text with Weather Icons
|
||||
|
||||
Use `draw_text_with_icons()` to combine text and icons:
|
||||
|
||||
```python
|
||||
icons = [
|
||||
("sun", 5, 5), # Sun icon at (5, 5)
|
||||
("cloud", 100, 5) # Cloud icon at (100, 5)
|
||||
]
|
||||
|
||||
self.display_manager.draw_text_with_icons(
|
||||
"Weather: Sunny, Cloudy",
|
||||
icons=icons,
|
||||
x=10, y=20,
|
||||
color=(255, 255, 255)
|
||||
)
|
||||
```
|
||||
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
|
||||
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
|
||||
are deprecated, removed in 3.7.0. Draw your own icons instead: render them
|
||||
onto a PIL image and paste it onto `self.display_manager.image`, or ship
|
||||
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
|
||||
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
|
||||
---
|
||||
|
||||
@@ -251,11 +194,8 @@ def update(self):
|
||||
sport_key = "nhl"
|
||||
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
||||
|
||||
# Uses sport-specific live_update_interval from config
|
||||
cached = self.cache_manager.get_background_cached_data(
|
||||
cache_key,
|
||||
sport_key=sport_key
|
||||
)
|
||||
# get_background_cached_data() is deprecated, removed in 3.7.0 — use get()
|
||||
cached = self.cache_manager.get(cache_key, max_age=60)
|
||||
|
||||
if cached:
|
||||
self.games = cached
|
||||
@@ -282,9 +222,9 @@ def on_config_change(self, new_config):
|
||||
|
||||
---
|
||||
|
||||
## Font Management and Overrides
|
||||
## Font Management
|
||||
|
||||
Use the Font Manager for advanced font handling and user customization.
|
||||
The display manager's built-in fonts and text measurement. For fonts shipped with a plugin, see [FONT_MANAGER.md](FONT_MANAGER.md).
|
||||
|
||||
### Using Different Fonts
|
||||
|
||||
@@ -656,14 +596,12 @@ def update(self):
|
||||
|
||||
```python
|
||||
def update(self):
|
||||
# Check if another plugin is enabled
|
||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
||||
if "weather" in enabled_plugins:
|
||||
# Weather plugin is available
|
||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||
if weather_plugin:
|
||||
# Use weather data
|
||||
pass
|
||||
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check the
|
||||
# instance's `enabled` flag instead
|
||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||
if weather_plugin is not None and weather_plugin.enabled:
|
||||
# Use weather data
|
||||
pass
|
||||
```
|
||||
|
||||
### Sharing Data Between Plugins
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Architecture
|
||||
|
||||
A map of the codebase for a new contributor: which process does what, how
|
||||
they talk to each other, and where to start reading for common changes.
|
||||
|
||||
## Processes
|
||||
|
||||
| systemd unit | Runs as | Runs | Installed by |
|
||||
|---|---|---|---|
|
||||
| `ledmatrix.service` | root | [`run.py`](../run.py) → `DisplayController` | [`install_service.sh`](../scripts/install/install_service.sh) |
|
||||
| `ledmatrix-web.service` | the installing user | [`start_web_conditionally.py`](../scripts/utils/start_web_conditionally.py) → [`web_interface/start.py`](../web_interface/start.py) (Flask, port 5000) | `install_service.sh`, [`install_web_service.sh`](../scripts/install/install_web_service.sh) |
|
||||
| `ledmatrix-update-verify.path` / `.service` | the web user | Health check after an automatic update | the same installers, or [`src/auto_update_setup.py`](../src/auto_update_setup.py) at runtime |
|
||||
| `ledmatrix-wifi-monitor.service` | root | [`wifi_monitor_daemon.py`](../scripts/utils/wifi_monitor_daemon.py) | [`install_wifi_monitor.sh`](../scripts/install/install_wifi_monitor.sh) |
|
||||
| `ledmatrix-mqtt-bridge.service` | root | [MQTT bridge](../integrations/mqtt_bridge/README.md) (optional) | [`install_mqtt_bridge.sh`](../scripts/install/install_mqtt_bridge.sh) |
|
||||
| `ledmatrix-dns-fix.service` | root | DNS workaround (optional) | [`install_dns_fix.sh`](../scripts/install/install_dns_fix.sh) |
|
||||
|
||||
Unit templates are in [`systemd/`](../systemd/README.md). The display runs as
|
||||
root because the LED matrix library needs direct GPIO access. The web
|
||||
interface runs unprivileged and uses a fixed list of `sudo` rules for the
|
||||
few privileged things it does; see [PERMISSIONS.md](PERMISSIONS.md).
|
||||
|
||||
`start_web_conditionally.py` exits without starting Flask when
|
||||
`web_display_autostart` is explicitly false in `config.json`.
|
||||
|
||||
## How the two main processes share state
|
||||
|
||||
The display and the web interface are separate processes that never call
|
||||
each other. They share three things:
|
||||
|
||||
1. **`config/config.json` and `config/config_secrets.json`.** The web
|
||||
interface writes them through `ConfigManager`
|
||||
([`src/config_manager.py`](../src/config_manager.py)); the display
|
||||
notices through `ConfigService` (below).
|
||||
2. **The disk cache**, `/var/cache/ledmatrix` (owned `root:ledmatrix`,
|
||||
setgid, files `0660`), read and written through `CacheManager`
|
||||
([`src/cache_manager.py`](../src/cache_manager.py),
|
||||
[`src/cache/disk_cache.py`](../src/cache/disk_cache.py)). Readers in the
|
||||
other process pass `memory_ttl=0` so they do not serve a stale in-memory
|
||||
copy.
|
||||
3. **A few files in `/tmp`.**
|
||||
|
||||
| State | Where | Written by | Read by |
|
||||
|---|---|---|---|
|
||||
| On-demand request | cache `display_on_demand_request` | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py) | display: `_poll_on_demand_requests()` |
|
||||
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
||||
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
||||
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
|
||||
| Preview viewer marker | `/tmp/led_matrix_preview_viewer` | web, while a preview is open | display: writes full-rate snapshots only while it is fresh |
|
||||
| Hardware init status | `/tmp/led_matrix_hw_status.json` | display | web: `/api/v3/hardware/status` |
|
||||
|
||||
The on-demand start route also restarts `ledmatrix.service` by default so the
|
||||
request takes effect straight away.
|
||||
|
||||
## Display loop
|
||||
|
||||
[`src/display_controller.py`](../src/display_controller.py), class
|
||||
`DisplayController`. `__init__` loads config, starts the cache and the
|
||||
error-snapshot publisher, runs the startup validator, creates the
|
||||
`DisplayManager` ([`src/display_manager.py`](../src/display_manager.py)),
|
||||
`FontManager` and `PluginManager`, loads the enabled plugins in parallel,
|
||||
runs an initial `update()` pass within a 20-second budget
|
||||
(`_INITIAL_UPDATE_BUDGET_SECONDS`; a plugin that misses it is deferred to
|
||||
the scheduler), and sets up Vegas mode.
|
||||
|
||||
`run()` is the main loop. Each pass, in order: apply a pending plugin
|
||||
enable/disable, poll on-demand requests, run scheduled plugin updates, check
|
||||
the on/off schedule and brightness, then show one screen. Priority is
|
||||
on-demand, then WiFi status messages, then live priority, then Vegas mode,
|
||||
then normal rotation.
|
||||
|
||||
- **Rotation.** `available_modes` is the ordered list of display modes;
|
||||
`current_mode_index` advances after each screen.
|
||||
`_apply_plugin_rotation_order()` applies `display.plugin_rotation_order`.
|
||||
- **Durations.** `_get_display_duration()`: `display.display_durations[mode]`,
|
||||
else the plugin's `get_display_duration()`, else 30 s. Plugins that
|
||||
support dynamic duration run until `is_cycle_complete()`, capped by
|
||||
`display.dynamic_duration.max_duration_seconds` (default 180 s).
|
||||
- **On-demand.** A request from the web interface pins one plugin (or mode)
|
||||
for a duration. `_activate_on_demand()` / `_clear_on_demand()`; the
|
||||
session is saved under `display_on_demand_config` so it survives a
|
||||
restart. It also keeps the display on during scheduled off hours.
|
||||
- **Live priority.** `_check_live_priority()` looks for a plugin whose
|
||||
`has_live_priority()` and `has_live_content()` are both true and switches
|
||||
to it, rotating between several live games.
|
||||
- **Schedule and dim schedule.** `_check_schedule()` reads `schedule`;
|
||||
`_check_dim_schedule()` reads `dim_schedule` and
|
||||
`display.hardware.brightness`. Both are re-evaluated once a minute.
|
||||
- **Long screens.** While a screen is showing (a dwell, a scroll, a Vegas
|
||||
iteration), `_service_pending_changes()` repeats the on-demand, schedule
|
||||
and brightness checks every 0.25 s, so a change does not wait for the
|
||||
screen to end.
|
||||
- **Config hot reload.** `ConfigService`
|
||||
([`src/config_service.py`](../src/config_service.py)) polls the config and
|
||||
secrets files' mtimes every 2 s and notifies subscribers when the content
|
||||
changes. The controller refreshes its cached settings; enabling or
|
||||
disabling a plugin queues `_reconcile_enabled_plugins()`, which loads or
|
||||
unloads it on the display thread; each plugin gets `on_config_change()`
|
||||
for its own section. Set `LEDMATRIX_HOT_RELOAD=false` to turn this off.
|
||||
Matrix hardware settings are only read at start-up.
|
||||
- **Vegas mode.** [`src/vegas_mode/`](../src/vegas_mode/): the display loop
|
||||
calls `VegasModeCoordinator.run_iteration()`
|
||||
([`coordinator.py`](../src/vegas_mode/coordinator.py)) when
|
||||
`display.vegas_scroll.enabled` is set. `PluginAdapter` gets each plugin's
|
||||
content (`get_vegas_content()`, else its `scroll_helper` image, else a
|
||||
capture of `display()`), `StreamManager` orders it and `RenderPipeline`
|
||||
scrolls it. See [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md).
|
||||
- **Multi-display sync.** `DisplaySyncManager`
|
||||
([`src/common/sync_manager.py`](../src/common/sync_manager.py)), enabled by
|
||||
`sync.role`: a leader sends a follower its share of each frame over UDP
|
||||
(port 5765).
|
||||
|
||||
## Plugin system
|
||||
|
||||
[`src/plugin_system/`](../src/plugin_system/):
|
||||
|
||||
| Area | Where |
|
||||
|---|---|
|
||||
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
|
||||
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
|
||||
| Discovery, load, unload, scheduled updates | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
|
||||
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
|
||||
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
||||
| Resource metrics | [`resource_monitor.py`](../src/plugin_system/resource_monitor.py) |
|
||||
| Config schemas and defaults | [`schema_manager.py`](../src/plugin_system/schema_manager.py) |
|
||||
| Install, update, uninstall | [`store_manager.py`](../src/plugin_system/store_manager.py) (`PluginStoreManager`) |
|
||||
| Core-version gate | [`compatibility.py`](../src/plugin_system/compatibility.py) |
|
||||
|
||||
Discovery scans only `plugin_system.plugins_directory` (default
|
||||
`plugin-repos/`). Scheduled `update()` calls run on one background worker
|
||||
thread; a per-plugin lock keeps `display()` from running during an update.
|
||||
|
||||
**Store flow.** `install_plugin()` renames any existing copy aside
|
||||
(`<id>.standalone-backup-preinstall`), installs the new one, and puts the old
|
||||
copy back if the install fails. Monorepo plugins come from the GitHub Trees
|
||||
API, falling back to the repository ZIP; other plugins by `git clone` or
|
||||
download. The manifest is checked (see
|
||||
[required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)), the core
|
||||
version gate runs, then dependencies are installed as root through
|
||||
`scripts/fix_perms/safe_pip_install.sh`. `update_plugin()` pulls git
|
||||
installs, undoing a pull whose new version is incompatible, and reinstalls
|
||||
everything else through `_reinstall_with_rollback()`.
|
||||
|
||||
## Web interface
|
||||
|
||||
- **App.** [`web_interface/app.py`](../web_interface/app.py) builds the
|
||||
Flask `app` at import time, creates the managers, and registers two
|
||||
blueprints. `web_interface/start.py` runs it on port 5000.
|
||||
- **Pages.** [`blueprints/pages_v3.py`](../web_interface/blueprints/pages_v3.py)
|
||||
serves the shell `templates/v3/base.html` at `/` and each tab as a
|
||||
partial at `/partials/<name>` (templates in
|
||||
`web_interface/templates/v3/partials/`). Plugin configuration tabs are
|
||||
rendered from the plugin's schema by `plugin_config.html`.
|
||||
- **API.** [`blueprints/api_v3/`](../web_interface/blueprints/api_v3/) is one
|
||||
blueprint at `/api/v3`, split by area: `backup.py`, `config.py`,
|
||||
`display.py`, `fonts.py`, `misc.py` (health, logs, errors, cache, sync),
|
||||
`plugins.py`, `starlark.py`, `system.py` (service actions, updates, git),
|
||||
`wifi.py`. `__init__.py` defines the blueprint and shared helpers and
|
||||
imports the modules so their routes register. Endpoints are listed in
|
||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md).
|
||||
- **Front end.** HTMX loads each tab's partial on first open
|
||||
(`hx-trigger="loadtab"`); Alpine.js holds page state. Scripts are in
|
||||
`web_interface/static/v3/js/`; form widgets are bundled from
|
||||
[`js/widgets/`](../web_interface/static/v3/js/widgets/README.md).
|
||||
- **Server-sent events** (`app.py`): `/api/v3/stream/stats` (CPU, memory,
|
||||
temperature, service state, every 10 s), `/api/v3/stream/display` (preview
|
||||
frames when the PNG changes) and `/api/v3/stream/logs` (journal of both
|
||||
services). One generator thread per stream is shared by all clients.
|
||||
|
||||
## Updates
|
||||
|
||||
- **Update Code** on the Overview tab and the automatic updater both call
|
||||
`perform_core_update()` in
|
||||
[`api_v3/system.py`](../web_interface/blueprints/api_v3/system.py):
|
||||
`git pull --rebase`, reinstall changed requirement files, report whether a
|
||||
restart is needed.
|
||||
- **Automatic updates** (`auto_update.enabled`, off by default):
|
||||
`AutoUpdater` in [`web_interface/auto_update.py`](../web_interface/auto_update.py)
|
||||
runs in the web process, checks every 30 minutes, and updates at most
|
||||
weekly between 02:00 and 05:00. Before pulling it copies
|
||||
[`scripts/utils/auto_update_verify.py`](../scripts/utils/auto_update_verify.py)
|
||||
to `data/auto_update_verifier.py`, then writes
|
||||
`data/auto_update_verify.request`. That file triggers
|
||||
`ledmatrix-update-verify.path`, which runs the verifier as a separate unit
|
||||
(so restarting the web service does not kill it). The verifier restarts
|
||||
both services, waits for the web API to answer and the display service to
|
||||
stay up, and on failure resets to the previous commit and restarts again.
|
||||
Plugin updates run only after a verified core update. State is in
|
||||
`data/auto_update_state.json` and `data/auto_update_pending.json`.
|
||||
- **Startup validator.** `StartupValidator`
|
||||
([`src/startup_validator.py`](../src/startup_validator.py)) runs twice in
|
||||
`DisplayController.__init__`: config and cache directory first, then
|
||||
enabled plugins once the plugin manager exists. It also warns when an
|
||||
installed systemd unit differs from its template in `systemd/`. Results
|
||||
are logged; startup continues either way.
|
||||
|
||||
## Where to start reading
|
||||
|
||||
| Task | Start with |
|
||||
|---|---|
|
||||
| Change rotation, durations or priorities | `DisplayController.run()` and `_get_display_duration()` in [`display_controller.py`](../src/display_controller.py) |
|
||||
| Add a config key | [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md), [`config/config.template.json`](../config/config.template.json), the tab's partial and `api_v3/config.py` |
|
||||
| Change drawing or fonts | [`display_manager.py`](../src/display_manager.py), [`font_manager.py`](../src/font_manager.py), [`src/common/bdf_font.py`](../src/common/bdf_font.py) |
|
||||
| Add a plugin-facing API | [`base_plugin.py`](../src/plugin_system/base_plugin.py) or [`src/common/`](../src/common/README.md); document it in [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) |
|
||||
| Plugin install/update bugs | `PluginStoreManager` in [`store_manager.py`](../src/plugin_system/store_manager.py) |
|
||||
| A plugin that won't load | `PluginManager.load_plugin()` and `PluginLoader.load_plugin()`; `python3 scripts/check_plugin.py --plugin <id>` |
|
||||
| Add an API endpoint | the matching module in [`api_v3/`](../web_interface/blueprints/api_v3/) |
|
||||
| Add a web UI tab or control | `templates/v3/base.html`, the tab's partial, `pages_v3.py` |
|
||||
| Vegas scroll | [`src/vegas_mode/coordinator.py`](../src/vegas_mode/coordinator.py) |
|
||||
| Installer or permissions | [`first_time_install.sh`](../first_time_install.sh), [`scripts/install/`](../scripts/install/), [PERMISSIONS.md](PERMISSIONS.md) |
|
||||
| Work without a Pi | [DEV_PREVIEW.md](DEV_PREVIEW.md), [EMULATOR_SETUP_GUIDE.md](EMULATOR_SETUP_GUIDE.md), [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) |
|
||||
@@ -172,10 +172,14 @@ ERROR - Plugin football-scoreboard configuration validation failed: 'api_key' is
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
Set environment variable:
|
||||
Run the display in the foreground with `-d`, or set `LEDMATRIX_DEBUG=true`
|
||||
(the value must be `true`; `1` is ignored — see `setup_logging()` in
|
||||
[`src/logging_config.py`](../src/logging_config.py)):
|
||||
```bash
|
||||
export LEDMATRIX_DEBUG=1
|
||||
python run.py
|
||||
sudo systemctl stop ledmatrix.service
|
||||
sudo python3 run.py -d
|
||||
# or
|
||||
sudo LEDMATRIX_DEBUG=true python3 run.py
|
||||
```
|
||||
|
||||
### Check Merged Configuration
|
||||
@@ -321,8 +325,10 @@ cp config/backups/config.json.backup.20240115_120000_000000 config/config.json
|
||||
|
||||
## Getting Help
|
||||
|
||||
1. Check logs: `tail -f logs/ledmatrix.log`
|
||||
2. Enable debug: `LEDMATRIX_DEBUG=1`
|
||||
1. Check logs. Both services log to journald, not to a file:
|
||||
`sudo journalctl -u ledmatrix.service -f` (display) and
|
||||
`sudo journalctl -u ledmatrix-web.service -f` (web interface)
|
||||
2. Enable debug: `LEDMATRIX_DEBUG=true` or `python3 run.py -d`
|
||||
3. Check error dashboard: `/api/v3/errors/summary`
|
||||
4. Validate JSON: https://jsonlint.com/
|
||||
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
||||
|
||||
@@ -105,6 +105,7 @@ logical image to multiple chained physical panels.
|
||||
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
|
||||
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
|
||||
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config |
|
||||
| `scan_order_compensation` | string, `"auto"` | `"auto"` shows one half of each panel a refresh behind while something scrolls at one frame per refresh, which removes the 1px step a 1:N-scan panel shows across its middle; `"off"` disables it. Applies only to layouts whose row order is known: plain or parallel chains, 0 or 180 degree orientation, `multiplexing` 0, `scan_mode` 0, and not in the emulator | `DisplayManager._setup_scan_order_compensation()` (`src/display_manager.py`, `src/scan_order.py`) |
|
||||
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
|
||||
|
||||
## `display.vegas_scroll` — continuous scroll mode
|
||||
@@ -128,6 +129,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `min_cut_gap` | int, `6` |
|
||||
| `continuous_scroll` | bool, `true` |
|
||||
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
|
||||
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
||||
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
|
||||
| `extend_threshold_screens` | float, `2.0` |
|
||||
|
||||
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||
|
||||
# Weather icons
|
||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
||||
# Weather icons: draw_weather_icon() is deprecated, removed in 3.7.0 —
|
||||
# draw your own icons (the weather plugin ships WeatherIcons)
|
||||
|
||||
# Scrolling state
|
||||
display_manager.set_scrolling_state(True)
|
||||
@@ -72,20 +72,23 @@ cache_manager.delete("key") # alias for clear_cache(key)
|
||||
|
||||
# Advanced caching
|
||||
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
||||
data = cache_manager.get_background_cached_data("key", sport_key="nhl")
|
||||
|
||||
# Strategy
|
||||
strategy = cache_manager.get_cache_strategy("weather")
|
||||
interval = cache_manager.get_sport_live_interval("nhl")
|
||||
```
|
||||
|
||||
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
|
||||
are deprecated, removed in 3.7.0. See
|
||||
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
|
||||
## Plugin Manager Quick Methods
|
||||
|
||||
```python
|
||||
# Get plugins
|
||||
plugin = plugin_manager.get_plugin("plugin-id")
|
||||
all_plugins = plugin_manager.get_all_plugins()
|
||||
enabled = plugin_manager.get_enabled_plugins()
|
||||
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check `enabled`
|
||||
# on the entries in plugin_manager.plugins
|
||||
|
||||
# Get info
|
||||
info = plugin_manager.get_plugin_info("plugin-id")
|
||||
@@ -168,7 +171,7 @@ def display(self, force_clear=False):
|
||||
|
||||
- [ ] Plugin inherits from `BasePlugin`
|
||||
- [ ] Implements `update()` and `display()` methods
|
||||
- [ ] `manifest.json` with required fields
|
||||
- [ ] `manifest.json` with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
|
||||
- [ ] `config_schema.json` for web UI (recommended)
|
||||
- [ ] `README.md` with documentation
|
||||
- [ ] Error handling implemented
|
||||
|
||||
+123
-326
@@ -9,12 +9,14 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||
- Manager font registration and detection
|
||||
- Plugin font management
|
||||
- Programmatic per-element font overrides
|
||||
- Performance monitoring and caching
|
||||
- Dynamic font discovery
|
||||
[`src/font_manager.py`](../src/font_manager.py) loads and caches the TTF and
|
||||
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
|
||||
which plugin uses which font so the web UI can show it.
|
||||
|
||||
Several methods are deprecated and will be removed in LEDMatrix 3.7.0; they
|
||||
log a warning on first call. They are listed in
|
||||
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
|
||||
[`test/test_deprecation.py`](../test/test_deprecation.py).
|
||||
|
||||
## Getting the FontManager
|
||||
|
||||
@@ -34,157 +36,60 @@ standalone FontManager when none is available (test harnesses, mocks).
|
||||
`DisplayManager` has **no** `font_manager` attribute —
|
||||
`display_manager.font_manager` raises `AttributeError`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Manager-Centric Design
|
||||
|
||||
Managers define their own fonts, but the FontManager:
|
||||
1. **Loads and caches fonts** for performance
|
||||
2. **Detects font usage** for visibility
|
||||
3. **Allows manual overrides** when needed
|
||||
4. **Supports plugin fonts** with namespacing
|
||||
|
||||
### Font Resolution Flow
|
||||
|
||||
```
|
||||
Manager requests font → Check manual overrides → Apply manager choice → Cache & return
|
||||
```
|
||||
|
||||
## For Manager Developers
|
||||
|
||||
### Basic Font Usage
|
||||
## Resolving a font
|
||||
|
||||
```python
|
||||
from src.font_manager import FontManager
|
||||
element_key = f"{self.plugin_id}.title"
|
||||
|
||||
class MyManager:
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
||||
self.display_manager = display_manager
|
||||
self.font_manager = plugin_manager.font_manager # Shared FontManager
|
||||
self.manager_id = "my_manager"
|
||||
|
||||
def display(self):
|
||||
# Define your font choices
|
||||
element_key = "my_manager.title"
|
||||
font_family = "press_start"
|
||||
font_size_px = 10
|
||||
color = (255, 255, 255) # RGB white
|
||||
|
||||
# Register your font choice (for detection and future overrides)
|
||||
self.font_manager.register_manager_font(
|
||||
manager_id=self.manager_id,
|
||||
element_key=element_key,
|
||||
family=font_family,
|
||||
size_px=font_size_px,
|
||||
color=color
|
||||
)
|
||||
|
||||
# Get the font (checks for manual overrides automatically)
|
||||
font = self.font_manager.resolve_font(
|
||||
element_key=element_key,
|
||||
family=font_family,
|
||||
size_px=font_size_px
|
||||
)
|
||||
|
||||
# Use the font for rendering
|
||||
self.display_manager.draw_text(
|
||||
"Hello World",
|
||||
x=10, y=10,
|
||||
color=color,
|
||||
font=font
|
||||
)
|
||||
```
|
||||
|
||||
### Advanced Font Usage
|
||||
|
||||
```python
|
||||
class AdvancedManager:
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
||||
self.display_manager = display_manager
|
||||
self.font_manager = plugin_manager.font_manager
|
||||
self.manager_id = "advanced_manager"
|
||||
|
||||
# Define your font specifications
|
||||
self.font_specs = {
|
||||
"title": {"family": "press_start", "size_px": 12, "color": (255, 255, 0)},
|
||||
"body": {"family": "four_by_six", "size_px": 8, "color": (255, 255, 255)},
|
||||
"footer": {"family": "five_by_seven", "size_px": 7, "color": (128, 128, 128)}
|
||||
}
|
||||
|
||||
# Register all font specs
|
||||
for element_type, spec in self.font_specs.items():
|
||||
element_key = f"{self.manager_id}.{element_type}"
|
||||
self.font_manager.register_manager_font(
|
||||
manager_id=self.manager_id,
|
||||
element_key=element_key,
|
||||
family=spec["family"],
|
||||
size_px=spec["size_px"],
|
||||
color=spec["color"]
|
||||
)
|
||||
|
||||
def get_font(self, element_type: str):
|
||||
"""Helper method to get fonts with override support."""
|
||||
spec = self.font_specs[element_type]
|
||||
element_key = f"{self.manager_id}.{element_type}"
|
||||
|
||||
return self.font_manager.resolve_font(
|
||||
element_key=element_key,
|
||||
family=spec["family"],
|
||||
size_px=spec["size_px"]
|
||||
)
|
||||
|
||||
def display(self):
|
||||
# Get fonts (automatically checks for overrides)
|
||||
title_font = self.get_font("title")
|
||||
body_font = self.get_font("body")
|
||||
footer_font = self.get_font("footer")
|
||||
|
||||
# Render with fonts
|
||||
self.display_manager.draw_text("Title", font=title_font, color=self.font_specs["title"]["color"])
|
||||
self.display_manager.draw_text("Body Text", font=body_font, color=self.font_specs["body"]["color"])
|
||||
self.display_manager.draw_text("Footer", font=footer_font, color=self.font_specs["footer"]["color"])
|
||||
```
|
||||
|
||||
### Using Size Tokens
|
||||
|
||||
```python
|
||||
# Get available size tokens
|
||||
tokens = self.font_manager.get_size_tokens()
|
||||
# Returns: {'xs': 6, 'sm': 8, 'md': 10, 'lg': 12, 'xl': 14, 'xxl': 16}
|
||||
|
||||
# Use token to get size
|
||||
size_px = tokens.get('md', 10) # 10px
|
||||
|
||||
# Then use in font resolution
|
||||
font = self.font_manager.resolve_font(
|
||||
element_key="my_manager.text",
|
||||
# Register the choice so the web UI's Fonts tab can list it.
|
||||
self.font_manager.register_manager_font(
|
||||
manager_id=self.plugin_id,
|
||||
element_key=element_key,
|
||||
family="press_start",
|
||||
size_px=size_px
|
||||
size_px=10,
|
||||
color=(255, 255, 255),
|
||||
)
|
||||
|
||||
font = self.font_manager.resolve_font(
|
||||
element_key=element_key,
|
||||
family="press_start",
|
||||
size_px=10,
|
||||
)
|
||||
self.display_manager.draw_text("Hello", x=10, y=10, font=font)
|
||||
```
|
||||
|
||||
## For Plugin Developers
|
||||
`resolve_font()` applies any entry for `element_key` in
|
||||
`config/font_overrides.json`, maps a plugin-local family to its namespaced
|
||||
name when `plugin_id` is passed, and then calls `get_font(family, size_px)`.
|
||||
On error it returns a fallback font rather than raising.
|
||||
|
||||
> **Note**: plugins that ship their own fonts via a `"fonts"` block
|
||||
> in `manifest.json` are registered automatically during plugin load
|
||||
> (`src/plugin_system/plugin_manager.py` calls
|
||||
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
|
||||
> URIs documented below are resolved relative to the plugin's
|
||||
> install directory.
|
||||
>
|
||||
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
|
||||
> font files in `assets/fonts/`. Its **Used by** column shows which
|
||||
> loaded plugins registered each file through `register_manager_font()`
|
||||
> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it
|
||||
> warns before deleting one of them. It has no override editor (the
|
||||
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
|
||||
> The programmatic override workflow in
|
||||
> [Manual Font Overrides](#manual-font-overrides) below still works.
|
||||
> Let users pick fonts through your plugin's own config schema.
|
||||
`get_font(family, size_px)` looks the family up in `font_catalog` and loads
|
||||
it (cached per family and size).
|
||||
|
||||
### Plugin Font Registration
|
||||
## Font families
|
||||
|
||||
In your plugin's `manifest.json`:
|
||||
At start-up the FontManager scans `assets/fonts/` for `.ttf` and `.bdf`
|
||||
files. Each becomes a family named after the file, lower-cased and without
|
||||
the extension (`PressStart2P-Regular.ttf` → `pressstart2p-regular`). Four
|
||||
aliases are added on top:
|
||||
|
||||
| Alias | File |
|
||||
|---|---|
|
||||
| `press_start` | `assets/fonts/PressStart2P-Regular.ttf` |
|
||||
| `four_by_six` | `assets/fonts/4x6-font.ttf` |
|
||||
| `five_by_seven` | `assets/fonts/5x7.bdf` |
|
||||
| `tom_thumb` | `assets/fonts/tom-thumb.bdf` |
|
||||
|
||||
Read the catalog directly: `font_manager.font_catalog` is a dict of family
|
||||
name to file path. Files added later are picked up on the next start of the
|
||||
display service.
|
||||
|
||||
## Plugin fonts
|
||||
|
||||
Plugins that ship their own fonts declare them in a `"fonts"` block in
|
||||
`manifest.json`. The plugin manager calls
|
||||
`FontManager.register_plugin_fonts()` during plugin load. `plugin://…`
|
||||
sources are resolved relative to the plugin's install directory.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -195,231 +100,123 @@ In your plugin's `manifest.json`:
|
||||
{
|
||||
"family": "custom_font",
|
||||
"source": "plugin://fonts/custom.ttf",
|
||||
"metadata": {
|
||||
"description": "Custom plugin font",
|
||||
"license": "MIT"
|
||||
}
|
||||
"metadata": {"description": "Custom plugin font", "license": "MIT"}
|
||||
},
|
||||
{
|
||||
"family": "web_font",
|
||||
"source": "https://example.com/fonts/font.ttf",
|
||||
"metadata": {
|
||||
"description": "Downloaded font",
|
||||
"checksum": "sha256:abc123..."
|
||||
}
|
||||
"metadata": {"checksum": "sha256:abc123..."}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Plugin Fonts
|
||||
Registered families are namespaced as `<plugin_id>::<family>`. Pass
|
||||
`plugin_id` to `resolve_font()` to use the short name:
|
||||
|
||||
```python
|
||||
class MyPlugin(BasePlugin):
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.font_manager = self._get_font_manager()
|
||||
|
||||
def display(self):
|
||||
# Use plugin font (automatically namespaced)
|
||||
font = self.font_manager.resolve_font(
|
||||
element_key=f"{self.plugin_id}.text",
|
||||
family="custom_font", # Will be resolved as "my-plugin::custom_font"
|
||||
size_px=10,
|
||||
plugin_id=self.plugin_id
|
||||
)
|
||||
|
||||
self.display_manager.draw_text("Plugin Text", font=font)
|
||||
```
|
||||
|
||||
## Manual Font Overrides
|
||||
|
||||
Overrides are set in code (there is no web UI or REST endpoint for them).
|
||||
They are stored in `config/font_overrides.json` and persist across restarts.
|
||||
|
||||
### Programmatic Overrides
|
||||
|
||||
```python
|
||||
# Set override
|
||||
font_manager.set_override(
|
||||
element_key="nfl.live.score",
|
||||
family="four_by_six",
|
||||
size_px=8
|
||||
font = self.font_manager.resolve_font(
|
||||
element_key=f"{self.plugin_id}.text",
|
||||
family="custom_font", # resolved as "my-plugin::custom_font"
|
||||
size_px=10,
|
||||
plugin_id=self.plugin_id,
|
||||
)
|
||||
|
||||
# Remove override
|
||||
font_manager.remove_override("nfl.live.score")
|
||||
|
||||
# Get all overrides
|
||||
overrides = font_manager.get_overrides()
|
||||
```
|
||||
|
||||
## Font Discovery
|
||||
## Overrides
|
||||
|
||||
### Available Fonts
|
||||
|
||||
The FontManager automatically scans `assets/fonts/` for TTF and BDF fonts:
|
||||
|
||||
```python
|
||||
# Get all available fonts
|
||||
fonts = font_manager.get_available_fonts()
|
||||
# Returns: {'press_start': 'assets/fonts/PressStart2P-Regular.ttf', ...}
|
||||
|
||||
# Check if font exists
|
||||
if "my_font" in fonts:
|
||||
font = font_manager.get_font("my_font", 10)
|
||||
```
|
||||
|
||||
### Adding Custom Fonts
|
||||
|
||||
Place font files in `assets/fonts/` directory:
|
||||
- Supported formats: `.ttf`, `.bdf`
|
||||
- Font family name is derived from filename (without extension)
|
||||
- Will be automatically discovered on next initialization
|
||||
`resolve_font()` still honours `config/font_overrides.json` (a map of
|
||||
element key to `family` and/or `size_px`), which is read once at start-up.
|
||||
The methods that edit it — `set_override()`, `remove_override()`,
|
||||
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
|
||||
for overrides (the override editor and `/api/v3/fonts/overrides` were
|
||||
removed). To let users choose a font, add a field to your plugin's config
|
||||
schema.
|
||||
|
||||
## Font usage in the web UI
|
||||
|
||||
The web interface runs in its own process and has no FontManager, so the
|
||||
display service publishes which plugin uses which font
|
||||
(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it:
|
||||
The web UI's **Fonts** tab lists, uploads, previews and deletes the font
|
||||
files in `assets/fonts/`. The web interface runs in its own process and has
|
||||
no FontManager, so the display service publishes which plugin uses which
|
||||
font ([`src/font_usage.py`](../src/font_usage.py)), and the tab's **Used by**
|
||||
column reads it:
|
||||
|
||||
- **Source**: `register_manager_font()` registrations of the loaded
|
||||
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
|
||||
and are not counted, and neither is a plugin that opens a font file
|
||||
directly with PIL — register the fonts your plugin draws with if you want
|
||||
them listed.
|
||||
- **Names**: a family, alias (`press_start`, `four_by_six`,
|
||||
`five_by_seven`, `tom_thumb`) or path is resolved through
|
||||
`font_catalog` to the file it loads and reported under that file's name
|
||||
without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`,
|
||||
`tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside
|
||||
`assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families
|
||||
that resolve to nothing are left out.
|
||||
- **Names**: a family, alias or path is resolved through `font_catalog` to
|
||||
the file it loads and reported under that file's name without extension
|
||||
(`PressStart2P-Regular`, `4x6-font`, `5x7`, `tom-thumb`), which is how the
|
||||
Fonts tab keys its rows. Fonts outside `assets/fonts/` (a plugin's own
|
||||
`plugin_id::family` fonts) and families that resolve to nothing are left
|
||||
out.
|
||||
- **When**: a daemon thread started once plugins have loaded checks every
|
||||
10 seconds and writes the `font_usage_snapshot` cache key only when the
|
||||
usage changed (and once a day, so the cache's cleanup never expires it).
|
||||
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
||||
- **Unknown**: until the display service has published, the column reads
|
||||
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
|
||||
- The tab warns before deleting a font that a loaded plugin registered.
|
||||
|
||||
## Performance Monitoring
|
||||
## Text measurement
|
||||
|
||||
```python
|
||||
# Get performance stats
|
||||
stats = font_manager.get_performance_stats()
|
||||
|
||||
print(f"Cache hit rate: {stats['cache_hit_rate']*100:.1f}%")
|
||||
print(f"Total fonts cached: {stats['total_fonts_cached']}")
|
||||
print(f"Failed loads: {stats['failed_loads']}")
|
||||
print(f"Manager fonts: {stats['manager_fonts']}")
|
||||
print(f"Plugin fonts: {stats['plugin_fonts']}")
|
||||
```
|
||||
|
||||
## Text Measurement
|
||||
|
||||
```python
|
||||
# Measure text dimensions
|
||||
width, height, baseline = font_manager.measure_text("Hello", font)
|
||||
|
||||
# Get font height
|
||||
font_height = font_manager.get_font_height(font)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
## Tips
|
||||
|
||||
### For Managers
|
||||
|
||||
1. **Register all fonts** you use for visibility
|
||||
2. **Use consistent element keys** (e.g., `{manager_id}.{element_type}`)
|
||||
3. **Cache font references** if using same font multiple times
|
||||
4. **Use `resolve_font()`** not `get_font()` directly to support overrides
|
||||
5. **Define sensible defaults** that work well on LED matrix
|
||||
|
||||
### For Plugins
|
||||
|
||||
1. **Use plugin-relative paths** (`plugin://fonts/...`)
|
||||
2. **Include font metadata** (license, description)
|
||||
3. **Provide fallback** fonts if custom fonts fail to load
|
||||
4. **Test with different display sizes**
|
||||
|
||||
### General
|
||||
|
||||
1. **BDF fonts** are often better for small sizes on LED matrices
|
||||
2. **TTF fonts** work well for larger sizes
|
||||
3. **Monospace fonts** are easier to align
|
||||
4. **Test on actual hardware** - what looks good on screen may not work on LED matrix
|
||||
|
||||
## Migration from Old System
|
||||
|
||||
### Old Way (Direct Font Loading)
|
||||
```python
|
||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
```
|
||||
|
||||
### New Way (FontManager)
|
||||
```python
|
||||
element_key = f"{self.manager_id}.text"
|
||||
self.font_manager.register_manager_font(
|
||||
manager_id=self.manager_id,
|
||||
element_key=element_key,
|
||||
family="pressstart2p-regular",
|
||||
size_px=8
|
||||
)
|
||||
self.font = self.font_manager.resolve_font(
|
||||
element_key=element_key,
|
||||
family="pressstart2p-regular",
|
||||
size_px=8
|
||||
)
|
||||
```
|
||||
- BDF fonts usually look better than TTF at small sizes on LED panels.
|
||||
- Use `{plugin_id}.{element}` element keys.
|
||||
- Register the fonts you draw with, so the Fonts tab can warn before one is
|
||||
deleted.
|
||||
- Replace direct `ImageFont.truetype("assets/fonts/...", 8)` calls with
|
||||
`resolve_font()`: it caches, resolves paths against the install directory,
|
||||
and handles BDF files.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Font Not Found
|
||||
- Check font file exists in `assets/fonts/`
|
||||
- Verify font family name matches filename (without extension, lowercase)
|
||||
- Check logs for font discovery errors
|
||||
**Font not found**
|
||||
- Check the file exists in `assets/fonts/`.
|
||||
- The family name is the filename without extension, lower-cased.
|
||||
- Check the display service log for font discovery errors.
|
||||
|
||||
### Override Not Working
|
||||
- Verify element key matches exactly what manager registered
|
||||
- Check `config/font_overrides.json` for correct syntax
|
||||
- Restart application to ensure overrides are loaded
|
||||
**Plugin fonts not loading**
|
||||
- Check the manifest's `"fonts"` block.
|
||||
- Check the log for download or registration errors, and that font URLs are
|
||||
reachable.
|
||||
|
||||
### Performance Issues
|
||||
- Check cache hit rate in performance stats
|
||||
- Reduce number of unique font/size combinations
|
||||
- Clear cache if it grows too large: `font_manager.clear_cache()`
|
||||
## API reference
|
||||
|
||||
### Plugin Fonts Not Loading
|
||||
- Verify plugin manifest syntax
|
||||
- Check plugin directory structure
|
||||
- Review logs for download/registration errors
|
||||
- Ensure font URLs are accessible
|
||||
Current methods:
|
||||
|
||||
## API Reference
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `register_manager_font(manager_id, element_key, family, size_px, color=None)` | Record a font choice (feeds the Fonts tab) |
|
||||
| `forget_manager_fonts(manager_id)` | Drop a manager's registrations (core calls it when a plugin unloads) |
|
||||
| `resolve_font(element_key, family, size_px, plugin_id=None)` | Get a font, applying overrides and plugin namespacing |
|
||||
| `get_font(family, size_px)` | Get a font directly |
|
||||
| `get_native_bdf_size(family)` | Native pixel size of a BDF family, or `None` |
|
||||
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||
| `get_font_height(font)` | Line height |
|
||||
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
||||
| `clear_cache()` | Drop cached fonts and metrics |
|
||||
| `font_catalog` (attribute) | Family name → file path |
|
||||
|
||||
### FontManager Methods
|
||||
### Deprecated methods
|
||||
|
||||
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage
|
||||
- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads)
|
||||
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
|
||||
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
|
||||
- `measure_text(text, font)` - Measure text dimensions
|
||||
- `get_font_height(font)` - Get font height
|
||||
- `set_override(element_key, family=None, size_px=None)` - Set manual override
|
||||
- `remove_override(element_key)` - Remove override
|
||||
- `get_overrides()` - Get all overrides
|
||||
- `get_detected_fonts()` - Get all detected font usage
|
||||
- `get_manager_fonts(manager_id=None)` - Get fonts by manager
|
||||
- `get_available_fonts()` - Get font catalog
|
||||
- `get_size_tokens()` - Get size token definitions
|
||||
- `get_performance_stats()` - Get performance metrics
|
||||
- `clear_cache()` - Clear font cache
|
||||
- `register_plugin_fonts(plugin_id, font_manifest)` - Register plugin fonts
|
||||
- `unregister_plugin_fonts(plugin_id)` - Unregister plugin fonts
|
||||
|
||||
## Example: Complete Manager Implementation
|
||||
|
||||
For a working example of the font manager API in use, see
|
||||
`src/font_manager.py` itself.
|
||||
Removed in 3.7.0. Each logs a warning on first call.
|
||||
|
||||
| Method | Use instead |
|
||||
|---|---|
|
||||
| `get_available_fonts()`, `get_font_catalog()` | read `font_catalog` |
|
||||
| `get_size_tokens()` | pass a pixel size |
|
||||
| `get_performance_stats()` | — |
|
||||
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
||||
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
|
||||
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
|
||||
|
||||
+11
-10
@@ -116,8 +116,8 @@ weather and other location-aware plugins.
|
||||
4. Wait for installation to finish — installed plugins appear in the
|
||||
**Installed Plugins** section above and get their own tab in the second
|
||||
nav row
|
||||
5. Toggle the plugin to enabled
|
||||
6. From **Overview**, click **Restart Display Service**
|
||||
5. Toggle the plugin to enabled. The running display loads it within a
|
||||
few seconds; no restart is needed
|
||||
|
||||
You can also install community plugins straight from a GitHub URL using the
|
||||
**Install from GitHub** section further down the same tab — see
|
||||
@@ -128,9 +128,9 @@ You can also install community plugins straight from a GitHub URL using the
|
||||
1. Each installed plugin gets its own tab in the second navigation row
|
||||
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
||||
update intervals, etc.)
|
||||
3. Click **Save**
|
||||
4. Restart the display service from **Overview** so the new settings take
|
||||
effect
|
||||
3. Click **Save**. The display service watches `config.json` and hands the
|
||||
new settings to the running plugin, so no restart is needed. If a plugin
|
||||
still shows old settings, restart the display service from **Overview**
|
||||
|
||||
**Note:** how long each plugin stays on screen is not set in the
|
||||
plugin's own tab — use the **Rotation** tab's **Screen Durations**
|
||||
@@ -197,14 +197,15 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
||||
|
||||
**Check:**
|
||||
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
||||
2. Display service was restarted after enabling
|
||||
3. Plugin's display duration is non-zero
|
||||
4. No errors in the **Logs** tab for that plugin
|
||||
2. Plugin's display duration is non-zero
|
||||
3. No errors in the **Logs** tab for that plugin. A plugin whose
|
||||
`validate_config()` fails is not loaded until its settings are fixed
|
||||
|
||||
**Fix:**
|
||||
1. Enable the plugin from **Plugin Manager**
|
||||
2. Click **Restart Display Service** on **Overview**
|
||||
3. Check the **Logs** tab for plugin-specific errors
|
||||
2. Check the **Logs** tab for plugin-specific errors
|
||||
3. If it still does not appear, click **Restart Display Service** on
|
||||
**Overview**
|
||||
|
||||
### Weather Plugin Shows "No Data"
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ pytest test/test_display_controller.py test/test_plugin_system.py
|
||||
|
||||
```bash
|
||||
# Run a specific test class
|
||||
pytest test/test_display_controller.py::TestDisplayControllerModeRotation
|
||||
pytest test/test_display_controller.py::TestDisplayControllerLivePriority
|
||||
|
||||
# Run a specific test function
|
||||
pytest test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
||||
pytest test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||
```
|
||||
|
||||
### Run Tests by Marker
|
||||
@@ -98,7 +98,7 @@ When you run `pytest`, you'll see:
|
||||
|
||||
```
|
||||
test/test_display_controller.py::TestDisplayControllerInitialization::test_init_success PASSED
|
||||
test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation PASSED
|
||||
test/test_display_controller.py::TestDisplayControllerOnDemand::test_activate_on_demand PASSED
|
||||
...
|
||||
```
|
||||
|
||||
@@ -174,10 +174,10 @@ pytest
|
||||
|
||||
```bash
|
||||
# Run with maximum verbosity and show print statements
|
||||
pytest -vv -s test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
||||
pytest -vv -s test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||
|
||||
# Run with Python debugger (pdb)
|
||||
pytest --pdb test/test_display_controller.py::TestDisplayControllerModeRotation::test_basic_rotation
|
||||
pytest --pdb test/test_display_controller.py::TestDisplayControllerSchedule::test_active_hours
|
||||
```
|
||||
|
||||
### Run Tests in Parallel (Faster)
|
||||
|
||||
@@ -21,6 +21,28 @@ moved to the prefetch thread still needs the GIL, and the render thread waits
|
||||
for it (risk 5 below). The late rate did not improve overall. The 1–2 s
|
||||
freezes appear in both builds and have a separate, not yet identified cause.
|
||||
|
||||
The GIL fix, measured on hdpi (90 px/s, `pwm_bits` 8, preview open, 8-minute
|
||||
runs after a 2-minute warm-up, order A B C C B A, 2026-09-24). Each arm pools
|
||||
two runs, about 81,000 frames:
|
||||
|
||||
| arm | late | by 1 | 2 | 3–5 | 6+ | 2+ late per 10k frames | freezes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| A: step 1 as is | 0.90% | 575 | 64 | 91 | 9 | 20.1 | 0 |
|
||||
| B: `switch_interval_ms` 1 | 0.78% | 510 | 105 | 23 | 2 | 15.8 | 0 |
|
||||
| C: `prefetch_gate` | **0.60%** | 471 | 11 | 7 | 2 | **2.5** | 0 |
|
||||
|
||||
The gate removes the frames the render thread spent waiting for the GIL, and
|
||||
it costs the prefetch nothing that shows: it parked the thread for 3–6 s per
|
||||
run, and the next group was ready at every strip extension in every arm.
|
||||
`prefetch_gate` is therefore on by default; `switch_interval_ms` stays an
|
||||
off-by-default experiment. What is left is almost all one refresh late, which
|
||||
is the per-frame budget (a 6.75 ms p50 blit in a refresh the panel holds at
|
||||
83–85 Hz while rendering), not contention.
|
||||
|
||||
The runs restart the service, so the hourly sports refresh never fell inside
|
||||
one. That refresh is its own case: about twenty ESPN chunk-fetch threads at
|
||||
once, which the gate does not cover (it gates only the prefetch thread).
|
||||
|
||||
## The problem
|
||||
|
||||
Vegas mode builds its ticker from every plugin's content. Most of that work
|
||||
@@ -284,9 +306,20 @@ updates are disabled while sync is active.
|
||||
removes the pauses, but the work still needs the GIL. Pillow drawing holds
|
||||
it, and a waiting thread only gets it back after the switch interval
|
||||
(default 5 ms). Expect some single-refresh late frames while a prefetch
|
||||
runs. Measure with the soak. Lowering `sys.setswitchinterval` during Vegas
|
||||
(e.g. 1 ms) is a one-line experiment, and a render process separate from
|
||||
plugin work is the structural answer (the "native presenter" step).
|
||||
runs. Measure with the soak. A render process separate from plugin work
|
||||
is the structural answer (the "native presenter" step). Two experiments
|
||||
get most of the way first (results under Status, above):
|
||||
- `vegas_scroll.switch_interval_ms` lowers the switch interval for a Vegas
|
||||
run (1 ms is the obvious try), so the render thread waits at most that
|
||||
long behind bytecode. It does nothing for a C call that keeps the GIL.
|
||||
- `vegas_scroll.prefetch_gate` (`src/common/render_gate.py`) lets the
|
||||
prefetch thread run Python only while the render thread is blocked in
|
||||
`SwapOnVSync`, up to just before the refresh the swap returns on, and
|
||||
parks it the rest of the time. That covers C calls too, since the gate is
|
||||
checked before each one starts. It never parks the thread while it holds
|
||||
a lock the render thread takes, and never for more than 50 ms. It needs
|
||||
the rebuilt binding, which releases the GIL during the swap. On by
|
||||
default.
|
||||
|
||||
## What this does not fix
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Permissions
|
||||
|
||||
Who owns what on an installed system, which privileged commands the web
|
||||
interface may run, and how to repair ownership when it goes wrong. The
|
||||
installer, [`first_time_install.sh`](../first_time_install.sh), sets all of
|
||||
this up; this page describes the result.
|
||||
|
||||
## Users and groups
|
||||
|
||||
| Account | Used by | Why |
|
||||
|---|---|---|
|
||||
| `root` | `ledmatrix.service` (the display) | The LED matrix library needs direct GPIO access |
|
||||
| The installing user (e.g. `ledpi`) | `ledmatrix-web.service`, `ledmatrix-update-verify.service` | A web server should not run as root |
|
||||
| `ledmatrix` group | shared files | Members: the installing user, `root`, and `daemon` if it exists. Created by [`setup_cache.sh`](../scripts/install/setup_cache.sh) and the installer |
|
||||
|
||||
The installer also adds the web user to `systemd-journal` and `adm` so the
|
||||
**Logs** tab can read the journal. Group changes apply after the user logs
|
||||
in again (services pick them up on restart).
|
||||
|
||||
## Files and directories
|
||||
|
||||
| Path | Owner | Mode | Notes |
|
||||
|---|---|---|---|
|
||||
| Project directory | web user | dirs `755`, files `644`, `*.sh` `755` | Set in the installer's "Normalize project file permissions" step |
|
||||
| `config/` | web user | `2775` | |
|
||||
| `config/config.json` | web user | `644` | Written by the web interface |
|
||||
| `config/config_secrets.json` | web user : `ledmatrix` | `640` | Owned by the web user because the web interface writes it; root reads it regardless of mode |
|
||||
| `plugin-repos/`, `plugins/` | web user | dirs `2775`, files `664` | The web interface installs and removes plugins |
|
||||
| `assets/` | web user | dirs `755`, files `644` | Root writes downloaded logos regardless |
|
||||
| `/var/cache/ledmatrix/` | `root:ledmatrix` | `2775` (setgid) | Shared cache: see below |
|
||||
| Cache files | creator : `ledmatrix` | `660` | |
|
||||
| `scripts/fix_perms/safe_plugin_rm.sh`, `safe_pip_install.sh` | `root:root` | `755` | Run as root through sudo, so the web user must not be able to edit them |
|
||||
| `/etc/sudoers.d/ledmatrix_web`, `ledmatrix_wifi` | `root` | `440` | |
|
||||
|
||||
What keeps it that way at runtime:
|
||||
|
||||
- **Config files.** Saves go through
|
||||
[`src/config_manager_atomic.py`](../src/config_manager_atomic.py), which
|
||||
applies `get_config_file_mode()` (`640` for secrets, `644` otherwise) and,
|
||||
when running as root, moves the file's group to the project directory's
|
||||
group (`ensure_shared_group_ownership()` in
|
||||
[`src/common/permission_utils.py`](../src/common/permission_utils.py)).
|
||||
- **Cache files.** [`src/cache/disk_cache.py`](../src/cache/disk_cache.py)
|
||||
sets every file it writes to `0660` and gives it the cache directory's
|
||||
group, without relying on the setgid bit. So a file root writes stays
|
||||
readable by the web user.
|
||||
- **Plugin directories.** [`run.py`](../run.py) sets
|
||||
`sys.dont_write_bytecode`, because root-owned `__pycache__` directories
|
||||
inside a plugin stop the web user updating or removing it.
|
||||
|
||||
`ledmatrix-web.service` deliberately has no `CacheDirectory=`: systemd would
|
||||
re-own `/var/cache/ledmatrix` to the web user and its primary group, and the
|
||||
web interface could no longer read what the display writes (see the comment
|
||||
in [`systemd/ledmatrix-web.service`](../systemd/ledmatrix-web.service)).
|
||||
|
||||
If `/var/cache/ledmatrix` is not usable, `CacheManager` falls back to
|
||||
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache` or a temp directory
|
||||
([`src/cache_manager.py`](../src/cache_manager.py)). The two services then
|
||||
may not share a cache, and the web UI shows stale or empty display status,
|
||||
on-demand state and plugin health. Fix the directory rather than living
|
||||
with the fallback.
|
||||
|
||||
## sudo rules
|
||||
|
||||
### `/etc/sudoers.d/ledmatrix_web`
|
||||
|
||||
Generated by `web_sudoers_rules()` in
|
||||
[`scripts/install/lib_sudoers.sh`](../scripts/install/lib_sudoers.sh), the
|
||||
only place these rules are defined. Installed by the installer and by
|
||||
[`configure_web_sudo.sh`](../scripts/install/configure_web_sudo.sh), both of
|
||||
which check them with `visudo -c` first. The web user may run, without a
|
||||
password:
|
||||
|
||||
- `reboot`, `poweroff`
|
||||
- `systemctl start|stop|restart|enable|disable|status ledmatrix.service`,
|
||||
`systemctl is-active ledmatrix[.service]`
|
||||
- `systemctl start|stop|restart ledmatrix-web.service`
|
||||
- `bash <project>/scripts/fix_perms/safe_plugin_rm.sh *` — removes a
|
||||
directory only if it resolves to a child of `plugin-repos/` or `plugins/`
|
||||
- `bash <project>/scripts/fix_perms/safe_pip_install.sh *` — installs a
|
||||
`requirements.txt` only if it is the project's own or one under
|
||||
`plugin-repos/` or `plugins/`, so the root display service can import the
|
||||
packages
|
||||
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
|
||||
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
|
||||
escape from that pager would be a root shell
|
||||
|
||||
### `/etc/sudoers.d/ledmatrix_wifi`
|
||||
|
||||
Written by
|
||||
[`scripts/install/configure_wifi_permissions.sh`](../scripts/install/configure_wifi_permissions.sh)
|
||||
(run as the web user; the installer calls it). It refuses to grant a binary
|
||||
that is not root-owned or is group/world-writable. The rules cover:
|
||||
|
||||
- `nmcli device wifi connect|disconnect *`, `nmcli device connect|disconnect *`,
|
||||
`nmcli radio wifi on|off`
|
||||
- `systemctl start|stop|restart hostapd`, `... dnsmasq`,
|
||||
`systemctl restart NetworkManager`
|
||||
- `sysctl -w net.ipv4.ip_forward=0|1`
|
||||
- `nft add|delete table ip ledmatrix`
|
||||
- `rfkill unblock wifi`
|
||||
- `mkdir -p /etc/NetworkManager/dnsmasq-shared.d`
|
||||
- `cp` of `/tmp/hostapd.conf` and `/tmp/dnsmasq.conf` to their fixed
|
||||
destinations, and `rm -f /etc/dnsmasq.d/ledmatrix-captive.conf`
|
||||
- `cp /tmp/ledmatrix-nm-dnsmasq.conf` to
|
||||
`/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf`, and
|
||||
`rm -f` of that file
|
||||
|
||||
**`iptables` is deliberately not granted.** The captive portal's rules are
|
||||
built from the interface name and port, so a rule covering them would need a
|
||||
trailing wildcard, and `iptables --modprobe=<path>` runs `<path>` as root: a
|
||||
wildcard grant is a root shell for the web user. Doing it safely needs a
|
||||
wrapper script that builds the rules itself, like `safe_plugin_rm.sh`. On a
|
||||
stock Raspberry Pi OS image the default user's blanket `NOPASSWD` rule
|
||||
(`/etc/sudoers.d/010_pi-nopasswd`) hides this gap.
|
||||
|
||||
### polkit
|
||||
|
||||
The same script installs `/etc/polkit-1/rules.d/10-ledmatrix-wifi.rules`,
|
||||
which lets the web user perform any `org.freedesktop.NetworkManager.*`
|
||||
action without authentication.
|
||||
|
||||
## Repair scripts
|
||||
|
||||
In [`scripts/fix_perms/`](../scripts/fix_perms/). Run them from the project
|
||||
directory.
|
||||
|
||||
| Script | Run as | What it does | Notes |
|
||||
|---|---|---|---|
|
||||
| `fix_plugin_permissions.sh` | `sudo` | `plugins/` and `plugin-repos/` to `root:<user>`, dirs `2775`, files `664`; makes a `700` home directory `755` so root can traverse it | Safe. Group-writable, so the web user keeps write access |
|
||||
| `fix_assets_permissions.sh` | `sudo` | `assets/` to `<user>:<group>`, mode `777` recursively | Works, but looser than the installer's `755`/`644` |
|
||||
| `fix_cache_permissions.sh` | `sudo` | Runs [`setup_cache.sh`](../scripts/install/setup_cache.sh) for `/var/cache/ledmatrix` (`root:ledmatrix`, `2775`, files `660`), then makes `~/.ledmatrix_cache` (the fallback cache) `<user>:<group>` mode `777` | Safe. The `~/.ledmatrix_cache` mode is still `777` |
|
||||
| `fix_web_permissions.sh` | the web user, **without** `sudo` | Resets project file ownership for the web user (it calls `sudo` itself), then makes `safe_plugin_rm.sh` and `safe_pip_install.sh` `root:root` `755` again and restores `config_secrets.json` to its owner, group `ledmatrix`, mode `640` | Refuses to run as root. It does not write sudoers rules |
|
||||
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
|
||||
|
||||
To reinstall the sudoers rules, run
|
||||
`./scripts/install/configure_web_sudo.sh` (web rules) or
|
||||
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
|
||||
the web user, not with `sudo`.
|
||||
|
||||
After any of these, restart both services:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix.service ledmatrix-web.service
|
||||
```
|
||||
|
||||
## Checking
|
||||
|
||||
```bash
|
||||
ls -ld /var/cache/ledmatrix # drwxrwsr-x root ledmatrix
|
||||
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
|
||||
id # web user should list ledmatrix
|
||||
sudo -l # lists the NOPASSWD rules
|
||||
```
|
||||
@@ -9,6 +9,7 @@ Complete API reference for plugin developers. This document describes all method
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Manifest Required Fields](#manifest-required-fields)
|
||||
- [BasePlugin](#baseplugin)
|
||||
- [Display Manager](#display-manager)
|
||||
- [Cache Manager](#cache-manager)
|
||||
@@ -17,6 +18,50 @@ Complete API reference for plugin developers. This document describes all method
|
||||
|
||||
---
|
||||
|
||||
## Manifest Required Fields
|
||||
|
||||
Three parts of core check `manifest.json`, each for a different set of
|
||||
fields:
|
||||
|
||||
| Check | Fields | What happens when one is missing |
|
||||
|---|---|---|
|
||||
| JSON schema, [`schema/manifest_schema.json`](../schema/manifest_schema.json) | `id`, `name`, `version`, `author`, `entry_point`, `class_name`, `compatible_versions` | Install from URL logs a warning (`PluginStoreManager._validate_manifest_schema()`); nothing is refused |
|
||||
| Plugin Store install, [`src/plugin_system/store_manager.py`](../src/plugin_system/store_manager.py) | `id`, `name`, `class_name`, `display_modes` | Install is refused. A registry install first tries to detect a missing `class_name` from the entry-point file |
|
||||
| Plugin loader, [`src/plugin_system/plugin_loader.py`](../src/plugin_system/plugin_loader.py) | `class_name` | The plugin fails to load |
|
||||
|
||||
Defaults and other uses:
|
||||
|
||||
- `entry_point` defaults to `manager.py`; the store writes the default back
|
||||
into the manifest on install.
|
||||
- `compatible_versions` (a list of semver ranges such as `">=2.0.0"`) is how
|
||||
the store decides whether a plugin can run on this core. An install is
|
||||
refused only when the field excludes the running version
|
||||
(`compatibility.check()` in
|
||||
[`src/plugin_system/compatibility.py`](../src/plugin_system/compatibility.py)).
|
||||
- `version` is compared with the registry's `latest_version` to decide
|
||||
whether an update is available.
|
||||
- If `display_modes` is empty at load time, the display controller uses the
|
||||
plugin id as the only mode.
|
||||
|
||||
**Set all eight:** `id`, `name`, `version`, `author`, `entry_point`,
|
||||
`class_name`, `display_modes`, `compatible_versions`. That satisfies every
|
||||
check. The schema lists the optional fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"name": "My Plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "YourName",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "MyPlugin",
|
||||
"display_modes": ["my-plugin"],
|
||||
"compatible_versions": [">=2.0.0"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BasePlugin
|
||||
|
||||
All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality.
|
||||
@@ -432,82 +477,17 @@ self.display_manager.update_display()
|
||||
|
||||
This is the canonical way to render arbitrary images.
|
||||
|
||||
### Weather Icons
|
||||
### Weather Icons (deprecated)
|
||||
|
||||
#### `draw_weather_icon(condition: str, x: int, y: int, size: int = 16) -> None`
|
||||
> Deprecated, removed in 3.7.0 — draw your own icons (the weather plugin
|
||||
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Draw a weather icon based on the condition string.
|
||||
|
||||
**Parameters**:
|
||||
- `condition` (str): Weather condition (e.g., "clear", "cloudy", "rain", "snow", "storm")
|
||||
- `x` (int): X position
|
||||
- `y` (int): Y position
|
||||
- `size` (int): Icon size in pixels (default: 16)
|
||||
|
||||
**Supported Conditions**:
|
||||
- `"clear"`, `"sunny"` → Sun icon
|
||||
- `"clouds"`, `"cloudy"`, `"partly cloudy"` → Cloud icon
|
||||
- `"rain"`, `"drizzle"`, `"shower"` → Rain icon
|
||||
- `"snow"`, `"sleet"`, `"hail"` → Snow icon
|
||||
- `"thunderstorm"`, `"storm"` → Storm icon
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
self.display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
||||
```
|
||||
|
||||
#### `draw_sun(x: int, y: int, size: int = 16) -> None`
|
||||
|
||||
Draw a sun icon with rays.
|
||||
|
||||
**Parameters**:
|
||||
- `x` (int): X position
|
||||
- `y` (int): Y position
|
||||
- `size` (int): Icon size (default: 16)
|
||||
|
||||
#### `draw_cloud(x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)) -> None`
|
||||
|
||||
Draw a cloud icon.
|
||||
|
||||
**Parameters**:
|
||||
- `x` (int): X position
|
||||
- `y` (int): Y position
|
||||
- `size` (int): Icon size (default: 16)
|
||||
- `color` (tuple): RGB color (default: light gray)
|
||||
|
||||
#### `draw_rain(x: int, y: int, size: int = 16) -> None`
|
||||
|
||||
Draw rain icon with cloud and droplets.
|
||||
|
||||
#### `draw_snow(x: int, y: int, size: int = 16) -> None`
|
||||
|
||||
Draw snow icon with cloud and snowflakes.
|
||||
|
||||
#### `draw_text_with_icons(text: str, icons: List[tuple] = None, x: int = None, y: int = None, color: tuple = (255, 255, 255)) -> None`
|
||||
|
||||
Draw text with weather icons at specified positions.
|
||||
|
||||
**Parameters**:
|
||||
- `text` (str): Text to display
|
||||
- `icons` (List[tuple], optional): List of (icon_type, x, y) tuples
|
||||
- `x` (int, optional): X position for text
|
||||
- `y` (int, optional): Y position for text
|
||||
- `color` (tuple): Text color
|
||||
|
||||
**Note**: Automatically calls `update_display()` after drawing.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
icons = [
|
||||
("sun", 5, 5),
|
||||
("cloud", 100, 5)
|
||||
]
|
||||
self.display_manager.draw_text_with_icons(
|
||||
"Weather: Sunny, Cloudy",
|
||||
icons=icons,
|
||||
x=10, y=20
|
||||
)
|
||||
```
|
||||
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
|
||||
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
|
||||
- `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
|
||||
`draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
|
||||
- `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
|
||||
— text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
|
||||
|
||||
### Scrolling State Management
|
||||
|
||||
@@ -601,6 +581,8 @@ Process any deferred updates if not currently scrolling. Called automatically by
|
||||
|
||||
#### `get_scrolling_stats() -> dict`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get current scrolling statistics for debugging.
|
||||
|
||||
**Returns**: Dictionary with scrolling state information
|
||||
@@ -742,6 +724,8 @@ data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
|
||||
|
||||
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
||||
|
||||
> Deprecated, removed in 3.7.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get background service cached data with sport-specific intervals.
|
||||
|
||||
**Parameters**:
|
||||
@@ -779,6 +763,8 @@ max_age = strategy['max_age'] # Get configured max age
|
||||
|
||||
#### `get_sport_live_interval(sport_key: str) -> int`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
|
||||
**Parameters**:
|
||||
@@ -803,6 +789,8 @@ Extract data type from cache key to determine appropriate cache strategy.
|
||||
|
||||
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Extract sport key from cache key for sport-specific strategies.
|
||||
|
||||
**Parameters**:
|
||||
@@ -847,10 +835,12 @@ for file_info in files:
|
||||
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
||||
```
|
||||
|
||||
### Metrics Methods
|
||||
### Metrics Methods (deprecated)
|
||||
|
||||
#### `get_cache_metrics() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get cache performance metrics.
|
||||
|
||||
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
|
||||
@@ -863,6 +853,8 @@ self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
|
||||
|
||||
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get memory cache statistics.
|
||||
|
||||
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
||||
@@ -907,6 +899,8 @@ for plugin_id, plugin in all_plugins.items():
|
||||
|
||||
#### `get_enabled_plugins() -> List[str]`
|
||||
|
||||
> Deprecated, removed in 3.7.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get list of enabled plugin IDs.
|
||||
|
||||
**Returns**: List of plugin identifier strings
|
||||
@@ -985,9 +979,8 @@ def update(self):
|
||||
|
||||
**Example - Checking if another plugin is enabled**:
|
||||
```python
|
||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
||||
if "weather" in enabled_plugins:
|
||||
# Weather plugin is enabled
|
||||
weather = self.plugin_manager.plugins.get("weather")
|
||||
if weather is not None and weather.enabled:
|
||||
pass
|
||||
```
|
||||
|
||||
|
||||
@@ -155,9 +155,9 @@ deep-merged back into the plugin's config at load time
|
||||
### Custom input widgets
|
||||
|
||||
Set `"x-widget": "<name>"` on a property. Core widgets are in
|
||||
`web_interface/static/v3/js/widgets/` (see its README); a plugin can ship its
|
||||
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`.
|
||||
See [widget-guide.md](widget-guide.md).
|
||||
`web_interface/static/v3/js/widgets/`; a plugin can ship its own widget
|
||||
script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`. See the
|
||||
[widget guide](../web_interface/static/v3/js/widgets/README.md).
|
||||
|
||||
### Custom actions
|
||||
|
||||
|
||||
@@ -157,5 +157,6 @@ For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TR
|
||||
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`)
|
||||
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
|
||||
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
|
||||
- Sudo rules: `scripts/install/configure_web_sudo.sh`
|
||||
- Sudo rules: `scripts/install/lib_sudoers.sh` (written by `first_time_install.sh`
|
||||
and `scripts/install/configure_web_sudo.sh`)
|
||||
- Manual installer: `scripts/install_plugin_dependencies.sh`
|
||||
|
||||
@@ -520,19 +520,22 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
|
||||
`display_manager.image` (a PIL Image) and call `update_display()`;
|
||||
there is no `draw_image()` helper method.
|
||||
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
||||
(deprecated, removed in 3.7.0 — draw your own icons)
|
||||
- `get_text_width()`, `get_font_height()` - Text utilities
|
||||
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
||||
|
||||
**Cache Manager** (`self.cache_manager`):
|
||||
- `get()`, `set()`, `delete()` - Basic caching
|
||||
- `get_cached_data_with_strategy()` - Advanced caching with strategies
|
||||
- `get_background_cached_data()` - Background service caching
|
||||
- `get_background_cached_data()` - deprecated, removed in 3.7.0 — use `get()`
|
||||
|
||||
**Plugin Manager** (`self.plugin_manager`):
|
||||
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
||||
- `get_plugin_info()` - Get plugin information
|
||||
|
||||
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete documentation.
|
||||
See [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) for complete
|
||||
documentation, and its [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis)
|
||||
table for everything removed in 3.7.0.
|
||||
|
||||
## 3rd Party Plugin Development
|
||||
|
||||
@@ -577,12 +580,14 @@ Your plugin must:
|
||||
pass
|
||||
```
|
||||
|
||||
2. **Include manifest.json** with required fields:
|
||||
2. **Include manifest.json** with the required fields listed in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields):
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"name": "My Plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "YourName",
|
||||
"class_name": "MyPlugin",
|
||||
"entry_point": "manager.py",
|
||||
"display_modes": ["my_plugin"],
|
||||
@@ -658,7 +663,7 @@ For your plugin to work well in the plugin store:
|
||||
with the registry's `latest_version`; releases and tags are not read
|
||||
- **README.md**: Clear installation and configuration instructions
|
||||
- **config_schema.json**: Recommended for web UI configuration
|
||||
- **manifest.json**: Required with all required fields
|
||||
- **manifest.json**: Required, with the [required fields](PLUGIN_API_REFERENCE.md#manifest-required-fields)
|
||||
- **requirements.txt**: If your plugin has Python dependencies
|
||||
|
||||
### Distribution Options
|
||||
|
||||
@@ -45,7 +45,8 @@ LEDMatrix/
|
||||
|
||||
### 1. Minimal Plugin Structure
|
||||
|
||||
**manifest.json**:
|
||||
**manifest.json** (the required fields are explained in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields)):
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
@@ -54,6 +55,8 @@ LEDMatrix/
|
||||
"author": "YourName",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "MyPlugin",
|
||||
"display_modes": ["my-plugin"],
|
||||
"compatible_versions": [">=2.0.0"],
|
||||
"category": "custom"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -67,9 +67,9 @@ Don't edit `latest_version` or `last_updated` by hand for monorepo plugins:
|
||||
|
||||
## Adding or changing an official plugin
|
||||
|
||||
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo. The store refuses
|
||||
a manifest without `id`, `name`, `class_name` and `display_modes`; also
|
||||
set `version`.
|
||||
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo, with the
|
||||
manifest fields listed in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#manifest-required-fields).
|
||||
2. Bump `version` in the plugin's `manifest.json` for every change, or users
|
||||
won't be offered the update.
|
||||
3. Run `python update_registry.py` in ledmatrix-plugins and commit the
|
||||
|
||||
+7
-3
@@ -11,7 +11,7 @@ the one-shot installer. The pages here go deeper.
|
||||
2. [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) — using the web UI
|
||||
3. [PLUGIN_STORE_GUIDE.md](PLUGIN_STORE_GUIDE.md) — installing and managing plugins
|
||||
4. [WIFI_NETWORK_SETUP.md](WIFI_NETWORK_SETUP.md) — WiFi and AP-mode setup
|
||||
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
||||
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes ([PERMISSIONS.md](PERMISSIONS.md) for "Permission denied")
|
||||
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
|
||||
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
||||
@@ -37,7 +37,7 @@ Going deeper:
|
||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
|
||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
|
||||
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
|
||||
- [widget-guide.md](widget-guide.md) — widget development
|
||||
- [Widget guide](../web_interface/static/v3/js/widgets/README.md) — built-in `x-widget`s and custom widgets
|
||||
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
|
||||
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
|
||||
|
||||
@@ -56,21 +56,25 @@ Going deeper:
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||
cache management, background services, permissions
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||
- [PERMISSIONS.md](PERMISSIONS.md) — file ownership, sudo rules, repair scripts
|
||||
- [MQTT bridge](../integrations/mqtt_bridge/README.md) — control the display from Home Assistant over MQTT
|
||||
|
||||
## Reference
|
||||
|
||||
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
|
||||
- [src/common/README.md](../src/common/README.md) — shared helper modules plugins can import
|
||||
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
|
||||
|
||||
## Contributing to LEDMatrix itself
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — processes, display loop, plugin system, web UI; where to start reading
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
|
||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
||||
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
||||
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
|
||||
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how the sports scoreboard base classes are organized
|
||||
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how shared sports scoreboard code moves into `src/common`
|
||||
|
||||
## Audits
|
||||
|
||||
|
||||
@@ -2111,13 +2111,18 @@ Errors use one of two shapes. Most endpoints answer:
|
||||
}
|
||||
```
|
||||
|
||||
Endpoints built on the structured error helper add a code and category:
|
||||
An exception no route anticipated gets this shape too, with a 500, the
|
||||
message `An error occurred; see logs for details`, and `details` naming the
|
||||
exception type and text (credentials redacted). The api_v3 blueprint's
|
||||
error handler produces it, so it is the same for every `/api/v3` route.
|
||||
|
||||
Endpoints built on the structured error helper add a code, and usually
|
||||
suggested fixes (the web UI's error dialog lists them):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error_code": "CONFIG_SAVE_FAILED",
|
||||
"error_category": "configuration",
|
||||
"message": "Error description",
|
||||
"details": "optional",
|
||||
"context": { },
|
||||
|
||||
@@ -234,6 +234,9 @@ advances by elapsed time at `scroll_speed / scroll_delay` px/s.
|
||||
|
||||
## Diagnosing a juddery scroller
|
||||
|
||||
To check a whole rig rather than one scroller, soak it -- see *Soaking a rig*
|
||||
below.
|
||||
|
||||
**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
|
||||
@@ -306,6 +309,269 @@ 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.
|
||||
|
||||
## Soaking a rig
|
||||
|
||||
The per-scroller lines above tell you *which* scroller misbehaves. The soak
|
||||
answers the question a release has to answer for each rig: **over a long run,
|
||||
how often did a moving frame reach the panel late?**
|
||||
|
||||
Every frame reaches the panel through `DisplayManager.update_display`, so it is
|
||||
timed there once, whoever drew it -- Vegas, a ticker plugin, anything. The
|
||||
render thread only appends a tuple; a worker thread aggregates and rewrites
|
||||
`/dev/shm/ledmatrix_frame_stats.json` every 10 seconds (RAM, so no SD-card
|
||||
wear). `src/common/frame_timing.py` has the details.
|
||||
|
||||
```bash
|
||||
python3 scripts/frame_soak.py # 10 minutes, as the display is now
|
||||
python3 scripts/frame_soak.py --preview # with the web preview open
|
||||
python3 scripts/frame_soak.py --show # totals since the service started
|
||||
python3 scripts/frame_soak.py --json a.json # keep the report to compare later
|
||||
```
|
||||
|
||||
It runs as any user next to the display service and stops nothing. It needs
|
||||
something to *scroll* during the run: a live game holding a static scoreboard
|
||||
on screen gives no verdict. `--preview` keeps the web preview's viewer marker
|
||||
fresh, which puts the preview's PNG encoding at full rate -- run it as the web
|
||||
service's user.
|
||||
|
||||
| line | what it tells you |
|
||||
|---|---|
|
||||
| **Late frames** | Frames presented one or more refreshes after they were due: the panel showed the previous frame again, a visible hitch. **The pass/fail number**, 0.1% by default (`--max-late-pct`). Only intervals between two scrolling frames count, and a frame held for `frame_hold` refreshes is due `frame_hold` refreshes after the last. |
|
||||
| **Freezes** | Gaps of 250 ms or more inside a scroll: recomposes, plugin handovers, blocking calls on the render thread. Reported but not failed on, because some are handovers between plugins rather than faults. A gap still counts when the display's scroll state went missing for one frame across it, as long as scrolling resumes within 1 s: both of that frame's intervals count. Two static frames in a row end the scroll. (The state expires after 2 s without scroll activity, and plugins can clear it from their own `display()`.) The late and early rates are over frames judged against a known refresh period, which the recorder adopts once two windows in a row agree on it. |
|
||||
| **blit** | Copying the frame into the matrix canvas (`SetImage`). It grows with width × height × `pwm_bits`: ~5.5 ms at 512×64 with 8 bits on a Pi 4. It is the biggest fixed cost, and it sets the refresh rates a rig can hold one pixel per refresh at. |
|
||||
| **wait** | Time blocked in `SwapOnVSync`, i.e. the slack left in each refresh. A p50 near zero means the rig has no headroom and anything extra lands a frame late. |
|
||||
| **work** | Everything else between two frames: drawing, scrolling, and waiting for the GIL. A wide gap between its p50 and p99 is another thread getting in the way. |
|
||||
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
|
||||
|
||||
The refresh rate is estimated from the frames themselves (swaps that block on
|
||||
vsync can only land on refresh boundaries). Cross-check it with
|
||||
`scroll_speeds.py --measure` if it looks wrong. It can read high on a rig where
|
||||
nothing ever presented at the full refresh rate.
|
||||
|
||||
A soak is only meaningful against a fixed workload. Compare runs with the same
|
||||
content and `--preview` setting, and alternate which build goes first when you
|
||||
A/B two of them. A live-API workload drifts over time.
|
||||
|
||||
The soak says how often; the service's log says why. A scroll that presents no
|
||||
frame for 250 ms logs `Render stall:` with the stack of the render thread and
|
||||
the top of every other thread's, and whether the whole interpreter was blocked
|
||||
(C code holding the GIL) rather than one thread. To see what is behind the
|
||||
shorter hitches, run the service with `LEDMATRIX_STALL_WATCHDOG_MS=30`, which
|
||||
dumps at three refreshes late instead: its extra polling costs a little GIL
|
||||
time of its own, so do that on a diagnostic run, not a soak you are grading.
|
||||
`LEDMATRIX_STALL_WATCHDOG=0` turns it off.
|
||||
|
||||
### Results: hdpi, 2026-09-24
|
||||
|
||||
Pi 4, 4×128×64 on one chain (512×64), `gpio_slowdown` 3, cap 120 Hz, the
|
||||
GIL-releasing binding. Vegas mode with live content, 8-minute soaks with
|
||||
`--preview`, run in the order shown so each build went both first and last.
|
||||
|
||||
| run | build | pacing | pwm_bits | refresh | late | 1 | 2 | 3–5 | 6+ | freezes |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.33% | 2,542 | 74 | 19 | 4 | 0 |
|
||||
| 2 | #628 | 1 px / refresh | 8 | 100.2 Hz | 0.66% | 238 | 32 | 30 | 5 | 2 |
|
||||
| 3 | #628 | 1 px / refresh | 8 | 100.3 Hz | 0.70% | 252 | 38 | 26 | 6 | 2 |
|
||||
| 4 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.46% | 2,659 | 90 | 10 | 4 | 0 |
|
||||
| 5 | #628 | 1 px / 2 refreshes (53 px/s) | **7** | 107.2 Hz | 0.32% | 68 | 7 | 4 | 2 | 1 |
|
||||
|
||||
- Blending cost the panel refresh rate as well as frames: 94.5 Hz against
|
||||
~100 Hz for the same hardware under whole-pixel pacing.
|
||||
- The freezes and the 3+ rows in the #628 runs line up with canvas-bound
|
||||
plugins fetched on the render thread (`drain_deferred`): `news` took ~320 ms
|
||||
and `hockey-scoreboard` ~660 ms there. Moving those
|
||||
fetches off the render thread is proposed separately (offscreen rendering).
|
||||
- Run 5 changed two things at once: the speed, and `pwm_bits` (changed on the
|
||||
rig between runs). Its lower late rate cannot be credited to either alone.
|
||||
- These soaks were taken before the recorder counted 1–2 s stalls as freezes,
|
||||
so a stall of that length would be missing from these rows.
|
||||
|
||||
### Without the service: `render_bench.py`
|
||||
|
||||
The soak measures the service as it really runs: live content, plugin
|
||||
updates, the web preview. `scripts/render_bench.py` answers the narrower
|
||||
question underneath: *with nothing else in the way, can this hardware present
|
||||
every frame on time?* It scrolls a synthetic strip through the production path
|
||||
-- a real `DisplayManager`, a real `ScrollHelper`, the same `scroll_config`
|
||||
resolver every ticker uses -- on content that is identical every run, which
|
||||
makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT against
|
||||
another) and for A/B testing a change to the render path.
|
||||
|
||||
```bash
|
||||
sudo systemctl stop ledmatrix # the service owns the GPIO
|
||||
|
||||
sudo python3 scripts/render_bench.py # 60s at one pixel per refresh
|
||||
sudo python3 scripts/render_bench.py --seconds 600 # the shipping gate
|
||||
sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) speed
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
```
|
||||
|
||||
It never starts or stops the service itself, so a crash in it cannot leave
|
||||
the panel dark. It grades with the same recorder as the soak and prints the
|
||||
same report, with the same exit status, except that **2** also means the run
|
||||
could not be set up at all (no root, no panel, a fallback display), so a rig
|
||||
that was never measured cannot pass by accident.
|
||||
|
||||
Two differences from the soak matter:
|
||||
|
||||
- **It measures the panel first.** Before scrolling it times bare swaps for a
|
||||
few seconds to get the idle refresh rate, and seeds the recorder with it.
|
||||
That is what catches a loop that never locked to the panel at all. The first
|
||||
version of the bench announced its scrolling state once instead of every
|
||||
frame; the state expired, the dirty-tracking skip fired mid-scroll, and the
|
||||
loop free-ran at 827 fps. Graded against its own frames that looks perfectly
|
||||
steady; graded against the panel's measured rate every frame is early, and
|
||||
the run fails as NOT LOCKED. (The soak has no idle measurement, so it checks
|
||||
the rate against `limit_refresh_rate_hz` instead: a "refresh" faster than
|
||||
the cap cannot have been waiting for the panel.)
|
||||
- **The stall watchdog prints to the terminal.** A frame held up for more than
|
||||
250 ms prints the stack of what held it up, in the middle of the run.
|
||||
|
||||
Measured with the first version of the bench on hdpi (Pi 4, 512x64,
|
||||
`pwm_bits` 8), two-minute runs at one pixel per refresh: 8 of 11,449 frames
|
||||
late (0.070%), and with `--busy 2` 3 of 11,445 (0.026%). The render path and
|
||||
the hardware pass on their own. Compare the soak results above, from the same
|
||||
rig with the service running, for how much of the late rate comes from
|
||||
everything else.
|
||||
|
||||
### The panel is slower while you are rendering into it
|
||||
|
||||
The bench prints two refresh rates, and they differ:
|
||||
|
||||
| | Pi 4, 512x64, `pwm_bits` 8 |
|
||||
|---|---|
|
||||
| idle, timing bare swaps | 100.4 Hz |
|
||||
| while scrolling | 96.3 Hz |
|
||||
|
||||
Both are real. Driving an LED matrix is bit-banging on the same machine, so
|
||||
`SetImage` over a 512x64 chain contends with the refresh itself and slows it.
|
||||
The recorder therefore reads the rendering rate back from the frames: swaps
|
||||
that block on vsync can only return on a refresh boundary, so the low end of
|
||||
`interval / frame_hold` is the period. The idle figure is still printed,
|
||||
because the gap between the two is itself a measure of how expensive a frame
|
||||
is: **a rise in that gap is a render-cost regression even when nothing is
|
||||
late.**
|
||||
|
||||
The practical consequence for config: set `limit_refresh_rate_hz` near the rate
|
||||
the panel holds *while rendering*, not the idle rate and certainly not a cap it
|
||||
can never reach. A cap well above the real rate makes `scroll_config` solve
|
||||
speeds against a refresh that does not exist, which is where "3px every 4
|
||||
refreshes" comes from.
|
||||
|
||||
### Bench-only counters
|
||||
|
||||
| line | meaning |
|
||||
|---|---|
|
||||
| `duplicate` | frames that advanced no pixels. A crisp fixed-step scroll should show none; any at all means the loop is presenting faster than the strip is moving. |
|
||||
| `blank` | frames with no visible slice to draw: the helper had no content. Should be zero. |
|
||||
| `restarts` | how many times the strip was scrolled through end to end. Informational: the bench restarts the strip where a plugin would hand over to the next one. |
|
||||
|
||||
`--json` writes the full report plus the panel geometry, the solved speed and
|
||||
these counters, so two rigs (or one rig before and after a change) can be
|
||||
compared without re-reading a terminal.
|
||||
|
||||
---
|
||||
|
||||
## A tear across the middle on fast scrolls
|
||||
|
||||
**Symptom:** while text scrolls, the top and bottom halves of the panel look
|
||||
shifted sideways against each other along a horizontal line at mid-height, and
|
||||
the shift grows with scroll speed. It shows most in Vegas mode at high speed.
|
||||
|
||||
**It is the panel's scan, not the software.** The measured panel, like most
|
||||
64-row panels, is multiplexed 1:32 (some panels of the same size scan
|
||||
differently, so check yours): it lights two rows at a time, one from each half
|
||||
(row 0 with row 32, row 1 with row 33, …), stepping down both halves together
|
||||
once per refresh. So row 31,
|
||||
the last row of the top half, lights almost a whole refresh period after row 32
|
||||
right below it. Your eye follows moving text, and moving content that lights at
|
||||
different times lands in different places, so the two rows meet with an offset
|
||||
of roughly
|
||||
|
||||
```
|
||||
offset ≈ scroll speed × refresh period
|
||||
```
|
||||
|
||||
Each frame reaches the panel whole (`SwapOnVSync` swaps complete frames between
|
||||
refreshes); the shift is created inside a single refresh. Other panel heights
|
||||
show it too, at the point where their two scan halves meet.
|
||||
|
||||
On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
|
||||
(7.7 ms per pass):
|
||||
|
||||
| scroll speed | offset at the midline |
|
||||
|---|---|
|
||||
| 50 px/s (Vegas default) | ~0.4 px |
|
||||
| 100 px/s | ~0.8 px |
|
||||
| 150 px/s | ~1.2 px, plainly visible |
|
||||
|
||||
### What the display does about it
|
||||
|
||||
At one pixel per refresh, the fastest crisp speed, the step is exactly one
|
||||
refresh's worth of motion, so it can be cancelled: show one half of the panel
|
||||
a refresh behind the other -- the half whose row at the seam lights at the
|
||||
start of each refresh. The two rows either side of the seam then show the same
|
||||
moment again. What is left is a
|
||||
lean of one pixel per half from top to bottom, continuous across the panel,
|
||||
which reads as nothing where the step read as a tear. `DisplayManager` does
|
||||
this while something scrolls at one frame per refresh
|
||||
(`display.scan_order_compensation`, `"auto"` by default, `"off"` to disable;
|
||||
the geometry is in `src/scan_order.py`). The lagging rows come from the
|
||||
previous frame the display presented, so it works for Vegas and every plugin
|
||||
ticker without knowing how they scroll.
|
||||
|
||||
Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was
|
||||
written: `scan_mode: 1` (interlaced) made the step vanish but turned moving
|
||||
edges grainy, and halving the speed halved it, so it is the scan and not a torn
|
||||
frame. With the compensation the step is gone at 90 px/s.
|
||||
|
||||
It is left off where the row order is unknown or the maths does not hold:
|
||||
|
||||
- **Slower speeds**, where each frame is held for two or more refreshes. The
|
||||
offset there is half a pixel or less, and cancelling it would need a lag of
|
||||
a fraction of a frame.
|
||||
- **Other layouts:** pixel mappers other than a 0 or 180 degree rotation
|
||||
(U-mapper, 90/270), non-zero `multiplexing`, interlaced `scan_mode`, and a
|
||||
canvas remapped to another height (double-sided mode).
|
||||
- **The emulator,** which has no scan order.
|
||||
|
||||
### When it cannot apply
|
||||
|
||||
Only a shorter scan period (a faster refresh) or a slower scroll. Measure what
|
||||
the panel actually achieves first. The library prints the rate with a carriage
|
||||
return and no newline, so read it from the raw journal:
|
||||
|
||||
```bash
|
||||
# set display.hardware.show_refresh_rate to true (web UI, Display tab), restart, then:
|
||||
journalctl -u ledmatrix --since "-1min" --no-pager -o cat --all | grep -a -oE "[0-9.]+Hz" | tail -5
|
||||
```
|
||||
|
||||
Turn it off again afterwards. Measured on that panel (Pi 4, single chain),
|
||||
changing one setting at a time from `pwm_bits: 7`, `gpio_slowdown: 3`:
|
||||
|
||||
| change | refresh, uncapped | notes |
|
||||
|---|---|---|
|
||||
| none | ~130 Hz | the ceiling for this wiring |
|
||||
| `pwm_bits: 6` | ~138 Hz | barely faster, and half the colour depth |
|
||||
| `gpio_slowdown: 2` | ~130 Hz | no faster, **and visible glitching**; keep 3 |
|
||||
| `limit_refresh_rate_hz: 0` | ~130 Hz | Vegas dropped from 100 to 72–95 fps as the refresh thread took more CPU |
|
||||
|
||||
None of these helps much, because the time goes into shifting each row's pixels
|
||||
out: a 2×128 chain pushes 256 pixels per row down one output. What does help is
|
||||
**fewer pixels per output**. On a bonnet with more than one output (the
|
||||
`regular` and `classic` mappings have 3; `adafruit-hat` has 1), put each panel
|
||||
on its own output and set `parallel` to the number of outputs used and
|
||||
`chain_length` to the panels per output, for example `parallel: 2`,
|
||||
`chain_length: 1` for two panels. Each refresh then shifts half the data, which
|
||||
should roughly double the refresh rate and halve the offset. That is a cable
|
||||
change, so measure again afterwards.
|
||||
|
||||
Short of rewiring, keep fast scrolls moderate on those layouts: at 50 px/s the
|
||||
offset is under half a pixel.
|
||||
|
||||
## Rebuilding the binding
|
||||
|
||||
```bash
|
||||
|
||||
@@ -73,14 +73,16 @@ B2 below promoted code into it (`SportsCore`, the mode classes,
|
||||
capabilities sections record that design, but none of it ships in core any
|
||||
more. Shared sports code lives in `src/common`:
|
||||
|
||||
```
|
||||
src/common/
|
||||
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
||||
(content building stays in the plugins)
|
||||
sports_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin
|
||||
(3.5.0) — the helpers byte-identical in the
|
||||
plugins' sports.py, and the _favorite_key seam
|
||||
```
|
||||
| Module | Since | Holds |
|
||||
|---|---|---|
|
||||
| `sports_scroll.py` | 3.2.0 | `SportsScrollDisplay` / `SportsScrollDisplayManager` — scroll orchestration (content building stays in the plugins) |
|
||||
| `sports_card.py` | 3.3.0 | Free functions for card settings, colours, favourite-team rules, dates and font sizes |
|
||||
| `sports_game_renderer.py` | 3.3.0 | `SportsGameRendererMixin` — scroll/Vegas card geometry |
|
||||
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
||||
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
|
||||
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
|
||||
|
||||
Each is described in [src/common/README.md](../src/common/README.md).
|
||||
|
||||
### Converging on `src/common`
|
||||
|
||||
@@ -90,7 +92,7 @@ modules taken from the plugin copies, each a **new module** rather than growth
|
||||
on an existing one: a plugin that deletes a method copy and relies on an older
|
||||
module having gained it fails at runtime with an `AttributeError`, while a
|
||||
missing module fails at load, where the version checks can see it.
|
||||
`sports_helpers.py` is the first (it holds `_favorite_key`, the override point
|
||||
`sports_helpers.py` is the newest (it holds `_favorite_key`, the override point
|
||||
listed below, for later phases); its parity test compares every body against
|
||||
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
||||
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
||||
|
||||
@@ -102,9 +102,16 @@ cd /path/to/LEDMatrix
|
||||
bash scripts/download_pixlet.sh
|
||||
```
|
||||
|
||||
The script downloads only the Linux ARM64 build (Raspberry Pi OS 64-bit),
|
||||
from the `tronbyt/pixlet` releases, to `bin/pixlet/pixlet-linux-arm64`. On
|
||||
any other platform (32-bit Pi OS, x86_64, macOS), put a `pixlet` binary on
|
||||
your `PATH`, or set the plugin's `pixlet_path`, or place it in `bin/pixlet/`
|
||||
under the name `_find_pixlet_binary()` looks for
|
||||
([`web_interface/blueprints/api_v3/__init__.py`](../web_interface/blueprints/api_v3/__init__.py)).
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
./bin/pixlet/pixlet-linux-amd64 version
|
||||
./bin/pixlet/pixlet-linux-arm64 version
|
||||
# Pixlet 0.50.2 (or later)
|
||||
```
|
||||
|
||||
@@ -276,10 +283,8 @@ LEDMatrix/
|
||||
│ ├── hour_hand.png
|
||||
│ └── minute_hand.png
|
||||
│
|
||||
├── bin/pixlet/ # Pixlet binaries
|
||||
│ ├── pixlet-linux-amd64
|
||||
│ ├── pixlet-linux-arm64
|
||||
│ └── pixlet-darwin-arm64
|
||||
├── bin/pixlet/ # Pixlet binary
|
||||
│ └── pixlet-linux-arm64 # the only one download_pixlet.sh fetches
|
||||
│
|
||||
└── scripts/
|
||||
└── download_pixlet.sh # Pixlet installer
|
||||
@@ -324,7 +329,7 @@ Many apps require API keys for external services:
|
||||
**Solutions**:
|
||||
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
|
||||
2. Verify config: Ensure all required fields are filled
|
||||
3. Test manually: `./bin/pixlet/pixlet-linux-amd64 render starlark-apps/{app-id}/{app-id}.star`
|
||||
3. Test manually: `./bin/pixlet/pixlet-linux-arm64 render starlark-apps/{app-id}/{app-id}.star`
|
||||
4. Missing assets: Some apps need images/fonts that may fail to download
|
||||
5. API issues: Check API keys and rate limits
|
||||
|
||||
|
||||
+32
-27
@@ -201,10 +201,11 @@ sudo systemctl restart ledmatrix-web
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Install dependencies:**
|
||||
1. **Install dependencies** as root, so the root display service can import
|
||||
them:
|
||||
```bash
|
||||
pip3 install --break-system-packages -r requirements.txt
|
||||
pip3 install --break-system-packages -r web_interface/requirements.txt
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r web_interface/requirements.txt
|
||||
```
|
||||
|
||||
2. **Test imports step-by-step:**
|
||||
@@ -250,15 +251,18 @@ sudo systemctl restart ledmatrix-web
|
||||
|
||||
**Solutions:**
|
||||
|
||||
```bash
|
||||
# Fix ownership of LEDMatrix directory
|
||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix
|
||||
[PERMISSIONS.md](PERMISSIONS.md) lists the expected owner and mode of every
|
||||
file and directory, and which `scripts/fix_perms/` script to run as which
|
||||
user. Don't `chown -R` the whole project: the two sudo helper scripts in
|
||||
`scripts/fix_perms/` must stay owned by root.
|
||||
|
||||
# Fix config file permissions
|
||||
```bash
|
||||
# Config files: web user owns both; secrets must stay 640
|
||||
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
|
||||
sudo chmod 644 config/config.json
|
||||
sudo chmod 640 config/config_secrets.json
|
||||
|
||||
# Verify service runs as correct user
|
||||
# Which user the web interface runs as
|
||||
sudo systemctl cat ledmatrix-web | grep User
|
||||
```
|
||||
|
||||
@@ -462,15 +466,17 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
}
|
||||
```
|
||||
|
||||
2. **Restart display:**
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
Or toggle the plugin on in the **Plugin Manager** tab, which writes the
|
||||
same flag.
|
||||
|
||||
3. **Verify in web interface:**
|
||||
- Open the **Plugin Manager** tab
|
||||
- Toggle the plugin switch to enable
|
||||
- From **Overview**, click **Restart Display Service**
|
||||
2. **Wait a few seconds.** The display service watches `config.json` and
|
||||
loads a newly enabled plugin without a restart
|
||||
(`DisplayController._reconcile_enabled_plugins()` in
|
||||
[`src/display_controller.py`](../src/display_controller.py)). This
|
||||
needs hot reload, which is on unless `LEDMATRIX_HOT_RELOAD=false` is set.
|
||||
|
||||
3. **If it still does not appear**, check the logs for a config validation
|
||||
error, then restart: `sudo systemctl restart ledmatrix`
|
||||
|
||||
#### Plugin Not Loading
|
||||
|
||||
@@ -491,10 +497,12 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
# Verify all required fields present
|
||||
```
|
||||
|
||||
3. **Check dependencies installed:**
|
||||
3. **Check dependencies installed.** Install them with `sudo`: the display
|
||||
service runs as root and does not see packages pip put in your user's
|
||||
`~/.local` (see [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md)):
|
||||
```bash
|
||||
if [ -f plugin-repos/plugin-id/requirements.txt ]; then
|
||||
pip3 install --break-system-packages -r plugin-repos/plugin-id/requirements.txt
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r plugin-repos/plugin-id/requirements.txt
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -503,14 +511,9 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
sudo journalctl -u ledmatrix -f | grep plugin-id
|
||||
```
|
||||
|
||||
5. **Test plugin import:**
|
||||
5. **Load and render the plugin headlessly:**
|
||||
```bash
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'plugin-repos/plugin-id')
|
||||
from manager import PluginClass
|
||||
print('Plugin imports successfully')
|
||||
"
|
||||
python3 scripts/check_plugin.py --plugin plugin-id
|
||||
```
|
||||
|
||||
#### Stale Cache Data
|
||||
@@ -540,10 +543,12 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
2. **Check cache permissions:**
|
||||
2. **Check cache permissions.** Expected: `root:ledmatrix`, `drwxrwsr-x`.
|
||||
`setup_cache.sh` restores that layout (see
|
||||
[PERMISSIONS.md](PERMISSIONS.md#repair-scripts)):
|
||||
```bash
|
||||
ls -ld /var/cache/ledmatrix
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
||||
sudo bash scripts/install/setup_cache.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -161,7 +161,11 @@ duration, and related settings — so you can configure Vegas mode
|
||||
entirely from the web UI without hand-editing JSON. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
|
||||
|
||||
Changes require **Restart Display Service** from the Overview tab.
|
||||
Brightness and the Vegas Scroll settings apply to the running display
|
||||
within a few seconds. Matrix hardware settings (rows, columns, chain length,
|
||||
mapping, GPIO slowdown, PWM and refresh settings) are only read when the
|
||||
display starts, so those need **Restart Display Service** from the Overview
|
||||
tab.
|
||||
|
||||
### Plugin Manager Tab
|
||||
|
||||
@@ -248,16 +252,16 @@ View real-time system logs:
|
||||
|
||||
1. Open the **Display** tab
|
||||
2. Adjust the **Brightness** slider (1–100)
|
||||
3. Click **Save**
|
||||
4. Click **Restart Display Service** on the **Overview** tab
|
||||
3. Click **Save**. The panel picks up the new brightness within a few
|
||||
seconds; no restart is needed
|
||||
|
||||
### Installing a New Plugin
|
||||
|
||||
1. Open the **Plugin Manager** tab
|
||||
2. Scroll to the **Plugin Store** section and browse or search
|
||||
3. Click **Install** next to the plugin
|
||||
4. Toggle the plugin on in **Installed Plugins**
|
||||
5. Click **Restart Display Service** on **Overview**
|
||||
4. Toggle the plugin on in **Installed Plugins**. The running display
|
||||
loads it within a few seconds; no restart is needed
|
||||
|
||||
### Configuring a Plugin
|
||||
|
||||
|
||||
+5
-588
@@ -1,590 +1,7 @@
|
||||
# Widget Development Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix Widget Registry system allows plugins to use reusable UI components for configuration forms. This enables:
|
||||
|
||||
- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code
|
||||
- **Custom Widgets**: Create plugin-specific widgets without modifying the LEDMatrix codebase
|
||||
- **Backwards Compatibility**: Existing plugins continue to work without changes
|
||||
|
||||
## Available Core Widgets
|
||||
|
||||
### Plugin File Manager Widget (`plugin-file-manager`)
|
||||
|
||||
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
|
||||
|
||||
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"file_manager": {
|
||||
"type": "null",
|
||||
"title": "Data Files",
|
||||
"x-widget": "plugin-file-manager",
|
||||
"x-widget-config": {
|
||||
"actions": {
|
||||
"list": "list-files",
|
||||
"get": "get-file",
|
||||
"save": "save-file",
|
||||
"upload": "upload-file",
|
||||
"delete": "delete-file",
|
||||
"create": "create-file",
|
||||
"toggle": "toggle-category"
|
||||
},
|
||||
"upload_hint": "JSON files with day numbers 1–365 as keys",
|
||||
"directory_label": "my_data/",
|
||||
"create_fields": [
|
||||
{ "key": "category_name", "label": "Category Name",
|
||||
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
||||
"hint": "Lowercase letters, numbers, underscores" },
|
||||
{ "key": "display_name", "label": "Display Name",
|
||||
"placeholder": "e.g., My Words", "hint": "Optional" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
||||
|
||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
||||
|
||||
**Used by:** of-the-day
|
||||
|
||||
---
|
||||
|
||||
### Time Picker Widget (`time-picker`)
|
||||
|
||||
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"target_time": {
|
||||
"type": "string",
|
||||
"x-widget": "time-picker",
|
||||
"default": "00:00",
|
||||
"x-options": {
|
||||
"placeholder": "Select time",
|
||||
"clearable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Used by:** countdown
|
||||
|
||||
---
|
||||
|
||||
### File Upload Single Widget (`file-upload-single`)
|
||||
|
||||
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"image_path": {
|
||||
"type": "string",
|
||||
"x-widget": "file-upload-single",
|
||||
"x-upload-config": {
|
||||
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
||||
"max_size_mb": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
|
||||
|
||||
**Used by:** countdown
|
||||
|
||||
---
|
||||
|
||||
### File Upload Widget (`file-upload`)
|
||||
|
||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"type": "array",
|
||||
"x-widget": "file-upload",
|
||||
"x-upload-config": {
|
||||
"plugin_id": "my-plugin",
|
||||
"max_files": 10,
|
||||
"max_size_mb": 5,
|
||||
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Used by:** static-image, news plugins
|
||||
|
||||
### Checkbox Group Widget (`checkbox-group`)
|
||||
|
||||
Multi-select checkboxes for array fields with enum items.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"type": "array",
|
||||
"x-widget": "checkbox-group",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["option1", "option2", "option3"]
|
||||
},
|
||||
"x-options": {
|
||||
"labels": {
|
||||
"option1": "Option 1 Label",
|
||||
"option2": "Option 2 Label"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Used by:** odds-ticker, news plugins
|
||||
|
||||
### Custom Feeds Widget (`custom-feeds`)
|
||||
|
||||
Table-based RSS feed editor with logo uploads.
|
||||
|
||||
**Schema Configuration:**
|
||||
```json
|
||||
{
|
||||
"type": "array",
|
||||
"x-widget": "custom-feeds",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"enabled": { "type": "boolean" },
|
||||
"logo": { "type": "object" }
|
||||
}
|
||||
},
|
||||
"maxItems": 50
|
||||
}
|
||||
```
|
||||
|
||||
**Used by:** news plugin (for custom RSS feeds)
|
||||
|
||||
## Using Existing Widgets
|
||||
|
||||
To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"my_images": {
|
||||
"type": "array",
|
||||
"x-widget": "file-upload",
|
||||
"x-upload-config": {
|
||||
"plugin_id": "my-plugin",
|
||||
"max_files": 5
|
||||
}
|
||||
},
|
||||
"enabled_leagues": {
|
||||
"type": "array",
|
||||
"x-widget": "checkbox-group",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["nfl", "nba", "mlb"]
|
||||
},
|
||||
"x-options": {
|
||||
"labels": {
|
||||
"nfl": "NFL",
|
||||
"nba": "NBA",
|
||||
"mlb": "MLB"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||
|
||||
## Labelling Enum Options (`x-options.labels`)
|
||||
|
||||
A plain `enum` renders as a dropdown whose option text is the value with
|
||||
underscores replaced and title case applied — `day_first` becomes "Day First".
|
||||
That is fine for values that read as their own label, and wrong for values that
|
||||
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
|
||||
actually produces.
|
||||
|
||||
Supply `x-options.labels` to set the visible text. This is the same convention
|
||||
the `checkbox-group` widget uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"date_format": {
|
||||
"type": "string",
|
||||
"enum": ["abbrev", "numeric", "day_first"],
|
||||
"default": "abbrev",
|
||||
"x-options": {
|
||||
"labels": {
|
||||
"abbrev": "Sep 19",
|
||||
"numeric": "9/19",
|
||||
"day_first": "19 Sep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Labels are **display only** — the stored value is still the enum value, so
|
||||
adding them never changes a saved config. The map may be partial: any value
|
||||
without a label keeps the humanised fallback. Older cores that predate this
|
||||
support ignore `x-options` and render the fallback for every option, so a
|
||||
plugin can ship labels without requiring a core upgrade.
|
||||
|
||||
Array-table columns (`x-widget: array-table`) accept the same
|
||||
`x-options.labels` on a column definition, but their fallback is the **raw
|
||||
value** rather than the humanised one, because those columns hold values such
|
||||
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
|
||||
browser use the labels too (`array-table.js`), so a column reads the same
|
||||
before and after a page reload.
|
||||
|
||||
## Marking Fields as Advanced (`x-advanced`)
|
||||
|
||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||
of the main form and into a single collapsed **Advanced Settings** section at
|
||||
the bottom of the plugin's configuration page:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"title": "City"
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "HTTP timeout in seconds",
|
||||
"x-advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Use it for fine-tuning knobs most users never touch (timeouts, retry
|
||||
behavior, cache TTLs, styling overrides). Anything a first-time user must
|
||||
set to get the plugin working should stay basic.
|
||||
- Nothing is hidden permanently — the section expands on click, and the
|
||||
settings search finds and auto-expands advanced fields like any others.
|
||||
- The flag is ignored on `object`-type properties (they already render as
|
||||
their own collapsible sections) and is safely ignored by older cores, so
|
||||
adding it never breaks compatibility.
|
||||
|
||||
## Hiding Fields From the Form (`x-display: "hidden"`)
|
||||
|
||||
Add `"x-display": "hidden"` to a property that must stay in the schema but
|
||||
should not appear as a control: a deprecated key kept so existing configs keep
|
||||
validating, or an internal value such as an auto-generated row id.
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"radar_zoom": {
|
||||
"type": "integer",
|
||||
"default": 6,
|
||||
"title": "Radar Zoom Level (deprecated)",
|
||||
"x-display": "hidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
What the core does with it:
|
||||
|
||||
- **Not rendered** at any depth: top-level fields, children of an object
|
||||
section, and properties of array-of-object items (never a table column, even
|
||||
if `x-columns` names it, and never in the row editor). A hidden field flagged
|
||||
`x-advanced` is not listed or counted in Advanced Settings, and an object
|
||||
whose children are all hidden draws no empty section. Hidden fields don't
|
||||
show up in the settings search either, since it indexes the rendered form.
|
||||
- **Stored value preserved on save.** Saving the form never changes a hidden
|
||||
value. The unchecked-checkbox rule ignores a hidden boolean. Array rows carry
|
||||
a hidden property's stored value through the form, so the value survives the
|
||||
row being posted back; a new row gets no value (the plugin fills it in).
|
||||
- **The API is unaffected.** A JSON save to `POST /api/v3/plugins/config` can
|
||||
still set a hidden field.
|
||||
|
||||
Older cores ignore the flag and render the field as a normal control.
|
||||
|
||||
## Creating Custom Widgets
|
||||
|
||||
### Step 1: Create Widget File
|
||||
|
||||
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
|
||||
// Ensure LEDMatrixWidgets registry is available
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('LEDMatrixWidgets registry not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Register your widget
|
||||
window.LEDMatrixWidgets.register('my-custom-widget', {
|
||||
name: 'My Custom Widget',
|
||||
version: '1.0.0',
|
||||
|
||||
/**
|
||||
* Render the widget HTML
|
||||
* @param {HTMLElement} container - Container element to render into
|
||||
* @param {Object} config - Widget configuration from schema
|
||||
* @param {*} value - Current value
|
||||
* @param {Object} options - Additional options (fieldId, pluginId, etc.)
|
||||
*/
|
||||
render: function(container, config, value, options) {
|
||||
const fieldId = options.fieldId || container.id;
|
||||
|
||||
// Always escape HTML to prevent XSS
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="my-custom-widget">
|
||||
<input type="text"
|
||||
id="${fieldId}_input"
|
||||
value="${escapeHtml(value || '')}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach event listeners
|
||||
const input = container.querySelector('input');
|
||||
input.addEventListener('change', (e) => {
|
||||
this.handlers.onChange(fieldId, e.target.value);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current value from widget
|
||||
*/
|
||||
getValue: function(fieldId) {
|
||||
const input = document.querySelector(`#${fieldId}_input`);
|
||||
return input ? input.value : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set value programmatically
|
||||
*/
|
||||
setValue: function(fieldId, value) {
|
||||
const input = document.querySelector(`#${fieldId}_input`);
|
||||
if (input) {
|
||||
input.value = value || '';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Event handlers
|
||||
*/
|
||||
handlers: {
|
||||
onChange: function(fieldId, value) {
|
||||
// Trigger form change event
|
||||
const event = new CustomEvent('widget-change', {
|
||||
detail: { fieldId, value },
|
||||
bubbles: true
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"my_field": {
|
||||
"type": "string",
|
||||
"description": "My custom field",
|
||||
"x-widget": "my-custom-widget",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Widget Loading
|
||||
|
||||
The widget is loaded on demand when the plugin's configuration form renders a
|
||||
field that references it. The system will:
|
||||
|
||||
1. Check whether the widget is already registered in the core registry.
|
||||
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
|
||||
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.
|
||||
|
||||
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 Definition Object
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: string, // Human-readable widget name
|
||||
version: string, // Widget version
|
||||
render: function, // Required: Render function
|
||||
getValue: function, // Optional: Get current value
|
||||
setValue: function, // Optional: Set value programmatically
|
||||
handlers: object // Optional: Event handlers
|
||||
}
|
||||
```
|
||||
|
||||
### Render Function
|
||||
|
||||
```javascript
|
||||
render(container, config, value, options)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `container` (HTMLElement): Container element to render into
|
||||
- `config` (Object): Widget configuration from schema
|
||||
- `value` (*): Current field value
|
||||
- `options` (Object): Additional options
|
||||
- `fieldId` (string): Field ID
|
||||
- `pluginId` (string): Plugin ID
|
||||
- `fullKey` (string): Full field key path
|
||||
|
||||
### Get Value Function
|
||||
|
||||
```javascript
|
||||
getValue(fieldId)
|
||||
```
|
||||
|
||||
**Returns:** Current widget value
|
||||
|
||||
### Set Value Function
|
||||
|
||||
```javascript
|
||||
setValue(fieldId, value)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `fieldId` (string): Field ID
|
||||
- `value` (*): Value to set
|
||||
|
||||
## Examples
|
||||
|
||||
See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interface/static/v3/js/widgets/example-color-picker.js) for a complete example of a custom color picker widget.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS
|
||||
2. **Validate inputs**: Validate user input before processing
|
||||
3. **Sanitize values**: Clean values before storing
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Lazy loading**: Load widget scripts only when needed
|
||||
2. **Event delegation**: Use event delegation for dynamic content
|
||||
3. **Debounce**: Debounce frequent events (e.g., input changes)
|
||||
|
||||
### Accessibility
|
||||
|
||||
1. **Labels**: Always associate labels with inputs
|
||||
2. **ARIA attributes**: Use appropriate ARIA attributes
|
||||
3. **Keyboard navigation**: Ensure keyboard accessibility
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Widget Not Loading
|
||||
|
||||
1. Check browser console for errors
|
||||
2. Verify widget file path is correct
|
||||
3. Ensure `LEDMatrixWidgets.register()` is called
|
||||
4. Check that widget name matches schema `x-widget` value
|
||||
|
||||
### Widget Not Rendering
|
||||
|
||||
1. Verify `render` function is defined
|
||||
2. Check container element exists
|
||||
3. Ensure widget is registered before form loads
|
||||
4. Check for JavaScript errors in console
|
||||
|
||||
### Value Not Saving
|
||||
|
||||
1. Ensure widget triggers `widget-change` event
|
||||
2. Verify form submission includes widget value
|
||||
3. Check `getValue` function returns correct type
|
||||
4. Verify field name matches schema property
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
**Phase 1 Complete:**
|
||||
- ✅ Widget registry system created
|
||||
- ✅ Core widgets extracted to separate files
|
||||
- ✅ Widget handlers available globally (backwards compatible)
|
||||
- ✅ Plugin widget loading system implemented
|
||||
|
||||
**Current Behavior:**
|
||||
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||
- Widget handlers are registered and available globally
|
||||
- Custom widgets can be created, declared in `manifest.json`, and are served
|
||||
and rendered on demand for `string`-typed fields
|
||||
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
|
||||
|
||||
**Backwards Compatibility:**
|
||||
- All existing plugins using widgets continue to work without changes
|
||||
- Server-side rendering remains the primary method
|
||||
- Widget registry provides foundation for future enhancements
|
||||
|
||||
## See Also
|
||||
|
||||
- [Widget README](../web_interface/static/v3/js/widgets/README.md) - Complete widget development guide with examples
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - General plugin development
|
||||
- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
|
||||
The widget guide lives next to the widgets, in
|
||||
[web_interface/static/v3/js/widgets/README.md](../web_interface/static/v3/js/widgets/README.md).
|
||||
It lists every built-in `x-widget`, the schema keywords the config form
|
||||
understands (`x-options.labels`, `x-advanced`, `x-display: "hidden"`), and how
|
||||
to ship a custom widget with a plugin.
|
||||
|
||||
+108
-147
@@ -18,7 +18,7 @@ on_error() {
|
||||
echo "-- Last 100 lines from log --" >&2
|
||||
tail -n 100 "$LOG_FILE" >&2 || true
|
||||
fi
|
||||
echo "\nCommon fixes:" >&2
|
||||
printf '\nCommon fixes:\n' >&2
|
||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||
echo "- If you saw an APT lock error: wait a minute, close other installers, then run: sudo dpkg --configure -a" >&2
|
||||
echo "- Re-run this script. It is safe to run multiple times." >&2
|
||||
@@ -115,7 +115,8 @@ fi
|
||||
echo "✓ OS requirements met"
|
||||
echo ""
|
||||
|
||||
# Get the actual user who invoked sudo (set after we ensure sudo below)
|
||||
# The user who ran the installer: SUDO_USER once we are running under sudo
|
||||
# (the re-exec below guarantees that), otherwise whoever we are now.
|
||||
if [ -n "${SUDO_USER:-}" ]; then
|
||||
ACTUAL_USER="$SUDO_USER"
|
||||
else
|
||||
@@ -202,7 +203,7 @@ echo ""
|
||||
# Check if running as root; if not, try to elevate automatically for novices
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "This script needs administrator privileges. Attempting to re-run with sudo..."
|
||||
exec sudo -E env LEDMATRIX_ELEVATED=1 bash "$0" "$@"
|
||||
exec sudo -E bash "$0" "$@"
|
||||
fi
|
||||
echo "✓ Running as root (required for installation)"
|
||||
|
||||
@@ -502,6 +503,44 @@ print_rgbmatrix_build_failure() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Set WEB_SERVICE_USER to the account ledmatrix-web.service runs as, or "root"
|
||||
# when it cannot tell. Steps 3.1 and 11 choose plugin-directory ownership from
|
||||
# it. The logic was pasted three times, identically, and is kept verbatim here.
|
||||
# Note: install_web_service.sh and install_service.sh no longer contain the
|
||||
# "User=root" / "User=${ACTUAL_USER}" strings grepped for below (the units come
|
||||
# from systemd/*.service templates with User=__USER__). So once the unit is
|
||||
# installed (Step 7.5, by install_service.sh) the first branch reads its real
|
||||
# User=; before that the second branch is taken whenever
|
||||
# install_web_service.sh exists, matches neither string, and yields "root" --
|
||||
# the later branches are reached only if that script is missing.
|
||||
detect_web_service_user() {
|
||||
WEB_SERVICE_USER="root"
|
||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||
# Check actual installed service file (most accurate)
|
||||
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||
# Check install_web_service.sh (used by first_time_install.sh)
|
||||
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="root"
|
||||
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||
# Check template file (may have placeholder)
|
||||
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
||||
# If template has placeholder, check install script
|
||||
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
||||
# Check install_service.sh to see what user it uses
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
# Web service will be installed by install_service.sh as ACTUAL_USER
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "This script will perform the following steps:"
|
||||
echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
|
||||
@@ -634,8 +673,9 @@ else
|
||||
echo "Setting ownership of assets directory..."
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets"
|
||||
|
||||
# Set permissions to allow read/write for owner, group, and others (for root service user)
|
||||
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
|
||||
# 777: read/write for owner, group and every other account. Root (the
|
||||
# display service) does not need it -- root ignores mode bits -- so the
|
||||
# "other" bits only matter to accounts that are neither the owner nor root.
|
||||
echo "Setting permissions for assets directory..."
|
||||
chmod -R 777 "$PROJECT_ROOT_DIR/assets"
|
||||
|
||||
@@ -699,32 +739,7 @@ else
|
||||
fi
|
||||
|
||||
# Determine ownership based on web service user
|
||||
# Check if web service file exists and what user it runs as
|
||||
WEB_SERVICE_USER="root"
|
||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||
# Check actual installed service file (most accurate)
|
||||
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||
# Check install_web_service.sh (used by first_time_install.sh)
|
||||
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="root"
|
||||
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||
# Check template file (may have placeholder)
|
||||
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
||||
# If template has placeholder, check install script
|
||||
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
||||
# Check install_service.sh to see what user it uses
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
# Web service will be installed by install_service.sh as ACTUAL_USER
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
detect_web_service_user
|
||||
|
||||
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
|
||||
# so the web service can change permissions. Root service can still access via group (775).
|
||||
@@ -758,32 +773,7 @@ if [ ! -d "$PLUGIN_REPOS_DIR" ]; then
|
||||
fi
|
||||
|
||||
# Determine ownership based on web service user
|
||||
# Check if web service file exists and what user it runs as
|
||||
WEB_SERVICE_USER="root"
|
||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||
# Check actual installed service file (most accurate)
|
||||
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||
# Check install_web_service.sh (used by first_time_install.sh)
|
||||
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="root"
|
||||
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||
# Check template file (may have placeholder)
|
||||
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
||||
# If template has placeholder, check install script
|
||||
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
||||
# Check install_service.sh to see what user it uses
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
# Web service will be installed by install_service.sh as ACTUAL_USER
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
detect_web_service_user
|
||||
|
||||
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_USER
|
||||
# so the web service can change permissions. Root service can still access via group (775).
|
||||
@@ -797,8 +787,8 @@ else
|
||||
chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
||||
fi
|
||||
|
||||
# Set directory permissions (775: rwxrwxr-x)
|
||||
echo "Setting plugin-repos directory permissions to 2775 (sticky bit)..."
|
||||
# Set directory permissions (2775: rwxrwsr-x, setgid so new entries inherit the group)
|
||||
echo "Setting plugin-repos directory permissions to 2775 (setgid)..."
|
||||
find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \;
|
||||
|
||||
# Set file permissions (664: rw-rw-r--)
|
||||
@@ -1005,9 +995,9 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
PACKAGE_NUM=$((PACKAGE_NUM + 1))
|
||||
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
||||
|
||||
# Check if package is already installed (basic check - may not catch all cases)
|
||||
# Try installing with verbose output and timeout (if available)
|
||||
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
||||
# Install with a timeout where available. --verbose output goes to
|
||||
# $INSTALL_OUTPUT (filtered below, full copy in the log); --no-cache-dir
|
||||
# avoids pip cache issues.
|
||||
INSTALL_OUTPUT=$(mktemp)
|
||||
INSTALL_SUCCESS=false
|
||||
|
||||
@@ -1312,7 +1302,11 @@ else
|
||||
WEB_DEPS_OK=false
|
||||
fi
|
||||
else
|
||||
echo "Web dependencies already installed from web_interface/requirements.txt in Step 5"
|
||||
# No marker means Step 5 did not install web_interface/requirements.txt,
|
||||
# and without the smart installer there is nothing else to try here.
|
||||
echo "⚠ scripts/install_dependencies_apt.py not found, and Step 5 did not install"
|
||||
echo " web_interface/requirements.txt, so web interface dependencies may be missing."
|
||||
WEB_DEPS_OK=false
|
||||
fi
|
||||
|
||||
# Create the marker only when installation actually succeeded, so a
|
||||
@@ -1516,51 +1510,31 @@ POWEROFF_PATH=$(which poweroff)
|
||||
BASH_PATH=$(which bash)
|
||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||
|
||||
# Create sudoers content
|
||||
cat > "$SUDOERS_TMP" << EOF
|
||||
# LED Matrix Web Interface passwordless sudo configuration
|
||||
# This allows the web interface user to run specific commands without a password
|
||||
|
||||
# Allow $ACTUAL_USER to run specific commands without a password for the LED Matrix web interface
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
||||
$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
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> "$SUDOERS_TMP" << EOF
|
||||
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||
# 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
|
||||
# The rules themselves live in scripts/install/lib_sudoers.sh, shared with
|
||||
# scripts/install/configure_web_sudo.sh so the two cannot drift apart again.
|
||||
# If it is missing (a damaged checkout), keep whatever is already installed
|
||||
# rather than failing the whole install; the gate below skips the install.
|
||||
SUDOERS_VALID=1
|
||||
SUDOERS_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_sudoers.sh"
|
||||
if [ -f "$SUDOERS_LIB" ]; then
|
||||
# shellcheck source=scripts/install/lib_sudoers.sh
|
||||
. "$SUDOERS_LIB"
|
||||
web_sudoers_rules "$ACTUAL_USER" "$PROJECT_ROOT_DIR" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
||||
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$SUDOERS_TMP"
|
||||
else
|
||||
SUDOERS_VALID=0
|
||||
echo "⚠ $SUDOERS_LIB not found; cannot generate the sudoers rules." >&2
|
||||
echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2
|
||||
echo " the display service until this is fixed." >&2
|
||||
fi
|
||||
|
||||
# Never install rules we have not parsed. A malformed drop-in in
|
||||
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
|
||||
# headless Pi leaves no way in at all. If the rules do not parse, say so and
|
||||
# keep whatever is already installed.
|
||||
SUDOERS_VALID=1
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
if [ "$SUDOERS_VALID" = "0" ]; then
|
||||
: # nothing was generated; already reported above
|
||||
elif command -v visudo >/dev/null 2>&1; then
|
||||
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
|
||||
SUDOERS_VALID=0
|
||||
echo "⚠ The generated sudoers rules did not parse:" >&2
|
||||
@@ -1597,11 +1571,12 @@ echo "-----------------------------------------------------"
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" ]; then
|
||||
echo "Configuring WiFi management permissions..."
|
||||
# Run as the actual user (not root) since the script checks for that
|
||||
sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" || {
|
||||
if sudo -u "$ACTUAL_USER" bash "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh"; then
|
||||
echo "✓ WiFi management permissions configured"
|
||||
else
|
||||
echo "⚠ WiFi permissions configuration failed, but continuing installation"
|
||||
echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh"
|
||||
}
|
||||
echo "✓ WiFi management permissions configured"
|
||||
fi
|
||||
else
|
||||
echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration"
|
||||
echo " You can configure WiFi permissions later by running:"
|
||||
@@ -1690,28 +1665,8 @@ fi
|
||||
|
||||
# Re-apply plugin directory permissions based on web service user
|
||||
echo "Re-applying plugin directory permissions..."
|
||||
# Determine web service user (check installed service, install scripts, or template)
|
||||
WEB_SERVICE_USER="root"
|
||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||
# Check actual installed service file (most accurate)
|
||||
WEB_SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo "root")
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||
# Check install_web_service.sh (used by first_time_install.sh)
|
||||
if grep -q "User=root" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="root"
|
||||
elif grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||
WEB_SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" | cut -d'=' -f2 || echo "root")
|
||||
if [ "$WEB_SERVICE_USER" = "__USER__" ] || [ -z "$WEB_SERVICE_USER" ]; then
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
fi
|
||||
elif [ -f "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" ] && grep -q "User=\${ACTUAL_USER}" "$PROJECT_ROOT_DIR/scripts/install/install_service.sh"; then
|
||||
WEB_SERVICE_USER="$ACTUAL_USER"
|
||||
fi
|
||||
# Determine ownership based on web service user
|
||||
detect_web_service_user
|
||||
|
||||
# Set ownership based on web service user
|
||||
if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then
|
||||
@@ -1791,10 +1746,11 @@ echo "-------------------------------------"
|
||||
echo "Removing potential conflicting services (bluetooth and others)..."
|
||||
if [ "$SKIP_SOUND" = "1" ]; then
|
||||
echo "Skipping sound module configuration as requested (--skip-sound)."
|
||||
elif apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio; then
|
||||
echo "✓ Unnecessary services removed (or not present)"
|
||||
else
|
||||
echo "⚠ Some packages could not be removed; continuing"
|
||||
# apt_remove never fails (it ends in `|| true`); apt itself reports any
|
||||
# package it could not remove.
|
||||
apt_remove bluez bluez-firmware pi-bluetooth triggerhappy pigpio
|
||||
echo "✓ Unnecessary services removed (or not present)"
|
||||
fi
|
||||
|
||||
# Blacklist onboard sound module (idempotent)
|
||||
@@ -2009,20 +1965,6 @@ if systemctl list-unit-files | grep -q "ledmatrix-wifi-monitor.service"; then
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
|
||||
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
|
||||
elif [ "$ASSUME_YES" = "1" ]; then
|
||||
echo "Non-interactive mode: rebooting now to apply changes..."
|
||||
reboot
|
||||
else
|
||||
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Rebooting now..."
|
||||
reboot
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "Installation Complete!"
|
||||
echo "=========================================="
|
||||
@@ -2068,7 +2010,7 @@ if command -v nmcli >/dev/null 2>&1; then
|
||||
if [ -n "$WIFI_STATUS" ]; then
|
||||
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
||||
if [ "$state" = "connected" ]; then
|
||||
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
||||
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1 || true)
|
||||
if [ -n "$SSID" ]; then
|
||||
echo " ✓ Connected to: $SSID"
|
||||
else
|
||||
@@ -2092,7 +2034,7 @@ echo "AP Mode Status:"
|
||||
if systemctl is-active --quiet hostapd 2>/dev/null; then
|
||||
echo " ✓ AP Mode is ACTIVE"
|
||||
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
||||
echo " → Password: ledmatrix123"
|
||||
echo " → Open network, no password"
|
||||
echo " → Access web UI at: http://192.168.4.1:5000"
|
||||
AP_MODE_ACTIVE=true
|
||||
else
|
||||
@@ -2100,7 +2042,7 @@ else
|
||||
if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
|
||||
echo " ✓ AP Mode is ACTIVE (IP detected)"
|
||||
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
||||
echo " → Password: ledmatrix123"
|
||||
echo " → Open network, no password"
|
||||
echo " → Access web UI at: http://192.168.4.1:5000"
|
||||
AP_MODE_ACTIVE=true
|
||||
else
|
||||
@@ -2202,3 +2144,22 @@ echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
|
||||
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
|
||||
echo ""
|
||||
echo "Enjoy your LED Matrix display!"
|
||||
|
||||
# Reboot last. It used to come before the summary above, so with -y (and
|
||||
# the one-shot installer, which always passes -y) the reboot was already
|
||||
# under way while the summary printed, and the SSH session usually dropped
|
||||
# before any of it -- the web UI address included -- could be read.
|
||||
echo ""
|
||||
if [ "$SKIP_REBOOT_PROMPT" = "1" ]; then
|
||||
echo "Skipping reboot prompt as requested (--no-reboot-prompt)."
|
||||
elif [ "$ASSUME_YES" = "1" ]; then
|
||||
echo "Non-interactive mode: rebooting now to apply changes..."
|
||||
reboot
|
||||
else
|
||||
read -p "A reboot is recommended to apply kernel and audio changes. Reboot now? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Rebooting now..."
|
||||
reboot
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -31,8 +31,6 @@ if args.emulator:
|
||||
print("Using pygame/RGBMatrixEmulator for display")
|
||||
print("Press ESC to exit\n")
|
||||
|
||||
# Project directory already added above
|
||||
|
||||
# Debug output (only in debug mode or emulator mode)
|
||||
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
|
||||
if debug_mode:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Scripts
|
||||
|
||||
Helper scripts for installing, repairing, diagnosing and developing
|
||||
LEDMatrix. Most users only ever run the one-shot installer (see the project
|
||||
README); everything else here is for troubleshooting or development.
|
||||
|
||||
Status key: **keep** — part of install/runtime or referenced by docs, CI,
|
||||
tests or code; **dev-only** — for plugin/core development, not needed on a
|
||||
display; **diagnostic** — run by hand on a Pi when something is wrong.
|
||||
|
||||
## Directories
|
||||
|
||||
| Directory | Status | What it holds |
|
||||
|---|---|---|
|
||||
| [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources |
|
||||
| [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) |
|
||||
| [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) |
|
||||
| [`dev/`](dev/README.md) | dev-only | Plugin linking, emulator runner, Vegas density audit, Pillow smoke test |
|
||||
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
|
||||
|
||||
## Top-level scripts
|
||||
|
||||
| Script | Status | What it does |
|
||||
|---|---|---|
|
||||
| `build_rgbmatrix_nogil.sh` | keep | Rebuilds the rgbmatrix Python binding so `SwapOnVSync` releases the GIL (docs/SCROLL_PERFORMANCE.md) |
|
||||
| `check_plugin.py` | dev-only | Renders a plugin across every mode and matrix size and fails on crashes, overflow or golden-image drift |
|
||||
| `check_release_version.py` | keep | Checks a release tag, CHANGELOG and `src.__version__` agree (release-version-check workflow) |
|
||||
| `check_system_compatibility.sh` | diagnostic | Pre-install check of hardware, OS (Trixie only), kernel, Python, packages, disk and network |
|
||||
| `dev_server.py` | dev-only | Browser preview server for plugins without the display loop (http://localhost:5001) |
|
||||
| `diagnose_dependencies.sh` | diagnostic | Investigates pip installs stuck on "Preparing metadata" |
|
||||
| `diagnose_web_interface.sh` | diagnostic | Checks why the web interface is not reachable |
|
||||
| `download_pixlet.sh` | keep | Downloads the bundled Pixlet binaries for Starlark apps (also run from the web UI) |
|
||||
| `emergency_reconnect.sh` | diagnostic | Reconnects to your WiFi network if captive-portal testing leaves the Pi offline |
|
||||
| `install_dependencies_apt.py` | keep | Dependency installer that tries apt packages first, then pip (installer Step 7, plugin loader) |
|
||||
| `install_plugin_dependencies.sh` | diagnostic | Installs plugin requirements by hand when the automatic install fails |
|
||||
| `prove_security.py` | keep | Security property checks run by pre-commit |
|
||||
| `render_plugin.py` | dev-only | Runs a plugin's `update()` + `display()` and saves the frame as a PNG |
|
||||
| `run_plugin_tests.py` | dev-only | Discovers and runs plugin test suites |
|
||||
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
|
||||
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
|
||||
| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo |
|
||||
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
|
||||
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
|
||||
|
||||
## Candidates for removal
|
||||
|
||||
Nothing in the repo (docs, CI, tests, other scripts or code) refers to these.
|
||||
They are kept for now; each one needs an owner decision before it goes.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `add_defaults_to_schemas.py` | One-off: adds missing `default` values to plugin config schemas |
|
||||
| `analyze_plugin_schemas.py` | One-off: reports duplicate/inconsistent fields across plugin schemas |
|
||||
| `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it |
|
||||
| `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` |
|
||||
| `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing |
|
||||
| `test_captive_portal.sh` | Tests the captive portal from a device connected to the AP |
|
||||
| `verify_wifi_before_testing.sh` | Pre-flight check before unplugging Ethernet to test WiFi |
|
||||
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
|
||||
@@ -28,12 +28,12 @@ print_success() {
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
((WARNINGS++))
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
((COMPATIBILITY_ISSUES++))
|
||||
COMPATIBILITY_ISSUES=$((COMPATIBILITY_ISSUES + 1))
|
||||
}
|
||||
|
||||
# Check if running on Raspberry Pi
|
||||
@@ -61,20 +61,18 @@ if [ -f /etc/os-release ]; then
|
||||
echo "OS: $PRETTY_NAME"
|
||||
echo "Version ID: ${VERSION_ID:-unknown}"
|
||||
|
||||
# first_time_install.sh refuses anything but Raspberry Pi OS / Debian 13
|
||||
# (Trixie), so anything else is an error here too, not a warning.
|
||||
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
|
||||
if [ "${VERSION_ID:-0}" -ge "12" ]; then
|
||||
print_success "Running compatible Debian/Raspbian version (${VERSION_ID})"
|
||||
|
||||
if [ "${VERSION_ID:-0}" -eq "13" ]; then
|
||||
print_success "Detected Debian 13 Trixie - full compatibility expected"
|
||||
elif [ "${VERSION_ID:-0}" -eq "12" ]; then
|
||||
print_success "Detected Debian 12 Bookworm - full compatibility confirmed"
|
||||
fi
|
||||
if [ "${VERSION_ID:-0}" = "13" ]; then
|
||||
print_success "Detected Debian 13 Trixie - supported"
|
||||
elif [ "${VERSION_ID:-0}" = "12" ]; then
|
||||
print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||
else
|
||||
print_warning "Old Debian/Raspbian version (${VERSION_ID}) - upgrade recommended"
|
||||
print_error "Debian/Raspbian ${VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||
fi
|
||||
else
|
||||
print_warning "Not running Debian/Raspbian - compatibility not guaranteed"
|
||||
print_error "${ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||
fi
|
||||
else
|
||||
print_error "Could not detect OS version"
|
||||
|
||||
@@ -6,6 +6,8 @@ This directory contains scripts and utilities for development and testing.
|
||||
|
||||
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
|
||||
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
|
||||
- **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio)
|
||||
- **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`)
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
+17
-10
@@ -18,21 +18,26 @@ system user.
|
||||
permissions on the `assets/` tree so plugins can download and cache
|
||||
team logos, fonts, and other static content.
|
||||
|
||||
- **`fix_cache_permissions.sh`** — Creates (if missing) and fixes
|
||||
permissions on `/var/cache/ledmatrix/` and `~/.ledmatrix_cache/` of the
|
||||
user running `sudo`, and creates
|
||||
`/var/cache/ledmatrix/placeholder_logos/` for the sports plugins. It does
|
||||
not touch the cache manager's other fallbacks (`/opt/ledmatrix/cache`,
|
||||
`$TMPDIR/ledmatrix_cache`).
|
||||
- **`fix_cache_permissions.sh`** — Restores `/var/cache/ledmatrix/` to the
|
||||
shared `ledmatrix`-group setup by running
|
||||
`scripts/install/setup_cache.sh` (the same script the installer uses),
|
||||
and creates/fixes `~/.ledmatrix_cache/` of the user running `sudo`. It
|
||||
does not touch the cache manager's other fallbacks
|
||||
(`/opt/ledmatrix/cache`, `$TMPDIR/ledmatrix_cache`).
|
||||
|
||||
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
||||
directory so both the root display service and the web service user
|
||||
can read and write plugin files (manifests, configs, requirements
|
||||
installs).
|
||||
|
||||
- **`fix_web_permissions.sh`** — Fixes permissions on log files,
|
||||
systemd journal access, and the sudoers entries the web interface
|
||||
needs to control the display service.
|
||||
- **`fix_web_permissions.sh`** — Adds you to the `systemd-journal` and
|
||||
`adm` groups so the web UI can read logs, and makes the project
|
||||
directory yours again, keeping the root-owned sudo helpers
|
||||
(`safe_plugin_rm.sh`, `safe_pip_install.sh`) and `config_secrets.json`
|
||||
the way the installer leaves them. Run it as the web interface's user,
|
||||
**without** `sudo` (it refuses to run as root and calls `sudo` itself).
|
||||
It does not write sudoers rules; that is
|
||||
`scripts/install/configure_web_sudo.sh`.
|
||||
|
||||
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
|
||||
after checking it is the project's own or one under `plugin-repos/` or
|
||||
@@ -67,7 +72,9 @@ Run these scripts only when:
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh
|
||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh
|
||||
sudo ./scripts/fix_perms/fix_web_permissions.sh
|
||||
|
||||
# Run as the web interface's user, without sudo (it asks for sudo itself)
|
||||
./scripts/fix_perms/fix_web_permissions.sh
|
||||
```
|
||||
|
||||
If you're not sure which one you need, run `fix_cache_permissions.sh`
|
||||
|
||||
@@ -36,11 +36,12 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set permissions to allow read/write for owner, group, and others (for root service user)
|
||||
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
|
||||
# 777: read/write for owner, group and every other account. Root (the display
|
||||
# service) does not need it -- root ignores mode bits -- so the "other" bits
|
||||
# only matter to accounts that are neither $REAL_USER nor root.
|
||||
echo "Setting permissions for assets directory..."
|
||||
if sudo chmod -R 777 "$ASSETS_DIR"; then
|
||||
echo "✓ Set assets directory permissions to 777 (writable by root service user)"
|
||||
echo "✓ Set assets directory permissions to 777"
|
||||
else
|
||||
echo "✗ Failed to set assets directory permissions"
|
||||
exit 1
|
||||
@@ -70,8 +71,7 @@ for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
|
||||
echo " - Current permissions:"
|
||||
ls -ld "$FULL_PATH"
|
||||
|
||||
# Ensure the directory is writable by both the real user and root (service user)
|
||||
# Use 777 permissions to allow root (service) to write, or set group ownership
|
||||
# Owned by the real user; 777 as above (root can write here regardless)
|
||||
sudo chmod 777 "$FULL_PATH"
|
||||
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
|
||||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# LEDMatrix Cache Permissions Fix Script
|
||||
# This script fixes permissions on all known cache directories so they're writable by the daemon or current user
|
||||
# Also sets up placeholder logo directories for sports managers
|
||||
#
|
||||
# /var/cache/ledmatrix is shared by the display service (root) and the web
|
||||
# interface (your user) through the ledmatrix group: root:ledmatrix, 2775,
|
||||
# files 660. scripts/install/setup_cache.sh is what sets that up (the
|
||||
# installer's Step 2 runs it, and install_web_service.sh keeps the group), so
|
||||
# this script runs it rather than applying a model of its own. It used to set
|
||||
# the directory 777 and re-group it to your own group, replacing the ledmatrix
|
||||
# group everything else relies on.
|
||||
#
|
||||
# It also repairs ~/.ledmatrix_cache, the cache manager's fallback when
|
||||
# /var/cache/ledmatrix is unusable.
|
||||
|
||||
echo "Fixing LEDMatrix cache directory permissions..."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SETUP_CACHE="$SCRIPT_DIR/../install/setup_cache.sh"
|
||||
|
||||
# Get the real user (not root when running with sudo)
|
||||
REAL_USER=${SUDO_USER:-$USER}
|
||||
# Resolve the home directory of the real user robustly
|
||||
@@ -16,72 +28,36 @@ else
|
||||
fi
|
||||
REAL_GROUP=$(id -gn "$REAL_USER")
|
||||
|
||||
# Known cache directories for LEDMatrix. Use the actual user's home instead of a hard-coded path.
|
||||
CACHE_DIRS=(
|
||||
"/var/cache/ledmatrix"
|
||||
"$REAL_HOME/.ledmatrix_cache"
|
||||
)
|
||||
|
||||
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
|
||||
echo ""
|
||||
echo "Checking cache directory: $CACHE_DIR"
|
||||
if [ ! -d "$CACHE_DIR" ]; then
|
||||
echo " - Directory does not exist. Creating it..."
|
||||
sudo mkdir -p "$CACHE_DIR"
|
||||
fi
|
||||
echo " - Current permissions:"
|
||||
ls -ld "$CACHE_DIR"
|
||||
echo " - Fixing permissions..."
|
||||
# Make directory writable by services regardless of user context
|
||||
sudo chmod 777 "$CACHE_DIR"
|
||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
|
||||
echo " - Updated permissions:"
|
||||
ls -ld "$CACHE_DIR"
|
||||
echo " - Testing write access as $REAL_USER..."
|
||||
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
|
||||
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
|
||||
else
|
||||
echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
|
||||
fi
|
||||
echo " - Permissions fix complete for $CACHE_DIR."
|
||||
done
|
||||
|
||||
# Set up placeholder logos directory for sports managers
|
||||
echo ""
|
||||
echo "Setting up placeholder logos directory for sports managers..."
|
||||
|
||||
PLACEHOLDER_DIR="/var/cache/ledmatrix/placeholder_logos"
|
||||
if [ ! -d "$PLACEHOLDER_DIR" ]; then
|
||||
echo "Creating placeholder logos directory: $PLACEHOLDER_DIR"
|
||||
sudo mkdir -p "$PLACEHOLDER_DIR"
|
||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
|
||||
sudo chmod 777 "$PLACEHOLDER_DIR"
|
||||
echo "Checking cache directory: /var/cache/ledmatrix"
|
||||
if [ -f "$SETUP_CACHE" ]; then
|
||||
bash "$SETUP_CACHE"
|
||||
else
|
||||
echo "Placeholder logos directory already exists: $PLACEHOLDER_DIR"
|
||||
sudo chmod 777 "$PLACEHOLDER_DIR"
|
||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
|
||||
echo " ✗ $SETUP_CACHE not found; /var/cache/ledmatrix left unchanged."
|
||||
fi
|
||||
|
||||
CACHE_DIR="$REAL_HOME/.ledmatrix_cache"
|
||||
echo ""
|
||||
echo "Checking cache directory: $CACHE_DIR"
|
||||
if [ ! -d "$CACHE_DIR" ]; then
|
||||
echo " - Directory does not exist. Creating it..."
|
||||
sudo mkdir -p "$CACHE_DIR"
|
||||
fi
|
||||
echo " - Current permissions:"
|
||||
ls -ld "$PLACEHOLDER_DIR"
|
||||
ls -ld "$CACHE_DIR"
|
||||
echo " - Fixing permissions..."
|
||||
sudo chmod 777 "$CACHE_DIR"
|
||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
|
||||
echo " - Updated permissions:"
|
||||
ls -ld "$CACHE_DIR"
|
||||
echo " - Testing write access as $REAL_USER..."
|
||||
if sudo -u "$REAL_USER" test -w "$PLACEHOLDER_DIR"; then
|
||||
echo " ✓ Placeholder logos directory is writable by $REAL_USER"
|
||||
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
|
||||
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
|
||||
else
|
||||
echo " ✗ Placeholder logos directory is not writable by $REAL_USER"
|
||||
fi
|
||||
|
||||
# Test with daemon user (which the system might run as)
|
||||
if sudo -u daemon test -w "$PLACEHOLDER_DIR" 2>/dev/null; then
|
||||
echo " ✓ Placeholder logos directory is writable by daemon user"
|
||||
else
|
||||
echo " ✗ Placeholder logos directory is not writable by daemon user"
|
||||
echo " ✗ $CACHE_DIR is still not writable by $REAL_USER"
|
||||
fi
|
||||
echo " - Permissions fix complete for $CACHE_DIR."
|
||||
|
||||
echo ""
|
||||
echo "All cache directory permission fixes attempted."
|
||||
echo "If you still see errors, check which user is running the LEDMatrix service and ensure it matches the owner above."
|
||||
echo ""
|
||||
echo "The system will now create placeholder logos in:"
|
||||
echo " $PLACEHOLDER_DIR"
|
||||
echo "This should eliminate the permission denied warnings for sports logos."
|
||||
@@ -51,9 +51,10 @@ fi
|
||||
echo "Setting ownership to root:$ACTUAL_USER..."
|
||||
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
|
||||
|
||||
# Set directory permissions (775: rwxrwxr-x)
|
||||
# Root: read/write/execute, Group (ACTUAL_USER): read/write/execute, Others: read/execute
|
||||
echo "Setting directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
|
||||
# Set directory permissions (2775: rwxrwsr-x)
|
||||
# Owner (root) and group (ACTUAL_USER): read/write/execute, others: read/execute.
|
||||
# The setgid bit makes new entries inherit the ACTUAL_USER group.
|
||||
echo "Setting directory permissions to 2775 (rwxrwsr-x, setgid)..."
|
||||
find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \;
|
||||
|
||||
# Set file permissions (664: rw-rw-r--)
|
||||
@@ -71,7 +72,7 @@ fi
|
||||
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
|
||||
sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
||||
|
||||
echo "Setting plugin-repos directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
|
||||
echo "Setting plugin-repos directory permissions to 2775 (rwxrwsr-x, setgid)..."
|
||||
find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \;
|
||||
|
||||
echo "Setting plugin-repos file permissions to 664..."
|
||||
@@ -87,7 +88,7 @@ echo "plugin-repos/:"
|
||||
ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)"
|
||||
echo ""
|
||||
echo "Permissions summary:"
|
||||
echo "- Root service: Can read/write plugins (for PWM hardware access)"
|
||||
echo "- Root service: Can read/write plugins (as root it needs no permission bits)"
|
||||
echo "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)"
|
||||
echo "- Others: Can read plugins"
|
||||
|
||||
|
||||
@@ -25,8 +25,9 @@ echo ""
|
||||
echo "This script will:"
|
||||
echo "1. Add the web user to the 'systemd-journal' group for log access"
|
||||
echo "2. Add the web user to the 'adm' group for additional system access"
|
||||
echo "3. Configure sudoers for passwordless access to system commands"
|
||||
echo "4. Set proper file permissions"
|
||||
echo "3. Make the project directory yours again, keeping the root-owned sudo"
|
||||
echo " helpers and config_secrets.json as the installer leaves them"
|
||||
echo " (sudoers rules are configure_web_sudo.sh's job, not this script's)"
|
||||
echo ""
|
||||
|
||||
# Ask for confirmation
|
||||
@@ -62,6 +63,51 @@ else
|
||||
echo "✗ Failed to set project ownership"
|
||||
fi
|
||||
|
||||
# The chown above also takes back two kinds of file that first_time_install.sh
|
||||
# deliberately keeps from the web user. Put them back the way the installer
|
||||
# leaves them (its Steps 11 and 11.1), whether or not the chown succeeded.
|
||||
#
|
||||
# 1. The helpers /etc/sudoers.d/ledmatrix_web lets the web user run as root
|
||||
# (scripts/install/lib_sudoers.sh). A copy the web user owns is a root shell
|
||||
# for whoever can edit it, so they stay root-owned and writable by root only.
|
||||
# Keep this list in step with the installer's Step 11.1 loop;
|
||||
# test/test_web_sudoers_installers_agree.py checks both against the grants.
|
||||
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
|
||||
HELPER_PATH="$PROJECT_DIR/scripts/fix_perms/$helper"
|
||||
if [ -f "$HELPER_PATH" ]; then
|
||||
if sudo chown root:root "$HELPER_PATH" && sudo chmod 755 "$HELPER_PATH"; then
|
||||
echo "✓ $helper is root-owned again (sudo runs it as root)"
|
||||
else
|
||||
echo "⚠ Could not make $HELPER_PATH root-owned, mode 755."
|
||||
echo " Fix it by hand: sudo chown root:root $HELPER_PATH && sudo chmod 755 $HELPER_PATH"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. config_secrets.json: owned by the account ledmatrix-web.service runs as,
|
||||
# group ledmatrix, mode 640 -- the same owner, group and mode as the
|
||||
# installer's Step 11 gives it.
|
||||
SECRETS_FILE="$PROJECT_DIR/config/config_secrets.json"
|
||||
if [ -f "$SECRETS_FILE" ]; then
|
||||
SECRETS_OWNER=""
|
||||
if [ -f /etc/systemd/system/ledmatrix-web.service ]; then
|
||||
SECRETS_OWNER=$(grep -m1 "^User=" /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || true)
|
||||
fi
|
||||
SECRETS_OWNER="${SECRETS_OWNER:-$WEB_USER}"
|
||||
if getent group ledmatrix >/dev/null 2>&1; then
|
||||
SECRETS_OWNERSHIP="$SECRETS_OWNER:ledmatrix"
|
||||
else
|
||||
# No ledmatrix group means the installer never ran; keep the chown's group.
|
||||
SECRETS_OWNERSHIP="$SECRETS_OWNER"
|
||||
fi
|
||||
if sudo chown "$SECRETS_OWNERSHIP" "$SECRETS_FILE" && sudo chmod 640 "$SECRETS_FILE"; then
|
||||
echo "✓ config_secrets.json restored to $SECRETS_OWNERSHIP, mode 640"
|
||||
else
|
||||
echo "⚠ Could not restore $SECRETS_FILE to $SECRETS_OWNERSHIP, mode 640."
|
||||
echo " Fix it by hand: sudo chown $SECRETS_OWNERSHIP $SECRETS_FILE && sudo chmod 640 $SECRETS_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set proper permissions for config files
|
||||
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
|
||||
echo "✓ Set config file permissions"
|
||||
@@ -86,7 +132,7 @@ echo "Step 5: Testing sudo access..."
|
||||
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
|
||||
echo "✓ Sudo access test passed"
|
||||
else
|
||||
echo "⚠ Sudo access test failed - you may need to run configure_web_sudo.sh"
|
||||
echo "⚠ Sudo access test failed - you may need to run scripts/install/configure_web_sudo.sh"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -101,5 +147,5 @@ echo ""
|
||||
echo "After logging back in, test journal access with:"
|
||||
echo " journalctl --no-pager --lines=5"
|
||||
echo ""
|
||||
echo "If you still have sudo issues, run:"
|
||||
echo " ./configure_web_sudo.sh"
|
||||
echo "If you still have sudo issues, run (as this user, without sudo):"
|
||||
echo " $PROJECT_DIR/scripts/install/configure_web_sudo.sh"
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Soak a running display and report how often moving frames reached the panel late.
|
||||
|
||||
Runs NEXT TO the display service, as any user: it only reads the stats file the
|
||||
service writes (src/common/frame_timing.py) at the start and end of the run and
|
||||
reports the difference. Nothing is stopped, restarted or drawn.
|
||||
|
||||
# 10 minutes, as the display is now
|
||||
python3 scripts/frame_soak.py
|
||||
|
||||
# the same with the web preview open (the preview's PNG encodes are one of
|
||||
# the things that used to make the render loop miss refreshes)
|
||||
python3 scripts/frame_soak.py --preview
|
||||
|
||||
# quick look at the totals since the service started
|
||||
python3 scripts/frame_soak.py --show
|
||||
|
||||
# keep the report for a before/after comparison
|
||||
python3 scripts/frame_soak.py --duration 600 --json soak-before.json
|
||||
|
||||
Exit status: 0 when the late-frame rate is within ``--max-late-pct``, 1 when it
|
||||
is not, 2 when there was nothing to measure (no stats file, the service
|
||||
restarted mid-run, or nothing scrolled).
|
||||
|
||||
What the numbers mean
|
||||
---------------------
|
||||
late frames frames that reached the panel one or more refreshes after they
|
||||
were due -- the panel showed the previous frame again, which on
|
||||
a moving strip is a visible hitch. This is the pass/fail number.
|
||||
freezes gaps of 250ms+ inside a scroll: recomposes, plugin handovers,
|
||||
blocking calls on the render thread. Reported, not failed on,
|
||||
since some are handovers between plugins rather than faults.
|
||||
blit copying the frame into the matrix canvas (rgbmatrix SetImage).
|
||||
Grows with width x height x pwm_bits.
|
||||
wait blocked in SwapOnVSync, i.e. slack before the refresh.
|
||||
work everything else between two frames: drawing, scrolling, and
|
||||
waiting for the GIL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.common.frame_timing import ( # noqa: E402
|
||||
BUCKET_COUNT,
|
||||
SCHEMA_VERSION,
|
||||
default_stats_path,
|
||||
)
|
||||
|
||||
#: Touched by the web UI while someone has the preview open; a fresh marker
|
||||
#: puts the display service's snapshot writer at full rate. Same path as
|
||||
#: DisplayManager._viewer_marker_path.
|
||||
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
|
||||
|
||||
#: A stats file not rewritten for this long means nothing is being presented.
|
||||
STALE_SECONDS = 30.0
|
||||
|
||||
|
||||
def load(path: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
stats = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(stats, dict) or stats.get("version") != SCHEMA_VERSION:
|
||||
return None
|
||||
return stats
|
||||
|
||||
|
||||
def _histogram(stats: Dict[str, Any], name: str) -> Dict[int, int]:
|
||||
raw = (stats.get("histograms") or {}).get(name) or {}
|
||||
return {int(k): int(v) for k, v in raw.items()}
|
||||
|
||||
|
||||
def diff(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""What happened between two snapshots of the same process."""
|
||||
tb, ta = before["totals"], after["totals"]
|
||||
totals = {}
|
||||
for key, value in ta.items():
|
||||
if isinstance(value, dict):
|
||||
totals[key] = {k: v - tb.get(key, {}).get(k, 0)
|
||||
for k, v in value.items()}
|
||||
elif key == "worst_interval_ms":
|
||||
# A running maximum can't be differenced; it is reported as the
|
||||
# worst since the service started.
|
||||
totals[key] = value
|
||||
else:
|
||||
totals[key] = value - tb.get(key, 0)
|
||||
histograms = {}
|
||||
for name in (after.get("histograms") or {}):
|
||||
hb, ha = _histogram(before, name), _histogram(after, name)
|
||||
histograms[name] = {k: v - hb.get(k, 0) for k, v in ha.items()
|
||||
if v - hb.get(k, 0) > 0}
|
||||
return {"totals": totals, "histograms": histograms,
|
||||
"seconds": after["updated"] - before["updated"]}
|
||||
|
||||
|
||||
def percentiles(histogram: Dict[int, int], bucket_ms: float) -> Dict[str, Any]:
|
||||
"""p50/p95/p99/max from a sparse histogram, as each bucket's upper edge."""
|
||||
count = sum(histogram.values())
|
||||
if not count:
|
||||
return {}
|
||||
out = {}
|
||||
targets = {"p50": 0.50, "p95": 0.95, "p99": 0.99}
|
||||
running = 0
|
||||
for index in sorted(histogram):
|
||||
running += histogram[index]
|
||||
for name, fraction in list(targets.items()):
|
||||
if running >= fraction * count:
|
||||
out[name] = _edge(index, bucket_ms)
|
||||
del targets[name]
|
||||
out["max"] = _edge(max(histogram), bucket_ms)
|
||||
return out
|
||||
|
||||
|
||||
def _edge(index: int, bucket_ms: float):
|
||||
if index >= BUCKET_COUNT - 1:
|
||||
return f">={index * bucket_ms:g}"
|
||||
return round((index + 1) * bucket_ms, 2)
|
||||
|
||||
|
||||
def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||
delta = diff(before, after)
|
||||
totals = delta["totals"]
|
||||
frames = totals["scroll_frames"]
|
||||
# The rates are over frames judged against a known refresh period. Stats
|
||||
# from a recorder that predates the count fall back to every frame.
|
||||
timed = totals.get("timed_frames", frames) if "timed_frames" in totals else frames
|
||||
hours = delta["seconds"] / 3600.0 if delta["seconds"] > 0 else 0.0
|
||||
bucket_ms = after.get("bucket_ms", 0.25)
|
||||
report = {
|
||||
"seconds": round(delta["seconds"], 1),
|
||||
"preview": preview,
|
||||
"info": after.get("info"),
|
||||
"binding_releases_gil": after.get("binding_releases_gil"),
|
||||
"measured_refresh_hz": after.get("measured_refresh_hz"),
|
||||
"scroll_frames": frames,
|
||||
"static_frames": totals["static_frames"],
|
||||
"late_frames": totals["late_frames"],
|
||||
"timed_frames": timed,
|
||||
"late_pct": round(100.0 * totals["late_frames"] / timed, 3) if timed else None,
|
||||
"missed_refreshes": totals["missed_refreshes"],
|
||||
"late_by": totals["late_by"],
|
||||
"early_frames": totals.get("early_frames", 0),
|
||||
"early_pct": (round(100.0 * totals.get("early_frames", 0) / timed, 3)
|
||||
if timed else None),
|
||||
"freeze_by": totals.get("freeze_by", {}),
|
||||
"freezes": totals["freezes"],
|
||||
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
|
||||
"freeze_seconds": round(totals["freeze_seconds"], 2),
|
||||
"worst_interval_ms": (round(totals["worst_interval_ms"], 1)
|
||||
if totals["worst_interval_ms"] else None),
|
||||
"timing_ms": {name: percentiles(h, bucket_ms)
|
||||
for name, h in delta["histograms"].items()},
|
||||
}
|
||||
# The rate the panel held while rendering: the typical frame's interval
|
||||
# per refresh held. A few percent under the idle rate is normal (the Pi is
|
||||
# bit-banging the panel and pushing frames at once); a widening gap between
|
||||
# the two is a render-cost regression even when nothing is late.
|
||||
typical = (report["timing_ms"].get("interval_per_hold") or {}).get("p50")
|
||||
# percentiles() reports a bucket's upper edge; the midpoint is the better
|
||||
# estimate, and half a 0.25ms bucket is already ~1% at 100Hz -- the size
|
||||
# of the idle-vs-held gap this number exists to show.
|
||||
if isinstance(typical, (int, float)) and typical > bucket_ms / 2:
|
||||
report["held_refresh_hz"] = round(1000.0 / (typical - bucket_ms / 2), 1)
|
||||
else:
|
||||
report["held_refresh_hz"] = None
|
||||
return report
|
||||
|
||||
|
||||
def print_report(report: Dict[str, Any], limit: float) -> None:
|
||||
info = report.get("info") or {}
|
||||
size = "{}x{}".format(
|
||||
(info.get("cols") or 0) * (info.get("chain_length") or 1),
|
||||
(info.get("rows") or 0) * (info.get("parallel") or 1))
|
||||
gil = {True: "releases the GIL", False: "STOCK (holds the GIL in SwapOnVSync)",
|
||||
None: "unknown"}[report.get("binding_releases_gil")]
|
||||
print(f"Rig {info.get('pi_model') or 'unknown'}")
|
||||
print(f"Panel {size} chain {info.get('chain_length')} x parallel "
|
||||
f"{info.get('parallel')} pwm_bits {info.get('pwm_bits')} "
|
||||
f"slowdown {info.get('gpio_slowdown')} mapping {info.get('hardware_mapping')}")
|
||||
print(f"Refresh {report.get('measured_refresh_hz') or '?'} Hz measured, "
|
||||
f"cap {info.get('limit_refresh_rate_hz')}")
|
||||
print(f"Binding {gil}")
|
||||
print(f"Run {report['seconds']:.0f}s, preview "
|
||||
f"{'open (simulated)' if report['preview'] else 'as-is'}")
|
||||
print()
|
||||
frames = report["scroll_frames"]
|
||||
print(f"Scrolling frames {frames}")
|
||||
if frames:
|
||||
late_by = report["late_by"]
|
||||
print(f"Late frames {report['late_frames']} ({report['late_pct']}%)"
|
||||
f" missed refreshes {report['missed_refreshes']}"
|
||||
f" [by 1: {late_by['1']}, 2: {late_by['2']}, "
|
||||
f"3-5: {late_by['3-5']}, 6+: {late_by['6+']}]")
|
||||
if report["early_frames"]:
|
||||
print(f"Early frames {report['early_frames']} "
|
||||
f"({report['early_pct']}%) swaps returned a refresh early")
|
||||
print(f"Freezes >=250ms {report['freezes']}"
|
||||
f" ({report['freezes_per_hour']}/h, {report['freeze_seconds']}s total)"
|
||||
f" worst gap since start {report['worst_interval_ms'] or '-'} ms")
|
||||
if report["freezes"]:
|
||||
print(" by length: " + ", ".join(
|
||||
f"{k}: {v}" for k, v in report["freeze_by"].items()))
|
||||
print()
|
||||
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
|
||||
for name in ("blit", "wait", "work", "interval_per_hold"):
|
||||
row = report["timing_ms"].get(name) or {}
|
||||
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
|
||||
for k in ("p50", "p95", "p99", "max")))
|
||||
print()
|
||||
if report["late_pct"] is None:
|
||||
print("RESULT nothing scrolled - no verdict")
|
||||
elif not locked(report, limit):
|
||||
ceiling = refresh_ceiling(report)
|
||||
if (report.get("early_pct") or 0.0) > limit:
|
||||
why = (f"{report['early_pct']}% of frames came a refresh early, so the "
|
||||
"swaps were not waiting for the panel")
|
||||
else:
|
||||
why = (f"frames arrived at {report['measured_refresh_hz']}Hz, faster than "
|
||||
f"the panel can refresh ({ceiling:g}Hz)")
|
||||
print(f"RESULT FAIL NOT LOCKED: {why}, and the late count means nothing")
|
||||
elif report["late_pct"] <= limit:
|
||||
print(f"RESULT PASS {report['late_pct']}% late <= {limit}%")
|
||||
else:
|
||||
print(f"RESULT FAIL {report['late_pct']}% late > {limit}%")
|
||||
|
||||
|
||||
#: How far over the panel's rate frames may arrive before the loop cannot have
|
||||
#: been waiting for it. The margin covers the refresh wandering a little.
|
||||
CEILING_MARGIN = 1.05
|
||||
|
||||
|
||||
def refresh_ceiling(report: Dict[str, Any]) -> Optional[float]:
|
||||
"""The fastest the panel can refresh, as far as this run knows.
|
||||
|
||||
The benchmark measures it (``idle_refresh_hz``); the service only knows its
|
||||
cap. With neither, there is no ceiling to check against.
|
||||
"""
|
||||
idle = report.get("idle_refresh_hz")
|
||||
if idle:
|
||||
return float(idle)
|
||||
cap = (report.get("info") or {}).get("limit_refresh_rate_hz")
|
||||
try:
|
||||
cap = float(cap)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return cap if cap > 0 else None
|
||||
|
||||
|
||||
def locked(report: Dict[str, Any], limit: float) -> bool:
|
||||
"""Whether the loop was paced by the panel at all.
|
||||
|
||||
Two ways it is not. Frames a whole refresh early mean some swaps did not
|
||||
wait. And a loop that never waited at all -- the dirty-tracking skip firing
|
||||
mid-scroll let one free-run at 827fps -- looks self-consistent to a refresh
|
||||
estimate taken from its own frames, so nothing registers as early; what
|
||||
gives it away is a "refresh" faster than the panel can physically do.
|
||||
"""
|
||||
if (report.get("early_pct") or 0.0) > limit:
|
||||
return False
|
||||
ceiling = refresh_ceiling(report)
|
||||
measured = report.get("measured_refresh_hz")
|
||||
return not (ceiling and measured and measured > ceiling * CEILING_MARGIN)
|
||||
|
||||
|
||||
def passed(report: Dict[str, Any], limit: float) -> bool:
|
||||
return (report["late_pct"] is not None and locked(report, limit)
|
||||
and report["late_pct"] <= limit)
|
||||
|
||||
|
||||
def touch_marker() -> bool:
|
||||
try:
|
||||
with open(VIEWER_MARKER, "a"):
|
||||
pass
|
||||
os.utime(VIEWER_MARKER, None)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_fresh(path: str, timeout: float) -> Optional[Dict[str, Any]]:
|
||||
"""The first snapshot written after now, so both ends of the run are exact."""
|
||||
first = load(path)
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
current = load(path)
|
||||
if current and (first is None or current["updated"] != first["updated"]):
|
||||
return current
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
parser.add_argument("--duration", type=float, default=600.0,
|
||||
help="seconds to soak (default 600)")
|
||||
parser.add_argument("--preview", action="store_true",
|
||||
help="keep the web-preview viewer marker fresh, as an "
|
||||
"open preview tab does")
|
||||
parser.add_argument("--max-late-pct", type=float, default=0.1,
|
||||
help="fail above this percentage of late frames (default 0.1)")
|
||||
parser.add_argument("--stats", default=default_stats_path(),
|
||||
help="stats file written by the display service")
|
||||
parser.add_argument("--json", metavar="PATH",
|
||||
help="also write the report as JSON")
|
||||
parser.add_argument("--show", action="store_true",
|
||||
help="print totals since the service started and exit")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
current = load(args.stats)
|
||||
if current is None:
|
||||
print(f"No frame stats at {args.stats}. Is the display service running a "
|
||||
"build with frame timing, and has anything scrolled for ~10s?",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if time.time() - current["updated"] > STALE_SECONDS:
|
||||
print(f"Frame stats are {time.time() - current['updated']:.0f}s old: nothing "
|
||||
"has been presented recently (static screen, or the service stopped).",
|
||||
file=sys.stderr)
|
||||
if not args.show:
|
||||
return 2
|
||||
|
||||
if args.show:
|
||||
empty = json.loads(json.dumps(current))
|
||||
for key, value in empty["totals"].items():
|
||||
empty["totals"][key] = ({k: 0 for k in value} if isinstance(value, dict)
|
||||
else 0)
|
||||
empty["histograms"] = {}
|
||||
empty["updated"] = current["started"]
|
||||
report = build_report(empty, current, preview=False)
|
||||
print_report(report, args.max_late_pct)
|
||||
return 0
|
||||
|
||||
if args.preview and not touch_marker():
|
||||
print(f"Cannot touch {VIEWER_MARKER}; run as the web service's user to "
|
||||
"simulate an open preview.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Waiting for a fresh baseline from {args.stats} ...", flush=True)
|
||||
before = wait_for_fresh(args.stats, timeout=60.0)
|
||||
if before is None:
|
||||
print("The stats file stopped updating.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
end = time.time() + args.duration
|
||||
next_progress = time.time() + 60.0
|
||||
while time.time() < end:
|
||||
if args.preview:
|
||||
touch_marker()
|
||||
time.sleep(1.0)
|
||||
if time.time() >= next_progress:
|
||||
now = load(args.stats)
|
||||
if now and now.get("pid") == before["pid"]:
|
||||
done = now["totals"]["scroll_frames"] - before["totals"]["scroll_frames"]
|
||||
late = now["totals"]["late_frames"] - before["totals"]["late_frames"]
|
||||
print(f" {int(end - time.time())}s left: {done} scrolling frames, "
|
||||
f"{late} late", flush=True)
|
||||
next_progress += 60.0
|
||||
|
||||
after = wait_for_fresh(args.stats, timeout=60.0)
|
||||
if after is None:
|
||||
print("The stats file stopped updating during the run.", file=sys.stderr)
|
||||
return 2
|
||||
if after.get("pid") != before.get("pid"):
|
||||
print("The display service restarted during the run; results discarded.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
report = build_report(before, after, preview=args.preview)
|
||||
print()
|
||||
print_report(report, args.max_late_pct)
|
||||
if args.json:
|
||||
with open(args.json, "w", encoding="utf-8") as handle:
|
||||
json.dump(report, handle, indent=2)
|
||||
if report["late_pct"] is None:
|
||||
return 2
|
||||
return 0 if passed(report, args.max_late_pct) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -19,6 +19,21 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
|
||||
(the user who runs the script, i.e. the one you installed LEDMatrix as;
|
||||
there is no `ledmatrix` system user) the passwordless `nmcli` and related
|
||||
WiFi permissions the web interface needs
|
||||
- **`install_dns_fix.sh`** - Optional. Installs `ledmatrix-dns-fix.service`,
|
||||
which adds `options single-request` to the resolver when API calls time
|
||||
out (see `systemd/README.md`)
|
||||
- **`install_mqtt_bridge.sh`** - Optional. Installs the Home Assistant MQTT
|
||||
bridge service (see `integrations/mqtt_bridge/README.md`)
|
||||
|
||||
Libraries (sourced, not run):
|
||||
|
||||
- **`lib_sudoers.sh`** - The web interface's sudo allow-list
|
||||
(`/etc/sudoers.d/ledmatrix_web`), shared by `first_time_install.sh` and
|
||||
`configure_web_sudo.sh`
|
||||
- **`lib_systemd_render.sh`** - `sed_escape_replacement`, used by every
|
||||
script that renders a unit from `systemd/*.service`
|
||||
- **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++
|
||||
build on low-memory Pis (`first_time_install.sh` Step 6)
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ fi
|
||||
# Get the full paths to commands and validate each one
|
||||
MISSING_CMDS=()
|
||||
|
||||
PYTHON_PATH=$(command -v python3) || true
|
||||
SYSTEMCTL_PATH=$(command -v systemctl) || true
|
||||
REBOOT_PATH=$(command -v reboot) || true
|
||||
POWEROFF_PATH=$(command -v poweroff) || true
|
||||
@@ -35,8 +34,8 @@ JOURNALCTL_PATH=$(command -v journalctl) || true
|
||||
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
||||
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
||||
|
||||
# Validate required commands (systemctl, bash, python3 are essential)
|
||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
||||
# Validate required commands (systemctl and bash are essential)
|
||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH; do
|
||||
CMD_VAL="${!CMD_NAME}"
|
||||
if [ -z "$CMD_VAL" ]; then
|
||||
MISSING_CMDS+=("$CMD_NAME")
|
||||
@@ -59,8 +58,17 @@ if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The rules are shared with first_time_install.sh (Step 10) so the two cannot
|
||||
# drift apart; add or remove a grant in lib_sudoers.sh, not here.
|
||||
SUDOERS_LIB="$PROJECT_DIR/lib_sudoers.sh"
|
||||
if [ ! -f "$SUDOERS_LIB" ]; then
|
||||
echo "Error: Sudoers rules library not found: $SUDOERS_LIB" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=scripts/install/lib_sudoers.sh
|
||||
. "$SUDOERS_LIB"
|
||||
|
||||
echo "Command paths:"
|
||||
echo " Python: $PYTHON_PATH"
|
||||
echo " Systemctl: $SYSTEMCTL_PATH"
|
||||
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
|
||||
echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
||||
@@ -69,62 +77,24 @@ echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
||||
echo " Safe plugin rm: $SAFE_RM_PATH"
|
||||
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
||||
|
||||
# Create a temporary sudoers file
|
||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
# Create a temporary sudoers file. A predictable name in a world-writable
|
||||
# directory is a symlink target, and these rules end up in /etc/sudoers.d, so
|
||||
# let mktemp pick the name; the trap removes it however the script ends.
|
||||
TEMP_SUDOERS=$(mktemp "${TMPDIR:-/tmp}/ledmatrix_web_sudoers.XXXXXX") || {
|
||||
echo "Error: could not create a temporary file" >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "$TEMP_SUDOERS"' EXIT
|
||||
|
||||
{
|
||||
echo "# LED Matrix Web Interface passwordless sudo configuration"
|
||||
echo "# This allows the web interface user to run specific commands without a password"
|
||||
echo ""
|
||||
echo "# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface"
|
||||
|
||||
# Optional: reboot/poweroff (non-critical — skip if not found)
|
||||
if [ -n "$REBOOT_PATH" ]; then
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
|
||||
fi
|
||||
if [ -n "$POWEROFF_PATH" ]; then
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
|
||||
fi
|
||||
|
||||
# Required: systemctl
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||
|
||||
# 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
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
||||
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
||||
echo ""
|
||||
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
|
||||
echo "# helper script, so packages are visible to root-run ledmatrix.service"
|
||||
echo "# (not just the web interface's own user). The helper validates the target"
|
||||
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
|
||||
} > "$TEMP_SUDOERS"
|
||||
web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
||||
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS"
|
||||
|
||||
# Never offer to install rules we have not parsed. A malformed drop-in in
|
||||
# /etc/sudoers.d makes sudo refuse every command for every user.
|
||||
# visudo lives in /usr/sbin, which is not on every user's PATH.
|
||||
if ! command -v visudo >/dev/null 2>&1 && [ -x /usr/sbin/visudo ]; then
|
||||
PATH="$PATH:/usr/sbin"
|
||||
fi
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
||||
echo ""
|
||||
@@ -134,6 +104,8 @@ if command -v visudo >/dev/null 2>&1; then
|
||||
rm -f "$TEMP_SUDOERS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⚠ visudo not found; the rules below have not been validated"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -162,7 +134,7 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Apply the configuration using visudo
|
||||
# Apply the configuration
|
||||
echo "Applying sudoers configuration..."
|
||||
# Harden the helper script: root-owned, not writable by web user
|
||||
echo "Hardening safe_plugin_rm.sh ownership..."
|
||||
@@ -181,21 +153,29 @@ if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
|
||||
fi
|
||||
|
||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||
# sudo reads /etc/sudoers.d files that are root-owned and not writable by
|
||||
# group or other; 440 is the mode visudo and first_time_install.sh use.
|
||||
if ! sudo chmod 440 /etc/sudoers.d/ledmatrix_web; then
|
||||
echo "Warning: could not set mode 440 on /etc/sudoers.d/ledmatrix_web"
|
||||
fi
|
||||
echo "Configuration applied successfully!"
|
||||
echo ""
|
||||
echo "Testing sudo access..."
|
||||
|
||||
# Test a few commands
|
||||
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
|
||||
# Ask sudo whether two of the new rules let this user in without a
|
||||
# password. `sudo -l CMD` answers from the rules without running CMD, so
|
||||
# this does not depend on whether ledmatrix.service is running, and it
|
||||
# tests commands the rules actually grant.
|
||||
if sudo -n -l "$SYSTEMCTL_PATH" status ledmatrix.service > /dev/null 2>&1; then
|
||||
echo "✓ systemctl status ledmatrix.service - OK"
|
||||
else
|
||||
echo "✗ systemctl status ledmatrix.service - Failed"
|
||||
echo "✗ systemctl status ledmatrix.service - not allowed without a password"
|
||||
fi
|
||||
|
||||
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
|
||||
echo "✓ File access test - OK"
|
||||
|
||||
if sudo -n -l "$BASH_PATH" "$SAFE_RM_PATH" "$PROJECT_ROOT/plugin-repos/example" > /dev/null 2>&1; then
|
||||
echo "✓ safe_plugin_rm.sh helper - OK"
|
||||
else
|
||||
echo "✗ File access test - Failed"
|
||||
echo "✗ safe_plugin_rm.sh helper - not allowed without a password"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -144,6 +144,11 @@ $WEB_USER ALL=(ALL) NOPASSWD: $MKDIR_PATH -p /etc/NetworkManager/dnsmasq-shared.
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
|
||||
# The same captive-portal DNS drop-in for NetworkManager's shared-mode dnsmasq
|
||||
# (wifi_manager._write_nm_dnsmasq_captive_conf / _remove_nm_dnsmasq_captive_conf),
|
||||
# exact paths.
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/ledmatrix-nm-dnsmasq.conf /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf
|
||||
EOF
|
||||
|
||||
echo "Generated sudoers configuration:"
|
||||
@@ -151,6 +156,21 @@ echo "--------------------------------"
|
||||
cat "$TEMP_SUDOERS"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Never install rules we have not parsed. A malformed drop-in in
|
||||
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
|
||||
# headless Pi leaves no way in at all. first_time_install.sh and
|
||||
# configure_web_sudo.sh check their rules the same way.
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
||||
echo "✗ The generated sudoers rules did not parse:" >&2
|
||||
visudo -c -f "$TEMP_SUDOERS" >&2 || true
|
||||
echo " Leaving $SUDOERS_FILE unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⚠ visudo not found; installing the sudoers rules unvalidated"
|
||||
fi
|
||||
|
||||
# Apply the sudoers configuration
|
||||
echo ""
|
||||
echo "Applying sudoers configuration..."
|
||||
@@ -213,11 +233,14 @@ rm -f "$TEMP_POLKIT"
|
||||
echo ""
|
||||
echo "Step 3: Testing permissions..."
|
||||
|
||||
# Test sudo access
|
||||
if sudo -n "$NMCLI_PATH" device status > /dev/null 2>&1; then
|
||||
echo "✓ nmcli device status - OK"
|
||||
# Ask sudo whether one of the new rules lets this user in without a password.
|
||||
# `sudo -l CMD` answers from the rules without running CMD, so the radio is
|
||||
# left alone. (This used to run `nmcli device status`, which is not granted,
|
||||
# so it could only ever report a failure.)
|
||||
if sudo -n -l "$NMCLI_PATH" radio wifi on > /dev/null 2>&1; then
|
||||
echo "✓ nmcli radio wifi on - OK"
|
||||
else
|
||||
echo "✗ nmcli device status - Failed (this is expected if not connected)"
|
||||
echo "✗ nmcli radio wifi on - not allowed without a password"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
set -e
|
||||
|
||||
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"
|
||||
|
||||
SERVICE_NAME="ledmatrix-dns-fix"
|
||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
@@ -34,7 +38,8 @@ 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" \
|
||||
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||
|
||||
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
set -e
|
||||
|
||||
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"
|
||||
|
||||
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
||||
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||
@@ -40,7 +44,8 @@ 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" \
|
||||
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||
|
||||
$SYSTEMCTL_CMD daemon-reload
|
||||
|
||||
@@ -51,20 +51,25 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
||||
|
||||
# Install packages automatically (no prompt)
|
||||
# Use apt directly if running as root, otherwise use sudo
|
||||
PACKAGES_OK=true
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
apt update || echo "⚠ apt update failed, continuing anyway..."
|
||||
apt install -y "${MISSING_PACKAGES[@]}" || {
|
||||
PACKAGES_OK=false
|
||||
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
||||
echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}"
|
||||
}
|
||||
else
|
||||
sudo apt update || echo "⚠ apt update failed, continuing anyway..."
|
||||
sudo apt install -y "${MISSING_PACKAGES[@]}" || {
|
||||
PACKAGES_OK=false
|
||||
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
||||
echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}"
|
||||
}
|
||||
fi
|
||||
echo "✓ Package installation completed"
|
||||
if [ "$PACKAGES_OK" = true ]; then
|
||||
echo "✓ Package installation completed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
||||
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The web interface's passwordless-sudo allow-list, /etc/sudoers.d/ledmatrix_web.
|
||||
#
|
||||
# Sourced by first_time_install.sh (Step 10) and
|
||||
# scripts/install/configure_web_sudo.sh. Both used to carry their own copy of
|
||||
# these rules, and the copies drifted: one granted safe_pip_install.sh and the
|
||||
# other did not. Each caller still owns its own validate (visudo -c) / install /
|
||||
# confirm flow; this file only prints the rules.
|
||||
#
|
||||
# Add or remove a grant here and nowhere else.
|
||||
|
||||
# web_sudoers_rules WEB_USER PROJECT_ROOT SYSTEMCTL_PATH BASH_PATH REBOOT_PATH POWEROFF_PATH JOURNALCTL_PATH
|
||||
#
|
||||
# Print the ledmatrix_web sudoers rules to stdout.
|
||||
#
|
||||
# SYSTEMCTL_PATH and BASH_PATH are required, and the caller must make sure they
|
||||
# are not empty: `visudo -c` does not catch every such rule (with an empty
|
||||
# BASH_PATH the helper rules still parse, granting the script itself).
|
||||
# first_time_install.sh stops on a failed `which`; configure_web_sudo.sh checks
|
||||
# them before calling this.
|
||||
# REBOOT_PATH, POWEROFF_PATH and JOURNALCTL_PATH are optional: pass "" and
|
||||
# their rules are left out.
|
||||
web_sudoers_rules() {
|
||||
local WEB_USER="${1:-}"
|
||||
local PROJECT_ROOT="${2:-}"
|
||||
local SYSTEMCTL_PATH="${3:-}"
|
||||
local BASH_PATH="${4:-}"
|
||||
local REBOOT_PATH="${5:-}"
|
||||
local POWEROFF_PATH="${6:-}"
|
||||
local JOURNALCTL_PATH="${7:-}"
|
||||
|
||||
cat << EOF
|
||||
# LED Matrix Web Interface passwordless sudo configuration
|
||||
# This allows the web interface user to run specific commands without a password
|
||||
|
||||
# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface
|
||||
EOF
|
||||
if [ -n "$REBOOT_PATH" ]; then
|
||||
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH"
|
||||
fi
|
||||
if [ -n "$POWEROFF_PATH" ]; then
|
||||
printf '%s\n' "$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH"
|
||||
fi
|
||||
cat << EOF
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/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).
|
||||
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat << EOF
|
||||
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||
# 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.
|
||||
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$WEB_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
#
|
||||
# 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.
|
||||
# Sourced by install_service.sh, install_web_service.sh,
|
||||
# install_wifi_monitor.sh, install_dns_fix.sh and install_mqtt_bridge.sh so
|
||||
# every unit renderer escapes sed replacement text the same way instead of
|
||||
# carrying its own copy of the fix.
|
||||
|
||||
# sed_escape_replacement VALUE
|
||||
#
|
||||
|
||||
@@ -205,27 +205,6 @@ check_sudo() {
|
||||
print_success "Sudo access confirmed"
|
||||
}
|
||||
|
||||
# Fix /tmp permissions if needed (common issue when running via curl | bash)
|
||||
# Note: /tmp permission fixing is now done inline before running first_time_install.sh
|
||||
# This function is kept for backward compatibility but not actively used
|
||||
fix_tmp_permissions() {
|
||||
CURRENT_STEP="TMP directory check"
|
||||
# Only fix if /tmp is actually not writable (don't preemptively fix)
|
||||
if [ ! -w /tmp ]; then
|
||||
print_warning "/tmp is not writable, attempting to fix..."
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
chmod 1777 /tmp 2>/dev/null || true
|
||||
else
|
||||
sudo chmod 1777 /tmp 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure TMPDIR is set correctly
|
||||
if [ -z "${TMPDIR:-}" ] || [ ! -w "${TMPDIR:-/tmp}" ]; then
|
||||
export TMPDIR=/tmp
|
||||
fi
|
||||
}
|
||||
|
||||
# Main installation function
|
||||
main() {
|
||||
print_step "LED Matrix One-Shot Installation"
|
||||
@@ -429,6 +408,13 @@ main() {
|
||||
print_step "Installation Complete!"
|
||||
print_success "LED Matrix has been successfully installed!"
|
||||
echo ""
|
||||
# first_time_install.sh -y reboots as its last action, so by now the
|
||||
# reboot is under way (unless LEDMATRIX_SKIP_REBOOT_PROMPT=1 was set).
|
||||
if [ "${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}" != "1" ]; then
|
||||
echo "The installer has just started a reboot to finish setup, so this"
|
||||
echo "session may disconnect now. Give the Pi a few minutes to come back, then:"
|
||||
echo ""
|
||||
fi
|
||||
echo "Next steps:"
|
||||
echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json"
|
||||
if command -v hostname >/dev/null 2>&1; then
|
||||
@@ -449,7 +435,7 @@ main() {
|
||||
else
|
||||
echo " 2. Or use the web interface: http://<your-pi-ip>:5000"
|
||||
fi
|
||||
echo " 3. Start the service: sudo systemctl start ledmatrix.service"
|
||||
echo " 3. The display service starts on boot; to start it by hand: sudo systemctl start ledmatrix.service"
|
||||
echo ""
|
||||
else
|
||||
print_error "Main installation script exited with code $INSTALL_EXIT_CODE"
|
||||
|
||||
Executable
+404
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark the render loop against the panel's real refresh rate.
|
||||
|
||||
The question this answers is the one that decides whether a rig ships: *does
|
||||
every frame present on the refresh it was meant to?* It drives the production
|
||||
path -- a real ``DisplayManager`` and ``ScrollHelper``, the same crisp speed
|
||||
resolver every ticker uses -- scrolls a synthetic strip for a while, and grades
|
||||
it with the same frame-timing recorder the display service uses
|
||||
(``src.common.frame_timing``), printing the same report as
|
||||
``scripts/frame_soak.py``. A run passes when the loop was genuinely locked to
|
||||
the panel and no more than ``--max-late-pct`` percent of frames were late.
|
||||
|
||||
Where frame_soak.py measures the service as it runs -- live content, plugin
|
||||
updates, the web preview -- this measures the hardware and the render path
|
||||
with nothing else in the way, on content that is identical every run. That is
|
||||
what makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT
|
||||
against another) and for A/B testing a change to the render path.
|
||||
|
||||
# stop the service first; it owns the GPIO
|
||||
sudo systemctl stop ledmatrix
|
||||
|
||||
sudo python3 scripts/render_bench.py # 60s, default speed
|
||||
sudo python3 scripts/render_bench.py --seconds 600 # the 10-minute gate
|
||||
sudo python3 scripts/render_bench.py --speed 50 # a slower, held speed
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with background load
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
|
||||
so a crash here can never leave the panel dark.
|
||||
|
||||
Exit status is 0 when the run clears the gate, 1 when it does not, and 2 when
|
||||
the run could not be set up (no hardware, no root, unusable config) -- so a rig
|
||||
that cannot be measured is never mistaken for a rig that passed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.common import frame_timing, scroll_config # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_soak # noqa: E402 (same report, same verdict as the soak)
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CONFIG = REPO / "config" / "config.json"
|
||||
|
||||
#: Long enough to average out a scheduler hiccup, short enough that nobody
|
||||
#: skips running it. The shipping gate is --seconds 600.
|
||||
DEFAULT_SECONDS = 60.0
|
||||
|
||||
#: Seconds spent timing bare swaps before the scroll starts. The measurement
|
||||
#: has to settle, but every second here is a second not scrolling.
|
||||
MEASURE_SECONDS = 4.0
|
||||
|
||||
#: Scrolling discarded before the graded run starts: the first frames carry
|
||||
#: first-touch costs and the scrolling state settling.
|
||||
WARMUP_SECONDS = 2.0
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""The config the display service would run with."""
|
||||
try:
|
||||
from src.config_manager import ConfigManager
|
||||
|
||||
config = ConfigManager().config
|
||||
if isinstance(config, dict) and config:
|
||||
return config
|
||||
except Exception as exc: # noqa: BLE001 - any failure means use the plain read
|
||||
print(f"ConfigManager unavailable ({exc}); reading {CONFIG} directly",
|
||||
file=sys.stderr)
|
||||
# ConfigManager pulls in a lot; a plain read is enough to drive the panel
|
||||
# and keeps the benchmark usable on a half-installed machine.
|
||||
try:
|
||||
with open(CONFIG, encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
except (OSError, ValueError) as exc:
|
||||
sys.exit(f"could not read {CONFIG}: {exc}")
|
||||
if not isinstance(config, dict):
|
||||
sys.exit(f"{CONFIG} is not a config object")
|
||||
return config
|
||||
|
||||
|
||||
def build_strip(width: int, height: int, label: str):
|
||||
"""A marquee strip a few screens wide, with text and colour.
|
||||
|
||||
Deliberately not plain white text on black: how long ``SetImage`` takes
|
||||
depends on how many subpixels are lit, so a strip that is mostly dark
|
||||
flatters the panel and hides exactly the regression this benchmark exists
|
||||
to catch.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
font = None
|
||||
for path, size in (
|
||||
(str(REPO / "assets/fonts/PressStart2P-Regular.ttf"), max(8, height // 4)),
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", max(10, height // 2)),
|
||||
):
|
||||
try:
|
||||
font = load_truetype(path, size)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text = f" {label} *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG *** "
|
||||
probe = ImageDraw.Draw(Image.new("RGB", (8, 8)))
|
||||
box = probe.textbbox((0, 0), text, font=font)
|
||||
text_width = max(1, box[2] - box[0])
|
||||
text_height = box[3] - box[1]
|
||||
|
||||
reps = max(2, (width * 4) // text_width + 1)
|
||||
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(strip)
|
||||
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
|
||||
palette = [(255, 210, 60), (80, 200, 255), (255, 90, 90), (140, 255, 140)]
|
||||
for i in range(reps):
|
||||
left = i * text_width
|
||||
# A filled block per repeat, so a meaningful share of the strip is lit.
|
||||
draw.rectangle(
|
||||
[left + 4, height - 4, left + text_width - 4, height - 2],
|
||||
fill=palette[i % len(palette)],
|
||||
)
|
||||
draw.text((left, (height - text_height) // 2 - box[1]), text,
|
||||
font=font, fill=palette[(i + 1) % len(palette)])
|
||||
return strip
|
||||
|
||||
|
||||
class BackgroundLoad:
|
||||
"""Threads that imitate plugins updating while the panel scrolls.
|
||||
|
||||
Not a simulation of any particular plugin -- it is the shape of the work
|
||||
that competes with the render loop for the GIL: decoding JSON, resizing an
|
||||
image, compressing bytes. A render loop that only holds its pacing on an
|
||||
idle machine is not shippable, and this is how that shows up.
|
||||
"""
|
||||
|
||||
def __init__(self, workers: int) -> None:
|
||||
self.workers = max(0, workers)
|
||||
self._stop = threading.Event()
|
||||
self._threads: list = []
|
||||
|
||||
def __enter__(self) -> "BackgroundLoad":
|
||||
for index in range(self.workers):
|
||||
thread = threading.Thread(
|
||||
target=self._run, args=(index,), name=f"bench-load-{index}", daemon=True)
|
||||
thread.start()
|
||||
self._threads.append(thread)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self._stop.set()
|
||||
for thread in self._threads:
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
def _run(self, index: int) -> None:
|
||||
from PIL import Image
|
||||
|
||||
payload = json.dumps({"games": [{"id": n, "score": [n, n + 1],
|
||||
"name": f"team {n}"} for n in range(200)]})
|
||||
image = Image.new("RGB", (256, 64), (12, 34, 56))
|
||||
while not self._stop.wait(0.25 + 0.05 * index):
|
||||
json.loads(payload)
|
||||
image.resize((128, 32), Image.LANCZOS)
|
||||
zlib.compress(image.tobytes(), 1)
|
||||
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--seconds", type=float, default=DEFAULT_SECONDS,
|
||||
help=f"how long to scroll for (default {DEFAULT_SECONDS:.0f}; "
|
||||
"the shipping gate is 600)")
|
||||
parser.add_argument("--speed", type=float, default=None,
|
||||
help="requested px/s; snapped to the nearest speed the "
|
||||
"panel can show in whole pixels (default: one pixel "
|
||||
"per refresh)")
|
||||
parser.add_argument("--hz", type=float, default=None,
|
||||
help="skip the idle measurement and take this as the "
|
||||
"panel's rate (for reproducing a rig's numbers)")
|
||||
parser.add_argument("--busy", type=int, default=0, metavar="N",
|
||||
help="run N background workers imitating plugin updates")
|
||||
parser.add_argument("--max-late-pct", "--max-missed", dest="max_late_pct",
|
||||
type=float, default=0.1, metavar="PCT",
|
||||
help="fail above this percentage of late frames (default 0.1)")
|
||||
parser.add_argument("--json", dest="json_path", default=None, metavar="PATH",
|
||||
help="also write the report as JSON, for comparing rigs")
|
||||
parser.add_argument("--label", default=None,
|
||||
help="name for this run in the JSON report (default: hostname)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Everything the display service logs would otherwise land in the middle of
|
||||
# the report; the benchmark's own output is the point. The stall watchdog
|
||||
# is the exception: a stack dump naming what held a frame up belongs here.
|
||||
logging.basicConfig(level=logging.ERROR, stream=sys.stderr)
|
||||
logging.getLogger("src.common.frame_timing").setLevel(logging.WARNING)
|
||||
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
print("this needs root for GPIO access - rerun with sudo", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
config = load_config()
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
try:
|
||||
display = DisplayManager(config, suppress_test_pattern=True)
|
||||
except Exception as exc:
|
||||
print(f"could not open the display ({exc}).\n"
|
||||
"If the display service is running it owns the GPIO - stop it "
|
||||
"first:\n sudo systemctl stop ledmatrix", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if getattr(display, "matrix", None) is None:
|
||||
print("the display came up in fallback mode - there is no panel here to "
|
||||
"measure, and a software loop's frame times say nothing about "
|
||||
"vsync. Run this on a rig.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
width, height = display.width, display.height
|
||||
|
||||
if args.hz is not None:
|
||||
idle_hz = float(args.hz)
|
||||
print(f"taking the panel's rate as {idle_hz:.1f}Hz (given, not measured)")
|
||||
else:
|
||||
print(f"measuring the panel for {MEASURE_SECONDS:.0f}s...", flush=True)
|
||||
idle_hz = frame_timing.measure_refresh_hz(display.matrix, MEASURE_SECONDS)
|
||||
if idle_hz <= 0:
|
||||
print("the panel did not answer a swap; cannot measure it",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
cap = scroll_config.refresh_hz_from_config(config)
|
||||
note = (f" (cap is {cap:.0f}Hz)" if idle_hz < cap * 0.98
|
||||
else " (at its configured cap)")
|
||||
print(f"panel refreshes at {idle_hz:.1f}Hz{note}")
|
||||
|
||||
requested = args.speed if args.speed else idle_hz
|
||||
|
||||
# Configured through the shared resolver rather than by setting the helper
|
||||
# up by hand, so the benchmark measures the engine every ticker runs on. A
|
||||
# speed the bench reached some other way would be measuring something no
|
||||
# plugin does.
|
||||
helper = ScrollHelper(width, height)
|
||||
settings = scroll_config.configure(
|
||||
helper,
|
||||
plugin_config={"scroll_pixels_per_second": requested},
|
||||
global_config=config,
|
||||
refresh_hz=idle_hz,
|
||||
display_manager=display,
|
||||
)
|
||||
choice = settings.crisp
|
||||
if choice is None:
|
||||
print("the resolver did not snap to a whole-pixel speed; nothing to "
|
||||
"grade against", file=sys.stderr)
|
||||
return 2
|
||||
print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
|
||||
|
||||
helper.set_sub_pixel_scrolling(False)
|
||||
helper.set_scrolling_image(
|
||||
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s"))
|
||||
|
||||
# The display service's own recorder, owned outright here: never flushed to
|
||||
# the service's stats file, drained exactly at the start and end of the
|
||||
# graded run, and seeded with the idle rate so a loop that never locked
|
||||
# (free-running, or stuck at a fraction of the refresh) shows as early or
|
||||
# late frames instead of looking self-consistent.
|
||||
recorder = frame_timing.FrameTimingRecorder(
|
||||
flush_interval=float("inf"),
|
||||
info=display._frame_timing_info(), # pylint: disable=protected-access
|
||||
refresh_hz=idle_hz,
|
||||
)
|
||||
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
|
||||
display.frame_timing = recorder
|
||||
|
||||
print(f"scrolling {width}x{height} for {args.seconds:.0f}s"
|
||||
+ (f" with {args.busy} background worker(s)" if args.busy else "")
|
||||
+ " ...", flush=True)
|
||||
|
||||
frames = 0
|
||||
duplicates = 0
|
||||
blanks = 0
|
||||
restarts = 0
|
||||
last_column = None
|
||||
before = None
|
||||
started = time.perf_counter()
|
||||
run_started = None
|
||||
try:
|
||||
with BackgroundLoad(args.busy):
|
||||
while True:
|
||||
now = time.perf_counter()
|
||||
if run_started is None and now - started >= WARMUP_SECONDS:
|
||||
recorder.drain()
|
||||
before = recorder.snapshot()
|
||||
run_started = now
|
||||
frames = duplicates = blanks = restarts = 0
|
||||
if run_started is not None and now - run_started >= args.seconds:
|
||||
break
|
||||
helper.update_scroll_position()
|
||||
if helper.is_scroll_complete():
|
||||
# The helper parks at the end of the strip and stops
|
||||
# advancing, exactly as it does under a plugin -- which
|
||||
# then hands over to the next one. Here there is nothing
|
||||
# to hand over to, so start the strip again. Without this
|
||||
# the benchmark measures a still image for the rest of the
|
||||
# run and reports a smoothness it never demonstrated.
|
||||
helper.reset_scroll()
|
||||
restarts += 1
|
||||
visible = helper.get_visible_portion()
|
||||
column = int(helper.scroll_position)
|
||||
if column == last_column:
|
||||
duplicates += 1
|
||||
last_column = column
|
||||
if visible is None:
|
||||
blanks += 1
|
||||
else:
|
||||
display.image.paste(visible, (0, 0))
|
||||
# Every frame, not once before the loop. The scrolling state
|
||||
# expires on its own inactivity threshold and takes the frame
|
||||
# hold with it, so a scroll that announces itself once is
|
||||
# presented at the wrong rate for all but its first moments --
|
||||
# and its unchanged frames start taking the dirty-tracking
|
||||
# skip, which returns without waiting for the panel at all.
|
||||
# Every ticker re-announces per frame; so does this.
|
||||
display.set_scrolling_state(True, frame_hold=choice.frame_hold)
|
||||
display.update_display()
|
||||
frames += 1
|
||||
except KeyboardInterrupt:
|
||||
print("\ninterrupted - reporting what was measured so far")
|
||||
finally:
|
||||
display.set_scrolling_state(False)
|
||||
try:
|
||||
display.clear()
|
||||
except Exception as exc: # noqa: BLE001 - a lit panel is harmless; say so and go on
|
||||
print(f"could not blank the panel: {exc}", file=sys.stderr)
|
||||
|
||||
if before is None:
|
||||
print("interrupted during warm-up; nothing was graded", file=sys.stderr)
|
||||
return 2
|
||||
recorder.drain()
|
||||
report = frame_soak.build_report(before, recorder.snapshot(), preview=False)
|
||||
report["idle_refresh_hz"] = round(idle_hz, 2)
|
||||
|
||||
print()
|
||||
frame_soak.print_report(report, args.max_late_pct)
|
||||
held = report.get("held_refresh_hz")
|
||||
if held:
|
||||
drop = 100.0 * (idle_hz - held) / idle_hz
|
||||
print(f"\npanel held ~{held:.1f}Hz while rendering, {drop:.1f}% below its "
|
||||
f"{idle_hz:.1f}Hz idle rate (a widening gap is a render-cost "
|
||||
"regression even with nothing late)")
|
||||
if duplicates:
|
||||
# A frame that shows the same columns as the one before it is work the
|
||||
# panel did not need. It is not a miss -- the frame arrived on time --
|
||||
# but it means the loop is presenting faster than the strip is moving.
|
||||
print(f"duplicate {duplicates} frames advanced no pixels "
|
||||
f"({100.0 * duplicates / max(1, frames):.2f}%)")
|
||||
if blanks:
|
||||
print(f"blank {blanks} frames had no visible slice to draw")
|
||||
if restarts:
|
||||
print(f"restarts {restarts} (the strip was scrolled through "
|
||||
f"{restarts} time{'s' if restarts != 1 else ''})")
|
||||
|
||||
if args.json_path:
|
||||
report.update({
|
||||
"label": args.label or os.uname().nodename,
|
||||
"bench": True,
|
||||
"requested_pixels_per_second": requested,
|
||||
"pixels_per_second": choice.pixels_per_second,
|
||||
"pixels_per_frame": choice.pixels_per_frame,
|
||||
"frame_hold": choice.frame_hold,
|
||||
"busy_workers": args.busy,
|
||||
"duplicate_frames": duplicates,
|
||||
"blank_frames": blanks,
|
||||
"strip_restarts": restarts,
|
||||
"max_late_pct": args.max_late_pct,
|
||||
"passed": frame_soak.passed(report, args.max_late_pct),
|
||||
})
|
||||
Path(args.json_path).write_text(json.dumps(report, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"\nwrote {args.json_path}")
|
||||
|
||||
if report["late_pct"] is None:
|
||||
return 2
|
||||
return 0 if frame_soak.passed(report, args.max_late_pct) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -42,7 +42,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.common import scroll_config # noqa: E402
|
||||
from src.common import frame_timing, scroll_config # noqa: E402
|
||||
|
||||
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
|
||||
|
||||
@@ -99,20 +99,13 @@ def open_matrix(config, refresh_override=None):
|
||||
def measure_refresh(config, 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.
|
||||
What an older Pi or a longer chain will really give you, as opposed to
|
||||
whatever limit_refresh_rate_hz optimistically asks for. The timing loop
|
||||
itself lives in src.common.frame_timing so the benchmark grades against
|
||||
the same measurement this ladder is built from.
|
||||
"""
|
||||
matrix = open_matrix(config, 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)
|
||||
measured = frame_timing.measure_refresh_hz(matrix, seconds)
|
||||
matrix.Clear()
|
||||
return measured
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
- **`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`)
|
||||
- **`auto_update_verify.py`** - Health check after an automatic update, rolling back if it fails (the updater copies it to `data/` before pulling and `ledmatrix-update-verify.service` runs that copy)
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -29,13 +29,9 @@ from typing import Any, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies).
|
||||
try:
|
||||
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
||||
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
||||
except AttributeError: # Pillow < 9.1
|
||||
RESAMPLE_LANCZOS = Image.LANCZOS
|
||||
RESAMPLE_NEAREST = Image.NEAREST
|
||||
# Re-exported by src.common for plugins, which import them from there.
|
||||
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
||||
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
||||
|
||||
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from enum import Enum
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import pytz
|
||||
from src.cache_manager import CacheManager
|
||||
from src.common.json_body import response_json
|
||||
from src.common.espn_dates import (
|
||||
RANGE_RETRY_SECONDS,
|
||||
_note_range_rejected,
|
||||
@@ -130,19 +131,14 @@ class BackgroundDataService:
|
||||
|
||||
# Thread management
|
||||
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.
|
||||
# cache_key -> request_id for fetches currently in flight, so a second
|
||||
# submit for the same key joins the running fetch instead of starting
|
||||
# another. It is the normal case: a sport's Recent and Upcoming
|
||||
# managers miss the cache for the same season schedule together.
|
||||
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.
|
||||
# Makes every request_id unique. The id also carries a millisecond
|
||||
# timestamp, but two submits can share a millisecond, and a joiner
|
||||
# uses the id as its handle for get_result().
|
||||
self._request_seq = itertools.count()
|
||||
self.active_requests: Dict[str, FetchRequest] = {}
|
||||
self.completed_requests: Dict[str, FetchResult] = {}
|
||||
@@ -185,9 +181,9 @@ class BackgroundDataService:
|
||||
This ensures Recent/Upcoming managers and background service
|
||||
use the same cache keys.
|
||||
"""
|
||||
# Same format as CacheManager.generate_sport_cache_key(). This used to
|
||||
# build a whole CacheManager to call it -- config load, cache-dir
|
||||
# probing with test writes -- on every submit without a cache_key.
|
||||
# Same format as CacheManager.generate_sport_cache_key(), built here
|
||||
# rather than by constructing a CacheManager (config load, cache-dir
|
||||
# probing) on every submit without a cache_key.
|
||||
if date_str is None:
|
||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||
return f"{sport}_{date_str}"
|
||||
@@ -330,10 +326,8 @@ class BackgroundDataService:
|
||||
|
||||
try:
|
||||
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.
|
||||
# A request cancelled while it sat in the executor queue stays
|
||||
# cancelled: no download, no cache write, no callback.
|
||||
if request.status == FetchStatus.CANCELLED:
|
||||
cancelled_before_start = True
|
||||
else:
|
||||
@@ -389,7 +383,7 @@ class BackgroundDataService:
|
||||
response.raise_for_status()
|
||||
else:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
data = response_json(response)
|
||||
|
||||
# Validate data structure
|
||||
if not isinstance(data, dict):
|
||||
@@ -462,10 +456,9 @@ class BackgroundDataService:
|
||||
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
||||
|
||||
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.
|
||||
# A cancelled request stays CANCELLED even when its fetch
|
||||
# failed: the finally block skips callbacks only for
|
||||
# CANCELLED, and nobody is waiting on this fetch any more.
|
||||
if request.status != FetchStatus.CANCELLED:
|
||||
request.status = FetchStatus.FAILED
|
||||
request.error = error_msg
|
||||
@@ -525,20 +518,13 @@ class BackgroundDataService:
|
||||
except Exception as 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.
|
||||
# Released after the loop, never inside it: every callback holds
|
||||
# the same FetchResult (a sport's recent, upcoming and live
|
||||
# managers usually share one fetch), so a release between
|
||||
# deliveries would hand the later ones `result.data is None`.
|
||||
#
|
||||
# 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.
|
||||
# Only when there were callbacks: a request submitted without one
|
||||
# collects its payload by polling get_result().
|
||||
if callbacks:
|
||||
self._release_payload(result)
|
||||
request.result = None
|
||||
@@ -720,9 +706,6 @@ class BackgroundDataService:
|
||||
'completed_requests_count': len(self.completed_requests),
|
||||
'max_completed_requests': self._max_completed_requests,
|
||||
'completed_requests_usage_percent': (len(self.completed_requests) / self._max_completed_requests * 100) if self._max_completed_requests > 0 else 0,
|
||||
# Nothing is queued outside the executor; kept for callers
|
||||
# that read the key.
|
||||
'queue_size': 0,
|
||||
'last_cleanup': self._last_completed_requests_cleanup,
|
||||
'cleanup_interval': self._completed_requests_cleanup_interval
|
||||
}
|
||||
@@ -792,27 +775,6 @@ class BackgroundDataService:
|
||||
|
||||
return removed_count
|
||||
|
||||
def clear_completed_requests(self, older_than_hours: int = 24):
|
||||
"""
|
||||
Clear completed requests older than specified time.
|
||||
|
||||
Args:
|
||||
older_than_hours: Clear requests older than this many hours
|
||||
"""
|
||||
cutoff_time = time.time() - (older_than_hours * 3600)
|
||||
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for request_id, result in self.completed_requests.items():
|
||||
if result.completed_at < cutoff_time:
|
||||
to_remove.append(request_id)
|
||||
|
||||
for request_id in to_remove:
|
||||
del self.completed_requests[request_id]
|
||||
|
||||
if to_remove:
|
||||
logger.info(f"Cleared {len(to_remove)} old completed requests")
|
||||
|
||||
def shutdown(self, wait: bool = True):
|
||||
"""
|
||||
Shutdown the background data service.
|
||||
|
||||
+71
-106
@@ -83,14 +83,25 @@ BUNDLED_FONTS: frozenset[str] = frozenset({
|
||||
_CONFIG_REL = Path("config/config.json")
|
||||
_SECRETS_REL = Path("config/config_secrets.json")
|
||||
_WIFI_REL = Path("config/wifi_config.json")
|
||||
# Sits in config/ next to the three above and is pure user state — a
|
||||
# YouTube Music session that has to be re-authenticated by hand if lost.
|
||||
# It was omitted from backups, so a restore silently signed the user out.
|
||||
# A YouTube Music session: pure user state that has to be re-authenticated by
|
||||
# hand if lost, so a restore must bring it back.
|
||||
_YTM_REL = Path("config/ytm_auth.json")
|
||||
_FONTS_REL = Path("assets/fonts")
|
||||
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||
_STATE_REL = Path("data/plugin_state.json")
|
||||
|
||||
#: The sections that are one file each: (section name, path, the
|
||||
#: RestoreOptions flag that restores it). create, preview, validate and
|
||||
#: restore all walk this table. ytm_auth follows restore_wifi: it is
|
||||
#: device-local auth like the Wi-Fi settings, and a toggle of its own for one
|
||||
#: file would be noise in the restore dialog.
|
||||
_SINGLE_FILE_SECTIONS: Tuple[Tuple[str, Path, str], ...] = (
|
||||
("config", _CONFIG_REL, "restore_config"),
|
||||
("secrets", _SECRETS_REL, "restore_secrets"),
|
||||
("wifi", _WIFI_REL, "restore_wifi"),
|
||||
("ytm_auth", _YTM_REL, "restore_wifi"),
|
||||
)
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||
|
||||
@@ -140,34 +151,18 @@ class RestoreResult:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ledmatrix_version(project_root: Path) -> str:
|
||||
"""Best-effort version string for the current install."""
|
||||
version_file = project_root / "VERSION"
|
||||
if version_file.exists():
|
||||
try:
|
||||
return version_file.read_text(encoding="utf-8").strip() or "unknown"
|
||||
except OSError:
|
||||
pass
|
||||
head_file = project_root / ".git" / "HEAD"
|
||||
if head_file.exists():
|
||||
try:
|
||||
head = head_file.read_text(encoding="utf-8").strip()
|
||||
if head.startswith("ref: "):
|
||||
ref = head[5:]
|
||||
ref_path = project_root / ".git" / ref
|
||||
if ref_path.exists():
|
||||
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
|
||||
return head[:12] or "unknown"
|
||||
except OSError:
|
||||
pass
|
||||
return "unknown"
|
||||
def _ledmatrix_version() -> str:
|
||||
"""The release of the running core (``src.__version__``), recorded in the
|
||||
manifest so a restore can tell which release wrote the backup."""
|
||||
from src import __version__
|
||||
return __version__
|
||||
|
||||
|
||||
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
||||
def _build_manifest(contents: List[str]) -> Dict[str, Any]:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"ledmatrix_version": _ledmatrix_version(project_root),
|
||||
"ledmatrix_version": _ledmatrix_version(),
|
||||
"hostname": socket.gethostname(),
|
||||
"contents": contents,
|
||||
}
|
||||
@@ -178,13 +173,34 @@ def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plugins_directory(project_root: Path) -> Path:
|
||||
"""The plugin install directory: ``plugin_system.plugins_directory`` from
|
||||
config/config.json (relative to ``project_root`` unless absolute), or
|
||||
``plugin-repos`` when the config does not say or cannot be read."""
|
||||
configured: Any = None
|
||||
try:
|
||||
with (project_root / _CONFIG_REL).open("r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if isinstance(config, dict):
|
||||
plugin_system = config.get("plugin_system")
|
||||
if isinstance(plugin_system, dict):
|
||||
configured = plugin_system.get("plugins_directory")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
if not isinstance(configured, str) or not configured.strip():
|
||||
configured = "plugin-repos"
|
||||
path = Path(configured)
|
||||
return path if path.is_absolute() else project_root / path
|
||||
|
||||
|
||||
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return a list of currently-installed plugins suitable for the backup
|
||||
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||
|
||||
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
|
||||
directory and reads each ``manifest.json``.
|
||||
Reads ``data/plugin_state.json`` if present, then adds any plugin it
|
||||
does not list from the ``manifest.json`` files in the configured plugin
|
||||
directory (see :func:`_plugins_directory`).
|
||||
"""
|
||||
plugins: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -206,8 +222,7 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Could not read plugin_state.json: %s", e)
|
||||
|
||||
# Fall back to scanning plugin-repos/ for manifests.
|
||||
plugins_root = project_root / "plugin-repos"
|
||||
plugins_root = _plugins_directory(project_root)
|
||||
if plugins_root.exists():
|
||||
for entry in sorted(plugins_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
@@ -298,19 +313,10 @@ def create_backup(
|
||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Config files.
|
||||
if (project_root / _CONFIG_REL).exists():
|
||||
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
||||
contents.append("config")
|
||||
if (project_root / _SECRETS_REL).exists():
|
||||
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
||||
contents.append("secrets")
|
||||
if (project_root / _WIFI_REL).exists():
|
||||
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||
contents.append("wifi")
|
||||
if (project_root / _YTM_REL).exists():
|
||||
zf.write(project_root / _YTM_REL, _YTM_REL.as_posix())
|
||||
contents.append("ytm_auth")
|
||||
for section, rel, _flag in _SINGLE_FILE_SECTIONS:
|
||||
if (project_root / rel).exists():
|
||||
zf.write(project_root / rel, rel.as_posix())
|
||||
contents.append(section)
|
||||
|
||||
# User-uploaded fonts.
|
||||
user_fonts = iter_user_fonts(project_root)
|
||||
@@ -338,7 +344,7 @@ def create_backup(
|
||||
contents.append("plugins")
|
||||
|
||||
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||
manifest = _build_manifest(contents, project_root)
|
||||
manifest = _build_manifest(contents)
|
||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||
|
||||
os.replace(tmp_path, zip_path)
|
||||
@@ -352,15 +358,16 @@ def create_backup(
|
||||
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
||||
"""Return a summary of what ``create_backup`` would include."""
|
||||
project_root = Path(project_root).resolve()
|
||||
return {
|
||||
"has_config": (project_root / _CONFIG_REL).exists(),
|
||||
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
||||
"has_wifi": (project_root / _WIFI_REL).exists(),
|
||||
"has_ytm_auth": (project_root / _YTM_REL).exists(),
|
||||
preview: Dict[str, Any] = {
|
||||
f"has_{section}": (project_root / rel).exists()
|
||||
for section, rel, _flag in _SINGLE_FILE_SECTIONS
|
||||
}
|
||||
preview.update({
|
||||
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||
"plugins": list_installed_plugins(project_root),
|
||||
}
|
||||
})
|
||||
return preview
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,15 +438,10 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
{},
|
||||
)
|
||||
|
||||
detected: List[str] = []
|
||||
if _CONFIG_REL.as_posix() in names:
|
||||
detected.append("config")
|
||||
if _SECRETS_REL.as_posix() in names:
|
||||
detected.append("secrets")
|
||||
if _WIFI_REL.as_posix() in names:
|
||||
detected.append("wifi")
|
||||
if _YTM_REL.as_posix() in names:
|
||||
detected.append("ytm_auth")
|
||||
detected: List[str] = [
|
||||
section for section, rel, _flag in _SINGLE_FILE_SECTIONS
|
||||
if rel.as_posix() in names
|
||||
]
|
||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||
detected.append("fonts")
|
||||
if any(
|
||||
@@ -584,55 +586,18 @@ def restore_backup(
|
||||
result.errors.append("Failed to extract backup")
|
||||
return result
|
||||
|
||||
# Main config.
|
||||
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
|
||||
for section, rel, flag in _SINGLE_FILE_SECTIONS:
|
||||
if not (tmp_dir / rel).exists():
|
||||
continue
|
||||
if not getattr(options, flag):
|
||||
result.skipped.append(section)
|
||||
continue
|
||||
try:
|
||||
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
||||
result.restored.append("config")
|
||||
_copy_file(tmp_dir / rel, project_root / rel)
|
||||
result.restored.append(section)
|
||||
except OSError as e:
|
||||
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True)
|
||||
result.errors.append("Failed to restore config.json")
|
||||
elif (tmp_dir / _CONFIG_REL).exists():
|
||||
result.skipped.append("config")
|
||||
|
||||
# Secrets.
|
||||
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
||||
result.restored.append("secrets")
|
||||
except OSError as e:
|
||||
logger.error(
|
||||
"[Backup] Failed to restore config_secrets.json: %s", e, exc_info=True
|
||||
)
|
||||
result.errors.append("Failed to restore config_secrets.json")
|
||||
elif (tmp_dir / _SECRETS_REL).exists():
|
||||
result.skipped.append("secrets")
|
||||
|
||||
# WiFi.
|
||||
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
||||
result.restored.append("wifi")
|
||||
except OSError as e:
|
||||
logger.error(
|
||||
"[Backup] Failed to restore wifi_config.json: %s", e, exc_info=True
|
||||
)
|
||||
result.errors.append("Failed to restore wifi_config.json")
|
||||
elif (tmp_dir / _WIFI_REL).exists():
|
||||
result.skipped.append("wifi")
|
||||
|
||||
# YouTube Music session. Follows restore_wifi rather than getting its
|
||||
# own flag: it is device-local auth in the same sense, and a separate
|
||||
# toggle for one file would be noise in the restore dialog.
|
||||
if options.restore_wifi and (tmp_dir / _YTM_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _YTM_REL, project_root / _YTM_REL)
|
||||
result.restored.append("ytm_auth")
|
||||
except OSError as e:
|
||||
logger.error("[Backup] Failed to restore ytm_auth.json: %s", e, exc_info=True)
|
||||
result.errors.append("Failed to restore ytm_auth.json")
|
||||
elif (tmp_dir / _YTM_REL).exists():
|
||||
result.skipped.append("ytm_auth")
|
||||
logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
|
||||
result.errors.append(f"Failed to restore {rel.name}")
|
||||
|
||||
# User fonts — skip anything that collides with a bundled font.
|
||||
tmp_fonts = tmp_dir / _FONTS_REL
|
||||
|
||||
+10
-21
@@ -18,6 +18,8 @@ import requests
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
|
||||
|
||||
class BaseOddsManager:
|
||||
"""
|
||||
@@ -45,22 +47,15 @@ class BaseOddsManager:
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||
|
||||
# This path used a bare requests.get, so it identified itself as
|
||||
# python-requests/x.y -- the one thing ESPN is known to reject. Around
|
||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
||||
# alike; what it accepts is a token with a URL that says who is
|
||||
# calling. Every other ESPN caller in the tree already sends this
|
||||
# (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost
|
||||
# the caller its whole update budget.
|
||||
# Core's shared headers: ESPN rejects requests' default User-Agent
|
||||
# (see api_helper.USER_AGENT), and a rejected odds request costs the
|
||||
# calling plugin its update budget.
|
||||
#
|
||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||
# request_timeout, which is set to 5s precisely to stay inside that
|
||||
# budget. One try, then the cooldown below.
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
self.session.headers.update(DEFAULT_HTTP_HEADERS)
|
||||
|
||||
# Configuration with defaults
|
||||
self.update_interval = 3600 # 1 hour default
|
||||
@@ -72,7 +67,6 @@ class BaseOddsManager:
|
||||
self.request_timeout = 5
|
||||
# Set when a request fails; until then, skip the network entirely.
|
||||
self._skip_network_until = 0.0
|
||||
self.cache_ttl = 1800 # 30 minutes default
|
||||
|
||||
# Load configuration if available
|
||||
if config_manager:
|
||||
@@ -89,12 +83,10 @@ class BaseOddsManager:
|
||||
|
||||
self.update_interval = odds_config.get('update_interval', self.update_interval)
|
||||
self.request_timeout = odds_config.get('timeout', self.request_timeout)
|
||||
self.cache_ttl = odds_config.get('cache_ttl', self.cache_ttl)
|
||||
|
||||
|
||||
self.logger.debug(f"BaseOddsManager configuration loaded: "
|
||||
f"update_interval={self.update_interval}s, "
|
||||
f"timeout={self.request_timeout}s, "
|
||||
f"cache_ttl={self.cache_ttl}s")
|
||||
f"timeout={self.request_timeout}s")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
|
||||
@@ -172,15 +164,12 @@ class BaseOddsManager:
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
if odds_data:
|
||||
self.logger.info(f"Successfully extracted odds data: {odds_data}")
|
||||
else:
|
||||
self.logger.debug("No odds data available for this game")
|
||||
|
||||
if odds_data:
|
||||
self.cache_manager.set(cache_key, odds_data, ttl=interval)
|
||||
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||
else:
|
||||
self.logger.debug(f"No odds data available for {cache_key}")
|
||||
# Cache the fact that no odds are available to avoid repeated API calls
|
||||
# Cache the absence too, so the game is not re-requested
|
||||
# on every update until the interval passes.
|
||||
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
|
||||
|
||||
return odds_data
|
||||
|
||||
Vendored
+43
@@ -7,6 +7,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
import tempfile
|
||||
@@ -98,6 +99,40 @@ def _replace_nonfinite(obj: Any) -> Any:
|
||||
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
|
||||
|
||||
|
||||
#: Enough of a record to hold its header: ``{"timestamp":<float>,"ttl":<n>,``.
|
||||
_HEAD_BYTES = 256
|
||||
|
||||
#: A record written with its header first (CacheManager.set does). Anything
|
||||
#: else -- older files with "data" first, records from other writers -- does not
|
||||
#: match and is parsed in full, as before.
|
||||
_HEAD_RE = re.compile(
|
||||
rb'\A\s*\{\s*"timestamp"\s*:\s*(-?[0-9][0-9.eE+-]*)\s*'
|
||||
rb'(?:,\s*"ttl"\s*:\s*(-?[0-9][0-9.eE+-]*))?\s*[,}]'
|
||||
)
|
||||
|
||||
|
||||
def _stale_from_head(head: bytes, max_age: Optional[int], now: float) -> bool:
|
||||
"""True when a record's header alone shows it has expired.
|
||||
|
||||
Mirrors the expiry rule in DiskCache.get: a per-entry ttl wins over the
|
||||
caller's max_age, and no limit at all means never stale. False whenever the
|
||||
header cannot be read, so the full parse decides as it always did.
|
||||
"""
|
||||
match = _HEAD_RE.match(head)
|
||||
if not match:
|
||||
return False
|
||||
try:
|
||||
timestamp = float(match.group(1))
|
||||
limit = max_age
|
||||
if match.group(2) is not None:
|
||||
ttl = float(match.group(2))
|
||||
if ttl >= 0:
|
||||
limit = ttl
|
||||
except ValueError:
|
||||
return False
|
||||
return limit is not None and (now - timestamp) > limit
|
||||
|
||||
|
||||
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
|
||||
@@ -266,6 +301,14 @@ class DiskCache:
|
||||
try:
|
||||
with self._lock:
|
||||
with open(cache_path, 'rb') as f:
|
||||
# Decide staleness from the header before paying for the
|
||||
# parse. A stale read is the common case for the biggest
|
||||
# records (a season schedule is re-fetched when its cache
|
||||
# expires), and parsing 53MB to throw it away held the GIL
|
||||
# for ~1.8s -- a visible freeze on the panel.
|
||||
if _stale_from_head(f.read(_HEAD_BYTES), max_age, time.time()):
|
||||
return None
|
||||
f.seek(0)
|
||||
record = _loads(f.read())
|
||||
|
||||
# Determine record timestamp (prefer embedded, else file mtime)
|
||||
|
||||
+12
-12
@@ -32,7 +32,6 @@ from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import threading
|
||||
import tempfile
|
||||
from src.exceptions import CacheError
|
||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
@@ -272,12 +271,9 @@ class CacheManager:
|
||||
# Update memory cache first
|
||||
self._memory_cache_component.set(key, data)
|
||||
|
||||
# Save to disk cache
|
||||
try:
|
||||
self._disk_cache_component.set(key, data)
|
||||
except CacheError:
|
||||
# Disk cache errors are already logged and raised by DiskCache
|
||||
raise
|
||||
# DiskCache logs a failed write and raises CacheError, which the
|
||||
# caller gets as is.
|
||||
self._disk_cache_component.set(key, data)
|
||||
|
||||
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data from cache with memory caching."""
|
||||
@@ -522,8 +518,9 @@ class CacheManager:
|
||||
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
|
||||
"""Update cache with new data."""
|
||||
cache_data = {
|
||||
# Header first; see DiskCache's stale check.
|
||||
'timestamp': time.time(),
|
||||
'data': data,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
return self.save_cache(data_type, cache_data)
|
||||
|
||||
@@ -556,12 +553,15 @@ class CacheManager:
|
||||
from the key and is only a fallback for entries that did not
|
||||
say. Omit it to keep that inferred behaviour.
|
||||
"""
|
||||
cache_data = {
|
||||
'data': data,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
# timestamp and ttl before data, so they are the first bytes on disk:
|
||||
# DiskCache.get reads them from the head of the file and can call a
|
||||
# record stale without parsing it. That matters for the big ones -- a
|
||||
# whole MLB season is 53MB and ~1.8s of orjson.loads with the GIL held,
|
||||
# paid in full only to learn the record had expired.
|
||||
cache_data: Dict[str, Any] = {'timestamp': time.time()}
|
||||
if ttl is not None:
|
||||
cache_data['ttl'] = ttl
|
||||
cache_data['data'] = data
|
||||
self.save_cache(key, cache_data)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
|
||||
+219
-30
@@ -1,53 +1,242 @@
|
||||
# Common Utilities
|
||||
# src/common
|
||||
|
||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
||||
Helpers shared by core and plugins. This page lists every module, what it is
|
||||
for, and whether plugins are expected to import it.
|
||||
|
||||
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
|
||||
Rules for the package:
|
||||
|
||||
The recommended way to lay out plugins that render legibly on **any** panel
|
||||
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
||||
from `src.common` for convenience; canonical import paths are
|
||||
`src.adaptive_layout` / `src.adaptive_images`.
|
||||
- Every module must import without display hardware: nothing here may import
|
||||
`src.display_manager` or `src.plugin_system` at module level
|
||||
([`test/test_common_is_hardware_free.py`](../../test/test_common_is_hardware_free.py)).
|
||||
That keeps plugins that use it loadable by the web preview,
|
||||
`scripts/check_plugin.py` and tests on a laptop.
|
||||
- A plugin that imports a module added in a given core release must declare
|
||||
that release as its minimum (`ledmatrix_min_version` in the manifest's
|
||||
`versions` entry). The "Since" column gives the release; "—" means it
|
||||
predates 3.1.0, "n/a" that plugins should not import it.
|
||||
- `from src.common import ...` re-exports `APIHelper`, `ScrollHelper`,
|
||||
`LogoHelper`, `TextHelper`, `scroll_config` (plus `ScrollSettings`,
|
||||
`configure_scroll`, `resolve_scroll_settings`, `refresh_hz_from_config`) and
|
||||
the adaptive layout names below ([`__init__.py`](__init__.py)).
|
||||
|
||||
## Summary
|
||||
|
||||
| Module | For | Plugins import it? | Since |
|
||||
|---|---|---|---|
|
||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
||||
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
||||
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
||||
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
|
||||
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
||||
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
||||
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||
|
||||
The four `sports_*` mixin and card modules hold code the scoreboard plugins
|
||||
used to carry as identical copies. Each module docstring lists what a host
|
||||
class must provide. The plan behind them is in
|
||||
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
|
||||
|
||||
## Adaptive layout and images
|
||||
|
||||
`src/adaptive_layout.py` and `src/adaptive_images.py` live outside this
|
||||
package but are re-exported from `src.common`. They are the recommended way
|
||||
to lay out a plugin that renders legibly on any panel size. Every
|
||||
`BasePlugin` already has `self.layout`, `self.draw_fit()` and
|
||||
`self.draw_image()`:
|
||||
|
||||
```python
|
||||
# Every BasePlugin already has self.layout and the draw helpers:
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
||||
self.draw_fit(status, regs.status_band)
|
||||
```
|
||||
|
||||
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
|
||||
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
||||
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
Key pieces: `Region`, the font ladders `LADDER_GRID` / `LADDER_ARCADE`,
|
||||
`LayoutContext` (`fit_text`, `fit_image`, `by_tier`, `px`), and
|
||||
`scoreboard_regions()` / `media_row()`. Guide:
|
||||
[docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## API Helpers (`api_helper.py`)
|
||||
## Modules
|
||||
|
||||
Utilities for making HTTP requests and handling API responses.
|
||||
### api_helper
|
||||
|
||||
## Logo Helpers (`logo_helper.py`)
|
||||
[`api_helper.py`](api_helper.py). `APIHelper(cache_manager=None, ...)`:
|
||||
`get()` and `post()` with retries, optional caching through the cache
|
||||
manager, and a minimum interval between requests (`set_rate_limit()`). Also has
|
||||
`fetch_espn_scoreboard()`, `fetch_espn_standings()` and
|
||||
`fetch_espn_rankings()`.
|
||||
|
||||
Utilities for loading and managing team logos.
|
||||
### bdf_font
|
||||
|
||||
## Text Helpers (`text_helper.py`)
|
||||
[`bdf_font.py`](bdf_font.py). The one BDF loader and rasterizer.
|
||||
`load_bdf_face(path, size)` returns `(face, realised_px)`, falling back to
|
||||
the file's native strike when it has none at `size`;
|
||||
`draw_bdf_text(draw, text, x, y, face, color)` draws top-left anchored onto a
|
||||
PIL `ImageDraw` the same way the panel does. `read_bdf_native_size(path)`
|
||||
and `clear_face_cache()` round it out. Faces are cached per thread (FreeType
|
||||
faces are not thread-safe). `DisplayManager`, `FontManager`, `element_style`
|
||||
and the plugin test harness all use it. Most plugins get BDF text through
|
||||
`display_manager.draw_text()` or `FontManager` and never import this.
|
||||
|
||||
Utilities for text processing and formatting.
|
||||
### espn_dates
|
||||
|
||||
## Scroll Helpers (`scroll_helper.py`)
|
||||
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
||||
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||
splits a range into month and day requests ESPN accepts and merges the
|
||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||
Scoreboard plugins also bundle a copy for older cores.
|
||||
|
||||
Utilities for scrolling text on the display.
|
||||
### font_layout
|
||||
|
||||
## Permission Utilities (`permission_utils.py`)
|
||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||
`ImageFont.truetype` with PIL's Basic layout engine pinned, so text lays out
|
||||
the same whether or not the host Pillow has libraqm; use it for anything
|
||||
drawn to the panel or compared against a golden image. `crisp_size()` gives
|
||||
the size a bundled face renders on whole pixels at. `resolve_asset_path()`
|
||||
resolves `assets/fonts/...` against the install root rather than the
|
||||
working directory.
|
||||
|
||||
Helpers for ensuring directory permissions and ownership are correct
|
||||
when running as a service (used by `CacheManager` to set up its
|
||||
persistent cache directory).
|
||||
### logo_helper
|
||||
|
||||
## Best Practices
|
||||
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
|
||||
display_height, ...)`: `load_logo()`, `load_logo_with_download()`,
|
||||
`get_logo_variations()`, `normalize_abbreviation()`, with an in-memory cache.
|
||||
|
||||
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
|
||||
2. **Reuse utilities**: Check existing utilities before creating new ones
|
||||
3. **Document additions**: Add documentation when adding new utilities
|
||||
### path_safety
|
||||
|
||||
[`path_safety.py`](path_safety.py). Core-internal, used by web handlers that
|
||||
open files named in a request. `safe_path_component(value)` returns the
|
||||
value if it is one harmless path segment, else `None`;
|
||||
`resolve_under(base, *parts)` returns the resolved path, or `None` if a part
|
||||
is unsafe or the result would leave `base`; `safe_relative_parts()` splits a
|
||||
relative path the same way. Both return the sanitised value rather than a
|
||||
boolean, so a caller cannot check one string and open another.
|
||||
|
||||
### permission_utils
|
||||
|
||||
[`permission_utils.py`](permission_utils.py). The modes and ownership that
|
||||
let the root display service and the web user share files:
|
||||
`ensure_directory_permissions()`, `ensure_file_permissions()`, the
|
||||
`get_*_mode()` functions, `ensure_shared_group_ownership()`,
|
||||
`sudo_remove_directory()` and `install_requirements_file()` (the sudo
|
||||
`safe_pip_install.sh` path). `ConfigManager`, `CacheManager` and the store
|
||||
already call these; a plugin needs them only when it creates its own files
|
||||
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
|
||||
|
||||
### scroll_config
|
||||
|
||||
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
|
||||
plugin_config=, global_config=, display_manager=, plugin_logger=)` reads a
|
||||
plugin's scroll settings, snaps the speed to a whole number of pixels per
|
||||
panel refresh, puts the helper in fixed-step mode and returns
|
||||
`ScrollSettings`. Pass `settings.frame_hold` to
|
||||
`display_manager.set_scrolling_state(True, frame_hold=...)` or the scroll
|
||||
runs too fast. `resolve()` does the calculation without touching a helper.
|
||||
See [docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
|
||||
|
||||
### scroll_helper
|
||||
|
||||
[`scroll_helper.py`](scroll_helper.py). `ScrollHelper(display_width,
|
||||
display_height, logger=None)`: build a wide image once
|
||||
(`create_scrolling_image()` or `set_scrolling_image()`), then per frame
|
||||
`update_scroll_position()` and `get_visible_portion()`;
|
||||
`is_scroll_complete()`, `calculate_dynamic_duration()` and
|
||||
`get_dynamic_duration()` for timing. Configure it with `scroll_config`
|
||||
rather than the `set_*` methods. Vegas mode reads a plugin's
|
||||
`scroll_helper` image when the plugin has no `get_vegas_content()`.
|
||||
|
||||
### snapshot_policy
|
||||
|
||||
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
|
||||
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
|
||||
touch its mtime, or skip, based on whether a browser is watching the preview.
|
||||
The web health check reads the file's age.
|
||||
|
||||
### sports_card
|
||||
|
||||
[`sports_card.py`](sports_card.py). Free functions taking `config`, `fonts`
|
||||
and `logger` explicitly: card options (`scroll_card_option()`,
|
||||
`vs_text()`, `upcoming_center_mode()`), colours (`element_color()`,
|
||||
`font_color()`, `score_color_for()`, `recent_score_color()`), favourite-team
|
||||
rules (`favorite_teams_for()`, `side_is_favorite()`, `favorite_result()`),
|
||||
dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
|
||||
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
|
||||
method and delegates the body.
|
||||
|
||||
### sports_game_renderer
|
||||
|
||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||
`SportsGameRendererMixin`: the scroll/Vegas card geometry (centre gap, logo
|
||||
slot, layout offsets, upcoming-card date and time). No `__init__` and no
|
||||
state; add it as a base class of the plugin's game renderer and override
|
||||
what differs.
|
||||
|
||||
### sports_helpers
|
||||
|
||||
[`sports_helpers.py`](sports_helpers.py). Free functions `clamp_window()`,
|
||||
`clamp_seconds()`, `logo_needs_refresh()`, `spread_weighted_order()`, and
|
||||
`SportsHelpersMixin` with the scoreboards' `_mode_customization`,
|
||||
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
|
||||
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
|
||||
Nothing in core uses it.
|
||||
|
||||
### sports_scroll
|
||||
|
||||
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
|
||||
`SportsScrollDisplayManager`: the scroll-display orchestration the
|
||||
scoreboards share (Vegas items, dynamic duration, frame loop), paced through
|
||||
`scroll_config`. Subclasses supply `prepare_scroll_content()` and set
|
||||
`SCROLL_LEAGUE_KEYS`; see the module docstring for an example.
|
||||
|
||||
### sports_shared
|
||||
|
||||
[`sports_shared.py`](sports_shared.py). `SportsCoreSharedMixin`,
|
||||
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` methods
|
||||
that were identical in every scoreboard (game selection and rotation,
|
||||
fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
||||
the attributes the host class must have and the three methods deliberately
|
||||
left out.
|
||||
|
||||
### sync_manager
|
||||
|
||||
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
||||
links two displays as leader and follower (`sync.role` in config) over UDP
|
||||
port 5765, plus TCP on the next port for scroll images. The leader drives the
|
||||
scroll and sends the follower its part of each frame; a follower falls back
|
||||
to its own plugins when the leader goes quiet. Rows and columns must match.
|
||||
Created by `DisplayController`; works with any plugin.
|
||||
|
||||
### text_helper
|
||||
|
||||
[`text_helper.py`](text_helper.py). `TextHelper(font_dir=None, ...)`:
|
||||
`load_fonts()`, `draw_text_with_outline()`, `get_text_width()`,
|
||||
`get_text_dimensions()`, `center_text()`, `wrap_text()`,
|
||||
`draw_multiline_text()`, `create_text_image()`.
|
||||
|
||||
## Logging
|
||||
|
||||
Modules here create their logger with `logging.getLogger(__name__)`, which is
|
||||
the same logger `src.logging_config.get_logger(__name__)` returns. The helper
|
||||
classes (`APIHelper`, `LogoHelper`, `ScrollHelper`, `TextHelper`) and
|
||||
`espn_dates` take an optional `logger`. In a plugin, pass `self.logger`: it is
|
||||
created by `get_logger(..., plugin_id=...)` in `BasePlugin`, so messages carry
|
||||
the plugin id.
|
||||
|
||||
## Adding a module
|
||||
|
||||
- Keep it importable without hardware (see the test above).
|
||||
- Give it a module docstring that says what it is for and, if it is a mixin,
|
||||
what the host class must provide.
|
||||
- Add it to the table on this page and, if plugins may import it, to the
|
||||
CHANGELOG with the release to floor on.
|
||||
|
||||
+41
-41
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
API Helper
|
||||
|
||||
Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
HTTP requests, response caching and ESPN fetch helpers for plugins
|
||||
(``from src.common import APIHelper``), plus the headers every core request
|
||||
sends (:data:`USER_AGENT`, :data:`DEFAULT_HTTP_HEADERS`).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -36,13 +37,20 @@ DEFAULT_HTTP_HEADERS: Mapping[str, str] = MappingProxyType({
|
||||
|
||||
class APIHelper:
|
||||
"""
|
||||
Helper class for HTTP requests, caching, and ESPN API integration.
|
||||
|
||||
Provides functionality for:
|
||||
- HTTP requests with retry logic and timeouts
|
||||
- Response caching with TTL support
|
||||
- ESPN API integration for sports data
|
||||
- Request rate limiting and throttling
|
||||
HTTP requests with retries, response caching and ESPN helpers.
|
||||
|
||||
- Requests go through one ``requests.Session`` that retries GET, HEAD
|
||||
and OPTIONS on 429 and 5xx with exponential backoff, and sends
|
||||
:data:`DEFAULT_HTTP_HEADERS`.
|
||||
- Consecutive requests from one helper are spaced at least
|
||||
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
|
||||
does not count.
|
||||
- With a ``cache_manager``, :meth:`get` caches the parsed JSON under
|
||||
``cache_key`` for ``cache_ttl`` seconds. The lifetime is stored with
|
||||
the entry, so CacheManager honours it on every later read, whatever
|
||||
max_age that read asks for.
|
||||
- Failed requests are logged and return None; nothing here raises for a
|
||||
network or HTTP error.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager=None, default_timeout: int = 30,
|
||||
@@ -73,13 +81,7 @@ class APIHelper:
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
|
||||
# Default headers
|
||||
self.session.headers.update({
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Connection': 'keep-alive'
|
||||
})
|
||||
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
|
||||
|
||||
# Rate limiting
|
||||
self._last_request_time = 0
|
||||
@@ -102,9 +104,8 @@ class APIHelper:
|
||||
Returns:
|
||||
Response data as dictionary or None if request fails
|
||||
"""
|
||||
# Check cache first
|
||||
if cache_key and self.cache_manager:
|
||||
cached = self._get_from_cache(cache_key)
|
||||
cached = self._get_from_cache(cache_key, cache_ttl)
|
||||
if cached is not None:
|
||||
self.logger.debug(f"Using cached response for {cache_key}")
|
||||
return cached
|
||||
@@ -268,33 +269,31 @@ class APIHelper:
|
||||
Args:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
ttl: Time-to-live in seconds (ignored - CacheManager doesn't support TTL)
|
||||
ttl: Seconds the entry stays valid. Stored with the entry, so
|
||||
it applies to every later read of ``key``.
|
||||
"""
|
||||
if self.cache_manager:
|
||||
self.cache_manager.set(key, data)
|
||||
self._set_cache(key, data, ttl)
|
||||
|
||||
def get_cache(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get cached data.
|
||||
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
|
||||
Returns:
|
||||
Cached data or None if not found
|
||||
Cached data, or None if there is none or it has expired. An
|
||||
entry written with a ttl (set_cache, get) expires after that ttl;
|
||||
one written without expires after CacheManager's default max_age.
|
||||
"""
|
||||
if self.cache_manager:
|
||||
return self.cache_manager.get(key)
|
||||
return None
|
||||
return self._get_from_cache(key)
|
||||
|
||||
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear cache data.
|
||||
|
||||
Uses CacheManager's real surface (clear_cache / delete /
|
||||
list_cache_files); safely no-ops on managers without it. The old
|
||||
implementation guarded on a nonexistent ``clear`` method, so it
|
||||
silently never cleared anything.
|
||||
Uses CacheManager's clear_cache(), or list_cache_files() and delete()
|
||||
for a pattern. A cache manager without those methods is left alone.
|
||||
|
||||
Args:
|
||||
pattern: Optional substring to match cache keys; only matching
|
||||
@@ -315,21 +314,22 @@ class APIHelper:
|
||||
"cannot clear by pattern")
|
||||
elif hasattr(self.cache_manager, 'clear_cache'):
|
||||
self.cache_manager.clear_cache()
|
||||
elif hasattr(self.cache_manager, 'clear'):
|
||||
self.cache_manager.clear()
|
||||
else:
|
||||
self.logger.debug("Cache manager exposes no clear method; no-op")
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||
"""Get data from cache."""
|
||||
if self.cache_manager:
|
||||
def _get_from_cache(self, key: str, max_age: Optional[int] = None) -> Optional[Any]:
|
||||
"""Cached data for ``key``, or None. ``max_age`` only matters for an
|
||||
entry stored without a ttl; one stored with a ttl uses that."""
|
||||
if not self.cache_manager:
|
||||
return None
|
||||
if max_age is None:
|
||||
return self.cache_manager.get(key)
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, data: Any, ttl: int) -> None:
|
||||
"""Set data in cache."""
|
||||
return self.cache_manager.get(key, max_age=max_age)
|
||||
|
||||
def _set_cache(self, key: str, data: Any, ttl: Optional[int]) -> None:
|
||||
"""Store ``data`` under ``key`` for ``ttl`` seconds."""
|
||||
if self.cache_manager:
|
||||
self.cache_manager.set(key, data)
|
||||
self.cache_manager.set(key, data, ttl=ttl)
|
||||
|
||||
def _enforce_rate_limit(self) -> None:
|
||||
"""Enforce rate limiting between requests."""
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Loading and drawing BDF bitmap fonts: one loader, one rasterizer.
|
||||
|
||||
BDF fonts are fixed-size bitmap strikes. FreeType renders them at the size
|
||||
baked into the file and rejects any other size, and PIL cannot draw a
|
||||
``freetype.Face`` at all, so the core draws BDF text itself, glyph by glyph.
|
||||
|
||||
This used to be done in several places that drifted apart:
|
||||
``FontManager``, ``element_style`` and ``DisplayManager`` each loaded faces
|
||||
their own way, and ``DisplayManager`` and the plugin test harness
|
||||
(``VisualTestDisplayManager``) each had a copy of the glyph drawing loop. The
|
||||
harness renders plugin golden images and ``check_plugin`` / ``dev_server``
|
||||
previews, so a copy that differs from the panel's shows something the panel
|
||||
never draws. Everything now goes through the two functions here:
|
||||
|
||||
* :func:`load_bdf_face` -- a ``freetype.Face`` at the requested pixel size,
|
||||
or at the file's native strike when the file has no strike at that size.
|
||||
* :func:`draw_bdf_text` -- draw a string in a ``freetype.Face`` onto a PIL
|
||||
``ImageDraw``, top-left anchored like ``ImageDraw.text``.
|
||||
|
||||
Only PIL and freetype-py are imported, so the module is as cheap to import
|
||||
from the test harness as from core.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Optional, Sequence, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
import freetype
|
||||
except ImportError: # pragma: no cover - freetype-py is a core requirement
|
||||
freetype = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["read_bdf_native_size", "load_bdf_face", "draw_bdf_text"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Loading
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
||||
"""A BDF file's one true pixel size, read from its header, or None.
|
||||
|
||||
Prefers the PIXEL_SIZE property, which states the real pixel height
|
||||
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE is
|
||||
absent, since point-size only equals pixel height at exactly 100dpi --
|
||||
several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined at 75dpi, where
|
||||
the two values genuinely differ. Stops at the first STARTCHAR.
|
||||
"""
|
||||
size_line_value = None
|
||||
try:
|
||||
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
|
||||
for line in f:
|
||||
if line.startswith("PIXEL_SIZE"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
return int(float(parts[1]))
|
||||
elif line.startswith("SIZE") and size_line_value is None:
|
||||
# Format: "SIZE <point_size> <xres> <yres>"
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
size_line_value = int(float(parts[1]))
|
||||
elif line.startswith("STARTCHAR"):
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return size_line_value
|
||||
|
||||
|
||||
#: Loaded faces, keyed on (absolute path, requested size, mtime_ns, file size)
|
||||
#: so a font file replaced on disk under the same name is loaded afresh.
|
||||
#: Bounded LRU: the display process runs for weeks and every config save can
|
||||
#: introduce a new (font, size) pair, but a panel draws from a handful.
|
||||
_FACE_CACHE_MAX = 256
|
||||
_face_cache: "OrderedDict[tuple, Tuple[Any, int]]" = OrderedDict()
|
||||
_face_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _face_at(path: str, size_px: int) -> Any:
|
||||
face = freetype.Face(path)
|
||||
# Character size in 1/64th points at 72dpi == pixel size.
|
||||
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
||||
return face
|
||||
|
||||
|
||||
def load_bdf_face(path: str, size_px: int) -> Tuple[Any, int]:
|
||||
"""``(face, realised_px)`` for the BDF file at ``path``.
|
||||
|
||||
``realised_px`` is ``size_px`` when the file has a strike at that size,
|
||||
otherwise the file's native size: FreeType refuses any other size for a
|
||||
bitmap font, and answering that with some other typeface (which both
|
||||
``FontManager`` and ``element_style`` once did) is worse than drawing the
|
||||
font that was asked for at the size it can do. Callers that lay out by
|
||||
size need ``realised_px``, not the size they asked for.
|
||||
|
||||
Faces are cached per thread. A ``freetype.Face`` holds per-glyph state
|
||||
(``load_char`` rewrites its glyph slot), and FreeType does not allow two
|
||||
threads to use one face at once, so the display thread and a plugin's
|
||||
update thread must never be handed the same object. Within a thread the
|
||||
face is shared by every caller. Raises if the file can't be loaded at
|
||||
either size.
|
||||
"""
|
||||
if freetype is None:
|
||||
raise RuntimeError("freetype-py is not installed; BDF fonts need it")
|
||||
size_px = int(size_px)
|
||||
abs_path = os.path.abspath(path)
|
||||
try:
|
||||
st = os.stat(abs_path)
|
||||
key = (threading.get_ident(), abs_path, size_px,
|
||||
st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
key = None # let freetype raise its own error below
|
||||
|
||||
if key is not None:
|
||||
with _face_cache_lock:
|
||||
cached = _face_cache.get(key)
|
||||
if cached is not None:
|
||||
_face_cache.move_to_end(key)
|
||||
return cached
|
||||
|
||||
try:
|
||||
entry = (_face_at(abs_path, size_px), size_px)
|
||||
except Exception:
|
||||
native = read_bdf_native_size(abs_path)
|
||||
if not native or native == size_px:
|
||||
raise
|
||||
# A fresh Face: the first one already took a failed set_char_size.
|
||||
entry = (_face_at(abs_path, native), native)
|
||||
logger.debug(
|
||||
"BDF font %s requested at %spx renders at its native %spx "
|
||||
"(the file has no strike at the requested size)",
|
||||
abs_path, size_px, native,
|
||||
)
|
||||
|
||||
if key is not None:
|
||||
with _face_cache_lock:
|
||||
_face_cache[key] = entry
|
||||
_face_cache.move_to_end(key)
|
||||
while len(_face_cache) > _FACE_CACHE_MAX:
|
||||
_face_cache.popitem(last=False)
|
||||
return entry
|
||||
|
||||
|
||||
def clear_face_cache() -> None:
|
||||
"""Drop every cached face (tests; a font directory swapped wholesale)."""
|
||||
with _face_cache_lock:
|
||||
_face_cache.clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Drawing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _bitmap_bytes(bitmap: Any, nbytes: int) -> bytes:
|
||||
"""The first ``nbytes`` of a glyph bitmap's buffer, zero-padded.
|
||||
|
||||
``bitmap.buffer`` builds a Python list one byte at a time; reading the
|
||||
underlying FT_Bitmap directly is the same bytes without that cost.
|
||||
"""
|
||||
raw = getattr(bitmap, "_FT_Bitmap", None)
|
||||
if raw is not None and raw.buffer:
|
||||
return ctypes.string_at(raw.buffer, nbytes)
|
||||
buf = bytes(bitmap.buffer[:nbytes])
|
||||
if len(buf) < nbytes:
|
||||
buf += bytes(nbytes - len(buf))
|
||||
return buf
|
||||
|
||||
|
||||
def _glyph_points(bitmap: Any, left: int, top: int,
|
||||
clip_w: int, clip_h: int) -> list:
|
||||
"""Every lit pixel of a glyph, clipped, as ``(x, y)`` pairs.
|
||||
|
||||
The reference definition of which pixels a glyph lights: the MSB-first
|
||||
bit ``j`` of byte ``i * pitch + j // 8``. Used only where the fast path
|
||||
below can't express exactly the same thing.
|
||||
"""
|
||||
buffer = bitmap.buffer
|
||||
pitch = bitmap.pitch
|
||||
points = []
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
byte_index = i * pitch + (j // 8)
|
||||
if byte_index < len(buffer) and buffer[byte_index] & (1 << (7 - (j % 8))):
|
||||
px = left + j
|
||||
py = top + i
|
||||
if 0 <= px < clip_w and 0 <= py < clip_h:
|
||||
points.append((px, py))
|
||||
return points
|
||||
|
||||
|
||||
def draw_bdf_text(draw: Any, text: str, x: int, y: int, face: Any,
|
||||
color: Any = (255, 255, 255),
|
||||
clip: Optional[Sequence[int]] = None) -> int:
|
||||
"""Draw ``text`` in a ``freetype.Face`` with ``draw``; return the pen x.
|
||||
|
||||
``(x, y)`` is the top-left of the line, as for ``ImageDraw.text``: the
|
||||
baseline is ``y`` plus the face's ascender. Each glyph's lit bits are set
|
||||
to ``color`` exactly -- no blending, no anti-aliasing -- and pixels
|
||||
outside ``[0, clip_w) x [0, clip_h)`` are skipped (``clip`` defaults to
|
||||
the image size). The pen advances by each glyph's advance width.
|
||||
|
||||
Glyphs are drawn as 1-bit masks with ``ImageDraw.bitmap`` rather than a
|
||||
point at a time, which is pixel-identical and far faster. A ``draw`` that
|
||||
blends (``ImageDraw.Draw(rgb_image, "RGBA")``) is drawn point by point, so
|
||||
a translucent colour still blends exactly as it always has.
|
||||
|
||||
Errors (a non-BDF ``face``, a bad colour) propagate after any glyphs
|
||||
before the failing one are drawn; callers decide whether to log them.
|
||||
"""
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
|
||||
if clip is None:
|
||||
clip_w, clip_h = draw.im.size
|
||||
else:
|
||||
clip_w, clip_h = int(clip[0]), int(clip[1])
|
||||
blending = draw.mode != draw.im.mode
|
||||
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
glyph = face.glyph
|
||||
bitmap = glyph.bitmap
|
||||
rows, width, pitch = bitmap.rows, bitmap.width, bitmap.pitch
|
||||
left = x + glyph.bitmap_left
|
||||
top = baseline_y - glyph.bitmap_top
|
||||
|
||||
if rows > 0 and width > 0:
|
||||
if blending or pitch <= 0:
|
||||
points = _glyph_points(bitmap, left, top, clip_w, clip_h)
|
||||
if points:
|
||||
draw.point(points, fill=color)
|
||||
else:
|
||||
# The visible part of the glyph box, in glyph coordinates.
|
||||
x0, y0 = max(0, -left), max(0, -top)
|
||||
x1, y1 = min(width, clip_w - left), min(rows, clip_h - top)
|
||||
if x0 < x1 and y0 < y1:
|
||||
# Raw mode "1" with stride=pitch reads exactly the bits
|
||||
# _glyph_points does, whatever the glyph's pixel mode.
|
||||
mask = Image.frombytes(
|
||||
"1", (width, rows), _bitmap_bytes(bitmap, rows * pitch),
|
||||
"raw", "1", pitch)
|
||||
if (x0, y0, x1, y1) != (0, 0, width, rows):
|
||||
mask = mask.crop((x0, y0, x1, y1))
|
||||
# An all-blank glyph draws nothing -- and, as before,
|
||||
# never touches the colour.
|
||||
if mask.getbbox() is not None:
|
||||
draw.bitmap((left + x0, top + y0), mask, fill=color)
|
||||
|
||||
x += glyph.advance.x >> 6
|
||||
return x
|
||||
@@ -39,6 +39,14 @@ from datetime import date, timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from src.common.json_body import response_json
|
||||
except ImportError:
|
||||
# Plugins bundle copies of this module for older cores, which predate
|
||||
# json_body; the stdlib parse is what those cores always used.
|
||||
def response_json(response: Any) -> Any:
|
||||
return response.json()
|
||||
|
||||
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||
# docstring: 500 is the largest value measured to return complete data.
|
||||
ESPN_MAX_LIMIT = 500
|
||||
@@ -194,7 +202,7 @@ def _fetch_one_chunk(
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return response_json(response)
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
if logger:
|
||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||
@@ -371,4 +379,4 @@ def fetch_espn_scoreboard(
|
||||
if data is not None:
|
||||
return data
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return response_json(response)
|
||||
|
||||
@@ -67,10 +67,11 @@ _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.
|
||||
In order: an absolute path that exists is returned untouched; otherwise
|
||||
``relative_path`` under the install root derived above, if that exists;
|
||||
otherwise ``relative_path`` unchanged, so a caller that wants to raise
|
||||
and fall back still can. The cwd is never consulted, so a relative path
|
||||
means the same file whichever directory the process started in.
|
||||
|
||||
Without the fallback, any process started outside the install root (the
|
||||
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
"""System-wide frame timing: one set of numbers for every presented frame.
|
||||
|
||||
Each scroller already logs its own stats line (ScrollHelper.log_frame_rate,
|
||||
the Vegas coordinator's "Vegas FPS"), but in different formats, per source,
|
||||
and Vegas only logs a healthy window at DEBUG. None of that answers the
|
||||
question a release has to answer on each rig: *over a long run, how often did
|
||||
a moving frame reach the panel late?*
|
||||
|
||||
Every frame reaches the panel through ``DisplayManager.update_display``, so it
|
||||
is recorded there, once, whoever drew it. The render thread only appends a
|
||||
tuple; a worker thread aggregates, and every ``flush_interval`` seconds writes
|
||||
cumulative counters and histograms to a small JSON file -- in ``/dev/shm`` where
|
||||
it exists, so a stats file refreshed all day costs no SD-card writes.
|
||||
``scripts/frame_soak.py`` reads it twice and reports the difference.
|
||||
|
||||
What is counted
|
||||
---------------
|
||||
Only intervals between two consecutive *scrolling* frames count: a static
|
||||
screen that changes once a second has no timing to get wrong, and the first
|
||||
frame of a scroll has no predecessor worth measuring against.
|
||||
|
||||
"Scrolling" is DisplayManager's scroll state when the frame is presented, and
|
||||
that state can go missing in the middle of a scroll. It expires after 2s
|
||||
without scroll activity, which a long enough stall outlasts, and any thread can
|
||||
clear it: plugins call ``set_scrolling_state(False)`` from their own
|
||||
``display()``, and Vegas captures some of those on the render thread between
|
||||
two of its frames. The frame after that is recorded as static, and the interval
|
||||
it ends -- the stall, or the capture -- would vanish from the report. So a
|
||||
single static frame between two scrolling ones, with the scroll picking up
|
||||
again within ``RESUME_SECONDS``, is treated as a frame of the scroll: both of
|
||||
its intervals count. A second static frame in a row means the scroll really
|
||||
ended. (On hdpi on 2026-09-24 the watchdog logged a 1.9s stall that the soak
|
||||
report did not have; this is how.)
|
||||
|
||||
A frame held for ``hold`` refreshes should arrive ``hold`` refresh periods
|
||||
after the one before it. One that arrives a whole refresh or more after that is
|
||||
**late**: the panel showed the previous frame again, which on a moving strip is
|
||||
a visible hitch. ``missed_refreshes`` sums how many refreshes late.
|
||||
|
||||
An interval of ``FREEZE_SECONDS`` or more is a **freeze** instead -- a
|
||||
recompose, a plugin handover, a blocking call on the render thread. Those are
|
||||
counted separately, both because they are a different fault and because
|
||||
folding a single 400ms handover into the late count as "40 missed refreshes"
|
||||
would drown the jitter the late count exists to measure. ``freeze_by`` splits
|
||||
them by length. Intervals of ``GAP_SECONDS`` or more are ignored as not being
|
||||
frames of one scroll at all.
|
||||
|
||||
A frame that arrives a whole refresh or more *early* means the swap did not
|
||||
wait for the panel: the emulator, the fallback display, or a hold that was not
|
||||
the one in effect. Those are counted as **early**, and a run with more than a
|
||||
trace of them was not locked to the panel, so its late count means nothing.
|
||||
|
||||
The refresh period is estimated from the frames themselves: swaps that block
|
||||
on vsync can only land on refresh boundaries, so the low end of
|
||||
interval / hold is the period. It is the smallest per-window 10th percentile
|
||||
seen so far, over windows with enough frames to trust -- except that a window
|
||||
cutting it by more than ``MAX_REFRESH_DROP`` is ignored. A panel's refresh does
|
||||
not jump like that; swaps that stopped blocking do, and adopting their period
|
||||
would make every early frame look on time.
|
||||
|
||||
A caller that has measured the panel independently -- ``scripts/render_bench.py``
|
||||
times bare swaps first with :func:`measure_refresh_hz` -- passes that rate in
|
||||
as ``refresh_hz``. The estimate then starts from it instead of from the frames,
|
||||
which is what catches a loop that never locked at all: one that free-runs
|
||||
faster than the panel (every frame early) or sits at half its rate (every
|
||||
frame late), both of which look self-consistent to an estimate taken from
|
||||
their own intervals.
|
||||
|
||||
Stall watchdog
|
||||
--------------
|
||||
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
|
||||
same frames from its own thread and, when a scroll's last frame is more than
|
||||
``STALL_SECONDS`` old, logs the stack of the thread that presented it and the
|
||||
top of every other thread's, so the log names what the render thread was
|
||||
waiting on. It also measures how late its own wake-up was: if the watchdog was
|
||||
held up as long as the render thread, the whole interpreter was blocked (C
|
||||
code holding the GIL, or the process not scheduled), not one thread on a lock.
|
||||
Set ``LEDMATRIX_STALL_WATCHDOG=0`` to turn it off, or
|
||||
``LEDMATRIX_STALL_WATCHDOG_MS`` to dump at a lower threshold -- 30 catches
|
||||
frames three refreshes late, which is where GIL contention shows. It polls
|
||||
three times per threshold, so keep it to diagnostic runs, not soaks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Bumped when a field changes meaning, so a reader can refuse stale files.
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
#: Histogram resolution. 64ms of range covers any frame worth drawing a
|
||||
#: distribution of; everything beyond lands in the last bucket.
|
||||
BUCKET_MS = 0.25
|
||||
BUCKET_COUNT = 256
|
||||
|
||||
#: See the module docstring.
|
||||
FREEZE_SECONDS = 0.25
|
||||
|
||||
#: Intervals this long are not frames of one scroll. This used to be 1s,
|
||||
#: which silently dropped every 1-2s stall inside a scroll. It is now only a
|
||||
#: sanity bound.
|
||||
GAP_SECONDS = 5.0
|
||||
|
||||
#: A frame recorded as static between two scrolling frames is a frame of the
|
||||
#: scroll whose state went missing, if the scroll resumes within this long.
|
||||
#: See "What is counted".
|
||||
RESUME_SECONDS = 1.0
|
||||
|
||||
#: Buckets for freeze length, as cumulative counters a soak can difference.
|
||||
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
|
||||
(float("inf"), "2s+"))
|
||||
|
||||
#: A window may lower the refresh-period estimate by at most this fraction.
|
||||
MAX_REFRESH_DROP = 0.2
|
||||
|
||||
#: A window needs this many scrolling frames before its refresh estimate is
|
||||
#: trusted -- about a second of scrolling.
|
||||
MIN_FRAMES_FOR_REFRESH = 90
|
||||
|
||||
FLUSH_INTERVAL = 10.0
|
||||
|
||||
#: A scroll's last frame older than this is a stall worth a stack dump.
|
||||
STALL_SECONDS = 0.25
|
||||
#: How often the watchdog looks. Also the resolution of its starvation check.
|
||||
WATCHDOG_POLL_SECONDS = 0.05
|
||||
#: At most one stack dump per this many seconds: a stall that repeats every
|
||||
#: extension would otherwise write the same stacks to the SD card all day.
|
||||
STALL_LOG_INTERVAL = 30.0
|
||||
|
||||
#: Written by the display service, read by scripts/frame_soak.py and anything
|
||||
#: else that wants the numbers. The web UI's viewer marker lives in /tmp; this
|
||||
#: goes to RAM where there is some, since it is rewritten all day.
|
||||
STATS_FILENAME = "ledmatrix_frame_stats.json"
|
||||
|
||||
|
||||
def default_stats_path() -> str:
|
||||
# A fixed name in a shared directory is safe here: write() creates its
|
||||
# temp file with mkstemp and os.replace()s it over this path, which swaps
|
||||
# out whatever is there -- a planted symlink included -- without following it.
|
||||
base = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() # nosec B108
|
||||
return os.path.join(base, STATS_FILENAME)
|
||||
|
||||
|
||||
def _bucket(seconds: float) -> int:
|
||||
index = int(seconds * 1000.0 / BUCKET_MS)
|
||||
return min(max(index, 0), BUCKET_COUNT - 1)
|
||||
|
||||
|
||||
def binding_releases_gil() -> Optional[bool]:
|
||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
|
||||
|
||||
The stock binding blocks in SwapOnVSync holding the GIL, which starves
|
||||
every other thread for most of each frame (docs/SCROLL_PERFORMANCE.md).
|
||||
scripts/build_rgbmatrix_nogil.sh rebuilds it, and the rebuilt module links
|
||||
PyEval_SaveThread where the stock one never does -- a crude test, but the
|
||||
only one that needs neither a probe on the panel nor the source tree the
|
||||
module was built from. None when no hardware binding is loaded.
|
||||
"""
|
||||
module = sys.modules.get("rgbmatrix.core")
|
||||
path = getattr(module, "__file__", None)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
return b"PyEval_SaveThread" in handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _pi_model() -> Optional[str]:
|
||||
try:
|
||||
with open("/proc/device-tree/model", "rb") as handle:
|
||||
return handle.read().rstrip(b"\0").decode("ascii", "replace").strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def measure_refresh_hz(matrix: Any, seconds: float = 4.0) -> float:
|
||||
"""The panel's refresh rate with nothing else running, by timing bare swaps.
|
||||
|
||||
``SwapOnVSync`` blocks until the panel's next refresh, so a loop that does
|
||||
nothing else runs at exactly the panel's rate. ``limit_refresh_rate_hz`` is
|
||||
a *cap*, and a long chain, a high ``pwm_bits`` or an older Pi will sit well
|
||||
under it. Solving scroll speeds against a cap the panel cannot reach is
|
||||
what produces "3px every 4 refreshes" and the judder that comes with it.
|
||||
|
||||
This is the idle rate. The panel refreshes a few percent slower while the
|
||||
Pi is also pushing frames into it (100.4Hz idle against 96.3Hz scrolling on
|
||||
a Pi 4 driving 512x64), which is why the recorder reads the rendering rate
|
||||
back from the frames rather than trusting this.
|
||||
|
||||
Pass the matrix the display is already running on rather than opening a
|
||||
second one: the GPIO has a single owner, and the options in force change
|
||||
the answer.
|
||||
|
||||
:returns: measured Hz, or 0.0 if the matrix cannot be swapped (no
|
||||
hardware, a stub, a mock).
|
||||
"""
|
||||
try:
|
||||
canvas = matrix.CreateFrameCanvas()
|
||||
# Discard the first swap: it carries construction and first-touch costs
|
||||
# that have nothing to do with the steady-state refresh.
|
||||
canvas = matrix.SwapOnVSync(canvas)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return 0.0
|
||||
|
||||
frames = 0
|
||||
started = time.perf_counter()
|
||||
while time.perf_counter() - started < seconds:
|
||||
canvas = matrix.SwapOnVSync(canvas)
|
||||
frames += 1
|
||||
elapsed = time.perf_counter() - started
|
||||
if elapsed <= 0 or frames <= 0:
|
||||
return 0.0
|
||||
return frames / elapsed
|
||||
|
||||
class FrameTimingRecorder:
|
||||
"""Collects per-frame timings on the render thread; aggregates elsewhere.
|
||||
|
||||
``record`` is the only method the render thread calls, and it does no more
|
||||
than compare two floats and append a tuple.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Optional[str] = None,
|
||||
flush_interval: float = FLUSH_INTERVAL,
|
||||
info: Optional[Dict[str, Any]] = None,
|
||||
refresh_hz: Optional[float] = None,
|
||||
):
|
||||
"""
|
||||
:param refresh_hz: the panel's rate, measured independently (see the
|
||||
module docstring). Omit it to estimate from the frames alone, as
|
||||
the display service does.
|
||||
"""
|
||||
self.path = path or default_stats_path()
|
||||
self.flush_interval = flush_interval
|
||||
self.info = dict(info or {})
|
||||
|
||||
# Render-thread state.
|
||||
self._pending: List[Tuple[float, float, float, int]] = []
|
||||
self._static_frames = 0
|
||||
self._previous: Optional[Tuple[float, bool, int]] = None
|
||||
# The interval ended by a static frame that followed a scrolling one,
|
||||
# until the next frame shows whether the scroll went on.
|
||||
self._unsure: Optional[Tuple[float, float, float, int]] = None
|
||||
self._last_flush: Optional[float] = None
|
||||
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
|
||||
# Worker-thread state. Nothing on the render thread reads these.
|
||||
self.started = time.time()
|
||||
self.refresh_period: Optional[float] = (
|
||||
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
|
||||
# The first estimate, until a second window agrees with it.
|
||||
self._refresh_candidate: Optional[float] = None
|
||||
self.totals: Dict[str, Any] = {
|
||||
"static_frames": 0,
|
||||
"scroll_frames": 0,
|
||||
"late_frames": 0,
|
||||
"missed_refreshes": 0,
|
||||
"late_by": {"1": 0, "2": 0, "3-5": 0, "6+": 0},
|
||||
"early_frames": 0,
|
||||
# Frames judged against a known refresh period: the denominator
|
||||
# for the late and early rates. Frames before the period is known
|
||||
# are neither, and must not dilute them.
|
||||
"timed_frames": 0,
|
||||
"freezes": 0,
|
||||
"freeze_seconds": 0.0,
|
||||
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
|
||||
"worst_interval_ms": 0.0,
|
||||
}
|
||||
self.histograms: Dict[str, Dict[int, int]] = {
|
||||
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
|
||||
}
|
||||
self._binding_gil: Optional[bool] = None
|
||||
self._binding_checked = False
|
||||
|
||||
# Read by the stall watchdog from its own thread: one tuple assignment,
|
||||
# so it always sees a consistent (time, scrolling, thread) triple.
|
||||
self.last_frame: Optional[Tuple[float, bool, int]] = None
|
||||
#: Whether a scroll is running *now*, supplied by the display manager.
|
||||
#: The last frame's flag alone would call the end of every scroll a
|
||||
#: stall.
|
||||
self.scrolling_now: Optional[Callable[[], bool]] = None
|
||||
self.watchdog: Optional["StallWatchdog"] = None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the stall watchdog, if one was started."""
|
||||
watchdog, self.watchdog = self.watchdog, None
|
||||
if watchdog is not None:
|
||||
watchdog.stop()
|
||||
|
||||
# -- render thread ------------------------------------------------------
|
||||
|
||||
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
|
||||
presented_at: float) -> None:
|
||||
"""One frame reached the panel.
|
||||
|
||||
:param blit: seconds spent copying the frame into the canvas.
|
||||
:param wait: seconds SwapOnVSync blocked.
|
||||
:param hold: the refreshes this frame was held for.
|
||||
:param scrolling: whether a scroll was running when it was presented.
|
||||
:param presented_at: ``time.perf_counter()`` when the swap returned.
|
||||
"""
|
||||
previous = self._previous
|
||||
self._previous = (presented_at, scrolling, hold)
|
||||
self.last_frame = (presented_at, scrolling, threading.get_ident())
|
||||
if not scrolling:
|
||||
self._static_frames += 1
|
||||
# The scroll ended, or its state went missing for this frame: the
|
||||
# next frame says which. Its hold may have been dropped with the
|
||||
# state, so the interval is due at the scroll's own.
|
||||
self._unsure = None
|
||||
if previous is not None and previous[1]:
|
||||
self._unsure = (presented_at - previous[0], blit, wait, previous[2])
|
||||
elif self.watchdog is None and self.scrolling_now is not None \
|
||||
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
|
||||
self.watchdog = StallWatchdog(self, **watchdog_settings())
|
||||
self.watchdog.start()
|
||||
elif previous is not None:
|
||||
interval = presented_at - previous[0]
|
||||
unsure, self._unsure = self._unsure, None
|
||||
if previous[1]:
|
||||
if interval < GAP_SECONDS:
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
elif unsure is not None and interval < RESUME_SECONDS:
|
||||
# One static frame between two scrolling ones: the scroll never
|
||||
# stopped, only its state did. Both intervals were motion.
|
||||
self._static_frames -= 1
|
||||
if unsure[0] < GAP_SECONDS:
|
||||
self._pending.append(unsure)
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
|
||||
if self._last_flush is None:
|
||||
self._last_flush = presented_at
|
||||
elif presented_at - self._last_flush >= self.flush_interval:
|
||||
self._hand_off()
|
||||
self._last_flush = presented_at
|
||||
|
||||
def _hand_off(self) -> None:
|
||||
batch, self._pending = self._pending, []
|
||||
static, self._static_frames = self._static_frames, 0
|
||||
self._queue.put((batch, static))
|
||||
if self._worker is None or not self._worker.is_alive():
|
||||
self._worker = threading.Thread(
|
||||
target=self._run, daemon=True, name="frame-timing")
|
||||
self._worker.start()
|
||||
|
||||
def drain(self) -> None:
|
||||
"""Aggregate everything recorded so far, on the calling thread.
|
||||
|
||||
For a caller that owns the recorder outright and wants exact numbers at
|
||||
a moment of its choosing -- the benchmark, between warm-up and run and
|
||||
at the end. Construct it with ``flush_interval=float('inf')`` so the
|
||||
worker never runs; the two must not aggregate at once.
|
||||
"""
|
||||
batch, self._pending = self._pending, []
|
||||
static, self._static_frames = self._static_frames, 0
|
||||
self.aggregate(batch, static)
|
||||
|
||||
# -- worker thread ------------------------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
batch, static = self._queue.get()
|
||||
try:
|
||||
self.aggregate(batch, static)
|
||||
self.write()
|
||||
except Exception: # never let telemetry take anything down
|
||||
logger.debug("Frame timing flush failed", exc_info=True)
|
||||
|
||||
def aggregate(self, batch: List[Tuple[float, float, float, int]],
|
||||
static: int) -> None:
|
||||
"""Fold one window of frames into the running totals."""
|
||||
totals = self.totals
|
||||
totals["static_frames"] += static
|
||||
|
||||
per_hold = sorted(interval / max(1, hold)
|
||||
for interval, _, _, hold in batch
|
||||
if interval < FREEZE_SECONDS)
|
||||
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
|
||||
estimate = per_hold[len(per_hold) // 10]
|
||||
current = self.refresh_period
|
||||
if estimate <= 0:
|
||||
pass
|
||||
elif current is None:
|
||||
# Adopt the first period only once two windows in a row agree:
|
||||
# one loaded window at startup, most of its frames a refresh
|
||||
# late, would otherwise fix a period twice the real one for
|
||||
# the life of the process, since later windows may only lower
|
||||
# it by MAX_REFRESH_DROP.
|
||||
candidate = self._refresh_candidate
|
||||
if candidate and abs(estimate - candidate) <= candidate * MAX_REFRESH_DROP:
|
||||
self.refresh_period = min(candidate, estimate)
|
||||
else:
|
||||
self._refresh_candidate = estimate
|
||||
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
|
||||
self.refresh_period = estimate
|
||||
period = self.refresh_period
|
||||
|
||||
histograms = self.histograms
|
||||
for interval, blit, wait, hold in batch:
|
||||
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
|
||||
interval * 1000.0)
|
||||
if interval >= FREEZE_SECONDS:
|
||||
totals["freezes"] += 1
|
||||
totals["freeze_seconds"] += interval
|
||||
label = next(name for limit, name in FREEZE_BUCKETS
|
||||
if interval < limit)
|
||||
totals["freeze_by"][label] += 1
|
||||
continue
|
||||
totals["scroll_frames"] += 1
|
||||
for name, value in (("blit", blit), ("wait", wait),
|
||||
("work", max(0.0, interval - blit - wait)),
|
||||
("interval_per_hold", interval / max(1, hold))):
|
||||
bucket = _bucket(value)
|
||||
histogram = histograms[name]
|
||||
histogram[bucket] = histogram.get(bucket, 0) + 1
|
||||
if period:
|
||||
totals["timed_frames"] += 1
|
||||
missed = round(interval / period) - hold
|
||||
if missed >= 1:
|
||||
totals["late_frames"] += 1
|
||||
totals["missed_refreshes"] += missed
|
||||
key = ("1" if missed == 1 else "2" if missed == 2
|
||||
else "3-5" if missed <= 5 else "6+")
|
||||
totals["late_by"][key] += 1
|
||||
elif missed <= -1:
|
||||
totals["early_frames"] += 1
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
"""The JSON document: cumulative since this process started."""
|
||||
if not self._binding_checked:
|
||||
self._binding_gil = binding_releases_gil()
|
||||
self._binding_checked = True
|
||||
info = dict(self.info)
|
||||
info.setdefault("pi_model", _pi_model())
|
||||
period = self.refresh_period
|
||||
return {
|
||||
"version": SCHEMA_VERSION,
|
||||
"pid": os.getpid(),
|
||||
"started": self.started,
|
||||
"updated": time.time(),
|
||||
"bucket_ms": BUCKET_MS,
|
||||
"freeze_seconds": FREEZE_SECONDS,
|
||||
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
|
||||
"binding_releases_gil": self._binding_gil,
|
||||
"info": info,
|
||||
"totals": copy.deepcopy(self.totals),
|
||||
# JSON keys are strings; readers convert back.
|
||||
"histograms": {name: {str(k): v for k, v in sorted(h.items())}
|
||||
for name, h in self.histograms.items()},
|
||||
}
|
||||
|
||||
def write(self) -> None:
|
||||
"""Replace the stats file atomically with the current snapshot."""
|
||||
directory = os.path.dirname(self.path) or "."
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".frame_stats.",
|
||||
suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(self.snapshot(), handle)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, self.path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def watchdog_settings() -> Dict[str, float]:
|
||||
"""StallWatchdog arguments from ``LEDMATRIX_STALL_WATCHDOG_MS``, if set.
|
||||
|
||||
The poll comes down with the threshold, or a stall shorter than one poll
|
||||
would go unseen.
|
||||
"""
|
||||
try:
|
||||
ms = float(os.environ.get("LEDMATRIX_STALL_WATCHDOG_MS") or 0)
|
||||
except ValueError:
|
||||
ms = 0.0
|
||||
if ms <= 0:
|
||||
return {}
|
||||
threshold = ms / 1000.0
|
||||
return {"threshold": threshold,
|
||||
"poll": min(WATCHDOG_POLL_SECONDS, threshold / 3)}
|
||||
|
||||
|
||||
class StallWatchdog:
|
||||
"""Log what the render thread is doing when a scroll stops presenting.
|
||||
|
||||
See the module docstring. Polls; never touches the render thread.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
recorder: FrameTimingRecorder,
|
||||
threshold: float = STALL_SECONDS,
|
||||
poll: float = WATCHDOG_POLL_SECONDS,
|
||||
log_interval: float = STALL_LOG_INTERVAL,
|
||||
clock: Callable[[], float] = time.perf_counter,
|
||||
):
|
||||
self.recorder = recorder
|
||||
self.threshold = threshold
|
||||
self.poll = poll
|
||||
self.log_interval = log_interval
|
||||
self.clock = clock
|
||||
self.stalls = 0
|
||||
self._last_dump: Optional[float] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, daemon=True, name="stall-watchdog")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, timeout: float = 1.0) -> None:
|
||||
"""End the polling thread (DisplayManager.cleanup calls this)."""
|
||||
self._stop.set()
|
||||
thread = self._thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout)
|
||||
|
||||
def _run(self) -> None:
|
||||
last_wake = self.clock()
|
||||
stall_from: Optional[float] = None # presented_at of the stalled frame
|
||||
dumped = False
|
||||
while not self._stop.wait(self.poll):
|
||||
now = self.clock()
|
||||
late = max(0.0, now - last_wake - self.poll)
|
||||
last_wake = now
|
||||
try:
|
||||
stall_from, dumped = self.check(now, late, stall_from, dumped)
|
||||
except Exception: # never let a diagnostic take anything down
|
||||
logger.debug("Stall watchdog check failed", exc_info=True)
|
||||
|
||||
def check(self, now: float, late: float, stall_from: Optional[float],
|
||||
dumped: bool) -> Tuple[Optional[float], bool]:
|
||||
"""One look. Returns the updated (stall_from, dumped) state."""
|
||||
frame = self.recorder.last_frame
|
||||
if frame is None:
|
||||
return None, False
|
||||
presented_at, scrolling, ident = frame
|
||||
|
||||
if stall_from is not None and presented_at != stall_from:
|
||||
# A frame arrived: the stall is over.
|
||||
if dumped:
|
||||
logger.warning(
|
||||
"Render stall over: no frame for %.0fms",
|
||||
(presented_at - stall_from) * 1000.0)
|
||||
return None, False
|
||||
|
||||
scrolling_now = self.recorder.scrolling_now
|
||||
if (stall_from is not None and now - stall_from >= GAP_SECONDS
|
||||
and (scrolling_now is None or not scrolling_now())):
|
||||
# The scroll ended without another frame: nothing more to time.
|
||||
# Only past GAP_SECONDS: the scroll state expires after 2s without
|
||||
# activity, which a stall outlasts, and its end still wants saying.
|
||||
return None, False
|
||||
age = now - presented_at
|
||||
if (stall_from is None and scrolling and age >= self.threshold
|
||||
and scrolling_now is not None and scrolling_now()):
|
||||
self.stalls += 1
|
||||
if self._last_dump is None or now - self._last_dump >= self.log_interval:
|
||||
self._last_dump = now
|
||||
logger.warning(self.describe(ident, age, late))
|
||||
return presented_at, True
|
||||
return presented_at, False
|
||||
return stall_from, dumped
|
||||
|
||||
def describe(self, ident: int, age: float, late: float) -> str:
|
||||
"""The stack dump: the stalled thread in full, the rest in brief."""
|
||||
names = {t.ident: t.name for t in threading.enumerate()}
|
||||
frames = sys._current_frames()
|
||||
lines = [
|
||||
f"Render stall: no frame for {age * 1000.0:.0f}ms mid-scroll "
|
||||
f"(watchdog woke {late * 1000.0:.0f}ms late"
|
||||
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
|
||||
+ ")",
|
||||
f"-- {names.get(ident, ident)} (presents frames):",
|
||||
]
|
||||
stalled = frames.get(ident)
|
||||
if stalled is not None:
|
||||
lines.extend(line.rstrip() for line in
|
||||
traceback.format_stack(stalled, limit=12))
|
||||
for other, frame in frames.items():
|
||||
if other in (ident, threading.get_ident()):
|
||||
continue
|
||||
top = traceback.extract_stack(frame, limit=3)
|
||||
where = " <- ".join(
|
||||
f"{os.path.basename(f.filename)}:{f.lineno} {f.name}"
|
||||
for f in reversed(top))
|
||||
lines.append(f"-- {names.get(other, other)}: {where}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Parse an HTTP response body as JSON, with orjson when it is installed.
|
||||
|
||||
``requests``' ``response.json()`` uses the stdlib parser. For the payloads the
|
||||
sports plugins fetch -- a season schedule is tens of MB -- that runs ~1.7x
|
||||
slower than orjson on a Pi 4 (3.1s against 1.8s for the 53MB MLB season), and
|
||||
both hold the GIL for the whole parse, which freezes the display for as long.
|
||||
Nothing else changes: the result is the same Python objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError: # optional dependency; see docs/SCROLL_PERFORMANCE.md
|
||||
orjson = None
|
||||
|
||||
|
||||
def response_json(response: Any) -> Any:
|
||||
"""``response.json()``, parsed by orjson when available."""
|
||||
body = getattr(response, "content", None)
|
||||
if orjson is None or not isinstance(body, (bytes, bytearray)):
|
||||
return response.json()
|
||||
try:
|
||||
return orjson.loads(body)
|
||||
except orjson.JSONDecodeError:
|
||||
# Let requests raise its usual error, with its usual message.
|
||||
return response.json()
|
||||
+33
-52
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageDraw
|
||||
from src.common.api_helper import USER_AGENT
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
@@ -32,35 +32,14 @@ from src.common.permission_utils import (
|
||||
# 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.
|
||||
# The cap for every logo download: src.logo_downloader.fetch_logo uses it too.
|
||||
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
||||
|
||||
#: A logo's default bounding box, as a multiple of the panel's width and
|
||||
#: height, when the caller gives no max_width / max_height.
|
||||
DEFAULT_LOGO_BOX_FACTOR = 1.5
|
||||
|
||||
|
||||
class LogoHelper:
|
||||
"""
|
||||
@@ -119,12 +98,16 @@ class LogoHelper:
|
||||
Args:
|
||||
team_abbr: Team abbreviation for caching
|
||||
logo_path: Path to the logo file
|
||||
max_width: Maximum width (defaults to display_width * 1.5)
|
||||
max_height: Maximum height (defaults to display_height * 1.5)
|
||||
max_width: Maximum width (default display_width *
|
||||
DEFAULT_LOGO_BOX_FACTOR)
|
||||
max_height: Maximum height (default display_height *
|
||||
DEFAULT_LOGO_BOX_FACTOR)
|
||||
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.
|
||||
``customization.layout.<element>.scale``; 1.0 leaves the box
|
||||
as is. Callers hold the config, so they resolve the element
|
||||
name; this only applies the number, clamped to
|
||||
src.element_style's MIN_ELEMENT_SCALE..MAX_ELEMENT_SCALE.
|
||||
A value that is not a finite positive number means 1.0.
|
||||
|
||||
Returns:
|
||||
PIL Image object or None if loading fails
|
||||
@@ -138,10 +121,13 @@ class LogoHelper:
|
||||
# key is size-qualified — a panel-size change must not return a
|
||||
# logo resized for the old dimensions.
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
scale = _usable_scale(scale)
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
# Imported here: src.element_style imports src.common (for bdf_font),
|
||||
# whose __init__ imports this module.
|
||||
from src.element_style import coerce_scale
|
||||
scale = coerce_scale(scale, 1.0)
|
||||
if scale != 1.0:
|
||||
max_width = max(1, int(round(max_width * scale)))
|
||||
max_height = max(1, int(round(max_height * scale)))
|
||||
@@ -374,9 +360,9 @@ class LogoHelper:
|
||||
nobody asked to grow would change every existing render.
|
||||
"""
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
|
||||
# Only resize if necessary
|
||||
if logo.width <= max_width and logo.height <= max_height:
|
||||
@@ -429,31 +415,26 @@ class LogoHelper:
|
||||
max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
||||
"""
|
||||
Create a placeholder logo with team abbreviation.
|
||||
|
||||
A stand-in for a logo that could not be loaded or downloaded: a
|
||||
translucent grey box with a light outline, filling the logo box.
|
||||
No text is drawn; ``team_abbr`` is only used in log messages.
|
||||
|
||||
Args:
|
||||
team_abbr: Team abbreviation to display
|
||||
max_width: Maximum width
|
||||
max_height: Maximum height
|
||||
|
||||
team_abbr: Team the placeholder stands in for
|
||||
max_width: Width (default display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
max_height: Height (default display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
|
||||
Returns:
|
||||
PIL Image with placeholder logo
|
||||
The RGBA placeholder, or None if it could not be created
|
||||
"""
|
||||
try:
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||
|
||||
# Create placeholder image
|
||||
placeholder = Image.new('RGBA', (max_width, max_height), (0, 0, 0, 0))
|
||||
|
||||
# This would require a font, so we'll create a simple colored rectangle
|
||||
# In a real implementation, you'd want to add text rendering here
|
||||
from PIL import ImageDraw
|
||||
draw = ImageDraw.Draw(placeholder)
|
||||
|
||||
# Draw a simple rectangle with team abbreviation
|
||||
draw.rectangle([0, 0, max_width-1, max_height-1],
|
||||
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
|
||||
|
||||
|
||||
@@ -241,7 +241,8 @@ def get_assets_dir_mode() -> int:
|
||||
Return permission mode for asset directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
@@ -251,7 +252,8 @@ def get_config_dir_mode() -> int:
|
||||
Return permission mode for config directory.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
@@ -271,7 +273,8 @@ def get_plugin_dir_mode() -> int:
|
||||
Return permission mode for plugin directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
@@ -281,7 +284,8 @@ def get_cache_dir_mode() -> int:
|
||||
Return permission mode for cache directories.
|
||||
|
||||
Returns:
|
||||
Permission mode: 0o2775 (rwxrwxr-x + sticky bit) for group-writable cache directories
|
||||
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
|
||||
entries created in it take the directory's group
|
||||
"""
|
||||
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Let a background thread run Python only while the render thread waits on vsync.
|
||||
|
||||
With plugin rendering moved to Vegas's prefetch thread (DisplayManager.offscreen,
|
||||
#630) the render thread no longer stops for it, but it still shares the GIL
|
||||
with it. The render thread spends most of each refresh inside SwapOnVSync,
|
||||
which releases the GIL, and needs it back the moment the swap returns. If the
|
||||
prefetch thread is running Python right then, the render thread waits: up to
|
||||
the switch interval (5ms) behind bytecode, and for as long as a C call that
|
||||
keeps the GIL takes. On hdpi that showed up as frames 2-5 refreshes late while
|
||||
a group was being prepared.
|
||||
|
||||
The gate turns that around. The display manager opens it just before each swap,
|
||||
with a deadline shortly ahead of the refresh the swap will return on, and
|
||||
closes it when the swap returns. A thread inside ``gate.yielding()`` checks it on
|
||||
every Python and C call through a profile hook, and once the window has closed
|
||||
it parks -- blocked on a condition, GIL released -- until the next swap opens
|
||||
it. The render thread then finds the GIL free when its refresh arrives, and the
|
||||
background work runs in time the render thread was only spending waiting.
|
||||
|
||||
Parking a thread is only safe if nothing the render thread needs is stuck
|
||||
behind it, so it is never parked:
|
||||
|
||||
* while it holds a lock registered with ``guard()`` (the Vegas buffers and
|
||||
caches the render thread also takes);
|
||||
* inside logging, threading, importlib or the cache, all of which take locks the
|
||||
render thread can take too;
|
||||
* when there is no render loop to protect -- no swap for ``STALE_SECONDS``, as
|
||||
on a static screen or a stalled frame.
|
||||
|
||||
And a parked thread is never held more than ``MAX_WAIT_SECONDS`` at a time, so
|
||||
whatever the gate gets wrong costs a frame, not a freeze. The render thread
|
||||
itself is never gated, whatever it calls.
|
||||
|
||||
It gates the prefetch thread only. Gating the ESPN fetch threads as well was
|
||||
tried for the hourly sports refresh, twenty-odd of them at once, and measured
|
||||
worse on hdpi (0.85% late frames without it, 1.14% with it, across a burst every
|
||||
five minutes): each parked thread has to take the GIL again just to park at the
|
||||
end of every window, and the fetches ran two to three times as long.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Deque, List, Optional
|
||||
|
||||
#: Park background threads this long before the refresh a swap will return on,
|
||||
#: so a short C call already under way has finished by then.
|
||||
MARGIN_SECONDS = 0.002
|
||||
|
||||
#: The longest a background thread is parked in one go.
|
||||
MAX_WAIT_SECONDS = 0.05
|
||||
|
||||
#: No swap for this long means there is no render loop running to protect.
|
||||
STALE_SECONDS = 0.05
|
||||
|
||||
#: Swaps needed before the refresh period is trusted enough to open a window.
|
||||
MIN_SAMPLES = 8
|
||||
|
||||
#: Parking inside any of these modules could hold a lock the render thread
|
||||
#: takes: logging handler locks, Condition and Event internals, the module
|
||||
#: import locks, and the disk and memory cache locks. Matched by module name,
|
||||
#: not file path: a path can say "cache" or "logging" for reasons of its own --
|
||||
#: a virtualenv under ~/.cache, or GitHub's /opt/hostedtoolcache, where every
|
||||
#: stdlib frame would otherwise count and the gate would never park anything.
|
||||
_UNSAFE_MODULES = frozenset({
|
||||
"logging", "threading", "importlib", "src.cache_manager", "src.cache",
|
||||
})
|
||||
_UNSAFE_PREFIXES = ("logging.", "importlib.", "_frozen_importlib", "src.cache.")
|
||||
|
||||
|
||||
def _unsafe(frame: Any, base: Any) -> bool:
|
||||
"""True if a frame above ``base`` comes from somewhere parking could deadlock.
|
||||
|
||||
``base`` is the frame that entered ``yielding()``; what lies below it (the
|
||||
thread's own bootstrap in threading.py) holds nothing.
|
||||
"""
|
||||
while frame is not None and frame is not base:
|
||||
name = frame.f_globals.get("__name__") or ""
|
||||
if name in _UNSAFE_MODULES or name.startswith(_UNSAFE_PREFIXES):
|
||||
return True
|
||||
frame = frame.f_back
|
||||
return False
|
||||
|
||||
|
||||
def swap_releases_gil() -> Optional[bool]:
|
||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
|
||||
|
||||
The rebuilt binding links PyEval_SaveThread and the stock one never does.
|
||||
The same test as src.common.frame_timing.binding_releases_gil (#629); one
|
||||
of the two goes once both have landed.
|
||||
"""
|
||||
module = sys.modules.get("rgbmatrix.core")
|
||||
path = getattr(module, "__file__", None)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
return b"PyEval_SaveThread" in handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _held(lock: Any) -> bool:
|
||||
"""Is ``lock`` held? RLocks report this thread's ownership; plain locks, anyone's."""
|
||||
is_owned = getattr(lock, "_is_owned", None)
|
||||
if is_owned is not None:
|
||||
return is_owned()
|
||||
return lock.locked()
|
||||
|
||||
|
||||
class RenderGate:
|
||||
"""Opened by the render thread around each swap; honoured by background threads."""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] = time.monotonic):
|
||||
self.clock = clock
|
||||
self._cond = threading.Condition()
|
||||
self._generation = 0
|
||||
self._open_until = 0.0
|
||||
self._last_return: Optional[float] = None
|
||||
self._periods: Deque[float] = deque(maxlen=64)
|
||||
self._period: Optional[float] = None
|
||||
self._guarded: List[Any] = []
|
||||
self._local = threading.local()
|
||||
self._render_ident: Optional[int] = None
|
||||
#: How often, and for how long in all, background threads were parked.
|
||||
self.parks = 0
|
||||
self.parked_seconds = 0.0
|
||||
|
||||
def guard(self, *locks: Any) -> None:
|
||||
"""Never park a thread while it holds (or, for a plain Lock, anyone holds) these."""
|
||||
self._guarded.extend(lock for lock in locks if lock is not None)
|
||||
|
||||
# -- render thread -----------------------------------------------------
|
||||
|
||||
def refresh_period(self) -> Optional[float]:
|
||||
"""The panel's refresh period from recent swaps, or None until known.
|
||||
|
||||
The 10th percentile of the gaps between swap returns, each divided by
|
||||
the hold: a late frame only ever lengthens a gap, so the low end is
|
||||
the panel's own period.
|
||||
"""
|
||||
return self._period
|
||||
|
||||
def before_swap(self, hold: int) -> None:
|
||||
"""The render thread is about to block in SwapOnVSync: open the window."""
|
||||
hold = max(1, int(hold))
|
||||
period = self._period
|
||||
now = self.clock()
|
||||
last = self._last_return
|
||||
if period and last is not None and now - last < STALE_SECONDS:
|
||||
# The swap returns on the first refresh boundary after both the
|
||||
# current frame's hold is up and this frame has been handed over;
|
||||
# boundaries fall a whole period apart from the last return.
|
||||
refreshes = max(hold, math.ceil((now - last) / period))
|
||||
open_until = last + refreshes * period - MARGIN_SECONDS
|
||||
else:
|
||||
open_until = 0.0 # no rhythm to predict from: leave threads be
|
||||
with self._cond:
|
||||
self._open_until = open_until
|
||||
self._generation += 1
|
||||
self._cond.notify_all()
|
||||
|
||||
def after_swap(self, hold: int) -> None:
|
||||
"""The swap returned and the render thread needs the GIL: close the window."""
|
||||
now = self.clock()
|
||||
self._open_until = 0.0
|
||||
if self._render_ident is None:
|
||||
# The first thread to swap is the render loop. A plugin pushing a
|
||||
# live refresh from its update thread swaps too, but must not take
|
||||
# over its exemption.
|
||||
self._render_ident = threading.get_ident()
|
||||
last = self._last_return
|
||||
if last is not None and now - last < STALE_SECONDS:
|
||||
self._periods.append((now - last) / max(1, int(hold)))
|
||||
if len(self._periods) >= MIN_SAMPLES:
|
||||
ordered = sorted(self._periods)
|
||||
self._period = ordered[len(ordered) // 10]
|
||||
self._last_return = now
|
||||
|
||||
# -- background threads ------------------------------------------------
|
||||
|
||||
def _should_park(self, frame: Any, now: float) -> bool:
|
||||
if now < self._open_until:
|
||||
return False # inside the window
|
||||
last = self._last_return
|
||||
if last is None or now - last > STALE_SECONDS or self._period is None:
|
||||
return False # no render loop to protect
|
||||
for lock in self._guarded:
|
||||
if _held(lock):
|
||||
return False
|
||||
return not _unsafe(frame, getattr(self._local, "base", None))
|
||||
|
||||
def _hook(self, frame: Any, _event: str, _arg: Any) -> None:
|
||||
now = self.clock()
|
||||
if not self._should_park(frame, now):
|
||||
return
|
||||
generation = self._generation
|
||||
with self._cond:
|
||||
self._cond.wait_for(lambda: self._generation != generation,
|
||||
timeout=MAX_WAIT_SECONDS)
|
||||
self.parks += 1
|
||||
self.parked_seconds += self.clock() - now
|
||||
|
||||
def yielding(self) -> "_Yielding":
|
||||
"""``with gate.yielding():`` runs the block giving way to the render thread."""
|
||||
return _Yielding(self)
|
||||
|
||||
|
||||
class _Yielding:
|
||||
"""Installs a gate's profile hook on the thread for the length of a block."""
|
||||
|
||||
def __init__(self, gate: RenderGate):
|
||||
self.gate = gate
|
||||
self._previous: Any = None
|
||||
self._previous_base: Any = None
|
||||
self._skipped = False
|
||||
|
||||
def __enter__(self) -> RenderGate:
|
||||
gate = self.gate
|
||||
# pylint: disable=protected-access
|
||||
if threading.get_ident() == gate._render_ident:
|
||||
self._skipped = True # parking the render thread parks the display
|
||||
return gate
|
||||
local = gate._local
|
||||
self._previous_base = getattr(local, "base", None)
|
||||
if self._previous_base is None:
|
||||
# Nested blocks keep the outermost frame, so everything the thread
|
||||
# entered since it first gave way is still checked for locks.
|
||||
local.base = sys._getframe(1)
|
||||
self._previous = sys.getprofile()
|
||||
sys.setprofile(gate._hook)
|
||||
return gate
|
||||
|
||||
def __exit__(self, *_exc: Any) -> None:
|
||||
if self._skipped:
|
||||
return
|
||||
sys.setprofile(self._previous)
|
||||
self.gate._local.base = self._previous_base # pylint: disable=protected-access
|
||||
|
||||
+15
-38
@@ -238,20 +238,9 @@ class ScrollHelper:
|
||||
self.cached_image = full_image
|
||||
# Convert to numpy array for fast operations
|
||||
self.cached_array = np.array(full_image)
|
||||
|
||||
# Use actual image width instead of calculated width to ensure accuracy
|
||||
# This fixes cases where width calculation doesn't match actual positioning
|
||||
actual_image_width = full_image.width
|
||||
self.total_scroll_width = actual_image_width
|
||||
|
||||
# Log if there's a mismatch (indicating a bug in width calculation)
|
||||
if actual_image_width != total_width:
|
||||
self.logger.warning(
|
||||
"Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). "
|
||||
"Using actual width for scroll calculations.",
|
||||
total_width, actual_image_width, abs(actual_image_width - total_width)
|
||||
)
|
||||
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
@@ -339,10 +328,8 @@ class ScrollHelper:
|
||||
# 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:
|
||||
pixels_per_second = self.scroll_speed * 100.0
|
||||
# set_scroll_delay clamps scroll_delay to at least 0.001.
|
||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||
pixels_to_move = pixels_per_second * delta_time
|
||||
self.last_step_time = current_time
|
||||
else:
|
||||
@@ -353,11 +340,11 @@ class ScrollHelper:
|
||||
self.scroll_position += pixels_to_move
|
||||
self.total_distance_scrolled += pixels_to_move
|
||||
|
||||
# Calculate required total distance: total_scroll_width only.
|
||||
# The image already includes display_width pixels of blank padding at the start
|
||||
# (added by create_scrolling_image), so once scroll_position reaches
|
||||
# total_scroll_width the last card has fully scrolled off the left edge.
|
||||
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
|
||||
# One pass is total_scroll_width. With the default lead_gap the strip
|
||||
# starts with display_width of blank, so by then the last item has
|
||||
# fully left the panel; a caller passing a smaller lead_gap (Vegas)
|
||||
# decides for itself where its cycle ends. Adding display_width here
|
||||
# caused 1-2 extra wrap-arounds on wide chains.
|
||||
required_total_distance = self.total_scroll_width
|
||||
|
||||
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip
|
||||
@@ -414,7 +401,6 @@ class ScrollHelper:
|
||||
and current_time - self.last_progress_log_time >= self.progress_log_interval
|
||||
):
|
||||
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
|
||||
required_total_distance = self.total_scroll_width
|
||||
# Progress telemetry, emitted every few seconds for the whole of
|
||||
# every scroll. It says how far along a marquee is, which is what
|
||||
@@ -461,10 +447,8 @@ class ScrollHelper:
|
||||
"""
|
||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||
|
||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||||
installed on the target devices, and the old scipy-based sub-pixel path
|
||||
was dead code -- get_visible_portion never consulted the flag. The scipy
|
||||
import was removed with it; installing scipy has no effect.
|
||||
Implemented with numpy rather than scipy.ndimage.shift, which is not
|
||||
installed on the target devices.
|
||||
|
||||
Args:
|
||||
start_x: Left column of the earlier of the two frames
|
||||
@@ -571,22 +555,16 @@ class ScrollHelper:
|
||||
return self.min_duration
|
||||
|
||||
try:
|
||||
# Calculate total scroll distance needed
|
||||
# The image already includes display_width padding at the start, so we need
|
||||
# to scroll total_scroll_width pixels to show all content, plus display_width
|
||||
# more pixels to ensure the last content scrolls completely off the screen
|
||||
# The strip's width plus one more screen, so the duration covers
|
||||
# the last item leaving the panel even when the strip has less
|
||||
# than display_width of lead-in blank (lead_gap).
|
||||
total_scroll_distance = self.total_scroll_width + self.display_width
|
||||
|
||||
# Calculate effective pixels per second based on scrolling mode
|
||||
if self.frame_based_scrolling:
|
||||
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame
|
||||
# Effective pixels per second = pixels per frame / seconds per frame
|
||||
if self.scroll_delay > 0:
|
||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||
else:
|
||||
# Fallback if scroll_delay is invalid
|
||||
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
|
||||
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
|
||||
# Frame-based mode: scroll_speed is pixels per scroll_delay
|
||||
# seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
|
||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||
scroll_mode_str = "frame-based"
|
||||
else:
|
||||
# Time-based mode: scroll_speed is already pixels per second
|
||||
@@ -1072,7 +1050,6 @@ class ScrollHelper:
|
||||
Returns:
|
||||
Dictionary with scroll state information
|
||||
"""
|
||||
# The image already includes display_width padding, so we only need total_scroll_width
|
||||
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
|
||||
return {
|
||||
'scroll_position': self.scroll_position,
|
||||
|
||||
@@ -5,7 +5,7 @@ serves two consumers with different needs:
|
||||
|
||||
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
||||
fresh frames — but only while a browser is actually watching.
|
||||
- The health check (web_interface/blueprints/api_v3.py, hardware status)
|
||||
- The health check (web_interface/blueprints/api_v3/misc.py, hardware status)
|
||||
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
|
||||
|
||||
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
|
||||
@@ -27,7 +27,7 @@ Policy:
|
||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||
|
||||
If any constant here changes, re-check the health threshold in
|
||||
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
@@ -37,7 +37,7 @@ VIEWER_INTERVAL = 0.2
|
||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||
IDLE_INTERVAL = 30.0
|
||||
# Max age of the last write/touch before bumping mtime for the health
|
||||
# check. MUST stay well under api_v3's 60s degraded threshold.
|
||||
# check. MUST stay well under get_hardware_status's 60s degraded threshold.
|
||||
TOUCH_INTERVAL = 20.0
|
||||
# A viewer marker older than this no longer counts as a live viewer.
|
||||
VIEWER_MARKER_FRESH_SEC = 5.0
|
||||
|
||||
+60
-31
@@ -101,11 +101,10 @@ def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||
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.
|
||||
Delegates to src.element_style.element_color, which 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 accepted.
|
||||
"""
|
||||
from src.element_style import element_color as _shared
|
||||
return _shared(config, element, default, mode)
|
||||
@@ -125,12 +124,11 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
|
||||
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
|
||||
Ambiguity is therefore 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*.
|
||||
genuine disagreement falls back to *default* -- otherwise an element
|
||||
drawn in any of the shipped bitmap fonts could lose a colour the user set.
|
||||
|
||||
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
|
||||
@@ -339,6 +337,18 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||
if not raw:
|
||||
return ""
|
||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
|
||||
|
||||
|
||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
||||
|
||||
The body both date formatters share. They differ in which setting names the
|
||||
style and in which zone the weekday is taken from (see
|
||||
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
||||
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
||||
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
||||
"""
|
||||
if fmt == "numeric":
|
||||
return raw
|
||||
parts = raw.replace("-", "/").split("/")
|
||||
@@ -347,14 +357,14 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||
month, day = int(parts[0]), int(parts[1])
|
||||
if not 1 <= month <= 12:
|
||||
return raw
|
||||
name = MONTH_ABBR[month - 1]
|
||||
name = months[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}"
|
||||
day_name = weekday()
|
||||
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||
return f"{name} {day}"
|
||||
|
||||
|
||||
@@ -388,6 +398,29 @@ def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
||||
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
|
||||
def _read_schema_font_sizes(schema_path: str) -> Dict[str, int]:
|
||||
"""``{element: font_size default}`` from a config_schema.json. Raises.
|
||||
|
||||
The parse both schema-default lookups share. Each keeps its own cache --
|
||||
this function per schema path, ``SportsCoreSharedMixin._schema_font_size``
|
||||
per class -- because the lifetimes differ: a class is rebuilt when the
|
||||
display service reloads a plugin, a module-level path cache is not. One
|
||||
cache would change when a reloaded plugin sees an edited schema.
|
||||
"""
|
||||
import json
|
||||
with open(schema_path) as fh:
|
||||
schema = json.load(fh)
|
||||
props = (schema.get('properties', {})
|
||||
.get('customization', {})
|
||||
.get('properties', {}))
|
||||
sizes: Dict[str, int] = {}
|
||||
for key, spec in props.items():
|
||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||
if size is not None:
|
||||
sizes[key] = int(size)
|
||||
return sizes
|
||||
|
||||
|
||||
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||
"""The font_size this plugin's config_schema.json declares, or None.
|
||||
|
||||
@@ -399,18 +432,8 @@ def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||
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)
|
||||
cache = _read_schema_font_sizes(schema_path)
|
||||
except Exception as exc:
|
||||
# See sports_shared._schema_font_size: an unreadable schema
|
||||
# silently disables the pixel-grid snap for every element.
|
||||
@@ -444,7 +467,7 @@ def resolve_font_size(schema_path: str, element_config, element_key,
|
||||
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||
|
||||
|
||||
def unshare_element_fonts(logger, fonts):
|
||||
def unshare_element_fonts(logger, fonts, element_for_font=None):
|
||||
"""Give each colourable element its own face object.
|
||||
|
||||
The colour a draw gets is resolved from the face it was handed, and
|
||||
@@ -458,14 +481,20 @@ def unshare_element_fonts(logger, fonts):
|
||||
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.
|
||||
path) are left shared; resolve_font_color then picks their colour.
|
||||
|
||||
*element_for_font* names the font keys to consider, in order (the first
|
||||
holder of a face keeps it); it defaults to this module's
|
||||
:data:`ELEMENT_FOR_FONT`. ``SportsCoreSharedMixin`` passes its own map,
|
||||
which names different keys -- see ``resolve_font_color`` for why the two
|
||||
vocabularies are kept apart.
|
||||
"""
|
||||
try:
|
||||
from src.common.font_layout import load_truetype as _load
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
# Looked up at call time so tests can spy on the pinned loader.
|
||||
from src.common.font_layout import load_truetype
|
||||
if element_for_font is None:
|
||||
element_for_font = ELEMENT_FOR_FONT
|
||||
seen = {}
|
||||
for key in ELEMENT_FOR_FONT:
|
||||
for key in element_for_font:
|
||||
font = fonts.get(key)
|
||||
if font is None:
|
||||
continue
|
||||
@@ -476,7 +505,7 @@ def unshare_element_fonts(logger, fonts):
|
||||
if not path or not size:
|
||||
continue
|
||||
try:
|
||||
fonts[key] = _load(path, size)
|
||||
fonts[key] = load_truetype(path, size)
|
||||
except (OSError, ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Could not un-share the %s face; it keeps the default colour", key)
|
||||
|
||||
@@ -56,21 +56,13 @@ class SportsGameRendererMixin:
|
||||
|
||||
# ---- 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.
|
||||
"""Centre strip the score needs: the width of _SCORE_PROBE in the
|
||||
score font plus a gutter each side, or 0 if it cannot be measured.
|
||||
|
||||
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.
|
||||
Measured rather than derived from the card width, because the score's
|
||||
size comes from config and the element-style resolver: a strip sized
|
||||
from the width alone lets a large score run over the logos.
|
||||
"""
|
||||
try:
|
||||
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||
@@ -87,6 +79,10 @@ class SportsGameRendererMixin:
|
||||
the card width between the configurable min and max. 0 restores
|
||||
edge-to-edge logos.
|
||||
"""
|
||||
# Non-finite settings are rejected before any int()/round(): "inf"
|
||||
# arrives from config as a float or a string, passes the isinstance
|
||||
# and >= 0 checks, and int() then raises OverflowError, which would
|
||||
# abort the whole card render.
|
||||
configured = self._scroll_card_option("center_gap")
|
||||
if (isinstance(configured, (int, float))
|
||||
and math.isfinite(configured) and configured >= 0):
|
||||
@@ -110,10 +106,9 @@ class SportsGameRendererMixin:
|
||||
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.
|
||||
Not 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. At least 8 px.
|
||||
"""
|
||||
available = (self.display_width - self._center_gap_width()) // 2
|
||||
return max(8, available)
|
||||
@@ -130,9 +125,8 @@ class SportsGameRendererMixin:
|
||||
"""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.
|
||||
_get_layout_offset), so a nudge configured in the web UI moves the
|
||||
element on the scroll/Vegas card as well as on the scorebug.
|
||||
"""
|
||||
from src.element_style import layout_offset
|
||||
return layout_offset(self.config, element, axis, default,
|
||||
|
||||
@@ -487,9 +487,9 @@ class SportsScrollDisplayManager:
|
||||
)
|
||||
except Exception:
|
||||
# prepare_scroll_content is subclass-implemented and builds cards
|
||||
# straight from feed data, which is exactly where this PR's other
|
||||
# crashes came from. One sport's bad payload must not take down the
|
||||
# shared orchestration for the others.
|
||||
# straight from feed data, so it can raise on a malformed payload.
|
||||
# One sport's bad payload must not take down the shared
|
||||
# orchestration for the others.
|
||||
self.logger.exception(
|
||||
"Error preparing scroll content for game_type=%s", game_type
|
||||
)
|
||||
|
||||
+75
-142
@@ -64,14 +64,27 @@ live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three d
|
||||
a side and override it, the same two that override ``_SCORE_PROBE`` on
|
||||
``SportsGameRendererMixin``.
|
||||
|
||||
DELIBERATELY NOT MERGED WITH sports_card
|
||||
----------------------------------------
|
||||
Fourteen of these have same-named twins in ``src/common/sports_card.py``, which
|
||||
the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together
|
||||
here. Only five are provably equivalent by source comparison; the other nine
|
||||
differ in ways inspection cannot settle, and a wrong guess silently changes what
|
||||
every scoreboard draws. Merging them needs differential testing against both
|
||||
implementations, and is left for its own change.
|
||||
TWINS IN sports_card
|
||||
--------------------
|
||||
Many of these have same-named twins in ``src/common/sports_card.py``, which the
|
||||
scoreboards' ``game_renderer.py`` uses. ``test/test_sports_twins.py`` calls
|
||||
each pair with the same inputs (the plugins' fixture games in every payload
|
||||
shape, plus edge cases) and splits them in two:
|
||||
|
||||
- Identical: ``_card_option``, ``_vs_text``, ``_format_game_time``,
|
||||
``_coerce_rgb``, ``_crisp_size``, ``_unshare_element_fonts`` (given the same
|
||||
element map) and the constant tables. These are now thin wrappers over the
|
||||
``sports_card`` function; ``_format_game_date`` and ``_schema_font_size``
|
||||
share its body/parser while keeping their own setting, zone and cache.
|
||||
``_resolve_font_size`` agrees too but keeps its body, because it dispatches
|
||||
through the overridable ``_schema_font_size``/``_crisp_size``.
|
||||
- Different, and pinned as they are: ``_side_is_favorite`` /
|
||||
``_favorite_result`` / ``_recent_score_color`` (flat keys and the host's
|
||||
favourites only), ``_weekday_for`` (the plugin's resolved zone, not
|
||||
``config["timezone"]``), ``_font_color`` / ``_ELEMENT_FOR_FONT`` (another
|
||||
element vocabulary), ``_element_color`` (passes ``SKIN_MODE``). Each shows
|
||||
up in one display mode only, so which side is right is a product decision;
|
||||
the test that pins it names the difference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -84,10 +97,11 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from src.common.espn_dates import fetch_espn_scoreboard
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
from PIL import Image, ImageDraw
|
||||
from src.common import sports_card as _card
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,36 +132,16 @@ def _resolve_font_path(path: str) -> str:
|
||||
load raises, the caller falls back, and the scoreboard renders in PIL's
|
||||
default face instead of the pixel font it was laid out for.
|
||||
|
||||
Resolution order matches the core's own resolver: the path as given
|
||||
first, so behaviour is unchanged wherever it already worked and a
|
||||
configured absolute path is returned untouched, then the core install
|
||||
root, then the original string so callers still raise and fall back
|
||||
exactly as they do today.
|
||||
Resolution order: the path as given, relative to the cwd, when it
|
||||
exists -- the order the scoreboards' own sports.py copies used, so a
|
||||
process running from another checkout keeps that checkout's fonts --
|
||||
then :func:`src.common.font_layout.resolve_asset_path` (the install
|
||||
root), which returns the original string when neither exists so callers
|
||||
still raise and fall back.
|
||||
"""
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
try:
|
||||
import src.font_manager as _core_fonts
|
||||
|
||||
# The core grew this resolver in ChuckBuilds/LEDMatrix#425. Use it
|
||||
# when it is there so both repos stay on one definition of "install
|
||||
# root"; older cores fall through to the equivalent derivation below.
|
||||
manager = getattr(_core_fonts, "FontManager", None)
|
||||
resolver = getattr(manager, "_resolve_asset_path", None)
|
||||
if resolver is not None:
|
||||
resolved = resolver(path)
|
||||
if resolved and os.path.exists(resolved):
|
||||
return resolved
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__)))
|
||||
candidate = os.path.join(root, path)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
except (ImportError, AttributeError, OSError):
|
||||
# No core on the path (standalone tooling), a core laid out
|
||||
# differently, or an unreadable install. Returning the original keeps
|
||||
# the caller's existing fallback intact.
|
||||
return path
|
||||
return path
|
||||
return resolve_asset_path(path)
|
||||
|
||||
|
||||
class SportsCoreSharedMixin:
|
||||
@@ -171,27 +165,22 @@ class SportsCoreSharedMixin:
|
||||
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
|
||||
"score": "score_text", "time": "period_text", "team": "team_text",
|
||||
"detail": "detail_text", "status": "status_text"}
|
||||
# The tables below are sports_card's (and font_layout's) values. The dicts
|
||||
# are copies, so a caller that mutates one module's table -- or a subclass
|
||||
# that replaces it -- does not reach into the other.
|
||||
#: Default tint for a favourite team's finished game.
|
||||
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = {
|
||||
"win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)}
|
||||
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = (
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = (
|
||||
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = dict(
|
||||
_card.FAVORITE_RESULT_COLOR_DEFAULTS)
|
||||
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = _card.MONTH_ABBR
|
||||
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = _card.WEEKDAY_ABBR
|
||||
#: Bitmap fonts snap to their native pixel grid.
|
||||
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = {
|
||||
"PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7}
|
||||
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = {
|
||||
"press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"}
|
||||
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = dict(_card.FONT_PIXEL_GRID)
|
||||
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = dict(_card.FONT_NAME_ALIASES)
|
||||
#: Accepted values for the other-games quality filter.
|
||||
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
|
||||
#: How long to stay quiet between ranking-coverage warnings.
|
||||
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
|
||||
|
||||
def _get_season_schedule_dates(self) -> tuple[str, str]:
|
||||
return "", ""
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Placeholder draw method - subclasses should override."""
|
||||
# This base method will be simple, subclasses provide specifics
|
||||
@@ -213,13 +202,11 @@ class SportsCoreSharedMixin:
|
||||
"""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.
|
||||
font is never second-guessed. The class's own tables are passed, so a
|
||||
host that declares extra faces keeps them.
|
||||
"""
|
||||
font_file = cls._FONT_NAME_ALIASES.get(font_file, font_file)
|
||||
grid = cls._FONT_PIXEL_GRID.get(font_file)
|
||||
if not grid or not desired or desired <= 0:
|
||||
return desired
|
||||
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||
return _card.crisp_size(font_file, desired,
|
||||
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
|
||||
|
||||
#: Absolute path of this plugin's directory, declared by the plugin
|
||||
#: itself. The mixin cannot work it out -- see _plugin_dir.
|
||||
@@ -281,23 +268,19 @@ class SportsCoreSharedMixin:
|
||||
"""The font_size this plugin's config_schema.json declares, or None."""
|
||||
if not element_key:
|
||||
return None
|
||||
# Cached per class, not in sports_card's per-path cache: the display
|
||||
# service rebuilds the class when it reloads a plugin, and that is
|
||||
# what makes an edited schema take effect. Both caches parse through
|
||||
# sports_card._read_schema_font_sizes.
|
||||
cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
try:
|
||||
import json
|
||||
directory = self._plugin_dir()
|
||||
if directory is None:
|
||||
raise FileNotFoundError("no config_schema.json on the MRO")
|
||||
with open(os.path.join(directory, 'config_schema.json')) 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)
|
||||
cache = _card._read_schema_font_sizes(
|
||||
os.path.join(directory, 'config_schema.json'))
|
||||
except Exception as exc:
|
||||
# Say so. An unreadable schema is not cosmetic: every element's
|
||||
# configured size then stops matching "the schema default", is
|
||||
@@ -339,10 +322,7 @@ class SportsCoreSharedMixin:
|
||||
|
||||
def _card_option(self, key: str, default: Any = None) -> Any:
|
||||
"""Read one key from the scroll_card config block."""
|
||||
block = (self.config or {}).get("scroll_card")
|
||||
if isinstance(block, dict) and block.get(key) is not None:
|
||||
return block.get(key)
|
||||
return default
|
||||
return _card.scroll_card_option(self.config, key, default)
|
||||
|
||||
def _switch_upcoming_center(self) -> str:
|
||||
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
|
||||
@@ -354,7 +334,7 @@ class SportsCoreSharedMixin:
|
||||
|
||||
def _vs_text(self) -> str:
|
||||
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||
return str(self._card_option("vs_text", "VS"))
|
||||
return _card.vs_text(self.config)
|
||||
|
||||
def _switch_date_format(self) -> str:
|
||||
"""Date style for the full-screen scorebug.
|
||||
@@ -374,28 +354,19 @@ class SportsCoreSharedMixin:
|
||||
return fmt
|
||||
|
||||
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
|
||||
"""Format an upcoming date per scroll_card.switch_date_format."""
|
||||
"""Format an upcoming date per scroll_card.switch_date_format.
|
||||
|
||||
The formatting is sports_card's. What differs from the card's
|
||||
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
||||
see :meth:`_switch_date_format`) and the weekday, which comes from
|
||||
:meth:`_weekday_for` and so from this plugin's resolved timezone.
|
||||
"""
|
||||
raw = str(date_text or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
fmt = self._switch_date_format()
|
||||
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 = self._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 = self._weekday_for(game)
|
||||
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||
return f"{name} {day}"
|
||||
return _card._format_date_as(self._switch_date_format(), raw,
|
||||
lambda: self._weekday_for(game),
|
||||
self._MONTH_ABBR)
|
||||
|
||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||
"""Weekday abbreviation from the game's start time, or ''."""
|
||||
@@ -413,22 +384,7 @@ class SportsCoreSharedMixin:
|
||||
|
||||
def _format_game_time(self, 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(self._card_option("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}"
|
||||
return _card.format_game_time(self.config, time_text)
|
||||
|
||||
def _scorebug_font(self, draw, text: str, width: int):
|
||||
"""The face this scorebug draws its date and time in.
|
||||
@@ -560,15 +516,7 @@ class SportsCoreSharedMixin:
|
||||
@staticmethod
|
||||
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)
|
||||
return _card.coerce_rgb(value, fallback)
|
||||
|
||||
@staticmethod
|
||||
def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool:
|
||||
@@ -849,28 +797,12 @@ class SportsCoreSharedMixin:
|
||||
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.
|
||||
|
||||
The body is sports_card's; this class's own element map is passed, so
|
||||
the keys considered are the ones this class colours by.
|
||||
"""
|
||||
try:
|
||||
from src.common.font_layout import load_truetype as _load
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
seen = {}
|
||||
for key in self._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):
|
||||
self.logger.debug(
|
||||
"Could not un-share the %s face; it keeps the default colour", key)
|
||||
return fonts
|
||||
return _card.unshare_element_fonts(self.logger, fonts,
|
||||
self._ELEMENT_FOR_FONT)
|
||||
|
||||
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""Colour for whichever element owns this face.
|
||||
@@ -934,7 +866,10 @@ class SportsCoreSharedMixin:
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
||||
"""Check if we should log a warning based on cooldown period."""
|
||||
"""True at most once per ``cooldown`` seconds, for rate-limiting a
|
||||
warning. The cooldown is shared by every warning on this manager:
|
||||
``warning_type`` is part of the signature scoreboards inherit, but
|
||||
does not give each type its own cooldown."""
|
||||
current_time = time.time()
|
||||
if current_time - self._last_warning_time > cooldown:
|
||||
self._last_warning_time = current_time
|
||||
@@ -949,8 +884,6 @@ class SportsCoreSharedMixin:
|
||||
try:
|
||||
# Fetch current week and next few days for immediate display
|
||||
now = datetime.now(pytz.utc)
|
||||
immediate_events = []
|
||||
|
||||
start_date = now - timedelta(days=self.schedule_lookback_days)
|
||||
end_date = now + timedelta(days=self.schedule_lookahead_days)
|
||||
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
|
||||
@@ -958,7 +891,7 @@ class SportsCoreSharedMixin:
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session,
|
||||
url,
|
||||
params={"dates": date_str, "limit": 1000},
|
||||
params={"dates": date_str, "limit": ESPN_MAX_LIMIT},
|
||||
headers=self.headers,
|
||||
timeout=10,
|
||||
logger=self.logger,
|
||||
|
||||
+29
-27
@@ -32,7 +32,7 @@ from typing import Callable, Optional
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH
|
||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_ROWS
|
||||
|
||||
# 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
|
||||
@@ -75,9 +75,12 @@ class FollowerState(Enum):
|
||||
class DisplaySyncManager:
|
||||
"""
|
||||
Core sync manager. Instantiated by DisplayController based on config['sync'].
|
||||
Leader sends compressed PNG frames to the follower after each render cycle.
|
||||
Follower renders received frames; returns to own plugin stack when leader
|
||||
goes offline.
|
||||
|
||||
The leader sends each rendered frame to the follower over UDP as raw RGB
|
||||
bytes (send_frame), and for Vegas scrolling sends the whole scroll image
|
||||
once per cycle as a PNG over TCP on port + 1 (send_scroll_image), then
|
||||
only the scroll position. The follower draws what it receives and goes
|
||||
back to its own plugins when the leader stops sending.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -192,8 +195,8 @@ class DisplaySyncManager:
|
||||
|
||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||
hw = self._hw_config
|
||||
local_rows = hw.get("rows", 32)
|
||||
local_cols = hw.get("cols", 64)
|
||||
local_rows = hw.get("rows", DEFAULT_ROWS)
|
||||
local_cols = hw.get("cols", DEFAULT_COLS)
|
||||
peer_rows = int(msg.get("rows", 0))
|
||||
peer_cols = int(msg.get("cols", 0))
|
||||
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
|
||||
@@ -469,16 +472,23 @@ class DisplaySyncManager:
|
||||
"""Record a decoded leader frame and enter follower mode if needed."""
|
||||
with self._frame_lock:
|
||||
self._latest_frame = img
|
||||
self._enter_follower_mode(sender_ip)
|
||||
|
||||
def _enter_follower_mode(self, sender_ip: str) -> bool:
|
||||
"""Note that the leader at ``sender_ip`` just sent something, and
|
||||
switch from standalone to follower mode if not already following.
|
||||
Returns True if this call made the switch."""
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
if self._follower_state != FollowerState.STANDALONE:
|
||||
return False
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
return True
|
||||
|
||||
def _follower_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
@@ -559,15 +569,7 @@ class DisplaySyncManager:
|
||||
# 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._leader_ip = sender_ip
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
if self._enter_follower_mode(sender_ip):
|
||||
fire_new_cycle = True # build initial scroll image
|
||||
elif t == "nc":
|
||||
# Leader started a new scroll cycle — rebuild local image
|
||||
@@ -589,8 +591,8 @@ class DisplaySyncManager:
|
||||
hw = self._hw_config
|
||||
hello = json.dumps({
|
||||
"t": "hello",
|
||||
"rows": hw.get("rows", 32),
|
||||
"cols": hw.get("cols", 64),
|
||||
"rows": hw.get("rows", DEFAULT_ROWS),
|
||||
"cols": hw.get("cols", DEFAULT_COLS),
|
||||
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||
}).encode("utf-8")
|
||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||
@@ -660,8 +662,8 @@ class DisplaySyncManager:
|
||||
base = {
|
||||
"role": self.role.value,
|
||||
"port": self.port,
|
||||
"local_rows": hw.get("rows", 32),
|
||||
"local_cols": hw.get("cols", 64),
|
||||
"local_rows": hw.get("rows", DEFAULT_ROWS),
|
||||
"local_cols": hw.get("cols", DEFAULT_COLS),
|
||||
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||
}
|
||||
|
||||
|
||||
+26
-22
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
|
||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
@@ -18,13 +18,14 @@ _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
|
||||
class TextHelper:
|
||||
"""
|
||||
Helper class for text rendering with outlines and font management.
|
||||
|
||||
Provides functionality for:
|
||||
- Loading and managing fonts
|
||||
- Drawing text with outlines for better readability
|
||||
- Calculating text dimensions and positioning
|
||||
- Managing font resources
|
||||
Font loading, outlined text and text measurement for plugins.
|
||||
|
||||
- :meth:`load_fonts` loads TrueType fonts from ``font_dir`` (the install's
|
||||
assets/fonts by default) with the layout engine pinned
|
||||
(font_layout.load_truetype). Each (file, size) is loaded once per helper
|
||||
and reused; a missing or unloadable file becomes PIL's default font.
|
||||
- :meth:`draw_text_with_outline` and friends draw onto a caller's
|
||||
``ImageDraw``; the measuring methods need no canvas.
|
||||
"""
|
||||
|
||||
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
|
||||
@@ -33,22 +34,26 @@ class TextHelper:
|
||||
Initialize the TextHelper.
|
||||
|
||||
Args:
|
||||
font_dir: Directory containing font files (defaults to assets/fonts)
|
||||
font_dir: Directory containing font files. Defaults to the
|
||||
install's assets/fonts, whatever the process cwd is.
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.font_dir = Path(font_dir) if font_dir else Path("assets/fonts")
|
||||
self.font_dir = Path(font_dir) if font_dir else Path(resolve_asset_path("assets/fonts"))
|
||||
# "<path>:<size>" -> loaded font; see load_fonts.
|
||||
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
|
||||
|
||||
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
|
||||
"""
|
||||
Load fonts for different text elements.
|
||||
|
||||
|
||||
Args:
|
||||
font_config: Custom font configuration dictionary
|
||||
|
||||
font_config: ``{name: {"file": <file in font_dir>, "size": <px>}}``;
|
||||
defaults to the scoreboard set in _get_default_font_config.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping font names to PIL ImageFont objects
|
||||
Dictionary mapping font names to PIL ImageFont objects. A font
|
||||
already loaded by this helper at the same size is reused.
|
||||
"""
|
||||
if font_config is None:
|
||||
font_config = self._get_default_font_config()
|
||||
@@ -61,9 +66,13 @@ class TextHelper:
|
||||
size = config['size']
|
||||
|
||||
if font_path.exists():
|
||||
font = load_truetype(str(font_path), size)
|
||||
cache_key = f"{font_path}:{size}"
|
||||
font = self._font_cache.get(cache_key)
|
||||
if font is None:
|
||||
font = load_truetype(str(font_path), size)
|
||||
self._font_cache[cache_key] = font
|
||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||
fonts[font_name] = font
|
||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
||||
else:
|
||||
# Fallback to default font
|
||||
font = ImageFont.load_default()
|
||||
@@ -115,12 +124,7 @@ class TextHelper:
|
||||
Returns:
|
||||
Width in pixels
|
||||
"""
|
||||
try:
|
||||
return int(_measure_draw.textlength(text, font=font))
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
return int(_measure_draw.textlength(text, font=font))
|
||||
|
||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
"""
|
||||
|
||||
+30
-37
@@ -16,8 +16,10 @@ additionally keeps rotating backups in ``config/backups/``.
|
||||
Plugin configuration
|
||||
--------------------
|
||||
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
||||
to write plugin settings; never write directly to the plugin directory.
|
||||
and survive plugin reinstalls. Write them by saving the whole config with
|
||||
:meth:`ConfigManager.save_config_atomic` (or
|
||||
:meth:`ConfigManager.save_raw_file_content`); never write settings into the
|
||||
plugin directory, which a reinstall deletes.
|
||||
|
||||
Hot-reload
|
||||
----------
|
||||
@@ -127,13 +129,10 @@ class ConfigManager:
|
||||
# Update in-memory config if save was successful
|
||||
if result.status == SaveResultStatus.SUCCESS:
|
||||
self.config = new_config_data
|
||||
# In-memory config now matches what was just written; refresh
|
||||
# the load signature so the fast path stays valid. NOTE: the
|
||||
# in-memory copy includes merged secrets; the on-disk file has
|
||||
# them stripped — the fast path returning self.config preserves
|
||||
# exactly the pre-cache behavior (load-after-save also returned
|
||||
# the secret-merged self.config only after re-reading secrets;
|
||||
# here secrets file is unchanged, so contents are equivalent).
|
||||
# In-memory config now matches what was just written, so the
|
||||
# load_config fast path may return it. It still carries the
|
||||
# merged secrets that were stripped on disk; that matches a full
|
||||
# reload, because the secrets file was not changed by the save.
|
||||
self._loaded_sig = self._files_signature()
|
||||
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
||||
elif result.status == SaveResultStatus.ROLLED_BACK:
|
||||
@@ -253,11 +252,11 @@ class ConfigManager:
|
||||
return self.config
|
||||
|
||||
except FileNotFoundError as e:
|
||||
if str(e).find('config_secrets.json') == -1: # Only raise if main config is missing
|
||||
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
return self.config
|
||||
# Only config.json can get here: a missing or unreadable secrets
|
||||
# file is handled where it is read.
|
||||
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
@@ -320,10 +319,9 @@ class ConfigManager:
|
||||
A missing secrets file is fine (nothing to strip). But a file that
|
||||
EXISTS and cannot be read or parsed means stripping is impossible —
|
||||
and the in-memory config being saved has secrets deep-merged into it,
|
||||
so proceeding would write them into config.json in plaintext. That
|
||||
was the historical behavior; it is now a hard refusal. The save
|
||||
raises so the caller (and user) fixes the secrets file instead of
|
||||
silently leaking its contents into the world-readable main config.
|
||||
so proceeding would write them into config.json in plaintext. The
|
||||
save raises instead, so the caller (and user) fixes the secrets file
|
||||
rather than leaking its contents into the world-readable main config.
|
||||
"""
|
||||
if not os.path.exists(self.secrets_path):
|
||||
return {}
|
||||
@@ -465,9 +463,8 @@ class ConfigManager:
|
||||
# Merge template defaults into current config
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
|
||||
# Save migrated config using atomic save to preserve permissions
|
||||
# Use atomic save to preserve file permissions
|
||||
# Note: save_config_atomic handles secrets internally
|
||||
# save_config_atomic strips the merged secrets back out and
|
||||
# keeps the file's owner and mode.
|
||||
result = self.save_config_atomic(
|
||||
new_config_data=self.config,
|
||||
create_backup=False, # Already created backup above
|
||||
@@ -600,20 +597,16 @@ class ConfigManager:
|
||||
|
||||
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
||||
|
||||
# If we just saved the main config or secrets, the merged self.config might be stale.
|
||||
# Reload it to reflect the new state.
|
||||
# Note: We wrap this in try-except because reload failures (e.g., migration errors)
|
||||
# should not cause the save operation to fail - the file was saved successfully.
|
||||
if file_type == "main" or file_type == "secrets":
|
||||
try:
|
||||
self.load_config()
|
||||
except Exception as reload_error:
|
||||
# Log the reload error but don't fail the save operation
|
||||
# The file was saved successfully, reload is just for in-memory consistency
|
||||
self.logger.warning(
|
||||
f"Configuration file saved successfully, but reload failed: {reload_error}. "
|
||||
f"The file on disk is valid, but in-memory config may be stale."
|
||||
)
|
||||
# The merged self.config is now stale; reload it. A reload failure
|
||||
# (a migration error, say) is logged, not raised: the file itself
|
||||
# was saved.
|
||||
try:
|
||||
self.load_config()
|
||||
except Exception as reload_error:
|
||||
self.logger.warning(
|
||||
f"Configuration file saved successfully, but reload failed: {reload_error}. "
|
||||
f"The file on disk is valid, but in-memory config may be stale."
|
||||
)
|
||||
|
||||
except PermissionError as e:
|
||||
# Provide helpful error message with fix instructions
|
||||
@@ -670,7 +663,7 @@ class ConfigManager:
|
||||
try:
|
||||
# Load current configs
|
||||
main_config = self.get_raw_file_content('main')
|
||||
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
|
||||
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
|
||||
|
||||
# Remove plugin from main config
|
||||
if plugin_id in main_config:
|
||||
@@ -703,7 +696,7 @@ class ConfigManager:
|
||||
try:
|
||||
# Load current configs
|
||||
main_config = self.get_raw_file_content('main')
|
||||
secrets_config = self.get_raw_file_content('secrets') if os.path.exists(self.secrets_path) else {}
|
||||
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
|
||||
|
||||
valid_set = set(valid_plugin_ids)
|
||||
|
||||
|
||||
+628
-699
File diff suppressed because it is too large
Load Diff
+203
-133
@@ -34,25 +34,27 @@ else:
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.bdf_font import draw_bdf_text, load_bdf_face
|
||||
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
||||
from src import scan_order
|
||||
from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
|
||||
resolve_double_sided,
|
||||
compose_pixel_mapper_config, physical_size, resolve_double_sided,
|
||||
)
|
||||
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
|
||||
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
import logging
|
||||
import math
|
||||
import zlib
|
||||
import freetype
|
||||
|
||||
from src.common import snapshot_policy
|
||||
from src.common.frame_timing import FrameTimingRecorder
|
||||
from src.deprecation import deprecated
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
@@ -60,9 +62,7 @@ from src.common.permission_utils import (
|
||||
get_assets_file_mode,
|
||||
)
|
||||
|
||||
# Get logger without configuring
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: 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 --
|
||||
@@ -211,11 +211,6 @@ def _per_thread_canvas_attr(name: str) -> property:
|
||||
return property(fget, fset, doc=f"The plugin-facing ``{name}``, per thread.")
|
||||
|
||||
|
||||
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
|
||||
# sync handshake compute the display size exactly as DisplayManager does
|
||||
# without importing rgbmatrix. Aliased here for existing callers.
|
||||
_resolve_double_sided = resolve_double_sided
|
||||
|
||||
|
||||
class DisplayManager:
|
||||
"""
|
||||
@@ -237,7 +232,6 @@ class DisplayManager:
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
@@ -288,11 +282,19 @@ class DisplayManager:
|
||||
self._last_snapshot_ts = 0.0
|
||||
self._last_snapshot_touch_ts = 0.0
|
||||
self._last_snapshot_digest: Optional[int] = None
|
||||
# The frame actually on disk. _last_snapshot_digest moves when a frame
|
||||
# is handed to the writer; this only once it has been saved, so an
|
||||
# mtime touch never vouches for a frame still waiting to be written.
|
||||
self._saved_snapshot_digest: Optional[int] = None
|
||||
self._snapshot_dir_prepared = False
|
||||
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
||||
self._snapshot_cond = threading.Condition()
|
||||
self._snapshot_pending: Optional[Image.Image] = None
|
||||
self._snapshot_pending: Optional[Tuple[Image.Image, Optional[int]]] = None
|
||||
self._snapshot_thread: Optional[threading.Thread] = None
|
||||
self._snapshot_stop = False
|
||||
# Held for the whole of each PNG write, by the writer thread and by
|
||||
# the inline static path, so the two land on disk in order.
|
||||
self._snapshot_write_lock = threading.Lock()
|
||||
self._viewer_check_ts = 0.0
|
||||
self._viewer_fresh = False
|
||||
self._viewer_was_fresh = False
|
||||
@@ -322,6 +324,16 @@ class DisplayManager:
|
||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
||||
self._frame_hold = 1
|
||||
|
||||
# A src.common.render_gate.RenderGate while Vegas runs with
|
||||
# vegas_scroll.prefetch_gate on: opened around each swap so the
|
||||
# prefetch thread only runs Python while this thread waits on vsync.
|
||||
self.render_gate = None
|
||||
|
||||
# Timing of every presented frame, whoever drew it, for
|
||||
# scripts/frame_soak.py. See src/common/frame_timing.py.
|
||||
self.frame_timing = FrameTimingRecorder(info=self._frame_timing_info())
|
||||
self.frame_timing.scrolling_now = self._scrolling_now
|
||||
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
'last_scroll_activity': 0,
|
||||
@@ -333,24 +345,21 @@ class DisplayManager:
|
||||
|
||||
self._setup_matrix()
|
||||
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
||||
self._setup_scan_order_compensation()
|
||||
|
||||
font_time = time.time()
|
||||
self._load_fonts()
|
||||
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
||||
|
||||
# Initialize managers
|
||||
# Calendar manager is now initialized by DisplayController
|
||||
|
||||
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
|
||||
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
|
||||
|
||||
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
|
||||
"""Compose pixel_mapper_config with the orientation setting.
|
||||
def _new_canvas(self, width: int, height: int) -> None:
|
||||
"""Replace ``image``/``draw`` with a black canvas of the given size.
|
||||
|
||||
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
|
||||
web preview shares so it sizes the canvas the same way.
|
||||
Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
|
||||
brightness, so anti-aliasing only smears glyphs.
|
||||
"""
|
||||
return compose_pixel_mapper_config(hardware_config)
|
||||
self.image = Image.new('RGB', (width, height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1"
|
||||
|
||||
@staticmethod
|
||||
def _fallback_advice(cause: str, error: Exception) -> str:
|
||||
@@ -419,7 +428,7 @@ class DisplayManager:
|
||||
# logical (per-screen) size, and keep a full-chain buffer to tile
|
||||
# the rendered screen into once per frame.
|
||||
ds_config = self.config.get('display', {}).get('double_sided', {})
|
||||
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
|
||||
ds = resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
|
||||
self._double_sided = ds
|
||||
if ds is not None:
|
||||
self._physical_image = Image.new(
|
||||
@@ -428,9 +437,7 @@ class DisplayManager:
|
||||
self.matrix, ds['logical_width'], ds['logical_height'])
|
||||
|
||||
# Create image with the (logical) display dimensions
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
self._new_canvas(self.matrix.width, self.matrix.height)
|
||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||
|
||||
# Initialize font with Press Start 2P
|
||||
@@ -461,7 +468,7 @@ class DisplayManager:
|
||||
fallback_width, fallback_height = physical_size(self.config)
|
||||
# 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 = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||
ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||
self._double_sided = ds
|
||||
if ds is not None:
|
||||
fallback_width = ds['logical_width']
|
||||
@@ -469,9 +476,7 @@ class DisplayManager:
|
||||
except Exception:
|
||||
fallback_width, fallback_height = 128, 32
|
||||
|
||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
self._new_canvas(fallback_width, fallback_height)
|
||||
# Simple fallback visualization so web UI shows a realistic canvas
|
||||
try:
|
||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||
@@ -700,18 +705,14 @@ class DisplayManager:
|
||||
line, font=font, fill=(0, 0, 255))
|
||||
|
||||
def _draw_test_pattern(self):
|
||||
"""Draw a test pattern to verify the display is working."""
|
||||
"""Draw a test pattern to verify the display is working.
|
||||
|
||||
Only called from _setup_matrix once the matrix exists; fallback mode
|
||||
draws its own "Simulation" canvas there.
|
||||
"""
|
||||
try:
|
||||
self.clear()
|
||||
|
||||
if self.matrix is None:
|
||||
# Fallback mode - just draw on the image
|
||||
self.draw.rectangle([0, 0, self.image.width-1, self.image.height-1], outline=(255, 0, 0))
|
||||
self.draw.line([0, 0, self.image.width-1, self.image.height-1], fill=(0, 255, 0))
|
||||
self.draw.text((10, 10), "Simulation", font=self.font, fill=(0, 0, 255))
|
||||
logger.info("Drew test pattern in fallback mode")
|
||||
return
|
||||
|
||||
|
||||
# Draw a red rectangle border
|
||||
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
|
||||
|
||||
@@ -726,7 +727,7 @@ class DisplayManager:
|
||||
|
||||
# Update the display once after everything is drawn
|
||||
self.update_display()
|
||||
time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||
@@ -734,7 +735,10 @@ class DisplayManager:
|
||||
@property
|
||||
def _capture_mode_active(self) -> bool:
|
||||
"""True while the calling thread is capturing content off-screen."""
|
||||
return getattr(self._capture_state, 'active', False)
|
||||
# Read like _current_surface(): a DisplayManager built without
|
||||
# __init__ (tests do) has no per-thread state, and captures nothing.
|
||||
state = self.__dict__.get('_capture_state')
|
||||
return getattr(state, 'active', False) if state is not None else False
|
||||
|
||||
@_capture_mode_active.setter
|
||||
def _capture_mode_active(self, value: bool) -> None:
|
||||
@@ -940,15 +944,26 @@ class DisplayManager:
|
||||
|
||||
# Copy the current image to the offscreen canvas. In double-sided
|
||||
# mode the logical screen is first tiled across the full chain.
|
||||
blit_started = time.perf_counter()
|
||||
if self._double_sided is not None:
|
||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||
else:
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
self.offscreen_canvas.SetImage(self._scan_compensated(self.image))
|
||||
blit_done = time.perf_counter()
|
||||
|
||||
# Swap buffers immediately. framerate_fraction holds the frame
|
||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
||||
# what paces the render loop to the chosen frame rate.
|
||||
gate = self.render_gate
|
||||
if gate is not None:
|
||||
gate.before_swap(self._frame_hold)
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
||||
if gate is not None:
|
||||
gate.after_swap(self._frame_hold)
|
||||
presented_at = time.perf_counter()
|
||||
self.frame_timing.record(
|
||||
blit_done - blit_started, presented_at - blit_done,
|
||||
self._frame_hold, self.is_currently_scrolling(), presented_at)
|
||||
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
@@ -960,33 +975,61 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating display: {e}")
|
||||
|
||||
def _setup_scan_order_compensation(self) -> None:
|
||||
"""Work out which rows to show a refresh behind while scrolling.
|
||||
|
||||
See src/scan_order.py. Only on real hardware: the emulator has no scan
|
||||
order, so there the lag would add the very step it removes elsewhere.
|
||||
"""
|
||||
self._scan_lag_bands = None
|
||||
self._scan_history = deque(maxlen=1)
|
||||
if (self.matrix is None or self._double_sided is not None
|
||||
or os.environ.get('EMULATOR', 'false') == 'true'):
|
||||
return
|
||||
display = self.config.get('display') or {}
|
||||
bands = scan_order.scan_lag_bands(
|
||||
display.get('hardware') or {}, self.height,
|
||||
display.get('scan_order_compensation', 'auto'))
|
||||
if not bands:
|
||||
return
|
||||
self._scan_lag_bands = bands
|
||||
self._scan_history = deque(maxlen=max(lag for _, _, lag in bands))
|
||||
logger.info(
|
||||
"Scan-order compensation on: while scrolling, %s",
|
||||
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
|
||||
for top, bottom, lag in bands))
|
||||
|
||||
def _scan_compensated(self, image: Image.Image) -> Image.Image:
|
||||
"""The frame to present, with lagging rows taken from earlier frames.
|
||||
|
||||
Only mid-scroll at one frame per refresh: that is when consecutive
|
||||
frames are consecutive refreshes. At a longer hold, or on a static
|
||||
screen, the history is dropped and the frame goes out as it is.
|
||||
"""
|
||||
bands = getattr(self, '_scan_lag_bands', None)
|
||||
if not bands:
|
||||
return image
|
||||
if self._frame_hold != 1 or not self.is_currently_scrolling():
|
||||
self._scan_history.clear()
|
||||
return image
|
||||
presented = scan_order.compose(image, self._scan_history, bands)
|
||||
# A copy: plugins draw into the same image object frame after frame.
|
||||
self._scan_history.appendleft(image.copy())
|
||||
return presented
|
||||
|
||||
def clear(self):
|
||||
"""Clear the display completely."""
|
||||
try:
|
||||
if self.matrix is None:
|
||||
# Fallback mode - just clear the image
|
||||
# Explicitly clear old image reference to help garbage collection
|
||||
old_image = getattr(self, 'image', None)
|
||||
width = old_image.width if old_image else 64
|
||||
height = old_image.height if old_image else 64
|
||||
if old_image is not None:
|
||||
del old_image
|
||||
|
||||
self.image = Image.new('RGB', (width, height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
self._new_canvas(width, height)
|
||||
logger.debug("Cleared display in fallback mode")
|
||||
return
|
||||
|
||||
# Explicitly clear old image reference to help garbage collection
|
||||
old_image = getattr(self, 'image', None)
|
||||
if old_image is not None:
|
||||
del old_image
|
||||
|
||||
# Create a new black image
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
|
||||
self._new_canvas(self.matrix.width, self.matrix.height)
|
||||
|
||||
if not self._writes_suppressed():
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||
@@ -1016,43 +1059,16 @@ class DisplayManager:
|
||||
logger.error(f"Error clearing display: {e}")
|
||||
|
||||
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
|
||||
"""Draw text using BDF font with proper bitmap handling."""
|
||||
"""Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left.
|
||||
|
||||
Delegates to :func:`src.common.bdf_font.draw_bdf_text`, which the
|
||||
plugin test harness uses too, so previews and golden images show the
|
||||
pixels the panel does. Clipped to the logical display size.
|
||||
"""
|
||||
try:
|
||||
# Use the passed font or fall back to calendar_font
|
||||
face = font if font else self.calendar_font
|
||||
|
||||
# Compute baseline from font ascender so caller can pass top-left y
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
bitmap = face.glyph.bitmap
|
||||
|
||||
# Get glyph metrics
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
|
||||
# Draw the character
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer):
|
||||
byte = bitmap.buffer[byte_index]
|
||||
if byte & (1 << (7 - (j % 8))):
|
||||
# Calculate actual pixel position
|
||||
pixel_x = x + glyph_left + j
|
||||
pixel_y = baseline_y - glyph_top + i
|
||||
# Only draw if within bounds
|
||||
if (0 <= pixel_x < self.width and 0 <= pixel_y < self.height):
|
||||
self.draw.point((pixel_x, pixel_y), fill=color)
|
||||
|
||||
# Move to next character
|
||||
x += face.glyph.advance.x >> 6
|
||||
|
||||
draw_bdf_text(self.draw, text, x, y, face, color,
|
||||
clip=(self.width, self.height))
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
|
||||
|
||||
@@ -1101,19 +1117,13 @@ class DisplayManager:
|
||||
if not os.path.exists(self.calendar_font_path):
|
||||
raise FileNotFoundError(f"Font file not found at {self.calendar_font_path}")
|
||||
|
||||
# Load with freetype for proper BDF handling
|
||||
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)
|
||||
# load_bdf_face sets the size: a Face built without
|
||||
# set_char_size reports face.size.height 0, and every caller
|
||||
# measuring the 5x7 face with get_font_height() got 0 and
|
||||
# stacked rows on top of one another. 5x7.bdf is a fixed
|
||||
# strike, so FreeType renders 7px whatever is asked for --
|
||||
# the size sets the metrics, not the raster.
|
||||
face, _ = load_bdf_face(self.calendar_font_path, _CALENDAR_FONT_PX)
|
||||
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
||||
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
||||
|
||||
@@ -1471,6 +1481,8 @@ class DisplayManager:
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
if hasattr(self, '_snapshot_cond'):
|
||||
self._stop_snapshot_writer()
|
||||
if hasattr(self, 'matrix') and self.matrix is not None:
|
||||
try:
|
||||
self.matrix.Clear()
|
||||
@@ -1479,14 +1491,14 @@ class DisplayManager:
|
||||
# Ensure image/draw are reset to a blank state
|
||||
if hasattr(self, 'image') and hasattr(self, 'draw'):
|
||||
try:
|
||||
self.image = Image.new('RGB', (self.width, self.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
self._new_canvas(self.width, self.height)
|
||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||
# The stall watchdog would otherwise outlive this manager.
|
||||
if getattr(self, 'frame_timing', None) is not None:
|
||||
self.frame_timing.close()
|
||||
# Reset the singleton state when cleaning up
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
def format_date_with_ordinal(self, dt):
|
||||
"""Formats a datetime object into 'Mon Aug 30th' style."""
|
||||
@@ -1524,9 +1536,9 @@ class DisplayManager:
|
||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||
# _build_pixel_mapper_config reads only class attributes, so the class
|
||||
# stands in for an instance here.
|
||||
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
|
||||
# Orientation becomes a "Rotate:<deg>" pixel mapper; the web preview
|
||||
# composes it the same way so it sizes the canvas identically.
|
||||
options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config)
|
||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||
options.panel_type = hardware_config.get('panel_type', '')
|
||||
@@ -1580,6 +1592,27 @@ class DisplayManager:
|
||||
value = 0.0
|
||||
return value if value > 0 else 100.0
|
||||
|
||||
def _scrolling_now(self) -> bool:
|
||||
"""Whether a scroll is running, without is_currently_scrolling()'s
|
||||
side effect of expiring the state -- safe from the stall watchdog's
|
||||
thread."""
|
||||
state = self._scrolling_state
|
||||
return bool(state['is_scrolling']) and (
|
||||
time.time() - state['last_scroll_activity']
|
||||
<= state['scroll_inactivity_threshold'])
|
||||
|
||||
def _frame_timing_info(self) -> Dict[str, Any]:
|
||||
"""What the frame-timing stats were measured on, for the soak report."""
|
||||
display = self.config.get('display') or {}
|
||||
hardware = display.get('hardware') or {}
|
||||
runtime = display.get('runtime') or {}
|
||||
info = {key: hardware.get(key) for key in (
|
||||
'rows', 'cols', 'chain_length', 'parallel', 'pwm_bits',
|
||||
'hardware_mapping', 'limit_refresh_rate_hz', 'pixel_mapper_config')}
|
||||
info['gpio_slowdown'] = runtime.get('gpio_slowdown')
|
||||
info['emulator'] = os.environ.get('EMULATOR', 'false') == 'true'
|
||||
return info
|
||||
|
||||
def set_frame_hold(self, refreshes: int) -> None:
|
||||
"""Hold each pushed frame for this many panel refreshes (>=1).
|
||||
|
||||
@@ -1625,13 +1658,16 @@ class DisplayManager:
|
||||
# it must not change the live scroll's state or frame hold.
|
||||
return
|
||||
current_time = time.time()
|
||||
# Scrolling callers set this every frame; log transitions only.
|
||||
changed = self._scrolling_state['is_scrolling'] != is_scrolling
|
||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||
if is_scrolling:
|
||||
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}")
|
||||
if changed:
|
||||
logger.debug("Scrolling state set to: %s", is_scrolling)
|
||||
|
||||
def is_currently_scrolling(self) -> bool:
|
||||
"""Check if the display is currently in a scrolling state."""
|
||||
@@ -1700,9 +1736,6 @@ class DisplayManager:
|
||||
if not self._scrolling_state['deferred_updates']:
|
||||
return
|
||||
|
||||
if not self._scrolling_state['deferred_updates']:
|
||||
return
|
||||
|
||||
# Process only a limited number of updates per call to avoid blocking
|
||||
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
|
||||
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
|
||||
@@ -1800,12 +1833,16 @@ class DisplayManager:
|
||||
viewer_fresh, digest != self._last_snapshot_digest)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
return
|
||||
if action is snapshot_policy.SnapshotAction.TOUCH:
|
||||
if (action is snapshot_policy.SnapshotAction.TOUCH
|
||||
and self._saved_snapshot_digest == digest):
|
||||
# mtime bump only: keeps the health check (snapshot age)
|
||||
# green without paying for a PNG encode of an unchanged frame
|
||||
os.utime(self._snapshot_path, None)
|
||||
self._last_snapshot_touch_ts = now
|
||||
return
|
||||
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
|
||||
# its write failed -- is written instead: touching would make the
|
||||
# older file on disk look current.)
|
||||
|
||||
# WRITE. Mid-scroll the PNG encode goes to a background thread: at
|
||||
# 512x64 it takes 12-14ms on a Pi 4, longer than a 95Hz refresh,
|
||||
@@ -1815,9 +1852,15 @@ class DisplayManager:
|
||||
# it compresses, so the encode no longer holds the loop up. Static
|
||||
# frames still write inline: nothing is moving to disturb.
|
||||
if self.is_currently_scrolling():
|
||||
self._queue_snapshot(self.image.copy())
|
||||
self._queue_snapshot(self.image.copy(), digest)
|
||||
else:
|
||||
self._save_snapshot(self.image)
|
||||
# A scroll that just ended can leave its last frame queued or
|
||||
# mid-write; it must not land on top of this newer one.
|
||||
with self._snapshot_write_lock:
|
||||
with self._snapshot_cond:
|
||||
self._snapshot_pending = None
|
||||
self._save_snapshot(self.image)
|
||||
self._saved_snapshot_digest = digest
|
||||
self._last_snapshot_ts = now
|
||||
self._last_snapshot_touch_ts = now
|
||||
self._last_snapshot_digest = digest
|
||||
@@ -1883,7 +1926,7 @@ class DisplayManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _queue_snapshot(self, image: Image.Image) -> None:
|
||||
def _queue_snapshot(self, image: Image.Image, digest: Optional[int] = None) -> None:
|
||||
"""Hand a frame to the snapshot writer thread; the newest frame wins.
|
||||
|
||||
One slot, not a queue: if the writer is still encoding when the next
|
||||
@@ -1891,7 +1934,7 @@ class DisplayManager:
|
||||
the latest frame, and a backlog would only cost memory and CPU.
|
||||
"""
|
||||
with self._snapshot_cond:
|
||||
self._snapshot_pending = image
|
||||
self._snapshot_pending = (image, digest)
|
||||
if self._snapshot_thread is None or not self._snapshot_thread.is_alive():
|
||||
self._snapshot_thread = threading.Thread(
|
||||
target=self._snapshot_writer, daemon=True,
|
||||
@@ -1902,10 +1945,37 @@ class DisplayManager:
|
||||
def _snapshot_writer(self) -> None:
|
||||
while True:
|
||||
with self._snapshot_cond:
|
||||
while self._snapshot_pending is None:
|
||||
while self._snapshot_pending is None and not self._snapshot_stop:
|
||||
self._snapshot_cond.wait()
|
||||
image, self._snapshot_pending = self._snapshot_pending, None
|
||||
try:
|
||||
self._save_snapshot(image)
|
||||
except Exception as e:
|
||||
self._log_snapshot_failure(e)
|
||||
if self._snapshot_stop:
|
||||
return # shutting down: a pending frame is dropped
|
||||
# The write lock before the frame: whichever of this and an inline
|
||||
# static save gets it first also writes first, and a static save
|
||||
# clears the slot, so an older frame never lands on a newer one.
|
||||
with self._snapshot_write_lock:
|
||||
with self._snapshot_cond:
|
||||
pending, self._snapshot_pending = self._snapshot_pending, None
|
||||
if pending is None:
|
||||
continue
|
||||
image, digest = pending
|
||||
try:
|
||||
self._save_snapshot(image)
|
||||
self._saved_snapshot_digest = digest
|
||||
except Exception as e:
|
||||
# The frame was recorded as written when it was queued.
|
||||
# Forget that, so an unchanged frame is written again
|
||||
# rather than only mtime-touching a stale file into
|
||||
# looking healthy.
|
||||
self._last_snapshot_digest = None
|
||||
self._log_snapshot_failure(e)
|
||||
|
||||
def _stop_snapshot_writer(self, timeout: float = 1.0) -> None:
|
||||
"""Stop the writer thread, dropping any frame it has not started."""
|
||||
with self._snapshot_cond:
|
||||
self._snapshot_stop = True
|
||||
self._snapshot_pending = None
|
||||
self._snapshot_cond.notify_all()
|
||||
thread = self._snapshot_thread
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout)
|
||||
self._snapshot_thread = None
|
||||
@@ -21,6 +21,8 @@ import time
|
||||
import requests
|
||||
from typing import Dict, List
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DynamicTeamResolver:
|
||||
@@ -141,7 +143,9 @@ class DynamicTeamResolver:
|
||||
self.logger.info("Fetching fresh NCAA Football rankings from ESPN API")
|
||||
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
|
||||
|
||||
response = requests.get(rankings_url, timeout=self.request_timeout)
|
||||
# ESPN rejects requests' default User-Agent; see api_helper.USER_AGENT.
|
||||
response = requests.get(rankings_url, headers=dict(DEFAULT_HTTP_HEADERS),
|
||||
timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
+43
-72
@@ -53,13 +53,9 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from PIL import ImageFont
|
||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
try:
|
||||
import freetype
|
||||
except ImportError: # pragma: no cover - freetype ships with the core
|
||||
freetype = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Core install root (the directory that contains src/ and assets/fonts/),
|
||||
@@ -94,6 +90,17 @@ def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None:
|
||||
# Config keys a style element block carries, in schema/UI order.
|
||||
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
|
||||
|
||||
# Title of every generated `layout` (x/y offset) group in the config form.
|
||||
_LAYOUT_TITLE = 'Layout Offsets'
|
||||
|
||||
#: Bounds on a user-set ``customization.layout.<element>.scale``. They are the
|
||||
#: Scale field's minimum and maximum in the generated schema, and every reader
|
||||
#: (coerce_scale, element_scale, LogoHelper.load_logo) clamps to them, so the
|
||||
#: web form and the renderer agree. Below 0.1 a logo is a dot; ten times a
|
||||
#: panel-sized box is already far off the panel.
|
||||
MIN_ELEMENT_SCALE = 0.1
|
||||
MAX_ELEMENT_SCALE = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementStyle:
|
||||
@@ -164,56 +171,25 @@ def native_bdf_size(font_name: str) -> Optional[int]:
|
||||
|
||||
|
||||
def _read_bdf_native_size(path: str) -> Optional[int]:
|
||||
"""A BDF file's own pixel size, delegated to FontManager.
|
||||
"""A BDF file's own pixel size (the web UI's fonts API imports this name).
|
||||
|
||||
Deliberately not reimplemented: FontManager's reader prefers PIXEL_SIZE
|
||||
over the SIZE line's point-size (they differ on the several bundled
|
||||
fonts defined at 75dpi) and stops at the first STARTCHAR. Core always
|
||||
ships it; the guard is for the plugin test harnesses that stub the
|
||||
module out.
|
||||
See :func:`src.common.bdf_font.read_bdf_native_size`: it prefers
|
||||
PIXEL_SIZE over the SIZE line's point-size, which differ on the several
|
||||
bundled fonts defined at 75dpi.
|
||||
"""
|
||||
try:
|
||||
from src.font_manager import FontManager
|
||||
return FontManager._read_bdf_native_size(path)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
return read_bdf_native_size(path)
|
||||
|
||||
|
||||
def _load_bdf(path: str, size: int) -> Tuple[Any, int]:
|
||||
"""A ``freetype.Face`` for a BDF file at the closest size it can do.
|
||||
|
||||
BDF fonts are fixed-size bitmap strikes, not scalable outlines:
|
||||
FreeType accepts only the exact pixel size baked into the file and
|
||||
raises for anything else. 32 of the 35 shipped fonts are BDF, so a
|
||||
size the user picked in the web UI usually is not a valid strike.
|
||||
|
||||
Retrying at the file's native size is the behaviour SportsCore already
|
||||
has (``_load_custom_font_from_element_config``). Without it this
|
||||
function fell through to the generic except below and returned
|
||||
*PressStart2P* — so choosing 5x7.bdf at size 10 silently rendered a
|
||||
completely different typeface rather than 5x7 at 7px.
|
||||
BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel
|
||||
size baked into the file, and 32 of the 35 shipped fonts are BDF, so a
|
||||
size the user picked in the web UI usually is not a valid strike. The
|
||||
shared loader retries at the native size; without that, 5x7.bdf at size
|
||||
10 used to fall through to *PressStart2P*, a different typeface.
|
||||
"""
|
||||
if freetype is None:
|
||||
raise RuntimeError("freetype not available for BDF fonts")
|
||||
|
||||
def _face_at(px: int) -> Any:
|
||||
face = freetype.Face(path)
|
||||
# Character size in 1/64th points at 72dpi == pixel size.
|
||||
face.set_char_size(px * 64, px * 64, 72, 72)
|
||||
return face
|
||||
|
||||
try:
|
||||
return _face_at(size), size
|
||||
except Exception:
|
||||
native = _read_bdf_native_size(path)
|
||||
if not native or native == size:
|
||||
raise
|
||||
# A fresh Face: the first one already took a failed set_char_size.
|
||||
face = _face_at(native)
|
||||
logger.debug("BDF font %s loaded at its native size %s "
|
||||
"(requested %s is not a strike in this file)",
|
||||
path, native, size)
|
||||
return face, native
|
||||
return load_bdf_face(path, size)
|
||||
|
||||
|
||||
def load_font(font_name: str, size: int) -> Any:
|
||||
@@ -336,7 +312,7 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if layout_props:
|
||||
layout = props.setdefault('layout', {
|
||||
'type': 'object',
|
||||
'title': 'Layout Offsets',
|
||||
'title': _LAYOUT_TITLE,
|
||||
'description': 'Pixel offsets applied to each element '
|
||||
'(positive x moves right, positive y moves down)',
|
||||
'x-advanced': True,
|
||||
@@ -380,16 +356,14 @@ def _element_block_from_spec(element_key: str,
|
||||
'type': 'string',
|
||||
'title': 'Font Family',
|
||||
'x-advanced': True,
|
||||
# The core already ships this widget and the config form already
|
||||
# allowlists it; without the hint the field rendered as a bare
|
||||
# text box the user had to type a filename into.
|
||||
# The core's font picker; without the hint the form renders a
|
||||
# bare text box the user has to type a filename into.
|
||||
'x-widget': 'font-selector',
|
||||
}
|
||||
# A bitmap font ignores font_size and renders at its own baked-in
|
||||
# size, so the size ceiling has to be enforced when picking the
|
||||
# font, not when setting the size.
|
||||
max_size = (size_spec or {}).get('max') if isinstance(
|
||||
spec.get('size'), dict) else None
|
||||
max_size = size_spec.get('max') if size_spec else None
|
||||
if isinstance(max_size, (int, float)):
|
||||
font_prop['x-options'] = {'maxFixedSize': max_size}
|
||||
if 'default' in font_spec:
|
||||
@@ -492,8 +466,8 @@ def _offset_block_from_spec(element_key: str,
|
||||
'title': 'Scale',
|
||||
'description': 'Size multiplier; 1 is the shipped size.',
|
||||
'default': 1.0,
|
||||
'minimum': 0.1,
|
||||
'maximum': 10.0,
|
||||
'minimum': MIN_ELEMENT_SCALE,
|
||||
'maximum': MAX_ELEMENT_SCALE,
|
||||
'x-advanced': True,
|
||||
}
|
||||
if isinstance(scale_spec, dict):
|
||||
@@ -574,7 +548,7 @@ def _modes_block(declaration: Dict[str, Any],
|
||||
if layout_props:
|
||||
element_props['layout'] = {
|
||||
'type': 'object',
|
||||
'title': 'Layout Offsets',
|
||||
'title': _LAYOUT_TITLE,
|
||||
'x-advanced': True,
|
||||
'additionalProperties': False,
|
||||
'properties': layout_props,
|
||||
@@ -715,7 +689,7 @@ def _modes_block_from_properties(props: Dict[str, Any], element_keys: list,
|
||||
if layout_props:
|
||||
element_props['layout'] = {
|
||||
'type': 'object',
|
||||
'title': 'Layout Offsets',
|
||||
'title': _LAYOUT_TITLE,
|
||||
'x-advanced': True,
|
||||
'additionalProperties': False,
|
||||
'properties': layout_props,
|
||||
@@ -1045,12 +1019,15 @@ def _coerce_align(value: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_scale(value: Any, default: float) -> float:
|
||||
"""A positive size multiplier, or ``default``.
|
||||
def coerce_scale(value: Any, default: float = 1.0) -> float:
|
||||
"""A usable size multiplier: ``value`` clamped to
|
||||
[MIN_ELEMENT_SCALE, MAX_ELEMENT_SCALE], or ``default``.
|
||||
|
||||
Clamped rather than merely validated: a scale of 0 or a negative one is
|
||||
a zero-or-inverted image, and the panel is 32 pixels tall -- a typo
|
||||
should cost a wrong size, not a crash inside PIL.
|
||||
``default`` is returned for anything that is not a finite positive number
|
||||
(None, a bool, a string, 0, a negative, NaN, infinity): those are typos,
|
||||
and a typo should cost the shipped size, not a blank or inverted image or
|
||||
a crash inside PIL. A positive number outside the range is a real request
|
||||
for "smaller" or "bigger", so it is clamped rather than ignored.
|
||||
"""
|
||||
if isinstance(value, bool) or value is None:
|
||||
return default
|
||||
@@ -1058,9 +1035,9 @@ def _coerce_scale(value: Any, default: float) -> float:
|
||||
scale = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if scale <= 0:
|
||||
if not math.isfinite(scale) or scale <= 0:
|
||||
return default
|
||||
return min(scale, 10.0)
|
||||
return min(max(scale, MIN_ELEMENT_SCALE), MAX_ELEMENT_SCALE)
|
||||
|
||||
|
||||
def _coerce_offset(value: Any, default: int, element_key: str,
|
||||
@@ -1229,7 +1206,7 @@ def element_scale(config: Any, element_key: str, default: float = 1.0,
|
||||
try:
|
||||
value = _element_field(config, element_key, 'scale', mode,
|
||||
in_layout=True)
|
||||
return default if value is None else _coerce_scale(value, default)
|
||||
return default if value is None else coerce_scale(value, default)
|
||||
except Exception as e:
|
||||
logger.warning("Error reading scale for %s: %s", element_key, e)
|
||||
return default
|
||||
@@ -1406,12 +1383,6 @@ class ElementStyleResolver:
|
||||
return {}
|
||||
return _lookup_element(block.get('layout'), element_key)
|
||||
|
||||
@classmethod
|
||||
def _layout_axis(cls, block: Dict[str, Any], element_key: str,
|
||||
axis: str) -> Any:
|
||||
"""``block['layout'][element][axis]``, or None if absent anywhere."""
|
||||
return cls._layout_element(block, element_key).get(axis)
|
||||
|
||||
|
||||
# -- resolution internals -----------------------------------------------
|
||||
|
||||
@@ -1532,7 +1503,7 @@ class ElementStyleResolver:
|
||||
user_forced_color=bool(color_forced),
|
||||
visible=_coerce_bool(visible, True),
|
||||
align=_coerce_align(align),
|
||||
scale=_coerce_scale(scale, 1.0),
|
||||
scale=coerce_scale(scale, 1.0),
|
||||
)
|
||||
|
||||
def _classic_style(self, classic_font: str, classic_size: int,
|
||||
|
||||
+16
-3
@@ -26,6 +26,19 @@ from src.exceptions import LEDMatrixError
|
||||
from src.redaction import redact_credentials
|
||||
|
||||
|
||||
def _format_trace(error: BaseException) -> str:
|
||||
"""The traceback carried by ``error`` itself.
|
||||
|
||||
Callers often record an exception after its ``except`` block has ended,
|
||||
or from another thread than the one that raised it (plugin_executor runs
|
||||
plugins on worker threads), where ``traceback.format_exc()`` has nothing
|
||||
to report. The exception object keeps its own ``__traceback__``, so the
|
||||
trace is built from that. An exception that was created but never raised
|
||||
has no traceback, and the result is just its type and message.
|
||||
"""
|
||||
return "".join(traceback.format_exception(type(error), error, error.__traceback__))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorRecord:
|
||||
"""Record of a single error occurrence."""
|
||||
@@ -145,8 +158,8 @@ class ErrorAggregator:
|
||||
with self._lock:
|
||||
error_type = type(error).__name__
|
||||
|
||||
# Extract additional context from LEDMatrixError subclasses
|
||||
error_context = context or {}
|
||||
# A copy, so the caller's dict is not changed behind its back.
|
||||
error_context = dict(context) if context else {}
|
||||
if isinstance(error, LEDMatrixError) and error.context:
|
||||
error_context.update(error.context)
|
||||
|
||||
@@ -157,7 +170,7 @@ class ErrorAggregator:
|
||||
context=error_context,
|
||||
plugin_id=plugin_id,
|
||||
operation=operation,
|
||||
stack_trace=traceback.format_exc()
|
||||
stack_trace=_format_trace(error)
|
||||
)
|
||||
|
||||
# Add record (with size limit)
|
||||
|
||||
+63
-119
@@ -38,7 +38,13 @@ import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_config_dir_mode,
|
||||
)
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
from src.deprecation import deprecated
|
||||
|
||||
@@ -56,7 +62,6 @@ class FontManager:
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.fonts_config = config.get("fonts", {})
|
||||
|
||||
# Font discovery and catalog
|
||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||
@@ -72,10 +77,8 @@ class FontManager:
|
||||
# Plugin font management
|
||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||
self.plugin_font_catalogs: Dict[str, Dict[str, str]] = {} # plugin_id -> {family_name -> file_path}
|
||||
self.font_metadata: Dict[str, Dict[str, Any]] = {} # family_name -> metadata
|
||||
self.font_dependencies: Dict[str, List[str]] = {} # family_name -> [required_families]
|
||||
|
||||
# Manager font registration - NEW for manager-centric model
|
||||
# Fonts managers and plugins report using (register_manager_font).
|
||||
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
|
||||
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
|
||||
# Bumped when a manager's registered families change (not when one
|
||||
@@ -87,13 +90,10 @@ class FontManager:
|
||||
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||
self.temp_font_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Performance monitoring
|
||||
# Counters behind get_performance_stats().
|
||||
self.performance_stats = {
|
||||
"font_load_times": {},
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"render_times": {},
|
||||
"total_renders": 0,
|
||||
"failed_loads": 0,
|
||||
"start_time": time.time()
|
||||
}
|
||||
@@ -104,9 +104,6 @@ class FontManager:
|
||||
"four_by_six": "assets/fonts/4x6-font.ttf",
|
||||
"five_by_seven": "assets/fonts/5x7.bdf",
|
||||
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
||||
# Note: cozette_bdf removed - font file not available
|
||||
# To re-enable: download cozette.bdf from https://github.com/the-moonwitch/Cozette
|
||||
# and add: "cozette_bdf": "assets/fonts/cozette.bdf"
|
||||
}
|
||||
|
||||
# Size tokens for convenience
|
||||
@@ -115,7 +112,10 @@ class FontManager:
|
||||
}
|
||||
|
||||
# Font overrides storage (for manual overrides)
|
||||
self.font_overrides_file = "config/font_overrides.json"
|
||||
# Under the install root's config/ (which always exists), not the
|
||||
# cwd: the file itself may not exist yet, and resolve_asset_path
|
||||
# hands back a missing path unchanged.
|
||||
self.font_overrides_file = os.path.join(resolve_asset_path("config"), "font_overrides.json")
|
||||
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Bumped whenever cached font objects are invalidated, so holders of
|
||||
@@ -127,7 +127,6 @@ class FontManager:
|
||||
def reload_config(self, new_config: Dict[str, Any]):
|
||||
"""Reload configuration and refresh font catalog."""
|
||||
self.config = new_config
|
||||
self.fonts_config = new_config.get("fonts", {})
|
||||
self.font_cache.clear() # Clear cache to force reload
|
||||
self.metrics_cache.clear() # Clear metrics cache
|
||||
self.cache_generation += 1
|
||||
@@ -135,7 +134,6 @@ class FontManager:
|
||||
logger.info("FontManager configuration reloaded successfully")
|
||||
|
||||
# ==================== Manager Font Registration ====================
|
||||
# NEW: Support for managers to register their font choices dynamically
|
||||
|
||||
def register_manager_font(self, manager_id: str, element_key: str,
|
||||
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
|
||||
@@ -208,16 +206,23 @@ class FontManager:
|
||||
|
||||
# ==================== Plugin Font Management ====================
|
||||
|
||||
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any]) -> bool:
|
||||
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
|
||||
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
|
||||
"""
|
||||
Register fonts for a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Unique identifier for the plugin
|
||||
font_manifest: Font manifest from plugin's manifest.json
|
||||
font_manifest: The ``fonts`` block of the plugin's manifest.json
|
||||
plugin_dir: The plugin's directory, which ``plugin://`` sources
|
||||
are relative to. PluginManager passes the directory it loaded
|
||||
the plugin from. When omitted, the plugin is looked up in the
|
||||
configured ``plugin_system.plugins_directory`` and then in
|
||||
``plugins/``.
|
||||
|
||||
Returns:
|
||||
True if registration successful, False otherwise
|
||||
True if the manifest was valid (individual fonts that fail to load
|
||||
are logged and skipped), False otherwise
|
||||
"""
|
||||
try:
|
||||
# Validate font manifest structure
|
||||
@@ -234,7 +239,7 @@ class FontManager:
|
||||
# Process font definitions
|
||||
fonts = font_manifest.get("fonts", [])
|
||||
for font_def in fonts:
|
||||
if self._register_plugin_font(plugin_id, font_def):
|
||||
if self._register_plugin_font(plugin_id, font_def, plugin_dir):
|
||||
logger.info(f"Successfully registered font {font_def.get('family')} for plugin {plugin_id}")
|
||||
|
||||
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
|
||||
@@ -269,7 +274,8 @@ class FontManager:
|
||||
|
||||
return True
|
||||
|
||||
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any]) -> bool:
|
||||
def _register_plugin_font(self, plugin_id: str, font_def: Dict[str, Any],
|
||||
plugin_dir: Optional[Union[str, Path]] = None) -> bool:
|
||||
"""Register a single font from a plugin."""
|
||||
try:
|
||||
family = font_def["family"]
|
||||
@@ -283,7 +289,7 @@ class FontManager:
|
||||
elif source.startswith("plugin://"):
|
||||
# Relative to plugin directory
|
||||
relative_path = source.replace("plugin://", "")
|
||||
font_path = self._resolve_plugin_font_path(plugin_id, relative_path)
|
||||
font_path = self._resolve_plugin_font_path(plugin_id, relative_path, plugin_dir)
|
||||
else:
|
||||
# Absolute or relative path
|
||||
font_path = source
|
||||
@@ -297,14 +303,6 @@ class FontManager:
|
||||
self.plugin_font_catalogs[plugin_id][family] = font_path
|
||||
self.font_catalog[namespaced_family] = font_path
|
||||
|
||||
# Store metadata
|
||||
if "metadata" in font_def:
|
||||
self.font_metadata[namespaced_family] = font_def["metadata"]
|
||||
|
||||
# Store dependencies
|
||||
if "dependencies" in font_def:
|
||||
self.font_dependencies[namespaced_family] = font_def["dependencies"]
|
||||
|
||||
logger.info(f"Registered plugin font: {namespaced_family} -> {font_path}")
|
||||
return True
|
||||
|
||||
@@ -366,11 +364,15 @@ class FontManager:
|
||||
return '.zip'
|
||||
return '.ttf' # default
|
||||
|
||||
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str) -> Optional[str]:
|
||||
"""Resolve a plugin-relative font path."""
|
||||
# Assume plugins are in a 'plugins' directory
|
||||
plugin_dir = Path("plugins") / plugin_id
|
||||
font_path = plugin_dir / relative_path
|
||||
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str,
|
||||
plugin_dir: Optional[Union[str, Path]] = None) -> Optional[str]:
|
||||
"""Resolve a ``plugin://`` font path against the plugin's directory."""
|
||||
if plugin_dir is None:
|
||||
plugin_dir = self._find_plugin_dir(plugin_id)
|
||||
if plugin_dir is None:
|
||||
logger.error(f"Plugin font {relative_path}: directory for plugin {plugin_id} not found")
|
||||
return None
|
||||
font_path = Path(plugin_dir) / relative_path
|
||||
|
||||
if font_path.exists():
|
||||
return str(font_path)
|
||||
@@ -378,6 +380,18 @@ class FontManager:
|
||||
logger.error(f"Plugin font not found: {font_path}")
|
||||
return None
|
||||
|
||||
def _find_plugin_dir(self, plugin_id: str) -> Optional[Path]:
|
||||
"""The installed directory of ``plugin_id``, for callers of
|
||||
register_plugin_fonts that do not pass one: the configured plugins
|
||||
directory (relative paths are relative to the install root), then the
|
||||
legacy ``plugins/`` directory."""
|
||||
# Imported here: src.plugin_system's package import loads PluginManager.
|
||||
from src.plugin_system.plugin_dirs import resolve_plugin_dir
|
||||
|
||||
configured = (self.config.get("plugin_system") or {}).get("plugins_directory") or "plugin-repos"
|
||||
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
|
||||
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Unregister all fonts for a plugin."""
|
||||
@@ -389,8 +403,6 @@ class FontManager:
|
||||
namespaced_family = f"{plugin_id}::{family}"
|
||||
if namespaced_family in self.font_catalog:
|
||||
del self.font_catalog[namespaced_family]
|
||||
if namespaced_family in self.font_metadata:
|
||||
del self.font_metadata[namespaced_family]
|
||||
|
||||
del self.plugin_font_catalogs[plugin_id]
|
||||
|
||||
@@ -441,8 +453,6 @@ class FontManager:
|
||||
Returns:
|
||||
Resolved font object
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Check for manual overrides first
|
||||
if element_key in self.font_overrides:
|
||||
@@ -459,14 +469,7 @@ class FontManager:
|
||||
if plugin_id in self.plugin_font_catalogs and family in self.plugin_font_catalogs[plugin_id]:
|
||||
family = f"{plugin_id}::{family}"
|
||||
|
||||
# Get the font
|
||||
font = self.get_font(family, size_px)
|
||||
|
||||
# Record performance
|
||||
duration = time.time() - start_time
|
||||
self._record_performance_metric("resolve", f"{family}_{size_px}", duration)
|
||||
|
||||
return font
|
||||
return self.get_font(family, size_px)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resolving font for {element_key}: {e}", exc_info=True)
|
||||
@@ -490,7 +493,6 @@ class FontManager:
|
||||
return self.font_cache[cache_key]
|
||||
|
||||
self.performance_stats["cache_misses"] += 1
|
||||
start_time = time.time()
|
||||
|
||||
# Load font
|
||||
font_path = self.font_catalog.get(family)
|
||||
@@ -505,44 +507,23 @@ class FontManager:
|
||||
else:
|
||||
font = load_truetype(font_path, size_px)
|
||||
except Exception as e:
|
||||
# The one log line for a failed load: _load_bdf_font lets
|
||||
# its error propagate to here.
|
||||
logger.error(f"Error loading font {font_path}: {e}")
|
||||
self.performance_stats["failed_loads"] += 1
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Cache and record performance
|
||||
self.font_cache[cache_key] = font
|
||||
duration = time.time() - start_time
|
||||
self.performance_stats["font_load_times"][cache_key] = duration
|
||||
|
||||
return font
|
||||
|
||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||
"""Load a BDF font using FreeType."""
|
||||
try:
|
||||
native_size = self._read_bdf_native_size(font_path)
|
||||
if native_size is not None and native_size != size_px:
|
||||
# BDF is a fixed-strike bitmap format: FreeType renders the
|
||||
# native size no matter what set_char_size asks for.
|
||||
logger.debug(
|
||||
"BDF font %s requested at %spx but renders at its native "
|
||||
"%spx", font_path, size_px, native_size
|
||||
)
|
||||
face = freetype.Face(font_path)
|
||||
try:
|
||||
# Character size in 1/64th points at 72dpi == pixel size.
|
||||
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
||||
except freetype.FT_Exception:
|
||||
# FreeType rejects any size but the strike's own, and get_font
|
||||
# used to answer that with PIL's default font -- a different
|
||||
# typeface. Use the native strike, as element_style does.
|
||||
if native_size is None or native_size == size_px:
|
||||
raise
|
||||
face = freetype.Face(font_path)
|
||||
face.set_char_size(native_size * 64, native_size * 64, 72, 72)
|
||||
return face
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading BDF font {font_path}: {e}")
|
||||
raise
|
||||
"""Load a BDF font through the shared loader.
|
||||
|
||||
A size the file has no strike for comes back at the native strike
|
||||
rather than failing over to PIL's default font, a different typeface
|
||||
(see :func:`src.common.bdf_font.load_bdf_face`).
|
||||
"""
|
||||
return load_bdf_face(font_path, size_px)[0]
|
||||
|
||||
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||
"""The one true pixel size of a BDF family in the catalog, or None
|
||||
@@ -554,30 +535,9 @@ class FontManager:
|
||||
|
||||
@staticmethod
|
||||
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
||||
"""Read a BDF file's own header to find its one true pixel size.
|
||||
Prefers the PIXEL_SIZE property, which states the real pixel height
|
||||
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE
|
||||
is absent, since point-size only equals pixel height at exactly
|
||||
100dpi — several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined
|
||||
at 75dpi, where the two values genuinely differ."""
|
||||
size_line_value = None
|
||||
try:
|
||||
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
|
||||
for line in f:
|
||||
if line.startswith("PIXEL_SIZE"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
return int(float(parts[1]))
|
||||
elif line.startswith("SIZE") and size_line_value is None:
|
||||
# Format: "SIZE <point_size> <xres> <yres>"
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
size_line_value = int(float(parts[1]))
|
||||
elif line.startswith("STARTCHAR"):
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return size_line_value
|
||||
"""A BDF file's one true pixel size; see
|
||||
:func:`src.common.bdf_font.read_bdf_native_size`."""
|
||||
return read_bdf_native_size(bdf_path)
|
||||
|
||||
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
||||
"""Get a fallback font when loading fails."""
|
||||
@@ -758,11 +718,6 @@ class FontManager:
|
||||
def _save_overrides(self):
|
||||
"""Save current font overrides to file."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
font_overrides_path = Path(self.font_overrides_file)
|
||||
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
||||
with open(self.font_overrides_file, 'w') as f:
|
||||
@@ -789,12 +744,6 @@ class FontManager:
|
||||
"""Get available size tokens."""
|
||||
return self.size_tokens.copy()
|
||||
|
||||
def _record_performance_metric(self, operation: str, font_key: str, duration: float):
|
||||
"""Record a performance metric."""
|
||||
if operation not in self.performance_stats:
|
||||
self.performance_stats[operation] = {}
|
||||
self.performance_stats[operation][font_key] = duration
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_performance_stats(self) -> Dict[str, Any]:
|
||||
"""Get performance statistics."""
|
||||
@@ -824,7 +773,8 @@ class FontManager:
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
||||
"""Add a new font to the catalog."""
|
||||
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
|
||||
stays where it is; only assets/fonts is created if it is missing."""
|
||||
try:
|
||||
# Validate font file
|
||||
if not os.path.exists(font_file_path):
|
||||
@@ -836,13 +786,7 @@ class FontManager:
|
||||
logger.warning(f"Font family '{family_name}' already exists")
|
||||
return False
|
||||
|
||||
# Copy font to assets/fonts directory
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_assets_dir_mode
|
||||
)
|
||||
fonts_dir = Path("assets/fonts")
|
||||
fonts_dir = Path(resolve_asset_path("assets/fonts"))
|
||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||
|
||||
# Add to catalog
|
||||
|
||||
+50
-73
@@ -16,7 +16,7 @@ import json
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
|
||||
from src.common.font_layout import load_truetype
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
@@ -370,22 +370,15 @@ class LogoDownloader:
|
||||
|
||||
@staticmethod
|
||||
def get_logo_filename_variations(abbreviation: str) -> list:
|
||||
"""Get possible filename variations for a team abbreviation."""
|
||||
variations = []
|
||||
"""Filenames a logo for ``abbreviation`` may be stored under: the
|
||||
upper-cased abbreviation as given, then its normalize_abbreviation()
|
||||
form (``TA&M.png``, then ``TAANDM.png``)."""
|
||||
original = abbreviation.upper()
|
||||
normalized = LogoDownloader.normalize_abbreviation(abbreviation)
|
||||
|
||||
# Add original and normalized versions
|
||||
variations.extend([f"{original}.png", f"{normalized}.png"])
|
||||
|
||||
# Special handling for known cases
|
||||
if original == 'TA&M':
|
||||
# TA&M has a file named TA&M.png, but normalize creates TAANDM.png
|
||||
variations = [f"{original}.png", f"{normalized}.png"]
|
||||
|
||||
return variations
|
||||
return [f"{original}.png", f"{normalized}.png"]
|
||||
|
||||
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
|
||||
# Allowlist for a league name or code that goes into a filesystem path or
|
||||
# an ESPN URL: lower-case alphanumerics, underscores and dashes only.
|
||||
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
|
||||
|
||||
def get_logo_directory(self, league: str) -> str:
|
||||
@@ -462,15 +455,12 @@ class LogoDownloader:
|
||||
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
|
||||
return False
|
||||
|
||||
# Allowlist for the league_code segment interpolated into ESPN API URLs
|
||||
_SAFE_LEAGUE_CODE_RE = re.compile(r'^[a-z0-9_-]+$')
|
||||
|
||||
def _resolve_api_url(self, league: str) -> Optional[str]:
|
||||
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
|
||||
api_url = self.API_ENDPOINTS.get(league)
|
||||
if not api_url and league.startswith('soccer_'):
|
||||
league_code = league[len('soccer_'):]
|
||||
if not self._SAFE_LEAGUE_CODE_RE.match(league_code):
|
||||
if not self._SAFE_LEAGUE_RE.match(league_code):
|
||||
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
|
||||
return None
|
||||
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
|
||||
@@ -501,7 +491,8 @@ class LogoDownloader:
|
||||
return None
|
||||
|
||||
def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
|
||||
"""Fetch team data from ESPN API for a specific league."""
|
||||
"""Fetch one team's record (``<teams endpoint>/<team_id>``) from the
|
||||
ESPN API; None on any request or parse failure."""
|
||||
api_url = self._resolve_api_url(league)
|
||||
if not api_url:
|
||||
logger.error(f"No API endpoint configured for league: {league}")
|
||||
@@ -520,7 +511,7 @@ class LogoDownloader:
|
||||
logger.error(f"Error fetching team data for {team_id} in {league}: {e}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Error parsing JSON response for{team_id} in {league}: {e}")
|
||||
logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}")
|
||||
return None
|
||||
|
||||
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
||||
@@ -626,42 +617,6 @@ class LogoDownloader:
|
||||
# Default to FBS for unknown conferences
|
||||
return 'FBS'
|
||||
|
||||
def _get_team_name_variations(self, abbreviation: str) -> List[str]:
|
||||
"""Generate common variations of a team abbreviation for matching."""
|
||||
variations = set()
|
||||
abbr = abbreviation.upper()
|
||||
variations.add(abbr)
|
||||
|
||||
# Add normalized version
|
||||
variations.add(self.normalize_abbreviation(abbr))
|
||||
|
||||
# Common substitutions
|
||||
substitutions = {
|
||||
'&': ['AND', 'A'],
|
||||
'A&M': ['TAMU', 'TA&M', 'TEXASAM'],
|
||||
'STATE': ['ST', 'ST.'],
|
||||
'UNIVERSITY': ['U', 'UNIV'],
|
||||
'COLLEGE': ['C', 'COL'],
|
||||
'TECHNICAL': ['TECH', 'T'],
|
||||
'NORTHERN': ['NORTH', 'N'],
|
||||
'SOUTHERN': ['SOUTH', 'S'],
|
||||
'EASTERN': ['EAST', 'E'],
|
||||
'WESTERN': ['WEST', 'W']
|
||||
}
|
||||
|
||||
# Apply substitutions
|
||||
for original, replacements in substitutions.items():
|
||||
if original in abbr:
|
||||
for replacement in replacements:
|
||||
variations.add(abbr.replace(original, replacement))
|
||||
variations.add(abbr.replace(original, '')) # Remove the word entirely
|
||||
|
||||
# Add common abbreviations for Texas A&M
|
||||
if 'A&M' in abbr or 'TAMU' in abbr:
|
||||
variations.update(['TAMU', 'TA&M', 'TEXASAM', 'TEXAS_A&M', 'TEXAS_AM'])
|
||||
|
||||
return list(variations)
|
||||
|
||||
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
||||
"""Download missing logos for a specific league."""
|
||||
logger.info(f"Starting logo download for league: {league}")
|
||||
@@ -794,7 +749,9 @@ class LogoDownloader:
|
||||
return False
|
||||
try:
|
||||
logo_url = data["team"]["logos"][0]["href"]
|
||||
except KeyError:
|
||||
except (KeyError, IndexError, TypeError):
|
||||
# A team without logos comes back with an empty list.
|
||||
logger.debug(f"No logo URL for team {team_id} in {league}")
|
||||
return False
|
||||
# Download the logo
|
||||
success = self.download_logo(logo_url, logo_path, team_abbreviation)
|
||||
@@ -824,24 +781,39 @@ class LogoDownloader:
|
||||
logger.info(f"Overall logo download results: {total_downloaded} downloaded, {total_failed} failed")
|
||||
return results
|
||||
|
||||
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str) -> bool:
|
||||
"""Create a placeholder logo when real logo cannot be downloaded."""
|
||||
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str,
|
||||
filepath: Optional[Path] = None) -> bool:
|
||||
"""Write a grey placeholder with the team abbreviation on it, for when
|
||||
the real logo cannot be downloaded.
|
||||
|
||||
Args:
|
||||
team_abbreviation: Drawn on the placeholder.
|
||||
logo_dir: Directory for the file when ``filepath`` is not given;
|
||||
the file is then ``<normalize_abbreviation(abbr)>.png``.
|
||||
filepath: The exact path to write, which is where the caller will
|
||||
look for the logo. ``logo_dir`` is ignored when it is given.
|
||||
|
||||
Returns:
|
||||
True if the placeholder was written, False otherwise (logged).
|
||||
"""
|
||||
if filepath is not None:
|
||||
filepath = Path(filepath)
|
||||
logo_dir = str(filepath.parent)
|
||||
try:
|
||||
# Ensure the logo directory exists
|
||||
if not self.ensure_logo_directory(logo_dir):
|
||||
logger.error(f"Failed to create logo directory: {logo_dir}")
|
||||
return False
|
||||
|
||||
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
|
||||
filepath = Path(logo_dir) / filename
|
||||
|
||||
# Create a simple placeholder logo
|
||||
logo = Image.new('RGBA', (64, 64), (100, 100, 100, 255)) # Gray background
|
||||
if filepath is None:
|
||||
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
|
||||
filepath = Path(logo_dir) / filename
|
||||
|
||||
logo = Image.new('RGBA', PLACEHOLDER_SIZE, PLACEHOLDER_BG)
|
||||
draw = ImageDraw.Draw(logo)
|
||||
|
||||
# Try to load a font, fallback to default
|
||||
try:
|
||||
font = load_truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
|
||||
font = load_truetype(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), 12)
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
@@ -855,8 +827,8 @@ class LogoDownloader:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = (64 - text_width) // 2
|
||||
y = (64 - text_height) // 2
|
||||
x = (PLACEHOLDER_SIZE[0] - text_width) // 2
|
||||
y = (PLACEHOLDER_SIZE[1] - text_height) // 2
|
||||
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
||||
else:
|
||||
# Fallback without font
|
||||
@@ -963,14 +935,19 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
||||
Convenience function to download a missing team logo.
|
||||
|
||||
Args:
|
||||
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
|
||||
league: League identifier (e.g., 'ncaa_fb', 'nfl')
|
||||
logo_path: Full path to where the logo should be saved
|
||||
team_id: ESPN team id, used to look up the logo URL when
|
||||
``logo_url`` is not given
|
||||
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
|
||||
logo_path: Where the logo (or placeholder) is written; relative paths
|
||||
are relative to the install root
|
||||
logo_url: Optional direct URL to the logo
|
||||
create_placeholder: Whether to create a placeholder if download fails
|
||||
|
||||
Returns:
|
||||
True if logo exists or was successfully downloaded, False otherwise
|
||||
True if a logo or placeholder is at ``logo_path`` afterwards (it was
|
||||
already there, was downloaded, or a placeholder was written),
|
||||
False otherwise.
|
||||
"""
|
||||
downloader = shared_downloader()
|
||||
|
||||
@@ -1011,7 +988,7 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
||||
time.sleep(0.1) # Small delay
|
||||
if not success and create_placeholder:
|
||||
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
||||
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
|
||||
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, filepath=filepath)
|
||||
return success
|
||||
|
||||
success = downloader.download_missing_logo_for_team(league, team_id, team_abbreviation, logo_path)
|
||||
@@ -1019,7 +996,7 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
||||
if not success and create_placeholder:
|
||||
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
||||
# Create placeholder as fallback
|
||||
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir)
|
||||
success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, filepath=filepath)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully handled logo for {team_abbreviation}")
|
||||
|
||||
@@ -11,7 +11,6 @@ Stability: Stable - maintains backward compatibility
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from src.logging_config import get_logger
|
||||
@@ -511,104 +510,53 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
|
||||
Automatically detects duration from:
|
||||
1. self.display_duration instance variable (if exists)
|
||||
2. self.config.get("display_duration", 15.0) (fallback)
|
||||
Uses, in order, the first positive number among:
|
||||
1. ``self.display_duration`` (a common pattern in scoreboard plugins)
|
||||
2. ``self.config["display_duration"]``
|
||||
3. 15.0
|
||||
|
||||
Can be overridden by plugins to provide dynamic durations based
|
||||
on content (e.g., longer duration for more complex displays).
|
||||
Numeric strings count as numbers. Can be overridden by plugins to
|
||||
provide dynamic durations based on content (e.g., longer duration for
|
||||
more complex displays).
|
||||
|
||||
Returns:
|
||||
Duration in seconds to display this plugin's content
|
||||
"""
|
||||
# Check for instance variable first (common pattern in scoreboard plugins)
|
||||
if hasattr(self, 'display_duration'):
|
||||
try:
|
||||
duration = getattr(self, 'display_duration')
|
||||
# Handle None case
|
||||
if duration is None:
|
||||
pass # Fall through to config
|
||||
# Try to convert to float if it's a number or numeric string.
|
||||
# bool is excluded: it's an int subclass, and True would
|
||||
# otherwise read as a 1-second duration.
|
||||
elif isinstance(duration, (int, float)) and not isinstance(duration, bool):
|
||||
if duration > 0:
|
||||
return float(duration)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"display_duration instance variable is non-positive (%s), using config fallback",
|
||||
duration
|
||||
)
|
||||
# Try converting string representations of numbers
|
||||
elif isinstance(duration, str):
|
||||
try:
|
||||
duration_float = float(duration)
|
||||
if duration_float > 0:
|
||||
return duration_float
|
||||
else:
|
||||
self.logger.debug(
|
||||
"display_duration string value is non-positive (%s), using config fallback",
|
||||
duration
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
self.logger.warning(
|
||||
"display_duration instance variable has invalid string value '%s', using config fallback",
|
||||
duration
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"display_duration instance variable has unexpected type %s (value: %s), using config fallback",
|
||||
type(duration).__name__, duration
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError) as e:
|
||||
self.logger.warning(
|
||||
"Error reading display_duration instance variable: %s, using config fallback",
|
||||
e
|
||||
)
|
||||
|
||||
# Fall back to config
|
||||
config_duration = self.config.get("display_duration", 15.0)
|
||||
try:
|
||||
# Ensure config value is also a valid float (bool excluded — an
|
||||
# int subclass that would otherwise read True as 1 second)
|
||||
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
||||
if config_duration > 0:
|
||||
return float(config_duration)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Config display_duration is non-positive (%s), using default 15.0",
|
||||
config_duration
|
||||
)
|
||||
return 15.0
|
||||
elif isinstance(config_duration, str):
|
||||
try:
|
||||
duration_float = float(config_duration)
|
||||
if duration_float > 0:
|
||||
return duration_float
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Config display_duration string is non-positive (%s), using default 15.0",
|
||||
config_duration
|
||||
)
|
||||
return 15.0
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
"Config display_duration has invalid string value '%s', using default 15.0",
|
||||
config_duration
|
||||
)
|
||||
return 15.0
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Config display_duration has unexpected type %s (value: %s), using default 15.0",
|
||||
type(config_duration).__name__, config_duration
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
duration = getattr(self, 'display_duration', None)
|
||||
except (TypeError, ValueError, AttributeError) as e:
|
||||
# A plugin may define display_duration as a property that raises.
|
||||
self.logger.warning(
|
||||
"Error processing config display_duration: %s, using default 15.0",
|
||||
e
|
||||
)
|
||||
"Error reading display_duration instance variable: %s, using config fallback", e)
|
||||
duration = None
|
||||
if duration is not None:
|
||||
seconds = self._positive_seconds(duration, "display_duration instance variable")
|
||||
if seconds is not None:
|
||||
return seconds
|
||||
|
||||
return 15.0
|
||||
seconds = self._positive_seconds(
|
||||
self.config.get("display_duration", 15.0), "config display_duration")
|
||||
return seconds if seconds is not None else 15.0
|
||||
|
||||
def _positive_seconds(self, value: Any, source: str) -> Optional[float]:
|
||||
"""``value`` as a positive float, or None (with a log line) if it is not one.
|
||||
|
||||
bool is rejected although it is an int subclass: True would otherwise
|
||||
read as a 1-second duration.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
self.logger.warning("%s has unexpected type %s (value: %s), ignoring it",
|
||||
source, type(value).__name__, value)
|
||||
return None
|
||||
try:
|
||||
seconds = float(value)
|
||||
except ValueError:
|
||||
self.logger.warning("%s has invalid value %r, ignoring it", source, value)
|
||||
return None
|
||||
if seconds > 0:
|
||||
return seconds
|
||||
self.logger.debug("%s is non-positive (%s), ignoring it", source, value)
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Dynamic duration support hooks
|
||||
@@ -926,25 +874,20 @@ class BasePlugin(ABC):
|
||||
config_mode, self.plugin_id
|
||||
)
|
||||
|
||||
# Fall back to mapping legacy content_type
|
||||
content_type = self.get_vegas_content_type()
|
||||
if content_type == 'multi':
|
||||
# Fall back to mapping legacy content_type. 'none' (excluded from
|
||||
# Vegas) also maps to FIXED_SEGMENT: exclusion is decided by checking
|
||||
# get_vegas_content_type() separately.
|
||||
if self.get_vegas_content_type() == 'multi':
|
||||
return VegasDisplayMode.SCROLL
|
||||
elif content_type == 'static':
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
elif content_type == 'none':
|
||||
# 'none' means excluded - return FIXED_SEGMENT as default
|
||||
# The exclusion is handled by checking get_vegas_content_type() separately
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
|
||||
return VegasDisplayMode.FIXED_SEGMENT
|
||||
|
||||
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
||||
"""
|
||||
Return list of Vegas display modes this plugin supports.
|
||||
|
||||
Used by the web UI to show available mode options for user configuration.
|
||||
Override to customize which modes are available for this plugin.
|
||||
Not currently consulted by core: neither Vegas mode nor the web UI
|
||||
calls it. It is kept, and plugins override it, as the declared set of
|
||||
modes a future mode picker would offer.
|
||||
|
||||
By default:
|
||||
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
||||
@@ -972,6 +915,10 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
Get the preferred width for this plugin in Vegas FIXED_SEGMENT mode.
|
||||
|
||||
Not currently consulted by core: Vegas mode sizes a card from the
|
||||
``vegas_width_pct`` / ``vegas_scroll.render_width_pct`` settings
|
||||
(see get_vegas_render_width()). Kept because plugins override it.
|
||||
|
||||
Returns the number of panels this plugin should occupy when displayed
|
||||
as a fixed segment. The actual pixel width is calculated as:
|
||||
width = panels * single_panel_width
|
||||
@@ -1025,7 +972,7 @@ class BasePlugin(ABC):
|
||||
required_fields = ['api_key', 'city']
|
||||
for field in required_fields:
|
||||
if field not in self.config:
|
||||
self.logger.error("Missing required field: %s", field)
|
||||
self.logger.error("Missing required field: %s", field)
|
||||
return False
|
||||
return True
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,6 @@ import threading
|
||||
import queue
|
||||
from typing import Dict, Optional, List, Callable, Any
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from src.plugin_system.operation_types import (
|
||||
PluginOperation, OperationType, OperationStatus
|
||||
@@ -28,28 +26,22 @@ class PluginOperationQueue:
|
||||
- Prevents concurrent operations on same plugin
|
||||
- Operation status tracking
|
||||
- Operation cancellation
|
||||
- Operation history
|
||||
- In-memory history of finished operations
|
||||
|
||||
The history is not persisted. The web UI's operation history comes from
|
||||
OperationHistory (operation_history.py), which has its own file; a copy
|
||||
written here was never read back by anything.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
history_file: Optional[str] = None,
|
||||
max_history: int = 100,
|
||||
lazy_load: bool = False
|
||||
):
|
||||
|
||||
def __init__(self, max_history: int = 100):
|
||||
"""
|
||||
Initialize operation queue.
|
||||
|
||||
|
||||
Args:
|
||||
history_file: Optional path to file for persisting operation history
|
||||
max_history: Maximum number of operations to keep in history
|
||||
lazy_load: If True, defer loading history file until first access
|
||||
"""
|
||||
self.logger = get_logger(__name__)
|
||||
self.history_file = Path(history_file) if history_file else None
|
||||
self.max_history = max_history
|
||||
self._lazy_load = lazy_load
|
||||
self._history_loaded = False
|
||||
|
||||
# Operation tracking
|
||||
self._operations: Dict[str, PluginOperation] = {}
|
||||
@@ -62,20 +54,8 @@ class PluginOperationQueue:
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Load history from file if it exists (unless lazy loading)
|
||||
if not self._lazy_load and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
# Start worker thread
|
||||
self._start_worker()
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure history is loaded (for lazy loading)."""
|
||||
if not self._history_loaded and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
|
||||
def enqueue_operation(
|
||||
self,
|
||||
operation_type: OperationType,
|
||||
@@ -139,7 +119,6 @@ class PluginOperationQueue:
|
||||
Returns:
|
||||
PluginOperation if found, None otherwise
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._operations.get(operation_id)
|
||||
|
||||
@@ -184,7 +163,6 @@ class PluginOperationQueue:
|
||||
Returns:
|
||||
List of operations, sorted by creation time (newest first)
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
# Sort by creation time (newest first)
|
||||
history = sorted(
|
||||
@@ -314,11 +292,7 @@ class PluginOperationQueue:
|
||||
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
||||
del self._active_operations[operation.plugin_id]
|
||||
|
||||
# Add to history
|
||||
self._add_to_history(operation)
|
||||
|
||||
# Save history to file
|
||||
self._save_history()
|
||||
|
||||
def _add_to_history(self, operation: PluginOperation) -> None:
|
||||
"""Add operation to history, maintaining max_history limit."""
|
||||
@@ -330,46 +304,6 @@ class PluginOperationQueue:
|
||||
self._operation_history.sort(key=lambda op: op.created_at)
|
||||
self._operation_history = self._operation_history[-self.max_history:]
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save operation history to file."""
|
||||
if not self.history_file:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
# Convert operations to dicts
|
||||
history_data = [op.to_dict() for op in self._operation_history]
|
||||
|
||||
# Ensure directory exists
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write to file
|
||||
with open(self.history_file, 'w') as f:
|
||||
json.dump(history_data, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error saving operation history: {e}")
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load operation history from file."""
|
||||
if not self.history_file or not self.history_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.history_file, 'r') as f:
|
||||
history_data = json.load(f)
|
||||
|
||||
with self._lock:
|
||||
self._operation_history = [
|
||||
PluginOperation.from_dict(op_data)
|
||||
for op_data in history_data
|
||||
]
|
||||
|
||||
self.logger.info(f"Loaded {len(self._operation_history)} operations from history")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error loading operation history: {e}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the operation queue and worker thread."""
|
||||
self.logger.info("Shutting down plugin operation queue")
|
||||
@@ -377,7 +311,4 @@ class PluginOperationQueue:
|
||||
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
self._worker_thread.join(timeout=5.0)
|
||||
|
||||
# Save history one last time
|
||||
self._save_history()
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
One answer to "which directory holds plugin X?".
|
||||
|
||||
Five places used to answer it, each with its own rules and each re-parsing
|
||||
every manifest per lookup: ``PluginManager`` discovery and
|
||||
``get_plugin_directory``, ``PluginLoader.find_plugin_directory``,
|
||||
``PluginStoreManager._find_plugin_path`` / ``list_installed_plugins`` and
|
||||
``state_reconciliation.disk_plugin_ids``. The rules now live here once; what
|
||||
still legitimately differs between callers (which directories to search,
|
||||
whether a ``ledmatrix-`` prefix or a case difference counts as a match) is a
|
||||
keyword argument at the call site, so a difference is always a visible choice
|
||||
rather than an accident of which copy you read.
|
||||
|
||||
The rules
|
||||
---------
|
||||
* A directory is a *candidate* when it is a directory (a symlink to one counts:
|
||||
dev plugins are symlinked in) and its name is neither hidden (leading ``.``)
|
||||
nor carries :data:`BACKUP_MARKER`. store_manager renames a plugin aside with
|
||||
that marker during install/rollback; the aside still holds a manifest, so
|
||||
treating it as a plugin would resurrect a ghost.
|
||||
* A plugin's id is its manifest ``id``. The directory name is only a fallback,
|
||||
for callers that must still see a plugin whose manifest is missing an id.
|
||||
* Resolving an id within one directory: a directory whose manifest declares
|
||||
the id wins; among several, the one named exactly for the id, then
|
||||
``ledmatrix-<id>``, then by name. Only when no manifest claims the id do
|
||||
directory names count: ``<id>``, then ``ledmatrix-<id>`` (``prefix=True``),
|
||||
then either of those ignoring case (``case_insensitive=True``). The name
|
||||
fallback still returns a directory whose manifest is unreadable -- that is
|
||||
how a broken plugin gets uninstalled or reinstalled. ``by_manifest=False``
|
||||
(``PluginManager.get_plugin_directory``, whose discovery map already holds
|
||||
the manifest answer) skips straight to the names.
|
||||
* Several directories are searched one at a time, in the order given: the
|
||||
first directory that resolves the id at all wins, by manifest or by name.
|
||||
* The id must be one plain path segment (``safe_path_component``); anything
|
||||
else resolves to nothing rather than being joined or truncated.
|
||||
* Returned paths are ``search_dir / name`` and are not resolved, so a
|
||||
symlinked dev plugin keeps the path that lies inside the search directory.
|
||||
|
||||
Manifests are read at most once per :class:`PluginDirectoryIndex`; one index is
|
||||
one scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Union
|
||||
|
||||
from src.common.path_safety import safe_path_component
|
||||
|
||||
__all__ = [
|
||||
'BACKUP_MARKER',
|
||||
'PLUGIN_DIR_PREFIX',
|
||||
'ManifestStatus',
|
||||
'PluginDirEntry',
|
||||
'PluginDirectoryIndex',
|
||||
'is_ignored_dir_name',
|
||||
'resolve_plugin_dir',
|
||||
'store_search_dirs',
|
||||
]
|
||||
|
||||
#: Substring store_manager embeds in a plugin directory it has set aside
|
||||
#: (``<id>.standalone-backup-preinstall`` / ``-migrating``). Existing debris on
|
||||
#: devices carries exactly this text, so it must never change.
|
||||
BACKUP_MARKER = '.standalone-backup-'
|
||||
|
||||
#: Legacy repository naming (``ledmatrix-<id>``); some installs still use it
|
||||
#: as the directory name.
|
||||
PLUGIN_DIR_PREFIX = 'ledmatrix-'
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
|
||||
|
||||
class ManifestStatus:
|
||||
"""What reading ``manifest.json`` in a candidate directory produced."""
|
||||
OK = 'ok' # a JSON object with a non-empty "id"
|
||||
MISSING = 'missing' # no manifest.json
|
||||
UNREADABLE = 'unreadable' # I/O error or invalid JSON
|
||||
NOT_OBJECT = 'not_object' # valid JSON, but not an object
|
||||
NO_ID = 'no_id' # an object without a usable "id"
|
||||
|
||||
|
||||
def is_ignored_dir_name(name: str) -> bool:
|
||||
"""True for names that are never a plugin: hidden, or set aside mid-install."""
|
||||
return name.startswith('.') or BACKUP_MARKER in name
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginDirEntry:
|
||||
"""One candidate directory and its manifest, read once."""
|
||||
path: Path
|
||||
status: str
|
||||
manifest: Optional[Any] = None
|
||||
error: Optional[BaseException] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def manifest_id(self) -> Optional[str]:
|
||||
"""The manifest's ``id`` when the manifest is usable, else None."""
|
||||
if self.status != ManifestStatus.OK:
|
||||
return None
|
||||
return self.manifest['id']
|
||||
|
||||
@property
|
||||
def manifest_parses(self) -> bool:
|
||||
"""The manifest exists and is valid JSON (of any shape)."""
|
||||
return self.status in (ManifestStatus.OK, ManifestStatus.NOT_OBJECT,
|
||||
ManifestStatus.NO_ID)
|
||||
|
||||
@property
|
||||
def installed_id(self) -> str:
|
||||
"""The manifest id, falling back to the directory name."""
|
||||
return self.manifest_id or self.name
|
||||
|
||||
|
||||
def _read_entry(path: Path) -> PluginDirEntry:
|
||||
manifest_path = path / 'manifest.json'
|
||||
if not manifest_path.is_file():
|
||||
return PluginDirEntry(path, ManifestStatus.MISSING)
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as handle:
|
||||
manifest = json.load(handle)
|
||||
except (OSError, ValueError) as exc: # ValueError covers JSON + decode errors
|
||||
return PluginDirEntry(path, ManifestStatus.UNREADABLE, error=exc)
|
||||
if not isinstance(manifest, dict):
|
||||
return PluginDirEntry(path, ManifestStatus.NOT_OBJECT, manifest)
|
||||
plugin_id = manifest.get('id')
|
||||
if not plugin_id or not isinstance(plugin_id, str):
|
||||
return PluginDirEntry(path, ManifestStatus.NO_ID, manifest)
|
||||
return PluginDirEntry(path, ManifestStatus.OK, manifest)
|
||||
|
||||
|
||||
def _preference(plugin_id: str, name: str) -> tuple:
|
||||
"""Sort key among directories that all claim ``plugin_id``."""
|
||||
if name == plugin_id:
|
||||
rank = 0
|
||||
elif name == PLUGIN_DIR_PREFIX + plugin_id:
|
||||
rank = 1
|
||||
else:
|
||||
rank = 2
|
||||
return (rank, name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginDirectoryIndex:
|
||||
"""Every candidate directory directly under ``root``, manifests read once.
|
||||
|
||||
Build one with :meth:`scan`. It is a snapshot: a directory added or
|
||||
removed afterwards is not seen until the next scan.
|
||||
"""
|
||||
root: Path
|
||||
entries: List[PluginDirEntry] = field(default_factory=list)
|
||||
#: Set when ``root`` exists but could not be listed.
|
||||
error: Optional[BaseException] = None
|
||||
_plugins: Optional[Dict[str, PluginDirEntry]] = field(
|
||||
default=None, init=False, repr=False, compare=False)
|
||||
|
||||
@classmethod
|
||||
def scan(cls, root: PathLike) -> 'PluginDirectoryIndex':
|
||||
root = Path(root)
|
||||
index = cls(root)
|
||||
try:
|
||||
children = sorted(root.iterdir(), key=lambda p: p.name)
|
||||
except FileNotFoundError:
|
||||
return index
|
||||
except OSError as exc:
|
||||
index.error = exc
|
||||
return index
|
||||
for child in children:
|
||||
if is_ignored_dir_name(child.name):
|
||||
continue
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
index.entries.append(_read_entry(child))
|
||||
return index
|
||||
|
||||
# -- listing ----------------------------------------------------------
|
||||
|
||||
def plugins(self) -> Dict[str, PluginDirEntry]:
|
||||
"""Manifest id -> entry, one entry per id.
|
||||
|
||||
When several directories declare the same id, the one named for it
|
||||
wins, then ``ledmatrix-<id>``, then the first by name; see
|
||||
:meth:`duplicates` for the losers.
|
||||
"""
|
||||
if self._plugins is not None:
|
||||
return self._plugins
|
||||
chosen: Dict[str, PluginDirEntry] = {}
|
||||
for entry in self.entries:
|
||||
plugin_id = entry.manifest_id
|
||||
if plugin_id is None:
|
||||
continue
|
||||
current = chosen.get(plugin_id)
|
||||
if current is None or (_preference(plugin_id, entry.name)
|
||||
< _preference(plugin_id, current.name)):
|
||||
chosen[plugin_id] = entry
|
||||
self._plugins = chosen
|
||||
return chosen
|
||||
|
||||
def duplicates(self) -> Dict[str, List[PluginDirEntry]]:
|
||||
"""Ids declared by more than one directory -> every such entry."""
|
||||
seen: Dict[str, List[PluginDirEntry]] = {}
|
||||
for entry in self.entries:
|
||||
if entry.manifest_id is not None:
|
||||
seen.setdefault(entry.manifest_id, []).append(entry)
|
||||
return {k: v for k, v in seen.items() if len(v) > 1}
|
||||
|
||||
def installed_ids(self, *, require_parseable_manifest: bool) -> Set[str]:
|
||||
"""Ids of everything that counts as installed.
|
||||
|
||||
A directory counts when it has a manifest.json -- which must also be
|
||||
valid JSON when ``require_parseable_manifest``. Its id is the manifest
|
||||
id, or the directory name when the manifest does not carry one.
|
||||
"""
|
||||
ids: Set[str] = set()
|
||||
for entry in self.entries:
|
||||
if entry.status == ManifestStatus.MISSING:
|
||||
continue
|
||||
if require_parseable_manifest and not entry.manifest_parses:
|
||||
continue
|
||||
ids.add(entry.installed_id)
|
||||
return ids
|
||||
|
||||
def entry_for_installed_id(self, plugin_id: str) -> Optional[PluginDirEntry]:
|
||||
"""The entry :meth:`installed_ids` reported as ``plugin_id``."""
|
||||
entry = self.plugins().get(plugin_id)
|
||||
if entry is not None:
|
||||
return entry
|
||||
for entry in self.entries:
|
||||
if entry.manifest_id is None and entry.name == plugin_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
# -- lookup -----------------------------------------------------------
|
||||
|
||||
def find(self, plugin_id: str, *, prefix: bool, case_insensitive: bool,
|
||||
by_manifest: bool = True) -> Optional[Path]:
|
||||
"""Resolve ``plugin_id`` within this directory (rules in the module doc)."""
|
||||
plugin_id = _lookup_id(plugin_id)
|
||||
if plugin_id is None:
|
||||
return None
|
||||
|
||||
if by_manifest:
|
||||
entry = self.plugins().get(plugin_id)
|
||||
if entry is not None:
|
||||
return entry.path
|
||||
|
||||
names = _candidate_names(plugin_id, prefix)
|
||||
by_name = {e.name: e for e in self.entries}
|
||||
for name in names:
|
||||
if name in by_name:
|
||||
return by_name[name].path
|
||||
if case_insensitive:
|
||||
for low in (n.lower() for n in names):
|
||||
for entry in self.entries:
|
||||
if entry.name.lower() == low:
|
||||
return entry.path
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_id(plugin_id: Any) -> Optional[str]:
|
||||
"""``plugin_id`` if it can name a plugin directory at all, else None."""
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
if plugin_id is None or is_ignored_dir_name(plugin_id):
|
||||
return None
|
||||
return plugin_id
|
||||
|
||||
|
||||
def _candidate_names(plugin_id: str, prefix: bool) -> List[str]:
|
||||
names = [plugin_id]
|
||||
if prefix:
|
||||
names.append(PLUGIN_DIR_PREFIX + plugin_id)
|
||||
return names
|
||||
|
||||
|
||||
def _is_dir(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_plugin_dir(plugin_id: Any, search_dirs: Iterable[PathLike], *,
|
||||
prefix: bool, case_insensitive: bool = False,
|
||||
by_manifest: bool = True) -> Optional[Path]:
|
||||
"""The directory holding ``plugin_id``, searching ``search_dirs`` in order.
|
||||
|
||||
Each search directory is scanned once and each manifest in it read once.
|
||||
``by_manifest=False`` skips the manifest pass and matches directory names
|
||||
only, which reads no manifests at all.
|
||||
|
||||
Names are compared against the directory listing, never by probing
|
||||
``search_dir / name``: on a case-insensitive filesystem that probe says
|
||||
``Demo`` exists when the directory is ``demo``, which made the answer
|
||||
depend on the platform.
|
||||
"""
|
||||
plugin_id = _lookup_id(plugin_id)
|
||||
if plugin_id is None:
|
||||
return None
|
||||
for search_dir in search_dirs:
|
||||
search_dir = Path(search_dir)
|
||||
if by_manifest or case_insensitive:
|
||||
found = PluginDirectoryIndex.scan(search_dir).find(
|
||||
plugin_id, prefix=prefix, case_insensitive=case_insensitive,
|
||||
by_manifest=by_manifest)
|
||||
else:
|
||||
found = _find_by_name(search_dir, _candidate_names(plugin_id, prefix))
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _find_by_name(search_dir: Path, names: List[str]) -> Optional[Path]:
|
||||
try:
|
||||
present = {child.name for child in search_dir.iterdir()}
|
||||
except OSError:
|
||||
return None
|
||||
for name in names:
|
||||
if name in present and _is_dir(search_dir / name):
|
||||
return search_dir / name
|
||||
return None
|
||||
|
||||
|
||||
def store_search_dirs(plugins_dir: PathLike) -> List[Path]:
|
||||
"""Directories the plugin store searches: the configured one, then a
|
||||
sibling ``plugins/`` (the legacy/dev location) when that is a different
|
||||
directory. Discovery deliberately does NOT use this -- it scans only the
|
||||
configured directory (see CLAUDE.md, test_discovery_path_contract.py)."""
|
||||
plugins_dir = Path(plugins_dir)
|
||||
dirs = [plugins_dir]
|
||||
try:
|
||||
base = plugins_dir if plugins_dir.is_absolute() else plugins_dir.resolve()
|
||||
sibling = base.parent / 'plugins'
|
||||
if sibling != base:
|
||||
dirs.append(sibling)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return dirs
|
||||
@@ -93,7 +93,7 @@ class PluginExecutor:
|
||||
if result_container['exception']:
|
||||
error = result_container['exception']
|
||||
error_msg = f"{plugin_context} operation failed: {error}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
self.logger.error(error_msg, exc_info=error)
|
||||
record_error(error, plugin_id=plugin_id, operation="execute")
|
||||
raise PluginError(error_msg, plugin_id=plugin_id) from error
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
@@ -21,6 +21,7 @@ from packaging.requirements import InvalidRequirement, Requirement
|
||||
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_dirs import resolve_plugin_dir
|
||||
|
||||
|
||||
def requirements_has_real_deps(requirements_file: str) -> bool:
|
||||
@@ -184,6 +185,46 @@ def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def contained_plugin_dir(plugin_dir: Path, plugins_dir: Path) -> Optional[str]:
|
||||
"""``plugin_dir`` rebuilt from an entry enumerated under ``plugins_dir``.
|
||||
|
||||
Returns None when ``plugin_dir`` is not a subdirectory of ``plugins_dir``.
|
||||
Callers derive ``plugin_dir`` from a manifest-declared id, so the path is
|
||||
rebuilt from :func:`find_trusted_subdir`'s answer rather than trusted: a
|
||||
name that came out of ``os.scandir()`` on the trusted root carries no
|
||||
taint, which is a real containment guarantee (and one CodeQL's
|
||||
path-injection query can follow), not a string sanitiser.
|
||||
"""
|
||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
||||
if matched_name is None:
|
||||
return None
|
||||
return os.path.join(plugins_dir_real, matched_name)
|
||||
|
||||
|
||||
def requirements_to_install(plugin_dir: str, logger: logging.Logger,
|
||||
label: str) -> Optional[str]:
|
||||
"""The plugin's requirements.txt if pip has work to do, else None.
|
||||
|
||||
None when there is no requirements.txt, when it lists nothing (plugins
|
||||
whose dependencies ship with core often keep an all-comments file), or
|
||||
when every requirement is already installed. Shared by the loader and the
|
||||
store so both skip pip for the same reasons; they differ only in how they
|
||||
run it.
|
||||
"""
|
||||
requirements_file = os.path.join(plugin_dir, "requirements.txt")
|
||||
if not os.path.isfile(requirements_file):
|
||||
return None
|
||||
if not requirements_has_real_deps(requirements_file):
|
||||
logger.debug("requirements.txt for %s has no real dependencies, skipping pip", label)
|
||||
return None
|
||||
if requirements_are_satisfied(requirements_file):
|
||||
logger.debug("Dependencies for %s already satisfied, skipping pip", label)
|
||||
return None
|
||||
return requirements_file
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""Handles plugin module loading and class instantiation."""
|
||||
|
||||
@@ -214,85 +255,36 @@ class PluginLoader:
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Find the plugin directory for a given plugin ID.
|
||||
|
||||
Tries multiple strategies:
|
||||
1. Use plugin_directories mapping if available
|
||||
2. Direct path matching
|
||||
3. Case-insensitive directory matching
|
||||
4. Manifest-based search
|
||||
|
||||
|
||||
1. The discovery mapping, when it has the id and the path exists.
|
||||
2. ``plugins_dir`` only, by the shared rules in
|
||||
``src/plugin_system/plugin_dirs.py``: a directory whose manifest
|
||||
declares the id wins; otherwise ``<id>`` or ``ledmatrix-<id>``,
|
||||
matched case-insensitively. Backup and hidden directories are
|
||||
never matched.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Base plugins directory
|
||||
plugin_directories: Optional mapping of plugin_id to directory
|
||||
|
||||
Returns:
|
||||
Path to plugin directory or None if not found
|
||||
"""
|
||||
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return None
|
||||
|
||||
Returns:
|
||||
Path to plugin directory or None if not found. An id that is not
|
||||
one plain path segment finds nothing.
|
||||
"""
|
||||
# Strategy 1: Use mapping from discovery
|
||||
if plugin_directories and plugin_id in plugin_directories:
|
||||
plugin_dir = plugin_directories[plugin_id]
|
||||
if plugin_dir.exists():
|
||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
|
||||
plugins_dir_resolved = plugins_dir.resolve()
|
||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
||||
try:
|
||||
_candidate.relative_to(plugins_dir_resolved)
|
||||
except ValueError:
|
||||
continue
|
||||
if _candidate.exists():
|
||||
return _candidate
|
||||
|
||||
# Strategy 3: Case-insensitive search
|
||||
normalized_id = plugin_id.lower()
|
||||
for item in plugins_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
item_name = item.name
|
||||
if item_name.lower() == normalized_id:
|
||||
return item
|
||||
|
||||
if item_name.lower() == f"ledmatrix-{plugin_id}".lower():
|
||||
return item
|
||||
|
||||
# Strategy 4: Manifest-based search
|
||||
self.logger.debug("Directory name search failed for %s, searching by manifest...", plugin_id)
|
||||
for item in plugins_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
# Skip if already checked
|
||||
if item.name.lower() == normalized_id or item.name.lower() == f"ledmatrix-{plugin_id}".lower():
|
||||
continue
|
||||
|
||||
manifest_path = item / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
item_manifest = json.load(f)
|
||||
item_manifest_id = item_manifest.get('id')
|
||||
if item_manifest_id == plugin_id:
|
||||
self.logger.info(
|
||||
"Found plugin %s in directory %s (manifest ID matches)",
|
||||
plugin_id,
|
||||
item.name
|
||||
)
|
||||
return item
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
||||
continue
|
||||
|
||||
return None
|
||||
plugin_dir = resolve_plugin_dir(
|
||||
plugin_id, [plugins_dir], prefix=True, case_insensitive=True)
|
||||
if plugin_dir is not None and plugin_dir.name != plugin_id:
|
||||
self.logger.debug("Found plugin %s in directory %s",
|
||||
plugin_id, plugin_dir.name)
|
||||
return plugin_dir
|
||||
|
||||
def install_dependencies(
|
||||
self,
|
||||
@@ -322,43 +314,15 @@ class PluginLoader:
|
||||
if not plugin_id:
|
||||
return False
|
||||
|
||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
requested_name = os.path.basename(plugin_dir_real)
|
||||
|
||||
# Match the requested directory against an entry actually enumerated
|
||||
# from the trusted plugins_dir, and build the path from that entry --
|
||||
# not from requested_name. A name that came out of os.scandir() on a
|
||||
# trusted root carries no taint regardless of what the caller asked
|
||||
# for, so this is a real containment guarantee (an allowlist check
|
||||
# against a trusted source), not a string-sanitisation of untrusted
|
||||
# input that a static analyzer has to trust blindly.
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||
if matched_name is None:
|
||||
safe_plugin_dir = contained_plugin_dir(plugin_dir, plugins_dir)
|
||||
if safe_plugin_dir is None:
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||
|
||||
if not os.path.isfile(requirements_file):
|
||||
return True # No dependencies needed
|
||||
|
||||
if not requirements_has_real_deps(requirements_file):
|
||||
self.logger.debug(
|
||||
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
|
||||
plugin_id
|
||||
)
|
||||
return True
|
||||
|
||||
if requirements_are_satisfied(requirements_file):
|
||||
self.logger.debug(
|
||||
"Dependencies for %s already satisfied in current environment, skipping pip",
|
||||
plugin_id
|
||||
)
|
||||
requirements_file = requirements_to_install(safe_plugin_dir, self.logger, plugin_id)
|
||||
if requirements_file is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -397,8 +361,8 @@ class PluginLoader:
|
||||
# below).
|
||||
try:
|
||||
# sys.executable is this process's own interpreter (not
|
||||
# attacker-influenced), and requirements_file is a path
|
||||
# built internally by find_plugin_directory, never raw
|
||||
# attacker-influenced), and requirements_file is rebuilt
|
||||
# by contained_plugin_dir() from a trusted listing, never raw
|
||||
# external input.
|
||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||
@@ -433,10 +397,10 @@ class PluginLoader:
|
||||
except FileNotFoundError:
|
||||
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
||||
return True
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
||||
# Often caused by network interruptions or output buffer issues
|
||||
if isinstance(e, OSError) and e.errno == 32:
|
||||
except OSError as e:
|
||||
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||
# mid-download, usually a network interruption.
|
||||
if e.errno == errno.EPIPE:
|
||||
self.logger.error(
|
||||
"Broken pipe error during dependency installation for %s. "
|
||||
"This usually indicates a network interruption or pip output buffer issue. "
|
||||
@@ -577,7 +541,7 @@ class PluginLoader:
|
||||
plugin_id: str,
|
||||
plugin_dir: Path,
|
||||
entry_point: str
|
||||
) -> Optional[Any]:
|
||||
) -> Any:
|
||||
"""
|
||||
Load a plugin module from file.
|
||||
|
||||
@@ -596,7 +560,12 @@ class PluginLoader:
|
||||
entry_point: Entry point filename (e.g., 'manager.py')
|
||||
|
||||
Returns:
|
||||
Loaded module or None on error
|
||||
The loaded module
|
||||
|
||||
Raises:
|
||||
PluginError: If the plugin id, directory or entry point is
|
||||
invalid. Whatever the module raises while executing
|
||||
propagates unchanged.
|
||||
"""
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
@@ -831,9 +800,7 @@ class PluginLoader:
|
||||
# Load module
|
||||
entry_point = manifest.get('entry_point', 'manager.py')
|
||||
module = self.load_module(plugin_id, plugin_dir, entry_point)
|
||||
if module is None:
|
||||
raise PluginError(f"Failed to load module for plugin {plugin_id}", plugin_id=plugin_id)
|
||||
|
||||
|
||||
# Get plugin class
|
||||
class_name = manifest.get('class_name')
|
||||
if not class_name:
|
||||
|
||||
+107
-104
@@ -2,7 +2,8 @@
|
||||
Plugin Manager
|
||||
|
||||
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
|
||||
Handles dynamic plugin loading from the plugins/ directory.
|
||||
Loads plugins from the configured plugins directory
|
||||
(``plugin_system.plugins_directory``, ``plugin-repos/`` by default).
|
||||
|
||||
API Version: 1.0.0
|
||||
"""
|
||||
@@ -25,7 +26,9 @@ from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
||||
from src.plugin_system.schema_manager import (
|
||||
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
|
||||
)
|
||||
from src.common.path_safety import safe_path_component
|
||||
from src.plugin_system.plugin_dirs import (
|
||||
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
|
||||
)
|
||||
from src.deprecation import deprecated
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
@@ -38,7 +41,7 @@ class PluginManager:
|
||||
Manages plugin discovery, loading, and lifecycle.
|
||||
|
||||
The PluginManager is responsible for:
|
||||
- Discovering plugins in the plugins/ directory
|
||||
- Discovering plugins in the configured plugins directory
|
||||
- Loading plugin modules and instantiating plugin classes
|
||||
- Managing plugin lifecycle (load, unload, reload)
|
||||
- Providing access to loaded plugins
|
||||
@@ -97,10 +100,9 @@ class PluginManager:
|
||||
self.plugin_directories: Dict[str, Path] = {}
|
||||
self.plugin_last_update: Dict[str, float] = {}
|
||||
|
||||
# Cached data-fetch intervals per plugin_id.
|
||||
# _get_plugin_update_interval falls back to config_manager.get_config()
|
||||
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
||||
# that copy on every 30-fps tick. Cleared on load/unload.
|
||||
# Cached static data-fetch intervals per plugin_id, so the render
|
||||
# loop's scheduling tick does not repeat the manifest/config lookup
|
||||
# for every plugin. Cleared on load/unload.
|
||||
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||
|
||||
# Health tracking (optional, set by display_controller if available)
|
||||
@@ -108,14 +110,12 @@ class PluginManager:
|
||||
self.resource_monitor = None
|
||||
|
||||
# --- Asynchronous plugin updates -------------------------------
|
||||
# update() used to run inline in the render loop (execute_update's
|
||||
# internal thread.join(timeout=30) blocked it), so one slow plugin
|
||||
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
|
||||
# happens on the render thread (run_scheduled_updates), but
|
||||
# execution moves to this single background worker. Per-plugin
|
||||
# locks keep a plugin's update() and display() mutually exclusive —
|
||||
# today's implicit guarantee, now explicit (and, unlike today,
|
||||
# also held across the post-timeout window).
|
||||
# Run inline in the render loop, one slow plugin HTTP fetch in
|
||||
# update() freezes scrolling for the whole fetch. Scheduling happens
|
||||
# on the render thread (run_scheduled_updates); execution happens on
|
||||
# this single background worker. Per-plugin locks keep a plugin's
|
||||
# update() and display() mutually exclusive, including across the
|
||||
# post-timeout window.
|
||||
# Kill switch: plugin_system.synchronous_updates: true restores the
|
||||
# inline path.
|
||||
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
||||
@@ -175,89 +175,88 @@ class PluginManager:
|
||||
self.logger.error("Could not create plugins directory %s: %s", self.plugins_dir, e, exc_info=True)
|
||||
raise PluginError(f"Could not create plugins directory: {self.plugins_dir}", context={'error': str(e)}) from e
|
||||
|
||||
def _report_skip_once(self, key: str, message: str, *args: Any) -> None:
|
||||
"""Warn about a skipped directory once per process, not per scan.
|
||||
|
||||
Discovery runs on every web UI page load and every config reconcile,
|
||||
so warning unconditionally would put a line in the journal each time
|
||||
someone opened a page -- the same log-volume problem this is meant to
|
||||
help diagnose.
|
||||
"""
|
||||
reported = self.__dict__.setdefault('_skip_reported', set())
|
||||
if key in reported:
|
||||
return
|
||||
reported.add(key)
|
||||
self.logger.warning(message, *args)
|
||||
|
||||
def _scan_directory_for_plugins(self, directory: Path) -> List[str]:
|
||||
"""
|
||||
Scan a directory for plugins.
|
||||
|
||||
Which directories count and how an id maps to one is decided by
|
||||
:class:`PluginDirectoryIndex` (``src/plugin_system/plugin_dirs.py``),
|
||||
shared with the loader, the store and reconciliation. Only
|
||||
``directory`` is scanned: discovery has no fallback to ``plugins/``.
|
||||
Directories set aside mid-install (``BACKUP_MARKER`` in the name) are
|
||||
skipped so they don't overwrite live entries.
|
||||
|
||||
Args:
|
||||
directory: Directory to scan
|
||||
|
||||
Returns:
|
||||
List of plugin IDs found
|
||||
"""
|
||||
plugin_ids = []
|
||||
|
||||
if not directory.exists():
|
||||
return plugin_ids
|
||||
return []
|
||||
|
||||
# Build new state locally before acquiring lock
|
||||
new_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
new_directories: Dict[str, Path] = {}
|
||||
|
||||
try:
|
||||
for item in directory.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
# Skip backup directories so they don't overwrite live entries
|
||||
if '.standalone-backup-' in item.name:
|
||||
continue
|
||||
|
||||
manifest_path = item / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
# Once per directory per process. Discovery runs on every
|
||||
# web UI page load and every config reconcile, so warning
|
||||
# unconditionally would put a line in the journal each
|
||||
# time someone opened a page -- the same log-volume
|
||||
# problem this is meant to help diagnose.
|
||||
# A directory here that carries no manifest is not a
|
||||
# plugin. Said once, because the alternative is a plugin
|
||||
# that is enabled in config, enabled in plugin state,
|
||||
# present on disk, and simply absent from the running
|
||||
# process with nothing anywhere to say why. Working that
|
||||
# out afterwards means reading cache-file mtimes.
|
||||
if item.name not in self._skip_reported:
|
||||
self._skip_reported.add(item.name)
|
||||
self.logger.warning(
|
||||
"Skipping %s: no manifest.json, so it cannot be "
|
||||
"loaded as a plugin", item.name)
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
||||
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
||||
continue
|
||||
index = PluginDirectoryIndex.scan(directory)
|
||||
if index.error is not None:
|
||||
self.logger.error("Error scanning directory %s: %s", directory,
|
||||
index.error, exc_info=index.error)
|
||||
|
||||
for entry in index.entries:
|
||||
if entry.status == ManifestStatus.MISSING:
|
||||
# A directory here that carries no manifest is not a plugin.
|
||||
# Said once, because the alternative is a plugin that is
|
||||
# enabled in config, enabled in plugin state, present on disk,
|
||||
# and simply absent from the running process with nothing
|
||||
# anywhere to say why. Working that out afterwards means
|
||||
# reading cache-file mtimes.
|
||||
self._report_skip_once(
|
||||
entry.name, "Skipping %s: no manifest.json, so it cannot be "
|
||||
"loaded as a plugin", entry.name)
|
||||
elif entry.status == ManifestStatus.UNREADABLE:
|
||||
self.logger.warning("Error reading manifest from %s: %s",
|
||||
entry.path / "manifest.json", entry.error,
|
||||
exc_info=entry.error)
|
||||
elif entry.status == ManifestStatus.NOT_OBJECT:
|
||||
# json.load accepts any JSON value, so a manifest holding
|
||||
# null, [] or "text" parses and then raises AttributeError on
|
||||
# .get(). Nothing here catches that -- the outer handler takes
|
||||
# OSError/PermissionError only -- so a single malformed
|
||||
# manifest aborted the whole scan and every other plugin on
|
||||
# null, [] or "text" parses. It once raised AttributeError on
|
||||
# .get() and aborted the whole scan, so every other plugin on
|
||||
# disk, however healthy, silently failed to register.
|
||||
if not isinstance(manifest, dict):
|
||||
if item.name not in self._skip_reported:
|
||||
self._skip_reported.add(item.name)
|
||||
self.logger.warning(
|
||||
"Skipping %s: its manifest.json is %s, not a JSON "
|
||||
"object", item.name, type(manifest).__name__)
|
||||
continue
|
||||
self._report_skip_once(
|
||||
entry.name, "Skipping %s: its manifest.json is %s, not a "
|
||||
"JSON object", entry.name, type(entry.manifest).__name__)
|
||||
elif entry.status == ManifestStatus.NO_ID:
|
||||
# Parsed but unusable. This was the quietest path of all: the
|
||||
# manifest is read successfully and then dropped.
|
||||
self._report_skip_once(
|
||||
entry.name, "Skipping %s: its manifest.json has no \"id\", "
|
||||
"so there is nothing to register it under", entry.name)
|
||||
|
||||
plugin_id = manifest.get('id')
|
||||
if not plugin_id:
|
||||
# Parsed but unusable. This was the quietest path of all:
|
||||
# the manifest is read successfully and then dropped.
|
||||
if item.name not in self._skip_reported:
|
||||
self._skip_reported.add(item.name)
|
||||
self.logger.warning(
|
||||
"Skipping %s: its manifest.json has no \"id\", so "
|
||||
"there is nothing to register it under", item.name)
|
||||
continue
|
||||
plugins = index.plugins()
|
||||
for plugin_id, entries in index.duplicates().items():
|
||||
self._report_skip_once(
|
||||
"duplicate:" + plugin_id,
|
||||
"Plugin id %r is declared by %d directories (%s); using %s",
|
||||
plugin_id, len(entries), ", ".join(e.name for e in entries),
|
||||
plugins[plugin_id].name)
|
||||
|
||||
plugin_ids.append(plugin_id)
|
||||
new_manifests[plugin_id] = manifest
|
||||
new_directories[plugin_id] = item
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
||||
new_manifests: Dict[str, Dict[str, Any]] = {
|
||||
plugin_id: entry.manifest for plugin_id, entry in plugins.items()}
|
||||
new_directories: Dict[str, Path] = {
|
||||
plugin_id: entry.path for plugin_id, entry in plugins.items()}
|
||||
|
||||
# Replace shared state under lock so uninstalled plugins don't linger
|
||||
with self._discovery_lock:
|
||||
@@ -266,8 +265,8 @@ class PluginManager:
|
||||
self.plugin_directories.clear()
|
||||
self.plugin_directories.update(new_directories)
|
||||
|
||||
return plugin_ids
|
||||
|
||||
return list(plugins)
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""
|
||||
Discover all plugins in the plugins directory.
|
||||
@@ -395,7 +394,8 @@ class PluginManager:
|
||||
self.font_manager, 'register_plugin_fonts'
|
||||
):
|
||||
try:
|
||||
self.font_manager.register_plugin_fonts(plugin_id, font_manifest)
|
||||
self.font_manager.register_plugin_fonts(
|
||||
plugin_id, font_manifest, plugin_dir=plugin_dir)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
"Failed to register fonts for plugin %s: %s", plugin_id, e
|
||||
@@ -660,9 +660,15 @@ class PluginManager:
|
||||
if not self.unload_plugin(plugin_id):
|
||||
return False
|
||||
|
||||
# Re-discover to get updated manifest
|
||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
# Re-read the manifest so an edit to it takes effect, from the
|
||||
# directory discovery found the plugin in: a directory's name need not
|
||||
# be the id its manifest declares.
|
||||
with self._discovery_lock:
|
||||
directories = dict(self.plugin_directories)
|
||||
plugin_dir = self.plugin_loader.find_plugin_directory(
|
||||
plugin_id, self.plugins_dir, directories)
|
||||
manifest_path = plugin_dir / "manifest.json" if plugin_dir is not None else None
|
||||
if manifest_path is not None and manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
@@ -772,24 +778,20 @@ class PluginManager:
|
||||
not one plain path segment (``..``, ``a/b``, an absolute path) is
|
||||
refused instead of being joined onto ``plugins_dir``. The join is not
|
||||
resolved further: dev plugins are symlinks into ``plugins_dir``.
|
||||
|
||||
The discovery map is authoritative. For an id discovery has not seen,
|
||||
only directory names are tried -- ``<id>`` then ``ledmatrix-<id>``,
|
||||
in ``plugins_dir`` only -- so a miss on a web request never reads
|
||||
every manifest on disk. Rules: ``src/plugin_system/plugin_dirs.py``.
|
||||
"""
|
||||
with self._discovery_lock:
|
||||
if plugin_id in self.plugin_directories:
|
||||
return str(self.plugin_directories[plugin_id])
|
||||
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
if plugin_id is None:
|
||||
return None
|
||||
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
return str(plugin_dir)
|
||||
|
||||
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
if plugin_dir.exists():
|
||||
return str(plugin_dir)
|
||||
|
||||
return None
|
||||
plugin_dir = resolve_plugin_dir(
|
||||
plugin_id, [self.plugins_dir], prefix=True, case_insensitive=False,
|
||||
by_manifest=False)
|
||||
return str(plugin_dir) if plugin_dir is not None else None
|
||||
|
||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||
"""
|
||||
@@ -884,11 +886,12 @@ class PluginManager:
|
||||
updating, since a scheduler that propagates a plugin bug stops every
|
||||
other plugin too.
|
||||
|
||||
The static result is cached per plugin_id after the first lookup to
|
||||
avoid calling config_manager.get_config() — which returns a full dict
|
||||
copy — on every tick of the 30-fps display loop. The cache is
|
||||
invalidated when a plugin is loaded or unloaded. The dynamic hook is
|
||||
deliberately *not* cached: caching it would defeat its only purpose.
|
||||
The static result is cached per plugin_id after the first lookup, so
|
||||
the manifest/config resolution is not repeated on every scheduling
|
||||
tick of the display loop. A change to ``update_interval`` in
|
||||
config.json therefore takes effect when the plugin is next loaded or
|
||||
unloaded, which clears the cache. The dynamic hook is deliberately
|
||||
*not* cached: caching it would defeat its only purpose.
|
||||
"""
|
||||
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
|
||||
if dynamic is not None:
|
||||
|
||||
@@ -41,7 +41,6 @@ class PluginStateManager:
|
||||
self._state_transition_counts: Dict[str, int] = {}
|
||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||
self._last_update: Dict[str, datetime] = {}
|
||||
self._last_display: Dict[str, datetime] = {}
|
||||
|
||||
def _record_transition(self, plugin_id: str) -> None:
|
||||
"""Count a state transition. Callers must already hold ``_lock``."""
|
||||
@@ -181,11 +180,7 @@ class PluginStateManager:
|
||||
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
|
||||
"""Get timestamp of last update() call."""
|
||||
return self._last_update.get(plugin_id)
|
||||
|
||||
def get_last_display(self, plugin_id: str) -> Optional[datetime]:
|
||||
"""Get timestamp of last display() call."""
|
||||
return self._last_display.get(plugin_id)
|
||||
|
||||
|
||||
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive state information for a plugin.
|
||||
@@ -212,7 +207,6 @@ class PluginStateManager:
|
||||
'is_error': self.is_error(plugin_id),
|
||||
'can_execute': self.can_execute(plugin_id),
|
||||
'last_update': self.get_last_update(plugin_id),
|
||||
'last_display': self.get_last_display(plugin_id),
|
||||
'error_info': self.get_error_info(plugin_id),
|
||||
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
|
||||
}
|
||||
@@ -231,5 +225,4 @@ class PluginStateManager:
|
||||
self._state_transition_counts.pop(plugin_id, None)
|
||||
self._error_info.pop(plugin_id, None)
|
||||
self._last_update.pop(plugin_id, None)
|
||||
self._last_display.pop(plugin_id, None)
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Repository URL helpers shared by the plugin store and saved repositories.
|
||||
|
||||
One definition of "the same repository URL", of how a GitHub URL maps to
|
||||
``owner/repo``, and of the headers sent to the GitHub API.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
#: Hosts whose URLs name a GitHub repository. Matched against
|
||||
#: ``urlparse(url).hostname``, never by substring: a substring test accepts
|
||||
#: ``https://github.com.example.org/...`` as GitHub.
|
||||
GITHUB_HOSTS = frozenset({'github.com', 'www.github.com'})
|
||||
|
||||
#: Sent on every request the store makes to GitHub.
|
||||
USER_AGENT = 'LEDMatrix-Plugin-Manager/1.0'
|
||||
|
||||
|
||||
def normalize_repo_url(url: str) -> str:
|
||||
"""``url`` without surrounding whitespace, trailing slashes or a trailing ``.git``.
|
||||
|
||||
Only a *trailing* ``.git`` is removed. The unanchored
|
||||
``url.replace('.git', '')`` this replaces turned
|
||||
``https://github.com/user/my.github.io`` into ``.../myhub.io``.
|
||||
Case is preserved; compare with :func:`same_repo`.
|
||||
"""
|
||||
url = url.strip().rstrip('/')
|
||||
if url.endswith('.git'):
|
||||
url = url[:-4]
|
||||
return url
|
||||
|
||||
|
||||
def same_repo(url_a: str, url_b: str) -> bool:
|
||||
"""Whether two URLs name the same repository.
|
||||
|
||||
GitHub owner and repository names are case-insensitive, so
|
||||
``ChuckBuilds/LEDMatrix-Plugins`` and ``chuckbuilds/ledmatrix-plugins``
|
||||
are one repository.
|
||||
"""
|
||||
return normalize_repo_url(url_a).lower() == normalize_repo_url(url_b).lower()
|
||||
|
||||
|
||||
def github_owner_repo(url: str) -> Optional[Tuple[str, str]]:
|
||||
"""``(owner, repo)`` for a github.com repository URL, else None.
|
||||
|
||||
The first two path segments, so a URL that points inside the repository
|
||||
(``.../owner/repo/tree/main/plugins/x``) still names ``owner/repo``.
|
||||
"""
|
||||
parsed = urlparse(normalize_repo_url(url))
|
||||
if parsed.hostname not in GITHUB_HOSTS:
|
||||
return None
|
||||
parts = [part for part in parsed.path.split('/') if part]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return parts[0], normalize_repo_url(parts[1])
|
||||
|
||||
|
||||
def github_api_headers(token: Optional[str] = None) -> Dict[str, str]:
|
||||
"""Headers for a GitHub REST API request, authenticated when ``token`` is set.
|
||||
|
||||
An authenticated request gets 5000 requests an hour instead of 60.
|
||||
"""
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': USER_AGENT,
|
||||
}
|
||||
if token:
|
||||
headers['Authorization'] = f'token {token}'
|
||||
return headers
|
||||
@@ -33,7 +33,14 @@ class ResourceLimits:
|
||||
|
||||
@dataclass
|
||||
class ResourceMetrics:
|
||||
"""Resource usage metrics for a plugin."""
|
||||
"""Resource usage metrics for a plugin.
|
||||
|
||||
``memory_mb`` is the largest growth in this *process's* resident memory
|
||||
seen across a single monitored call -- a high-water mark, not current
|
||||
usage, and not the plugin's own footprint (another thread allocating
|
||||
during the call counts too). ``cpu_percent`` is the whole process's CPU
|
||||
use since the previous sample.
|
||||
"""
|
||||
memory_mb: float = 0.0
|
||||
cpu_percent: float = 0.0
|
||||
execution_time: float = 0.0
|
||||
@@ -42,11 +49,6 @@ class ResourceMetrics:
|
||||
max_execution_time: float = 0.0
|
||||
min_execution_time: float = float('inf')
|
||||
last_update_time: float = field(default_factory=time.time)
|
||||
|
||||
def update_average_execution_time(self):
|
||||
"""Update average execution time."""
|
||||
if self.call_count > 0:
|
||||
self.total_execution_time = self.total_execution_time / self.call_count
|
||||
|
||||
|
||||
#: How often a plugin's metrics are written to the cache, in seconds.
|
||||
@@ -287,7 +289,8 @@ class PluginResourceMonitor:
|
||||
|
||||
# Calculate execution time
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
memory_growth_mb = 0.0
|
||||
|
||||
# Update metrics
|
||||
with self._lock:
|
||||
metrics.execution_time = execution_time
|
||||
@@ -302,17 +305,17 @@ class PluginResourceMonitor:
|
||||
|
||||
# Update memory and CPU if monitoring enabled
|
||||
if self.enable_monitoring:
|
||||
end_memory = self._get_process_memory_mb()
|
||||
metrics.memory_mb = max(metrics.memory_mb, end_memory - start_memory)
|
||||
memory_growth_mb = self._get_process_memory_mb() - start_memory
|
||||
metrics.memory_mb = max(metrics.memory_mb, memory_growth_mb)
|
||||
# CPU is harder to measure per-call, so we track it separately
|
||||
metrics.cpu_percent = self._get_process_cpu_percent()
|
||||
|
||||
|
||||
# Persist metrics, at most once per interval per plugin.
|
||||
self._persist_metrics(plugin_id, metrics)
|
||||
|
||||
# Check limits
|
||||
|
||||
if limits:
|
||||
self._check_limits(plugin_id, metrics, limits, execution_time)
|
||||
self._check_limits(plugin_id, metrics, limits, execution_time,
|
||||
memory_growth_mb)
|
||||
|
||||
return result
|
||||
|
||||
@@ -326,9 +329,17 @@ class PluginResourceMonitor:
|
||||
metrics.last_update_time = time.time()
|
||||
raise
|
||||
|
||||
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
||||
limits: ResourceLimits, execution_time: float) -> None:
|
||||
"""Check if plugin has exceeded resource limits."""
|
||||
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
||||
limits: ResourceLimits, execution_time: float,
|
||||
memory_growth_mb: float) -> None:
|
||||
"""Raise ResourceLimitExceeded if this call went over a limit.
|
||||
|
||||
Execution time and memory growth are this call's own; CPU is the
|
||||
latest process sample. Judging memory by the stored high-water mark
|
||||
(``metrics.memory_mb``) instead would fail every call after the first
|
||||
expensive one, so the health tracker's circuit breaker would reopen
|
||||
on every recovery probe and the plugin would never update again.
|
||||
"""
|
||||
warnings = []
|
||||
errors = []
|
||||
|
||||
@@ -343,13 +354,13 @@ class PluginResourceMonitor:
|
||||
)
|
||||
|
||||
# Check memory
|
||||
if limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb:
|
||||
if limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb:
|
||||
errors.append(
|
||||
f"Memory usage {metrics.memory_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
|
||||
f"Memory growth {memory_growth_mb:.2f}MB exceeds limit {limits.max_memory_mb:.2f}MB"
|
||||
)
|
||||
elif limits.max_memory_mb and metrics.memory_mb > limits.max_memory_mb * limits.warning_threshold:
|
||||
elif limits.max_memory_mb and memory_growth_mb > limits.max_memory_mb * limits.warning_threshold:
|
||||
warnings.append(
|
||||
f"Memory usage {metrics.memory_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
|
||||
f"Memory growth {memory_growth_mb:.2f}MB approaching limit {limits.max_memory_mb:.2f}MB"
|
||||
)
|
||||
|
||||
# Check CPU
|
||||
|
||||
@@ -10,6 +10,8 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from src.plugin_system.repo_urls import normalize_repo_url
|
||||
|
||||
|
||||
class SavedRepositoriesManager:
|
||||
"""Manages saved GitHub repository URLs."""
|
||||
@@ -71,18 +73,6 @@ class SavedRepositoriesManager:
|
||||
pass
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _clean_url(repo_url: str) -> str:
|
||||
"""Normalize a repo URL: strip whitespace, trailing slashes, and a
|
||||
trailing ``.git`` suffix ONLY. (The old ``.replace('.git', '')``
|
||||
was an unanchored substring replace that mangled URLs merely
|
||||
containing ``.git``, e.g. ``https://github.com/user/my.github.io``.)
|
||||
"""
|
||||
repo_url = repo_url.strip().rstrip('/')
|
||||
if repo_url.endswith('.git'):
|
||||
repo_url = repo_url[:-4]
|
||||
return repo_url
|
||||
|
||||
def get_all(self) -> List[Dict[str, str]]:
|
||||
"""Get all saved repositories."""
|
||||
return self.repositories.copy()
|
||||
@@ -98,7 +88,7 @@ class SavedRepositoriesManager:
|
||||
Returns:
|
||||
True if added successfully
|
||||
"""
|
||||
repo_url = self._clean_url(repo_url)
|
||||
repo_url = normalize_repo_url(repo_url)
|
||||
|
||||
# Check if already exists
|
||||
for repo in self.repositories:
|
||||
@@ -138,7 +128,7 @@ class SavedRepositoriesManager:
|
||||
Returns:
|
||||
True if removed successfully
|
||||
"""
|
||||
repo_url = self._clean_url(repo_url)
|
||||
repo_url = normalize_repo_url(repo_url)
|
||||
|
||||
previous = self.repositories
|
||||
remaining = [r for r in previous if r.get('url') != repo_url]
|
||||
@@ -156,7 +146,7 @@ class SavedRepositoriesManager:
|
||||
|
||||
def has(self, repo_url: str) -> bool:
|
||||
"""Check if a repository is already saved."""
|
||||
repo_url = self._clean_url(repo_url)
|
||||
repo_url = normalize_repo_url(repo_url)
|
||||
return any(r.get('url') == repo_url for r in self.repositories)
|
||||
|
||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||
|
||||
@@ -14,6 +14,7 @@ import jsonschema
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.element_style import expand_style_elements
|
||||
|
||||
|
||||
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
||||
@@ -452,11 +453,7 @@ class SchemaManager:
|
||||
# full per-element style blocks (font/size/color + layout
|
||||
# offsets) the web-UI config form renders. No-op for schemas
|
||||
# without the declaration; never raises.
|
||||
try:
|
||||
from src.element_style import expand_style_elements
|
||||
schema = expand_style_elements(schema)
|
||||
except ImportError:
|
||||
pass
|
||||
schema = expand_style_elements(schema)
|
||||
|
||||
# Cache the schema
|
||||
self._schema_cache[plugin_id] = schema
|
||||
@@ -643,20 +640,12 @@ class SchemaManager:
|
||||
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
|
||||
)
|
||||
|
||||
# Create validator with enhanced schema
|
||||
# iter_errors reports every violation, including one ``required``
|
||||
# error per missing field at every depth.
|
||||
validator = Draft7Validator(enhanced_schema)
|
||||
|
||||
# Collect all validation errors
|
||||
for error in validator.iter_errors(config):
|
||||
error_msg = self._format_validation_error(error, plugin_id)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Check required fields
|
||||
required_fields = enhanced_schema.get('required', [])
|
||||
for field in required_fields:
|
||||
if field not in config:
|
||||
errors.append(f"Missing required field: '{field}'")
|
||||
|
||||
errors.append(self._format_validation_error(error, plugin_id))
|
||||
|
||||
if errors:
|
||||
return False, errors
|
||||
|
||||
@@ -687,7 +676,15 @@ class SchemaManager:
|
||||
field_path = f"'{path}'" if path else "root"
|
||||
|
||||
if error.validator == 'required':
|
||||
missing = error.validator_value
|
||||
# validator_value is the schema's whole ``required`` list; the
|
||||
# error itself is about one field, which jsonschema names only in
|
||||
# its message ("'api_key' is a required property").
|
||||
missing = next(
|
||||
(name for name in error.validator_value
|
||||
if error.message.startswith(f"{name!r} ")),
|
||||
None)
|
||||
if missing is None:
|
||||
return f"Field {field_path}: {error.message}"
|
||||
return f"Field {field_path}: Missing required property '{missing}'"
|
||||
elif error.validator == 'type':
|
||||
expected = error.validator_value
|
||||
|
||||
@@ -34,7 +34,9 @@ class PluginState:
|
||||
version: Optional[str] = None
|
||||
installed_at: Optional[datetime] = None
|
||||
last_updated: Optional[datetime] = None
|
||||
config_version: int = 1 # For detecting state corruption
|
||||
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
|
||||
# plugin_state.json keeps the shape older releases load with cls(**data).
|
||||
config_version: int = 1
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -100,6 +102,8 @@ class PluginStateManager:
|
||||
|
||||
# State storage
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
# The file's top-level "version", written back as read. Nothing
|
||||
# checks it yet; it is there for a future format change to branch on.
|
||||
self._state_version = 1
|
||||
|
||||
# Threading
|
||||
@@ -193,7 +197,6 @@ class PluginStateManager:
|
||||
current_state.metadata = {}
|
||||
current_state.metadata.update(updates['metadata'])
|
||||
|
||||
# Increment config version
|
||||
current_state.config_version += 1
|
||||
|
||||
# Store updated state
|
||||
|
||||
@@ -15,6 +15,7 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.logging_config import get_logger
|
||||
|
||||
@@ -102,31 +103,25 @@ def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str
|
||||
def disk_plugin_ids(plugins_dir) -> Set[str]:
|
||||
"""Plugin ids actually installed on disk.
|
||||
|
||||
A directory counts only when it is not a standalone backup and its
|
||||
manifest.json parses. A corrupt manifest must not read as installed, or a
|
||||
live "in config but not on disk" finding gets cleared on the strength of an
|
||||
unreadable file.
|
||||
A directory counts only when it is not a standalone backup (or hidden)
|
||||
and its manifest.json parses. A corrupt manifest must not read as
|
||||
installed, or a live "in config but not on disk" finding gets cleared on
|
||||
the strength of an unreadable file.
|
||||
|
||||
The id is the manifest's ``id`` -- what discovery registers and what the
|
||||
config is keyed by -- and the directory name only when the manifest has
|
||||
none. Directory names alone made a plugin living in ``ledmatrix-stocks/``
|
||||
with id ``stocks`` read as both "stocks in config but not on disk" and
|
||||
"ledmatrix-stocks on disk but not in config".
|
||||
"""
|
||||
ids: Set[str] = set()
|
||||
root = Path(plugins_dir)
|
||||
try:
|
||||
if not root.exists():
|
||||
return ids
|
||||
for entry in root.iterdir():
|
||||
if not entry.is_dir() or '.standalone-backup-' in entry.name:
|
||||
continue
|
||||
manifest = entry / "manifest.json"
|
||||
if not manifest.exists():
|
||||
continue
|
||||
try:
|
||||
with open(manifest, 'r') as f:
|
||||
json.load(f)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
ids.add(entry.name)
|
||||
return _disk_index(plugins_dir).installed_ids(require_parseable_manifest=True)
|
||||
except OSError:
|
||||
return ids
|
||||
return ids
|
||||
return set()
|
||||
|
||||
|
||||
def _disk_index(plugins_dir) -> PluginDirectoryIndex:
|
||||
return PluginDirectoryIndex.scan(Path(plugins_dir))
|
||||
|
||||
|
||||
def still_unresolved(entries: List[Dict[str, Any]],
|
||||
@@ -326,16 +321,15 @@ class StateReconciliation:
|
||||
"""Get plugin state from disk (installed plugins)."""
|
||||
state = {}
|
||||
try:
|
||||
# Membership comes from the shared extractor so the web interface
|
||||
# re-checks stored findings against this same definition; the
|
||||
# manifest is then re-read here only for version/name.
|
||||
for plugin_id in disk_plugin_ids(self.plugins_dir):
|
||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
||||
try:
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, ValueError): # nosec B112 - raced or corrupt; skip
|
||||
continue
|
||||
# Membership uses the same index and rule as disk_plugin_ids, so
|
||||
# the web interface re-checks stored findings against this same
|
||||
# definition; each manifest is read once, by the scan.
|
||||
index = _disk_index(self.plugins_dir)
|
||||
for plugin_id in index.installed_ids(require_parseable_manifest=True):
|
||||
entry = index.entry_for_installed_id(plugin_id)
|
||||
manifest = entry.manifest if entry is not None else None
|
||||
if not isinstance(manifest, dict):
|
||||
manifest = {}
|
||||
state[plugin_id] = {
|
||||
'exists_on_disk': True,
|
||||
'version': manifest.get('version'),
|
||||
|
||||
+352
-520
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