mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Compare commits
46
Commits
c5281d9a45
...
853b5aa618
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853b5aa618 | ||
|
|
8ad9d191a7 | ||
|
|
7f9c73e9aa | ||
|
|
86c27970ef | ||
|
|
ab39552cdb | ||
|
|
83fd3491c7 | ||
|
|
37203e635c | ||
|
|
695be34a1b | ||
|
|
ffdd0efd43 | ||
|
|
cfcdbfb18a | ||
|
|
4c17e18786 | ||
|
|
b9416ef803 | ||
|
|
764fc6fcdb | ||
|
|
f6afbdbb15 | ||
|
|
99a3608516 | ||
|
|
b1510c209d | ||
|
|
48a433328a | ||
|
|
edfcd9e2a1 | ||
|
|
3a81f38f09 | ||
|
|
7b90759252 | ||
|
|
b11bcfa204 | ||
|
|
3967a6cffc | ||
|
|
74ba36a059 | ||
|
|
d37a3a712a | ||
|
|
dc26baa134 | ||
|
|
6aef54598b | ||
|
|
7b16e953d2 | ||
|
|
52bc520335 | ||
|
|
4e61d7248a | ||
|
|
ece416c4e5 | ||
|
|
13bbb537f3 | ||
|
|
afe9001aed | ||
|
|
abedc46104 | ||
|
|
1fe7237799 | ||
|
|
ddf5f085a5 | ||
|
|
5baf983fe0 | ||
|
|
82f3a3a3e4 | ||
|
|
c1ce0b7b04 | ||
|
|
14c38a3189 | ||
|
|
b67818d5c5 | ||
|
|
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
|
||||
|
||||
@@ -128,7 +128,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `min_content_separation` | int, `24` |
|
||||
| `min_cut_gap` | int, `6` |
|
||||
| `continuous_scroll` | bool, `true` |
|
||||
| `smooth_scroll` | bool, `true` |
|
||||
| `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` |
|
||||
| `auto_trim` | bool, `true` |
|
||||
| `trim_threshold` | int, `10` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": { },
|
||||
|
||||
+270
-1
@@ -207,7 +207,10 @@ Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
||||
### 3. Sub-pixel blending was wrong for this display
|
||||
|
||||
Enabling it made things worse, not better — see the rule at the top. It is off
|
||||
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
||||
by default everywhere. Vegas mode used to opt in; it now scrolls in whole
|
||||
pixels locked to the refresh like the plugin tickers, and keeps the blend only
|
||||
behind `display.vegas_scroll.sub_pixel_blend` (default `false`). The blend is
|
||||
also why text looked anti-aliased in the web preview while the panel shimmered.
|
||||
|
||||
### 4. Frame-based stepping raced the vsync clock
|
||||
|
||||
@@ -231,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
|
||||
@@ -303,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)
|
||||
|
||||
|
||||
+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
+235
-175
@@ -34,12 +34,12 @@ 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
|
||||
@@ -47,13 +47,14 @@ import threading
|
||||
import time
|
||||
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,
|
||||
@@ -61,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 --
|
||||
@@ -134,11 +133,6 @@ class _LogicalMatrix:
|
||||
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||
|
||||
|
||||
# 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:
|
||||
"""
|
||||
@@ -160,7 +154,6 @@ class DisplayManager:
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
@@ -203,7 +196,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[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
|
||||
@@ -233,6 +238,11 @@ class DisplayManager:
|
||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
||||
self._frame_hold = 1
|
||||
|
||||
# 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,
|
||||
@@ -249,20 +259,16 @@ class DisplayManager:
|
||||
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:
|
||||
@@ -331,7 +337,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(
|
||||
@@ -340,9 +346,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
|
||||
@@ -373,7 +377,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']
|
||||
@@ -381,9 +385,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))
|
||||
@@ -612,18 +614,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))
|
||||
|
||||
@@ -638,7 +636,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)
|
||||
@@ -713,9 +711,7 @@ class DisplayManager:
|
||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||
# With no hardware, the width/height properties fall through to
|
||||
# self.image, so swapping the buffer below is enough on its own.
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
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(target_w, target_h)
|
||||
yield
|
||||
finally:
|
||||
self.matrix = real_matrix
|
||||
@@ -810,15 +806,21 @@ 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._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.
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, 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
|
||||
@@ -877,28 +879,14 @@ class DisplayManager:
|
||||
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._capture_mode_active:
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||
@@ -928,43 +916,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)
|
||||
|
||||
@@ -1013,19 +974,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")
|
||||
|
||||
@@ -1383,6 +1338,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()
|
||||
@@ -1391,14 +1348,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."""
|
||||
@@ -1436,9 +1393,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', '')
|
||||
@@ -1492,6 +1449,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).
|
||||
|
||||
@@ -1531,13 +1509,16 @@ class DisplayManager:
|
||||
that does not care gets a new frame every refresh.
|
||||
"""
|
||||
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."""
|
||||
@@ -1606,9 +1587,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]
|
||||
@@ -1706,67 +1684,149 @@ 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: ensure directory permissions once, not per frame
|
||||
snapshot_path_obj = Path(self._snapshot_path)
|
||||
if not self._snapshot_dir_prepared:
|
||||
# Never modify /tmp permissions - it has special system
|
||||
# permissions (1777) that must not be changed or it breaks
|
||||
# apt and other system tools
|
||||
parent_dir = snapshot_path_obj.parent
|
||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
self._snapshot_dir_prepared = True
|
||||
# Write atomically: temp then replace. The temp name must be
|
||||
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
||||
# and this file is written by whichever user the display service
|
||||
# runs as while tests and tooling run as someone else. A leftover
|
||||
# fixed-name temp owned by another user is then unopenable even by
|
||||
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
||||
# sticky dir), which froze the preview and the health check's
|
||||
# liveness proxy until somebody deleted it by hand. Same pattern as
|
||||
# the hardware-status write above.
|
||||
_fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(snapshot_path_obj.parent),
|
||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(_fd, "wb") as _f:
|
||||
self.image.save(_f, format='PNG')
|
||||
os.chmod(tmp_path, 0o644)
|
||||
os.replace(tmp_path, self._snapshot_path)
|
||||
except Exception:
|
||||
# Never leave the temp behind -- that is what made the failure
|
||||
# permanent rather than transient.
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to direct save if replace not supported
|
||||
self.image.save(self._snapshot_path, format='PNG')
|
||||
# Set proper file permissions after saving
|
||||
try:
|
||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||
except Exception:
|
||||
pass
|
||||
# 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,
|
||||
# so on the render thread every preview write made the next swap
|
||||
# miss its vsync -- five visible hitches a second, but only while
|
||||
# someone had the web preview open. Pillow releases the GIL while
|
||||
# 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(), digest)
|
||||
else:
|
||||
# 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
|
||||
except Exception as e:
|
||||
# Snapshot failures must never break display — but they must not
|
||||
# be silent either: the snapshot's mtime is the web UI's display
|
||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||
# write freezes the mirror and makes health checks lie (seen in
|
||||
# the field: a stale root-owned /tmp file froze it for a day).
|
||||
# Warn at most once per 5 minutes to avoid log spam.
|
||||
if (now - self._snapshot_fail_log_ts) > 300:
|
||||
self._snapshot_fail_log_ts = now
|
||||
logger.warning("Snapshot write failing (web preview/health "
|
||||
"mirror is stale): %s", e)
|
||||
else:
|
||||
logger.debug(f"Snapshot write skipped: {e}")
|
||||
self._log_snapshot_failure(e)
|
||||
|
||||
def _log_snapshot_failure(self, error: Exception) -> None:
|
||||
# Snapshot failures must never break display — but they must not
|
||||
# be silent either: the snapshot's mtime is the web UI's display
|
||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||
# write freezes the mirror and makes health checks lie (seen in
|
||||
# the field: a stale root-owned /tmp file froze it for a day).
|
||||
# Warn at most once per 5 minutes to avoid log spam.
|
||||
now = time.time()
|
||||
if (now - self._snapshot_fail_log_ts) > 300:
|
||||
self._snapshot_fail_log_ts = now
|
||||
logger.warning("Snapshot write failing (web preview/health "
|
||||
"mirror is stale): %s", error)
|
||||
else:
|
||||
logger.debug(f"Snapshot write skipped: {error}")
|
||||
|
||||
def _save_snapshot(self, image: Image.Image) -> None:
|
||||
"""Encode ``image`` to the snapshot path atomically. Raises on failure."""
|
||||
# Ensure directory permissions once, not per frame
|
||||
snapshot_path_obj = Path(self._snapshot_path)
|
||||
if not self._snapshot_dir_prepared:
|
||||
# Never modify /tmp permissions - it has special system
|
||||
# permissions (1777) that must not be changed or it breaks
|
||||
# apt and other system tools
|
||||
parent_dir = snapshot_path_obj.parent
|
||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
self._snapshot_dir_prepared = True
|
||||
# Write atomically: temp then replace. The temp name must be
|
||||
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
||||
# and this file is written by whichever user the display service
|
||||
# runs as while tests and tooling run as someone else. A leftover
|
||||
# fixed-name temp owned by another user is then unopenable even by
|
||||
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
||||
# sticky dir), which froze the preview and the health check's
|
||||
# liveness proxy until somebody deleted it by hand. Same pattern as
|
||||
# the hardware-status write above.
|
||||
_fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(snapshot_path_obj.parent),
|
||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(_fd, "wb") as _f:
|
||||
image.save(_f, format='PNG')
|
||||
os.chmod(tmp_path, 0o644)
|
||||
os.replace(tmp_path, self._snapshot_path)
|
||||
except Exception:
|
||||
# Never leave the temp behind -- that is what made the failure
|
||||
# permanent rather than transient.
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to direct save if replace not supported
|
||||
image.save(self._snapshot_path, format='PNG')
|
||||
# Set proper file permissions after saving
|
||||
try:
|
||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
frame is due, the waiting frame is simply replaced. The preview wants
|
||||
the latest frame, and a backlog would only cost memory and CPU.
|
||||
"""
|
||||
with self._snapshot_cond:
|
||||
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,
|
||||
name="snapshot-writer")
|
||||
self._snapshot_thread.start()
|
||||
self._snapshot_cond.notify()
|
||||
|
||||
def _snapshot_writer(self) -> None:
|
||||
while True:
|
||||
with self._snapshot_cond:
|
||||
while self._snapshot_pending is None and not self._snapshot_stop:
|
||||
self._snapshot_cond.wait()
|
||||
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
@@ -14,13 +14,16 @@ PIL Image canvas and draws text using the actual project fonts.
|
||||
MAINTENANCE WARNING: this class is a deliberate fork of
|
||||
src/display_manager.py so it can run without hardware. It mirrors
|
||||
these DisplayManager methods by name and behavior: _load_fonts,
|
||||
_draw_bdf_text, get_font_height, get_text_width, draw_text,
|
||||
get_font_height, get_text_width, draw_text,
|
||||
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
|
||||
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
|
||||
capture_mode, set_scrolling_state, is_currently_scrolling,
|
||||
process_deferred_updates, update_display, render_size. A behavior
|
||||
change to any of those in DisplayManager must be mirrored here, or
|
||||
plugin visual tests will pass against stale behavior.
|
||||
|
||||
BDF text is not mirrored: both classes load BDF faces and draw BDF glyphs
|
||||
through src/common/bdf_font.py, so those pixels cannot drift.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -31,6 +34,7 @@ from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
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
|
||||
|
||||
from src.logging_config import get_logger
|
||||
@@ -147,16 +151,18 @@ class VisualTestDisplayManager:
|
||||
self.small_font = load_truetype(ttf_path, crisp_size(press_start, 8))
|
||||
self.font = self.regular_font # alias used by some code paths
|
||||
|
||||
# 5x7 BDF font via freetype
|
||||
# 5x7 BDF font, loaded exactly as DisplayManager._load_fonts does
|
||||
# (same loader, same 7px request as its _CALENDAR_FONT_PX). A bare
|
||||
# freetype.Face has no active size, so its ascender reads 0 and
|
||||
# every line drew a baseline too high.
|
||||
try:
|
||||
import freetype
|
||||
bdf_path = str(fonts_dir / '5x7.bdf')
|
||||
if not os.path.exists(bdf_path):
|
||||
raise FileNotFoundError(f"BDF font not found: {bdf_path}")
|
||||
face = freetype.Face(bdf_path)
|
||||
face, _ = load_bdf_face(bdf_path, 7)
|
||||
self.calendar_font = face
|
||||
self.bdf_5x7_font = face
|
||||
except (ImportError, FileNotFoundError, OSError) as e:
|
||||
except Exception as e: # freetype missing or the file unloadable
|
||||
logger.debug("BDF font not available, using small_font as fallback: %s", e)
|
||||
self.calendar_font = self.small_font
|
||||
self.bdf_5x7_font = self.small_font
|
||||
@@ -300,41 +306,18 @@ class VisualTestDisplayManager:
|
||||
logger.debug(f"Error drawing image: {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.
|
||||
|
||||
Replicated from DisplayManager._draw_bdf_text().
|
||||
Not a copy: DisplayManager._draw_bdf_text calls the same
|
||||
:func:`src.common.bdf_font.draw_bdf_text`, so what this draws is
|
||||
what the panel draws.
|
||||
"""
|
||||
try:
|
||||
if isinstance(color, list):
|
||||
color = tuple(color)
|
||||
face = font if font else self.calendar_font
|
||||
|
||||
# Compute baseline from font ascender
|
||||
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
|
||||
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
|
||||
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))):
|
||||
pixel_x = x + glyph_left + j
|
||||
pixel_y = baseline_y - glyph_top + i
|
||||
if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height:
|
||||
self.draw.point((pixel_x, pixel_y), fill=color)
|
||||
|
||||
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.debug(f"Error drawing BDF text: {e}")
|
||||
|
||||
|
||||
+16
-3
@@ -24,8 +24,13 @@ _REDACT_CREDENTIAL = re.compile(
|
||||
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
||||
# above, whose value part stops at whitespace and so would keep the credential
|
||||
# once a space follows the scheme.
|
||||
#
|
||||
# The opening quote and the whitespace after it are one optional unit. Written
|
||||
# `\s*["\']?\s*`, a whitespace run with no quote in it could be split between
|
||||
# the two `\s*` in every possible way, and a header with no credential after
|
||||
# it tried them all: quadratic, 8s for 20k spaces.
|
||||
_REDACT_AUTH_HEADER = re.compile(
|
||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*(?:["\']\s*)?'
|
||||
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
||||
r'([^\s,"\'<>}]+)', # the credential, redacted
|
||||
re.IGNORECASE,
|
||||
@@ -34,8 +39,16 @@ _REDACT_AUTH_HEADER = re.compile(
|
||||
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
||||
# the full URL in its exceptions, so this is a realistic leak. The username is
|
||||
# kept -- it identifies which account failed without being the secret.
|
||||
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||
re.IGNORECASE)
|
||||
#
|
||||
# A match may only start where a run of scheme characters starts. Unanchored,
|
||||
# `[a-z][a-z0-9+.-]*://` was tried from every letter of a long run (a hex
|
||||
# digest, an ID, a blob of response body), each attempt reading to the end of
|
||||
# the run: quadratic, 1.6s for 20k characters, all of it holding the GIL.
|
||||
# Leading digits and `+.-` sit inside group 1 so the substitution puts them
|
||||
# back; the scheme proper still has to start with a letter.
|
||||
_REDACT_URL_USERINFO = re.compile(
|
||||
r'((?<![a-z0-9+.-])[0-9+.-]*[a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def redact_credentials(text: str) -> str:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user