mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 11:59:13 +00:00
Compare commits
46
Commits
c5281d9a45
...
853b5aa618
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853b5aa618 | ||
|
|
8ad9d191a7 | ||
|
|
7f9c73e9aa | ||
|
|
86c27970ef | ||
|
|
ab39552cdb | ||
|
|
83fd3491c7 | ||
|
|
37203e635c | ||
|
|
695be34a1b | ||
|
|
ffdd0efd43 | ||
|
|
cfcdbfb18a | ||
|
|
4c17e18786 | ||
|
|
b9416ef803 | ||
|
|
764fc6fcdb | ||
|
|
f6afbdbb15 | ||
|
|
99a3608516 | ||
|
|
b1510c209d | ||
|
|
48a433328a | ||
|
|
edfcd9e2a1 | ||
|
|
3a81f38f09 | ||
|
|
7b90759252 | ||
|
|
b11bcfa204 | ||
|
|
3967a6cffc | ||
|
|
74ba36a059 | ||
|
|
d37a3a712a | ||
|
|
dc26baa134 | ||
|
|
6aef54598b | ||
|
|
7b16e953d2 | ||
|
|
52bc520335 | ||
|
|
4e61d7248a | ||
|
|
ece416c4e5 | ||
|
|
13bbb537f3 | ||
|
|
afe9001aed | ||
|
|
abedc46104 | ||
|
|
1fe7237799 | ||
|
|
ddf5f085a5 | ||
|
|
5baf983fe0 | ||
|
|
82f3a3a3e4 | ||
|
|
c1ce0b7b04 | ||
|
|
14c38a3189 | ||
|
|
b67818d5c5 | ||
|
|
f79618d4f7 | ||
|
|
d56ec2ab3a | ||
|
|
c883a2fd1e | ||
|
|
ac841f4583 | ||
|
|
98728d3b81 | ||
|
|
3eb7a2e349 |
@@ -3,21 +3,9 @@ name: Claude Code Review
|
|||||||
on:
|
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
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `min_content_separation` | int, `24` |
|
| `min_content_separation` | int, `24` |
|
||||||
| `min_cut_gap` | int, `6` |
|
| `min_cut_gap` | int, `6` |
|
||||||
| `continuous_scroll` | bool, `true` |
|
| `continuous_scroll` | bool, `true` |
|
||||||
| `smooth_scroll` | bool, `true` |
|
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
||||||
|
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
|
||||||
| `extend_threshold_screens` | float, `2.0` |
|
| `extend_threshold_screens` | float, `2.0` |
|
||||||
| `auto_trim` | bool, `true` |
|
| `auto_trim` | bool, `true` |
|
||||||
| `trim_threshold` | int, `10` |
|
| `trim_threshold` | int, `10` |
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
|||||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
self.draw_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)
|
||||||
|
|||||||
@@ -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": { },
|
||||||
|
|||||||
+270
-1
@@ -207,7 +207,10 @@ Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
|||||||
### 3. Sub-pixel blending was wrong for this display
|
### 3. Sub-pixel blending was wrong for this display
|
||||||
|
|
||||||
Enabling it made things worse, not better — see the rule at the top. It is off
|
Enabling it made things worse, not better — see the rule at the top. It is off
|
||||||
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
by default everywhere. Vegas mode used to opt in; it now scrolls in whole
|
||||||
|
pixels locked to the refresh like the plugin tickers, and keeps the blend only
|
||||||
|
behind `display.vegas_scroll.sub_pixel_blend` (default `false`). The blend is
|
||||||
|
also why text looked anti-aliased in the web preview while the panel shimmered.
|
||||||
|
|
||||||
### 4. Frame-based stepping raced the vsync clock
|
### 4. Frame-based stepping raced the vsync clock
|
||||||
|
|
||||||
@@ -231,6 +234,9 @@ advances by elapsed time at `scroll_speed / scroll_delay` px/s.
|
|||||||
|
|
||||||
## Diagnosing a juddery scroller
|
## 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
|
||||||
@@ -303,6 +309,269 @@ journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
|
|||||||
If a plugin logs its scroll config **twice** with different modes, the second
|
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
|
||||||
|
|||||||
+10
-21
@@ -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.
|
||||||
|
|||||||
+41
-41
@@ -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,33 +269,31 @@ 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]:
|
||||||
"""
|
"""
|
||||||
Get cached data.
|
Get cached data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
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()
|
||||||
+33
-52
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-22
@@ -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,22 +34,26 @@ 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]:
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
+628
-699
File diff suppressed because it is too large
Load Diff
+235
-175
@@ -34,12 +34,12 @@ 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 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
|
||||||
@@ -47,13 +47,14 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import OrderedDict, deque
|
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,
|
||||||
@@ -61,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 --
|
||||||
@@ -134,11 +133,6 @@ class _LogicalMatrix:
|
|||||||
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||||
|
|
||||||
|
|
||||||
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
|
|
||||||
# sync handshake compute the display size exactly as DisplayManager does
|
|
||||||
# without importing rgbmatrix. Aliased here for existing callers.
|
|
||||||
_resolve_double_sided = resolve_double_sided
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
"""
|
"""
|
||||||
@@ -160,7 +154,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:
|
||||||
@@ -203,7 +196,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.
|
||||||
|
self._snapshot_cond = threading.Condition()
|
||||||
|
self._snapshot_pending: Optional[Tuple[Image.Image, Optional[int]]] = None
|
||||||
|
self._snapshot_thread: Optional[threading.Thread] = None
|
||||||
|
self._snapshot_stop = False
|
||||||
|
# Held for the whole of each PNG write, by the writer thread and by
|
||||||
|
# the inline static path, so the two land on disk in order.
|
||||||
|
self._snapshot_write_lock = threading.Lock()
|
||||||
self._viewer_check_ts = 0.0
|
self._viewer_check_ts = 0.0
|
||||||
self._viewer_fresh = False
|
self._viewer_fresh = False
|
||||||
self._viewer_was_fresh = False
|
self._viewer_was_fresh = False
|
||||||
@@ -233,6 +238,11 @@ 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
|
||||||
|
|
||||||
|
# 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,
|
||||||
@@ -249,20 +259,16 @@ class DisplayManager:
|
|||||||
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
|
|
||||||
# Calendar manager is now initialized by DisplayController
|
|
||||||
|
|
||||||
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
|
|
||||||
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
|
|
||||||
|
|
||||||
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
|
def _new_canvas(self, width: int, height: int) -> None:
|
||||||
"""Compose pixel_mapper_config with the orientation setting.
|
"""Replace ``image``/``draw`` with a black canvas of the given size.
|
||||||
|
|
||||||
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
|
Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
|
||||||
web preview shares so it sizes the canvas the same way.
|
brightness, so anti-aliasing only smears glyphs.
|
||||||
"""
|
"""
|
||||||
return compose_pixel_mapper_config(hardware_config)
|
self.image = Image.new('RGB', (width, height))
|
||||||
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fallback_advice(cause: str, error: Exception) -> str:
|
def _fallback_advice(cause: str, error: Exception) -> str:
|
||||||
@@ -331,7 +337,7 @@ class DisplayManager:
|
|||||||
# logical (per-screen) size, and keep a full-chain buffer to tile
|
# 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(
|
||||||
@@ -340,9 +346,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
|
||||||
@@ -373,7 +377,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']
|
||||||
@@ -381,9 +385,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))
|
||||||
@@ -612,18 +614,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))
|
||||||
|
|
||||||
@@ -638,7 +636,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)
|
||||||
@@ -713,9 +711,7 @@ class DisplayManager:
|
|||||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||||
# With no hardware, the width/height properties fall through to
|
# With no hardware, the width/height properties fall through to
|
||||||
# self.image, so swapping the buffer below is enough on its own.
|
# self.image, so swapping the buffer below is enough on its own.
|
||||||
self.image = Image.new('RGB', (target_w, target_h))
|
self._new_canvas(target_w, target_h)
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self.matrix = real_matrix
|
self.matrix = real_matrix
|
||||||
@@ -810,15 +806,21 @@ 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._scan_compensated(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.
|
||||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
||||||
|
presented_at = time.perf_counter()
|
||||||
|
self.frame_timing.record(
|
||||||
|
blit_done - blit_started, presented_at - blit_done,
|
||||||
|
self._frame_hold, self.is_currently_scrolling(), presented_at)
|
||||||
|
|
||||||
# Swap our canvas references
|
# 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
|
||||||
@@ -877,28 +879,14 @@ class DisplayManager:
|
|||||||
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._capture_mode_active:
|
if not self._capture_mode_active:
|
||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
@@ -928,43 +916,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)
|
||||||
|
|
||||||
@@ -1013,19 +974,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")
|
||||||
|
|
||||||
@@ -1383,6 +1338,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()
|
||||||
@@ -1391,14 +1348,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."""
|
||||||
@@ -1436,9 +1393,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', '')
|
||||||
@@ -1492,6 +1449,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).
|
||||||
|
|
||||||
@@ -1531,13 +1509,16 @@ class DisplayManager:
|
|||||||
that does not care gets a new frame every refresh.
|
that does not care gets a new frame every refresh.
|
||||||
"""
|
"""
|
||||||
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."""
|
||||||
@@ -1606,9 +1587,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]
|
||||||
@@ -1706,67 +1684,149 @@ 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: ensure directory permissions once, not per frame
|
# WRITE. Mid-scroll the PNG encode goes to a background thread: at
|
||||||
snapshot_path_obj = Path(self._snapshot_path)
|
# 512x64 it takes 12-14ms on a Pi 4, longer than a 95Hz refresh,
|
||||||
if not self._snapshot_dir_prepared:
|
# so on the render thread every preview write made the next swap
|
||||||
# Never modify /tmp permissions - it has special system
|
# miss its vsync -- five visible hitches a second, but only while
|
||||||
# permissions (1777) that must not be changed or it breaks
|
# someone had the web preview open. Pillow releases the GIL while
|
||||||
# apt and other system tools
|
# it compresses, so the encode no longer holds the loop up. Static
|
||||||
parent_dir = snapshot_path_obj.parent
|
# frames still write inline: nothing is moving to disturb.
|
||||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
if self.is_currently_scrolling():
|
||||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
self._queue_snapshot(self.image.copy(), digest)
|
||||||
self._snapshot_dir_prepared = True
|
else:
|
||||||
# Write atomically: temp then replace. The temp name must be
|
# A scroll that just ended can leave its last frame queued or
|
||||||
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
# mid-write; it must not land on top of this newer one.
|
||||||
# and this file is written by whichever user the display service
|
with self._snapshot_write_lock:
|
||||||
# runs as while tests and tooling run as someone else. A leftover
|
with self._snapshot_cond:
|
||||||
# fixed-name temp owned by another user is then unopenable even by
|
self._snapshot_pending = None
|
||||||
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
self._save_snapshot(self.image)
|
||||||
# sticky dir), which froze the preview and the health check's
|
self._saved_snapshot_digest = digest
|
||||||
# liveness proxy until somebody deleted it by hand. Same pattern as
|
|
||||||
# the hardware-status write above.
|
|
||||||
_fd, tmp_path = tempfile.mkstemp(
|
|
||||||
dir=str(snapshot_path_obj.parent),
|
|
||||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
|
||||||
try:
|
|
||||||
with os.fdopen(_fd, "wb") as _f:
|
|
||||||
self.image.save(_f, format='PNG')
|
|
||||||
os.chmod(tmp_path, 0o644)
|
|
||||||
os.replace(tmp_path, self._snapshot_path)
|
|
||||||
except Exception:
|
|
||||||
# Never leave the temp behind -- that is what made the failure
|
|
||||||
# permanent rather than transient.
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
# Fallback to direct save if replace not supported
|
|
||||||
self.image.save(self._snapshot_path, format='PNG')
|
|
||||||
# Set proper file permissions after saving
|
|
||||||
try:
|
|
||||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
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
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Snapshot failures must never break display — but they must not
|
self._log_snapshot_failure(e)
|
||||||
# be silent either: the snapshot's mtime is the web UI's display
|
|
||||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
def _log_snapshot_failure(self, error: Exception) -> None:
|
||||||
# write freezes the mirror and makes health checks lie (seen in
|
# Snapshot failures must never break display — but they must not
|
||||||
# the field: a stale root-owned /tmp file froze it for a day).
|
# be silent either: the snapshot's mtime is the web UI's display
|
||||||
# Warn at most once per 5 minutes to avoid log spam.
|
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||||
if (now - self._snapshot_fail_log_ts) > 300:
|
# write freezes the mirror and makes health checks lie (seen in
|
||||||
self._snapshot_fail_log_ts = now
|
# the field: a stale root-owned /tmp file froze it for a day).
|
||||||
logger.warning("Snapshot write failing (web preview/health "
|
# Warn at most once per 5 minutes to avoid log spam.
|
||||||
"mirror is stale): %s", e)
|
now = time.time()
|
||||||
else:
|
if (now - self._snapshot_fail_log_ts) > 300:
|
||||||
logger.debug(f"Snapshot write skipped: {e}")
|
self._snapshot_fail_log_ts = now
|
||||||
|
logger.warning("Snapshot write failing (web preview/health "
|
||||||
|
"mirror is stale): %s", error)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Snapshot write skipped: {error}")
|
||||||
|
|
||||||
|
def _save_snapshot(self, image: Image.Image) -> None:
|
||||||
|
"""Encode ``image`` to the snapshot path atomically. Raises on failure."""
|
||||||
|
# Ensure directory permissions once, not per frame
|
||||||
|
snapshot_path_obj = Path(self._snapshot_path)
|
||||||
|
if not self._snapshot_dir_prepared:
|
||||||
|
# Never modify /tmp permissions - it has special system
|
||||||
|
# permissions (1777) that must not be changed or it breaks
|
||||||
|
# apt and other system tools
|
||||||
|
parent_dir = snapshot_path_obj.parent
|
||||||
|
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||||
|
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||||
|
self._snapshot_dir_prepared = True
|
||||||
|
# Write atomically: temp then replace. The temp name must be
|
||||||
|
# unique, not "<snapshot>.tmp": /tmp is world-writable and sticky,
|
||||||
|
# and this file is written by whichever user the display service
|
||||||
|
# runs as while tests and tooling run as someone else. A leftover
|
||||||
|
# fixed-name temp owned by another user is then unopenable even by
|
||||||
|
# root (fs.protected_regular refuses O_CREAT on a foreign file in a
|
||||||
|
# sticky dir), which froze the preview and the health check's
|
||||||
|
# liveness proxy until somebody deleted it by hand. Same pattern as
|
||||||
|
# the hardware-status write above.
|
||||||
|
_fd, tmp_path = tempfile.mkstemp(
|
||||||
|
dir=str(snapshot_path_obj.parent),
|
||||||
|
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(_fd, "wb") as _f:
|
||||||
|
image.save(_f, format='PNG')
|
||||||
|
os.chmod(tmp_path, 0o644)
|
||||||
|
os.replace(tmp_path, self._snapshot_path)
|
||||||
|
except Exception:
|
||||||
|
# Never leave the temp behind -- that is what made the failure
|
||||||
|
# permanent rather than transient.
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Fallback to direct save if replace not supported
|
||||||
|
image.save(self._snapshot_path, format='PNG')
|
||||||
|
# Set proper file permissions after saving
|
||||||
|
try:
|
||||||
|
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _queue_snapshot(self, image: Image.Image, digest: Optional[int] = None) -> None:
|
||||||
|
"""Hand a frame to the snapshot writer thread; the newest frame wins.
|
||||||
|
|
||||||
|
One slot, not a queue: if the writer is still encoding when the next
|
||||||
|
frame is due, the waiting frame is simply replaced. The preview wants
|
||||||
|
the latest frame, and a backlog would only cost memory and CPU.
|
||||||
|
"""
|
||||||
|
with self._snapshot_cond:
|
||||||
|
self._snapshot_pending = (image, digest)
|
||||||
|
if self._snapshot_thread is None or not self._snapshot_thread.is_alive():
|
||||||
|
self._snapshot_thread = threading.Thread(
|
||||||
|
target=self._snapshot_writer, daemon=True,
|
||||||
|
name="snapshot-writer")
|
||||||
|
self._snapshot_thread.start()
|
||||||
|
self._snapshot_cond.notify()
|
||||||
|
|
||||||
|
def _snapshot_writer(self) -> None:
|
||||||
|
while True:
|
||||||
|
with self._snapshot_cond:
|
||||||
|
while self._snapshot_pending is None and not self._snapshot_stop:
|
||||||
|
self._snapshot_cond.wait()
|
||||||
|
if self._snapshot_stop:
|
||||||
|
return # shutting down: a pending frame is dropped
|
||||||
|
# The write lock before the frame: whichever of this and an inline
|
||||||
|
# static save gets it first also writes first, and a static save
|
||||||
|
# clears the slot, so an older frame never lands on a newer one.
|
||||||
|
with self._snapshot_write_lock:
|
||||||
|
with self._snapshot_cond:
|
||||||
|
pending, self._snapshot_pending = self._snapshot_pending, None
|
||||||
|
if pending is None:
|
||||||
|
continue
|
||||||
|
image, digest = pending
|
||||||
|
try:
|
||||||
|
self._save_snapshot(image)
|
||||||
|
self._saved_snapshot_digest = digest
|
||||||
|
except Exception as e:
|
||||||
|
# The frame was recorded as written when it was queued.
|
||||||
|
# Forget that, so an unchanged frame is written again
|
||||||
|
# rather than only mtime-touching a stale file into
|
||||||
|
# looking healthy.
|
||||||
|
self._last_snapshot_digest = None
|
||||||
|
self._log_snapshot_failure(e)
|
||||||
|
|
||||||
|
def _stop_snapshot_writer(self, timeout: float = 1.0) -> None:
|
||||||
|
"""Stop the writer thread, dropping any frame it has not started."""
|
||||||
|
with self._snapshot_cond:
|
||||||
|
self._snapshot_stop = True
|
||||||
|
self._snapshot_pending = None
|
||||||
|
self._snapshot_cond.notify_all()
|
||||||
|
thread = self._snapshot_thread
|
||||||
|
if thread is not None and thread is not threading.current_thread():
|
||||||
|
thread.join(timeout)
|
||||||
|
self._snapshot_thread = None
|
||||||
@@ -21,6 +21,8 @@ import time
|
|||||||
import requests
|
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
|
||||||
|
|||||||
+50
-73
@@ -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
|
|
||||||
variations.extend([f"{original}.png", f"{normalized}.png"])
|
|
||||||
|
|
||||||
# Special handling for known cases
|
|
||||||
if original == 'TA&M':
|
|
||||||
# TA&M has a file named TA&M.png, but normalize creates TAANDM.png
|
|
||||||
variations = [f"{original}.png", f"{normalized}.png"]
|
|
||||||
|
|
||||||
return variations
|
|
||||||
|
|
||||||
# Allowlist for league names used in filesystem paths: alphanumerics, underscores, dashes only
|
# Allowlist for a league name or code that goes into a filesystem path or
|
||||||
|
# an ESPN URL: lower-case alphanumerics, underscores and dashes only.
|
||||||
_SAFE_LEAGUE_RE = re.compile(r'^[a-z0-9_-]+$')
|
_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"
|
|
||||||
filepath = Path(logo_dir) / filename
|
|
||||||
|
|
||||||
# Create a simple placeholder logo
|
if filepath is None:
|
||||||
logo = Image.new('RGBA', (64, 64), (100, 100, 100, 255)) # Gray background
|
filename = f"{self.normalize_abbreviation(team_abbreviation)}.png"
|
||||||
|
filepath = Path(logo_dir) / filename
|
||||||
|
|
||||||
|
logo = Image.new('RGBA', PLACEHOLDER_SIZE, PLACEHOLDER_BG)
|
||||||
draw = ImageDraw.Draw(logo)
|
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,11 +292,7 @@ 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."""
|
||||||
@@ -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")
|
||||||
@@ -377,7 +311,4 @@ 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."""
|
||||||
|
|
||||||
@@ -214,85 +255,36 @@ class PluginLoader:
|
|||||||
) -> Optional[Path]:
|
) -> Optional[Path]:
|
||||||
"""
|
"""
|
||||||
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
|
||||||
plugins_dir: Base plugins directory
|
plugins_dir: Base plugins directory
|
||||||
plugin_directories: Optional mapping of plugin_id to directory
|
plugin_directories: Optional mapping of plugin_id to directory
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to plugin directory or None if not found
|
|
||||||
"""
|
|
||||||
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
|
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
|
||||||
if not plugin_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to plugin directory or None if not found. An id that is not
|
||||||
|
one plain path segment finds nothing.
|
||||||
|
"""
|
||||||
# Strategy 1: Use mapping from discovery
|
# 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]
|
||||||
if plugin_dir.exists():
|
if plugin_dir.exists():
|
||||||
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
|
|
||||||
plugins_dir_resolved = plugins_dir.resolve()
|
|
||||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
|
||||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
|
||||||
try:
|
|
||||||
_candidate.relative_to(plugins_dir_resolved)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if _candidate.exists():
|
|
||||||
return _candidate
|
|
||||||
|
|
||||||
# Strategy 3: Case-insensitive search
|
|
||||||
normalized_id = plugin_id.lower()
|
|
||||||
for item in plugins_dir.iterdir():
|
|
||||||
if not item.is_dir():
|
|
||||||
continue
|
|
||||||
|
|
||||||
item_name = item.name
|
|
||||||
if item_name.lower() == normalized_id:
|
|
||||||
return item
|
|
||||||
|
|
||||||
if item_name.lower() == f"ledmatrix-{plugin_id}".lower():
|
|
||||||
return item
|
|
||||||
|
|
||||||
# Strategy 4: Manifest-based search
|
|
||||||
self.logger.debug("Directory name search failed for %s, searching by manifest...", plugin_id)
|
|
||||||
for item in plugins_dir.iterdir():
|
|
||||||
if not item.is_dir():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip if already checked
|
|
||||||
if item.name.lower() == normalized_id or item.name.lower() == f"ledmatrix-{plugin_id}".lower():
|
|
||||||
continue
|
|
||||||
|
|
||||||
manifest_path = item / "manifest.json"
|
|
||||||
if manifest_path.exists():
|
|
||||||
try:
|
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
||||||
item_manifest = json.load(f)
|
|
||||||
item_manifest_id = item_manifest.get('id')
|
|
||||||
if item_manifest_id == plugin_id:
|
|
||||||
self.logger.info(
|
|
||||||
"Found plugin %s in directory %s (manifest ID matches)",
|
|
||||||
plugin_id,
|
|
||||||
item.name
|
|
||||||
)
|
|
||||||
return item
|
|
||||||
except (json.JSONDecodeError, Exception) as e:
|
|
||||||
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None
|
plugin_dir = resolve_plugin_dir(
|
||||||
|
plugin_id, [plugins_dir], prefix=True, case_insensitive=True)
|
||||||
|
if plugin_dir is not None and plugin_dir.name != plugin_id:
|
||||||
|
self.logger.debug("Found plugin %s in directory %s",
|
||||||
|
plugin_id, plugin_dir.name)
|
||||||
|
return plugin_dir
|
||||||
|
|
||||||
def install_dependencies(
|
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,9 +800,7 @@ 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')
|
||||||
if not class_name:
|
if not class_name:
|
||||||
|
|||||||
+107
-104
@@ -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,8 +265,8 @@ 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]:
|
||||||
"""
|
"""
|
||||||
Discover all plugins in the plugins directory.
|
Discover all plugins in the plugins directory.
|
||||||
@@ -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``."""
|
||||||
@@ -181,11 +180,7 @@ class PluginStateManager:
|
|||||||
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
|
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
|
||||||
"""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
|
||||||
@@ -42,11 +49,6 @@ class ResourceMetrics:
|
|||||||
max_execution_time: float = 0.0
|
max_execution_time: float = 0.0
|
||||||
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,7 +289,8 @@ 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:
|
||||||
metrics.execution_time = execution_time
|
metrics.execution_time = execution_time
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -326,9 +329,17 @@ class PluginResourceMonitor:
|
|||||||
metrics.last_update_time = time.time()
|
metrics.last_update_time = time.time()
|
||||||
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,20 +640,12 @@ 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'),
|
||||||
|
|||||||
+352
-520
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,16 @@ PIL Image canvas and draws text using the actual project fonts.
|
|||||||
MAINTENANCE WARNING: this class is a deliberate fork of
|
MAINTENANCE WARNING: this class is a deliberate fork of
|
||||||
src/display_manager.py so it can run without hardware. It mirrors
|
src/display_manager.py so it can run without hardware. It mirrors
|
||||||
these DisplayManager methods by name and behavior: _load_fonts,
|
these DisplayManager methods by name and behavior: _load_fonts,
|
||||||
_draw_bdf_text, get_font_height, get_text_width, draw_text,
|
get_font_height, get_text_width, draw_text,
|
||||||
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
|
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
|
||||||
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
|
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
|
||||||
capture_mode, set_scrolling_state, is_currently_scrolling,
|
capture_mode, set_scrolling_state, is_currently_scrolling,
|
||||||
process_deferred_updates, update_display, render_size. A behavior
|
process_deferred_updates, update_display, render_size. A behavior
|
||||||
change to any of those in DisplayManager must be mirrored here, or
|
change to any of those in DisplayManager must be mirrored here, or
|
||||||
plugin visual tests will pass against stale behavior.
|
plugin visual tests will pass against stale behavior.
|
||||||
|
|
||||||
|
BDF text is not mirrored: both classes load BDF faces and draw BDF glyphs
|
||||||
|
through src/common/bdf_font.py, so those pixels cannot drift.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -31,6 +34,7 @@ from pathlib import Path
|
|||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
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
|
from src.common.font_layout import crisp_size, load_truetype
|
||||||
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
@@ -147,16 +151,18 @@ class VisualTestDisplayManager:
|
|||||||
self.small_font = load_truetype(ttf_path, crisp_size(press_start, 8))
|
self.small_font = load_truetype(ttf_path, crisp_size(press_start, 8))
|
||||||
self.font = self.regular_font # alias used by some code paths
|
self.font = self.regular_font # alias used by some code paths
|
||||||
|
|
||||||
# 5x7 BDF font via freetype
|
# 5x7 BDF font, loaded exactly as DisplayManager._load_fonts does
|
||||||
|
# (same loader, same 7px request as its _CALENDAR_FONT_PX). A bare
|
||||||
|
# freetype.Face has no active size, so its ascender reads 0 and
|
||||||
|
# every line drew a baseline too high.
|
||||||
try:
|
try:
|
||||||
import freetype
|
|
||||||
bdf_path = str(fonts_dir / '5x7.bdf')
|
bdf_path = str(fonts_dir / '5x7.bdf')
|
||||||
if not os.path.exists(bdf_path):
|
if not os.path.exists(bdf_path):
|
||||||
raise FileNotFoundError(f"BDF font not found: {bdf_path}")
|
raise FileNotFoundError(f"BDF font not found: {bdf_path}")
|
||||||
face = freetype.Face(bdf_path)
|
face, _ = load_bdf_face(bdf_path, 7)
|
||||||
self.calendar_font = face
|
self.calendar_font = face
|
||||||
self.bdf_5x7_font = face
|
self.bdf_5x7_font = face
|
||||||
except (ImportError, FileNotFoundError, OSError) as e:
|
except Exception as e: # freetype missing or the file unloadable
|
||||||
logger.debug("BDF font not available, using small_font as fallback: %s", e)
|
logger.debug("BDF font not available, using small_font as fallback: %s", e)
|
||||||
self.calendar_font = self.small_font
|
self.calendar_font = self.small_font
|
||||||
self.bdf_5x7_font = self.small_font
|
self.bdf_5x7_font = self.small_font
|
||||||
@@ -300,41 +306,18 @@ class VisualTestDisplayManager:
|
|||||||
logger.debug(f"Error drawing image: {e}")
|
logger.debug(f"Error drawing image: {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.
|
||||||
|
|
||||||
Replicated from DisplayManager._draw_bdf_text().
|
Not a copy: DisplayManager._draw_bdf_text calls the same
|
||||||
|
:func:`src.common.bdf_font.draw_bdf_text`, so what this draws is
|
||||||
|
what the panel draws.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if isinstance(color, list):
|
if isinstance(color, list):
|
||||||
color = tuple(color)
|
color = tuple(color)
|
||||||
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
|
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
|
|
||||||
|
|
||||||
glyph_left = face.glyph.bitmap_left
|
|
||||||
glyph_top = face.glyph.bitmap_top
|
|
||||||
|
|
||||||
for i in range(bitmap.rows):
|
|
||||||
for j in range(bitmap.width):
|
|
||||||
byte_index = i * bitmap.pitch + (j // 8)
|
|
||||||
if byte_index < len(bitmap.buffer):
|
|
||||||
byte = bitmap.buffer[byte_index]
|
|
||||||
if byte & (1 << (7 - (j % 8))):
|
|
||||||
pixel_x = x + glyph_left + j
|
|
||||||
pixel_y = baseline_y - glyph_top + i
|
|
||||||
if 0 <= pixel_x < self.width and 0 <= pixel_y < self.height:
|
|
||||||
self.draw.point((pixel_x, pixel_y), fill=color)
|
|
||||||
|
|
||||||
x += face.glyph.advance.x >> 6
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error drawing BDF text: {e}")
|
logger.debug(f"Error drawing BDF text: {e}")
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -24,8 +24,13 @@ _REDACT_CREDENTIAL = re.compile(
|
|||||||
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
||||||
# above, whose value part stops at whitespace and so would keep the credential
|
# above, whose value part stops at whitespace and so would keep the credential
|
||||||
# once a space follows the scheme.
|
# once a space follows the scheme.
|
||||||
|
#
|
||||||
|
# The opening quote and the whitespace after it are one optional unit. Written
|
||||||
|
# `\s*["\']?\s*`, a whitespace run with no quote in it could be split between
|
||||||
|
# the two `\s*` in every possible way, and a header with no credential after
|
||||||
|
# it tried them all: quadratic, 8s for 20k spaces.
|
||||||
_REDACT_AUTH_HEADER = re.compile(
|
_REDACT_AUTH_HEADER = re.compile(
|
||||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
r'((?:proxy-)?authorization["\']?\s*[=:]\s*(?:["\']\s*)?'
|
||||||
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
||||||
r'([^\s,"\'<>}]+)', # the credential, redacted
|
r'([^\s,"\'<>}]+)', # the credential, redacted
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -34,8 +39,16 @@ _REDACT_AUTH_HEADER = re.compile(
|
|||||||
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
||||||
# the full URL in its exceptions, so this is a realistic leak. The username is
|
# the full URL in its exceptions, so this is a realistic leak. The username is
|
||||||
# kept -- it identifies which account failed without being the secret.
|
# kept -- it identifies which account failed without being the secret.
|
||||||
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
#
|
||||||
re.IGNORECASE)
|
# A match may only start where a run of scheme characters starts. Unanchored,
|
||||||
|
# `[a-z][a-z0-9+.-]*://` was tried from every letter of a long run (a hex
|
||||||
|
# digest, an ID, a blob of response body), each attempt reading to the end of
|
||||||
|
# the run: quadratic, 1.6s for 20k characters, all of it holding the GIL.
|
||||||
|
# Leading digits and `+.-` sit inside group 1 so the substitution puts them
|
||||||
|
# back; the scheme proper still has to start with a letter.
|
||||||
|
_REDACT_URL_USERINFO = re.compile(
|
||||||
|
r'((?<![a-z0-9+.-])[0-9+.-]*[a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def redact_credentials(text: str) -> str:
|
def redact_credentials(text: str) -> str:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user