mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-12 18:16:35 +00:00
Compare commits
64
Commits
8163104581
...
489c9e2fd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489c9e2fd3 | ||
|
|
865d62f67b | ||
|
|
3471ebe845 | ||
|
|
8ad9d191a7 | ||
|
|
7f9c73e9aa | ||
|
|
c3bc09a3ae | ||
|
|
ab39552cdb | ||
|
|
a95b9bd96e | ||
|
|
98ebc859e0 | ||
|
|
0bd63f8c20 | ||
|
|
ae828e1454 | ||
|
|
37203e635c | ||
|
|
00e14ea55f | ||
|
|
ab38c5f320 | ||
|
|
ffdd0efd43 | ||
|
|
892d33e638 | ||
|
|
01b82edc23 | ||
|
|
9c81e6b42f | ||
|
|
4c17e18786 | ||
|
|
c43c10bd78 | ||
|
|
8482af7814 | ||
|
|
b9416ef803 | ||
|
|
478516dbd1 | ||
|
|
690407860e | ||
|
|
f6afbdbb15 | ||
|
|
99a3608516 | ||
|
|
42ff3825b4 | ||
|
|
edfcd9e2a1 | ||
|
|
3a81f38f09 | ||
|
|
19ee84a760 | ||
|
|
9a705c2ca4 | ||
|
|
7b90759252 | ||
|
|
b11bcfa204 | ||
|
|
3967a6cffc | ||
|
|
e39f65fce0 | ||
|
|
d37a3a712a | ||
|
|
409b63bc17 | ||
|
|
84043468ca | ||
|
|
a6648ec991 | ||
|
|
1df6def8f7 | ||
|
|
6279530c16 | ||
|
|
52bc520335 | ||
|
|
1afb2383cd | ||
|
|
4e61d7248a | ||
|
|
ece416c4e5 | ||
|
|
13bbb537f3 | ||
|
|
afe9001aed | ||
|
|
abedc46104 | ||
|
|
1fe7237799 | ||
|
|
ddf5f085a5 | ||
|
|
5baf983fe0 | ||
|
|
82f3a3a3e4 | ||
|
|
c1ce0b7b04 | ||
|
|
14c38a3189 | ||
|
|
de54fc879a | ||
|
|
b67818d5c5 | ||
|
|
4f30bc62ca | ||
|
|
55a2760892 | ||
|
|
f79618d4f7 | ||
|
|
d56ec2ab3a | ||
|
|
c883a2fd1e | ||
|
|
ac841f4583 | ||
|
|
98728d3b81 | ||
|
|
3eb7a2e349 |
@@ -3,21 +3,9 @@ name: Claude Code Review
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude-review:
|
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
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -27,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -45,6 +33,4 @@ jobs:
|
|||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
plugins: 'code-review@claude-code-plugins'
|
plugins: 'code-review@claude-code-plugins'
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
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
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -40,11 +40,3 @@ jobs:
|
|||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
actions: read
|
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.
|
# Plugin operation state written at runtime.
|
||||||
#
|
#
|
||||||
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
|
# web_interface/app.py writes data/plugin_state.json and data/operation_history.json
|
||||||
# and data/operation_history.json as the web interface runs, into a directory that
|
# (older releases also data/plugin_operations.json) as the web interface runs, into
|
||||||
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
|
# a directory that ships tracked (data/.gitkeep). Unignored, every rig that ever
|
||||||
# opened the web UI -- and every test run that constructs the app -- left three
|
# 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
|
# 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.
|
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||||
data/*
|
data/*
|
||||||
|
|||||||
+136
@@ -19,6 +19,93 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- `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
|
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
|
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
|
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
|
app's location in the web UI now actually clears it; the save used to drop
|
||||||
the blank field, so the old value stayed.
|
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
|
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
|
||||||
registered each font with `FontManager.register_manager_font()`, published
|
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.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
|
||||||
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
|
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
|
||||||
`shared_downloader`.
|
`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
|
### Logo downloads
|
||||||
|
|
||||||
@@ -120,6 +234,13 @@ floor on the release that ships them):
|
|||||||
when the count is only known to the display service.
|
when the count is only known to the display service.
|
||||||
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
|
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
|
||||||
errors and a Clear button.
|
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
|
### 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
|
`api_extractors`). No known plugin imports it. A plugin that does must use
|
||||||
`src.common` or its own copy of the code.
|
`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
|
## 3.5.0
|
||||||
|
|
||||||
New modules a plugin may import via `src.*` (floor on 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()`
|
loader does NOT fall back to it — `PluginManager.discover_plugins()`
|
||||||
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
||||||
directory. Fallbacks exist in two narrower places: store operations
|
directory. Fallbacks exist in two narrower places: store operations
|
||||||
(`StoreManager._find_plugin_path()` in `store_manager.py`) and schema
|
(`PluginStoreManager._find_plugin_path()` in `store_manager.py`, which
|
||||||
lookup (`SchemaManager.get_schema_path()` in `schema_manager.py`,
|
searches `store_search_dirs()` from `plugin_dirs.py`) and schema lookup
|
||||||
which probes `plugins/` *before* `plugin-repos/`).
|
(`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
|
## Plugin System
|
||||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||||
- Required abstract methods: `update()`, `display(force_clear=False)`
|
- 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`
|
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||||
- Config schemas use JSON Schema Draft-7
|
- Config schemas use JSON Schema Draft-7
|
||||||
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
- 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
|
- 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`)
|
- 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>`
|
- 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
|
## Plugin Store Architecture
|
||||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||||
- Plugin repo naming convention: `ledmatrix-<plugin-id>` (e.g., `ledmatrix-football-scoreboard`)
|
- 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`
|
- `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
|
- Store manager (`PluginStoreManager` in `src/plugin_system/store_manager.py`) handles install/update/uninstall
|
||||||
- Monorepo plugins are installed via ZIP extraction (no `.git` directory)
|
- 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)
|
- 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
|
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
## Common Pitfalls
|
## 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()`
|
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||||
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
||||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
`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.)
|
- Install required system packages (git, python3, build tools, etc.)
|
||||||
- Clone or update the LEDMatrix repository
|
- Clone or update the LEDMatrix repository
|
||||||
- Run the complete first-time installation script
|
- 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.
|
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
|
### Display Format Settings
|
||||||
|
|
||||||
- **`use_short_date_format`** (boolean, default: true)
|
- **`use_short_date_format`** (boolean, default: true)
|
||||||
- Use short date format (e.g., "Jan 15") instead of long format (e.g., "January 15th")
|
- Currently has no effect. The web UI still saves it, but no core code
|
||||||
- Set to `false` for longer, more readable dates
|
reads it. Scoreboard plugins that offer a short date format read the
|
||||||
- Set to `true` to save space and show more information
|
setting from their own plugin config instead. See
|
||||||
|
[CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md#display--other-keys).
|
||||||
|
|
||||||
### Dynamic Duration Settings (`display.dynamic_duration`)
|
### Dynamic Duration Settings (`display.dynamic_duration`)
|
||||||
|
|
||||||
@@ -779,15 +781,21 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
|||||||
<details>
|
<details>
|
||||||
<summary>Manual SSH Commands (for reference)</summary>
|
<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
|
```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
|
### Convenience Scripts
|
||||||
|
|
||||||
@@ -957,9 +965,10 @@ sudo systemctl enable ledmatrix-web.service
|
|||||||
3. Check if another service is using port 5000
|
3. Check if another service is using port 5000
|
||||||
|
|
||||||
**Service Fails to Start:**
|
**Service Fails to Start:**
|
||||||
1. Check Python dependencies are installed
|
1. Check Python dependencies are installed. The installer puts them in the
|
||||||
2. Verify the virtual environment is set up correctly
|
system Python with `pip install --break-system-packages` (there is no
|
||||||
3. Check file permissions and ownership
|
virtual environment), so `python3 -c "import flask"` should succeed.
|
||||||
|
2. Check file permissions and ownership
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+55
-74
@@ -185,63 +185,53 @@ their config section to control how oversized content is handled (see
|
|||||||
|
|
||||||
### Plugin Integration (Developer Guide)
|
### 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:**
|
**1. Implement Content Method:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_vegas_content(self):
|
def get_vegas_content(self):
|
||||||
"""
|
# Return a PIL Image, a list of Images, or None.
|
||||||
Return PIL Image or list of Images for Vegas mode.
|
# A single image is one block; a list becomes one item per image.
|
||||||
|
return [self._render_game(game) for game in self.games]
|
||||||
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]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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:**
|
**2. Specify Content Type:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_vegas_content_type(self):
|
def get_vegas_content_type(self):
|
||||||
"""
|
# 'multi' | 'static' | 'none' -- default is 'static'
|
||||||
Specify how content should be handled.
|
return 'multi'
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 'multi' | 'static' | 'none'
|
|
||||||
"""
|
|
||||||
return 'multi' # Default for most plugins
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`'none'` excludes the plugin from Vegas mode.
|
||||||
|
|
||||||
**3. Optionally Specify Display Mode:**
|
**3. Optionally Specify Display Mode:**
|
||||||
|
|
||||||
```python
|
These return `VegasDisplayMode` members, not strings:
|
||||||
def get_vegas_display_mode(self):
|
|
||||||
"""
|
|
||||||
Preferred display mode for this plugin.
|
|
||||||
|
|
||||||
Returns:
|
```python
|
||||||
str: 'scroll' | 'fixed' | 'static'
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||||
"""
|
|
||||||
return 'scroll'
|
def get_vegas_display_mode(self):
|
||||||
|
return VegasDisplayMode.SCROLL
|
||||||
|
|
||||||
def get_supported_vegas_modes(self):
|
def get_supported_vegas_modes(self):
|
||||||
"""
|
return [VegasDisplayMode.SCROLL, VegasDisplayMode.STATIC]
|
||||||
List of supported modes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: ['scroll', 'fixed', 'static']
|
|
||||||
"""
|
|
||||||
return ['scroll', '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
|
### Content Rendering Guidelines
|
||||||
|
|
||||||
**Image Dimensions:**
|
**Image Dimensions:**
|
||||||
@@ -966,11 +956,16 @@ from src.cache_manager import CacheManager
|
|||||||
|
|
||||||
service = get_background_service(CacheManager())
|
service = get_background_service(CacheManager())
|
||||||
stats = service.get_statistics()
|
stats = service.get_statistics()
|
||||||
print(f"Active tasks: {stats['active_tasks']}")
|
print(f"Active: {stats['active_requests']}")
|
||||||
print(f"Completed: {stats['completed']}")
|
print(f"Completed: {stats['completed_requests']}")
|
||||||
print(f"Failed: {stats['failed']}")
|
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:**
|
**Enable Debug Logging:**
|
||||||
```python
|
```python
|
||||||
import logging
|
import logging
|
||||||
@@ -981,6 +976,10 @@ logging.getLogger('src.background_data_service').setLevel(logging.DEBUG)
|
|||||||
|
|
||||||
## 5. Permission Management
|
## 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
|
### 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.
|
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 |
|
| Config (secrets) | `rw-r-----` | `0o640` | Owner write, group read |
|
||||||
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
| Assets | `rw-rw-r--` | `0o664` | Owner/group write, all read |
|
||||||
| Plugins | `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:**
|
**Directory Permissions:**
|
||||||
|
|
||||||
@@ -1115,40 +1114,22 @@ These core utilities **already handle permissions** - you don't need to call per
|
|||||||
|
|
||||||
### Manual Fixes
|
### 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
|
- `fix_assets_permissions.sh`, `fix_cache_permissions.sh` and
|
||||||
# Targeted permission fixes (see scripts/fix_perms/README.md)
|
`fix_plugin_permissions.sh` are run with `sudo`.
|
||||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts)
|
- `fix_web_permissions.sh` is run as the web interface user, without
|
||||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh # all cache directories
|
`sudo` (it refuses to run as root and calls `sudo` itself where needed).
|
||||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh # plugin directories
|
It resets project file ownership for that user, then makes the two
|
||||||
sudo ./scripts/fix_perms/fix_web_permissions.sh # web interface files
|
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
|
Do not `chmod` the whole `config/` directory: `config_secrets.json` must stay
|
||||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
|
`640`.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
|||||||
- [Using Weather Icons](#using-weather-icons)
|
- [Using Weather Icons](#using-weather-icons)
|
||||||
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
- [Implementing Scrolling with Deferred Updates](#implementing-scrolling-with-deferred-updates)
|
||||||
- [Cache Strategy Patterns](#cache-strategy-patterns)
|
- [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)
|
- [Error Handling Best Practices](#error-handling-best-practices)
|
||||||
- [Performance Optimization](#performance-optimization)
|
- [Performance Optimization](#performance-optimization)
|
||||||
- [Testing Plugins with Mocks](#testing-plugins-with-mocks)
|
- [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
|
## Using Weather Icons
|
||||||
|
|
||||||
The Display Manager provides built-in weather icon drawing methods for easy visual representation of weather conditions.
|
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
|
||||||
|
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
|
||||||
### Basic Weather Icon Usage
|
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
|
||||||
```python
|
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
|
||||||
def display(self, force_clear=False):
|
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||||
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)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -251,11 +194,8 @@ def update(self):
|
|||||||
sport_key = "nhl"
|
sport_key = "nhl"
|
||||||
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
||||||
|
|
||||||
# Uses sport-specific live_update_interval from config
|
# get_background_cached_data() is deprecated, removed in 3.7.0 — use get()
|
||||||
cached = self.cache_manager.get_background_cached_data(
|
cached = self.cache_manager.get(cache_key, max_age=60)
|
||||||
cache_key,
|
|
||||||
sport_key=sport_key
|
|
||||||
)
|
|
||||||
|
|
||||||
if cached:
|
if cached:
|
||||||
self.games = 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
|
### Using Different Fonts
|
||||||
|
|
||||||
@@ -656,14 +596,12 @@ def update(self):
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
def update(self):
|
def update(self):
|
||||||
# Check if another plugin is enabled
|
# get_enabled_plugins() is deprecated, removed in 3.7.0 — check the
|
||||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
# instance's `enabled` flag instead
|
||||||
if "weather" in enabled_plugins:
|
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||||
# Weather plugin is available
|
if weather_plugin is not None and weather_plugin.enabled:
|
||||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
# Use weather data
|
||||||
if weather_plugin:
|
pass
|
||||||
# Use weather data
|
|
||||||
pass
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sharing Data Between Plugins
|
### 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
|
### 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
|
```bash
|
||||||
export LEDMATRIX_DEBUG=1
|
sudo systemctl stop ledmatrix.service
|
||||||
python run.py
|
sudo python3 run.py -d
|
||||||
|
# or
|
||||||
|
sudo LEDMATRIX_DEBUG=true python3 run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Merged Configuration
|
### Check Merged Configuration
|
||||||
@@ -321,8 +325,10 @@ cp config/backups/config.json.backup.20240115_120000_000000 config/config.json
|
|||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
1. Check logs: `tail -f logs/ledmatrix.log`
|
1. Check logs. Both services log to journald, not to a file:
|
||||||
2. Enable debug: `LEDMATRIX_DEBUG=1`
|
`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`
|
3. Check error dashboard: `/api/v3/errors/summary`
|
||||||
4. Validate JSON: https://jsonlint.com/
|
4. Validate JSON: https://jsonlint.com/
|
||||||
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
5. File an issue: https://github.com/ChuckBuilds/LEDMatrix/issues
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ logical image to multiple chained physical panels.
|
|||||||
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
|
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
|
||||||
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
|
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
|
||||||
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config |
|
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config |
|
||||||
|
| `scan_order_compensation` | string, `"auto"` | `"auto"` shows one half of each panel a refresh behind while something scrolls at one frame per refresh, which removes the 1px step a 1:N-scan panel shows across its middle; `"off"` disables it. Applies only to layouts whose row order is known: plain or parallel chains, 0 or 180 degree orientation, `multiplexing` 0, `scan_mode` 0, and not in the emulator | `DisplayManager._setup_scan_order_compensation()` (`src/display_manager.py`, `src/scan_order.py`) |
|
||||||
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
|
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
|
||||||
|
|
||||||
## `display.vegas_scroll` — continuous scroll mode
|
## `display.vegas_scroll` — continuous scroll mode
|
||||||
@@ -128,6 +129,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `min_cut_gap` | int, `6` |
|
| `min_cut_gap` | int, `6` |
|
||||||
| `continuous_scroll` | bool, `true` |
|
| `continuous_scroll` | bool, `true` |
|
||||||
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||||
|
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||||
|
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
|
||||||
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
| `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 |
|
| `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` |
|
| `extend_threshold_screens` | float, `2.0` |
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
|||||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||||
|
|
||||||
# Weather icons
|
# Weather icons: draw_weather_icon() is deprecated, removed in 3.7.0 —
|
||||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
# draw your own icons (the weather plugin ships WeatherIcons)
|
||||||
|
|
||||||
# Scrolling state
|
# Scrolling state
|
||||||
display_manager.set_scrolling_state(True)
|
display_manager.set_scrolling_state(True)
|
||||||
@@ -72,20 +72,23 @@ cache_manager.delete("key") # alias for clear_cache(key)
|
|||||||
|
|
||||||
# Advanced caching
|
# Advanced caching
|
||||||
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
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
|
||||||
strategy = cache_manager.get_cache_strategy("weather")
|
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
|
## Plugin Manager Quick Methods
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Get plugins
|
# Get plugins
|
||||||
plugin = plugin_manager.get_plugin("plugin-id")
|
plugin = plugin_manager.get_plugin("plugin-id")
|
||||||
all_plugins = plugin_manager.get_all_plugins()
|
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
|
# Get info
|
||||||
info = plugin_manager.get_plugin_info("plugin-id")
|
info = plugin_manager.get_plugin_info("plugin-id")
|
||||||
@@ -168,7 +171,7 @@ def display(self, force_clear=False):
|
|||||||
|
|
||||||
- [ ] Plugin inherits from `BasePlugin`
|
- [ ] Plugin inherits from `BasePlugin`
|
||||||
- [ ] Implements `update()` and `display()` methods
|
- [ ] 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)
|
- [ ] `config_schema.json` for web UI (recommended)
|
||||||
- [ ] `README.md` with documentation
|
- [ ] `README.md` with documentation
|
||||||
- [ ] Error handling implemented
|
- [ ] Error handling implemented
|
||||||
|
|||||||
+123
-326
@@ -9,12 +9,14 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
[`src/font_manager.py`](../src/font_manager.py) loads and caches the TTF and
|
||||||
- Manager font registration and detection
|
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
|
||||||
- Plugin font management
|
which plugin uses which font so the web UI can show it.
|
||||||
- Programmatic per-element font overrides
|
|
||||||
- Performance monitoring and caching
|
Several methods are deprecated and will be removed in LEDMatrix 3.7.0; they
|
||||||
- Dynamic font discovery
|
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
|
## Getting the FontManager
|
||||||
|
|
||||||
@@ -34,157 +36,60 @@ standalone FontManager when none is available (test harnesses, mocks).
|
|||||||
`DisplayManager` has **no** `font_manager` attribute —
|
`DisplayManager` has **no** `font_manager` attribute —
|
||||||
`display_manager.font_manager` raises `AttributeError`.
|
`display_manager.font_manager` raises `AttributeError`.
|
||||||
|
|
||||||
## Architecture
|
## Resolving a font
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from src.font_manager import FontManager
|
element_key = f"{self.plugin_id}.title"
|
||||||
|
|
||||||
class MyManager:
|
# Register the choice so the web UI's Fonts tab can list it.
|
||||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
self.font_manager.register_manager_font(
|
||||||
self.display_manager = display_manager
|
manager_id=self.plugin_id,
|
||||||
self.font_manager = plugin_manager.font_manager # Shared FontManager
|
element_key=element_key,
|
||||||
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",
|
|
||||||
family="press_start",
|
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
|
`get_font(family, size_px)` looks the family up in `font_catalog` and loads
|
||||||
> in `manifest.json` are registered automatically during plugin load
|
it (cached per family and size).
|
||||||
> (`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.
|
|
||||||
|
|
||||||
### 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -195,231 +100,123 @@ In your plugin's `manifest.json`:
|
|||||||
{
|
{
|
||||||
"family": "custom_font",
|
"family": "custom_font",
|
||||||
"source": "plugin://fonts/custom.ttf",
|
"source": "plugin://fonts/custom.ttf",
|
||||||
"metadata": {
|
"metadata": {"description": "Custom plugin font", "license": "MIT"}
|
||||||
"description": "Custom plugin font",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"family": "web_font",
|
"family": "web_font",
|
||||||
"source": "https://example.com/fonts/font.ttf",
|
"source": "https://example.com/fonts/font.ttf",
|
||||||
"metadata": {
|
"metadata": {"checksum": "sha256:abc123..."}
|
||||||
"description": "Downloaded font",
|
|
||||||
"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
|
```python
|
||||||
class MyPlugin(BasePlugin):
|
font = self.font_manager.resolve_font(
|
||||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
element_key=f"{self.plugin_id}.text",
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
family="custom_font", # resolved as "my-plugin::custom_font"
|
||||||
self.font_manager = self._get_font_manager()
|
size_px=10,
|
||||||
|
plugin_id=self.plugin_id,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove override
|
|
||||||
font_manager.remove_override("nfl.live.score")
|
|
||||||
|
|
||||||
# Get all overrides
|
|
||||||
overrides = font_manager.get_overrides()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Font Discovery
|
## Overrides
|
||||||
|
|
||||||
### Available Fonts
|
`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 FontManager automatically scans `assets/fonts/` for TTF and BDF fonts:
|
The methods that edit it — `set_override()`, `remove_override()`,
|
||||||
|
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
|
||||||
```python
|
for overrides (the override editor and `/api/v3/fonts/overrides` were
|
||||||
# Get all available fonts
|
removed). To let users choose a font, add a field to your plugin's config
|
||||||
fonts = font_manager.get_available_fonts()
|
schema.
|
||||||
# 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
|
|
||||||
|
|
||||||
## Font usage in the web UI
|
## Font usage in the web UI
|
||||||
|
|
||||||
The web interface runs in its own process and has no FontManager, so the
|
The web UI's **Fonts** tab lists, uploads, previews and deletes the font
|
||||||
display service publishes which plugin uses which font
|
files in `assets/fonts/`. The web interface runs in its own process and has
|
||||||
(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it:
|
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
|
- **Source**: `register_manager_font()` registrations of the loaded
|
||||||
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
|
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
|
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
|
directly with PIL — register the fonts your plugin draws with if you want
|
||||||
them listed.
|
them listed.
|
||||||
- **Names**: a family, alias (`press_start`, `four_by_six`,
|
- **Names**: a family, alias or path is resolved through `font_catalog` to
|
||||||
`five_by_seven`, `tom_thumb`) or path is resolved through
|
the file it loads and reported under that file's name without extension
|
||||||
`font_catalog` to the file it loads and reported under that file's name
|
(`PressStart2P-Regular`, `4x6-font`, `5x7`, `tom-thumb`), which is how the
|
||||||
without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`,
|
Fonts tab keys its rows. Fonts outside `assets/fonts/` (a plugin's own
|
||||||
`tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside
|
`plugin_id::family` fonts) and families that resolve to nothing are left
|
||||||
`assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families
|
out.
|
||||||
that resolve to nothing are left out.
|
|
||||||
- **When**: a daemon thread started once plugins have loaded checks every
|
- **When**: a daemon thread started once plugins have loaded checks every
|
||||||
10 seconds and writes the `font_usage_snapshot` cache key only when the
|
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).
|
usage changed (and once a day, so the cache's cleanup never expires it).
|
||||||
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
||||||
- **Unknown**: until the display service has published, the column reads
|
- **Unknown**: until the display service has published, the column reads
|
||||||
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
|
"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
|
```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)
|
width, height, baseline = font_manager.measure_text("Hello", font)
|
||||||
|
|
||||||
# Get font height
|
|
||||||
font_height = font_manager.get_font_height(font)
|
font_height = font_manager.get_font_height(font)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Tips
|
||||||
|
|
||||||
### For Managers
|
- BDF fonts usually look better than TTF at small sizes on LED panels.
|
||||||
|
- Use `{plugin_id}.{element}` element keys.
|
||||||
1. **Register all fonts** you use for visibility
|
- Register the fonts you draw with, so the Fonts tab can warn before one is
|
||||||
2. **Use consistent element keys** (e.g., `{manager_id}.{element_type}`)
|
deleted.
|
||||||
3. **Cache font references** if using same font multiple times
|
- Replace direct `ImageFont.truetype("assets/fonts/...", 8)` calls with
|
||||||
4. **Use `resolve_font()`** not `get_font()` directly to support overrides
|
`resolve_font()`: it caches, resolves paths against the install directory,
|
||||||
5. **Define sensible defaults** that work well on LED matrix
|
and handles BDF files.
|
||||||
|
|
||||||
### 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
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Font Not Found
|
**Font not found**
|
||||||
- Check font file exists in `assets/fonts/`
|
- Check the file exists in `assets/fonts/`.
|
||||||
- Verify font family name matches filename (without extension, lowercase)
|
- The family name is the filename without extension, lower-cased.
|
||||||
- Check logs for font discovery errors
|
- Check the display service log for font discovery errors.
|
||||||
|
|
||||||
### Override Not Working
|
**Plugin fonts not loading**
|
||||||
- Verify element key matches exactly what manager registered
|
- Check the manifest's `"fonts"` block.
|
||||||
- Check `config/font_overrides.json` for correct syntax
|
- Check the log for download or registration errors, and that font URLs are
|
||||||
- Restart application to ensure overrides are loaded
|
reachable.
|
||||||
|
|
||||||
### Performance Issues
|
## API reference
|
||||||
- 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()`
|
|
||||||
|
|
||||||
### Plugin Fonts Not Loading
|
Current methods:
|
||||||
- Verify plugin manifest syntax
|
|
||||||
- Check plugin directory structure
|
|
||||||
- Review logs for download/registration errors
|
|
||||||
- Ensure font URLs are accessible
|
|
||||||
|
|
||||||
## 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
|
Removed in 3.7.0. Each logs a warning on first call.
|
||||||
- `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.
|
|
||||||
|
|
||||||
|
| 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
|
4. Wait for installation to finish — installed plugins appear in the
|
||||||
**Installed Plugins** section above and get their own tab in the second
|
**Installed Plugins** section above and get their own tab in the second
|
||||||
nav row
|
nav row
|
||||||
5. Toggle the plugin to enabled
|
5. Toggle the plugin to enabled. The running display loads it within a
|
||||||
6. From **Overview**, click **Restart Display Service**
|
few seconds; no restart is needed
|
||||||
|
|
||||||
You can also install community plugins straight from a GitHub URL using the
|
You can also install community plugins straight from a GitHub URL using the
|
||||||
**Install from GitHub** section further down the same tab — see
|
**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
|
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,
|
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
||||||
update intervals, etc.)
|
update intervals, etc.)
|
||||||
3. Click **Save**
|
3. Click **Save**. The display service watches `config.json` and hands the
|
||||||
4. Restart the display service from **Overview** so the new settings take
|
new settings to the running plugin, so no restart is needed. If a plugin
|
||||||
effect
|
still shows old settings, restart the display service from **Overview**
|
||||||
|
|
||||||
**Note:** how long each plugin stays on screen is not set in the
|
**Note:** how long each plugin stays on screen is not set in the
|
||||||
plugin's own tab — use the **Rotation** tab's **Screen Durations**
|
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:**
|
**Check:**
|
||||||
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
1. Plugin is enabled (toggle on the **Plugin Manager** tab)
|
||||||
2. Display service was restarted after enabling
|
2. Plugin's display duration is non-zero
|
||||||
3. Plugin's display duration is non-zero
|
3. No errors in the **Logs** tab for that plugin. A plugin whose
|
||||||
4. No errors in the **Logs** tab for that plugin
|
`validate_config()` fails is not loaded until its settings are fixed
|
||||||
|
|
||||||
**Fix:**
|
**Fix:**
|
||||||
1. Enable the plugin from **Plugin Manager**
|
1. Enable the plugin from **Plugin Manager**
|
||||||
2. Click **Restart Display Service** on **Overview**
|
2. Check the **Logs** tab for plugin-specific errors
|
||||||
3. 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"
|
### Weather Plugin Shows "No Data"
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ pytest test/test_display_controller.py test/test_plugin_system.py
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run a specific test class
|
# Run a specific test class
|
||||||
pytest test/test_display_controller.py::TestDisplayControllerModeRotation
|
pytest test/test_display_controller.py::TestDisplayControllerLivePriority
|
||||||
|
|
||||||
# Run a specific test function
|
# 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
|
### 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::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
|
```bash
|
||||||
# Run with maximum verbosity and show print statements
|
# 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)
|
# 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)
|
### Run Tests in Parallel (Faster)
|
||||||
|
|||||||
@@ -21,6 +21,28 @@ moved to the prefetch thread still needs the GIL, and the render thread waits
|
|||||||
for it (risk 5 below). The late rate did not improve overall. The 1–2 s
|
for it (risk 5 below). The late rate did not improve overall. The 1–2 s
|
||||||
freezes appear in both builds and have a separate, not yet identified cause.
|
freezes appear in both builds and have a separate, not yet identified cause.
|
||||||
|
|
||||||
|
The GIL fix, measured on hdpi (90 px/s, `pwm_bits` 8, preview open, 8-minute
|
||||||
|
runs after a 2-minute warm-up, order A B C C B A, 2026-09-24). Each arm pools
|
||||||
|
two runs, about 81,000 frames:
|
||||||
|
|
||||||
|
| arm | late | by 1 | 2 | 3–5 | 6+ | 2+ late per 10k frames | freezes |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| A: step 1 as is | 0.90% | 575 | 64 | 91 | 9 | 20.1 | 0 |
|
||||||
|
| B: `switch_interval_ms` 1 | 0.78% | 510 | 105 | 23 | 2 | 15.8 | 0 |
|
||||||
|
| C: `prefetch_gate` | **0.60%** | 471 | 11 | 7 | 2 | **2.5** | 0 |
|
||||||
|
|
||||||
|
The gate removes the frames the render thread spent waiting for the GIL, and
|
||||||
|
it costs the prefetch nothing that shows: it parked the thread for 3–6 s per
|
||||||
|
run, and the next group was ready at every strip extension in every arm.
|
||||||
|
`prefetch_gate` is therefore on by default; `switch_interval_ms` stays an
|
||||||
|
off-by-default experiment. What is left is almost all one refresh late, which
|
||||||
|
is the per-frame budget (a 6.75 ms p50 blit in a refresh the panel holds at
|
||||||
|
83–85 Hz while rendering), not contention.
|
||||||
|
|
||||||
|
The runs restart the service, so the hourly sports refresh never fell inside
|
||||||
|
one. That refresh is its own case: about twenty ESPN chunk-fetch threads at
|
||||||
|
once, which the gate does not cover (it gates only the prefetch thread).
|
||||||
|
|
||||||
## The problem
|
## The problem
|
||||||
|
|
||||||
Vegas mode builds its ticker from every plugin's content. Most of that work
|
Vegas mode builds its ticker from every plugin's content. Most of that work
|
||||||
@@ -284,9 +306,20 @@ updates are disabled while sync is active.
|
|||||||
removes the pauses, but the work still needs the GIL. Pillow drawing holds
|
removes the pauses, but the work still needs the GIL. Pillow drawing holds
|
||||||
it, and a waiting thread only gets it back after the switch interval
|
it, and a waiting thread only gets it back after the switch interval
|
||||||
(default 5 ms). Expect some single-refresh late frames while a prefetch
|
(default 5 ms). Expect some single-refresh late frames while a prefetch
|
||||||
runs. Measure with the soak. Lowering `sys.setswitchinterval` during Vegas
|
runs. Measure with the soak. A render process separate from plugin work
|
||||||
(e.g. 1 ms) is a one-line experiment, and a render process separate from
|
is the structural answer (the "native presenter" step). Two experiments
|
||||||
plugin work is the structural answer (the "native presenter" step).
|
get most of the way first (results under Status, above):
|
||||||
|
- `vegas_scroll.switch_interval_ms` lowers the switch interval for a Vegas
|
||||||
|
run (1 ms is the obvious try), so the render thread waits at most that
|
||||||
|
long behind bytecode. It does nothing for a C call that keeps the GIL.
|
||||||
|
- `vegas_scroll.prefetch_gate` (`src/common/render_gate.py`) lets the
|
||||||
|
prefetch thread run Python only while the render thread is blocked in
|
||||||
|
`SwapOnVSync`, up to just before the refresh the swap returns on, and
|
||||||
|
parks it the rest of the time. That covers C calls too, since the gate is
|
||||||
|
checked before each one starts. It never parks the thread while it holds
|
||||||
|
a lock the render thread takes, and never for more than 50 ms. It needs
|
||||||
|
the rebuilt binding, which releases the GIL during the swap. On by
|
||||||
|
default.
|
||||||
|
|
||||||
## What this does not fix
|
## What this does not fix
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Permissions
|
||||||
|
|
||||||
|
Who owns what on an installed system, which privileged commands the web
|
||||||
|
interface may run, and how to repair ownership when it goes wrong. The
|
||||||
|
installer, [`first_time_install.sh`](../first_time_install.sh), sets all of
|
||||||
|
this up; this page describes the result.
|
||||||
|
|
||||||
|
## Users and groups
|
||||||
|
|
||||||
|
| Account | Used by | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `root` | `ledmatrix.service` (the display) | The LED matrix library needs direct GPIO access |
|
||||||
|
| The installing user (e.g. `ledpi`) | `ledmatrix-web.service`, `ledmatrix-update-verify.service` | A web server should not run as root |
|
||||||
|
| `ledmatrix` group | shared files | Members: the installing user, `root`, and `daemon` if it exists. Created by [`setup_cache.sh`](../scripts/install/setup_cache.sh) and the installer |
|
||||||
|
|
||||||
|
The installer also adds the web user to `systemd-journal` and `adm` so the
|
||||||
|
**Logs** tab can read the journal. Group changes apply after the user logs
|
||||||
|
in again (services pick them up on restart).
|
||||||
|
|
||||||
|
## Files and directories
|
||||||
|
|
||||||
|
| Path | Owner | Mode | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Project directory | web user | dirs `755`, files `644`, `*.sh` `755` | Set in the installer's "Normalize project file permissions" step |
|
||||||
|
| `config/` | web user | `2775` | |
|
||||||
|
| `config/config.json` | web user | `644` | Written by the web interface |
|
||||||
|
| `config/config_secrets.json` | web user : `ledmatrix` | `640` | Owned by the web user because the web interface writes it; root reads it regardless of mode |
|
||||||
|
| `plugin-repos/`, `plugins/` | web user | dirs `2775`, files `664` | The web interface installs and removes plugins |
|
||||||
|
| `assets/` | web user | dirs `755`, files `644` | Root writes downloaded logos regardless |
|
||||||
|
| `/var/cache/ledmatrix/` | `root:ledmatrix` | `2775` (setgid) | Shared cache: see below |
|
||||||
|
| Cache files | creator : `ledmatrix` | `660` | |
|
||||||
|
| `scripts/fix_perms/safe_plugin_rm.sh`, `safe_pip_install.sh` | `root:root` | `755` | Run as root through sudo, so the web user must not be able to edit them |
|
||||||
|
| `/etc/sudoers.d/ledmatrix_web`, `ledmatrix_wifi` | `root` | `440` | |
|
||||||
|
|
||||||
|
What keeps it that way at runtime:
|
||||||
|
|
||||||
|
- **Config files.** Saves go through
|
||||||
|
[`src/config_manager_atomic.py`](../src/config_manager_atomic.py), which
|
||||||
|
applies `get_config_file_mode()` (`640` for secrets, `644` otherwise) and,
|
||||||
|
when running as root, moves the file's group to the project directory's
|
||||||
|
group (`ensure_shared_group_ownership()` in
|
||||||
|
[`src/common/permission_utils.py`](../src/common/permission_utils.py)).
|
||||||
|
- **Cache files.** [`src/cache/disk_cache.py`](../src/cache/disk_cache.py)
|
||||||
|
sets every file it writes to `0660` and gives it the cache directory's
|
||||||
|
group, without relying on the setgid bit. So a file root writes stays
|
||||||
|
readable by the web user.
|
||||||
|
- **Plugin directories.** [`run.py`](../run.py) sets
|
||||||
|
`sys.dont_write_bytecode`, because root-owned `__pycache__` directories
|
||||||
|
inside a plugin stop the web user updating or removing it.
|
||||||
|
|
||||||
|
`ledmatrix-web.service` deliberately has no `CacheDirectory=`: systemd would
|
||||||
|
re-own `/var/cache/ledmatrix` to the web user and its primary group, and the
|
||||||
|
web interface could no longer read what the display writes (see the comment
|
||||||
|
in [`systemd/ledmatrix-web.service`](../systemd/ledmatrix-web.service)).
|
||||||
|
|
||||||
|
If `/var/cache/ledmatrix` is not usable, `CacheManager` falls back to
|
||||||
|
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache` or a temp directory
|
||||||
|
([`src/cache_manager.py`](../src/cache_manager.py)). The two services then
|
||||||
|
may not share a cache, and the web UI shows stale or empty display status,
|
||||||
|
on-demand state and plugin health. Fix the directory rather than living
|
||||||
|
with the fallback.
|
||||||
|
|
||||||
|
## sudo rules
|
||||||
|
|
||||||
|
### `/etc/sudoers.d/ledmatrix_web`
|
||||||
|
|
||||||
|
Generated by `web_sudoers_rules()` in
|
||||||
|
[`scripts/install/lib_sudoers.sh`](../scripts/install/lib_sudoers.sh), the
|
||||||
|
only place these rules are defined. Installed by the installer and by
|
||||||
|
[`configure_web_sudo.sh`](../scripts/install/configure_web_sudo.sh), both of
|
||||||
|
which check them with `visudo -c` first. The web user may run, without a
|
||||||
|
password:
|
||||||
|
|
||||||
|
- `reboot`, `poweroff`
|
||||||
|
- `systemctl start|stop|restart|enable|disable|status ledmatrix.service`,
|
||||||
|
`systemctl is-active ledmatrix[.service]`
|
||||||
|
- `systemctl start|stop|restart ledmatrix-web.service`
|
||||||
|
- `bash <project>/scripts/fix_perms/safe_plugin_rm.sh *` — removes a
|
||||||
|
directory only if it resolves to a child of `plugin-repos/` or `plugins/`
|
||||||
|
- `bash <project>/scripts/fix_perms/safe_pip_install.sh *` — installs a
|
||||||
|
`requirements.txt` only if it is the project's own or one under
|
||||||
|
`plugin-repos/` or `plugins/`, so the root display service can import the
|
||||||
|
packages
|
||||||
|
- `journalctl -u ledmatrix.service *`, `-u ledmatrix *`, `-t ledmatrix *`,
|
||||||
|
tagged `NOEXEC`: journalctl opens a pager on a terminal, and a shell
|
||||||
|
escape from that pager would be a root shell
|
||||||
|
|
||||||
|
### `/etc/sudoers.d/ledmatrix_wifi`
|
||||||
|
|
||||||
|
Written by
|
||||||
|
[`scripts/install/configure_wifi_permissions.sh`](../scripts/install/configure_wifi_permissions.sh)
|
||||||
|
(run as the web user; the installer calls it). It refuses to grant a binary
|
||||||
|
that is not root-owned or is group/world-writable. The rules cover:
|
||||||
|
|
||||||
|
- `nmcli device wifi connect|disconnect *`, `nmcli device connect|disconnect *`,
|
||||||
|
`nmcli radio wifi on|off`
|
||||||
|
- `systemctl start|stop|restart hostapd`, `... dnsmasq`,
|
||||||
|
`systemctl restart NetworkManager`
|
||||||
|
- `sysctl -w net.ipv4.ip_forward=0|1`
|
||||||
|
- `nft add|delete table ip ledmatrix`
|
||||||
|
- `rfkill unblock wifi`
|
||||||
|
- `mkdir -p /etc/NetworkManager/dnsmasq-shared.d`
|
||||||
|
- `cp` of `/tmp/hostapd.conf` and `/tmp/dnsmasq.conf` to their fixed
|
||||||
|
destinations, and `rm -f /etc/dnsmasq.d/ledmatrix-captive.conf`
|
||||||
|
- `cp /tmp/ledmatrix-nm-dnsmasq.conf` to
|
||||||
|
`/etc/NetworkManager/dnsmasq-shared.d/ledmatrix-captive.conf`, and
|
||||||
|
`rm -f` of that file
|
||||||
|
|
||||||
|
**`iptables` is deliberately not granted.** The captive portal's rules are
|
||||||
|
built from the interface name and port, so a rule covering them would need a
|
||||||
|
trailing wildcard, and `iptables --modprobe=<path>` runs `<path>` as root: a
|
||||||
|
wildcard grant is a root shell for the web user. Doing it safely needs a
|
||||||
|
wrapper script that builds the rules itself, like `safe_plugin_rm.sh`. On a
|
||||||
|
stock Raspberry Pi OS image the default user's blanket `NOPASSWD` rule
|
||||||
|
(`/etc/sudoers.d/010_pi-nopasswd`) hides this gap.
|
||||||
|
|
||||||
|
### polkit
|
||||||
|
|
||||||
|
The same script installs `/etc/polkit-1/rules.d/10-ledmatrix-wifi.rules`,
|
||||||
|
which lets the web user perform any `org.freedesktop.NetworkManager.*`
|
||||||
|
action without authentication.
|
||||||
|
|
||||||
|
## Repair scripts
|
||||||
|
|
||||||
|
In [`scripts/fix_perms/`](../scripts/fix_perms/). Run them from the project
|
||||||
|
directory.
|
||||||
|
|
||||||
|
| Script | Run as | What it does | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `fix_plugin_permissions.sh` | `sudo` | `plugins/` and `plugin-repos/` to `root:<user>`, dirs `2775`, files `664`; makes a `700` home directory `755` so root can traverse it | Safe. Group-writable, so the web user keeps write access |
|
||||||
|
| `fix_assets_permissions.sh` | `sudo` | `assets/` to `<user>:<group>`, mode `777` recursively | Works, but looser than the installer's `755`/`644` |
|
||||||
|
| `fix_cache_permissions.sh` | `sudo` | Runs [`setup_cache.sh`](../scripts/install/setup_cache.sh) for `/var/cache/ledmatrix` (`root:ledmatrix`, `2775`, files `660`), then makes `~/.ledmatrix_cache` (the fallback cache) `<user>:<group>` mode `777` | Safe. The `~/.ledmatrix_cache` mode is still `777` |
|
||||||
|
| `fix_web_permissions.sh` | the web user, **without** `sudo` | Resets project file ownership for the web user (it calls `sudo` itself), then makes `safe_plugin_rm.sh` and `safe_pip_install.sh` `root:root` `755` again and restores `config_secrets.json` to its owner, group `ledmatrix`, mode `640` | Refuses to run as root. It does not write sudoers rules |
|
||||||
|
| `safe_plugin_rm.sh`, `safe_pip_install.sh` | — | Called by the web interface through sudo | Not for manual use |
|
||||||
|
|
||||||
|
To reinstall the sudoers rules, run
|
||||||
|
`./scripts/install/configure_web_sudo.sh` (web rules) or
|
||||||
|
`./scripts/install/configure_wifi_permissions.sh` (WiFi rules and polkit) as
|
||||||
|
the web user, not with `sudo`.
|
||||||
|
|
||||||
|
After any of these, restart both services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart ledmatrix.service ledmatrix-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -ld /var/cache/ledmatrix # drwxrwsr-x root ledmatrix
|
||||||
|
stat -c '%U:%G %a %n' config/config.json config/config_secrets.json
|
||||||
|
id # web user should list ledmatrix
|
||||||
|
sudo -l # lists the NOPASSWD rules
|
||||||
|
```
|
||||||
@@ -9,6 +9,7 @@ Complete API reference for plugin developers. This document describes all method
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Manifest Required Fields](#manifest-required-fields)
|
||||||
- [BasePlugin](#baseplugin)
|
- [BasePlugin](#baseplugin)
|
||||||
- [Display Manager](#display-manager)
|
- [Display Manager](#display-manager)
|
||||||
- [Cache Manager](#cache-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
|
## BasePlugin
|
||||||
|
|
||||||
All plugins must inherit from `BasePlugin` and implement the required methods. The base class provides access to managers and common functionality.
|
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.
|
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.
|
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
|
||||||
|
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
|
||||||
**Parameters**:
|
- `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
|
||||||
- `condition` (str): Weather condition (e.g., "clear", "cloudy", "rain", "snow", "storm")
|
`draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
|
||||||
- `x` (int): X position
|
- `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
|
||||||
- `y` (int): Y position
|
— text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
|
||||||
- `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
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scrolling State Management
|
### Scrolling State Management
|
||||||
|
|
||||||
@@ -601,6 +581,8 @@ Process any deferred updates if not currently scrolling. Called automatically by
|
|||||||
|
|
||||||
#### `get_scrolling_stats() -> dict`
|
#### `get_scrolling_stats() -> dict`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get current scrolling statistics for debugging.
|
Get current scrolling statistics for debugging.
|
||||||
|
|
||||||
**Returns**: Dictionary with scrolling state information
|
**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]]`
|
#### `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.
|
Get background service cached data with sport-specific intervals.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -779,6 +763,8 @@ max_age = strategy['max_age'] # Get configured max age
|
|||||||
|
|
||||||
#### `get_sport_live_interval(sport_key: str) -> int`
|
#### `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.
|
Get the live_update_interval for a specific sport from config.
|
||||||
|
|
||||||
**Parameters**:
|
**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]`
|
#### `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.
|
Extract sport key from cache key for sport-specific strategies.
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -847,10 +835,12 @@ for file_info in files:
|
|||||||
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Metrics Methods
|
### Metrics Methods (deprecated)
|
||||||
|
|
||||||
#### `get_cache_metrics() -> Dict[str, Any]`
|
#### `get_cache_metrics() -> Dict[str, Any]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get cache performance metrics.
|
Get cache performance metrics.
|
||||||
|
|
||||||
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
|
**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]`
|
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
||||||
|
|
||||||
|
> Deprecated, removed in 3.7.0. See [Deprecated APIs](#deprecated-apis).
|
||||||
|
|
||||||
Get memory cache statistics.
|
Get memory cache statistics.
|
||||||
|
|
||||||
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
**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]`
|
#### `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.
|
Get list of enabled plugin IDs.
|
||||||
|
|
||||||
**Returns**: List of plugin identifier strings
|
**Returns**: List of plugin identifier strings
|
||||||
@@ -985,9 +979,8 @@ def update(self):
|
|||||||
|
|
||||||
**Example - Checking if another plugin is enabled**:
|
**Example - Checking if another plugin is enabled**:
|
||||||
```python
|
```python
|
||||||
enabled_plugins = self.plugin_manager.get_enabled_plugins()
|
weather = self.plugin_manager.plugins.get("weather")
|
||||||
if "weather" in enabled_plugins:
|
if weather is not None and weather.enabled:
|
||||||
# Weather plugin is enabled
|
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ deep-merged back into the plugin's config at load time
|
|||||||
### Custom input widgets
|
### Custom input widgets
|
||||||
|
|
||||||
Set `"x-widget": "<name>"` on a property. Core widgets are in
|
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
|
`web_interface/static/v3/js/widgets/`; a plugin can ship its own widget
|
||||||
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`.
|
script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`. See the
|
||||||
See [widget-guide.md](widget-guide.md).
|
[widget guide](../web_interface/static/v3/js/widgets/README.md).
|
||||||
|
|
||||||
### Custom actions
|
### 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`)
|
- 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`
|
- 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`)
|
- 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`
|
- 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()`;
|
`display_manager.image` (a PIL Image) and call `update_display()`;
|
||||||
there is no `draw_image()` helper method.
|
there is no `draw_image()` helper method.
|
||||||
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
- `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
|
- `get_text_width()`, `get_font_height()` - Text utilities
|
||||||
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
||||||
|
|
||||||
**Cache Manager** (`self.cache_manager`):
|
**Cache Manager** (`self.cache_manager`):
|
||||||
- `get()`, `set()`, `delete()` - Basic caching
|
- `get()`, `set()`, `delete()` - Basic caching
|
||||||
- `get_cached_data_with_strategy()` - Advanced caching with strategies
|
- `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`):
|
**Plugin Manager** (`self.plugin_manager`):
|
||||||
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
||||||
- `get_plugin_info()` - Get plugin information
|
- `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
|
## 3rd Party Plugin Development
|
||||||
|
|
||||||
@@ -577,12 +580,14 @@ Your plugin must:
|
|||||||
pass
|
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
|
```json
|
||||||
{
|
{
|
||||||
"id": "my-plugin",
|
"id": "my-plugin",
|
||||||
"name": "My Plugin",
|
"name": "My Plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"author": "YourName",
|
||||||
"class_name": "MyPlugin",
|
"class_name": "MyPlugin",
|
||||||
"entry_point": "manager.py",
|
"entry_point": "manager.py",
|
||||||
"display_modes": ["my_plugin"],
|
"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
|
with the registry's `latest_version`; releases and tags are not read
|
||||||
- **README.md**: Clear installation and configuration instructions
|
- **README.md**: Clear installation and configuration instructions
|
||||||
- **config_schema.json**: Recommended for web UI configuration
|
- **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
|
- **requirements.txt**: If your plugin has Python dependencies
|
||||||
|
|
||||||
### Distribution Options
|
### Distribution Options
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ LEDMatrix/
|
|||||||
|
|
||||||
### 1. Minimal Plugin Structure
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
"id": "my-plugin",
|
"id": "my-plugin",
|
||||||
@@ -54,6 +55,8 @@ LEDMatrix/
|
|||||||
"author": "YourName",
|
"author": "YourName",
|
||||||
"entry_point": "manager.py",
|
"entry_point": "manager.py",
|
||||||
"class_name": "MyPlugin",
|
"class_name": "MyPlugin",
|
||||||
|
"display_modes": ["my-plugin"],
|
||||||
|
"compatible_versions": [">=2.0.0"],
|
||||||
"category": "custom"
|
"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
|
## Adding or changing an official plugin
|
||||||
|
|
||||||
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo. The store refuses
|
1. Add or edit `plugins/<your-plugin-id>/` in the monorepo, with the
|
||||||
a manifest without `id`, `name`, `class_name` and `display_modes`; also
|
manifest fields listed in
|
||||||
set `version`.
|
[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
|
2. Bump `version` in the plugin's `manifest.json` for every change, or users
|
||||||
won't be offered the update.
|
won't be offered the update.
|
||||||
3. Run `python update_registry.py` in ledmatrix-plugins and commit the
|
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
|
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
|
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
|
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
|
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
||||||
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
||||||
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
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_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
|
||||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
|
- [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
|
- [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)
|
- [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
|
- [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,
|
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||||
cache management, background services, permissions
|
cache management, background services, permissions
|
||||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||||
|
- [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
|
## Reference
|
||||||
|
|
||||||
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
|
- [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
|
- [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
|
- [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
|
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
|
||||||
|
|
||||||
## Contributing to LEDMatrix itself
|
## 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
|
- [DEVELOPMENT.md](DEVELOPMENT.md) — environment setup
|
||||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
- [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
|
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
||||||
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
|
- [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
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error_code": "CONFIG_SAVE_FAILED",
|
"error_code": "CONFIG_SAVE_FAILED",
|
||||||
"error_category": "configuration",
|
|
||||||
"message": "Error description",
|
"message": "Error description",
|
||||||
"details": "optional",
|
"details": "optional",
|
||||||
"context": { },
|
"context": { },
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ advances by elapsed time at `scroll_speed / scroll_delay` px/s.
|
|||||||
|
|
||||||
## Diagnosing a juddery scroller
|
## 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
|
**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
|
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
|
healthy 100 fps. The stats line reports the tail for that reason — read the
|
||||||
@@ -306,6 +309,269 @@ journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
|
|||||||
If a plugin logs its scroll config **twice** with different modes, the second
|
If a plugin logs its scroll config **twice** with different modes, the second
|
||||||
line is what is running.
|
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
|
## Rebuilding the binding
|
||||||
|
|
||||||
```bash
|
```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
|
capabilities sections record that design, but none of it ships in core any
|
||||||
more. Shared sports code lives in `src/common`:
|
more. Shared sports code lives in `src/common`:
|
||||||
|
|
||||||
```
|
| Module | Since | Holds |
|
||||||
src/common/
|
|---|---|---|
|
||||||
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
| `sports_scroll.py` | 3.2.0 | `SportsScrollDisplay` / `SportsScrollDisplayManager` — scroll orchestration (content building stays in the plugins) |
|
||||||
(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_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin
|
| `sports_game_renderer.py` | 3.3.0 | `SportsGameRendererMixin` — scroll/Vegas card geometry |
|
||||||
(3.5.0) — the helpers byte-identical in the
|
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
||||||
plugins' sports.py, and the _favorite_key seam
|
| `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`
|
### 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
|
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
|
module having gained it fails at runtime with an `AttributeError`, while a
|
||||||
missing module fails at load, where the version checks can see it.
|
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
|
listed below, for later phases); its parity test compares every body against
|
||||||
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
||||||
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
`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
|
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:
|
Verify installation:
|
||||||
```bash
|
```bash
|
||||||
./bin/pixlet/pixlet-linux-amd64 version
|
./bin/pixlet/pixlet-linux-arm64 version
|
||||||
# Pixlet 0.50.2 (or later)
|
# Pixlet 0.50.2 (or later)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -276,10 +283,8 @@ LEDMatrix/
|
|||||||
│ ├── hour_hand.png
|
│ ├── hour_hand.png
|
||||||
│ └── minute_hand.png
|
│ └── minute_hand.png
|
||||||
│
|
│
|
||||||
├── bin/pixlet/ # Pixlet binaries
|
├── bin/pixlet/ # Pixlet binary
|
||||||
│ ├── pixlet-linux-amd64
|
│ └── pixlet-linux-arm64 # the only one download_pixlet.sh fetches
|
||||||
│ ├── pixlet-linux-arm64
|
|
||||||
│ └── pixlet-darwin-arm64
|
|
||||||
│
|
│
|
||||||
└── scripts/
|
└── scripts/
|
||||||
└── download_pixlet.sh # Pixlet installer
|
└── download_pixlet.sh # Pixlet installer
|
||||||
@@ -324,7 +329,7 @@ Many apps require API keys for external services:
|
|||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
|
1. Check logs: `journalctl -u ledmatrix | grep -i pixlet`
|
||||||
2. Verify config: Ensure all required fields are filled
|
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
|
4. Missing assets: Some apps need images/fonts that may fail to download
|
||||||
5. API issues: Check API keys and rate limits
|
5. API issues: Check API keys and rate limits
|
||||||
|
|
||||||
|
|||||||
+32
-27
@@ -201,10 +201,11 @@ sudo systemctl restart ledmatrix-web
|
|||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
|
||||||
1. **Install dependencies:**
|
1. **Install dependencies** as root, so the root display service can import
|
||||||
|
them:
|
||||||
```bash
|
```bash
|
||||||
pip3 install --break-system-packages -r requirements.txt
|
sudo python3 -m pip install --break-system-packages --no-cache-dir -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 web_interface/requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Test imports step-by-step:**
|
2. **Test imports step-by-step:**
|
||||||
@@ -250,15 +251,18 @@ sudo systemctl restart ledmatrix-web
|
|||||||
|
|
||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
|
||||||
```bash
|
[PERMISSIONS.md](PERMISSIONS.md) lists the expected owner and mode of every
|
||||||
# Fix ownership of LEDMatrix directory
|
file and directory, and which `scripts/fix_perms/` script to run as which
|
||||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix
|
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 644 config/config.json
|
||||||
sudo chmod 640 config/config_secrets.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
|
sudo systemctl cat ledmatrix-web | grep User
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -462,15 +466,17 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Restart display:**
|
Or toggle the plugin on in the **Plugin Manager** tab, which writes the
|
||||||
```bash
|
same flag.
|
||||||
sudo systemctl restart ledmatrix
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify in web interface:**
|
2. **Wait a few seconds.** The display service watches `config.json` and
|
||||||
- Open the **Plugin Manager** tab
|
loads a newly enabled plugin without a restart
|
||||||
- Toggle the plugin switch to enable
|
(`DisplayController._reconcile_enabled_plugins()` in
|
||||||
- From **Overview**, click **Restart Display Service**
|
[`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
|
#### Plugin Not Loading
|
||||||
|
|
||||||
@@ -491,10 +497,12 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
# Verify all required fields present
|
# 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
|
```bash
|
||||||
if [ -f plugin-repos/plugin-id/requirements.txt ]; then
|
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
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -503,14 +511,9 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
sudo journalctl -u ledmatrix -f | grep plugin-id
|
sudo journalctl -u ledmatrix -f | grep plugin-id
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Test plugin import:**
|
5. **Load and render the plugin headlessly:**
|
||||||
```bash
|
```bash
|
||||||
python3 -c "
|
python3 scripts/check_plugin.py --plugin plugin-id
|
||||||
import sys
|
|
||||||
sys.path.insert(0, 'plugin-repos/plugin-id')
|
|
||||||
from manager import PluginClass
|
|
||||||
print('Plugin imports successfully')
|
|
||||||
"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Stale Cache Data
|
#### Stale Cache Data
|
||||||
@@ -540,10 +543,12 @@ sudo systemctl cat ledmatrix-web | grep User
|
|||||||
sudo systemctl restart ledmatrix
|
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
|
```bash
|
||||||
ls -ld /var/cache/ledmatrix
|
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
|
entirely from the web UI without hand-editing JSON. See
|
||||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
|
[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
|
### Plugin Manager Tab
|
||||||
|
|
||||||
@@ -248,16 +252,16 @@ View real-time system logs:
|
|||||||
|
|
||||||
1. Open the **Display** tab
|
1. Open the **Display** tab
|
||||||
2. Adjust the **Brightness** slider (1–100)
|
2. Adjust the **Brightness** slider (1–100)
|
||||||
3. Click **Save**
|
3. Click **Save**. The panel picks up the new brightness within a few
|
||||||
4. Click **Restart Display Service** on the **Overview** tab
|
seconds; no restart is needed
|
||||||
|
|
||||||
### Installing a New Plugin
|
### Installing a New Plugin
|
||||||
|
|
||||||
1. Open the **Plugin Manager** tab
|
1. Open the **Plugin Manager** tab
|
||||||
2. Scroll to the **Plugin Store** section and browse or search
|
2. Scroll to the **Plugin Store** section and browse or search
|
||||||
3. Click **Install** next to the plugin
|
3. Click **Install** next to the plugin
|
||||||
4. Toggle the plugin on in **Installed Plugins**
|
4. Toggle the plugin on in **Installed Plugins**. The running display
|
||||||
5. Click **Restart Display Service** on **Overview**
|
loads it within a few seconds; no restart is needed
|
||||||
|
|
||||||
### Configuring a Plugin
|
### Configuring a Plugin
|
||||||
|
|
||||||
|
|||||||
+5
-588
@@ -1,590 +1,7 @@
|
|||||||
# Widget Development Guide
|
# Widget Development Guide
|
||||||
|
|
||||||
## Overview
|
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).
|
||||||
The LEDMatrix Widget Registry system allows plugins to use reusable UI components for configuration forms. This enables:
|
It lists every built-in `x-widget`, the schema keywords the config form
|
||||||
|
understands (`x-options.labels`, `x-advanced`, `x-display: "hidden"`), and how
|
||||||
- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code
|
to ship a custom widget with a plugin.
|
||||||
- **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
|
|
||||||
|
|||||||
+108
-147
@@ -18,7 +18,7 @@ on_error() {
|
|||||||
echo "-- Last 100 lines from log --" >&2
|
echo "-- Last 100 lines from log --" >&2
|
||||||
tail -n 100 "$LOG_FILE" >&2 || true
|
tail -n 100 "$LOG_FILE" >&2 || true
|
||||||
fi
|
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 "- 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 "- 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
|
echo "- Re-run this script. It is safe to run multiple times." >&2
|
||||||
@@ -115,7 +115,8 @@ fi
|
|||||||
echo "✓ OS requirements met"
|
echo "✓ OS requirements met"
|
||||||
echo ""
|
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
|
if [ -n "${SUDO_USER:-}" ]; then
|
||||||
ACTUAL_USER="$SUDO_USER"
|
ACTUAL_USER="$SUDO_USER"
|
||||||
else
|
else
|
||||||
@@ -202,7 +203,7 @@ echo ""
|
|||||||
# Check if running as root; if not, try to elevate automatically for novices
|
# Check if running as root; if not, try to elevate automatically for novices
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
echo "This script needs administrator privileges. Attempting to re-run with sudo..."
|
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
|
fi
|
||||||
echo "✓ Running as root (required for installation)"
|
echo "✓ Running as root (required for installation)"
|
||||||
|
|
||||||
@@ -502,6 +503,44 @@ print_rgbmatrix_build_failure() {
|
|||||||
fi
|
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 ""
|
||||||
echo "This script will perform the following steps:"
|
echo "This script will perform the following steps:"
|
||||||
echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
|
echo "1. Check prerequisites (network, disk, memory) and install system dependencies"
|
||||||
@@ -634,8 +673,9 @@ else
|
|||||||
echo "Setting ownership of assets directory..."
|
echo "Setting ownership of assets directory..."
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PROJECT_ROOT_DIR/assets"
|
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)
|
# 777: read/write for owner, group and every other account. Root (the
|
||||||
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
|
# 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..."
|
echo "Setting permissions for assets directory..."
|
||||||
chmod -R 777 "$PROJECT_ROOT_DIR/assets"
|
chmod -R 777 "$PROJECT_ROOT_DIR/assets"
|
||||||
|
|
||||||
@@ -699,32 +739,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine ownership based on web service user
|
# Determine ownership based on web service user
|
||||||
# Check if web service file exists and what user it runs as
|
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
|
|
||||||
|
|
||||||
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_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).
|
# 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
|
fi
|
||||||
|
|
||||||
# Determine ownership based on web service user
|
# Determine ownership based on web service user
|
||||||
# Check if web service file exists and what user it runs as
|
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
|
|
||||||
|
|
||||||
# If web service runs as ACTUAL_USER (not root), set ownership to ACTUAL_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).
|
# 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"
|
chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set directory permissions (775: rwxrwxr-x)
|
# Set directory permissions (2775: rwxrwsr-x, setgid so new entries inherit the group)
|
||||||
echo "Setting plugin-repos directory permissions to 2775 (sticky bit)..."
|
echo "Setting plugin-repos directory permissions to 2775 (setgid)..."
|
||||||
find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \;
|
find "$PLUGIN_REPOS_DIR" -type d -exec chmod 2775 {} \;
|
||||||
|
|
||||||
# Set file permissions (664: rw-rw-r--)
|
# Set file permissions (664: rw-rw-r--)
|
||||||
@@ -1005,9 +995,9 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
PACKAGE_NUM=$((PACKAGE_NUM + 1))
|
PACKAGE_NUM=$((PACKAGE_NUM + 1))
|
||||||
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
||||||
|
|
||||||
# Check if package is already installed (basic check - may not catch all cases)
|
# Install with a timeout where available. --verbose output goes to
|
||||||
# Try installing with verbose output and timeout (if available)
|
# $INSTALL_OUTPUT (filtered below, full copy in the log); --no-cache-dir
|
||||||
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
# avoids pip cache issues.
|
||||||
INSTALL_OUTPUT=$(mktemp)
|
INSTALL_OUTPUT=$(mktemp)
|
||||||
INSTALL_SUCCESS=false
|
INSTALL_SUCCESS=false
|
||||||
|
|
||||||
@@ -1312,7 +1302,11 @@ else
|
|||||||
WEB_DEPS_OK=false
|
WEB_DEPS_OK=false
|
||||||
fi
|
fi
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
# Create the marker only when installation actually succeeded, so a
|
# Create the marker only when installation actually succeeded, so a
|
||||||
@@ -1516,51 +1510,31 @@ POWEROFF_PATH=$(which poweroff)
|
|||||||
BASH_PATH=$(which bash)
|
BASH_PATH=$(which bash)
|
||||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||||
|
|
||||||
# Create sudoers content
|
# The rules themselves live in scripts/install/lib_sudoers.sh, shared with
|
||||||
cat > "$SUDOERS_TMP" << EOF
|
# scripts/install/configure_web_sudo.sh so the two cannot drift apart again.
|
||||||
# LED Matrix Web Interface passwordless sudo configuration
|
# If it is missing (a damaged checkout), keep whatever is already installed
|
||||||
# This allows the web interface user to run specific commands without a password
|
# rather than failing the whole install; the gate below skips the install.
|
||||||
|
SUDOERS_VALID=1
|
||||||
# Allow $ACTUAL_USER to run specific commands without a password for the LED Matrix web interface
|
SUDOERS_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_sudoers.sh"
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH
|
if [ -f "$SUDOERS_LIB" ]; then
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH
|
# shellcheck source=scripts/install/lib_sudoers.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
|
. "$SUDOERS_LIB"
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
|
web_sudoers_rules "$ACTUAL_USER" "$PROJECT_ROOT_DIR" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$SUDOERS_TMP"
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
else
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
SUDOERS_VALID=0
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
echo "⚠ $SUDOERS_LIB not found; cannot generate the sudoers rules." >&2
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
echo " the display service until this is fixed." >&2
|
||||||
$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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Never install rules we have not parsed. A malformed drop-in in
|
# 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
|
# /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
|
# headless Pi leaves no way in at all. If the rules do not parse, say so and
|
||||||
# keep whatever is already installed.
|
# keep whatever is already installed.
|
||||||
SUDOERS_VALID=1
|
if [ "$SUDOERS_VALID" = "0" ]; then
|
||||||
if command -v visudo >/dev/null 2>&1; 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
|
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
|
||||||
SUDOERS_VALID=0
|
SUDOERS_VALID=0
|
||||||
echo "⚠ The generated sudoers rules did not parse:" >&2
|
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
|
if [ -f "$PROJECT_ROOT_DIR/scripts/install/configure_wifi_permissions.sh" ]; then
|
||||||
echo "Configuring WiFi management permissions..."
|
echo "Configuring WiFi management permissions..."
|
||||||
# Run as the actual user (not root) since the script checks for that
|
# 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 "⚠ WiFi permissions configuration failed, but continuing installation"
|
||||||
echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh"
|
echo " You can run it manually later: ./scripts/install/configure_wifi_permissions.sh"
|
||||||
}
|
fi
|
||||||
echo "✓ WiFi management permissions configured"
|
|
||||||
else
|
else
|
||||||
echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration"
|
echo "⚠ configure_wifi_permissions.sh not found; skipping WiFi permissions configuration"
|
||||||
echo " You can configure WiFi permissions later by running:"
|
echo " You can configure WiFi permissions later by running:"
|
||||||
@@ -1690,28 +1665,8 @@ fi
|
|||||||
|
|
||||||
# Re-apply plugin directory permissions based on web service user
|
# Re-apply plugin directory permissions based on web service user
|
||||||
echo "Re-applying plugin directory permissions..."
|
echo "Re-applying plugin directory permissions..."
|
||||||
# Determine web service user (check installed service, install scripts, or template)
|
# Determine ownership based on web service user
|
||||||
WEB_SERVICE_USER="root"
|
detect_web_service_user
|
||||||
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
|
|
||||||
|
|
||||||
# Set ownership based on web service user
|
# Set ownership based on web service user
|
||||||
if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then
|
if [ "$WEB_SERVICE_USER" = "$ACTUAL_USER" ] || [ "$WEB_SERVICE_USER" != "root" ]; then
|
||||||
@@ -1791,10 +1746,11 @@ echo "-------------------------------------"
|
|||||||
echo "Removing potential conflicting services (bluetooth and others)..."
|
echo "Removing potential conflicting services (bluetooth and others)..."
|
||||||
if [ "$SKIP_SOUND" = "1" ]; then
|
if [ "$SKIP_SOUND" = "1" ]; then
|
||||||
echo "Skipping sound module configuration as requested (--skip-sound)."
|
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
|
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
|
fi
|
||||||
|
|
||||||
# Blacklist onboard sound module (idempotent)
|
# Blacklist onboard sound module (idempotent)
|
||||||
@@ -2009,20 +1965,6 @@ if systemctl list-unit-files | grep -q "ledmatrix-wifi-monitor.service"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
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 "=========================================="
|
||||||
echo "Installation Complete!"
|
echo "Installation Complete!"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
@@ -2068,7 +2010,7 @@ if command -v nmcli >/dev/null 2>&1; then
|
|||||||
if [ -n "$WIFI_STATUS" ]; then
|
if [ -n "$WIFI_STATUS" ]; then
|
||||||
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
||||||
if [ "$state" = "connected" ]; then
|
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
|
if [ -n "$SSID" ]; then
|
||||||
echo " ✓ Connected to: $SSID"
|
echo " ✓ Connected to: $SSID"
|
||||||
else
|
else
|
||||||
@@ -2092,7 +2034,7 @@ echo "AP Mode Status:"
|
|||||||
if systemctl is-active --quiet hostapd 2>/dev/null; then
|
if systemctl is-active --quiet hostapd 2>/dev/null; then
|
||||||
echo " ✓ AP Mode is ACTIVE"
|
echo " ✓ AP Mode is ACTIVE"
|
||||||
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
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"
|
echo " → Access web UI at: http://192.168.4.1:5000"
|
||||||
AP_MODE_ACTIVE=true
|
AP_MODE_ACTIVE=true
|
||||||
else
|
else
|
||||||
@@ -2100,7 +2042,7 @@ else
|
|||||||
if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
|
if ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
|
||||||
echo " ✓ AP Mode is ACTIVE (IP detected)"
|
echo " ✓ AP Mode is ACTIVE (IP detected)"
|
||||||
echo " → Connect to WiFi network: LEDMatrix-Setup"
|
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"
|
echo " → Access web UI at: http://192.168.4.1:5000"
|
||||||
AP_MODE_ACTIVE=true
|
AP_MODE_ACTIVE=true
|
||||||
else
|
else
|
||||||
@@ -2202,3 +2144,22 @@ echo " - Main config: $PROJECT_ROOT_DIR/config/config.json"
|
|||||||
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
|
echo " - Secrets: $PROJECT_ROOT_DIR/config/config_secrets.json"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Enjoy your LED Matrix display!"
|
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("Using pygame/RGBMatrixEmulator for display")
|
||||||
print("Press ESC to exit\n")
|
print("Press ESC to exit\n")
|
||||||
|
|
||||||
# Project directory already added above
|
|
||||||
|
|
||||||
# Debug output (only in debug mode or emulator mode)
|
# Debug output (only in debug mode or emulator mode)
|
||||||
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
|
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
|
||||||
if debug_mode:
|
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() {
|
print_warning() {
|
||||||
echo -e "${YELLOW}⚠${NC} $1"
|
echo -e "${YELLOW}⚠${NC} $1"
|
||||||
((WARNINGS++))
|
WARNINGS=$((WARNINGS + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
print_error() {
|
print_error() {
|
||||||
echo -e "${RED}✗${NC} $1"
|
echo -e "${RED}✗${NC} $1"
|
||||||
((COMPATIBILITY_ISSUES++))
|
COMPATIBILITY_ISSUES=$((COMPATIBILITY_ISSUES + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if running on Raspberry Pi
|
# Check if running on Raspberry Pi
|
||||||
@@ -61,20 +61,18 @@ if [ -f /etc/os-release ]; then
|
|||||||
echo "OS: $PRETTY_NAME"
|
echo "OS: $PRETTY_NAME"
|
||||||
echo "Version ID: ${VERSION_ID:-unknown}"
|
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 [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
|
||||||
if [ "${VERSION_ID:-0}" -ge "12" ]; then
|
if [ "${VERSION_ID:-0}" = "13" ]; then
|
||||||
print_success "Running compatible Debian/Raspbian version (${VERSION_ID})"
|
print_success "Detected Debian 13 Trixie - supported"
|
||||||
|
elif [ "${VERSION_ID:-0}" = "12" ]; then
|
||||||
if [ "${VERSION_ID:-0}" -eq "13" ]; then
|
print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13"
|
||||||
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
|
|
||||||
else
|
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
|
fi
|
||||||
else
|
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
|
fi
|
||||||
else
|
else
|
||||||
print_error "Could not detect OS version"
|
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
|
- **`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)
|
- **`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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -18,21 +18,26 @@ system user.
|
|||||||
permissions on the `assets/` tree so plugins can download and cache
|
permissions on the `assets/` tree so plugins can download and cache
|
||||||
team logos, fonts, and other static content.
|
team logos, fonts, and other static content.
|
||||||
|
|
||||||
- **`fix_cache_permissions.sh`** — Creates (if missing) and fixes
|
- **`fix_cache_permissions.sh`** — Restores `/var/cache/ledmatrix/` to the
|
||||||
permissions on `/var/cache/ledmatrix/` and `~/.ledmatrix_cache/` of the
|
shared `ledmatrix`-group setup by running
|
||||||
user running `sudo`, and creates
|
`scripts/install/setup_cache.sh` (the same script the installer uses),
|
||||||
`/var/cache/ledmatrix/placeholder_logos/` for the sports plugins. It does
|
and creates/fixes `~/.ledmatrix_cache/` of the user running `sudo`. It
|
||||||
not touch the cache manager's other fallbacks (`/opt/ledmatrix/cache`,
|
does not touch the cache manager's other fallbacks
|
||||||
`$TMPDIR/ledmatrix_cache`).
|
(`/opt/ledmatrix/cache`, `$TMPDIR/ledmatrix_cache`).
|
||||||
|
|
||||||
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
||||||
directory so both the root display service and the web service user
|
directory so both the root display service and the web service user
|
||||||
can read and write plugin files (manifests, configs, requirements
|
can read and write plugin files (manifests, configs, requirements
|
||||||
installs).
|
installs).
|
||||||
|
|
||||||
- **`fix_web_permissions.sh`** — Fixes permissions on log files,
|
- **`fix_web_permissions.sh`** — Adds you to the `systemd-journal` and
|
||||||
systemd journal access, and the sudoers entries the web interface
|
`adm` groups so the web UI can read logs, and makes the project
|
||||||
needs to control the display service.
|
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
|
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
|
||||||
after checking it is the project's own or one under `plugin-repos/` or
|
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_cache_permissions.sh
|
||||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh
|
sudo ./scripts/fix_perms/fix_assets_permissions.sh
|
||||||
sudo ./scripts/fix_perms/fix_plugin_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`
|
If you're not sure which one you need, run `fix_cache_permissions.sh`
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Set permissions to allow read/write for owner, group, and others (for root service user)
|
# 777: read/write for owner, group and every other account. Root (the display
|
||||||
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
|
# 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..."
|
echo "Setting permissions for assets directory..."
|
||||||
if sudo chmod -R 777 "$ASSETS_DIR"; then
|
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
|
else
|
||||||
echo "✗ Failed to set assets directory permissions"
|
echo "✗ Failed to set assets directory permissions"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -70,8 +71,7 @@ for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
|
|||||||
echo " - Current permissions:"
|
echo " - Current permissions:"
|
||||||
ls -ld "$FULL_PATH"
|
ls -ld "$FULL_PATH"
|
||||||
|
|
||||||
# Ensure the directory is writable by both the real user and root (service user)
|
# Owned by the real user; 777 as above (root can write here regardless)
|
||||||
# Use 777 permissions to allow root (service) to write, or set group ownership
|
|
||||||
sudo chmod 777 "$FULL_PATH"
|
sudo chmod 777 "$FULL_PATH"
|
||||||
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
|
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# LEDMatrix Cache Permissions Fix Script
|
# 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..."
|
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)
|
# Get the real user (not root when running with sudo)
|
||||||
REAL_USER=${SUDO_USER:-$USER}
|
REAL_USER=${SUDO_USER:-$USER}
|
||||||
# Resolve the home directory of the real user robustly
|
# Resolve the home directory of the real user robustly
|
||||||
@@ -16,72 +28,36 @@ else
|
|||||||
fi
|
fi
|
||||||
REAL_GROUP=$(id -gn "$REAL_USER")
|
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 ""
|
||||||
echo "Setting up placeholder logos directory for sports managers..."
|
echo "Checking cache directory: /var/cache/ledmatrix"
|
||||||
|
if [ -f "$SETUP_CACHE" ]; then
|
||||||
PLACEHOLDER_DIR="/var/cache/ledmatrix/placeholder_logos"
|
bash "$SETUP_CACHE"
|
||||||
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"
|
|
||||||
else
|
else
|
||||||
echo "Placeholder logos directory already exists: $PLACEHOLDER_DIR"
|
echo " ✗ $SETUP_CACHE not found; /var/cache/ledmatrix left unchanged."
|
||||||
sudo chmod 777 "$PLACEHOLDER_DIR"
|
|
||||||
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
|
|
||||||
fi
|
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:"
|
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..."
|
echo " - Testing write access as $REAL_USER..."
|
||||||
if sudo -u "$REAL_USER" test -w "$PLACEHOLDER_DIR"; then
|
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
|
||||||
echo " ✓ Placeholder logos directory is writable by $REAL_USER"
|
echo " ✓ $CACHE_DIR is now writable by $REAL_USER"
|
||||||
else
|
else
|
||||||
echo " ✗ Placeholder logos directory is not writable by $REAL_USER"
|
echo " ✗ $CACHE_DIR is still 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"
|
|
||||||
fi
|
fi
|
||||||
|
echo " - Permissions fix complete for $CACHE_DIR."
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "All cache directory permission fixes attempted."
|
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 "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..."
|
echo "Setting ownership to root:$ACTUAL_USER..."
|
||||||
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
|
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
|
||||||
|
|
||||||
# Set directory permissions (775: rwxrwxr-x)
|
# Set directory permissions (2775: rwxrwsr-x)
|
||||||
# Root: read/write/execute, Group (ACTUAL_USER): read/write/execute, Others: read/execute
|
# Owner (root) and group (ACTUAL_USER): read/write/execute, others: read/execute.
|
||||||
echo "Setting directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
|
# 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 {} \;
|
find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \;
|
||||||
|
|
||||||
# Set file permissions (664: rw-rw-r--)
|
# Set file permissions (664: rw-rw-r--)
|
||||||
@@ -71,7 +72,7 @@ fi
|
|||||||
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
|
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
|
||||||
sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
|
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 {} \;
|
find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \;
|
||||||
|
|
||||||
echo "Setting plugin-repos file permissions to 664..."
|
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)"
|
ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Permissions summary:"
|
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 "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)"
|
||||||
echo "- Others: Can read plugins"
|
echo "- Others: Can read plugins"
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ echo ""
|
|||||||
echo "This script will:"
|
echo "This script will:"
|
||||||
echo "1. Add the web user to the 'systemd-journal' group for log access"
|
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 "2. Add the web user to the 'adm' group for additional system access"
|
||||||
echo "3. Configure sudoers for passwordless access to system commands"
|
echo "3. Make the project directory yours again, keeping the root-owned sudo"
|
||||||
echo "4. Set proper file permissions"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
@@ -62,6 +63,51 @@ else
|
|||||||
echo "✗ Failed to set project ownership"
|
echo "✗ Failed to set project ownership"
|
||||||
fi
|
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
|
# Set proper permissions for config files
|
||||||
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
|
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
|
||||||
echo "✓ Set config file permissions"
|
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
|
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
|
||||||
echo "✓ Sudo access test passed"
|
echo "✓ Sudo access test passed"
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -101,5 +147,5 @@ echo ""
|
|||||||
echo "After logging back in, test journal access with:"
|
echo "After logging back in, test journal access with:"
|
||||||
echo " journalctl --no-pager --lines=5"
|
echo " journalctl --no-pager --lines=5"
|
||||||
echo ""
|
echo ""
|
||||||
echo "If you still have sudo issues, run:"
|
echo "If you still have sudo issues, run (as this user, without sudo):"
|
||||||
echo " ./configure_web_sudo.sh"
|
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;
|
(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
|
there is no `ledmatrix` system user) the passwordless `nmcli` and related
|
||||||
WiFi permissions the web interface needs
|
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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ fi
|
|||||||
# Get the full paths to commands and validate each one
|
# Get the full paths to commands and validate each one
|
||||||
MISSING_CMDS=()
|
MISSING_CMDS=()
|
||||||
|
|
||||||
PYTHON_PATH=$(command -v python3) || true
|
|
||||||
SYSTEMCTL_PATH=$(command -v systemctl) || true
|
SYSTEMCTL_PATH=$(command -v systemctl) || true
|
||||||
REBOOT_PATH=$(command -v reboot) || true
|
REBOOT_PATH=$(command -v reboot) || true
|
||||||
POWEROFF_PATH=$(command -v poweroff) || 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_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
||||||
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
||||||
|
|
||||||
# Validate required commands (systemctl, bash, python3 are essential)
|
# Validate required commands (systemctl and bash are essential)
|
||||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH; do
|
||||||
CMD_VAL="${!CMD_NAME}"
|
CMD_VAL="${!CMD_NAME}"
|
||||||
if [ -z "$CMD_VAL" ]; then
|
if [ -z "$CMD_VAL" ]; then
|
||||||
MISSING_CMDS+=("$CMD_NAME")
|
MISSING_CMDS+=("$CMD_NAME")
|
||||||
@@ -59,8 +58,17 @@ if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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 "Command paths:"
|
||||||
echo " Python: $PYTHON_PATH"
|
|
||||||
echo " Systemctl: $SYSTEMCTL_PATH"
|
echo " Systemctl: $SYSTEMCTL_PATH"
|
||||||
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
|
echo " Reboot: ${REBOOT_PATH:-(not found, skipping)}"
|
||||||
echo " Poweroff: ${POWEROFF_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 plugin rm: $SAFE_RM_PATH"
|
||||||
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
||||||
|
|
||||||
# Create a temporary sudoers file
|
# Create a temporary sudoers file. A predictable name in a world-writable
|
||||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
# 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
|
||||||
|
|
||||||
{
|
web_sudoers_rules "$WEB_USER" "$PROJECT_ROOT" "$SYSTEMCTL_PATH" "$BASH_PATH" \
|
||||||
echo "# LED Matrix Web Interface passwordless sudo configuration"
|
"$REBOOT_PATH" "$POWEROFF_PATH" "$JOURNALCTL_PATH" > "$TEMP_SUDOERS"
|
||||||
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"
|
|
||||||
|
|
||||||
# Never offer to install rules we have not parsed. A malformed drop-in in
|
# 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.
|
# /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 command -v visudo >/dev/null 2>&1; then
|
||||||
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
||||||
echo ""
|
echo ""
|
||||||
@@ -134,6 +104,8 @@ if command -v visudo >/dev/null 2>&1; then
|
|||||||
rm -f "$TEMP_SUDOERS"
|
rm -f "$TEMP_SUDOERS"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠ visudo not found; the rules below have not been validated"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -162,7 +134,7 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Apply the configuration using visudo
|
# Apply the configuration
|
||||||
echo "Applying sudoers configuration..."
|
echo "Applying sudoers configuration..."
|
||||||
# Harden the helper script: root-owned, not writable by web user
|
# Harden the helper script: root-owned, not writable by web user
|
||||||
echo "Hardening safe_plugin_rm.sh ownership..."
|
echo "Hardening safe_plugin_rm.sh ownership..."
|
||||||
@@ -181,21 +153,29 @@ if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
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 "Configuration applied successfully!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Testing sudo access..."
|
echo "Testing sudo access..."
|
||||||
|
|
||||||
# Test a few commands
|
# Ask sudo whether two of the new rules let this user in without a
|
||||||
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
|
# 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"
|
echo "✓ systemctl status ledmatrix.service - OK"
|
||||||
else
|
else
|
||||||
echo "✗ systemctl status ledmatrix.service - Failed"
|
echo "✗ systemctl status ledmatrix.service - not allowed without a password"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
|
if sudo -n -l "$BASH_PATH" "$SAFE_RM_PATH" "$PROJECT_ROOT/plugin-repos/example" > /dev/null 2>&1; then
|
||||||
echo "✓ File access test - OK"
|
echo "✓ safe_plugin_rm.sh helper - OK"
|
||||||
else
|
else
|
||||||
echo "✗ File access test - Failed"
|
echo "✗ safe_plugin_rm.sh helper - not allowed without a password"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
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/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/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
|
$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
|
EOF
|
||||||
|
|
||||||
echo "Generated sudoers configuration:"
|
echo "Generated sudoers configuration:"
|
||||||
@@ -151,6 +156,21 @@ echo "--------------------------------"
|
|||||||
cat "$TEMP_SUDOERS"
|
cat "$TEMP_SUDOERS"
|
||||||
echo "--------------------------------"
|
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
|
# Apply the sudoers configuration
|
||||||
echo ""
|
echo ""
|
||||||
echo "Applying sudoers configuration..."
|
echo "Applying sudoers configuration..."
|
||||||
@@ -213,11 +233,14 @@ rm -f "$TEMP_POLKIT"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Step 3: Testing permissions..."
|
echo "Step 3: Testing permissions..."
|
||||||
|
|
||||||
# Test sudo access
|
# Ask sudo whether one of the new rules lets this user in without a password.
|
||||||
if sudo -n "$NMCLI_PATH" device status > /dev/null 2>&1; then
|
# `sudo -l CMD` answers from the rules without running CMD, so the radio is
|
||||||
echo "✓ nmcli device status - OK"
|
# 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
|
else
|
||||||
echo "✗ nmcli device status - Failed (this is expected if not connected)"
|
echo "✗ nmcli radio wifi on - not allowed without a password"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
SERVICE_NAME="ledmatrix-dns-fix"
|
SERVICE_NAME="ledmatrix-dns-fix"
|
||||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||||
UNIT_DEST="/etc/systemd/system/$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"
|
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
|
||||||
|
|
||||||
echo "Installing $UNIT_DEST..."
|
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
|
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||||
|
|
||||||
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
|
||||||
|
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||||
|
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||||
|
|
||||||
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
||||||
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
||||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
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"
|
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
|
||||||
|
|
||||||
echo "Installing $UNIT_DEST..."
|
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
|
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||||
|
|
||||||
$SYSTEMCTL_CMD daemon-reload
|
$SYSTEMCTL_CMD daemon-reload
|
||||||
|
|||||||
@@ -51,20 +51,25 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
|||||||
|
|
||||||
# Install packages automatically (no prompt)
|
# Install packages automatically (no prompt)
|
||||||
# Use apt directly if running as root, otherwise use sudo
|
# Use apt directly if running as root, otherwise use sudo
|
||||||
|
PACKAGES_OK=true
|
||||||
if [ "$EUID" -eq 0 ]; then
|
if [ "$EUID" -eq 0 ]; then
|
||||||
apt update || echo "⚠ apt update failed, continuing anyway..."
|
apt update || echo "⚠ apt update failed, continuing anyway..."
|
||||||
apt install -y "${MISSING_PACKAGES[@]}" || {
|
apt install -y "${MISSING_PACKAGES[@]}" || {
|
||||||
|
PACKAGES_OK=false
|
||||||
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
||||||
echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}"
|
echo " You may need to install packages manually: apt install -y ${MISSING_PACKAGES[*]}"
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
sudo apt update || echo "⚠ apt update failed, continuing anyway..."
|
sudo apt update || echo "⚠ apt update failed, continuing anyway..."
|
||||||
sudo apt install -y "${MISSING_PACKAGES[@]}" || {
|
sudo apt install -y "${MISSING_PACKAGES[@]}" || {
|
||||||
|
PACKAGES_OK=false
|
||||||
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
echo "⚠ Package installation failed, but continuing with WiFi monitor setup"
|
||||||
echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}"
|
echo " You may need to install packages manually: sudo apt install -y ${MISSING_PACKAGES[*]}"
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
echo "✓ Package installation completed"
|
if [ "$PACKAGES_OK" = true ]; then
|
||||||
|
echo "✓ Package installation completed"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
# 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.
|
# Shared helper for rendering systemd unit templates via sed.
|
||||||
#
|
#
|
||||||
# Sourced by install_service.sh, install_web_service.sh and
|
# Sourced by install_service.sh, install_web_service.sh,
|
||||||
# install_wifi_monitor.sh so all three escape sed replacement text the same
|
# install_wifi_monitor.sh, install_dns_fix.sh and install_mqtt_bridge.sh so
|
||||||
# way instead of carrying three copies of the same fix.
|
# every unit renderer escapes sed replacement text the same way instead of
|
||||||
|
# carrying its own copy of the fix.
|
||||||
|
|
||||||
# sed_escape_replacement VALUE
|
# sed_escape_replacement VALUE
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -205,27 +205,6 @@ check_sudo() {
|
|||||||
print_success "Sudo access confirmed"
|
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 installation function
|
||||||
main() {
|
main() {
|
||||||
print_step "LED Matrix One-Shot Installation"
|
print_step "LED Matrix One-Shot Installation"
|
||||||
@@ -429,6 +408,13 @@ main() {
|
|||||||
print_step "Installation Complete!"
|
print_step "Installation Complete!"
|
||||||
print_success "LED Matrix has been successfully installed!"
|
print_success "LED Matrix has been successfully installed!"
|
||||||
echo ""
|
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 "Next steps:"
|
||||||
echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json"
|
echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json"
|
||||||
if command -v hostname >/dev/null 2>&1; then
|
if command -v hostname >/dev/null 2>&1; then
|
||||||
@@ -449,7 +435,7 @@ main() {
|
|||||||
else
|
else
|
||||||
echo " 2. Or use the web interface: http://<your-pi-ip>:5000"
|
echo " 2. Or use the web interface: http://<your-pi-ip>:5000"
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
else
|
else
|
||||||
print_error "Main installation script exited with code $INSTALL_EXIT_CODE"
|
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))
|
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"
|
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):
|
def measure_refresh(config, seconds=6.0):
|
||||||
"""Actual refresh rate, by running uncapped and timing the swaps.
|
"""Actual refresh rate, by running uncapped and timing the swaps.
|
||||||
|
|
||||||
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
|
What an older Pi or a longer chain will really give you, as opposed to
|
||||||
runs at exactly the panel's rate. This is what an older Pi or a longer
|
whatever limit_refresh_rate_hz optimistically asks for. The timing loop
|
||||||
chain will really give you, as opposed to whatever limit_refresh_rate_hz
|
itself lives in src.common.frame_timing so the benchmark grades against
|
||||||
optimistically asks for.
|
the same measurement this ladder is built from.
|
||||||
"""
|
"""
|
||||||
matrix = open_matrix(config, refresh_override=0)
|
matrix = open_matrix(config, refresh_override=0)
|
||||||
canvas = matrix.CreateFrameCanvas()
|
measured = frame_timing.measure_refresh_hz(matrix, seconds)
|
||||||
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
|
|
||||||
frames = 0
|
|
||||||
started = time.perf_counter()
|
|
||||||
while time.perf_counter() - started < seconds:
|
|
||||||
canvas = matrix.SwapOnVSync(canvas)
|
|
||||||
frames += 1
|
|
||||||
measured = frames / (time.perf_counter() - started)
|
|
||||||
matrix.Clear()
|
matrix.Clear()
|
||||||
return measured
|
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
|
- **`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
|
- **`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`)
|
- **`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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -29,13 +29,9 @@ from typing import Any, Optional, Tuple
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies).
|
# Re-exported by src.common for plugins, which import them from there.
|
||||||
try:
|
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
||||||
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
||||||
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
|
||||||
except AttributeError: # Pillow < 9.1
|
|
||||||
RESAMPLE_LANCZOS = Image.LANCZOS
|
|
||||||
RESAMPLE_NEAREST = Image.NEAREST
|
|
||||||
|
|
||||||
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from enum import Enum
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import pytz
|
import pytz
|
||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
|
from src.common.json_body import response_json
|
||||||
from src.common.espn_dates import (
|
from src.common.espn_dates import (
|
||||||
RANGE_RETRY_SECONDS,
|
RANGE_RETRY_SECONDS,
|
||||||
_note_range_rejected,
|
_note_range_rejected,
|
||||||
@@ -130,19 +131,14 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
# Thread management
|
# Thread management
|
||||||
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="BackgroundData")
|
||||||
# cache_key -> request_id for fetches currently in flight. Submitting
|
# cache_key -> request_id for fetches currently in flight, so a second
|
||||||
# the same key twice used to start two identical fetches: request_id
|
# submit for the same key joins the running fetch instead of starting
|
||||||
# carries a millisecond timestamp, so every submit looked new, and
|
# another. It is the normal case: a sport's Recent and Upcoming
|
||||||
# active_requests is keyed by it rather than by what is being fetched.
|
# managers miss the cache for the same season schedule together.
|
||||||
# On a real board the season-schedule key is requested by both the
|
|
||||||
# Recent and the Upcoming manager, which miss the cache in the same
|
|
||||||
# millisecond and each download and parse the same payload.
|
|
||||||
self._inflight_by_cache_key: Dict[str, str] = {}
|
self._inflight_by_cache_key: Dict[str, str] = {}
|
||||||
# request_id was sport_year_milliseconds, which is not unique: two
|
# Makes every request_id unique. The id also carries a millisecond
|
||||||
# submits inside the same millisecond produced the SAME id, so one
|
# timestamp, but two submits can share a millisecond, and a joiner
|
||||||
# silently replaced the other in active_requests and completed_requests.
|
# uses the id as its handle for get_result().
|
||||||
# Rare before, but dedupe hands this id back to every joiner as their
|
|
||||||
# handle for get_result(), so it has to be unique. A counter is enough.
|
|
||||||
self._request_seq = itertools.count()
|
self._request_seq = itertools.count()
|
||||||
self.active_requests: Dict[str, FetchRequest] = {}
|
self.active_requests: Dict[str, FetchRequest] = {}
|
||||||
self.completed_requests: Dict[str, FetchResult] = {}
|
self.completed_requests: Dict[str, FetchResult] = {}
|
||||||
@@ -185,9 +181,9 @@ class BackgroundDataService:
|
|||||||
This ensures Recent/Upcoming managers and background service
|
This ensures Recent/Upcoming managers and background service
|
||||||
use the same cache keys.
|
use the same cache keys.
|
||||||
"""
|
"""
|
||||||
# Same format as CacheManager.generate_sport_cache_key(). This used to
|
# Same format as CacheManager.generate_sport_cache_key(), built here
|
||||||
# build a whole CacheManager to call it -- config load, cache-dir
|
# rather than by constructing a CacheManager (config load, cache-dir
|
||||||
# probing with test writes -- on every submit without a cache_key.
|
# probing) on every submit without a cache_key.
|
||||||
if date_str is None:
|
if date_str is None:
|
||||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||||
return f"{sport}_{date_str}"
|
return f"{sport}_{date_str}"
|
||||||
@@ -330,10 +326,8 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# A request cancelled while it sat in the executor queue must
|
# A request cancelled while it sat in the executor queue stays
|
||||||
# stay cancelled. Overwriting the status here undid the cancel
|
# cancelled: no download, no cache write, no callback.
|
||||||
# outright: the worker went on to download, cache and call back
|
|
||||||
# for work the caller had already withdrawn.
|
|
||||||
if request.status == FetchStatus.CANCELLED:
|
if request.status == FetchStatus.CANCELLED:
|
||||||
cancelled_before_start = True
|
cancelled_before_start = True
|
||||||
else:
|
else:
|
||||||
@@ -389,7 +383,7 @@ class BackgroundDataService:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
else:
|
else:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response_json(response)
|
||||||
|
|
||||||
# Validate data structure
|
# Validate data structure
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -462,10 +456,9 @@ class BackgroundDataService:
|
|||||||
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
logger.error(f"Failed to fetch {request.sport} {request.year} data: {error_msg}")
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Don't relabel a cancelled request. The callback gate in the
|
# A cancelled request stays CANCELLED even when its fetch
|
||||||
# finally block only suppresses CANCELLED, so promoting it to
|
# failed: the finally block skips callbacks only for
|
||||||
# FAILED here delivered an error callback for a fetch nobody
|
# CANCELLED, and nobody is waiting on this fetch any more.
|
||||||
# was waiting on any more.
|
|
||||||
if request.status != FetchStatus.CANCELLED:
|
if request.status != FetchStatus.CANCELLED:
|
||||||
request.status = FetchStatus.FAILED
|
request.status = FetchStatus.FAILED
|
||||||
request.error = error_msg
|
request.error = error_msg
|
||||||
@@ -525,20 +518,13 @@ class BackgroundDataService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for request {request.id}: {e}")
|
logger.error(f"Error in callback for request {request.id}: {e}")
|
||||||
|
|
||||||
# Released AFTER the loop, not inside it. Every callback here holds
|
# Released after the loop, never inside it: every callback holds
|
||||||
# the same FetchResult, so releasing per-delivery handed the first
|
# the same FetchResult (a sport's recent, upcoming and live
|
||||||
# one the data and every joiner `result.data is None` -- which is
|
# managers usually share one fetch), so a release between
|
||||||
# not a quiet degradation: they read `result.data.get('events')` and
|
# deliveries would hand the later ones `result.data is None`.
|
||||||
# raise AttributeError, which this very loop catches and logs, so
|
|
||||||
# the symptom was one ERROR line and a manager that silently never
|
|
||||||
# got its schedule. Deduplication is the normal case, not a corner:
|
|
||||||
# a sport's recent, upcoming and live managers all ride one season
|
|
||||||
# fetch.
|
|
||||||
#
|
#
|
||||||
# Guarded on `callbacks`, because a request submitted without one
|
# Only when there were callbacks: a request submitted without one
|
||||||
# has no other way to collect its payload than polling get_result().
|
# collects its payload by polling get_result().
|
||||||
# The old per-delivery release got that right by accident: an empty
|
|
||||||
# list never entered the loop body.
|
|
||||||
if callbacks:
|
if callbacks:
|
||||||
self._release_payload(result)
|
self._release_payload(result)
|
||||||
request.result = None
|
request.result = None
|
||||||
@@ -720,9 +706,6 @@ class BackgroundDataService:
|
|||||||
'completed_requests_count': len(self.completed_requests),
|
'completed_requests_count': len(self.completed_requests),
|
||||||
'max_completed_requests': self._max_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,
|
'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,
|
'last_cleanup': self._last_completed_requests_cleanup,
|
||||||
'cleanup_interval': self._completed_requests_cleanup_interval
|
'cleanup_interval': self._completed_requests_cleanup_interval
|
||||||
}
|
}
|
||||||
@@ -792,27 +775,6 @@ class BackgroundDataService:
|
|||||||
|
|
||||||
return removed_count
|
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):
|
def shutdown(self, wait: bool = True):
|
||||||
"""
|
"""
|
||||||
Shutdown the background data service.
|
Shutdown the background data service.
|
||||||
|
|||||||
+71
-106
@@ -83,14 +83,25 @@ BUNDLED_FONTS: frozenset[str] = frozenset({
|
|||||||
_CONFIG_REL = Path("config/config.json")
|
_CONFIG_REL = Path("config/config.json")
|
||||||
_SECRETS_REL = Path("config/config_secrets.json")
|
_SECRETS_REL = Path("config/config_secrets.json")
|
||||||
_WIFI_REL = Path("config/wifi_config.json")
|
_WIFI_REL = Path("config/wifi_config.json")
|
||||||
# Sits in config/ next to the three above and is pure user state — a
|
# A YouTube Music session: pure user state that has to be re-authenticated by
|
||||||
# YouTube Music session that has to be re-authenticated by hand if lost.
|
# hand if lost, so a restore must bring it back.
|
||||||
# It was omitted from backups, so a restore silently signed the user out.
|
|
||||||
_YTM_REL = Path("config/ytm_auth.json")
|
_YTM_REL = Path("config/ytm_auth.json")
|
||||||
_FONTS_REL = Path("assets/fonts")
|
_FONTS_REL = Path("assets/fonts")
|
||||||
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||||
_STATE_REL = Path("data/plugin_state.json")
|
_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"
|
MANIFEST_NAME = "manifest.json"
|
||||||
PLUGINS_MANIFEST_NAME = "plugins.json"
|
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||||
|
|
||||||
@@ -140,34 +151,18 @@ class RestoreResult:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _ledmatrix_version(project_root: Path) -> str:
|
def _ledmatrix_version() -> str:
|
||||||
"""Best-effort version string for the current install."""
|
"""The release of the running core (``src.__version__``), recorded in the
|
||||||
version_file = project_root / "VERSION"
|
manifest so a restore can tell which release wrote the backup."""
|
||||||
if version_file.exists():
|
from src import __version__
|
||||||
try:
|
return __version__
|
||||||
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 _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
def _build_manifest(contents: List[str]) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
"ledmatrix_version": _ledmatrix_version(project_root),
|
"ledmatrix_version": _ledmatrix_version(),
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"contents": contents,
|
"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]]:
|
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Return a list of currently-installed plugins suitable for the backup
|
Return a list of currently-installed plugins suitable for the backup
|
||||||
manifest. Each entry has ``plugin_id`` and ``version``.
|
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||||
|
|
||||||
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
|
Reads ``data/plugin_state.json`` if present, then adds any plugin it
|
||||||
directory and reads each ``manifest.json``.
|
does not list from the ``manifest.json`` files in the configured plugin
|
||||||
|
directory (see :func:`_plugins_directory`).
|
||||||
"""
|
"""
|
||||||
plugins: Dict[str, Dict[str, Any]] = {}
|
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:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
logger.warning("Could not read plugin_state.json: %s", e)
|
logger.warning("Could not read plugin_state.json: %s", e)
|
||||||
|
|
||||||
# Fall back to scanning plugin-repos/ for manifests.
|
plugins_root = _plugins_directory(project_root)
|
||||||
plugins_root = project_root / "plugin-repos"
|
|
||||||
if plugins_root.exists():
|
if plugins_root.exists():
|
||||||
for entry in sorted(plugins_root.iterdir()):
|
for entry in sorted(plugins_root.iterdir()):
|
||||||
if not entry.is_dir():
|
if not entry.is_dir():
|
||||||
@@ -298,19 +313,10 @@ def create_backup(
|
|||||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
# Config files.
|
for section, rel, _flag in _SINGLE_FILE_SECTIONS:
|
||||||
if (project_root / _CONFIG_REL).exists():
|
if (project_root / rel).exists():
|
||||||
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
zf.write(project_root / rel, rel.as_posix())
|
||||||
contents.append("config")
|
contents.append(section)
|
||||||
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")
|
|
||||||
|
|
||||||
# User-uploaded fonts.
|
# User-uploaded fonts.
|
||||||
user_fonts = iter_user_fonts(project_root)
|
user_fonts = iter_user_fonts(project_root)
|
||||||
@@ -338,7 +344,7 @@ def create_backup(
|
|||||||
contents.append("plugins")
|
contents.append("plugins")
|
||||||
|
|
||||||
# Manifest goes last so that `contents` reflects what we actually wrote.
|
# 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))
|
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
os.replace(tmp_path, zip_path)
|
os.replace(tmp_path, zip_path)
|
||||||
@@ -352,15 +358,16 @@ def create_backup(
|
|||||||
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
||||||
"""Return a summary of what ``create_backup`` would include."""
|
"""Return a summary of what ``create_backup`` would include."""
|
||||||
project_root = Path(project_root).resolve()
|
project_root = Path(project_root).resolve()
|
||||||
return {
|
preview: Dict[str, Any] = {
|
||||||
"has_config": (project_root / _CONFIG_REL).exists(),
|
f"has_{section}": (project_root / rel).exists()
|
||||||
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
for section, rel, _flag in _SINGLE_FILE_SECTIONS
|
||||||
"has_wifi": (project_root / _WIFI_REL).exists(),
|
}
|
||||||
"has_ytm_auth": (project_root / _YTM_REL).exists(),
|
preview.update({
|
||||||
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||||
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||||
"plugins": list_installed_plugins(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] = []
|
detected: List[str] = [
|
||||||
if _CONFIG_REL.as_posix() in names:
|
section for section, rel, _flag in _SINGLE_FILE_SECTIONS
|
||||||
detected.append("config")
|
if rel.as_posix() in names
|
||||||
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")
|
|
||||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||||
detected.append("fonts")
|
detected.append("fonts")
|
||||||
if any(
|
if any(
|
||||||
@@ -584,55 +586,18 @@ def restore_backup(
|
|||||||
result.errors.append("Failed to extract backup")
|
result.errors.append("Failed to extract backup")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Main config.
|
for section, rel, flag in _SINGLE_FILE_SECTIONS:
|
||||||
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
|
if not (tmp_dir / rel).exists():
|
||||||
|
continue
|
||||||
|
if not getattr(options, flag):
|
||||||
|
result.skipped.append(section)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
_copy_file(tmp_dir / rel, project_root / rel)
|
||||||
result.restored.append("config")
|
result.restored.append(section)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error("[Backup] Failed to restore config.json: %s", e, exc_info=True)
|
logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
|
||||||
result.errors.append("Failed to restore config.json")
|
result.errors.append(f"Failed to restore {rel.name}")
|
||||||
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")
|
|
||||||
|
|
||||||
# User fonts — skip anything that collides with a bundled font.
|
# User fonts — skip anything that collides with a bundled font.
|
||||||
tmp_fonts = tmp_dir / _FONTS_REL
|
tmp_fonts = tmp_dir / _FONTS_REL
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import requests
|
|||||||
import json
|
import json
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
|
|
||||||
|
|
||||||
class BaseOddsManager:
|
class BaseOddsManager:
|
||||||
"""
|
"""
|
||||||
@@ -45,22 +47,15 @@ class BaseOddsManager:
|
|||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
self.base_url = "https://sports.core.api.espn.com/v2/sports"
|
||||||
|
|
||||||
# This path used a bare requests.get, so it identified itself as
|
# Core's shared headers: ESPN rejects requests' default User-Agent
|
||||||
# python-requests/x.y -- the one thing ESPN is known to reject. Around
|
# (see api_helper.USER_AGENT), and a rejected odds request costs the
|
||||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
# calling plugin its update budget.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||||
# request_timeout, which is set to 5s precisely to stay inside that
|
# request_timeout, which is set to 5s precisely to stay inside that
|
||||||
# budget. One try, then the cooldown below.
|
# budget. One try, then the cooldown below.
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update(DEFAULT_HTTP_HEADERS)
|
||||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Configuration with defaults
|
# Configuration with defaults
|
||||||
self.update_interval = 3600 # 1 hour default
|
self.update_interval = 3600 # 1 hour default
|
||||||
@@ -72,7 +67,6 @@ class BaseOddsManager:
|
|||||||
self.request_timeout = 5
|
self.request_timeout = 5
|
||||||
# Set when a request fails; until then, skip the network entirely.
|
# Set when a request fails; until then, skip the network entirely.
|
||||||
self._skip_network_until = 0.0
|
self._skip_network_until = 0.0
|
||||||
self.cache_ttl = 1800 # 30 minutes default
|
|
||||||
|
|
||||||
# Load configuration if available
|
# Load configuration if available
|
||||||
if config_manager:
|
if config_manager:
|
||||||
@@ -89,12 +83,10 @@ class BaseOddsManager:
|
|||||||
|
|
||||||
self.update_interval = odds_config.get('update_interval', self.update_interval)
|
self.update_interval = odds_config.get('update_interval', self.update_interval)
|
||||||
self.request_timeout = odds_config.get('timeout', self.request_timeout)
|
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: "
|
self.logger.debug(f"BaseOddsManager configuration loaded: "
|
||||||
f"update_interval={self.update_interval}s, "
|
f"update_interval={self.update_interval}s, "
|
||||||
f"timeout={self.request_timeout}s, "
|
f"timeout={self.request_timeout}s")
|
||||||
f"cache_ttl={self.cache_ttl}s")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {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)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
if odds_data:
|
if odds_data:
|
||||||
self.logger.info(f"Successfully extracted odds data: {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.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")
|
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"No odds data available for {cache_key}")
|
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)
|
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
|
||||||
|
|
||||||
return odds_data
|
return odds_data
|
||||||
|
|||||||
Vendored
+43
@@ -7,6 +7,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -98,6 +99,40 @@ def _replace_nonfinite(obj: Any) -> Any:
|
|||||||
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
|
# 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:
|
if orjson is not None:
|
||||||
# Encoding the cache record dominated the background fetch worker: on a
|
# 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
|
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
|
||||||
@@ -266,6 +301,14 @@ class DiskCache:
|
|||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
with open(cache_path, 'rb') as f:
|
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())
|
record = _loads(f.read())
|
||||||
|
|
||||||
# Determine record timestamp (prefer embedded, else file mtime)
|
# Determine record timestamp (prefer embedded, else file mtime)
|
||||||
|
|||||||
+12
-12
@@ -32,7 +32,6 @@ from typing import Any, Dict, List, Optional
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
from src.exceptions import CacheError
|
|
||||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||||
from src.cache.disk_cache import DiskCache
|
from src.cache.disk_cache import DiskCache
|
||||||
from src.cache.cache_strategy import CacheStrategy
|
from src.cache.cache_strategy import CacheStrategy
|
||||||
@@ -272,12 +271,9 @@ class CacheManager:
|
|||||||
# Update memory cache first
|
# Update memory cache first
|
||||||
self._memory_cache_component.set(key, data)
|
self._memory_cache_component.set(key, data)
|
||||||
|
|
||||||
# Save to disk cache
|
# DiskCache logs a failed write and raises CacheError, which the
|
||||||
try:
|
# caller gets as is.
|
||||||
self._disk_cache_component.set(key, data)
|
self._disk_cache_component.set(key, data)
|
||||||
except CacheError:
|
|
||||||
# Disk cache errors are already logged and raised by DiskCache
|
|
||||||
raise
|
|
||||||
|
|
||||||
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Load data from cache with memory caching."""
|
"""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:
|
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
|
||||||
"""Update cache with new data."""
|
"""Update cache with new data."""
|
||||||
cache_data = {
|
cache_data = {
|
||||||
|
# Header first; see DiskCache's stale check.
|
||||||
|
'timestamp': time.time(),
|
||||||
'data': data,
|
'data': data,
|
||||||
'timestamp': time.time()
|
|
||||||
}
|
}
|
||||||
return self.save_cache(data_type, cache_data)
|
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
|
from the key and is only a fallback for entries that did not
|
||||||
say. Omit it to keep that inferred behaviour.
|
say. Omit it to keep that inferred behaviour.
|
||||||
"""
|
"""
|
||||||
cache_data = {
|
# timestamp and ttl before data, so they are the first bytes on disk:
|
||||||
'data': data,
|
# DiskCache.get reads them from the head of the file and can call a
|
||||||
'timestamp': time.time()
|
# 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:
|
if ttl is not None:
|
||||||
cache_data['ttl'] = ttl
|
cache_data['ttl'] = ttl
|
||||||
|
cache_data['data'] = data
|
||||||
self.save_cache(key, cache_data)
|
self.save_cache(key, cache_data)
|
||||||
|
|
||||||
@deprecated("3.7.0")
|
@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
|
- Every module must import without display hardware: nothing here may import
|
||||||
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
`src.display_manager` or `src.plugin_system` at module level
|
||||||
from `src.common` for convenience; canonical import paths are
|
([`test/test_common_is_hardware_free.py`](../../test/test_common_is_hardware_free.py)).
|
||||||
`src.adaptive_layout` / `src.adaptive_images`.
|
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
|
```python
|
||||||
# Every BasePlugin already has self.layout and the draw helpers:
|
|
||||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||||
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
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(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),
|
Key pieces: `Region`, the font ladders `LADDER_GRID` / `LADDER_ARCADE`,
|
||||||
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
`LayoutContext` (`fit_text`, `fit_image`, `by_tier`, `px`), and
|
||||||
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
`scoreboard_regions()` / `media_row()`. Guide:
|
||||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
[docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||||
Full 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
|
### logo_helper
|
||||||
when running as a service (used by `CacheManager` to set up its
|
|
||||||
persistent cache directory).
|
|
||||||
|
|
||||||
## 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
|
### path_safety
|
||||||
2. **Reuse utilities**: Check existing utilities before creating new ones
|
|
||||||
3. **Document additions**: Add documentation when adding new utilities
|
[`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.
|
||||||
|
|||||||
+37
-37
@@ -1,8 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
API Helper
|
API Helper
|
||||||
|
|
||||||
Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
HTTP requests, response caching and ESPN fetch helpers for plugins
|
||||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
(``from src.common import APIHelper``), plus the headers every core request
|
||||||
|
sends (:data:`USER_AGENT`, :data:`DEFAULT_HTTP_HEADERS`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -36,13 +37,20 @@ DEFAULT_HTTP_HEADERS: Mapping[str, str] = MappingProxyType({
|
|||||||
|
|
||||||
class APIHelper:
|
class APIHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for HTTP requests, caching, and ESPN API integration.
|
HTTP requests with retries, response caching and ESPN helpers.
|
||||||
|
|
||||||
Provides functionality for:
|
- Requests go through one ``requests.Session`` that retries GET, HEAD
|
||||||
- HTTP requests with retry logic and timeouts
|
and OPTIONS on 429 and 5xx with exponential backoff, and sends
|
||||||
- Response caching with TTL support
|
:data:`DEFAULT_HTTP_HEADERS`.
|
||||||
- ESPN API integration for sports data
|
- Consecutive requests from one helper are spaced at least
|
||||||
- Request rate limiting and throttling
|
``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,
|
def __init__(self, cache_manager=None, default_timeout: int = 30,
|
||||||
@@ -73,13 +81,7 @@ class APIHelper:
|
|||||||
self.session.mount("https://", adapter)
|
self.session.mount("https://", adapter)
|
||||||
self.session.mount("http://", adapter)
|
self.session.mount("http://", adapter)
|
||||||
|
|
||||||
# Default headers
|
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
|
||||||
self.session.headers.update({
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
|
||||||
'Connection': 'keep-alive'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
self._last_request_time = 0
|
self._last_request_time = 0
|
||||||
@@ -102,9 +104,8 @@ class APIHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Response data as dictionary or None if request fails
|
Response data as dictionary or None if request fails
|
||||||
"""
|
"""
|
||||||
# Check cache first
|
|
||||||
if cache_key and self.cache_manager:
|
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:
|
if cached is not None:
|
||||||
self.logger.debug(f"Using cached response for {cache_key}")
|
self.logger.debug(f"Using cached response for {cache_key}")
|
||||||
return cached
|
return cached
|
||||||
@@ -268,10 +269,10 @@ class APIHelper:
|
|||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
data: Data to cache
|
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._set_cache(key, data, ttl)
|
||||||
self.cache_manager.set(key, data)
|
|
||||||
|
|
||||||
def get_cache(self, key: str) -> Optional[Any]:
|
def get_cache(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
@@ -281,20 +282,18 @@ class APIHelper:
|
|||||||
key: Cache key
|
key: Cache key
|
||||||
|
|
||||||
Returns:
|
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._get_from_cache(key)
|
||||||
return self.cache_manager.get(key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
def clear_cache(self, pattern: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Clear cache data.
|
Clear cache data.
|
||||||
|
|
||||||
Uses CacheManager's real surface (clear_cache / delete /
|
Uses CacheManager's clear_cache(), or list_cache_files() and delete()
|
||||||
list_cache_files); safely no-ops on managers without it. The old
|
for a pattern. A cache manager without those methods is left alone.
|
||||||
implementation guarded on a nonexistent ``clear`` method, so it
|
|
||||||
silently never cleared anything.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pattern: Optional substring to match cache keys; only matching
|
pattern: Optional substring to match cache keys; only matching
|
||||||
@@ -315,21 +314,22 @@ class APIHelper:
|
|||||||
"cannot clear by pattern")
|
"cannot clear by pattern")
|
||||||
elif hasattr(self.cache_manager, 'clear_cache'):
|
elif hasattr(self.cache_manager, 'clear_cache'):
|
||||||
self.cache_manager.clear_cache()
|
self.cache_manager.clear_cache()
|
||||||
elif hasattr(self.cache_manager, 'clear'):
|
|
||||||
self.cache_manager.clear()
|
|
||||||
else:
|
else:
|
||||||
self.logger.debug("Cache manager exposes no clear method; no-op")
|
self.logger.debug("Cache manager exposes no clear method; no-op")
|
||||||
|
|
||||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
def _get_from_cache(self, key: str, max_age: Optional[int] = None) -> Optional[Any]:
|
||||||
"""Get data from cache."""
|
"""Cached data for ``key``, or None. ``max_age`` only matters for an
|
||||||
if self.cache_manager:
|
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 self.cache_manager.get(key)
|
||||||
return None
|
return self.cache_manager.get(key, max_age=max_age)
|
||||||
|
|
||||||
def _set_cache(self, key: str, data: Any, ttl: int) -> None:
|
def _set_cache(self, key: str, data: Any, ttl: Optional[int]) -> None:
|
||||||
"""Set data in cache."""
|
"""Store ``data`` under ``key`` for ``ttl`` seconds."""
|
||||||
if self.cache_manager:
|
if self.cache_manager:
|
||||||
self.cache_manager.set(key, data)
|
self.cache_manager.set(key, data, ttl=ttl)
|
||||||
|
|
||||||
def _enforce_rate_limit(self) -> None:
|
def _enforce_rate_limit(self) -> None:
|
||||||
"""Enforce rate limiting between requests."""
|
"""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 functools import partial
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
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
|
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||||
# docstring: 500 is the largest value measured to return complete data.
|
# docstring: 500 is the largest value measured to return complete data.
|
||||||
ESPN_MAX_LIMIT = 500
|
ESPN_MAX_LIMIT = 500
|
||||||
@@ -194,7 +202,7 @@ def _fetch_one_chunk(
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response_json(response)
|
||||||
except Exception as exc: # noqa: BLE001 - see docstring
|
except Exception as exc: # noqa: BLE001 - see docstring
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||||
@@ -371,4 +379,4 @@ def fetch_espn_scoreboard(
|
|||||||
if data is not None:
|
if data is not None:
|
||||||
return data
|
return data
|
||||||
response.raise_for_status()
|
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:
|
def resolve_asset_path(relative_path: str) -> str:
|
||||||
"""Resolve a repo-relative asset path independently of the process cwd.
|
"""Resolve a repo-relative asset path independently of the process cwd.
|
||||||
|
|
||||||
Prefers the path as given — so an absolute path is returned untouched and
|
In order: an absolute path that exists is returned untouched; otherwise
|
||||||
behaviour is unchanged wherever the cwd already happened to be the install
|
``relative_path`` under the install root derived above, if that exists;
|
||||||
root — then the install root derived above, then the original string so a
|
otherwise ``relative_path`` unchanged, so a caller that wants to raise
|
||||||
caller that wants to raise and fall back still can.
|
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
|
Without the fallback, any process started outside the install root (the
|
||||||
plugin safety harness, a manual ``python run.py`` from ``$HOME``, a unit
|
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()
|
||||||
+31
-50
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image, ImageDraw
|
||||||
from src.common.api_helper import USER_AGENT
|
from src.common.api_helper import USER_AGENT
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
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.
|
# trade for not re-warning about a file nobody is going to add.
|
||||||
MISSING_LOGO_RECHECK_SECONDS = 3600.0
|
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.
|
# 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.
|
# The cap for every logo download: src.logo_downloader.fetch_logo uses it too.
|
||||||
MAX_LOGO_BYTES = 10 * 1024 * 1024
|
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:
|
class LogoHelper:
|
||||||
"""
|
"""
|
||||||
@@ -119,12 +98,16 @@ class LogoHelper:
|
|||||||
Args:
|
Args:
|
||||||
team_abbr: Team abbreviation for caching
|
team_abbr: Team abbreviation for caching
|
||||||
logo_path: Path to the logo file
|
logo_path: Path to the logo file
|
||||||
max_width: Maximum width (defaults to display_width * 1.5)
|
max_width: Maximum width (default display_width *
|
||||||
max_height: Maximum height (defaults to display_height * 1.5)
|
DEFAULT_LOGO_BOX_FACTOR)
|
||||||
|
max_height: Maximum height (default display_height *
|
||||||
|
DEFAULT_LOGO_BOX_FACTOR)
|
||||||
scale: User's size multiplier for this image, from
|
scale: User's size multiplier for this image, from
|
||||||
``customization.layout.<element>.scale``. 1.0 is untouched and
|
``customization.layout.<element>.scale``; 1.0 leaves the box
|
||||||
takes exactly the path it always did. Callers hold the config,
|
as is. Callers hold the config, so they resolve the element
|
||||||
so they resolve the element name; this only applies the number.
|
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:
|
Returns:
|
||||||
PIL Image object or None if loading fails
|
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
|
# key is size-qualified — a panel-size change must not return a
|
||||||
# logo resized for the old dimensions.
|
# logo resized for the old dimensions.
|
||||||
if max_width is None:
|
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:
|
if max_height is None:
|
||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
scale = _usable_scale(scale)
|
# 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:
|
if scale != 1.0:
|
||||||
max_width = max(1, int(round(max_width * scale)))
|
max_width = max(1, int(round(max_width * scale)))
|
||||||
max_height = max(1, int(round(max_height * 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.
|
nobody asked to grow would change every existing render.
|
||||||
"""
|
"""
|
||||||
if max_width is None:
|
if max_width is None:
|
||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
if max_height is None:
|
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
|
# Only resize if necessary
|
||||||
if logo.width <= max_width and logo.height <= max_height:
|
if logo.width <= max_width and logo.height <= max_height:
|
||||||
@@ -429,31 +415,26 @@ class LogoHelper:
|
|||||||
max_width: Optional[int] = None,
|
max_width: Optional[int] = None,
|
||||||
max_height: Optional[int] = None) -> Optional[Image.Image]:
|
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:
|
Args:
|
||||||
team_abbr: Team abbreviation to display
|
team_abbr: Team the placeholder stands in for
|
||||||
max_width: Maximum width
|
max_width: Width (default display_width * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
max_height: Maximum height
|
max_height: Height (default display_height * DEFAULT_LOGO_BOX_FACTOR)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image with placeholder logo
|
The RGBA placeholder, or None if it could not be created
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if max_width is None:
|
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:
|
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))
|
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 = ImageDraw.Draw(placeholder)
|
||||||
|
|
||||||
# Draw a simple rectangle with team abbreviation
|
|
||||||
draw.rectangle([0, 0, max_width-1, max_height-1],
|
draw.rectangle([0, 0, max_width-1, max_height-1],
|
||||||
fill=(100, 100, 100, 200), outline=(200, 200, 200, 255))
|
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.
|
Return permission mode for asset directories.
|
||||||
|
|
||||||
Returns:
|
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)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
@@ -251,7 +252,8 @@ def get_config_dir_mode() -> int:
|
|||||||
Return permission mode for config directory.
|
Return permission mode for config directory.
|
||||||
|
|
||||||
Returns:
|
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)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
@@ -271,7 +273,8 @@ def get_plugin_dir_mode() -> int:
|
|||||||
Return permission mode for plugin directories.
|
Return permission mode for plugin directories.
|
||||||
|
|
||||||
Returns:
|
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)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
@@ -281,7 +284,8 @@ def get_cache_dir_mode() -> int:
|
|||||||
Return permission mode for cache directories.
|
Return permission mode for cache directories.
|
||||||
|
|
||||||
Returns:
|
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)
|
return 0o2775 # rwxrwsr-x (setgid + group writable)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""Let a background thread run Python only while the render thread waits on vsync.
|
||||||
|
|
||||||
|
With plugin rendering moved to Vegas's prefetch thread (DisplayManager.offscreen,
|
||||||
|
#630) the render thread no longer stops for it, but it still shares the GIL
|
||||||
|
with it. The render thread spends most of each refresh inside SwapOnVSync,
|
||||||
|
which releases the GIL, and needs it back the moment the swap returns. If the
|
||||||
|
prefetch thread is running Python right then, the render thread waits: up to
|
||||||
|
the switch interval (5ms) behind bytecode, and for as long as a C call that
|
||||||
|
keeps the GIL takes. On hdpi that showed up as frames 2-5 refreshes late while
|
||||||
|
a group was being prepared.
|
||||||
|
|
||||||
|
The gate turns that around. The display manager opens it just before each swap,
|
||||||
|
with a deadline shortly ahead of the refresh the swap will return on, and
|
||||||
|
closes it when the swap returns. A thread inside ``gate.yielding()`` checks it on
|
||||||
|
every Python and C call through a profile hook, and once the window has closed
|
||||||
|
it parks -- blocked on a condition, GIL released -- until the next swap opens
|
||||||
|
it. The render thread then finds the GIL free when its refresh arrives, and the
|
||||||
|
background work runs in time the render thread was only spending waiting.
|
||||||
|
|
||||||
|
Parking a thread is only safe if nothing the render thread needs is stuck
|
||||||
|
behind it, so it is never parked:
|
||||||
|
|
||||||
|
* while it holds a lock registered with ``guard()`` (the Vegas buffers and
|
||||||
|
caches the render thread also takes);
|
||||||
|
* inside logging, threading, importlib or the cache, all of which take locks the
|
||||||
|
render thread can take too;
|
||||||
|
* when there is no render loop to protect -- no swap for ``STALE_SECONDS``, as
|
||||||
|
on a static screen or a stalled frame.
|
||||||
|
|
||||||
|
And a parked thread is never held more than ``MAX_WAIT_SECONDS`` at a time, so
|
||||||
|
whatever the gate gets wrong costs a frame, not a freeze. The render thread
|
||||||
|
itself is never gated, whatever it calls.
|
||||||
|
|
||||||
|
It gates the prefetch thread only. Gating the ESPN fetch threads as well was
|
||||||
|
tried for the hourly sports refresh, twenty-odd of them at once, and measured
|
||||||
|
worse on hdpi (0.85% late frames without it, 1.14% with it, across a burst every
|
||||||
|
five minutes): each parked thread has to take the GIL again just to park at the
|
||||||
|
end of every window, and the fetches ran two to three times as long.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any, Callable, Deque, List, Optional
|
||||||
|
|
||||||
|
#: Park background threads this long before the refresh a swap will return on,
|
||||||
|
#: so a short C call already under way has finished by then.
|
||||||
|
MARGIN_SECONDS = 0.002
|
||||||
|
|
||||||
|
#: The longest a background thread is parked in one go.
|
||||||
|
MAX_WAIT_SECONDS = 0.05
|
||||||
|
|
||||||
|
#: No swap for this long means there is no render loop running to protect.
|
||||||
|
STALE_SECONDS = 0.05
|
||||||
|
|
||||||
|
#: Swaps needed before the refresh period is trusted enough to open a window.
|
||||||
|
MIN_SAMPLES = 8
|
||||||
|
|
||||||
|
#: Parking inside any of these modules could hold a lock the render thread
|
||||||
|
#: takes: logging handler locks, Condition and Event internals, the module
|
||||||
|
#: import locks, and the disk and memory cache locks. Matched by module name,
|
||||||
|
#: not file path: a path can say "cache" or "logging" for reasons of its own --
|
||||||
|
#: a virtualenv under ~/.cache, or GitHub's /opt/hostedtoolcache, where every
|
||||||
|
#: stdlib frame would otherwise count and the gate would never park anything.
|
||||||
|
_UNSAFE_MODULES = frozenset({
|
||||||
|
"logging", "threading", "importlib", "src.cache_manager", "src.cache",
|
||||||
|
})
|
||||||
|
_UNSAFE_PREFIXES = ("logging.", "importlib.", "_frozen_importlib", "src.cache.")
|
||||||
|
|
||||||
|
|
||||||
|
def _unsafe(frame: Any, base: Any) -> bool:
|
||||||
|
"""True if a frame above ``base`` comes from somewhere parking could deadlock.
|
||||||
|
|
||||||
|
``base`` is the frame that entered ``yielding()``; what lies below it (the
|
||||||
|
thread's own bootstrap in threading.py) holds nothing.
|
||||||
|
"""
|
||||||
|
while frame is not None and frame is not base:
|
||||||
|
name = frame.f_globals.get("__name__") or ""
|
||||||
|
if name in _UNSAFE_MODULES or name.startswith(_UNSAFE_PREFIXES):
|
||||||
|
return True
|
||||||
|
frame = frame.f_back
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def swap_releases_gil() -> Optional[bool]:
|
||||||
|
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
|
||||||
|
|
||||||
|
The rebuilt binding links PyEval_SaveThread and the stock one never does.
|
||||||
|
The same test as src.common.frame_timing.binding_releases_gil (#629); one
|
||||||
|
of the two goes once both have landed.
|
||||||
|
"""
|
||||||
|
module = sys.modules.get("rgbmatrix.core")
|
||||||
|
path = getattr(module, "__file__", None)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
return b"PyEval_SaveThread" in handle.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _held(lock: Any) -> bool:
|
||||||
|
"""Is ``lock`` held? RLocks report this thread's ownership; plain locks, anyone's."""
|
||||||
|
is_owned = getattr(lock, "_is_owned", None)
|
||||||
|
if is_owned is not None:
|
||||||
|
return is_owned()
|
||||||
|
return lock.locked()
|
||||||
|
|
||||||
|
|
||||||
|
class RenderGate:
|
||||||
|
"""Opened by the render thread around each swap; honoured by background threads."""
|
||||||
|
|
||||||
|
def __init__(self, clock: Callable[[], float] = time.monotonic):
|
||||||
|
self.clock = clock
|
||||||
|
self._cond = threading.Condition()
|
||||||
|
self._generation = 0
|
||||||
|
self._open_until = 0.0
|
||||||
|
self._last_return: Optional[float] = None
|
||||||
|
self._periods: Deque[float] = deque(maxlen=64)
|
||||||
|
self._period: Optional[float] = None
|
||||||
|
self._guarded: List[Any] = []
|
||||||
|
self._local = threading.local()
|
||||||
|
self._render_ident: Optional[int] = None
|
||||||
|
#: How often, and for how long in all, background threads were parked.
|
||||||
|
self.parks = 0
|
||||||
|
self.parked_seconds = 0.0
|
||||||
|
|
||||||
|
def guard(self, *locks: Any) -> None:
|
||||||
|
"""Never park a thread while it holds (or, for a plain Lock, anyone holds) these."""
|
||||||
|
self._guarded.extend(lock for lock in locks if lock is not None)
|
||||||
|
|
||||||
|
# -- render thread -----------------------------------------------------
|
||||||
|
|
||||||
|
def refresh_period(self) -> Optional[float]:
|
||||||
|
"""The panel's refresh period from recent swaps, or None until known.
|
||||||
|
|
||||||
|
The 10th percentile of the gaps between swap returns, each divided by
|
||||||
|
the hold: a late frame only ever lengthens a gap, so the low end is
|
||||||
|
the panel's own period.
|
||||||
|
"""
|
||||||
|
return self._period
|
||||||
|
|
||||||
|
def before_swap(self, hold: int) -> None:
|
||||||
|
"""The render thread is about to block in SwapOnVSync: open the window."""
|
||||||
|
hold = max(1, int(hold))
|
||||||
|
period = self._period
|
||||||
|
now = self.clock()
|
||||||
|
last = self._last_return
|
||||||
|
if period and last is not None and now - last < STALE_SECONDS:
|
||||||
|
# The swap returns on the first refresh boundary after both the
|
||||||
|
# current frame's hold is up and this frame has been handed over;
|
||||||
|
# boundaries fall a whole period apart from the last return.
|
||||||
|
refreshes = max(hold, math.ceil((now - last) / period))
|
||||||
|
open_until = last + refreshes * period - MARGIN_SECONDS
|
||||||
|
else:
|
||||||
|
open_until = 0.0 # no rhythm to predict from: leave threads be
|
||||||
|
with self._cond:
|
||||||
|
self._open_until = open_until
|
||||||
|
self._generation += 1
|
||||||
|
self._cond.notify_all()
|
||||||
|
|
||||||
|
def after_swap(self, hold: int) -> None:
|
||||||
|
"""The swap returned and the render thread needs the GIL: close the window."""
|
||||||
|
now = self.clock()
|
||||||
|
self._open_until = 0.0
|
||||||
|
if self._render_ident is None:
|
||||||
|
# The first thread to swap is the render loop. A plugin pushing a
|
||||||
|
# live refresh from its update thread swaps too, but must not take
|
||||||
|
# over its exemption.
|
||||||
|
self._render_ident = threading.get_ident()
|
||||||
|
last = self._last_return
|
||||||
|
if last is not None and now - last < STALE_SECONDS:
|
||||||
|
self._periods.append((now - last) / max(1, int(hold)))
|
||||||
|
if len(self._periods) >= MIN_SAMPLES:
|
||||||
|
ordered = sorted(self._periods)
|
||||||
|
self._period = ordered[len(ordered) // 10]
|
||||||
|
self._last_return = now
|
||||||
|
|
||||||
|
# -- background threads ------------------------------------------------
|
||||||
|
|
||||||
|
def _should_park(self, frame: Any, now: float) -> bool:
|
||||||
|
if now < self._open_until:
|
||||||
|
return False # inside the window
|
||||||
|
last = self._last_return
|
||||||
|
if last is None or now - last > STALE_SECONDS or self._period is None:
|
||||||
|
return False # no render loop to protect
|
||||||
|
for lock in self._guarded:
|
||||||
|
if _held(lock):
|
||||||
|
return False
|
||||||
|
return not _unsafe(frame, getattr(self._local, "base", None))
|
||||||
|
|
||||||
|
def _hook(self, frame: Any, _event: str, _arg: Any) -> None:
|
||||||
|
now = self.clock()
|
||||||
|
if not self._should_park(frame, now):
|
||||||
|
return
|
||||||
|
generation = self._generation
|
||||||
|
with self._cond:
|
||||||
|
self._cond.wait_for(lambda: self._generation != generation,
|
||||||
|
timeout=MAX_WAIT_SECONDS)
|
||||||
|
self.parks += 1
|
||||||
|
self.parked_seconds += self.clock() - now
|
||||||
|
|
||||||
|
def yielding(self) -> "_Yielding":
|
||||||
|
"""``with gate.yielding():`` runs the block giving way to the render thread."""
|
||||||
|
return _Yielding(self)
|
||||||
|
|
||||||
|
|
||||||
|
class _Yielding:
|
||||||
|
"""Installs a gate's profile hook on the thread for the length of a block."""
|
||||||
|
|
||||||
|
def __init__(self, gate: RenderGate):
|
||||||
|
self.gate = gate
|
||||||
|
self._previous: Any = None
|
||||||
|
self._previous_base: Any = None
|
||||||
|
self._skipped = False
|
||||||
|
|
||||||
|
def __enter__(self) -> RenderGate:
|
||||||
|
gate = self.gate
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
if threading.get_ident() == gate._render_ident:
|
||||||
|
self._skipped = True # parking the render thread parks the display
|
||||||
|
return gate
|
||||||
|
local = gate._local
|
||||||
|
self._previous_base = getattr(local, "base", None)
|
||||||
|
if self._previous_base is None:
|
||||||
|
# Nested blocks keep the outermost frame, so everything the thread
|
||||||
|
# entered since it first gave way is still checked for locks.
|
||||||
|
local.base = sys._getframe(1)
|
||||||
|
self._previous = sys.getprofile()
|
||||||
|
sys.setprofile(gate._hook)
|
||||||
|
return gate
|
||||||
|
|
||||||
|
def __exit__(self, *_exc: Any) -> None:
|
||||||
|
if self._skipped:
|
||||||
|
return
|
||||||
|
sys.setprofile(self._previous)
|
||||||
|
self.gate._local.base = self._previous_base # pylint: disable=protected-access
|
||||||
|
|
||||||
+15
-38
@@ -238,20 +238,9 @@ class ScrollHelper:
|
|||||||
self.cached_image = full_image
|
self.cached_image = full_image
|
||||||
# Convert to numpy array for fast operations
|
# Convert to numpy array for fast operations
|
||||||
self.cached_array = np.array(full_image)
|
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
|
actual_image_width = full_image.width
|
||||||
self.total_scroll_width = actual_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.scroll_position = 0.0
|
||||||
self.total_distance_scrolled = 0.0
|
self.total_distance_scrolled = 0.0
|
||||||
self.scroll_complete = False
|
self.scroll_complete = False
|
||||||
@@ -339,10 +328,8 @@ class ScrollHelper:
|
|||||||
# gained. This is what the one visibly smooth scroller on the
|
# gained. This is what the one visibly smooth scroller on the
|
||||||
# hardware (the stock ticker) was already doing by virtue of never
|
# hardware (the stock ticker) was already doing by virtue of never
|
||||||
# enabling frame-based mode.
|
# enabling frame-based mode.
|
||||||
if self.scroll_delay > 0:
|
# set_scroll_delay clamps scroll_delay to at least 0.001.
|
||||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||||
else:
|
|
||||||
pixels_per_second = self.scroll_speed * 100.0
|
|
||||||
pixels_to_move = pixels_per_second * delta_time
|
pixels_to_move = pixels_per_second * delta_time
|
||||||
self.last_step_time = current_time
|
self.last_step_time = current_time
|
||||||
else:
|
else:
|
||||||
@@ -353,11 +340,11 @@ class ScrollHelper:
|
|||||||
self.scroll_position += pixels_to_move
|
self.scroll_position += pixels_to_move
|
||||||
self.total_distance_scrolled += pixels_to_move
|
self.total_distance_scrolled += pixels_to_move
|
||||||
|
|
||||||
# Calculate required total distance: total_scroll_width only.
|
# One pass is total_scroll_width. With the default lead_gap the strip
|
||||||
# The image already includes display_width pixels of blank padding at the start
|
# starts with display_width of blank, so by then the last item has
|
||||||
# (added by create_scrolling_image), so once scroll_position reaches
|
# fully left the panel; a caller passing a smaller lead_gap (Vegas)
|
||||||
# total_scroll_width the last card has fully scrolled off the left edge.
|
# decides for itself where its cycle ends. Adding display_width here
|
||||||
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
|
# caused 1-2 extra wrap-arounds on wide chains.
|
||||||
required_total_distance = self.total_scroll_width
|
required_total_distance = self.total_scroll_width
|
||||||
|
|
||||||
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip
|
# 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
|
and current_time - self.last_progress_log_time >= self.progress_log_interval
|
||||||
):
|
):
|
||||||
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
||||||
# The image already includes display_width padding, so we only need total_scroll_width
|
|
||||||
required_total_distance = self.total_scroll_width
|
required_total_distance = self.total_scroll_width
|
||||||
# Progress telemetry, emitted every few seconds for the whole of
|
# Progress telemetry, emitted every few seconds for the whole of
|
||||||
# every scroll. It says how far along a marquee is, which is what
|
# 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``.
|
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||||
|
|
||||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
Implemented with numpy rather than scipy.ndimage.shift, which is not
|
||||||
installed on the target devices, and the old scipy-based sub-pixel path
|
installed on the target devices.
|
||||||
was dead code -- get_visible_portion never consulted the flag. The scipy
|
|
||||||
import was removed with it; installing scipy has no effect.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
start_x: Left column of the earlier of the two frames
|
start_x: Left column of the earlier of the two frames
|
||||||
@@ -571,22 +555,16 @@ class ScrollHelper:
|
|||||||
return self.min_duration
|
return self.min_duration
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Calculate total scroll distance needed
|
# The strip's width plus one more screen, so the duration covers
|
||||||
# The image already includes display_width padding at the start, so we need
|
# the last item leaving the panel even when the strip has less
|
||||||
# to scroll total_scroll_width pixels to show all content, plus display_width
|
# than display_width of lead-in blank (lead_gap).
|
||||||
# more pixels to ensure the last content scrolls completely off the screen
|
|
||||||
total_scroll_distance = self.total_scroll_width + self.display_width
|
total_scroll_distance = self.total_scroll_width + self.display_width
|
||||||
|
|
||||||
# Calculate effective pixels per second based on scrolling mode
|
# Calculate effective pixels per second based on scrolling mode
|
||||||
if self.frame_based_scrolling:
|
if self.frame_based_scrolling:
|
||||||
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame
|
# Frame-based mode: scroll_speed is pixels per scroll_delay
|
||||||
# Effective pixels per second = pixels per frame / seconds per frame
|
# seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
|
||||||
if self.scroll_delay > 0:
|
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||||
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)
|
|
||||||
scroll_mode_str = "frame-based"
|
scroll_mode_str = "frame-based"
|
||||||
else:
|
else:
|
||||||
# Time-based mode: scroll_speed is already pixels per second
|
# Time-based mode: scroll_speed is already pixels per second
|
||||||
@@ -1072,7 +1050,6 @@ class ScrollHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with scroll state information
|
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
|
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
|
||||||
return {
|
return {
|
||||||
'scroll_position': self.scroll_position,
|
'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
|
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
||||||
fresh frames — but only while a browser is actually watching.
|
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.
|
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 —
|
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.
|
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||||
|
|
||||||
If any constant here changes, re-check the health threshold in
|
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
|
from enum import Enum
|
||||||
@@ -37,7 +37,7 @@ VIEWER_INTERVAL = 0.2
|
|||||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||||
IDLE_INTERVAL = 30.0
|
IDLE_INTERVAL = 30.0
|
||||||
# Max age of the last write/touch before bumping mtime for the health
|
# 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
|
TOUCH_INTERVAL = 20.0
|
||||||
# A viewer marker older than this no longer counts as a live viewer.
|
# A viewer marker older than this no longer counts as a live viewer.
|
||||||
VIEWER_MARKER_FRESH_SEC = 5.0
|
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):
|
mode: Optional[str] = None):
|
||||||
"""Per-element text colour from customization.<element>.text_color.
|
"""Per-element text colour from customization.<element>.text_color.
|
||||||
|
|
||||||
Delegated rather than reimplemented: there were two copies of this
|
Delegates to src.element_style.element_color, which also resolves the
|
||||||
read and three of the offset read, and the shared one also resolves
|
element under the names plugins actually use (the layout block says
|
||||||
the element under the names plugins actually use (the layout block
|
`score` where the style block says `score_text`) and honours a per-mode
|
||||||
says `score` where the style block says `score_text`) and honours a
|
override. Hex strings are accepted.
|
||||||
per-mode override. Hex strings are still accepted.
|
|
||||||
"""
|
"""
|
||||||
from src.element_style import element_color as _shared
|
from src.element_style import element_color as _shared
|
||||||
return _shared(config, element, default, mode)
|
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
|
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
|
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.
|
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
|
Ambiguity is therefore narrowed before it is given up on: among the
|
||||||
of the 32 shipped bitmap fonts could silently lose a colour the user had
|
|
||||||
set. So ambiguity is now narrowed before it is given up on: among the
|
|
||||||
elements sharing a face, a single configured colour is the only thing the
|
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
|
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
|
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
|
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:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
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":
|
if fmt == "numeric":
|
||||||
return raw
|
return raw
|
||||||
parts = raw.replace("-", "/").split("/")
|
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])
|
month, day = int(parts[0]), int(parts[1])
|
||||||
if not 1 <= month <= 12:
|
if not 1 <= month <= 12:
|
||||||
return raw
|
return raw
|
||||||
name = MONTH_ABBR[month - 1]
|
name = months[month - 1]
|
||||||
if fmt == "numeric_day_first":
|
if fmt == "numeric_day_first":
|
||||||
return f"{day}/{month}"
|
return f"{day}/{month}"
|
||||||
if fmt == "day_first":
|
if fmt == "day_first":
|
||||||
return f"{day} {name}"
|
return f"{day} {name}"
|
||||||
if fmt == "weekday":
|
if fmt == "weekday":
|
||||||
weekday = weekday_for(config, logger, game)
|
day_name = weekday()
|
||||||
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||||
return 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]] = {}
|
_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]:
|
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||||
"""The font_size this plugin's config_schema.json declares, or None.
|
"""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
|
return None
|
||||||
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
||||||
if cache is None:
|
if cache is None:
|
||||||
cache = {}
|
|
||||||
try:
|
try:
|
||||||
import json
|
cache = _read_schema_font_sizes(schema_path)
|
||||||
with open(schema_path) as fh:
|
|
||||||
schema = json.load(fh)
|
|
||||||
props = (schema.get('properties', {})
|
|
||||||
.get('customization', {})
|
|
||||||
.get('properties', {}))
|
|
||||||
for key, spec in props.items():
|
|
||||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
|
||||||
if size is not None:
|
|
||||||
cache[key] = int(size)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# See sports_shared._schema_font_size: an unreadable schema
|
# See sports_shared._schema_font_size: an unreadable schema
|
||||||
# silently disables the pixel-grid snap for every element.
|
# 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)
|
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.
|
"""Give each colourable element its own face object.
|
||||||
|
|
||||||
The colour a draw gets is resolved from the face it was handed, and
|
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
|
with identical metrics, so nothing about the rendering changes; only
|
||||||
the ability to tell two elements apart does. Faces that cannot be
|
the ability to tell two elements apart does. Faces that cannot be
|
||||||
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
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:
|
# Looked up at call time so tests can spy on the pinned loader.
|
||||||
from src.common.font_layout import load_truetype as _load
|
from src.common.font_layout import load_truetype
|
||||||
except ImportError: # pragma: no cover
|
if element_for_font is None:
|
||||||
return fonts
|
element_for_font = ELEMENT_FOR_FONT
|
||||||
seen = {}
|
seen = {}
|
||||||
for key in ELEMENT_FOR_FONT:
|
for key in element_for_font:
|
||||||
font = fonts.get(key)
|
font = fonts.get(key)
|
||||||
if font is None:
|
if font is None:
|
||||||
continue
|
continue
|
||||||
@@ -476,7 +505,7 @@ def unshare_element_fonts(logger, fonts):
|
|||||||
if not path or not size:
|
if not path or not size:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
fonts[key] = _load(path, size)
|
fonts[key] = load_truetype(path, size)
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Could not un-share the %s face; it keeps the default colour", key)
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
|||||||
@@ -56,21 +56,13 @@ class SportsGameRendererMixin:
|
|||||||
|
|
||||||
# ---- geometry ------------------------------------------------------
|
# ---- 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:
|
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
|
Measured rather than derived from the card width, because the score's
|
||||||
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
size comes from config and the element-style resolver: a strip sized
|
||||||
comes from config and the element-style resolver. Nothing compared the
|
from the width alone lets a large score run over the logos.
|
||||||
two, so any score wider than the clamp was drawn over the logos.
|
|
||||||
Measuring it keeps the strip wide enough for whatever font is in play.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
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
|
the card width between the configurable min and max. 0 restores
|
||||||
edge-to-edge logos.
|
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")
|
configured = self._scroll_card_option("center_gap")
|
||||||
if (isinstance(configured, (int, float))
|
if (isinstance(configured, (int, float))
|
||||||
and math.isfinite(configured) and configured >= 0):
|
and math.isfinite(configured) and configured >= 0):
|
||||||
@@ -110,10 +106,9 @@ class SportsGameRendererMixin:
|
|||||||
def _logo_slot_width(self) -> int:
|
def _logo_slot_width(self) -> int:
|
||||||
"""Per-side logo slot, leaving the center gap clear.
|
"""Per-side logo slot, leaving the center gap clear.
|
||||||
|
|
||||||
No longer capped at display_height: the card is sized as two
|
Not capped at display_height: the card is sized as two full-height
|
||||||
full-height logos plus the measured gap, so what is left after the gap
|
logos plus the measured gap, so what is left after the gap is exactly
|
||||||
is exactly the logo's share. The cap was what froze the logos at 46px
|
the logo's share. At least 8 px.
|
||||||
on the old flat 128px card.
|
|
||||||
"""
|
"""
|
||||||
available = (self.display_width - self._center_gap_width()) // 2
|
available = (self.display_width - self._center_gap_width()) // 2
|
||||||
return max(8, available)
|
return max(8, available)
|
||||||
@@ -130,9 +125,8 @@ class SportsGameRendererMixin:
|
|||||||
"""X/Y nudge for one element, from customization.layout.
|
"""X/Y nudge for one element, from customization.layout.
|
||||||
|
|
||||||
Same block the full-screen scorebug reads (sports.py
|
Same block the full-screen scorebug reads (sports.py
|
||||||
_get_layout_offset), so a nudge configured in the web UI now moves
|
_get_layout_offset), so a nudge configured in the web UI moves the
|
||||||
the element on the scroll/Vegas card too -- previously the schema
|
element on the scroll/Vegas card as well as on the scorebug.
|
||||||
advertised these offsets but this renderer ignored them.
|
|
||||||
"""
|
"""
|
||||||
from src.element_style import layout_offset
|
from src.element_style import layout_offset
|
||||||
return layout_offset(self.config, element, axis, default,
|
return layout_offset(self.config, element, axis, default,
|
||||||
|
|||||||
@@ -487,9 +487,9 @@ class SportsScrollDisplayManager:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# prepare_scroll_content is subclass-implemented and builds cards
|
# prepare_scroll_content is subclass-implemented and builds cards
|
||||||
# straight from feed data, which is exactly where this PR's other
|
# straight from feed data, so it can raise on a malformed payload.
|
||||||
# crashes came from. One sport's bad payload must not take down the
|
# One sport's bad payload must not take down the shared
|
||||||
# shared orchestration for the others.
|
# orchestration for the others.
|
||||||
self.logger.exception(
|
self.logger.exception(
|
||||||
"Error preparing scroll content for game_type=%s", game_type
|
"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
|
a side and override it, the same two that override ``_SCORE_PROBE`` on
|
||||||
``SportsGameRendererMixin``.
|
``SportsGameRendererMixin``.
|
||||||
|
|
||||||
DELIBERATELY NOT MERGED WITH sports_card
|
TWINS IN sports_card
|
||||||
----------------------------------------
|
--------------------
|
||||||
Fourteen of these have same-named twins in ``src/common/sports_card.py``, which
|
Many of these have same-named twins in ``src/common/sports_card.py``, which the
|
||||||
the scoreboards' ``game_renderer.py`` already uses. They are NOT wired together
|
scoreboards' ``game_renderer.py`` uses. ``test/test_sports_twins.py`` calls
|
||||||
here. Only five are provably equivalent by source comparison; the other nine
|
each pair with the same inputs (the plugins' fixture games in every payload
|
||||||
differ in ways inspection cannot settle, and a wrong guess silently changes what
|
shape, plus edge cases) and splits them in two:
|
||||||
every scoreboard draws. Merging them needs differential testing against both
|
|
||||||
implementations, and is left for its own change.
|
- 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
|
from __future__ import annotations
|
||||||
@@ -84,10 +97,11 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import pytz
|
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
|
import requests
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw
|
||||||
from src.common.font_layout import load_truetype
|
from src.common import sports_card as _card
|
||||||
|
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
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.
|
default face instead of the pixel font it was laid out for.
|
||||||
|
|
||||||
Resolution order matches the core's own resolver: the path as given
|
Resolution order: the path as given, relative to the cwd, when it
|
||||||
first, so behaviour is unchanged wherever it already worked and a
|
exists -- the order the scoreboards' own sports.py copies used, so a
|
||||||
configured absolute path is returned untouched, then the core install
|
process running from another checkout keeps that checkout's fonts --
|
||||||
root, then the original string so callers still raise and fall back
|
then :func:`src.common.font_layout.resolve_asset_path` (the install
|
||||||
exactly as they do today.
|
root), which returns the original string when neither exists so callers
|
||||||
|
still raise and fall back.
|
||||||
"""
|
"""
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return path
|
return path
|
||||||
try:
|
return resolve_asset_path(path)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class SportsCoreSharedMixin:
|
class SportsCoreSharedMixin:
|
||||||
@@ -171,27 +165,22 @@ class SportsCoreSharedMixin:
|
|||||||
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
|
_ELEMENT_FOR_FONT: ClassVar[Dict[str, str]] = {
|
||||||
"score": "score_text", "time": "period_text", "team": "team_text",
|
"score": "score_text", "time": "period_text", "team": "team_text",
|
||||||
"detail": "detail_text", "status": "status_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.
|
#: Default tint for a favourite team's finished game.
|
||||||
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = {
|
FAVORITE_RESULT_COLOR_DEFAULTS: ClassVar[Dict[str, Tuple[int, int, int]]] = dict(
|
||||||
"win": (0, 255, 0), "loss": (255, 0, 0), "tie": (255, 200, 0)}
|
_card.FAVORITE_RESULT_COLOR_DEFAULTS)
|
||||||
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = (
|
_MONTH_ABBR: ClassVar[Tuple[str, ...]] = _card.MONTH_ABBR
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = _card.WEEKDAY_ABBR
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
||||||
_WEEKDAY_ABBR: ClassVar[Tuple[str, ...]] = (
|
|
||||||
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
||||||
#: Bitmap fonts snap to their native pixel grid.
|
#: Bitmap fonts snap to their native pixel grid.
|
||||||
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = {
|
_FONT_PIXEL_GRID: ClassVar[Dict[str, int]] = dict(_card.FONT_PIXEL_GRID)
|
||||||
"PressStart2P-Regular.ttf": 8, "4x6-font.ttf": 7}
|
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = dict(_card.FONT_NAME_ALIASES)
|
||||||
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]] = {
|
|
||||||
"press_start": "PressStart2P-Regular.ttf", "four_by_six": "4x6-font.ttf"}
|
|
||||||
#: Accepted values for the other-games quality filter.
|
#: Accepted values for the other-games quality filter.
|
||||||
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
|
_QUALITY_CHOICES: ClassVar[frozenset] = frozenset({"any", "ranked"})
|
||||||
#: How long to stay quiet between ranking-coverage warnings.
|
#: How long to stay quiet between ranking-coverage warnings.
|
||||||
_RANKING_COVERAGE_SECONDS: ClassVar[int] = 60 * 60
|
_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:
|
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||||
"""Placeholder draw method - subclasses should override."""
|
"""Placeholder draw method - subclasses should override."""
|
||||||
# This base method will be simple, subclasses provide specifics
|
# 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.
|
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
||||||
|
|
||||||
A face with no known grid is returned unchanged, so a user-supplied
|
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)
|
return _card.crisp_size(font_file, desired,
|
||||||
grid = cls._FONT_PIXEL_GRID.get(font_file)
|
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
|
||||||
if not grid or not desired or desired <= 0:
|
|
||||||
return desired
|
|
||||||
return max(grid, int(round(float(desired) / grid)) * grid)
|
|
||||||
|
|
||||||
#: Absolute path of this plugin's directory, declared by the plugin
|
#: Absolute path of this plugin's directory, declared by the plugin
|
||||||
#: itself. The mixin cannot work it out -- see _plugin_dir.
|
#: 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."""
|
"""The font_size this plugin's config_schema.json declares, or None."""
|
||||||
if not element_key:
|
if not element_key:
|
||||||
return None
|
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)
|
cache = getattr(self.__class__, '_SCHEMA_FONT_SIZES', None)
|
||||||
if cache is None:
|
if cache is None:
|
||||||
cache = {}
|
cache = {}
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
directory = self._plugin_dir()
|
directory = self._plugin_dir()
|
||||||
if directory is None:
|
if directory is None:
|
||||||
raise FileNotFoundError("no config_schema.json on the MRO")
|
raise FileNotFoundError("no config_schema.json on the MRO")
|
||||||
with open(os.path.join(directory, 'config_schema.json')) as fh:
|
cache = _card._read_schema_font_sizes(
|
||||||
schema = json.load(fh)
|
os.path.join(directory, 'config_schema.json'))
|
||||||
props = (schema.get('properties', {})
|
|
||||||
.get('customization', {})
|
|
||||||
.get('properties', {}))
|
|
||||||
for key, spec in props.items():
|
|
||||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
|
||||||
if size is not None:
|
|
||||||
cache[key] = int(size)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Say so. An unreadable schema is not cosmetic: every element's
|
# Say so. An unreadable schema is not cosmetic: every element's
|
||||||
# configured size then stops matching "the schema default", is
|
# 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:
|
def _card_option(self, key: str, default: Any = None) -> Any:
|
||||||
"""Read one key from the scroll_card config block."""
|
"""Read one key from the scroll_card config block."""
|
||||||
block = (self.config or {}).get("scroll_card")
|
return _card.scroll_card_option(self.config, key, default)
|
||||||
if isinstance(block, dict) and block.get(key) is not None:
|
|
||||||
return block.get(key)
|
|
||||||
return default
|
|
||||||
|
|
||||||
def _switch_upcoming_center(self) -> str:
|
def _switch_upcoming_center(self) -> str:
|
||||||
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
|
"""Middle of the full-screen upcoming scorebug: 'vs', 'date_time' or 'none'."""
|
||||||
@@ -354,7 +334,7 @@ class SportsCoreSharedMixin:
|
|||||||
|
|
||||||
def _vs_text(self) -> str:
|
def _vs_text(self) -> str:
|
||||||
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
"""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:
|
def _switch_date_format(self) -> str:
|
||||||
"""Date style for the full-screen scorebug.
|
"""Date style for the full-screen scorebug.
|
||||||
@@ -374,28 +354,19 @@ class SportsCoreSharedMixin:
|
|||||||
return fmt
|
return fmt
|
||||||
|
|
||||||
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
|
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()
|
raw = str(date_text or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return raw
|
return raw
|
||||||
fmt = self._switch_date_format()
|
return _card._format_date_as(self._switch_date_format(), raw,
|
||||||
if fmt == "numeric":
|
lambda: self._weekday_for(game),
|
||||||
return raw
|
self._MONTH_ABBR)
|
||||||
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}"
|
|
||||||
|
|
||||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||||
"""Weekday abbreviation from the game's start time, or ''."""
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
@@ -413,22 +384,7 @@ class SportsCoreSharedMixin:
|
|||||||
|
|
||||||
def _format_game_time(self, time_text: str) -> str:
|
def _format_game_time(self, time_text: str) -> str:
|
||||||
"""Return the time as-is (12h) or converted to 24h."""
|
"""Return the time as-is (12h) or converted to 24h."""
|
||||||
raw = str(time_text or "").strip()
|
return _card.format_game_time(self.config, time_text)
|
||||||
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}"
|
|
||||||
|
|
||||||
def _scorebug_font(self, draw, text: str, width: int):
|
def _scorebug_font(self, draw, text: str, width: int):
|
||||||
"""The face this scorebug draws its date and time in.
|
"""The face this scorebug draws its date and time in.
|
||||||
@@ -560,15 +516,7 @@ class SportsCoreSharedMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_rgb(value, fallback):
|
def _coerce_rgb(value, fallback):
|
||||||
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||||
# Checked before unpacking: a 3-character string ("123") would otherwise
|
return _card.coerce_rgb(value, fallback)
|
||||||
# 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)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _side_is_favorite(game: Dict, side: str, favorites: set) -> bool:
|
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
|
the ability to tell two elements apart does. Faces that cannot be
|
||||||
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
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, 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:
|
return _card.unshare_element_fonts(self.logger, fonts,
|
||||||
from src.common.font_layout import load_truetype as _load
|
self._ELEMENT_FOR_FONT)
|
||||||
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
|
|
||||||
|
|
||||||
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
"""Colour for whichever element owns this face.
|
"""Colour for whichever element owns this face.
|
||||||
@@ -934,7 +866,10 @@ class SportsCoreSharedMixin:
|
|||||||
draw.text((x, y), text, font=font, fill=fill)
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
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()
|
current_time = time.time()
|
||||||
if current_time - self._last_warning_time > cooldown:
|
if current_time - self._last_warning_time > cooldown:
|
||||||
self._last_warning_time = current_time
|
self._last_warning_time = current_time
|
||||||
@@ -949,8 +884,6 @@ class SportsCoreSharedMixin:
|
|||||||
try:
|
try:
|
||||||
# Fetch current week and next few days for immediate display
|
# Fetch current week and next few days for immediate display
|
||||||
now = datetime.now(pytz.utc)
|
now = datetime.now(pytz.utc)
|
||||||
immediate_events = []
|
|
||||||
|
|
||||||
start_date = now - timedelta(days=self.schedule_lookback_days)
|
start_date = now - timedelta(days=self.schedule_lookback_days)
|
||||||
end_date = now + timedelta(days=self.schedule_lookahead_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')}"
|
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(
|
data = fetch_espn_scoreboard(
|
||||||
self.session,
|
self.session,
|
||||||
url,
|
url,
|
||||||
params={"dates": date_str, "limit": 1000},
|
params={"dates": date_str, "limit": ESPN_MAX_LIMIT},
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
logger=self.logger,
|
logger=self.logger,
|
||||||
|
|||||||
+29
-27
@@ -32,7 +32,7 @@ from typing import Callable, Optional
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH
|
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
|
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||||
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
||||||
@@ -75,9 +75,12 @@ class FollowerState(Enum):
|
|||||||
class DisplaySyncManager:
|
class DisplaySyncManager:
|
||||||
"""
|
"""
|
||||||
Core sync manager. Instantiated by DisplayController based on config['sync'].
|
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
|
The leader sends each rendered frame to the follower over UDP as raw RGB
|
||||||
goes offline.
|
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__(
|
def __init__(
|
||||||
@@ -192,8 +195,8 @@ class DisplaySyncManager:
|
|||||||
|
|
||||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
local_rows = hw.get("rows", 32)
|
local_rows = hw.get("rows", DEFAULT_ROWS)
|
||||||
local_cols = hw.get("cols", 64)
|
local_cols = hw.get("cols", DEFAULT_COLS)
|
||||||
peer_rows = int(msg.get("rows", 0))
|
peer_rows = int(msg.get("rows", 0))
|
||||||
peer_cols = int(msg.get("cols", 0))
|
peer_cols = int(msg.get("cols", 0))
|
||||||
peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
|
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."""
|
"""Record a decoded leader frame and enter follower mode if needed."""
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
self._latest_frame = img
|
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._last_leader_frame_time = time.time()
|
||||||
self._leader_ip = sender_ip
|
self._leader_ip = sender_ip
|
||||||
|
if self._follower_state != FollowerState.STANDALONE:
|
||||||
if self._follower_state == FollowerState.STANDALONE:
|
return False
|
||||||
self._follower_state = FollowerState.FOLLOWER
|
self._follower_state = FollowerState.FOLLOWER
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Sync: leader active at %s — switching to follower mode",
|
"Sync: leader active at %s — switching to follower mode",
|
||||||
sender_ip,
|
sender_ip,
|
||||||
)
|
)
|
||||||
self.write_status_file()
|
self.write_status_file()
|
||||||
|
return True
|
||||||
|
|
||||||
def _follower_recv_loop(self) -> None:
|
def _follower_recv_loop(self) -> None:
|
||||||
while self._running:
|
while self._running:
|
||||||
@@ -559,15 +569,7 @@ class DisplaySyncManager:
|
|||||||
# back from. Treat it as malformed.
|
# back from. Treat it as malformed.
|
||||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||||
self._latest_scroll_x = scroll_x
|
self._latest_scroll_x = scroll_x
|
||||||
self._last_leader_frame_time = time.time()
|
if self._enter_follower_mode(sender_ip):
|
||||||
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()
|
|
||||||
fire_new_cycle = True # build initial scroll image
|
fire_new_cycle = True # build initial scroll image
|
||||||
elif t == "nc":
|
elif t == "nc":
|
||||||
# Leader started a new scroll cycle — rebuild local image
|
# Leader started a new scroll cycle — rebuild local image
|
||||||
@@ -589,8 +591,8 @@ class DisplaySyncManager:
|
|||||||
hw = self._hw_config
|
hw = self._hw_config
|
||||||
hello = json.dumps({
|
hello = json.dumps({
|
||||||
"t": "hello",
|
"t": "hello",
|
||||||
"rows": hw.get("rows", 32),
|
"rows": hw.get("rows", DEFAULT_ROWS),
|
||||||
"cols": hw.get("cols", 64),
|
"cols": hw.get("cols", DEFAULT_COLS),
|
||||||
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
"chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||||
}).encode("utf-8")
|
}).encode("utf-8")
|
||||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||||
@@ -660,8 +662,8 @@ class DisplaySyncManager:
|
|||||||
base = {
|
base = {
|
||||||
"role": self.role.value,
|
"role": self.role.value,
|
||||||
"port": self.port,
|
"port": self.port,
|
||||||
"local_rows": hw.get("rows", 32),
|
"local_rows": hw.get("rows", DEFAULT_ROWS),
|
||||||
"local_cols": hw.get("cols", 64),
|
"local_cols": hw.get("cols", DEFAULT_COLS),
|
||||||
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
"local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-19
@@ -10,7 +10,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from src.common.font_layout import load_truetype
|
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||||
|
|
||||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
@@ -18,13 +18,14 @@ _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
|||||||
|
|
||||||
class TextHelper:
|
class TextHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for text rendering with outlines and font management.
|
Font loading, outlined text and text measurement for plugins.
|
||||||
|
|
||||||
Provides functionality for:
|
- :meth:`load_fonts` loads TrueType fonts from ``font_dir`` (the install's
|
||||||
- Loading and managing fonts
|
assets/fonts by default) with the layout engine pinned
|
||||||
- Drawing text with outlines for better readability
|
(font_layout.load_truetype). Each (file, size) is loaded once per helper
|
||||||
- Calculating text dimensions and positioning
|
and reused; a missing or unloadable file becomes PIL's default font.
|
||||||
- Managing font resources
|
- :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,
|
def __init__(self, font_dir: Optional[Union[str, Path]] = None,
|
||||||
@@ -33,11 +34,13 @@ class TextHelper:
|
|||||||
Initialize the TextHelper.
|
Initialize the TextHelper.
|
||||||
|
|
||||||
Args:
|
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
|
logger: Optional logger instance
|
||||||
"""
|
"""
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
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] = {}
|
self._font_cache: Dict[str, ImageFont.ImageFont] = {}
|
||||||
|
|
||||||
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
|
def load_fonts(self, font_config: Optional[Dict[str, Dict]] = None) -> Dict[str, ImageFont.ImageFont]:
|
||||||
@@ -45,10 +48,12 @@ class TextHelper:
|
|||||||
Load fonts for different text elements.
|
Load fonts for different text elements.
|
||||||
|
|
||||||
Args:
|
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:
|
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:
|
if font_config is None:
|
||||||
font_config = self._get_default_font_config()
|
font_config = self._get_default_font_config()
|
||||||
@@ -61,9 +66,13 @@ class TextHelper:
|
|||||||
size = config['size']
|
size = config['size']
|
||||||
|
|
||||||
if font_path.exists():
|
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
|
fonts[font_name] = font
|
||||||
self.logger.debug(f"Loaded font: {font_name} ({font_path}, size {size})")
|
|
||||||
else:
|
else:
|
||||||
# Fallback to default font
|
# Fallback to default font
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
@@ -115,12 +124,7 @@ class TextHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Width in pixels
|
Width in pixels
|
||||||
"""
|
"""
|
||||||
try:
|
return int(_measure_draw.textlength(text, font=font))
|
||||||
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]
|
|
||||||
|
|
||||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
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 configuration
|
||||||
--------------------
|
--------------------
|
||||||
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||||
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
and survive plugin reinstalls. Write them by saving the whole config with
|
||||||
to write plugin settings; never write directly to the plugin directory.
|
: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
|
Hot-reload
|
||||||
----------
|
----------
|
||||||
@@ -127,13 +129,10 @@ class ConfigManager:
|
|||||||
# Update in-memory config if save was successful
|
# Update in-memory config if save was successful
|
||||||
if result.status == SaveResultStatus.SUCCESS:
|
if result.status == SaveResultStatus.SUCCESS:
|
||||||
self.config = new_config_data
|
self.config = new_config_data
|
||||||
# In-memory config now matches what was just written; refresh
|
# In-memory config now matches what was just written, so the
|
||||||
# the load signature so the fast path stays valid. NOTE: the
|
# load_config fast path may return it. It still carries the
|
||||||
# in-memory copy includes merged secrets; the on-disk file has
|
# merged secrets that were stripped on disk; that matches a full
|
||||||
# them stripped — the fast path returning self.config preserves
|
# reload, because the secrets file was not changed by the save.
|
||||||
# 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).
|
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
||||||
elif result.status == SaveResultStatus.ROLLED_BACK:
|
elif result.status == SaveResultStatus.ROLLED_BACK:
|
||||||
@@ -253,11 +252,11 @@ class ConfigManager:
|
|||||||
return self.config
|
return self.config
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
if str(e).find('config_secrets.json') == -1: # Only raise if main config is missing
|
# Only config.json can get here: a missing or unreadable secrets
|
||||||
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
# file is handled where it is read.
|
||||||
self.logger.error(error_msg, exc_info=True)
|
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
||||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
self.logger.error(error_msg, exc_info=True)
|
||||||
return self.config
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
|
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
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
|
A missing secrets file is fine (nothing to strip). But a file that
|
||||||
EXISTS and cannot be read or parsed means stripping is impossible —
|
EXISTS and cannot be read or parsed means stripping is impossible —
|
||||||
and the in-memory config being saved has secrets deep-merged into it,
|
and the in-memory config being saved has secrets deep-merged into it,
|
||||||
so proceeding would write them into config.json in plaintext. That
|
so proceeding would write them into config.json in plaintext. The
|
||||||
was the historical behavior; it is now a hard refusal. The save
|
save raises instead, so the caller (and user) fixes the secrets file
|
||||||
raises so the caller (and user) fixes the secrets file instead of
|
rather than leaking its contents into the world-readable main config.
|
||||||
silently leaking its contents into the world-readable main config.
|
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(self.secrets_path):
|
if not os.path.exists(self.secrets_path):
|
||||||
return {}
|
return {}
|
||||||
@@ -465,9 +463,8 @@ class ConfigManager:
|
|||||||
# Merge template defaults into current config
|
# Merge template defaults into current config
|
||||||
self._merge_template_defaults(self.config, template_config)
|
self._merge_template_defaults(self.config, template_config)
|
||||||
|
|
||||||
# Save migrated config using atomic save to preserve permissions
|
# save_config_atomic strips the merged secrets back out and
|
||||||
# Use atomic save to preserve file permissions
|
# keeps the file's owner and mode.
|
||||||
# Note: save_config_atomic handles secrets internally
|
|
||||||
result = self.save_config_atomic(
|
result = self.save_config_atomic(
|
||||||
new_config_data=self.config,
|
new_config_data=self.config,
|
||||||
create_backup=False, # Already created backup above
|
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)}")
|
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.
|
# The merged self.config is now stale; reload it. A reload failure
|
||||||
# Reload it to reflect the new state.
|
# (a migration error, say) is logged, not raised: the file itself
|
||||||
# Note: We wrap this in try-except because reload failures (e.g., migration errors)
|
# was saved.
|
||||||
# should not cause the save operation to fail - the file was saved successfully.
|
try:
|
||||||
if file_type == "main" or file_type == "secrets":
|
self.load_config()
|
||||||
try:
|
except Exception as reload_error:
|
||||||
self.load_config()
|
self.logger.warning(
|
||||||
except Exception as reload_error:
|
f"Configuration file saved successfully, but reload failed: {reload_error}. "
|
||||||
# Log the reload error but don't fail the save operation
|
f"The file on disk is valid, but in-memory config may be stale."
|
||||||
# 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."
|
|
||||||
)
|
|
||||||
|
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
# Provide helpful error message with fix instructions
|
# Provide helpful error message with fix instructions
|
||||||
@@ -670,7 +663,7 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
# Load current configs
|
# Load current configs
|
||||||
main_config = self.get_raw_file_content('main')
|
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
|
# Remove plugin from main config
|
||||||
if plugin_id in main_config:
|
if plugin_id in main_config:
|
||||||
@@ -703,7 +696,7 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
# Load current configs
|
# Load current configs
|
||||||
main_config = self.get_raw_file_content('main')
|
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)
|
valid_set = set(valid_plugin_ids)
|
||||||
|
|
||||||
|
|||||||
+608
-679
File diff suppressed because it is too large
Load Diff
+201
-131
@@ -34,25 +34,27 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from src.common.bdf_font import draw_bdf_text, load_bdf_face
|
||||||
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
||||||
|
from src import scan_order
|
||||||
from src.display_geometry import (
|
from src.display_geometry import (
|
||||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||||
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
|
compose_pixel_mapper_config, physical_size, resolve_double_sided,
|
||||||
resolve_double_sided,
|
|
||||||
)
|
)
|
||||||
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
|
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
|
||||||
from src.pi5_matrix_support import is_raspberry_pi_5
|
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict, deque
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
import logging
|
|
||||||
import math
|
import math
|
||||||
import zlib
|
import zlib
|
||||||
import freetype
|
import freetype
|
||||||
|
|
||||||
from src.common import snapshot_policy
|
from src.common import snapshot_policy
|
||||||
|
from src.common.frame_timing import FrameTimingRecorder
|
||||||
from src.deprecation import deprecated
|
from src.deprecation import deprecated
|
||||||
|
from src.logging_config import get_logger
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
ensure_file_permissions,
|
ensure_file_permissions,
|
||||||
@@ -60,9 +62,7 @@ from src.common.permission_utils import (
|
|||||||
get_assets_file_mode,
|
get_assets_file_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get logger without configuring
|
logger = get_logger(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
|
||||||
|
|
||||||
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
|
#: 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 --
|
#: size regardless, but a Face needs an active size before its metrics --
|
||||||
@@ -211,11 +211,6 @@ def _per_thread_canvas_attr(name: str) -> property:
|
|||||||
return property(fget, fset, doc=f"The plugin-facing ``{name}``, per thread.")
|
return property(fget, fset, doc=f"The plugin-facing ``{name}``, per thread.")
|
||||||
|
|
||||||
|
|
||||||
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
|
|
||||||
# sync handshake compute the display size exactly as DisplayManager does
|
|
||||||
# without importing rgbmatrix. Aliased here for existing callers.
|
|
||||||
_resolve_double_sided = resolve_double_sided
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
"""
|
"""
|
||||||
@@ -237,7 +232,6 @@ class DisplayManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
def __new__(cls, *args, **kwargs):
|
def __new__(cls, *args, **kwargs):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
@@ -288,11 +282,19 @@ class DisplayManager:
|
|||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
self._last_snapshot_touch_ts = 0.0
|
self._last_snapshot_touch_ts = 0.0
|
||||||
self._last_snapshot_digest: Optional[int] = None
|
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
|
self._snapshot_dir_prepared = False
|
||||||
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
||||||
self._snapshot_cond = threading.Condition()
|
self._snapshot_cond = threading.Condition()
|
||||||
self._snapshot_pending: Optional[Image.Image] = None
|
self._snapshot_pending: Optional[Tuple[Image.Image, Optional[int]]] = None
|
||||||
self._snapshot_thread: Optional[threading.Thread] = None
|
self._snapshot_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_check_ts = 0.0
|
||||||
self._viewer_fresh = False
|
self._viewer_fresh = False
|
||||||
self._viewer_was_fresh = False
|
self._viewer_was_fresh = False
|
||||||
@@ -322,6 +324,16 @@ class DisplayManager:
|
|||||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
||||||
self._frame_hold = 1
|
self._frame_hold = 1
|
||||||
|
|
||||||
|
# A src.common.render_gate.RenderGate while Vegas runs with
|
||||||
|
# vegas_scroll.prefetch_gate on: opened around each swap so the
|
||||||
|
# prefetch thread only runs Python while this thread waits on vsync.
|
||||||
|
self.render_gate = None
|
||||||
|
|
||||||
|
# Timing of every presented frame, whoever drew it, for
|
||||||
|
# scripts/frame_soak.py. See src/common/frame_timing.py.
|
||||||
|
self.frame_timing = FrameTimingRecorder(info=self._frame_timing_info())
|
||||||
|
self.frame_timing.scrolling_now = self._scrolling_now
|
||||||
|
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
'last_scroll_activity': 0,
|
'last_scroll_activity': 0,
|
||||||
@@ -333,24 +345,21 @@ class DisplayManager:
|
|||||||
|
|
||||||
self._setup_matrix()
|
self._setup_matrix()
|
||||||
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
||||||
|
self._setup_scan_order_compensation()
|
||||||
|
|
||||||
font_time = time.time()
|
font_time = time.time()
|
||||||
self._load_fonts()
|
self._load_fonts()
|
||||||
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
||||||
|
|
||||||
# Initialize managers
|
def _new_canvas(self, width: int, height: int) -> None:
|
||||||
# Calendar manager is now initialized by DisplayController
|
"""Replace ``image``/``draw`` with a black canvas of the given size.
|
||||||
|
|
||||||
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
|
Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
|
||||||
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
|
brightness, so anti-aliasing only smears glyphs.
|
||||||
|
|
||||||
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
|
|
||||||
"""Compose pixel_mapper_config with the orientation setting.
|
|
||||||
|
|
||||||
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
|
|
||||||
web preview shares so it sizes the canvas the same way.
|
|
||||||
"""
|
"""
|
||||||
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
|
@staticmethod
|
||||||
def _fallback_advice(cause: str, error: Exception) -> str:
|
def _fallback_advice(cause: str, error: Exception) -> str:
|
||||||
@@ -419,7 +428,7 @@ class DisplayManager:
|
|||||||
# logical (per-screen) size, and keep a full-chain buffer to tile
|
# logical (per-screen) size, and keep a full-chain buffer to tile
|
||||||
# the rendered screen into once per frame.
|
# the rendered screen into once per frame.
|
||||||
ds_config = self.config.get('display', {}).get('double_sided', {})
|
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
|
self._double_sided = ds
|
||||||
if ds is not None:
|
if ds is not None:
|
||||||
self._physical_image = Image.new(
|
self._physical_image = Image.new(
|
||||||
@@ -428,9 +437,7 @@ class DisplayManager:
|
|||||||
self.matrix, ds['logical_width'], ds['logical_height'])
|
self.matrix, ds['logical_width'], ds['logical_height'])
|
||||||
|
|
||||||
# Create image with the (logical) display dimensions
|
# Create image with the (logical) display dimensions
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self._new_canvas(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.
|
|
||||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||||
|
|
||||||
# Initialize font with Press Start 2P
|
# Initialize font with Press Start 2P
|
||||||
@@ -461,7 +468,7 @@ class DisplayManager:
|
|||||||
fallback_width, fallback_height = physical_size(self.config)
|
fallback_width, fallback_height = physical_size(self.config)
|
||||||
# Mirror double-sided in fallback so the preview shows one screen.
|
# Mirror double-sided in fallback so the preview shows one screen.
|
||||||
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
||||||
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||||
self._double_sided = ds
|
self._double_sided = ds
|
||||||
if ds is not None:
|
if ds is not None:
|
||||||
fallback_width = ds['logical_width']
|
fallback_width = ds['logical_width']
|
||||||
@@ -469,9 +476,7 @@ class DisplayManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
fallback_width, fallback_height = 128, 32
|
fallback_width, fallback_height = 128, 32
|
||||||
|
|
||||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
self._new_canvas(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.
|
|
||||||
# Simple fallback visualization so web UI shows a realistic canvas
|
# Simple fallback visualization so web UI shows a realistic canvas
|
||||||
try:
|
try:
|
||||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||||
@@ -700,18 +705,14 @@ class DisplayManager:
|
|||||||
line, font=font, fill=(0, 0, 255))
|
line, font=font, fill=(0, 0, 255))
|
||||||
|
|
||||||
def _draw_test_pattern(self):
|
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:
|
try:
|
||||||
self.clear()
|
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
|
# Draw a red rectangle border
|
||||||
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
|
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
|
||||||
|
|
||||||
@@ -726,7 +727,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
# Update the display once after everything is drawn
|
# Update the display once after everything is drawn
|
||||||
self.update_display()
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||||
@@ -734,7 +735,10 @@ class DisplayManager:
|
|||||||
@property
|
@property
|
||||||
def _capture_mode_active(self) -> bool:
|
def _capture_mode_active(self) -> bool:
|
||||||
"""True while the calling thread is capturing content off-screen."""
|
"""True while the calling thread is capturing content off-screen."""
|
||||||
return getattr(self._capture_state, 'active', False)
|
# Read like _current_surface(): a DisplayManager built without
|
||||||
|
# __init__ (tests do) has no per-thread state, and captures nothing.
|
||||||
|
state = self.__dict__.get('_capture_state')
|
||||||
|
return getattr(state, 'active', False) if state is not None else False
|
||||||
|
|
||||||
@_capture_mode_active.setter
|
@_capture_mode_active.setter
|
||||||
def _capture_mode_active(self, value: bool) -> None:
|
def _capture_mode_active(self, value: bool) -> None:
|
||||||
@@ -940,15 +944,26 @@ class DisplayManager:
|
|||||||
|
|
||||||
# Copy the current image to the offscreen canvas. In double-sided
|
# Copy the current image to the offscreen canvas. In double-sided
|
||||||
# mode the logical screen is first tiled across the full chain.
|
# mode the logical screen is first tiled across the full chain.
|
||||||
|
blit_started = time.perf_counter()
|
||||||
if self._double_sided is not None:
|
if self._double_sided is not None:
|
||||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||||
else:
|
else:
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
self.offscreen_canvas.SetImage(self._scan_compensated(self.image))
|
||||||
|
blit_done = time.perf_counter()
|
||||||
|
|
||||||
# Swap buffers immediately. framerate_fraction holds the frame
|
# Swap buffers immediately. framerate_fraction holds the frame
|
||||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
||||||
# what paces the render loop to the chosen frame rate.
|
# what paces the render loop to the chosen frame rate.
|
||||||
|
gate = self.render_gate
|
||||||
|
if gate is not None:
|
||||||
|
gate.before_swap(self._frame_hold)
|
||||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
||||||
|
if gate is not None:
|
||||||
|
gate.after_swap(self._frame_hold)
|
||||||
|
presented_at = time.perf_counter()
|
||||||
|
self.frame_timing.record(
|
||||||
|
blit_done - blit_started, presented_at - blit_done,
|
||||||
|
self._frame_hold, self.is_currently_scrolling(), presented_at)
|
||||||
|
|
||||||
# Swap our canvas references
|
# Swap our canvas references
|
||||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||||
@@ -960,33 +975,61 @@ class DisplayManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating display: {e}")
|
logger.error(f"Error updating display: {e}")
|
||||||
|
|
||||||
|
def _setup_scan_order_compensation(self) -> None:
|
||||||
|
"""Work out which rows to show a refresh behind while scrolling.
|
||||||
|
|
||||||
|
See src/scan_order.py. Only on real hardware: the emulator has no scan
|
||||||
|
order, so there the lag would add the very step it removes elsewhere.
|
||||||
|
"""
|
||||||
|
self._scan_lag_bands = None
|
||||||
|
self._scan_history = deque(maxlen=1)
|
||||||
|
if (self.matrix is None or self._double_sided is not None
|
||||||
|
or os.environ.get('EMULATOR', 'false') == 'true'):
|
||||||
|
return
|
||||||
|
display = self.config.get('display') or {}
|
||||||
|
bands = scan_order.scan_lag_bands(
|
||||||
|
display.get('hardware') or {}, self.height,
|
||||||
|
display.get('scan_order_compensation', 'auto'))
|
||||||
|
if not bands:
|
||||||
|
return
|
||||||
|
self._scan_lag_bands = bands
|
||||||
|
self._scan_history = deque(maxlen=max(lag for _, _, lag in bands))
|
||||||
|
logger.info(
|
||||||
|
"Scan-order compensation on: while scrolling, %s",
|
||||||
|
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
|
||||||
|
for top, bottom, lag in bands))
|
||||||
|
|
||||||
|
def _scan_compensated(self, image: Image.Image) -> Image.Image:
|
||||||
|
"""The frame to present, with lagging rows taken from earlier frames.
|
||||||
|
|
||||||
|
Only mid-scroll at one frame per refresh: that is when consecutive
|
||||||
|
frames are consecutive refreshes. At a longer hold, or on a static
|
||||||
|
screen, the history is dropped and the frame goes out as it is.
|
||||||
|
"""
|
||||||
|
bands = getattr(self, '_scan_lag_bands', None)
|
||||||
|
if not bands:
|
||||||
|
return image
|
||||||
|
if self._frame_hold != 1 or not self.is_currently_scrolling():
|
||||||
|
self._scan_history.clear()
|
||||||
|
return image
|
||||||
|
presented = scan_order.compose(image, self._scan_history, bands)
|
||||||
|
# A copy: plugins draw into the same image object frame after frame.
|
||||||
|
self._scan_history.appendleft(image.copy())
|
||||||
|
return presented
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Clear the display completely."""
|
"""Clear the display completely."""
|
||||||
try:
|
try:
|
||||||
if self.matrix is None:
|
if self.matrix is None:
|
||||||
# Fallback mode - just clear the image
|
# Fallback mode - just clear the image
|
||||||
# Explicitly clear old image reference to help garbage collection
|
|
||||||
old_image = getattr(self, 'image', None)
|
old_image = getattr(self, 'image', None)
|
||||||
width = old_image.width if old_image else 64
|
width = old_image.width if old_image else 64
|
||||||
height = old_image.height if old_image else 64
|
height = old_image.height if old_image else 64
|
||||||
if old_image is not None:
|
self._new_canvas(width, height)
|
||||||
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.
|
|
||||||
logger.debug("Cleared display in fallback mode")
|
logger.debug("Cleared display in fallback mode")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Explicitly clear old image reference to help garbage collection
|
self._new_canvas(self.matrix.width, self.matrix.height)
|
||||||
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.
|
|
||||||
|
|
||||||
if not self._writes_suppressed():
|
if not self._writes_suppressed():
|
||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
@@ -1016,43 +1059,16 @@ class DisplayManager:
|
|||||||
logger.error(f"Error clearing display: {e}")
|
logger.error(f"Error clearing display: {e}")
|
||||||
|
|
||||||
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
|
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:
|
try:
|
||||||
# Use the passed font or fall back to calendar_font
|
|
||||||
face = font if font else self.calendar_font
|
face = font if font else self.calendar_font
|
||||||
|
draw_bdf_text(self.draw, text, x, y, face, color,
|
||||||
# Compute baseline from font ascender so caller can pass top-left y
|
clip=(self.width, self.height))
|
||||||
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
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
|
logger.error(f"Error drawing BDF text: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -1101,19 +1117,13 @@ class DisplayManager:
|
|||||||
if not os.path.exists(self.calendar_font_path):
|
if not os.path.exists(self.calendar_font_path):
|
||||||
raise FileNotFoundError(f"Font file not found at {self.calendar_font_path}")
|
raise FileNotFoundError(f"Font file not found at {self.calendar_font_path}")
|
||||||
|
|
||||||
# Load with freetype for proper BDF handling
|
# load_bdf_face sets the size: a Face built without
|
||||||
face = freetype.Face(self.calendar_font_path)
|
# set_char_size reports face.size.height 0, and every caller
|
||||||
# A freshly constructed Face has no active size, so
|
# measuring the 5x7 face with get_font_height() got 0 and
|
||||||
# face.size.height is 0 until set_char_size is called -- and
|
# stacked rows on top of one another. 5x7.bdf is a fixed
|
||||||
# get_font_height() reads exactly that. Without this, every
|
# strike, so FreeType renders 7px whatever is asked for --
|
||||||
# caller measuring the 5x7 face got 0 and stacked rows on top
|
# the size sets the metrics, not the raster.
|
||||||
# of one another; the "Calendar font size: 0 pixels" line
|
face, _ = load_bdf_face(self.calendar_font_path, _CALENDAR_FONT_PX)
|
||||||
# below has been printing the symptom on every start-up.
|
|
||||||
# font_manager._load_bdf_font already does this; the two paths
|
|
||||||
# disagreed about whether a Face was usable for measurement.
|
|
||||||
# 5x7.bdf is a fixed strike, so FreeType renders 7px whatever
|
|
||||||
# is asked for -- this sets the metrics, not the raster.
|
|
||||||
face.set_char_size(_CALENDAR_FONT_PX * 64, _CALENDAR_FONT_PX * 64, 72, 72)
|
|
||||||
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
logger.info(f"5x7 calendar font loaded successfully from {self.calendar_font_path}")
|
||||||
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
logger.info(f"Calendar font size: {face.size.height >> 6} pixels")
|
||||||
|
|
||||||
@@ -1471,6 +1481,8 @@ class DisplayManager:
|
|||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
|
if hasattr(self, '_snapshot_cond'):
|
||||||
|
self._stop_snapshot_writer()
|
||||||
if hasattr(self, 'matrix') and self.matrix is not None:
|
if hasattr(self, 'matrix') and self.matrix is not None:
|
||||||
try:
|
try:
|
||||||
self.matrix.Clear()
|
self.matrix.Clear()
|
||||||
@@ -1479,14 +1491,14 @@ class DisplayManager:
|
|||||||
# Ensure image/draw are reset to a blank state
|
# Ensure image/draw are reset to a blank state
|
||||||
if hasattr(self, 'image') and hasattr(self, 'draw'):
|
if hasattr(self, 'image') and hasattr(self, 'draw'):
|
||||||
try:
|
try:
|
||||||
self.image = Image.new('RGB', (self.width, self.height))
|
self._new_canvas(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.
|
|
||||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||||
|
# 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
|
# Reset the singleton state when cleaning up
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
|
||||||
|
|
||||||
def format_date_with_ordinal(self, dt):
|
def format_date_with_ordinal(self, dt):
|
||||||
"""Formats a datetime object into 'Mon Aug 30th' style."""
|
"""Formats a datetime object into 'Mon Aug 30th' style."""
|
||||||
@@ -1524,9 +1536,9 @@ class DisplayManager:
|
|||||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||||
# _build_pixel_mapper_config reads only class attributes, so the class
|
# Orientation becomes a "Rotate:<deg>" pixel mapper; the web preview
|
||||||
# stands in for an instance here.
|
# composes it the same way so it sizes the canvas identically.
|
||||||
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
|
options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config)
|
||||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||||
options.panel_type = hardware_config.get('panel_type', '')
|
options.panel_type = hardware_config.get('panel_type', '')
|
||||||
@@ -1580,6 +1592,27 @@ class DisplayManager:
|
|||||||
value = 0.0
|
value = 0.0
|
||||||
return value if value > 0 else 100.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:
|
def set_frame_hold(self, refreshes: int) -> None:
|
||||||
"""Hold each pushed frame for this many panel refreshes (>=1).
|
"""Hold each pushed frame for this many panel refreshes (>=1).
|
||||||
|
|
||||||
@@ -1625,13 +1658,16 @@ class DisplayManager:
|
|||||||
# it must not change the live scroll's state or frame hold.
|
# it must not change the live scroll's state or frame hold.
|
||||||
return
|
return
|
||||||
current_time = time.time()
|
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
|
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||||
if is_scrolling:
|
if is_scrolling:
|
||||||
self._scrolling_state['last_scroll_activity'] = current_time
|
self._scrolling_state['last_scroll_activity'] = current_time
|
||||||
self.set_frame_hold(frame_hold)
|
self.set_frame_hold(frame_hold)
|
||||||
else:
|
else:
|
||||||
self._frame_hold = 1
|
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:
|
def is_currently_scrolling(self) -> bool:
|
||||||
"""Check if the display is currently in a scrolling state."""
|
"""Check if the display is currently in a scrolling state."""
|
||||||
@@ -1700,9 +1736,6 @@ class DisplayManager:
|
|||||||
if not self._scrolling_state['deferred_updates']:
|
if not self._scrolling_state['deferred_updates']:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._scrolling_state['deferred_updates']:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Process only a limited number of updates per call to avoid blocking
|
# Process only a limited number of updates per call to avoid blocking
|
||||||
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
|
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
|
||||||
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
|
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
|
||||||
@@ -1800,12 +1833,16 @@ class DisplayManager:
|
|||||||
viewer_fresh, digest != self._last_snapshot_digest)
|
viewer_fresh, digest != self._last_snapshot_digest)
|
||||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||||
return
|
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)
|
# mtime bump only: keeps the health check (snapshot age)
|
||||||
# green without paying for a PNG encode of an unchanged frame
|
# green without paying for a PNG encode of an unchanged frame
|
||||||
os.utime(self._snapshot_path, None)
|
os.utime(self._snapshot_path, None)
|
||||||
self._last_snapshot_touch_ts = now
|
self._last_snapshot_touch_ts = now
|
||||||
return
|
return
|
||||||
|
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
|
||||||
|
# its write failed -- is written instead: touching would make the
|
||||||
|
# older file on disk look current.)
|
||||||
|
|
||||||
# WRITE. Mid-scroll the PNG encode goes to a background thread: at
|
# 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,
|
# 512x64 it takes 12-14ms on a Pi 4, longer than a 95Hz refresh,
|
||||||
@@ -1815,9 +1852,15 @@ class DisplayManager:
|
|||||||
# it compresses, so the encode no longer holds the loop up. Static
|
# it compresses, so the encode no longer holds the loop up. Static
|
||||||
# frames still write inline: nothing is moving to disturb.
|
# frames still write inline: nothing is moving to disturb.
|
||||||
if self.is_currently_scrolling():
|
if self.is_currently_scrolling():
|
||||||
self._queue_snapshot(self.image.copy())
|
self._queue_snapshot(self.image.copy(), digest)
|
||||||
else:
|
else:
|
||||||
self._save_snapshot(self.image)
|
# A scroll that just ended can leave its last frame queued or
|
||||||
|
# mid-write; it must not land on top of this newer one.
|
||||||
|
with self._snapshot_write_lock:
|
||||||
|
with self._snapshot_cond:
|
||||||
|
self._snapshot_pending = None
|
||||||
|
self._save_snapshot(self.image)
|
||||||
|
self._saved_snapshot_digest = digest
|
||||||
self._last_snapshot_ts = now
|
self._last_snapshot_ts = now
|
||||||
self._last_snapshot_touch_ts = now
|
self._last_snapshot_touch_ts = now
|
||||||
self._last_snapshot_digest = digest
|
self._last_snapshot_digest = digest
|
||||||
@@ -1883,7 +1926,7 @@ class DisplayManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _queue_snapshot(self, image: Image.Image) -> None:
|
def _queue_snapshot(self, image: Image.Image, digest: Optional[int] = None) -> None:
|
||||||
"""Hand a frame to the snapshot writer thread; the newest frame wins.
|
"""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
|
One slot, not a queue: if the writer is still encoding when the next
|
||||||
@@ -1891,7 +1934,7 @@ class DisplayManager:
|
|||||||
the latest frame, and a backlog would only cost memory and CPU.
|
the latest frame, and a backlog would only cost memory and CPU.
|
||||||
"""
|
"""
|
||||||
with self._snapshot_cond:
|
with self._snapshot_cond:
|
||||||
self._snapshot_pending = image
|
self._snapshot_pending = (image, digest)
|
||||||
if self._snapshot_thread is None or not self._snapshot_thread.is_alive():
|
if self._snapshot_thread is None or not self._snapshot_thread.is_alive():
|
||||||
self._snapshot_thread = threading.Thread(
|
self._snapshot_thread = threading.Thread(
|
||||||
target=self._snapshot_writer, daemon=True,
|
target=self._snapshot_writer, daemon=True,
|
||||||
@@ -1902,10 +1945,37 @@ class DisplayManager:
|
|||||||
def _snapshot_writer(self) -> None:
|
def _snapshot_writer(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
with self._snapshot_cond:
|
with self._snapshot_cond:
|
||||||
while self._snapshot_pending is None:
|
while self._snapshot_pending is None and not self._snapshot_stop:
|
||||||
self._snapshot_cond.wait()
|
self._snapshot_cond.wait()
|
||||||
image, self._snapshot_pending = self._snapshot_pending, None
|
if self._snapshot_stop:
|
||||||
try:
|
return # shutting down: a pending frame is dropped
|
||||||
self._save_snapshot(image)
|
# The write lock before the frame: whichever of this and an inline
|
||||||
except Exception as e:
|
# static save gets it first also writes first, and a static save
|
||||||
self._log_snapshot_failure(e)
|
# 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
|
import requests
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class DynamicTeamResolver:
|
class DynamicTeamResolver:
|
||||||
@@ -141,7 +143,9 @@ class DynamicTeamResolver:
|
|||||||
self.logger.info("Fetching fresh NCAA Football rankings from ESPN API")
|
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"
|
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()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|||||||
+43
-72
@@ -53,13 +53,9 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Dict, Optional, Tuple, Union
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
from PIL import ImageFont
|
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
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Core install root (the directory that contains src/ and assets/fonts/),
|
# 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.
|
# Config keys a style element block carries, in schema/UI order.
|
||||||
_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align')
|
_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)
|
@dataclass(frozen=True)
|
||||||
class ElementStyle:
|
class ElementStyle:
|
||||||
@@ -164,56 +171,25 @@ def native_bdf_size(font_name: str) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
def _read_bdf_native_size(path: 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
|
See :func:`src.common.bdf_font.read_bdf_native_size`: it prefers
|
||||||
over the SIZE line's point-size (they differ on the several bundled
|
PIXEL_SIZE over the SIZE line's point-size, which differ on the several
|
||||||
fonts defined at 75dpi) and stops at the first STARTCHAR. Core always
|
bundled fonts defined at 75dpi.
|
||||||
ships it; the guard is for the plugin test harnesses that stub the
|
|
||||||
module out.
|
|
||||||
"""
|
"""
|
||||||
try:
|
return read_bdf_native_size(path)
|
||||||
from src.font_manager import FontManager
|
|
||||||
return FontManager._read_bdf_native_size(path)
|
|
||||||
except Exception: # pragma: no cover - defensive
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_bdf(path: str, size: int) -> Tuple[Any, int]:
|
def _load_bdf(path: str, size: int) -> Tuple[Any, int]:
|
||||||
"""A ``freetype.Face`` for a BDF file at the closest size it can do.
|
"""A ``freetype.Face`` for a BDF file at the closest size it can do.
|
||||||
|
|
||||||
BDF fonts are fixed-size bitmap strikes, not scalable outlines:
|
BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel
|
||||||
FreeType accepts only the exact pixel size baked into the file and
|
size baked into the file, and 32 of the 35 shipped fonts are BDF, so a
|
||||||
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. The
|
||||||
size the user picked in the web UI usually is not a valid strike.
|
shared loader retries at the native size; without that, 5x7.bdf at size
|
||||||
|
10 used to fall through to *PressStart2P*, a different typeface.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
if freetype is None:
|
return load_bdf_face(path, size)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(font_name: str, size: int) -> Any:
|
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:
|
if layout_props:
|
||||||
layout = props.setdefault('layout', {
|
layout = props.setdefault('layout', {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'title': 'Layout Offsets',
|
'title': _LAYOUT_TITLE,
|
||||||
'description': 'Pixel offsets applied to each element '
|
'description': 'Pixel offsets applied to each element '
|
||||||
'(positive x moves right, positive y moves down)',
|
'(positive x moves right, positive y moves down)',
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
@@ -380,16 +356,14 @@ def _element_block_from_spec(element_key: str,
|
|||||||
'type': 'string',
|
'type': 'string',
|
||||||
'title': 'Font Family',
|
'title': 'Font Family',
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
# The core already ships this widget and the config form already
|
# The core's font picker; without the hint the form renders a
|
||||||
# allowlists it; without the hint the field rendered as a bare
|
# bare text box the user has to type a filename into.
|
||||||
# text box the user had to type a filename into.
|
|
||||||
'x-widget': 'font-selector',
|
'x-widget': 'font-selector',
|
||||||
}
|
}
|
||||||
# A bitmap font ignores font_size and renders at its own baked-in
|
# 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
|
# size, so the size ceiling has to be enforced when picking the
|
||||||
# font, not when setting the size.
|
# font, not when setting the size.
|
||||||
max_size = (size_spec or {}).get('max') if isinstance(
|
max_size = size_spec.get('max') if size_spec else None
|
||||||
spec.get('size'), dict) else None
|
|
||||||
if isinstance(max_size, (int, float)):
|
if isinstance(max_size, (int, float)):
|
||||||
font_prop['x-options'] = {'maxFixedSize': max_size}
|
font_prop['x-options'] = {'maxFixedSize': max_size}
|
||||||
if 'default' in font_spec:
|
if 'default' in font_spec:
|
||||||
@@ -492,8 +466,8 @@ def _offset_block_from_spec(element_key: str,
|
|||||||
'title': 'Scale',
|
'title': 'Scale',
|
||||||
'description': 'Size multiplier; 1 is the shipped size.',
|
'description': 'Size multiplier; 1 is the shipped size.',
|
||||||
'default': 1.0,
|
'default': 1.0,
|
||||||
'minimum': 0.1,
|
'minimum': MIN_ELEMENT_SCALE,
|
||||||
'maximum': 10.0,
|
'maximum': MAX_ELEMENT_SCALE,
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
}
|
}
|
||||||
if isinstance(scale_spec, dict):
|
if isinstance(scale_spec, dict):
|
||||||
@@ -574,7 +548,7 @@ def _modes_block(declaration: Dict[str, Any],
|
|||||||
if layout_props:
|
if layout_props:
|
||||||
element_props['layout'] = {
|
element_props['layout'] = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'title': 'Layout Offsets',
|
'title': _LAYOUT_TITLE,
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
'additionalProperties': False,
|
'additionalProperties': False,
|
||||||
'properties': layout_props,
|
'properties': layout_props,
|
||||||
@@ -715,7 +689,7 @@ def _modes_block_from_properties(props: Dict[str, Any], element_keys: list,
|
|||||||
if layout_props:
|
if layout_props:
|
||||||
element_props['layout'] = {
|
element_props['layout'] = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'title': 'Layout Offsets',
|
'title': _LAYOUT_TITLE,
|
||||||
'x-advanced': True,
|
'x-advanced': True,
|
||||||
'additionalProperties': False,
|
'additionalProperties': False,
|
||||||
'properties': layout_props,
|
'properties': layout_props,
|
||||||
@@ -1045,12 +1019,15 @@ def _coerce_align(value: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _coerce_scale(value: Any, default: float) -> float:
|
def coerce_scale(value: Any, default: float = 1.0) -> float:
|
||||||
"""A positive size multiplier, or ``default``.
|
"""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
|
``default`` is returned for anything that is not a finite positive number
|
||||||
a zero-or-inverted image, and the panel is 32 pixels tall -- a typo
|
(None, a bool, a string, 0, a negative, NaN, infinity): those are typos,
|
||||||
should cost a wrong size, not a crash inside PIL.
|
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:
|
if isinstance(value, bool) or value is None:
|
||||||
return default
|
return default
|
||||||
@@ -1058,9 +1035,9 @@ def _coerce_scale(value: Any, default: float) -> float:
|
|||||||
scale = float(value)
|
scale = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
if scale <= 0:
|
if not math.isfinite(scale) or scale <= 0:
|
||||||
return default
|
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,
|
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:
|
try:
|
||||||
value = _element_field(config, element_key, 'scale', mode,
|
value = _element_field(config, element_key, 'scale', mode,
|
||||||
in_layout=True)
|
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:
|
except Exception as e:
|
||||||
logger.warning("Error reading scale for %s: %s", element_key, e)
|
logger.warning("Error reading scale for %s: %s", element_key, e)
|
||||||
return default
|
return default
|
||||||
@@ -1406,12 +1383,6 @@ class ElementStyleResolver:
|
|||||||
return {}
|
return {}
|
||||||
return _lookup_element(block.get('layout'), element_key)
|
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 -----------------------------------------------
|
# -- resolution internals -----------------------------------------------
|
||||||
|
|
||||||
@@ -1532,7 +1503,7 @@ class ElementStyleResolver:
|
|||||||
user_forced_color=bool(color_forced),
|
user_forced_color=bool(color_forced),
|
||||||
visible=_coerce_bool(visible, True),
|
visible=_coerce_bool(visible, True),
|
||||||
align=_coerce_align(align),
|
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,
|
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
|
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
|
@dataclass
|
||||||
class ErrorRecord:
|
class ErrorRecord:
|
||||||
"""Record of a single error occurrence."""
|
"""Record of a single error occurrence."""
|
||||||
@@ -145,8 +158,8 @@ class ErrorAggregator:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
error_type = type(error).__name__
|
error_type = type(error).__name__
|
||||||
|
|
||||||
# Extract additional context from LEDMatrixError subclasses
|
# A copy, so the caller's dict is not changed behind its back.
|
||||||
error_context = context or {}
|
error_context = dict(context) if context else {}
|
||||||
if isinstance(error, LEDMatrixError) and error.context:
|
if isinstance(error, LEDMatrixError) and error.context:
|
||||||
error_context.update(error.context)
|
error_context.update(error.context)
|
||||||
|
|
||||||
@@ -157,7 +170,7 @@ class ErrorAggregator:
|
|||||||
context=error_context,
|
context=error_context,
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=_format_trace(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add record (with size limit)
|
# Add record (with size limit)
|
||||||
|
|||||||
+63
-119
@@ -38,7 +38,13 @@ import time
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import ImageFont
|
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.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 typing import Dict, Tuple, Optional, Union, Any, List
|
||||||
from src.deprecation import deprecated
|
from src.deprecation import deprecated
|
||||||
|
|
||||||
@@ -56,7 +62,6 @@ class FontManager:
|
|||||||
|
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.fonts_config = config.get("fonts", {})
|
|
||||||
|
|
||||||
# Font discovery and catalog
|
# Font discovery and catalog
|
||||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||||
@@ -72,10 +77,8 @@ class FontManager:
|
|||||||
# Plugin font management
|
# Plugin font management
|
||||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
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.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.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}
|
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
|
# 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 = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||||
self.temp_font_dir.mkdir(exist_ok=True)
|
self.temp_font_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
# Performance monitoring
|
# Counters behind get_performance_stats().
|
||||||
self.performance_stats = {
|
self.performance_stats = {
|
||||||
"font_load_times": {},
|
|
||||||
"cache_hits": 0,
|
"cache_hits": 0,
|
||||||
"cache_misses": 0,
|
"cache_misses": 0,
|
||||||
"render_times": {},
|
|
||||||
"total_renders": 0,
|
|
||||||
"failed_loads": 0,
|
"failed_loads": 0,
|
||||||
"start_time": time.time()
|
"start_time": time.time()
|
||||||
}
|
}
|
||||||
@@ -104,9 +104,6 @@ class FontManager:
|
|||||||
"four_by_six": "assets/fonts/4x6-font.ttf",
|
"four_by_six": "assets/fonts/4x6-font.ttf",
|
||||||
"five_by_seven": "assets/fonts/5x7.bdf",
|
"five_by_seven": "assets/fonts/5x7.bdf",
|
||||||
"tom_thumb": "assets/fonts/tom-thumb.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
|
# Size tokens for convenience
|
||||||
@@ -115,7 +112,10 @@ class FontManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Font overrides storage (for manual overrides)
|
# 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]] = {}
|
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
# Bumped whenever cached font objects are invalidated, so holders of
|
# 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]):
|
def reload_config(self, new_config: Dict[str, Any]):
|
||||||
"""Reload configuration and refresh font catalog."""
|
"""Reload configuration and refresh font catalog."""
|
||||||
self.config = new_config
|
self.config = new_config
|
||||||
self.fonts_config = new_config.get("fonts", {})
|
|
||||||
self.font_cache.clear() # Clear cache to force reload
|
self.font_cache.clear() # Clear cache to force reload
|
||||||
self.metrics_cache.clear() # Clear metrics cache
|
self.metrics_cache.clear() # Clear metrics cache
|
||||||
self.cache_generation += 1
|
self.cache_generation += 1
|
||||||
@@ -135,7 +134,6 @@ class FontManager:
|
|||||||
logger.info("FontManager configuration reloaded successfully")
|
logger.info("FontManager configuration reloaded successfully")
|
||||||
|
|
||||||
# ==================== Manager Font Registration ====================
|
# ==================== Manager Font Registration ====================
|
||||||
# NEW: Support for managers to register their font choices dynamically
|
|
||||||
|
|
||||||
def register_manager_font(self, manager_id: str, element_key: str,
|
def register_manager_font(self, manager_id: str, element_key: str,
|
||||||
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
|
family: str, size_px: int, color: Optional[Tuple[int, int, int]] = None):
|
||||||
@@ -208,16 +206,23 @@ class FontManager:
|
|||||||
|
|
||||||
# ==================== Plugin Font Management ====================
|
# ==================== 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.
|
Register fonts for a specific plugin.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Unique identifier for the plugin
|
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:
|
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:
|
try:
|
||||||
# Validate font manifest structure
|
# Validate font manifest structure
|
||||||
@@ -234,7 +239,7 @@ class FontManager:
|
|||||||
# Process font definitions
|
# Process font definitions
|
||||||
fonts = font_manifest.get("fonts", [])
|
fonts = font_manifest.get("fonts", [])
|
||||||
for font_def in 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"Successfully registered font {font_def.get('family')} for plugin {plugin_id}")
|
||||||
|
|
||||||
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
|
logger.info(f"Registered {len(fonts)} fonts for plugin {plugin_id}")
|
||||||
@@ -269,7 +274,8 @@ class FontManager:
|
|||||||
|
|
||||||
return True
|
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."""
|
"""Register a single font from a plugin."""
|
||||||
try:
|
try:
|
||||||
family = font_def["family"]
|
family = font_def["family"]
|
||||||
@@ -283,7 +289,7 @@ class FontManager:
|
|||||||
elif source.startswith("plugin://"):
|
elif source.startswith("plugin://"):
|
||||||
# Relative to plugin directory
|
# Relative to plugin directory
|
||||||
relative_path = source.replace("plugin://", "")
|
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:
|
else:
|
||||||
# Absolute or relative path
|
# Absolute or relative path
|
||||||
font_path = source
|
font_path = source
|
||||||
@@ -297,14 +303,6 @@ class FontManager:
|
|||||||
self.plugin_font_catalogs[plugin_id][family] = font_path
|
self.plugin_font_catalogs[plugin_id][family] = font_path
|
||||||
self.font_catalog[namespaced_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}")
|
logger.info(f"Registered plugin font: {namespaced_family} -> {font_path}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -366,11 +364,15 @@ class FontManager:
|
|||||||
return '.zip'
|
return '.zip'
|
||||||
return '.ttf' # default
|
return '.ttf' # default
|
||||||
|
|
||||||
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str) -> Optional[str]:
|
def _resolve_plugin_font_path(self, plugin_id: str, relative_path: str,
|
||||||
"""Resolve a plugin-relative font path."""
|
plugin_dir: Optional[Union[str, Path]] = None) -> Optional[str]:
|
||||||
# Assume plugins are in a 'plugins' directory
|
"""Resolve a ``plugin://`` font path against the plugin's directory."""
|
||||||
plugin_dir = Path("plugins") / plugin_id
|
if plugin_dir is None:
|
||||||
font_path = plugin_dir / relative_path
|
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():
|
if font_path.exists():
|
||||||
return str(font_path)
|
return str(font_path)
|
||||||
@@ -378,6 +380,18 @@ class FontManager:
|
|||||||
logger.error(f"Plugin font not found: {font_path}")
|
logger.error(f"Plugin font not found: {font_path}")
|
||||||
return None
|
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")
|
@deprecated("3.7.0")
|
||||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||||
"""Unregister all fonts for a plugin."""
|
"""Unregister all fonts for a plugin."""
|
||||||
@@ -389,8 +403,6 @@ class FontManager:
|
|||||||
namespaced_family = f"{plugin_id}::{family}"
|
namespaced_family = f"{plugin_id}::{family}"
|
||||||
if namespaced_family in self.font_catalog:
|
if namespaced_family in self.font_catalog:
|
||||||
del self.font_catalog[namespaced_family]
|
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]
|
del self.plugin_font_catalogs[plugin_id]
|
||||||
|
|
||||||
@@ -441,8 +453,6 @@ class FontManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Resolved font object
|
Resolved font object
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check for manual overrides first
|
# Check for manual overrides first
|
||||||
if element_key in self.font_overrides:
|
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]:
|
if plugin_id in self.plugin_font_catalogs and family in self.plugin_font_catalogs[plugin_id]:
|
||||||
family = f"{plugin_id}::{family}"
|
family = f"{plugin_id}::{family}"
|
||||||
|
|
||||||
# Get the font
|
return self.get_font(family, size_px)
|
||||||
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
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error resolving font for {element_key}: {e}", exc_info=True)
|
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]
|
return self.font_cache[cache_key]
|
||||||
|
|
||||||
self.performance_stats["cache_misses"] += 1
|
self.performance_stats["cache_misses"] += 1
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Load font
|
# Load font
|
||||||
font_path = self.font_catalog.get(family)
|
font_path = self.font_catalog.get(family)
|
||||||
@@ -505,44 +507,23 @@ class FontManager:
|
|||||||
else:
|
else:
|
||||||
font = load_truetype(font_path, size_px)
|
font = load_truetype(font_path, size_px)
|
||||||
except Exception as e:
|
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}")
|
logger.error(f"Error loading font {font_path}: {e}")
|
||||||
self.performance_stats["failed_loads"] += 1
|
self.performance_stats["failed_loads"] += 1
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
# Cache and record performance
|
|
||||||
self.font_cache[cache_key] = font
|
self.font_cache[cache_key] = font
|
||||||
duration = time.time() - start_time
|
|
||||||
self.performance_stats["font_load_times"][cache_key] = duration
|
|
||||||
|
|
||||||
return font
|
return font
|
||||||
|
|
||||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||||
"""Load a BDF font using FreeType."""
|
"""Load a BDF font through the shared loader.
|
||||||
try:
|
|
||||||
native_size = self._read_bdf_native_size(font_path)
|
A size the file has no strike for comes back at the native strike
|
||||||
if native_size is not None and native_size != size_px:
|
rather than failing over to PIL's default font, a different typeface
|
||||||
# BDF is a fixed-strike bitmap format: FreeType renders the
|
(see :func:`src.common.bdf_font.load_bdf_face`).
|
||||||
# native size no matter what set_char_size asks for.
|
"""
|
||||||
logger.debug(
|
return load_bdf_face(font_path, size_px)[0]
|
||||||
"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
|
|
||||||
|
|
||||||
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||||
"""The one true pixel size of a BDF family in the catalog, or None
|
"""The one true pixel size of a BDF family in the catalog, or None
|
||||||
@@ -554,30 +535,9 @@ class FontManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
||||||
"""Read a BDF file's own header to find its one true pixel size.
|
"""A BDF file's one true pixel size; see
|
||||||
Prefers the PIXEL_SIZE property, which states the real pixel height
|
:func:`src.common.bdf_font.read_bdf_native_size`."""
|
||||||
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE
|
return read_bdf_native_size(bdf_path)
|
||||||
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
|
|
||||||
|
|
||||||
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
||||||
"""Get a fallback font when loading fails."""
|
"""Get a fallback font when loading fails."""
|
||||||
@@ -758,11 +718,6 @@ class FontManager:
|
|||||||
def _save_overrides(self):
|
def _save_overrides(self):
|
||||||
"""Save current font overrides to file."""
|
"""Save current font overrides to file."""
|
||||||
try:
|
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)
|
font_overrides_path = Path(self.font_overrides_file)
|
||||||
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
||||||
with open(self.font_overrides_file, 'w') as f:
|
with open(self.font_overrides_file, 'w') as f:
|
||||||
@@ -789,12 +744,6 @@ class FontManager:
|
|||||||
"""Get available size tokens."""
|
"""Get available size tokens."""
|
||||||
return self.size_tokens.copy()
|
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")
|
@deprecated("3.7.0")
|
||||||
def get_performance_stats(self) -> Dict[str, Any]:
|
def get_performance_stats(self) -> Dict[str, Any]:
|
||||||
"""Get performance statistics."""
|
"""Get performance statistics."""
|
||||||
@@ -824,7 +773,8 @@ class FontManager:
|
|||||||
|
|
||||||
@deprecated("3.7.0")
|
@deprecated("3.7.0")
|
||||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
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:
|
try:
|
||||||
# Validate font file
|
# Validate font file
|
||||||
if not os.path.exists(font_file_path):
|
if not os.path.exists(font_file_path):
|
||||||
@@ -836,13 +786,7 @@ class FontManager:
|
|||||||
logger.warning(f"Font family '{family_name}' already exists")
|
logger.warning(f"Font family '{family_name}' already exists")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Copy font to assets/fonts directory
|
fonts_dir = Path(resolve_asset_path("assets/fonts"))
|
||||||
from pathlib import Path
|
|
||||||
from src.common.permission_utils import (
|
|
||||||
ensure_directory_permissions,
|
|
||||||
get_assets_dir_mode
|
|
||||||
)
|
|
||||||
fonts_dir = Path("assets/fonts")
|
|
||||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||||
|
|
||||||
# Add to catalog
|
# Add to catalog
|
||||||
|
|||||||
+49
-72
@@ -16,7 +16,7 @@ import json
|
|||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
|
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 PIL.PngImagePlugin import PngInfo
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
@@ -370,22 +370,15 @@ class LogoDownloader:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_logo_filename_variations(abbreviation: str) -> list:
|
def get_logo_filename_variations(abbreviation: str) -> list:
|
||||||
"""Get possible filename variations for a team abbreviation."""
|
"""Filenames a logo for ``abbreviation`` may be stored under: the
|
||||||
variations = []
|
upper-cased abbreviation as given, then its normalize_abbreviation()
|
||||||
|
form (``TA&M.png``, then ``TAANDM.png``)."""
|
||||||
original = abbreviation.upper()
|
original = abbreviation.upper()
|
||||||
normalized = LogoDownloader.normalize_abbreviation(abbreviation)
|
normalized = LogoDownloader.normalize_abbreviation(abbreviation)
|
||||||
|
return [f"{original}.png", f"{normalized}.png"]
|
||||||
|
|
||||||
# Add original and normalized versions
|
# Allowlist for a league name or code that goes into a filesystem path or
|
||||||
variations.extend([f"{original}.png", f"{normalized}.png"])
|
# an ESPN URL: lower-case alphanumerics, underscores and dashes only.
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
|
|
||||||
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
|
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
|
||||||
|
|
||||||
def get_logo_directory(self, league: str) -> str:
|
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}")
|
logger.error(f"Unexpected error downloading logo for {team_abbreviation}: {e}")
|
||||||
return False
|
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]:
|
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."""
|
"""Resolve the ESPN API teams URL for a league, with dynamic fallback for custom soccer leagues."""
|
||||||
api_url = self.API_ENDPOINTS.get(league)
|
api_url = self.API_ENDPOINTS.get(league)
|
||||||
if not api_url and league.startswith('soccer_'):
|
if not api_url and league.startswith('soccer_'):
|
||||||
league_code = league[len('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}")
|
logger.warning(f"Rejecting unsafe league_code for ESPN URL construction: {league_code!r}")
|
||||||
return None
|
return None
|
||||||
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
|
api_url = f'https://site.api.espn.com/apis/site/v2/sports/soccer/{league_code}/teams'
|
||||||
@@ -501,7 +491,8 @@ class LogoDownloader:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def fetch_single_team(self, league: str, team_id: str) -> Optional[Dict]:
|
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)
|
api_url = self._resolve_api_url(league)
|
||||||
if not api_url:
|
if not api_url:
|
||||||
logger.error(f"No API endpoint configured for league: {league}")
|
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}")
|
logger.error(f"Error fetching team data for {team_id} in {league}: {e}")
|
||||||
return None
|
return None
|
||||||
except json.JSONDecodeError as e:
|
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
|
return None
|
||||||
|
|
||||||
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
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
|
# Default to FBS for unknown conferences
|
||||||
return 'FBS'
|
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]:
|
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
||||||
"""Download missing logos for a specific league."""
|
"""Download missing logos for a specific league."""
|
||||||
logger.info(f"Starting logo download for league: {league}")
|
logger.info(f"Starting logo download for league: {league}")
|
||||||
@@ -794,7 +749,9 @@ class LogoDownloader:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
logo_url = data["team"]["logos"][0]["href"]
|
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
|
return False
|
||||||
# Download the logo
|
# Download the logo
|
||||||
success = self.download_logo(logo_url, logo_path, team_abbreviation)
|
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")
|
logger.info(f"Overall logo download results: {total_downloaded} downloaded, {total_failed} failed")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str) -> bool:
|
def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str,
|
||||||
"""Create a placeholder logo when real logo cannot be downloaded."""
|
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:
|
try:
|
||||||
# Ensure the logo directory exists
|
|
||||||
if not self.ensure_logo_directory(logo_dir):
|
if not self.ensure_logo_directory(logo_dir):
|
||||||
logger.error(f"Failed to create logo directory: {logo_dir}")
|
logger.error(f"Failed to create logo directory: {logo_dir}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
|
if filepath is None:
|
||||||
filepath = Path(logo_dir) / filename
|
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
|
||||||
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
# Create a simple placeholder logo
|
logo = Image.new('RGBA', PLACEHOLDER_SIZE, PLACEHOLDER_BG)
|
||||||
logo = Image.new('RGBA', (64, 64), (100, 100, 100, 255)) # Gray background
|
|
||||||
draw = ImageDraw.Draw(logo)
|
draw = ImageDraw.Draw(logo)
|
||||||
|
|
||||||
# Try to load a font, fallback to default
|
# Try to load a font, fallback to default
|
||||||
try:
|
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):
|
except (OSError, IOError):
|
||||||
try:
|
try:
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
@@ -855,8 +827,8 @@ class LogoDownloader:
|
|||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
text_width = bbox[2] - bbox[0]
|
text_width = bbox[2] - bbox[0]
|
||||||
text_height = bbox[3] - bbox[1]
|
text_height = bbox[3] - bbox[1]
|
||||||
x = (64 - text_width) // 2
|
x = (PLACEHOLDER_SIZE[0] - text_width) // 2
|
||||||
y = (64 - text_height) // 2
|
y = (PLACEHOLDER_SIZE[1] - text_height) // 2
|
||||||
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
||||||
else:
|
else:
|
||||||
# Fallback without font
|
# 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.
|
Convenience function to download a missing team logo.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M')
|
|
||||||
league: League identifier (e.g., 'ncaa_fb', 'nfl')
|
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
|
logo_url: Optional direct URL to the logo
|
||||||
create_placeholder: Whether to create a placeholder if download fails
|
create_placeholder: Whether to create a placeholder if download fails
|
||||||
|
|
||||||
Returns:
|
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()
|
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
|
time.sleep(0.1) # Small delay
|
||||||
if not success and create_placeholder:
|
if not success and create_placeholder:
|
||||||
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
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
|
return success
|
||||||
|
|
||||||
success = downloader.download_missing_logo_for_team(league, team_id, team_abbreviation, logo_path)
|
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:
|
if not success and create_placeholder:
|
||||||
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
logger.info(f"Creating placeholder logo for {team_abbreviation}")
|
||||||
# Create placeholder as fallback
|
# 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:
|
if success:
|
||||||
logger.info(f"Successfully handled logo for {team_abbreviation}")
|
logger.info(f"Successfully handled logo for {team_abbreviation}")
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ Stability: Stable - maintains backward compatibility
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
@@ -511,104 +510,53 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
Get the display duration for this plugin instance.
|
Get the display duration for this plugin instance.
|
||||||
|
|
||||||
Automatically detects duration from:
|
Uses, in order, the first positive number among:
|
||||||
1. self.display_duration instance variable (if exists)
|
1. ``self.display_duration`` (a common pattern in scoreboard plugins)
|
||||||
2. self.config.get("display_duration", 15.0) (fallback)
|
2. ``self.config["display_duration"]``
|
||||||
|
3. 15.0
|
||||||
|
|
||||||
Can be overridden by plugins to provide dynamic durations based
|
Numeric strings count as numbers. Can be overridden by plugins to
|
||||||
on content (e.g., longer duration for more complex displays).
|
provide dynamic durations based on content (e.g., longer duration for
|
||||||
|
more complex displays).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Duration in seconds to display this plugin's content
|
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:
|
try:
|
||||||
# Ensure config value is also a valid float (bool excluded — an
|
duration = getattr(self, 'display_duration', None)
|
||||||
# int subclass that would otherwise read True as 1 second)
|
except (TypeError, ValueError, AttributeError) as e:
|
||||||
if isinstance(config_duration, (int, float)) and not isinstance(config_duration, bool):
|
# A plugin may define display_duration as a property that raises.
|
||||||
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:
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Error processing config display_duration: %s, using default 15.0",
|
"Error reading display_duration instance variable: %s, using config fallback", e)
|
||||||
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
|
# Dynamic duration support hooks
|
||||||
@@ -926,25 +874,20 @@ class BasePlugin(ABC):
|
|||||||
config_mode, self.plugin_id
|
config_mode, self.plugin_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fall back to mapping legacy content_type
|
# Fall back to mapping legacy content_type. 'none' (excluded from
|
||||||
content_type = self.get_vegas_content_type()
|
# Vegas) also maps to FIXED_SEGMENT: exclusion is decided by checking
|
||||||
if content_type == 'multi':
|
# get_vegas_content_type() separately.
|
||||||
|
if self.get_vegas_content_type() == 'multi':
|
||||||
return VegasDisplayMode.SCROLL
|
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
|
return VegasDisplayMode.FIXED_SEGMENT
|
||||||
|
|
||||||
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:
|
||||||
"""
|
"""
|
||||||
Return list of Vegas display modes this plugin supports.
|
Return list of Vegas display modes this plugin supports.
|
||||||
|
|
||||||
Used by the web UI to show available mode options for user configuration.
|
Not currently consulted by core: neither Vegas mode nor the web UI
|
||||||
Override to customize which modes are available for this plugin.
|
calls it. It is kept, and plugins override it, as the declared set of
|
||||||
|
modes a future mode picker would offer.
|
||||||
|
|
||||||
By default:
|
By default:
|
||||||
- 'multi' content type plugins support SCROLL and FIXED_SEGMENT
|
- '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.
|
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
|
Returns the number of panels this plugin should occupy when displayed
|
||||||
as a fixed segment. The actual pixel width is calculated as:
|
as a fixed segment. The actual pixel width is calculated as:
|
||||||
width = panels * single_panel_width
|
width = panels * single_panel_width
|
||||||
@@ -1025,7 +972,7 @@ class BasePlugin(ABC):
|
|||||||
required_fields = ['api_key', 'city']
|
required_fields = ['api_key', 'city']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if field not in self.config:
|
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 False
|
||||||
return True
|
return True
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import threading
|
|||||||
import queue
|
import queue
|
||||||
from typing import Dict, Optional, List, Callable, Any
|
from typing import Dict, Optional, List, Callable, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
from src.plugin_system.operation_types import (
|
from src.plugin_system.operation_types import (
|
||||||
PluginOperation, OperationType, OperationStatus
|
PluginOperation, OperationType, OperationStatus
|
||||||
@@ -28,28 +26,22 @@ class PluginOperationQueue:
|
|||||||
- Prevents concurrent operations on same plugin
|
- Prevents concurrent operations on same plugin
|
||||||
- Operation status tracking
|
- Operation status tracking
|
||||||
- Operation cancellation
|
- 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__(
|
def __init__(self, max_history: int = 100):
|
||||||
self,
|
|
||||||
history_file: Optional[str] = None,
|
|
||||||
max_history: int = 100,
|
|
||||||
lazy_load: bool = False
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Initialize operation queue.
|
Initialize operation queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
history_file: Optional path to file for persisting operation history
|
|
||||||
max_history: Maximum number of operations to keep in 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.logger = get_logger(__name__)
|
||||||
self.history_file = Path(history_file) if history_file else None
|
|
||||||
self.max_history = max_history
|
self.max_history = max_history
|
||||||
self._lazy_load = lazy_load
|
|
||||||
self._history_loaded = False
|
|
||||||
|
|
||||||
# Operation tracking
|
# Operation tracking
|
||||||
self._operations: Dict[str, PluginOperation] = {}
|
self._operations: Dict[str, PluginOperation] = {}
|
||||||
@@ -62,20 +54,8 @@ class PluginOperationQueue:
|
|||||||
self._worker_thread: Optional[threading.Thread] = None
|
self._worker_thread: Optional[threading.Thread] = None
|
||||||
self._stop_event = threading.Event()
|
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()
|
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(
|
def enqueue_operation(
|
||||||
self,
|
self,
|
||||||
operation_type: OperationType,
|
operation_type: OperationType,
|
||||||
@@ -139,7 +119,6 @@ class PluginOperationQueue:
|
|||||||
Returns:
|
Returns:
|
||||||
PluginOperation if found, None otherwise
|
PluginOperation if found, None otherwise
|
||||||
"""
|
"""
|
||||||
self._ensure_loaded()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._operations.get(operation_id)
|
return self._operations.get(operation_id)
|
||||||
|
|
||||||
@@ -184,7 +163,6 @@ class PluginOperationQueue:
|
|||||||
Returns:
|
Returns:
|
||||||
List of operations, sorted by creation time (newest first)
|
List of operations, sorted by creation time (newest first)
|
||||||
"""
|
"""
|
||||||
self._ensure_loaded()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Sort by creation time (newest first)
|
# Sort by creation time (newest first)
|
||||||
history = sorted(
|
history = sorted(
|
||||||
@@ -314,12 +292,8 @@ class PluginOperationQueue:
|
|||||||
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
if self._active_operations[operation.plugin_id].operation_id == operation.operation_id:
|
||||||
del self._active_operations[operation.plugin_id]
|
del self._active_operations[operation.plugin_id]
|
||||||
|
|
||||||
# Add to history
|
|
||||||
self._add_to_history(operation)
|
self._add_to_history(operation)
|
||||||
|
|
||||||
# Save history to file
|
|
||||||
self._save_history()
|
|
||||||
|
|
||||||
def _add_to_history(self, operation: PluginOperation) -> None:
|
def _add_to_history(self, operation: PluginOperation) -> None:
|
||||||
"""Add operation to history, maintaining max_history limit."""
|
"""Add operation to history, maintaining max_history limit."""
|
||||||
self._operation_history.append(operation)
|
self._operation_history.append(operation)
|
||||||
@@ -330,46 +304,6 @@ class PluginOperationQueue:
|
|||||||
self._operation_history.sort(key=lambda op: op.created_at)
|
self._operation_history.sort(key=lambda op: op.created_at)
|
||||||
self._operation_history = self._operation_history[-self.max_history:]
|
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:
|
def shutdown(self) -> None:
|
||||||
"""Shutdown the operation queue and worker thread."""
|
"""Shutdown the operation queue and worker thread."""
|
||||||
self.logger.info("Shutting down plugin operation queue")
|
self.logger.info("Shutting down plugin operation queue")
|
||||||
@@ -378,6 +312,3 @@ class PluginOperationQueue:
|
|||||||
if self._worker_thread and self._worker_thread.is_alive():
|
if self._worker_thread and self._worker_thread.is_alive():
|
||||||
self._worker_thread.join(timeout=5.0)
|
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']:
|
if result_container['exception']:
|
||||||
error = result_container['exception']
|
error = result_container['exception']
|
||||||
error_msg = f"{plugin_context} operation failed: {error}"
|
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")
|
record_error(error, plugin_id=plugin_id, operation="execute")
|
||||||
raise PluginError(error_msg, plugin_id=plugin_id) from error
|
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.
|
Extracted from PluginManager to improve separation of concerns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -21,6 +21,7 @@ from packaging.requirements import InvalidRequirement, Requirement
|
|||||||
|
|
||||||
from src.exceptions import PluginError
|
from src.exceptions import PluginError
|
||||||
from src.logging_config import get_logger
|
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:
|
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
|
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:
|
class PluginLoader:
|
||||||
"""Handles plugin module loading and class instantiation."""
|
"""Handles plugin module loading and class instantiation."""
|
||||||
|
|
||||||
@@ -215,11 +256,12 @@ class PluginLoader:
|
|||||||
"""
|
"""
|
||||||
Find the plugin directory for a given plugin ID.
|
Find the plugin directory for a given plugin ID.
|
||||||
|
|
||||||
Tries multiple strategies:
|
1. The discovery mapping, when it has the id and the path exists.
|
||||||
1. Use plugin_directories mapping if available
|
2. ``plugins_dir`` only, by the shared rules in
|
||||||
2. Direct path matching
|
``src/plugin_system/plugin_dirs.py``: a directory whose manifest
|
||||||
3. Case-insensitive directory matching
|
declares the id wins; otherwise ``<id>`` or ``ledmatrix-<id>``,
|
||||||
4. Manifest-based search
|
matched case-insensitively. Backup and hidden directories are
|
||||||
|
never matched.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
@@ -227,13 +269,9 @@ class PluginLoader:
|
|||||||
plugin_directories: Optional mapping of plugin_id to directory
|
plugin_directories: Optional mapping of plugin_id to directory
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to plugin directory or None if not found
|
Path to plugin directory or None if not found. An id that is not
|
||||||
|
one plain path segment finds nothing.
|
||||||
"""
|
"""
|
||||||
# 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
|
|
||||||
|
|
||||||
# Strategy 1: Use mapping from discovery
|
# Strategy 1: Use mapping from discovery
|
||||||
if plugin_directories and plugin_id in plugin_directories:
|
if plugin_directories and plugin_id in plugin_directories:
|
||||||
plugin_dir = plugin_directories[plugin_id]
|
plugin_dir = plugin_directories[plugin_id]
|
||||||
@@ -241,58 +279,12 @@ class PluginLoader:
|
|||||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||||
return plugin_dir
|
return plugin_dir
|
||||||
|
|
||||||
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
|
plugin_dir = resolve_plugin_dir(
|
||||||
plugins_dir_resolved = plugins_dir.resolve()
|
plugin_id, [plugins_dir], prefix=True, case_insensitive=True)
|
||||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
if plugin_dir is not None and plugin_dir.name != plugin_id:
|
||||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
self.logger.debug("Found plugin %s in directory %s",
|
||||||
try:
|
plugin_id, plugin_dir.name)
|
||||||
_candidate.relative_to(plugins_dir_resolved)
|
return plugin_dir
|
||||||
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
|
|
||||||
|
|
||||||
def install_dependencies(
|
def install_dependencies(
|
||||||
self,
|
self,
|
||||||
@@ -322,43 +314,15 @@ class PluginLoader:
|
|||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
safe_plugin_dir = contained_plugin_dir(plugin_dir, plugins_dir)
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
if safe_plugin_dir is None:
|
||||||
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:
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
requirements_file = requirements_to_install(safe_plugin_dir, self.logger, plugin_id)
|
||||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
if requirements_file is None:
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -397,8 +361,8 @@ class PluginLoader:
|
|||||||
# below).
|
# below).
|
||||||
try:
|
try:
|
||||||
# sys.executable is this process's own interpreter (not
|
# sys.executable is this process's own interpreter (not
|
||||||
# attacker-influenced), and requirements_file is a path
|
# attacker-influenced), and requirements_file is rebuilt
|
||||||
# built internally by find_plugin_directory, never raw
|
# by contained_plugin_dir() from a trusted listing, never raw
|
||||||
# external input.
|
# external input.
|
||||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||||
@@ -433,10 +397,10 @@ class PluginLoader:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
except (BrokenPipeError, OSError) as e:
|
except OSError as e:
|
||||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
# A broken pipe (EPIPE) happens when pip's output pipe closes
|
||||||
# Often caused by network interruptions or output buffer issues
|
# mid-download, usually a network interruption.
|
||||||
if isinstance(e, OSError) and e.errno == 32:
|
if e.errno == errno.EPIPE:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"Broken pipe error during dependency installation for %s. "
|
"Broken pipe error during dependency installation for %s. "
|
||||||
"This usually indicates a network interruption or pip output buffer issue. "
|
"This usually indicates a network interruption or pip output buffer issue. "
|
||||||
@@ -577,7 +541,7 @@ class PluginLoader:
|
|||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
entry_point: str
|
entry_point: str
|
||||||
) -> Optional[Any]:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Load a plugin module from file.
|
Load a plugin module from file.
|
||||||
|
|
||||||
@@ -596,7 +560,12 @@ class PluginLoader:
|
|||||||
entry_point: Entry point filename (e.g., 'manager.py')
|
entry_point: Entry point filename (e.g., 'manager.py')
|
||||||
|
|
||||||
Returns:
|
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 '')
|
plugin_id = os.path.basename(plugin_id or '')
|
||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
@@ -831,8 +800,6 @@ class PluginLoader:
|
|||||||
# Load module
|
# Load module
|
||||||
entry_point = manifest.get('entry_point', 'manager.py')
|
entry_point = manifest.get('entry_point', 'manager.py')
|
||||||
module = self.load_module(plugin_id, plugin_dir, entry_point)
|
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
|
# Get plugin class
|
||||||
class_name = manifest.get('class_name')
|
class_name = manifest.get('class_name')
|
||||||
|
|||||||
+106
-103
@@ -2,7 +2,8 @@
|
|||||||
Plugin Manager
|
Plugin Manager
|
||||||
|
|
||||||
Manages plugin discovery, loading, and lifecycle for the LEDMatrix system.
|
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
|
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 (
|
from src.plugin_system.schema_manager import (
|
||||||
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
|
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.deprecation import deprecated
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
@@ -38,7 +41,7 @@ class PluginManager:
|
|||||||
Manages plugin discovery, loading, and lifecycle.
|
Manages plugin discovery, loading, and lifecycle.
|
||||||
|
|
||||||
The PluginManager is responsible for:
|
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
|
- Loading plugin modules and instantiating plugin classes
|
||||||
- Managing plugin lifecycle (load, unload, reload)
|
- Managing plugin lifecycle (load, unload, reload)
|
||||||
- Providing access to loaded plugins
|
- Providing access to loaded plugins
|
||||||
@@ -97,10 +100,9 @@ class PluginManager:
|
|||||||
self.plugin_directories: Dict[str, Path] = {}
|
self.plugin_directories: Dict[str, Path] = {}
|
||||||
self.plugin_last_update: Dict[str, float] = {}
|
self.plugin_last_update: Dict[str, float] = {}
|
||||||
|
|
||||||
# Cached data-fetch intervals per plugin_id.
|
# Cached static data-fetch intervals per plugin_id, so the render
|
||||||
# _get_plugin_update_interval falls back to config_manager.get_config()
|
# loop's scheduling tick does not repeat the manifest/config lookup
|
||||||
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
# for every plugin. Cleared on load/unload.
|
||||||
# that copy on every 30-fps tick. Cleared on load/unload.
|
|
||||||
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||||
|
|
||||||
# Health tracking (optional, set by display_controller if available)
|
# Health tracking (optional, set by display_controller if available)
|
||||||
@@ -108,14 +110,12 @@ class PluginManager:
|
|||||||
self.resource_monitor = None
|
self.resource_monitor = None
|
||||||
|
|
||||||
# --- Asynchronous plugin updates -------------------------------
|
# --- Asynchronous plugin updates -------------------------------
|
||||||
# update() used to run inline in the render loop (execute_update's
|
# Run inline in the render loop, one slow plugin HTTP fetch in
|
||||||
# internal thread.join(timeout=30) blocked it), so one slow plugin
|
# update() freezes scrolling for the whole fetch. Scheduling happens
|
||||||
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
|
# on the render thread (run_scheduled_updates); execution happens on
|
||||||
# happens on the render thread (run_scheduled_updates), but
|
# this single background worker. Per-plugin locks keep a plugin's
|
||||||
# execution moves to this single background worker. Per-plugin
|
# update() and display() mutually exclusive, including across the
|
||||||
# locks keep a plugin's update() and display() mutually exclusive —
|
# post-timeout window.
|
||||||
# today's implicit guarantee, now explicit (and, unlike today,
|
|
||||||
# also held across the post-timeout window).
|
|
||||||
# Kill switch: plugin_system.synchronous_updates: true restores the
|
# Kill switch: plugin_system.synchronous_updates: true restores the
|
||||||
# inline path.
|
# inline path.
|
||||||
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
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)
|
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
|
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]:
|
def _scan_directory_for_plugins(self, directory: Path) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Scan a directory for plugins.
|
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:
|
Args:
|
||||||
directory: Directory to scan
|
directory: Directory to scan
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of plugin IDs found
|
List of plugin IDs found
|
||||||
"""
|
"""
|
||||||
plugin_ids = []
|
|
||||||
|
|
||||||
if not directory.exists():
|
if not directory.exists():
|
||||||
return plugin_ids
|
return []
|
||||||
|
|
||||||
# Build new state locally before acquiring lock
|
# Build new state locally before acquiring lock
|
||||||
new_manifests: Dict[str, Dict[str, Any]] = {}
|
index = PluginDirectoryIndex.scan(directory)
|
||||||
new_directories: Dict[str, Path] = {}
|
if index.error is not None:
|
||||||
|
self.logger.error("Error scanning directory %s: %s", directory,
|
||||||
try:
|
index.error, exc_info=index.error)
|
||||||
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
|
|
||||||
|
|
||||||
|
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
|
# json.load accepts any JSON value, so a manifest holding
|
||||||
# null, [] or "text" parses and then raises AttributeError on
|
# null, [] or "text" parses. It once raised AttributeError on
|
||||||
# .get(). Nothing here catches that -- the outer handler takes
|
# .get() and aborted the whole scan, so every other plugin on
|
||||||
# OSError/PermissionError only -- so a single malformed
|
|
||||||
# manifest aborted the whole scan and every other plugin on
|
|
||||||
# disk, however healthy, silently failed to register.
|
# disk, however healthy, silently failed to register.
|
||||||
if not isinstance(manifest, dict):
|
self._report_skip_once(
|
||||||
if item.name not in self._skip_reported:
|
entry.name, "Skipping %s: its manifest.json is %s, not a "
|
||||||
self._skip_reported.add(item.name)
|
"JSON object", entry.name, type(entry.manifest).__name__)
|
||||||
self.logger.warning(
|
elif entry.status == ManifestStatus.NO_ID:
|
||||||
"Skipping %s: its manifest.json is %s, not a JSON "
|
# Parsed but unusable. This was the quietest path of all: the
|
||||||
"object", item.name, type(manifest).__name__)
|
# manifest is read successfully and then dropped.
|
||||||
continue
|
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')
|
plugins = index.plugins()
|
||||||
if not plugin_id:
|
for plugin_id, entries in index.duplicates().items():
|
||||||
# Parsed but unusable. This was the quietest path of all:
|
self._report_skip_once(
|
||||||
# the manifest is read successfully and then dropped.
|
"duplicate:" + plugin_id,
|
||||||
if item.name not in self._skip_reported:
|
"Plugin id %r is declared by %d directories (%s); using %s",
|
||||||
self._skip_reported.add(item.name)
|
plugin_id, len(entries), ", ".join(e.name for e in entries),
|
||||||
self.logger.warning(
|
plugins[plugin_id].name)
|
||||||
"Skipping %s: its manifest.json has no \"id\", so "
|
|
||||||
"there is nothing to register it under", item.name)
|
|
||||||
continue
|
|
||||||
|
|
||||||
plugin_ids.append(plugin_id)
|
new_manifests: Dict[str, Dict[str, Any]] = {
|
||||||
new_manifests[plugin_id] = manifest
|
plugin_id: entry.manifest for plugin_id, entry in plugins.items()}
|
||||||
new_directories[plugin_id] = item
|
new_directories: Dict[str, Path] = {
|
||||||
except (OSError, PermissionError) as e:
|
plugin_id: entry.path for plugin_id, entry in plugins.items()}
|
||||||
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
|
||||||
|
|
||||||
# Replace shared state under lock so uninstalled plugins don't linger
|
# Replace shared state under lock so uninstalled plugins don't linger
|
||||||
with self._discovery_lock:
|
with self._discovery_lock:
|
||||||
@@ -266,7 +265,7 @@ class PluginManager:
|
|||||||
self.plugin_directories.clear()
|
self.plugin_directories.clear()
|
||||||
self.plugin_directories.update(new_directories)
|
self.plugin_directories.update(new_directories)
|
||||||
|
|
||||||
return plugin_ids
|
return list(plugins)
|
||||||
|
|
||||||
def discover_plugins(self) -> List[str]:
|
def discover_plugins(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
@@ -395,7 +394,8 @@ class PluginManager:
|
|||||||
self.font_manager, 'register_plugin_fonts'
|
self.font_manager, 'register_plugin_fonts'
|
||||||
):
|
):
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Failed to register fonts for plugin %s: %s", plugin_id, e
|
"Failed to register fonts for plugin %s: %s", plugin_id, e
|
||||||
@@ -660,9 +660,15 @@ class PluginManager:
|
|||||||
if not self.unload_plugin(plugin_id):
|
if not self.unload_plugin(plugin_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Re-discover to get updated manifest
|
# Re-read the manifest so an edit to it takes effect, from the
|
||||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
# directory discovery found the plugin in: a directory's name need not
|
||||||
if manifest_path.exists():
|
# 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:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
@@ -772,24 +778,20 @@ class PluginManager:
|
|||||||
not one plain path segment (``..``, ``a/b``, an absolute path) is
|
not one plain path segment (``..``, ``a/b``, an absolute path) is
|
||||||
refused instead of being joined onto ``plugins_dir``. The join is not
|
refused instead of being joined onto ``plugins_dir``. The join is not
|
||||||
resolved further: dev plugins are symlinks into ``plugins_dir``.
|
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:
|
with self._discovery_lock:
|
||||||
if plugin_id in self.plugin_directories:
|
if plugin_id in self.plugin_directories:
|
||||||
return str(self.plugin_directories[plugin_id])
|
return str(self.plugin_directories[plugin_id])
|
||||||
|
|
||||||
plugin_id = safe_path_component(plugin_id)
|
plugin_dir = resolve_plugin_dir(
|
||||||
if plugin_id is None:
|
plugin_id, [self.plugins_dir], prefix=True, case_insensitive=False,
|
||||||
return None
|
by_manifest=False)
|
||||||
|
return str(plugin_dir) if plugin_dir is not None else 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
|
|
||||||
|
|
||||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
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
|
updating, since a scheduler that propagates a plugin bug stops every
|
||||||
other plugin too.
|
other plugin too.
|
||||||
|
|
||||||
The static result is cached per plugin_id after the first lookup to
|
The static result is cached per plugin_id after the first lookup, so
|
||||||
avoid calling config_manager.get_config() — which returns a full dict
|
the manifest/config resolution is not repeated on every scheduling
|
||||||
copy — on every tick of the 30-fps display loop. The cache is
|
tick of the display loop. A change to ``update_interval`` in
|
||||||
invalidated when a plugin is loaded or unloaded. The dynamic hook is
|
config.json therefore takes effect when the plugin is next loaded or
|
||||||
deliberately *not* cached: caching it would defeat its only purpose.
|
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)
|
dynamic = self._dynamic_update_interval(plugin_id, plugin_instance)
|
||||||
if dynamic is not None:
|
if dynamic is not None:
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class PluginStateManager:
|
|||||||
self._state_transition_counts: Dict[str, int] = {}
|
self._state_transition_counts: Dict[str, int] = {}
|
||||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||||
self._last_update: Dict[str, datetime] = {}
|
self._last_update: Dict[str, datetime] = {}
|
||||||
self._last_display: Dict[str, datetime] = {}
|
|
||||||
|
|
||||||
def _record_transition(self, plugin_id: str) -> None:
|
def _record_transition(self, plugin_id: str) -> None:
|
||||||
"""Count a state transition. Callers must already hold ``_lock``."""
|
"""Count a state transition. Callers must already hold ``_lock``."""
|
||||||
@@ -182,10 +181,6 @@ class PluginStateManager:
|
|||||||
"""Get timestamp of last update() call."""
|
"""Get timestamp of last update() call."""
|
||||||
return self._last_update.get(plugin_id)
|
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]:
|
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get comprehensive state information for a plugin.
|
Get comprehensive state information for a plugin.
|
||||||
@@ -212,7 +207,6 @@ class PluginStateManager:
|
|||||||
'is_error': self.is_error(plugin_id),
|
'is_error': self.is_error(plugin_id),
|
||||||
'can_execute': self.can_execute(plugin_id),
|
'can_execute': self.can_execute(plugin_id),
|
||||||
'last_update': self.get_last_update(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),
|
'error_info': self.get_error_info(plugin_id),
|
||||||
'state_history_count': self._state_transition_counts.get(plugin_id, 0)
|
'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._state_transition_counts.pop(plugin_id, None)
|
||||||
self._error_info.pop(plugin_id, None)
|
self._error_info.pop(plugin_id, None)
|
||||||
self._last_update.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
|
@dataclass
|
||||||
class ResourceMetrics:
|
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
|
memory_mb: float = 0.0
|
||||||
cpu_percent: float = 0.0
|
cpu_percent: float = 0.0
|
||||||
execution_time: float = 0.0
|
execution_time: float = 0.0
|
||||||
@@ -43,11 +50,6 @@ class ResourceMetrics:
|
|||||||
min_execution_time: float = float('inf')
|
min_execution_time: float = float('inf')
|
||||||
last_update_time: float = field(default_factory=time.time)
|
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.
|
#: How often a plugin's metrics are written to the cache, in seconds.
|
||||||
#:
|
#:
|
||||||
@@ -287,6 +289,7 @@ class PluginResourceMonitor:
|
|||||||
|
|
||||||
# Calculate execution time
|
# Calculate execution time
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
memory_growth_mb = 0.0
|
||||||
|
|
||||||
# Update metrics
|
# Update metrics
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -302,17 +305,17 @@ class PluginResourceMonitor:
|
|||||||
|
|
||||||
# Update memory and CPU if monitoring enabled
|
# Update memory and CPU if monitoring enabled
|
||||||
if self.enable_monitoring:
|
if self.enable_monitoring:
|
||||||
end_memory = self._get_process_memory_mb()
|
memory_growth_mb = self._get_process_memory_mb() - start_memory
|
||||||
metrics.memory_mb = max(metrics.memory_mb, end_memory - start_memory)
|
metrics.memory_mb = max(metrics.memory_mb, memory_growth_mb)
|
||||||
# CPU is harder to measure per-call, so we track it separately
|
# CPU is harder to measure per-call, so we track it separately
|
||||||
metrics.cpu_percent = self._get_process_cpu_percent()
|
metrics.cpu_percent = self._get_process_cpu_percent()
|
||||||
|
|
||||||
# Persist metrics, at most once per interval per plugin.
|
# Persist metrics, at most once per interval per plugin.
|
||||||
self._persist_metrics(plugin_id, metrics)
|
self._persist_metrics(plugin_id, metrics)
|
||||||
|
|
||||||
# Check limits
|
|
||||||
if 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
|
return result
|
||||||
|
|
||||||
@@ -327,8 +330,16 @@ class PluginResourceMonitor:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
def _check_limits(self, plugin_id: str, metrics: ResourceMetrics,
|
||||||
limits: ResourceLimits, execution_time: float) -> None:
|
limits: ResourceLimits, execution_time: float,
|
||||||
"""Check if plugin has exceeded resource limits."""
|
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 = []
|
warnings = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -343,13 +354,13 @@ class PluginResourceMonitor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check memory
|
# 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(
|
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(
|
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
|
# Check CPU
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
from src.plugin_system.repo_urls import normalize_repo_url
|
||||||
|
|
||||||
|
|
||||||
class SavedRepositoriesManager:
|
class SavedRepositoriesManager:
|
||||||
"""Manages saved GitHub repository URLs."""
|
"""Manages saved GitHub repository URLs."""
|
||||||
@@ -71,18 +73,6 @@ class SavedRepositoriesManager:
|
|||||||
pass
|
pass
|
||||||
return False
|
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]]:
|
def get_all(self) -> List[Dict[str, str]]:
|
||||||
"""Get all saved repositories."""
|
"""Get all saved repositories."""
|
||||||
return self.repositories.copy()
|
return self.repositories.copy()
|
||||||
@@ -98,7 +88,7 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if added successfully
|
True if added successfully
|
||||||
"""
|
"""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
for repo in self.repositories:
|
for repo in self.repositories:
|
||||||
@@ -138,7 +128,7 @@ class SavedRepositoriesManager:
|
|||||||
Returns:
|
Returns:
|
||||||
True if removed successfully
|
True if removed successfully
|
||||||
"""
|
"""
|
||||||
repo_url = self._clean_url(repo_url)
|
repo_url = normalize_repo_url(repo_url)
|
||||||
|
|
||||||
previous = self.repositories
|
previous = self.repositories
|
||||||
remaining = [r for r in previous if r.get('url') != repo_url]
|
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:
|
def has(self, repo_url: str) -> bool:
|
||||||
"""Check if a repository is already saved."""
|
"""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)
|
return any(r.get('url') == repo_url for r in self.repositories)
|
||||||
|
|
||||||
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
def get_registry_repositories(self) -> List[Dict[str, str]]:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import jsonschema
|
|||||||
from jsonschema import Draft7Validator, ValidationError
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
|
|
||||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
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:
|
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
||||||
@@ -452,11 +453,7 @@ class SchemaManager:
|
|||||||
# full per-element style blocks (font/size/color + layout
|
# full per-element style blocks (font/size/color + layout
|
||||||
# offsets) the web-UI config form renders. No-op for schemas
|
# offsets) the web-UI config form renders. No-op for schemas
|
||||||
# without the declaration; never raises.
|
# without the declaration; never raises.
|
||||||
try:
|
schema = expand_style_elements(schema)
|
||||||
from src.element_style import expand_style_elements
|
|
||||||
schema = expand_style_elements(schema)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Cache the schema
|
# Cache the schema
|
||||||
self._schema_cache[plugin_id] = schema
|
self._schema_cache[plugin_id] = schema
|
||||||
@@ -643,19 +640,11 @@ class SchemaManager:
|
|||||||
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
|
[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)
|
validator = Draft7Validator(enhanced_schema)
|
||||||
|
|
||||||
# Collect all validation errors
|
|
||||||
for error in validator.iter_errors(config):
|
for error in validator.iter_errors(config):
|
||||||
error_msg = self._format_validation_error(error, plugin_id)
|
errors.append(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}'")
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return False, errors
|
return False, errors
|
||||||
@@ -687,7 +676,15 @@ class SchemaManager:
|
|||||||
field_path = f"'{path}'" if path else "root"
|
field_path = f"'{path}'" if path else "root"
|
||||||
|
|
||||||
if error.validator == 'required':
|
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}'"
|
return f"Field {field_path}: Missing required property '{missing}'"
|
||||||
elif error.validator == 'type':
|
elif error.validator == 'type':
|
||||||
expected = error.validator_value
|
expected = error.validator_value
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class PluginState:
|
|||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
installed_at: Optional[datetime] = None
|
installed_at: Optional[datetime] = None
|
||||||
last_updated: 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
|
metadata: Dict[str, Any] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -100,6 +102,8 @@ class PluginStateManager:
|
|||||||
|
|
||||||
# State storage
|
# State storage
|
||||||
self._states: Dict[str, PluginState] = {}
|
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
|
self._state_version = 1
|
||||||
|
|
||||||
# Threading
|
# Threading
|
||||||
@@ -193,7 +197,6 @@ class PluginStateManager:
|
|||||||
current_state.metadata = {}
|
current_state.metadata = {}
|
||||||
current_state.metadata.update(updates['metadata'])
|
current_state.metadata.update(updates['metadata'])
|
||||||
|
|
||||||
# Increment config version
|
|
||||||
current_state.config_version += 1
|
current_state.config_version += 1
|
||||||
|
|
||||||
# Store updated state
|
# Store updated state
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
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.plugin_system.state_manager import PluginStateManager
|
||||||
from src.logging_config import get_logger
|
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]:
|
def disk_plugin_ids(plugins_dir) -> Set[str]:
|
||||||
"""Plugin ids actually installed on disk.
|
"""Plugin ids actually installed on disk.
|
||||||
|
|
||||||
A directory counts only when it is not a standalone backup and its
|
A directory counts only when it is not a standalone backup (or hidden)
|
||||||
manifest.json parses. A corrupt manifest must not read as installed, or a
|
and its manifest.json parses. A corrupt manifest must not read as
|
||||||
live "in config but not on disk" finding gets cleared on the strength of an
|
installed, or a live "in config but not on disk" finding gets cleared on
|
||||||
unreadable file.
|
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:
|
try:
|
||||||
if not root.exists():
|
return _disk_index(plugins_dir).installed_ids(require_parseable_manifest=True)
|
||||||
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)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
return ids
|
return set()
|
||||||
return ids
|
|
||||||
|
|
||||||
|
def _disk_index(plugins_dir) -> PluginDirectoryIndex:
|
||||||
|
return PluginDirectoryIndex.scan(Path(plugins_dir))
|
||||||
|
|
||||||
|
|
||||||
def still_unresolved(entries: List[Dict[str, Any]],
|
def still_unresolved(entries: List[Dict[str, Any]],
|
||||||
@@ -326,16 +321,15 @@ class StateReconciliation:
|
|||||||
"""Get plugin state from disk (installed plugins)."""
|
"""Get plugin state from disk (installed plugins)."""
|
||||||
state = {}
|
state = {}
|
||||||
try:
|
try:
|
||||||
# Membership comes from the shared extractor so the web interface
|
# Membership uses the same index and rule as disk_plugin_ids, so
|
||||||
# re-checks stored findings against this same definition; the
|
# the web interface re-checks stored findings against this same
|
||||||
# manifest is then re-read here only for version/name.
|
# definition; each manifest is read once, by the scan.
|
||||||
for plugin_id in disk_plugin_ids(self.plugins_dir):
|
index = _disk_index(self.plugins_dir)
|
||||||
manifest_path = self.plugins_dir / plugin_id / "manifest.json"
|
for plugin_id in index.installed_ids(require_parseable_manifest=True):
|
||||||
try:
|
entry = index.entry_for_installed_id(plugin_id)
|
||||||
with open(manifest_path, 'r') as f:
|
manifest = entry.manifest if entry is not None else None
|
||||||
manifest = json.load(f)
|
if not isinstance(manifest, dict):
|
||||||
except (OSError, ValueError): # nosec B112 - raced or corrupt; skip
|
manifest = {}
|
||||||
continue
|
|
||||||
state[plugin_id] = {
|
state[plugin_id] = {
|
||||||
'exists_on_disk': True,
|
'exists_on_disk': True,
|
||||||
'version': manifest.get('version'),
|
'version': manifest.get('version'),
|
||||||
|
|||||||
+345
-513
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user