mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7603728e5a | ||
|
|
6c23994b2f | ||
|
|
f12d11334a | ||
|
|
be7a7b7baf | ||
|
|
406e68fba9 | ||
|
|
ea54e56bed | ||
|
|
2f42d179f6 | ||
|
|
37fc1b56b5 | ||
|
|
acc55ef119 | ||
|
|
1a0864e5d4 | ||
|
|
732c7d1a30 | ||
|
|
d42593e7ce | ||
|
|
f0bef7784c | ||
|
|
986f74e38b | ||
|
|
e450a6dfb6 | ||
|
|
5133643600 | ||
|
|
5929190e36 | ||
|
|
79ba93f5a6 | ||
|
|
e499efb1f0 | ||
|
|
cd7e16e58e | ||
|
|
47e3021fc3 | ||
|
|
e319540c6e |
@@ -36,12 +36,6 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Review PRs opened by the Claude GitHub App. Without this the action
|
||||
# aborts before reading the diff ("Workflow initiated by non-human
|
||||
# actor"), so every such PR shows this check red. Named rather than
|
||||
# '*': the allow-list is matched against the triggering actor, so
|
||||
# this admits claude[bot] alone and no other app.
|
||||
allowed_bots: 'claude'
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
|
||||
+3
-44
@@ -5,9 +5,6 @@ __pycache__/
|
||||
|
||||
# Secrets
|
||||
config/config_secrets.json
|
||||
# Atomic writes leave these behind when a save or a test is interrupted;
|
||||
# the suite drops several per run.
|
||||
config/.config_secrets.json.tmp.*
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
@@ -39,12 +36,11 @@ htmlcov/
|
||||
# Cache directory (root level only, not src/cache which is source code)
|
||||
/cache/
|
||||
|
||||
# Development plugins directory: symlinks into a ledmatrix-plugins checkout
|
||||
# See docs/PLUGIN_DEVELOPMENT_GUIDE.md and docs/MULTI_ROOT_WORKSPACE_SETUP.md
|
||||
# Development plugins directory
|
||||
# Plugins are managed as separate repositories via multi-root workspace
|
||||
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
|
||||
plugins/*
|
||||
!plugins/.gitkeep
|
||||
# Local settings for scripts/dev/dev_plugin_setup.sh (template: dev_plugins.json.example)
|
||||
/dev_plugins.json
|
||||
|
||||
# Binary files and backups
|
||||
bin/pixlet/
|
||||
@@ -53,40 +49,3 @@ config/backups/
|
||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||
/starlark-apps/
|
||||
skin_renders/
|
||||
|
||||
# JS test deps (test/js)
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Team logos fetched at runtime.
|
||||
#
|
||||
# src/logo_downloader.py and LogoHelper write into assets/sports/<league>_logos/
|
||||
# whenever a plugin meets a team whose logo is not on disk. Those directories are
|
||||
# also tracked -- 209 NCAA logos and 153 soccer ones ship with the repo -- so
|
||||
# every rig accumulated untracked files it was never meant to commit and
|
||||
# `git status` was permanently dirty. That noise is not harmless: it trains
|
||||
# everyone to ignore the one signal that says a checkout is not what you think
|
||||
# it is, which is how a stale tree sat unnoticed on a rig until a restart
|
||||
# surfaced four dead sports plugins.
|
||||
#
|
||||
# Ignoring a directory does not untrack what is already in it, so the logos that
|
||||
# ship keep shipping. Only new downloads are hidden.
|
||||
#
|
||||
# Adding a logo on purpose is rare and deliberate -- the last time was #415, four
|
||||
# named NCAA logos a plugin needed, and there has been no other in a year. Do it
|
||||
# with an explicit override:
|
||||
# git add -f assets/sports/ncaa_logos/DUKE.png
|
||||
assets/sports/*_logos/
|
||||
assets/stocks/ticker_icons/
|
||||
assets/stocks/crypto_icons/
|
||||
|
||||
# Plugin operation state written at runtime.
|
||||
#
|
||||
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
|
||||
# and data/operation_history.json as the web interface runs, into a directory that
|
||||
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
|
||||
# opened the web UI -- and every test run that constructs the app -- left three
|
||||
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
||||
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
-682
@@ -17,690 +17,8 @@ release that ships it.
|
||||
accepts both, but the store flags the old spelling as deprecated
|
||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||
|
||||
## Unreleased
|
||||
|
||||
New names in existing modules (no new modules; a plugin importing these must
|
||||
floor on the release that ships them):
|
||||
|
||||
- `src.common.api_helper`: `USER_AGENT`, `DEFAULT_HTTP_HEADERS` (read-only).
|
||||
- `src.logo_downloader`: `fetch_logo`, `save_png_atomically`,
|
||||
`shared_downloader`.
|
||||
|
||||
### Logo downloads
|
||||
|
||||
- `download_missing_logo` / `LogoDownloader.download_logo` (the path the
|
||||
scoreboard plugins use) now stream the logo with a 10 MB cap, accept only an
|
||||
`image/*` response that Pillow can decode, and move the finished RGBA PNG
|
||||
into place atomically. A failed, oversized or non-image download no longer
|
||||
leaves a partial file behind, and no longer replaces a logo already on disk.
|
||||
`LogoHelper._download_logo` goes through the same code. Signatures and return
|
||||
values are unchanged; saved files are pixel-identical to before.
|
||||
- `download_missing_logo` reuses one downloader (one `requests.Session`) per
|
||||
thread instead of building a new one for every logo.
|
||||
- Placeholder logos are written atomically, without the `test_write.tmp`
|
||||
probe file.
|
||||
|
||||
### HTTP headers
|
||||
|
||||
- The logo downloader and the background data service send the real
|
||||
`LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)` User-Agent
|
||||
instead of a `yourusername` / `contact@example.com` placeholder, and no
|
||||
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
|
||||
`br` response could not be decoded); requests picks the encodings.
|
||||
|
||||
## 3.5.0
|
||||
|
||||
New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
|
||||
- `src/common/sports_helpers.py` — the helpers the scoreboards' `sports.py`
|
||||
carry byte-identical copies of: `clamp_window`, `clamp_seconds`,
|
||||
`logo_needs_refresh`, `spread_weighted_order` (+ `MIN_WINDOW_DAYS`,
|
||||
`MAX_WINDOW_DAYS`), and `SportsHelpersMixin` with `_mode_customization`,
|
||||
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
|
||||
`_spread_weighted_order`, `_odds_color`, `_upcoming_date_and_time_text` under
|
||||
the plugins' names and signatures, plus the `_favorite_key` override point.
|
||||
Constructor-free; keeps lazy state on its host (see the module docstring,
|
||||
which also gives the host contract).
|
||||
A new module rather than more methods on `sports_shared`: a plugin that
|
||||
deletes a copy and leans on an older module having grown the method fails at
|
||||
runtime with `AttributeError`, which no load-time check sees, while a missing
|
||||
module fails at load. Nothing in core uses it yet.
|
||||
- `test/test_common_is_hardware_free.py` — `src/common` must import without
|
||||
`rgbmatrix` and never import `src.base_classes`, `src.display_manager` or
|
||||
`src.plugin_system` at module level.
|
||||
- `src/common/espn_dates.py` — `fetch_espn_scoreboard`,
|
||||
`fetch_espn_date_chunks`, `espn_date_chunks`, `clamp_espn_limit`,
|
||||
`ESPN_MAX_LIMIT`: fetch an ESPN scoreboard date range now that ESPN rejects
|
||||
ranges (see Sports data below). Plugins bundle a copy of it.
|
||||
|
||||
### Config saves and plugin config preparation
|
||||
|
||||
- A JSON `POST /api/v3/config/main` changes only the keys it sends. The MQTT
|
||||
bridge's brightness slider used to turn off `disable_hardware_pulsing`,
|
||||
`inverse_colors`, `show_refresh_rate` and `use_short_date_format`, and a
|
||||
timezone- or location-only save turned off web-UI autostart and weekly
|
||||
automatic updates. Missing checkboxes still save as unchecked for the
|
||||
settings forms (they now send a hidden `__form_section` field) and for
|
||||
form-encoded posts.
|
||||
- A partial JSON `POST /api/v3/plugins/config` merges onto the plugin's stored
|
||||
settings instead of resetting everything it didn't send to the schema
|
||||
defaults, and keeps a submitted `skin`, `skin_options`, `vegas_width_pct`,
|
||||
`vegas_overflow` or `vegas_max_width_screens` (they were silently dropped).
|
||||
- Plugin sections posted to `/config/main` are validated and prepared exactly
|
||||
like `/plugins/config`; a value that endpoint rejects is rejected here too,
|
||||
and nothing is saved.
|
||||
- Legacy boolean settings (#588) are read as `{"enabled": ...}` objects
|
||||
everywhere, not just when the plugin loads: `GET /plugins/config` returns
|
||||
the object, posting it back saves, and hot reload hands plugins the same
|
||||
shape (schema defaults included) they were constructed with.
|
||||
`schema_manager.prepare_plugin_config` is the one implementation.
|
||||
- A plugin's settings tab shows schema defaults for options its saved config
|
||||
doesn't have yet. A boolean added with `"default": true` in a plugin update
|
||||
(geochron 1.2.0's `show_date` and `show_date_line`) used to render unchecked,
|
||||
and the next save of that tab stored it as `false`. Enum dropdowns likewise
|
||||
showed their first option instead of the default. The partial now runs the
|
||||
stored section through `prepare_plugin_config` like `GET /plugins/config`
|
||||
(secrets are still masked, after the merge), and the form falls back to a
|
||||
field's own `default` inside objects that declare a default of their own.
|
||||
- `scripts/dev_server.py`, `check_plugin.py`, `render_plugin.py` and the plugin
|
||||
harness build configs the way a device does: nested defaults are included,
|
||||
a schema `enabled: false` no longer beats the forced `enabled: true` in the
|
||||
dev server, and nested overrides such as `{"nhl": {"enabled": true}}` keep
|
||||
the other defaults of that section.
|
||||
- Clearing Vegas "Min/Max Cycle Time" no longer rejects the whole Display save,
|
||||
and those fields no longer add junk entries to `display.display_durations`.
|
||||
- Turning automatic updates on from the Raw JSON editor finishes their setup
|
||||
like the General tab does, instead of waiting for the next display restart.
|
||||
- `POST /config/schedule` and `/config/dim-schedule` accept the per-day
|
||||
`days.<day>.{enabled,start_time,end_time}` shape their GETs return, as well
|
||||
as the flat form keys.
|
||||
- The startup check no longer warns that `auto_update` or `dim_schedule` is
|
||||
"enabled but not found in plugins directory", and plugin ids that collide
|
||||
with any core config section are flagged: the last private copies of the
|
||||
core-key list now use `src/core_config_keys.py`.
|
||||
|
||||
### Sports data
|
||||
|
||||
- Since 2026-09-15 ESPN answers `dates=YYYYMMDD-YYYYMMDD` scoreboard queries
|
||||
with `400 Bad Request` for every sport, so season schedules, the weeks window
|
||||
and today's games all failed ("400 Client Error" from the NFL/NCAAFB managers
|
||||
and `src.background_data_service`). A rejected range is now re-fetched as
|
||||
whole months (`dates=YYYYMM`) plus the leftover days at each end, which cover
|
||||
the window exactly: a football season is 8 requests. A month that returns
|
||||
exactly 500 events is truncated and is re-fetched day by day.
|
||||
- Scoreboard requests send `limit=500` at most. Above 500 ESPN silently returns
|
||||
a short list: college football gave 25 of 68 games for one Saturday at the
|
||||
`limit=1000` everything used to send.
|
||||
- `BackgroundDataService.handles_espn_date_ranges` is `True`. Plugins check it
|
||||
to decide whether to submit a season range to the service or fetch it
|
||||
themselves on an older core.
|
||||
- A league with no live games no longer backs its poll off past the next
|
||||
kickoff. The escalation counted empty looks and nothing else, so a league
|
||||
three hours before kickoff was indistinguishable from one out of season and
|
||||
both reached `live_idle_max_interval`: measured gaps of up to 928 seconds,
|
||||
and a rig that sat for a quarter of an hour with eight NFL games in progress
|
||||
without noticing any of them. The wait is now clamped so it cannot run past
|
||||
the earliest start still ahead, which the live fetch already downloads, so
|
||||
it costs no extra request. Just after a kickoff the live cadence is held for
|
||||
a grace window, because a provider that has not yet flipped the status would
|
||||
otherwise read as another empty check and escalate the back-off again.
|
||||
- ESPN date chunks are fetched six at a time (`ESPN_CHUNK_WORKERS`) in two
|
||||
passes: months and edge days first, then the days of any month that came
|
||||
back at the cap. A cold college-baseball season is about 130 requests, and
|
||||
they went out one at a time; March and April measured on a Pi 4 (63
|
||||
requests, 3101 events) went from 11.2s to 1.6s. Merged events still follow
|
||||
`espn_date_chunks` order, so the payload does not depend on which request
|
||||
won the race, and a capped month's payload is dropped before its days are
|
||||
fetched, which keeps the peak memory of a four-capped-month fetch to about
|
||||
16 MB over the sequential path rather than 43 MB — `docs/LOW_MEMORY_BOARDS.md`
|
||||
puts a 1 GB Pi 3B+ at under 200 MB of headroom.
|
||||
|
||||
### Scrolling
|
||||
|
||||
- **Scoreboard scroll speed no longer changes with the General tab's "Scroll
|
||||
Frame Rate" (`target_fps`).** Scoreboards on `src.common.sports_scroll`
|
||||
computed their speed for that rate while the panel kept presenting at its
|
||||
real refresh, so on a 100 Hz panel 60 ran a 50 px/s scoreboard at 100 px/s
|
||||
and 200 ran it at 25 px/s. Speed now comes from `scroll_speed` and the panel
|
||||
refresh only. The field is labelled legacy: nothing in core scrolling reads
|
||||
it. Anyone who lowered it will see scoreboards scroll slower than before --
|
||||
at the speed they configured.
|
||||
- `scripts/scroll_speeds.py --measure` / `--demo` open the panel with the
|
||||
display service's own options (`DisplayManager.apply_matrix_options`), so
|
||||
`display.runtime.gpio_slowdown`, `rp1_rio`, `panel_type` and orientation are
|
||||
honoured; the script used to read `gpio_slowdown` from `display.hardware`.
|
||||
Its closing advice now gives the `scroll_speed` + `scroll_delay` pair
|
||||
instead of `scroll_pixels_per_second`, which the resolver ignores whenever
|
||||
the pair is present.
|
||||
- The frame-stats log no longer opens a scroll with a one-frame window for
|
||||
scrollers that never call `reset_scroll()`.
|
||||
- Removed dead scroll code: the optional scipy import (`HAS_SCIPY`),
|
||||
`ScrollHelper._last_integer_position` and `frame_time_target`.
|
||||
`ScrollHelper.target_fps` / `set_target_fps()` remain, documented as
|
||||
informational.
|
||||
- Docs describe the fixed-step scroll model: `PLUGIN_API_REFERENCE.md`
|
||||
documents `set_scrolling_state(..., frame_hold)` (omitting the hold runs a
|
||||
scroll `frame_hold` times too fast), `SCROLL_PERFORMANCE.md` no longer reads a
|
||||
held 20 ms frame as missed refreshes, and Vegas `frame_based_scrolling` /
|
||||
`scroll_delay` are described as the speed clamp they are rather than frame
|
||||
stepping. Scoreboard `scroll_delay` is documented as ignored for pacing.
|
||||
|
||||
### Web interface
|
||||
|
||||
- The plugin settings form honours `"x-display": "hidden"` in config schemas:
|
||||
the property gets no control at any depth (top level, nested objects, array
|
||||
rows, Advanced Settings), and saving the form never changes its stored value.
|
||||
JSON API saves are unaffected. Lets plugins keep deprecated or internal keys
|
||||
declared, e.g. countdown's row `id` and weather's `api_key` / `radar_zoom`.
|
||||
See `docs/widget-guide.md`.
|
||||
- Display settings no longer silently cut values on save: columns were capped
|
||||
at 128, chain length at 24 and PWM LSB nanoseconds at 500. Columns have no
|
||||
upper limit, chain length is 1–255 and rows must be even and 8–64 (see
|
||||
"Display hardware settings the library refuses" below);
|
||||
parallel is 1–3 and PWM dither bits 0–2, matching the library. A stored GPIO
|
||||
slowdown, PWM dither bits or refresh-rate cap of 0 no longer shows (and
|
||||
re-saves) as 3, 1 or 120, and the refresh cap accepts 0 (no cap). The config
|
||||
API rejects out-of-range or non-integer `rows`, `cols`, `chain_length`,
|
||||
`parallel`, `brightness`, `scan_mode`, `pwm_bits`, `pwm_dither_bits`,
|
||||
`pwm_lsb_nanoseconds`, `limit_refresh_rate_hz`, `row_address_type`,
|
||||
`multiplexing` and `gpio_slowdown` with a 400 (JSON `true` or `5.5` used to
|
||||
save as 1 or 5) instead of saving a config the matrix refuses to start with.
|
||||
- Display setting help tips and README / config-reference entries corrected
|
||||
and completed: `panel_type` and `rp1_rio` are documented,
|
||||
`show_refresh_rate` prints to the console rather than drawing on the panel,
|
||||
PWM dither bits raise the refresh rate rather than lowering it, and every
|
||||
numeric setting states its range.
|
||||
- Row Address Type offers 5, the SM5368 / B707 row shift register. The
|
||||
Waveshare 96x48 V2 panel (back silkscreen `24S-A1`) needs it with RGB
|
||||
sequence BGR and, on a Pi 4, a GPIO slowdown of 6–8. Panels with FM6124
|
||||
column drivers need no Panel Type.
|
||||
- On a Raspberry Pi 5 the pinned rgbmatrix library can drive only row address
|
||||
types 0 and 2, parallel 1–3 and the standard mappings. For anything else it
|
||||
returns no matrix, which the Python binding doesn't catch, so the display
|
||||
service crashed and restarted every 10 seconds. `DisplayManager` now refuses
|
||||
those settings before creating the matrix (logged, reported by
|
||||
`/api/v3/hardware/status`, fallback mode), the config API rejects them, and
|
||||
the Display form offers only row address types 0 and 2 on a Pi 5. The rule
|
||||
lives in `src/pi5_matrix_support.py` and must be re-checked when the
|
||||
submodule is bumped.
|
||||
- The Plugin Config Warning no longer lists core settings as plugins that are
|
||||
"in config but not installed" (seen as `auto_update` on 3.4.0, where the
|
||||
advice would have deleted the weekly-update setting). Core top-level config
|
||||
keys now live in one list, `src/core_config_keys.py`, which reconciliation
|
||||
uses and tests pin to `config.template.json` and the settings save endpoint.
|
||||
A stored warning is also dropped once its entry is no longer a plugin in
|
||||
config, so an old verdict clears without a restart.
|
||||
- **Check & Update All** no longer sends installed Starlark apps
|
||||
(`starlark:<app_id>` entries in `/plugins/installed`) to the plugin updater,
|
||||
which answered each with a 500 "plugin not found". `POST /plugins/update`
|
||||
now answers a `starlark:` id with a 400 saying it is a Starlark app. A
|
||||
request that gets no HTTP answer (e.g. the web service restarting mid-run) is
|
||||
re-sent with backoff instead of being counted as failed and skipped — that is
|
||||
how a disabled plugin with an update waiting was silently left out.
|
||||
- Three routes consulted the web process's plugin manifests without
|
||||
discovering plugins first, so they misbehaved from every `ledmatrix-web`
|
||||
restart until something else ran a discovery — in practice until someone
|
||||
opened the dashboard, measured at over three minutes on one rig.
|
||||
`POST /display/on-demand/start` and `POST /plugins/toggle` answered 404
|
||||
"Plugin not found", and `POST /config/main` did not recognise a plugin
|
||||
section, so it skipped secret separation and wrote the plugin's API key to
|
||||
`config.json` in plain text instead of `config_secrets.json`. The routes now
|
||||
discover when nothing has been discovered yet, and rescan once when a
|
||||
specific plugin id (or, for on-demand by mode, a mode) is not found, so a
|
||||
plugin installed since the last scan is found too.
|
||||
|
||||
### Security (request paths and inline handlers, siblings of #561)
|
||||
|
||||
- `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and
|
||||
`POST .../assets/delete` validate `plugin_id` with `src/common/path_safety`
|
||||
and answer 400 otherwise. A `plugin_id` of `../../config` used to create an
|
||||
`uploads/` directory outside `assets/plugins`, write images and
|
||||
`.metadata.json` there, list it, and delete whatever file a metadata entry
|
||||
named. Delete now unlinks only a path that resolves inside that plugin's
|
||||
uploads directory (any other entry is dropped without touching a file).
|
||||
- `PluginManager.get_plugin_directory()` returns `None` for anything but a
|
||||
plain name, so `POST /api/v3/plugins/action` can no longer run a manifest
|
||||
script from a directory outside the plugins directory (`../elsewhere`); the
|
||||
route also rejects such ids with 400.
|
||||
- Plugin Store, saved-repository and custom-registry buttons escape registry
|
||||
values for their inline `onclick` handlers (`jsStringAttr` in
|
||||
`plugins_manager.js`). An entry id containing `'` used to close the attribute
|
||||
and add its own script. The store's View button opens only `http(s)` links.
|
||||
- The uploaded-images list escapes each file's original name, path and ids; a
|
||||
name like `<img src=x onerror=...>.png` was inserted as markup.
|
||||
|
||||
### Display hardware settings the library refuses
|
||||
|
||||
- The rgbmatrix library answers several settings with no matrix or `abort()`
|
||||
rather than an error, on every board, so the display service crash-looped
|
||||
instead of falling back: rows above 64, `chain_length` above 255 (the Python
|
||||
binding stores it in one byte; this was documented as "no upper limit"), a
|
||||
misspelled `hardware_mapping`, and `parallel` 2–3 on a mapping with one output
|
||||
(`adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1`, `classic-pi1`) — the last
|
||||
one reachable from the Display form on the default mapping. The config API
|
||||
now refuses them with a 400 naming the setting, and `DisplayManager` refuses
|
||||
a hand-edited one before creating the matrix: logged, fallback mode, reported
|
||||
by `/api/v3/hardware/status`. The rules, including the Pi 5 ones, live in
|
||||
`src/matrix_support.py` and must be re-checked when the submodule is bumped.
|
||||
- `/api/v3/hardware/status` adds `cause`: `"settings"` when LEDMatrix refused
|
||||
the config, `"library"` when the library failed. The Display tab banner and
|
||||
the fallback log line give the Pi 5 rebuild hint only for a library failure;
|
||||
they used to follow every failure with it and with GPIO slowdown advice.
|
||||
- The Display form offers the `classic` and `classic-pi1` mappings and the
|
||||
`90` / `270` orientations, and renders any other stored mapping selected with
|
||||
a warning. With no option selected the browser posted the first one, so one
|
||||
unrelated save rewrote those settings. The API accepts orientation `90` and
|
||||
`270`, which `DisplayManager` already applied.
|
||||
- The display size the web preview, Starlark magnify default and
|
||||
`scripts/dev/vegas_audit.py` compute (`src/display_geometry.py`) now applies
|
||||
`orientation` and `pixel_mapper_config` as the library does: `Rotate:90`
|
||||
swaps width and height, `U-mapper` folds the chain.
|
||||
- One Raspberry Pi 5 GPIO slowdown recommendation everywhere: 1–3 in PIO mode,
|
||||
starting at 1. README and the config reference now describe the template
|
||||
values as the defaults; the "code default" values they listed never apply,
|
||||
because config migration fills missing keys from the template.
|
||||
|
||||
### Plugin system
|
||||
|
||||
- A plugin no longer starts with a schema warning and a degraded flag because
|
||||
config.json still holds a boolean where its schema now has an object with an
|
||||
`enabled` property (news' `global.dynamic_duration: true`). The loader reads
|
||||
the boolean as `{"enabled": <bool>}` before merging schema defaults and
|
||||
validating, the same rule the settings form already applies
|
||||
(`legacy_bool_as_object` in `src/plugin_system/schema_manager.py`). Nothing
|
||||
is written at load; the next save of that plugin's settings stores the object.
|
||||
Other type mismatches still warn.
|
||||
|
||||
### Core
|
||||
|
||||
- `ConfigManager.load_config()` no longer raises on a host without the POSIX
|
||||
ownership APIs. The self-heal that chgrp's `config_secrets.json` to the
|
||||
shared group (added in #416) looked up `os.geteuid` unguarded; that name does
|
||||
not exist on Windows, and the resulting `AttributeError` is not an `OSError`,
|
||||
so it escaped the helper's own "best-effort" handling and every caller's.
|
||||
Any Windows checkout with a `config/config_secrets.json` got a `ConfigError`
|
||||
from every config load and could not `import web_interface.app` at all.
|
||||
`ensure_shared_group_ownership()` now returns immediately when `os.geteuid`
|
||||
or `os.chown` is missing. No behaviour change on the Pi.
|
||||
- Restoring a backup on Windows no longer fails over files that already exist.
|
||||
The restore carries each replaced file's owner across with `os.chown`, which
|
||||
does not exist on Windows; the `AttributeError` escaped the per-file error
|
||||
handling, so the restore stopped at `config.json` with nothing restored. The
|
||||
ownership step is now skipped where `os.chown` is missing. No behaviour
|
||||
change on the Pi.
|
||||
|
||||
### Cache permissions
|
||||
|
||||
- The web interface can read what the display service caches again.
|
||||
`ledmatrix-web.service` carried `CacheDirectory=ledmatrix`, and systemd
|
||||
re-owns `/var/cache/ledmatrix` and its contents to the unit's `User=`
|
||||
whenever the directory's owner differs, which erased the `root:ledmatrix`
|
||||
setgid layout the installers set up: every file the root display service
|
||||
wrote afterwards was `root:root` 0660 and unreadable by the web interface
|
||||
(392 unreadable files on one rig, with display status, on-demand state and
|
||||
plugin health empty). Since #547 the web unit is rendered from its template
|
||||
on every install, so every fresh install hit this.
|
||||
`DiskCache.set` now gives each file the directory's group (when that
|
||||
directory is group-writable) and 0660 on the open descriptor before the
|
||||
rename, independent of setgid, which also closes a window where a fresh
|
||||
file was visible as mkstemp's 0600. `DiskCache.share_existing_files`
|
||||
repairs files an older version left behind, once per process, through
|
||||
`O_NOFOLLOW` descriptors, skipping hard links and other users' files.
|
||||
Existing installs only ever receive `git pull`, so that repair is the fix
|
||||
for them; new installs also drop `CacheDirectory=` and
|
||||
`CacheDirectoryMode=` from the web unit.
|
||||
- `install_web_service.sh` replaces an existing cache directory's group
|
||||
whenever the installing user is not in it. It used to replace only root's,
|
||||
so a `root:ledmatrix` directory belonging to a user outside that group was
|
||||
left alone and everything root wrote there stayed unreadable.
|
||||
- `/display/on-demand/status` and the current-display status read the display
|
||||
service's keys with `memory_ttl=0`, as every other cross-process reader
|
||||
already does. They served the first copy the web process had read for the
|
||||
full 120s `max_age`, so on-demand reported "active" for over 100 seconds
|
||||
after the file on disk said "idle".
|
||||
|
||||
### Automatic updates and Update Code
|
||||
|
||||
- An update that changes `web_interface/requirements.txt` is no longer rolled
|
||||
back on every auto-updating device. `safe_pip_install.sh` allowed only the
|
||||
root `requirements.txt`, so the install Update Code and the health check run
|
||||
for the web requirements was refused, and the health check rolls back any
|
||||
update whose dependencies failed (Install Base Requirements failed the same
|
||||
way). The wrapper now allows both core requirement files; a core requirement
|
||||
file symlinked out of the project is refused.
|
||||
- The automatic update's local-change check and Update Code now count changes
|
||||
the same way (`auto_update.local_changes`): permission-only changes and
|
||||
anything under `plugins/` or `plugin-repos/` don't count, and a core path
|
||||
that merely contains `plugins/` does. Such edits used to pass the check and
|
||||
then be stashed by the pull and never restored, despite "will not stash your
|
||||
changes". The pull's `--autostash` now carries them across. Update Code
|
||||
still stashes other edits; the automatic update refuses instead.
|
||||
- When the automatic update's own rollback fails (a partial pull, or a health
|
||||
check that never started), plugins are no longer updated and the display is
|
||||
not restarted, as the 3.4.0 notes promised.
|
||||
- The health check's dependency reinstall no longer retries pip failures or
|
||||
timeouts with a second bash path, and all reinstalls share a 10-minute
|
||||
budget, so a rollback finishes inside the unit's 30-minute limit instead of
|
||||
being killed mid-way.
|
||||
|
||||
### Small fixes (update-all, plugin system settings, scripts)
|
||||
|
||||
- **Check & Update All** counts a plugin that had nothing to update as
|
||||
"already up to date" instead of "updated". ZIP-installed monorepo plugins
|
||||
(most official ones) already at the registry version were called "updated
|
||||
successfully" on every run. `POST /plugins/update` now returns
|
||||
`data.update_status` (`updated`, `up_to_date`, `local_only`).
|
||||
- An update request that got an HTTP error answer without an `error_code`, or
|
||||
a body that is not JSON (e.g. a reverse proxy's 502 page), is no longer
|
||||
classified as `NETWORK_ERROR` and re-sent five times. Only a request that got
|
||||
no HTTP answer is retried; the rest are `API_ERROR` with the HTTP status.
|
||||
- The General tab no longer shows Auto Discover Plugins, Auto Load Enabled
|
||||
Plugins or Development Mode. Nothing read `plugin_system.auto_discover`,
|
||||
`auto_load_enabled` or `development_mode`: every enabled plugin was always
|
||||
discovered and loaded. Stored values are kept, and saving the General tab no
|
||||
longer rewrites them to `false`.
|
||||
- `BackgroundDataService` shares the 6-hour "ESPN rejects date ranges" memo
|
||||
with `fetch_espn_scoreboard`, so a background season fetch no longer spends a
|
||||
doomed range request first once either path has seen a rejection.
|
||||
- `scripts/install_plugin_dependencies.sh` installs from the configured
|
||||
`plugin_system.plugins_directory` (default `plugin-repos`, where the Plugin
|
||||
Store installs) and also scans `plugins/` for dev symlinks. It used to scan
|
||||
only `plugins/` and find nothing. A failed `pip install` is now reported as a
|
||||
failure instead of being hidden by `tee`.
|
||||
- `scripts/verify_installation.sh` no longer fails a healthy install: it
|
||||
checked for the removed `web_interface_v2.py` and port 5001. It and
|
||||
`scripts/verify_web_ui.sh` now check port 5000, where the web interface
|
||||
listens.
|
||||
- `scripts/install/install_service.sh --help` prints usage and exits without
|
||||
changes. It used to ignore the flag and reinstall and restart every service.
|
||||
Unknown arguments are rejected before anything runs.
|
||||
- `scripts/diagnose_web_ui.sh`, `scripts/diagnose_web_interface.sh` and
|
||||
`scripts/debug/debug_web_manual.py` apply the launcher's own autostart rule
|
||||
(only an explicit `web_display_autostart: false` keeps the web interface
|
||||
down), so a missing key no longer shows as disabled. The shell scripts also
|
||||
check `web_interface/blueprints/api_v3/`, which became a package, instead of
|
||||
reporting `api_v3.py` as missing.
|
||||
|
||||
### Docs and developer tools
|
||||
|
||||
- `docs/REST_API_REFERENCE.md` rechecked against every handler: request
|
||||
fields that made documented calls fail (`repo_url`, `action_id`/`params`,
|
||||
`files`/`image_id`, `font_file`+`font_family`, `?font=`, cache `key`,
|
||||
`auto_enable_ap_mode`, plugin limit keys) and response shapes are fixed, the
|
||||
removed font-override endpoints are gone, and the 26 undocumented routes
|
||||
(backup, git/auto-update, WiFi radio, Starlark editor, MQTT bridge, status
|
||||
endpoints, skins) are listed. Store search is `/plugins/store/list?query=`.
|
||||
- `FONT_MANAGER.md` no longer tells plugins to read
|
||||
`display_manager.font_manager`, which does not exist; use
|
||||
`plugin_manager.font_manager` / `BasePlugin._get_font_manager()`.
|
||||
- Plugin docs, `DisplayManager` docstrings and the bundled `starlark-apps`
|
||||
plugin now all read the display size from `display_manager.width/height`,
|
||||
which works in fallback mode where `matrix` is `None`.
|
||||
- `scripts/dev/dev_plugin_setup.sh link-github <name>` links the plugin from a
|
||||
clone of the `ledmatrix-plugins` monorepo (per-plugin `ledmatrix-<name>`
|
||||
repositories no longer exist). `dev_plugins.json` honours `github_user`,
|
||||
`plugins_repo` and `plugins_branch`; `dev_plugins.json.example` ships and
|
||||
`dev_plugins.json` is git-ignored. `update`/`status` handle monorepo links,
|
||||
and `status` no longer exits 1 when nothing is broken.
|
||||
- Rewritten for current behaviour: plugin dependency installation (web service
|
||||
runs as the installing user and installs through `safe_pip_install.sh`),
|
||||
`PLUGIN_CONFIG_ARCHITECTURE.md`, `MULTI_ROOT_WORKSPACE_SETUP.md`; stale
|
||||
`app.py` line numbers, `api_v3.py` paths, StreamManager method names,
|
||||
nonexistent version-bump scripts and `ledmatrix` service user references
|
||||
removed.
|
||||
|
||||
## 3.4.0
|
||||
|
||||
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
|
||||
|
||||
- `BasePlugin.get_update_interval()` (#555) — return seconds to override the
|
||||
manifest's `update_interval` at runtime (e.g. poll fast only while a game is
|
||||
live), or `None` to keep it. Clamped to at least 5 seconds; a raising or
|
||||
non-numeric return is ignored. Called every scheduling tick, so keep it
|
||||
cheap. Older cores never call it. See `docs/PLUGIN_API_REFERENCE.md`.
|
||||
- `src.common.scroll_config` (#523) — turns a plugin's scroll config into a
|
||||
configured `ScrollHelper` in one place, replacing per-plugin resolution that
|
||||
disagreed between tickers, and warns when a speed won't advance whole pixels
|
||||
per panel refresh. Floor on 3.4.0 to import it.
|
||||
- **Skins are marked unsupported.** No current scoreboard plugin builds on
|
||||
`src.base_classes`, so the skin hook (`SportsCore._render_game`) never runs.
|
||||
The web UI no longer shows the Visual Skin dropdown, the store hides and
|
||||
refuses `"type": "skin"` entries, and `GET /api/v3/skins` reports
|
||||
`"supported": false`. Saved `skin` config values still load and save.
|
||||
`src/skin_system/` is unchanged.
|
||||
- **Web preview size** now comes from `src/display_geometry.py`, the same
|
||||
computation `DisplayManager` uses: double-sided setups preview one screen,
|
||||
and a missing `chain_length` defaults to 2 everywhere (the Starlark magnify
|
||||
default and the sync handshake used 1). The module is core-internal: plugins
|
||||
keep reading `display_manager.width`/`height`.
|
||||
- `src.common.font_layout` (#539, #565) — `load_truetype()` is
|
||||
`ImageFont.truetype` with the layout engine pinned, so text lays out the same
|
||||
whether or not the host's Pillow was built with libraqm; `crisp_size()` and
|
||||
`FONT_PIXEL_GRID` give the size a bundled face renders on whole pixels at
|
||||
(`sports_card` still re-exports them); `resolve_asset_path()` resolves
|
||||
`assets/fonts/...` against the install root, not the working directory.
|
||||
Floor on 3.4.0 to import it. Relatedly, `DisplayManager` now draws text
|
||||
1-bit (#521), so golden images recorded against 3.3.x may need regenerating.
|
||||
|
||||
### Install and updates
|
||||
|
||||
**Weekly automatic updates (#581), off by default.** Switching on
|
||||
*Automatically check for and install updates once a week* on the General tab
|
||||
(or `first_time_install.sh --enable-auto-update` / `LEDMATRIX_AUTO_UPDATE=1`)
|
||||
updates the core and then every installed plugin once a week, preferably 2–5 AM
|
||||
local time. It follows the branch the checkout tracks — `main` on a standard
|
||||
install — so a device gets whatever has merged there, not only tagged releases.
|
||||
See `docs/WEB_INTERFACE_GUIDE.md`.
|
||||
|
||||
- The core step is skipped, with the reason shown, when the checkout has local
|
||||
edits or commits, a rebase or merge is in progress, the branch has no
|
||||
upstream, less than 300 MB is free, or that commit was already rolled back.
|
||||
- After pulling, `ledmatrix-update-verify.service` restarts the services and
|
||||
requires the web interface to answer and the display to stay up. If they
|
||||
don't, or the new requirements fail to install, it resets to the previous
|
||||
commit, reinstalls its requirements and restarts again. Anything but success
|
||||
shows under the toggle and as a banner on Overview.
|
||||
- Plugins update through the Plugin Store even when the core step is skipped,
|
||||
fails or is rolled back. A plugin version whose `ledmatrix_min_version` is
|
||||
above the device's core is held back, not installed. When the core did
|
||||
update, plugins wait for its health check, and are left alone if that check
|
||||
never reports or the rollback fails.
|
||||
- No SSH is needed: switching the toggle on restarts the display service, which
|
||||
installs the health-check units (`src/auto_update_setup.py`, core-internal
|
||||
and not a plugin API).
|
||||
|
||||
Installer and service fixes:
|
||||
|
||||
- rgbmatrix builds on ARMv6 boards (Pi Zero, Pi 1); an existing checkout is
|
||||
moved forward to the new pin and no longer left root-owned (#577).
|
||||
- `first_time_install.sh` grants the web user `safe_pip_install.sh`, as
|
||||
`configure_web_sudo.sh` already did, so plugin requirements install where
|
||||
the display service can see them (#579).
|
||||
- The web interface starts when `web_display_autostart` is missing or
|
||||
`config.json` is unreadable; only an explicit `false` keeps it down (#556).
|
||||
- Installers render every systemd unit from its `systemd/` template, so the
|
||||
boot-time unit-drift warning can clear, non-root installs included (#547).
|
||||
|
||||
### Scrolling
|
||||
|
||||
- **Frame pacing (#523).** The loop waits only for the rest of each panel
|
||||
refresh instead of a flat 8 ms: 44–46 fps → 100 fps, and slow frames 14% →
|
||||
0.02%, on a 2×128×64 chain. Sub-pixel blending is off by default again (it
|
||||
shimmered on pixel fonts; Vegas mode still opts in).
|
||||
- **Whole-pixel steps (#545).** At a speed `scroll_config` can render in whole
|
||||
pixels, every frame advances by exactly the same amount, removing about six
|
||||
hitches a second. A loop that can't keep up now scrolls slightly slow rather
|
||||
than jumping.
|
||||
- The eight sports scoreboards scroll through `scroll_config` too (#542): the
|
||||
default 50 px/s holds each frame for two refreshes instead of alternating
|
||||
0 px and 1 px steps.
|
||||
- **Frame stats ignore the pause between scrolls (#582).** The `Scroll frame
|
||||
stats` log line counted the idle wait before each scroll as one frame,
|
||||
inflating `max` and the stall rate. `docs/SCROLL_PERFORMANCE.md` now
|
||||
describes the line actually logged.
|
||||
|
||||
### Plugins
|
||||
|
||||
- `FontManager` registers the bundled `tom_thumb` font, so plugins no longer
|
||||
need a private loader (#534).
|
||||
- The test harness's `set_scrolling_state()` accepts `frame_hold`, as
|
||||
`DisplayManager`'s does (#534).
|
||||
- A `display()` with nothing to draw should return `False`, the only value the
|
||||
controller skips on; starlark-apps now does, rather than holding a black
|
||||
panel (#534).
|
||||
- Starlark apps may set `render_width`/`render_height` in their `config.json`
|
||||
to render at their own canvas size instead of Pixlet's 64×32 (#552).
|
||||
- `scripts/render_plugin.py --display-mode <mode>` renders one mode of a
|
||||
multi-mode plugin; scoreboards previously rendered blank (#522).
|
||||
- Scoreboards resolve their own directory under the real plugin loader
|
||||
(declare `_PLUGIN_DIR`), so 4x6 text snaps to its 7px grid instead of
|
||||
rendering a pixel narrow, and an unreadable schema is logged (#519, #520).
|
||||
`DisplayManager` loads 4x6 on that grid too (#565).
|
||||
- The 5x7 BDF face reports a real height, so rows stacked by
|
||||
`get_font_height()` no longer overlap (#539).
|
||||
- `LogoHelper` remembers a missing logo instead of warning every rotation
|
||||
(#548), and the decoded sports logo cache is bounded (#559).
|
||||
|
||||
### Web interface
|
||||
|
||||
- Installed Plugins has search, All / Enabled / Disabled / Updates filters and
|
||||
sort (#540).
|
||||
- Hardened and polished per the September 2026 audit (#568): utility classes
|
||||
such as `.hidden` actually exist, focus rings, labels and modal focus
|
||||
trapping, dark theme throughout, no overflow at phone width, and background
|
||||
streams pause when hidden, with first-load JS/CSS down from 1358 KB to 291 KB.
|
||||
- WiFi Connect works from the LEDMatrix-Setup hotspot: the page is answered
|
||||
before the hotspot drops, and reopening it shows why an attempt failed (#571).
|
||||
- Pixlet install, the Starlark app store and app toggles work again (#535,
|
||||
#537); the store uses the configured GitHub token and reports a rate limit
|
||||
instead of drawing a blank grid (#541).
|
||||
- Plugin config: geochron and news saves no longer always fail (#575), the page
|
||||
survives stored values the schema outgrew (#578), the form uses the full page
|
||||
height (#573), and file-manager widgets show the script's error (#574).
|
||||
- The live status stream reports real disk usage and available memory (#558);
|
||||
a system action refused for want of passwordless sudo says so and names
|
||||
`configure_web_sudo.sh` (#560).
|
||||
|
||||
### Tools and security
|
||||
|
||||
- **CodeQL triage (#561):** 129 of 134 alerts fixed. Three were exploitable
|
||||
path-handling flaws in the web interface and are closed; web UI escapers now
|
||||
escape quotes, and URL fields refuse script schemes. Path checks share
|
||||
`src/common/path_safety.py` (core-internal).
|
||||
- **Home Assistant MQTT bridge** (`integrations/mqtt_bridge`, #538): mode
|
||||
select, stop, power and brightness over MQTT Discovery.
|
||||
- **Tools tab** manages the MQTT bridge and the Pixlet editor (#554); the
|
||||
editor stays on loopback when `PIXLET_EDITOR_HOST` says so.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Updating a plugin whose directory is named for its manifest id (leaderboard,
|
||||
music, stocks, weather) silently did nothing (#536).
|
||||
- Plugin reconciliation no longer reports working plugins as stale or replaces
|
||||
their config with a stub, and the Overview banner advises each case correctly
|
||||
(#557).
|
||||
- Two config saves in the same second no longer share one backup, so rollback
|
||||
restores the version asked for (#564).
|
||||
- On-demand: a second request is honoured without a restart (#534), a pinned
|
||||
request stays on its mode, and restarting mid-session loads every plugin
|
||||
again (#538).
|
||||
- `/health` and `/display/current` report real state, and the preview no longer
|
||||
freezes on a leftover snapshot temp file (#534).
|
||||
|
||||
### Per-element display customization
|
||||
|
||||
**Per-element display customization, and the last mile of it into the web UI.**
|
||||
A user can set the font, size, colour, position, visibility and alignment of
|
||||
individual display elements per plugin -- and, where a plugin has display
|
||||
modes, separately per mode.
|
||||
|
||||
New public API a plugin may import via `src.*` (floor on the release that
|
||||
ships this):
|
||||
|
||||
- `src.element_style.layout_offset(config, element, axis, default, mode)` and
|
||||
`element_color(config, element, default, mode)` — the stateless reads the
|
||||
scoreboard helpers share. There were three copies of the offset read and two
|
||||
of the colour read; these are the one implementation, and they carry the
|
||||
element-name aliasing and the per-mode lookup.
|
||||
- `src.element_style.alias_keys(element)` — the names one element may be stored
|
||||
under. The style block names elements `score_text` while the layout block
|
||||
says `score`, and `records`/`record` and `status_text`/`status` split seven
|
||||
to two across the published schemas. A lookup tries the exact name first, so
|
||||
this is inert for a config that already matches.
|
||||
- `src.element_style.element_visible(config, element, default, mode)`,
|
||||
`element_align(...)` and `element_scale(...)` — the stateless reads for the
|
||||
three knobs the resolver already understood but no draw path consumed, so an
|
||||
element could be marked hidden in the web UI and still render.
|
||||
- `SportsCoreSharedMixin._draw_text_with_outline(..., element="score_text")` —
|
||||
naming the element resolves its colour by name and honours its visibility
|
||||
toggle. Without a name the colour is inferred from font-object identity,
|
||||
which cannot separate two elements sharing a face; that is the case every
|
||||
bitmap font is in, because a `freetype.Face` cannot be re-instantiated, and
|
||||
it is how a BDF-rendered element silently lost a configured colour. Shared
|
||||
faces now resolve when exactly one sharer has a colour set.
|
||||
- `LogoHelper.load_logo(..., scale=)` — applies a user's image scale, and keys
|
||||
the cache on the scaled box so two elements scaled differently cannot be
|
||||
served each other's image.
|
||||
- `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font
|
||||
can render at, or None for a scalable one. The web UI needs this to know
|
||||
whether a size control can take effect at all.
|
||||
- `ElementStyleResolver(config, defaults, mode=...)` plus `visible`, `align`
|
||||
and `scale` on `ElementStyle`. The mode binds to the resolver rather than
|
||||
being passed per call, so a plugin with one instance per mode makes every
|
||||
existing lookup mode-aware by setting one class attribute.
|
||||
- `BasePlugin.styles` / `styles_for(mode)` / `STYLE_MODE` — the accessor every
|
||||
plugin inherits, so adopting this is no longer a guarded import plus schema
|
||||
discovery plus resolver invalidation in each plugin.
|
||||
- `SportsCoreSharedMixin._get_layout_offset` — promoted from the plugins'
|
||||
bundled copies. Each still carries its own, which wins by MRO, so adopting
|
||||
it is a deletion.
|
||||
|
||||
Schema and web UI:
|
||||
|
||||
- A `customization` block is now rendered by a composite style editor: one row
|
||||
per element rather than nested accordions, with a tab per declared mode.
|
||||
Plugins that hand-wrote their style blocks get it without a plugin release;
|
||||
`x-style-elements` and `x-style-modes` declare it compactly.
|
||||
- Font fields become a real picker rather than a hardcoded `enum`, so a font
|
||||
the user uploads is selectable. Bitmap fonts taller than the element's
|
||||
declared size ceiling are filtered out, because a bitmap font ignores
|
||||
`font_size` and renders at its own size.
|
||||
- `/static/plugin-widgets/<plugin>/<widget>.js` serves a plugin's own web-UI
|
||||
widgets. The client half and the docs already existed; nothing served them.
|
||||
|
||||
Fixed:
|
||||
|
||||
- A bitmap font asked for a size it has no strike for fell back to
|
||||
*PressStart2P* — a different typeface — rather than to its own native size.
|
||||
32 of the 35 shipped fonts are bitmap, so this was reachable for most font
|
||||
choices.
|
||||
- The plugin config form read `config_schema.json` directly while the save
|
||||
route read it through `SchemaManager`. Only the latter expands a compact
|
||||
`x-style-elements` declaration, so a plugin using that form had a
|
||||
customization section that rendered as empty space.
|
||||
- `unshare_element_fonts` rebuilt faces through bare `ImageFont.truetype`,
|
||||
bypassing the layout engine `src/common/font_layout.py` pins. These were the
|
||||
only two call sites in `src/` doing so.
|
||||
- The form parser compared a schema type to a bare string, so a nullable field
|
||||
(`["array", "null"]`) never had its indexed colour inputs recombined, and a
|
||||
blank one became `[]` rather than null.
|
||||
|
||||
Removed:
|
||||
|
||||
- The Fonts tab's "Element Font Overrides" panel and its three endpoints. They
|
||||
reported success and saved nothing, and the element keys the panel offered
|
||||
(`nfl.live.score`, `clock.time`) are read by no plugin, so wiring them to the
|
||||
real `FontManager` methods would still have changed nothing on the panel.
|
||||
Per-element font choice now lives in each plugin's own config editor.
|
||||
- "Detected Manager Fonts", which listed every installed font with a hardcoded
|
||||
usage count.
|
||||
- Two dead client-side config-form renderers in `app-shell.js` (~580 lines) and
|
||||
the legacy `plugins/config_manager.js`, superseded by server-side rendering.
|
||||
|
||||
## 3.3.0
|
||||
|
||||
Historical note: tags `v3.3.0` and `v3.3.1` both report `__version__` "3.3.0" and both ship `src/common/sports_shared.py`, so a "3.3.0" floor always means a core with `sports_shared`.
|
||||
|
||||
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||
enforceable; this ships the last three shared modules and completes the store
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||
`config.json` (default per `config/config.template.json`).
|
||||
`config.json` (default per `config/config.template.json:167`).
|
||||
Not gitignored.
|
||||
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||
@@ -23,14 +23,14 @@
|
||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||
- Config schemas use JSON Schema Draft-7
|
||||
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
||||
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
|
||||
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
|
||||
via `"x-secret": true` in the plugin's config schema, and deep-merged into
|
||||
the plugin's config dict at load time — plugins read them with plain
|
||||
`config.get(...)`, never a separate accessor
|
||||
|
||||
## Dev Workflow
|
||||
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` clones the `ledmatrix-plugins` monorepo into `~/.ledmatrix-dev-plugins/` and links its `plugins/<name>` under the manifest id (add a repo URL for a plugin with its own repo; or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up. Fork/location overrides: `dev_plugins.json` (from `dev_plugins.json.example`)
|
||||
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` (or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up
|
||||
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
|
||||
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
|
||||
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
|
||||
@@ -45,12 +45,9 @@
|
||||
- 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`
|
||||
|
||||
## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET
|
||||
- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes`
|
||||
- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py`
|
||||
- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section)
|
||||
## Skin System (visual overlays for sports scoreboards)
|
||||
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports/core.py`
|
||||
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||
@@ -63,4 +60,3 @@
|
||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
|
||||
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
|
||||
- `src/pi5_matrix_support.py` hardcodes what the pinned `rpi-rgb-led-matrix-master` can drive on a Raspberry Pi 5 (`Rp1PioConfigSupported()` in `lib/rp1/rp1_pio_backend.cc`). Re-check it whenever the submodule is bumped: a stale rule blocks Pi 5 settings the new library supports, and a missing one lets the display service crash-loop. `src/matrix_support.py` holds the same kind of rules for every board (rows, chain length, mapping names, parallel per mapping) and needs the same re-check
|
||||
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
Designed novice-first, with power tools kept within reach.
|
||||
|
||||
- **Primary: hobbyist builders.** People who assembled an LED matrix panel on a Raspberry Pi, often by following the install video, and are frequently new to Linux and the Pi. They set the display up once (panel size, timezone, WiFi), install and enable a few plugins, then come back occasionally to tweak what the panel shows. They usually reach the control panel from a phone or laptop on their home network, sometimes as an installed home-screen app.
|
||||
- **Secondary: tinkerers and plugin developers.** Comfortable with SSH, `config.json`, and GitHub. They lean on the Config Editor, Logs, Cache, Operation History, Tools, GitHub-repo installs, and per-plugin config while building or debugging. Their tools must stay reachable without sitting in the novice's path.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
LEDMatrix turns a Raspberry Pi and an RGB LED matrix panel into an information-rich display (clock, weather, calendar, sports scores, stocks, music, and more) through a plugin platform. The web control panel ("LED Matrix Control") is where the display gets configured, extended, and kept healthy.
|
||||
|
||||
Success means a builder gets from a freshly flashed Pi to a working, personalized display without needing a terminal, and can keep it running (updates, recovery, troubleshooting) the same way.
|
||||
|
||||
## Positioning
|
||||
|
||||
Four strengths define LEDMatrix, and future work must protect all of them:
|
||||
|
||||
1. **Plugin ecosystem.** The core ships only `starlark-apps` and `web-ui-info`; everything else comes from the built-in Plugin Store (the official `ledmatrix-plugins` monorepo), third-party GitHub repos, or Starlark (Tidbyt-style) apps. Each installed plugin gets its own configuration tab, generated from its schema.
|
||||
2. **Runs on tiny Pis.** The UI is served by the same device that drives the matrix, on boards as small as the Pi Zero 2 W (512 MB), Pi 3/3B+, and the 1 GB Pi 4.
|
||||
3. **Recovers without SSH.** WiFi access-point fallback with a captive setup page, backup & restore, in-UI updates, live logs, diagnostics, service control, and plugin health let users fix problems from the browser.
|
||||
4. **Open and community-led.** GPL-3.0, a Discord community, and contributions welcome. The maintainer (ChuckBuilds) builds in public and openly relies on AI development tools.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- **Access.** Served on the local network at `http://<pi-ip>:5000` by the `ledmatrix-web` service. It is installable as a PWA (`web_interface/static/v3/manifest.json`, short name "LEDMatrix").
|
||||
- **First run.** When the Pi has no network it creates its own WiFi access point, so the captive setup page (`templates/v3/captive_setup.html`) may be the very first screen a user sees, on a phone, with no internet connection.
|
||||
- **Navigation.**
|
||||
- System tabs: Overview, General, WiFi, Schedule, Display, Rotation, Config Editor, Backup & Restore, Fonts, Logs, Cache, Operation History, Tools.
|
||||
- A second row holds Plugin Manager (with the Plugin Store), Starlark Apps, and one tab per installed plugin.
|
||||
- **Live data.** The Overview shows system stats (CPU, memory, temperature, power/throttling) and a live display preview, streamed over SSE.
|
||||
- **Getting Started checklist.** The Overview's first-run checklist runs: set panel size → set timezone → install a plugin → enable it → configure it.
|
||||
- **Development.** `python3 scripts/dev_server.py` gives a browser preview without the display loop; `python3 run.py -e` runs the full display in emulator mode.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
- **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working.
|
||||
- **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls.
|
||||
- **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets.
|
||||
- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode.
|
||||
- **Open decisions** (offered during init, not adopted as constraints):
|
||||
- Whether the UI must work fully offline, with no CDN fallbacks at runtime.
|
||||
- Whether a Node/CSS build step is acceptable for contributors.
|
||||
- Whether a formal accessibility standard (e.g. WCAG 2.2 AA) is a requirement.
|
||||
|
||||
## Brand Commitments
|
||||
|
||||
- **Names.** The product is "LEDMatrix" and the web UI is titled "LED Matrix Control". The maintainer brand is ChuckBuilds.
|
||||
- **Voice.** Friendly, honest, and learning-in-public, as in the README.
|
||||
- **App icons.** They live in `web_interface/static/v3/icons/`.
|
||||
|
||||
No other visual identity has been made binding.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
- **Photos.** Real photographs of running displays are linked in `README.md` (clock, weather, calendar, NHL/MLB/NFL/NCAA, stocks, music).
|
||||
- **Video.** YouTube install and walkthrough videos from ChuckBuilds.
|
||||
- **Docs.** Extensive documentation in `docs/`, e.g. `WEB_INTERFACE_GUIDE.md`, `GETTING_STARTED.md`, `WIFI_NETWORK_SETUP.md`, `LOW_MEMORY_BOARDS.md`, `PLUGIN_STORE_GUIDE.md`.
|
||||
- **Absences.** There are no testimonials, user counts, or benchmark figures. Do not fabricate them.
|
||||
|
||||
## Product Principles
|
||||
|
||||
1. **Novice path first, power one click away.** Default views serve the first-time builder, while advanced tools stay discoverable for tinkerers.
|
||||
2. **Never strand the user at a terminal.** Every setup, recovery, and troubleshooting task has a browser path, including from the AP-mode captive page.
|
||||
3. **Respect the Pi.** Every feature is paid for in memory and CPU on a Pi Zero 2 W that is also driving the display.
|
||||
4. **The ecosystem is the product.** Plugins, including third-party ones, must feel first-class and keep working across core UI changes.
|
||||
5. **Honest and welcoming.** Plain language, truthful status, and no overstated claims, in keeping with an open, community-built project.
|
||||
@@ -148,7 +148,7 @@ The system supports live, recent, and upcoming game information for multiple spo
|
||||
```bash
|
||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
||||
```
|
||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and start `gpio_slowdown` at `1`, raising it a step at a time if the image flickers or shows garbage (see `gpio_slowdown` under Display Settings).
|
||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
||||
- **1GB models (Pi 3B / 3B+) and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`.
|
||||
|
||||
|
||||
@@ -463,12 +463,13 @@ For plugin development, check out the [Hello World Plugin](https://github.com/Ch
|
||||
|
||||
### Visual Skins for Scoreboards
|
||||
|
||||
**Not supported yet.** Skins are meant to restyle a sports scoreboard's
|
||||
live/recent/upcoming screens without forking the plugin, but the current
|
||||
scoreboard plugins don't render them: a selected skin has no effect. The web
|
||||
UI doesn't offer skin install or selection for that reason. The skin system
|
||||
and its docs stay in place for when scoreboards adopt it; see
|
||||
[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why.
|
||||
Want a different look for a sports scoreboard without forking the plugin?
|
||||
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
||||
handling data, scheduling, caching, and vegas mode. Install one with
|
||||
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
||||
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
||||
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
||||
including a ready-made Claude Code prompt).
|
||||
|
||||
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||
</details>
|
||||
@@ -485,10 +486,6 @@ If you are copying my exact setup, you can likely leave the defaults alone. Howe
|
||||
|
||||
The display settings are located in `config/config.json` under the `"display"` key and are organized into three main sections: `hardware`, `runtime`, and `display_durations`.
|
||||
|
||||
The defaults below are the values in `config/config.template.json`. They are what applies when you haven't set a key: on every load, LEDMatrix adds any key your `config.json` lacks from the template, so `DisplayManager`'s own fallbacks are never reached on a normal install.
|
||||
|
||||
The web UI and the config API refuse values the rgbmatrix library can't start with. If one is written into `config.json` by hand anyway, the display logs which setting it is (`Failed to initialize RGB Matrix` in `sudo journalctl -u ledmatrix`), runs in fallback mode, and the Display tab shows the message.
|
||||
|
||||
### Hardware Configuration (`display.hardware`)
|
||||
|
||||
These settings control the physical hardware configuration and how the matrix is driven.
|
||||
@@ -498,18 +495,15 @@ These settings control the physical hardware configuration and how the matrix is
|
||||
- **`rows`** (integer, default: 32)
|
||||
- Number of LED rows (vertical pixels) in each panel
|
||||
- Common values: 16, 32, 48, 64
|
||||
- An even number from 8 to 64, the most the rgbmatrix library drives per panel
|
||||
- Must match your physical panel configuration
|
||||
|
||||
- **`cols`** (integer, default: 64)
|
||||
- Number of LED columns (horizontal pixels) in each panel
|
||||
- Common values: 32, 64, 96, 128
|
||||
- At least 16, with no upper limit
|
||||
- Must match your physical panel configuration
|
||||
|
||||
- **`chain_length`** (integer, default: 2)
|
||||
- Number of LED panels chained together horizontally
|
||||
- 1 to 255 (the library's Python binding stores it in one byte); longer chains lower the refresh rate
|
||||
- If you have 2 panels side-by-side, set to 2
|
||||
- If you have 4 panels in a row, set to 4
|
||||
- Total display width = `cols × chain_length`
|
||||
@@ -518,70 +512,68 @@ These settings control the physical hardware configuration and how the matrix is
|
||||
- Number of parallel chains (panels stacked vertically)
|
||||
- Use 1 for a single row of panels
|
||||
- Use 2 if you have panels stacked in two rows
|
||||
- 1–3, and no more than your `hardware_mapping` has outputs: `regular` and `classic` have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); `adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1` and `classic-pi1` have 1. The library stops the display service outright on a mismatch, so it is refused
|
||||
- Total display height = `rows × parallel`
|
||||
|
||||
#### Brightness and Visual Settings
|
||||
|
||||
- **`brightness`** (integer, 1-100, default: 90)
|
||||
- **`brightness`** (integer, 0-100, default: 90)
|
||||
- Display brightness level
|
||||
- Lower values (1-50) are dimmer, higher values (50-100) are brighter
|
||||
- Lower values (0-50) are dimmer, higher values (50-100) are brighter
|
||||
- Recommended: 70-90 for indoor use, 90-100 for bright environments
|
||||
- Very high brightness may cause distortion or require more power
|
||||
|
||||
#### Hardware Mapping
|
||||
|
||||
- **`hardware_mapping`** (string, default: "adafruit-hat")
|
||||
- **`hardware_mapping`** (string, default: "adafruit-hat-pwm")
|
||||
- Specifies which GPIO pin mapping to use for your hardware
|
||||
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
|
||||
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic)
|
||||
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
|
||||
- **`"classic"`** / **`"classic-pi1"`**: the library's original pin-outs, for old adapter boards wired to them. Not used by current HATs
|
||||
- Any other name is refused. `compute-module` is only compiled in when the library is built with `ENABLE_WIDE_GPIO_COMPUTE_MODULE`, which the installer doesn't do. On a Raspberry Pi 5, `classic-pi1` isn't supported
|
||||
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
|
||||
- Hardware pulsing (see `disable_hardware_pulsing`) needs the panel's OE line on GPIO 18, which `adafruit-hat-pwm` and `regular` provide and `adafruit-hat` does not
|
||||
|
||||
#### PWM (Pulse Width Modulation) Settings
|
||||
|
||||
These settings affect color fidelity and smoothness of color transitions:
|
||||
|
||||
- **`pwm_bits`** (integer, 1-11, default: 9)
|
||||
- Color depth per channel: how many brightness levels each LED gets
|
||||
- Higher values (9-11) = more color levels, smoother gradients, lower refresh rate
|
||||
- Lower values (7-8) = the subtlest shades are dropped for a higher refresh rate; `1` gives 8 colors
|
||||
- Recommended: 9-10
|
||||
- **`pwm_bits`** (integer, default: 9)
|
||||
- Number of bits used for PWM (affects color depth)
|
||||
- Higher values (9-11) = more color levels, smoother gradients
|
||||
- Lower values (7-8) = fewer color levels, but may improve stability on some hardware
|
||||
- Range: 1-11, recommended: 9-10
|
||||
|
||||
- **`pwm_dither_bits`** (integer, 0-2, default: 1)
|
||||
- Time-dithers the lowest color bits: their brightness comes from showing them on only some frames
|
||||
- Raises the refresh rate; the cost is that dark shades can shimmer slightly
|
||||
- `0` = steadiest dim colors, `2` = fastest
|
||||
- The rgbmatrix library accepts only 0-2; a higher value stops the display starting
|
||||
- **`pwm_dither_bits`** (integer, default: 1)
|
||||
- Additional dithering bits for smoother color transitions
|
||||
- Helps reduce color banding in gradients
|
||||
- Higher values (1-2) = smoother gradients but may impact performance
|
||||
- Range: 0-2, recommended: 1
|
||||
|
||||
- **`pwm_lsb_nanoseconds`** (integer, 50-3000, default: 130)
|
||||
- On-time of the least significant color bit; each higher bit doubles it
|
||||
- Lower values = higher refresh rate, but can cost color accuracy or add ghosting on some panels
|
||||
- Higher values = less ghosting (faint trails behind bright text on black), lower refresh rate
|
||||
- **`pwm_lsb_nanoseconds`** (integer, default: 130)
|
||||
- Least significant bit timing in nanoseconds
|
||||
- Controls the base timing for PWM signals
|
||||
- Lower values = faster PWM, higher values = slower PWM
|
||||
- Typical range: 100-300 nanoseconds
|
||||
- May need adjustment if you see flickering or color issues
|
||||
|
||||
#### Advanced Hardware Settings
|
||||
|
||||
- **`scan_mode`** (integer, 0-1, default: 0)
|
||||
- Order the rows are refreshed in: `0` = progressive, `1` = interlaced
|
||||
- Interlaced can look a little smoother when the refresh rate is very low, but usually shows a comb effect on anything moving
|
||||
- Leave at `0` unless you are tuning a slow setup
|
||||
- **`scan_mode`** (integer, default: 0)
|
||||
- Panel scan mode (how rows are addressed)
|
||||
- Common values: 0 (progressive), 1 (interlaced)
|
||||
- Most panels use 0, but some require 1
|
||||
- Check your panel datasheet if colors appear incorrect
|
||||
|
||||
- **`limit_refresh_rate_hz`** (integer, default: 100)
|
||||
- Caps the panel refresh rate in Hz; `0` = no cap
|
||||
- A steady cap reduces flicker caused by other activity on the Pi, and in camera recordings
|
||||
- Scroll speeds are worked out against this value (against 100 Hz when it is `0`), so a cap the panel can actually hold keeps scrolling even
|
||||
- Recommended: 80-120. `sudo python3 scripts/scroll_speeds.py --measure` reports the rate your panel really achieves
|
||||
- Maximum refresh rate in Hz (frames per second)
|
||||
- Caps the refresh rate for better stability
|
||||
- Lower values (60-80) = more stable, less CPU usage
|
||||
- Higher values (100-120) = smoother animations, more CPU usage
|
||||
- Recommended: 80-100 for most setups
|
||||
|
||||
- **`disable_hardware_pulsing`** (boolean, default: false)
|
||||
- `false` = the Pi's hardware PWM times each brightness pulse; `true` = software timing
|
||||
- Leave `false` where possible. Software timing is less exact, so a row, or the whole panel, can briefly flash brighter
|
||||
- Hardware pulsing needs the panel's OE line on GPIO 18 (`adafruit-hat-pwm`, `regular`, the Adafruit Triple LED Matrix Bonnet). With `adafruit-hat` the library uses software timing anyway
|
||||
- It also needs the Pi's onboard sound driver (`snd_bcm2835`) disabled, which `first_time_install.sh` does. Set `true` only if you need the Pi's own audio
|
||||
- Disables hardware pulsing (usually leave as false)
|
||||
- Set to `true` only if you experience timing issues
|
||||
- Most users should leave this as `false`
|
||||
|
||||
- **`inverse_colors`** (boolean, default: false)
|
||||
- Inverts all colors (red becomes cyan, etc.)
|
||||
@@ -589,9 +581,9 @@ These settings affect color fidelity and smoothness of color transitions:
|
||||
- Set to `true` only if colors appear inverted
|
||||
|
||||
- **`show_refresh_rate`** (boolean, default: false)
|
||||
- Prints the live refresh rate to the console; nothing is drawn on the panel
|
||||
- Readable when you stop the service and run `sudo python3 run.py` in a terminal; under the service the output is buffered
|
||||
- `sudo python3 scripts/scroll_speeds.py --measure` is an easier way to see the real refresh rate
|
||||
- Displays the current refresh rate on the matrix (for debugging)
|
||||
- Set to `true` to see FPS on the display
|
||||
- Useful for troubleshooting performance issues
|
||||
|
||||
#### Advanced Panel Configuration (Advanced Users Only)
|
||||
|
||||
@@ -601,7 +593,6 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Color channel order for your LED panel
|
||||
- Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR"
|
||||
- Most panels use "RGB", but some use "GRB" or other orders
|
||||
- If red shows as blue, try "BGR" (the Waveshare 96x48 V2 needs it)
|
||||
- Check your panel datasheet if colors appear wrong
|
||||
|
||||
- **`pixel_mapper_config`** (string, default: "")
|
||||
@@ -616,68 +607,35 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
|
||||
settings) if the panel is mounted upside down — useful for optimizing
|
||||
where the Raspberry Pi and wiring sit relative to the mounting location
|
||||
- `"90"` and `"270"` are for a panel mounted on its side; they swap the
|
||||
display's width and height
|
||||
- Applied independently of `pixel_mapper_config` (appended as a trailing
|
||||
`Rotate:<degrees>` mapper), so custom mapper configs keep working alongside it
|
||||
`Rotate:180` mapper), so custom mapper configs keep working alongside it
|
||||
|
||||
- **`row_address_type`** (integer, default: 0)
|
||||
- How rows are addressed on the panel
|
||||
- Most panels use 0 (direct addressing)
|
||||
- 1 = AB-addressed, 2 = direct row select, 3 = ABC-addressed,
|
||||
4 = ABC shift + DE direct (SM5266), 5 = SM5368 / B707 row shift register
|
||||
- ABC panels (no E line, e.g. many 128x64 FM6124 panels) use 3
|
||||
- Panels with SM5368 row drivers use 5 with `led_rgb_sequence` `"BGR"` —
|
||||
e.g. the Waveshare 96x48 V2 (back silkscreen `24S-A1`; the V1, `24S-A2.1`,
|
||||
uses the defaults). This is what Waveshare's `96X48_1_24_SM5368` panel
|
||||
type sets in their library fork.
|
||||
- SM5368 row drivers are timing-sensitive: if rows jump up and down or the
|
||||
bottom row shows a copy of other rows, raise `gpio_slowdown`. On a Pi 4
|
||||
with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a
|
||||
stable image.
|
||||
- On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2
|
||||
(and `parallel` 1-3). Anything else would crash the display service, so on
|
||||
a Pi 5 the web UI offers only 0 and 2, the config API refuses the others,
|
||||
and if one is set in `config.json` anyway the display logs why and runs in
|
||||
fallback mode
|
||||
- Some panels require 1 (AB addressing) or 2 (ABC addressing)
|
||||
- Check your panel datasheet if display appears corrupted
|
||||
|
||||
- **`multiplexing`** (integer, 0-22, default: 0)
|
||||
- How pixels are wired on outdoor/specialty panels (P10, P8, P4 and P3 outdoor modules and similar) whose LEDs aren't laid out in straight rows
|
||||
- `0` = direct (standard indoor panels)
|
||||
- `1` Stripe, `2` Checkered, `3` Spiral, `4` ZStripe, `5` ZnMirrorZStripe,
|
||||
`6` Coreman, `7` Kaler2Scan, `8` ZStripeUneven, `9` P10-128x4-Z,
|
||||
`10` QiangLiQ8, `11` InversedZStripe, `12`–`14` P10Outdoor1R1G1B v1–v3,
|
||||
`15` P10CoremanMapper, `16` P8Outdoor1R1G1B, `17` FlippedStripe,
|
||||
`18` P10-32x16-HalfScan, `19` P10-32x16-QuarterScan, `20` P3Outdoor-64x64,
|
||||
`21` DoubleZMultiplex, `22` P4Outdoor-80x40
|
||||
- If the image is scrambled in a repeating pattern, try the value named after your panel first
|
||||
|
||||
- **`panel_type`** (string, default: `""`)
|
||||
- Sends a start-up initialization sequence to driver chips that need one
|
||||
- `""` = Standard (no initialization) — right for most panels, including FM6124 / FM6124D / FM6124DJ
|
||||
- `"FM6126A"` or `"FM6127"` for panels with those chips; try `"FM6126A"` if the panel stays dark or lights only the first pixel on Standard
|
||||
- **`multiplexing`** (integer, default: 0)
|
||||
- Panel multiplexing type
|
||||
- 0 = no multiplexing (standard panels)
|
||||
- Higher values for panels with different multiplexing schemes
|
||||
- Check your panel datasheet for the correct value
|
||||
|
||||
### Runtime Configuration (`display.runtime`)
|
||||
|
||||
These settings control runtime behavior and GPIO timing:
|
||||
|
||||
- **`gpio_slowdown`** (integer, default: 3)
|
||||
- GPIO timing slowdown factor (0-10): slows GPIO writes so the panel electronics keep up. Higher is more reliable but lowers the refresh rate
|
||||
- **Critical setting**: depends on your Raspberry Pi model and your panel
|
||||
- **Raspberry Pi Zero/1**: 0-1
|
||||
- **Raspberry Pi 2/3**: 1-3
|
||||
- **Raspberry Pi 4**: 2-4 (the config template ships 3)
|
||||
- **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default). Start at `1` (the library treats `0` as `1` there) and raise it a step at a time if the image flickers or shows garbage — chained panels are the likeliest to need it
|
||||
- Panels on `row_address_type` 5 (SM5368 row drivers) can need 6-8 on a Pi 4
|
||||
- Too low: garbage, flicker or rows jumping. Too high: a lower refresh rate
|
||||
- GPIO timing slowdown factor
|
||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||
- **Raspberry Pi 3**: Use 3
|
||||
- **Raspberry Pi 4**: Use 4
|
||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- **Raspberry Pi Zero/1**: Use 1-2
|
||||
- Incorrect values can cause display corruption, flickering, or system instability
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
|
||||
- **`rp1_rio`** (integer, 0 or 1, default: 0) — Raspberry Pi 5 only
|
||||
- Which driver the Pi 5's RP1 chip uses: `0` = PIO (default, less CPU), `1` = RIO (registered I/O, can reach a higher refresh rate)
|
||||
- In RIO mode the effect of `gpio_slowdown` is inverted: higher values may be faster
|
||||
- Ignored on a Pi 0-4, and applied only if the installed rgbmatrix library supports it
|
||||
|
||||
### Display Durations (`display.display_durations`)
|
||||
|
||||
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
|
||||
@@ -710,7 +668,7 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
||||
- Some plugins can automatically adjust their display time based on content
|
||||
- This setting limits how long they can extend (prevents one display from dominating)
|
||||
- Example: If set to 60, a plugin can extend up to 60 seconds even if it requests longer
|
||||
- Leave unset to use the default cap (180 seconds; the web UI accepts 30-1800)
|
||||
- Leave unset to use the default cap (typically 90 seconds)
|
||||
|
||||
### Example Configuration
|
||||
|
||||
@@ -757,14 +715,6 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
||||
- Verify `hardware_mapping` matches your HAT/connection type
|
||||
- Try adjusting `gpio_slowdown`
|
||||
- Ensure your display doesn't need the E-Addressable line
|
||||
- If it went blank right after a settings change, the Display tab shows a "simulation mode" banner, and `sudo journalctl -u ledmatrix` shows `Failed to initialize RGB Matrix` followed by the reason. When LEDMatrix refused the settings (for example more than 64 `rows`, `parallel` 2 on an `adafruit-hat` mapping, a misspelled `hardware_mapping`, or on a Raspberry Pi 5 a `row_address_type` other than 0 or 2), the message names each one: change them, save, and restart the display service. Otherwise the library itself failed, and its own message just before names the problem
|
||||
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
|
||||
|
||||
**Rows jump up and down, or the bottom row repeats other rows:**
|
||||
- Raise `gpio_slowdown` a step at a time (SM5368 panels on `row_address_type` 5 can need 6-8 on a Pi 4)
|
||||
|
||||
**A row or the whole panel briefly flashes brighter:**
|
||||
- Set `disable_hardware_pulsing` to `false` (needs the OE line on GPIO 18; see `hardware_mapping`)
|
||||
|
||||
**Colors are wrong or inverted:**
|
||||
- Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work)
|
||||
@@ -839,11 +789,9 @@ sudo ./scripts/install/install_service.sh
|
||||
|
||||
The script will:
|
||||
- Detect your user account and home directory
|
||||
- Install `ledmatrix.service` (display, runs as root), `ledmatrix-web.service`
|
||||
(web interface, runs as your user) and the `ledmatrix-update-verify` units,
|
||||
with the correct paths
|
||||
- Enable them to start on boot
|
||||
- Start them immediately
|
||||
- Install the service file with the correct paths
|
||||
- Enable the service to start on boot
|
||||
- Start the service immediately
|
||||
|
||||
### Managing the Service
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"auto_update": {
|
||||
"enabled": false
|
||||
},
|
||||
"schedule": {
|
||||
"enabled": false,
|
||||
"mode": "per-day",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
|
||||
"github_user": "ChuckBuilds",
|
||||
"plugins_repo": "ledmatrix-plugins",
|
||||
"plugins_branch": "main"
|
||||
}
|
||||
@@ -223,8 +223,8 @@ The harness already renders every plugin at a spread of sizes (now
|
||||
including 96x48):
|
||||
|
||||
```bash
|
||||
python scripts/check_plugin.py --plugin <plugin-id> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py --plugin <plugin-id> --width 96 --height 48
|
||||
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||||
```
|
||||
|
||||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||
|
||||
+14
-36
@@ -377,16 +377,9 @@ Vegas mode consists of four core components working together to provide smooth 1
|
||||
5. Compose into continuous stream with separators
|
||||
|
||||
**Key Methods:**
|
||||
- `get_next_segment()` - Returns the next buffered `ContentSegment` (or `None`)
|
||||
- `take_next_group(count=None, offscreen_only=False)` - Hands over the next
|
||||
slice of the rotation as `(plugin_id, images)` groups
|
||||
- `get_grouped_content_for_composition()` - Buffered images grouped by plugin
|
||||
- `mark_plugin_updated(plugin_id)` / `process_updates()` - Refresh one
|
||||
plugin's segment in place when its data changes
|
||||
- `refresh()` - Re-read the plugin list and config
|
||||
- `advance_cycle()` - Clear the active buffer when a scroll cycle completes
|
||||
|
||||
(`src/vegas_mode/stream_manager.py`)
|
||||
- `get_stream_content()` - Returns current stream content as PIL Image
|
||||
- `advance_stream(pixels)` - Advances stream by N pixels
|
||||
- `refresh_stream()` - Regenerates stream from current plugins
|
||||
|
||||
#### 3. PluginAdapter
|
||||
|
||||
@@ -440,14 +433,10 @@ Vegas mode consists of four core components working together to provide smooth 1
|
||||
- **Frame Rate Control:** Precise timing to maintain 125 FPS
|
||||
- **Pre-rendered Content:** Plugins pre-render during update()
|
||||
|
||||
**Scroll Speed Calculation:** motion is by elapsed time; `target_fps` paces
|
||||
the render loop, not the speed.
|
||||
**Scroll Speed Calculation:**
|
||||
```python
|
||||
# frame_based_scrolling: false
|
||||
scroll_position += scroll_speed * elapsed_time # scroll_speed in px/s
|
||||
# frame_based_scrolling: true (the default) -- not stepping, just a clamp
|
||||
applied = clamp(scroll_speed * scroll_delay, 0.1, 5) / scroll_delay
|
||||
scroll_position += applied * elapsed_time
|
||||
pixels_per_frame = (scroll_speed / target_fps)
|
||||
scroll_position += pixels_per_frame * elapsed_time
|
||||
```
|
||||
|
||||
#### Component Interactions
|
||||
@@ -563,8 +552,7 @@ time when something is active.
|
||||
|
||||
### REST API Reference
|
||||
|
||||
The API is mounted at `/api/v3` (the `api_v3` blueprint, registered in
|
||||
`web_interface/app.py`). Full details: [REST_API_REFERENCE.md](REST_API_REFERENCE.md#display-control).
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
|
||||
#### Start On-Demand Display
|
||||
|
||||
@@ -620,30 +608,20 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
||||
|
||||
# Response:
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"state": {
|
||||
"active": true,
|
||||
"plugin_id": "weather",
|
||||
"mode": "weather",
|
||||
"duration": 30,
|
||||
"pinned": false,
|
||||
"status": "running",
|
||||
"last_updated": 1234567890.1
|
||||
},
|
||||
"service": {"active": true, "returncode": 0, "stdout": "active", "stderr": ""}
|
||||
}
|
||||
"active": true,
|
||||
"plugin_id": "weather",
|
||||
"mode": "weather",
|
||||
"remaining": 25.5,
|
||||
"pinned": false,
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
When nothing is running on demand, `data.state` is
|
||||
`{"active": false, "status": "idle", "last_updated": null}`.
|
||||
|
||||
> There is no public Python on-demand API. The display controller's
|
||||
> on-demand machinery is internal — drive it through the REST endpoints
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
|
||||
> `web_interface/blueprints/api_v3.py`) write a request into the cache
|
||||
> manager under the `display_on_demand_request` key, which
|
||||
> `DisplayController._poll_on_demand_requests()`
|
||||
> (`src/display_controller.py`) picks up. A separate
|
||||
|
||||
@@ -250,21 +250,14 @@ WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard
|
||||
|
||||
## Checking Configuration via API
|
||||
|
||||
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
`/api/v3` in `web_interface/app.py`.
|
||||
The API blueprint mounts at `/api/v3` (`web_interface/app.py:144`).
|
||||
|
||||
```bash
|
||||
# Get full main config (includes all plugin sections; credential-named
|
||||
# fields are blanked in the response)
|
||||
# Get full main config (includes all plugin sections)
|
||||
curl http://localhost:5000/api/v3/config/main
|
||||
|
||||
# Change some settings: only the keys you send are changed
|
||||
# Save updated main config
|
||||
curl -X POST http://localhost:5000/api/v3/config/main \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"timezone": "America/Chicago", "brightness": 80}'
|
||||
|
||||
# Replace config.json wholesale (advanced)
|
||||
curl -X POST http://localhost:5000/api/v3/config/raw/main \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @new-config.json
|
||||
|
||||
@@ -276,10 +269,8 @@ curl "http://localhost:5000/api/v3/plugins/config?plugin_id=football-scoreboard"
|
||||
```
|
||||
|
||||
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
|
||||
> endpoint. `POST /plugins/config` validates against the plugin's schema
|
||||
> and rejects an invalid config with `400`; `POST /config/main` checks the
|
||||
> individual fields it knows (display hardware values, durations, Vegas
|
||||
> and sync settings). See
|
||||
> endpoint — config validation runs server-side automatically when you
|
||||
> POST to `/config/main` or `/plugins/config`. See
|
||||
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
+33
-36
@@ -17,7 +17,7 @@ tooling against it.
|
||||
|---|---|---|---|
|
||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
|
||||
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config |
|
||||
|
||||
## `schedule` — display on/off hours
|
||||
@@ -47,44 +47,39 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
||||
## `display.hardware` — matrix panel hardware
|
||||
|
||||
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
||||
in `DisplayManager._setup_matrix` (`src/display_manager.py`). Defaults are the
|
||||
`config/config.template.json` values: `ConfigManager` adds any key missing from
|
||||
`config.json` from the template on load, so `DisplayManager`'s own fallbacks
|
||||
don't apply on a normal install.
|
||||
|
||||
The ranges are what the pinned rgbmatrix library and its Python binding accept
|
||||
(`src/matrix_support.py`). The config API refuses anything else; a value
|
||||
hand-edited into `config.json` makes the display log the setting and run in
|
||||
fallback mode instead of starting the matrix.
|
||||
in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `rows` / `cols` | int, `32` / `64` — rows: even, 8–64; cols: at least 16 |
|
||||
| `chain_length` | int, `2` — 1–255 (the Python binding stores it in one byte) |
|
||||
| `parallel` | int, `1` — 1–3, and no more than `hardware_mapping` has outputs (`regular`, `classic`: 3; the others: 1) |
|
||||
| `brightness` | int, `90` — 1–100 |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` — `"adafruit-hat-pwm"`, `"adafruit-hat"`, `"regular"`, `"regular-pi1"`, `"classic"` or `"classic-pi1"` (case-insensitive; `compute-module` isn't in the installed build). A Pi 5 doesn't support `"classic-pi1"` |
|
||||
| `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
|
||||
| `pwm_bits` | int, `9` — 1–11 |
|
||||
| `pwm_dither_bits` | int, `1` — 0–2 |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` — 50–3000 |
|
||||
| `disable_hardware_pulsing` | bool, `false` — `true` times brightness pulses in software (less exact); hardware pulsing needs the OE line on GPIO 18 and the Pi's onboard sound driver off |
|
||||
| `rows` / `cols` | int, `32` / `64` |
|
||||
| `chain_length` | int, `2` |
|
||||
| `parallel` | int, `1` |
|
||||
| `brightness` | int, `90` |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
|
||||
| `scan_mode` | int, `0` |
|
||||
| `pwm_bits` | int, `9` (code default 10) |
|
||||
| `pwm_dither_bits` | int, `1` |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) |
|
||||
| `disable_hardware_pulsing` | bool, `false` |
|
||||
| `inverse_colors` | bool, `false` |
|
||||
| `show_refresh_rate` | bool, `false` — prints the refresh rate to stdout; draws nothing on the panel |
|
||||
| `led_rgb_sequence` | string, `"RGB"` — `"RGB"`, `"RBG"`, `"GRB"`, `"GBR"`, `"BRG"` or `"BGR"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"`; mappers that rotate or fold the chain change the display size plugins and the web preview see |
|
||||
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); `"90"` / `"270"` for a panel on its side, swapping width and height; composed onto `pixel_mapper_config` as a trailing `Rotate:<degrees>` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
|
||||
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
|
||||
| `show_refresh_rate` | bool, `false` |
|
||||
| `led_rgb_sequence` | string, `"RGB"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
||||
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||
|
||||
Where "code default" differs from the template value, the code default only
|
||||
applies if the key is missing entirely from your config.
|
||||
|
||||
## `display.runtime`
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). On a Pi 5 in PIO mode start at `1` (`0` acts as `1`) and raise it if the image flickers or shows garbage. Panels on `row_address_type` `5` (SM5368 row drivers) can need 6–8 on a Pi 4 — lower values make rows jump |
|
||||
| `rp1_rio` | int, `0` | Pi 5 only: `0` = PIO (less CPU), `1` = RIO (higher refresh; `gpio_slowdown` effect inverted). Applied only if the installed matrix library supports it |
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis |
|
||||
| `rp1_rio` | int, `0` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) |
|
||||
|
||||
## `display.double_sided`
|
||||
|
||||
@@ -139,8 +134,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `dynamic_duration_enabled` | bool, `true` |
|
||||
| `min_cycle_duration` | int, `60` |
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
|
||||
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
|
||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||
@@ -157,12 +152,14 @@ Read by `src/common/sync_manager.py` and `src/display_controller.py`.
|
||||
|
||||
## `plugin_system`
|
||||
|
||||
Read by the plugin loader/manager (`src/plugin_system/`).
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins and the only directory the plugin loader scans. Read by `PluginManager` and `PluginStoreManager` (`src/plugin_system/`); editable under General settings |
|
||||
| `auto_discover` | bool, `true` | **Unused.** Legacy key, read by nothing. Plugins are always discovered, and every plugin with `enabled: true` is loaded. Not shown in the web UI; may be left in or removed from config.json |
|
||||
| `auto_load_enabled` | bool, `true` | **Unused.** Legacy key, read by nothing (see `auto_discover`). To keep a plugin installed but dormant, set its own `enabled` to `false` |
|
||||
| `development_mode` | bool, `false` | **Unused.** Legacy key, read by nothing |
|
||||
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins |
|
||||
| `auto_discover` | bool, `true` | Scan the plugins directory at startup |
|
||||
| `auto_load_enabled` | bool, `true` | Load discovered plugins automatically |
|
||||
| `development_mode` | bool, `false` | Development conveniences in the web UI (editable under General settings) |
|
||||
|
||||
## Plugin config blocks
|
||||
|
||||
|
||||
+5
-17
@@ -1,14 +1,5 @@
|
||||
# Creating Skins
|
||||
|
||||
> **Not supported yet: skins don't render with the current scoreboard
|
||||
> plugins.** The only render hook is `SportsCore._render_game()` in
|
||||
> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or
|
||||
> third-party) builds on `src.base_classes`, so a skin you build here passes
|
||||
> `validate_skin.py` but never appears on the matrix. The web UI and Plugin
|
||||
> Store don't offer skins for that reason. Details:
|
||||
> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below
|
||||
> stays accurate for the skin API itself.
|
||||
|
||||
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||||
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||||
doing vegas mode; your skin only draws. Architecture background:
|
||||
@@ -28,9 +19,7 @@ panel sizes with **no hardware, no network, no running service**, saves PNGs
|
||||
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
||||
edit → validate → look at the PNGs.
|
||||
|
||||
To select it, add to your plugin's section in `config/config.json` (this is
|
||||
stored and validated, but has no visible effect until a scoreboard uses the
|
||||
skin hook — see the note at the top):
|
||||
To see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||
|
||||
```json
|
||||
"baseball-scoreboard": {
|
||||
@@ -39,8 +28,8 @@ skin hook — see the note at the top):
|
||||
}
|
||||
```
|
||||
|
||||
The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
|
||||
`"skin"` also accepts a per-mode mapping:
|
||||
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||||
matching skin is installed). `"skin"` also accepts a per-mode mapping:
|
||||
`{"live": "my-skin", "recent": "built-in"}`.
|
||||
|
||||
## The manifest (`skin.json`)
|
||||
@@ -245,9 +234,8 @@ Tips that keep Claude (and you) out of trouble:
|
||||
dev machine
|
||||
|
||||
Distribute by publishing the directory as a git repo (users
|
||||
`git clone <repo> skins/<id>`). Registry entries with `"type": "skin"` are
|
||||
hidden and refused by the Plugin Store while skins are unsupported (see
|
||||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
|
||||
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||
|
||||
**Trust note:** a skin is Python running inside the display service — the
|
||||
same trust level as a plugin. Review code before installing skins from
|
||||
|
||||
+33
-38
@@ -12,28 +12,10 @@
|
||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||
- Manager font registration and detection
|
||||
- Plugin font management
|
||||
- Programmatic per-element font overrides
|
||||
- Manual font overrides via web interface
|
||||
- Performance monitoring and caching
|
||||
- Dynamic font discovery
|
||||
|
||||
## Getting the FontManager
|
||||
|
||||
There is one shared FontManager per display process. The display controller
|
||||
creates it and hands it to the `PluginManager`, so a plugin reaches it
|
||||
through its `plugin_manager`:
|
||||
|
||||
```python
|
||||
class MyPlugin(BasePlugin):
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.font_manager = self._get_font_manager()
|
||||
```
|
||||
|
||||
`BasePlugin._get_font_manager()` returns `plugin_manager.font_manager`, or a
|
||||
standalone FontManager when none is available (test harnesses, mocks).
|
||||
`DisplayManager` has **no** `font_manager` attribute —
|
||||
`display_manager.font_manager` raises `AttributeError`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Manager-Centric Design
|
||||
@@ -58,9 +40,8 @@ Manager requests font → Check manual overrides → Apply manager choice → Ca
|
||||
from src.font_manager import FontManager
|
||||
|
||||
class MyManager:
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
||||
self.display_manager = display_manager
|
||||
self.font_manager = plugin_manager.font_manager # Shared FontManager
|
||||
def __init__(self, config, display_manager, cache_manager):
|
||||
self.font_manager = display_manager.font_manager # Access shared FontManager
|
||||
self.manager_id = "my_manager"
|
||||
|
||||
def display(self):
|
||||
@@ -99,9 +80,8 @@ class MyManager:
|
||||
|
||||
```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
|
||||
def __init__(self, config, display_manager, cache_manager):
|
||||
self.font_manager = display_manager.font_manager
|
||||
self.manager_id = "advanced_manager"
|
||||
|
||||
# Define your font specifications
|
||||
@@ -172,13 +152,19 @@ font = self.font_manager.resolve_font(
|
||||
> 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/`. It does not show fonts registered
|
||||
> through `register_manager_font()` and 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.
|
||||
> The **Fonts** tab in the web UI that lists detected
|
||||
> manager-registered fonts is still a **placeholder
|
||||
> implementation** — fonts that managers register through
|
||||
> `register_manager_font()` do not yet appear there. The
|
||||
> programmatic per-element override workflow described in
|
||||
> [Manual Font Overrides](#manual-font-overrides) below
|
||||
> (`set_override()` / `remove_override()` / the
|
||||
> `config/font_overrides.json` store) **does** work today and is
|
||||
> the supported way to override a font for an element until the
|
||||
> Fonts tab is wired up. If you can't wait and need a workaround
|
||||
> right now, you can also just load the font directly with PIL
|
||||
> (or `freetype-py` for BDF) inside your plugin's `manager.py`
|
||||
> and skip the override system entirely.
|
||||
|
||||
### Plugin Font Registration
|
||||
|
||||
@@ -214,10 +200,10 @@ In your plugin's `manifest.json`:
|
||||
### Using Plugin Fonts
|
||||
|
||||
```python
|
||||
class MyPlugin(BasePlugin):
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.font_manager = self._get_font_manager()
|
||||
class PluginManager:
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_id):
|
||||
self.font_manager = display_manager.font_manager
|
||||
self.plugin_id = plugin_id
|
||||
|
||||
def display(self):
|
||||
# Use plugin font (automatically namespaced)
|
||||
@@ -233,8 +219,17 @@ class MyPlugin(BasePlugin):
|
||||
|
||||
## 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.
|
||||
Users can override any font through the web interface:
|
||||
|
||||
1. Navigate to **Fonts** tab
|
||||
2. View **Detected Manager Fonts** to see what's currently in use
|
||||
3. In **Element Overrides** section:
|
||||
- Select the element (e.g., "nfl.live.score")
|
||||
- Choose a different font family
|
||||
- Choose a different size
|
||||
- Click **Add Override**
|
||||
|
||||
Overrides are stored in `config/font_overrides.json` and persist across restarts.
|
||||
|
||||
### Programmatic Overrides
|
||||
|
||||
|
||||
@@ -83,10 +83,10 @@ You should see:
|
||||
|
||||
1. Open the **Display** tab
|
||||
2. Set your matrix configuration:
|
||||
- **Rows**: match your panel — commonly 32 or 64; any even number
|
||||
from 8 to 64
|
||||
- **Columns**: match your panel — commonly 64 or 96; at least 16,
|
||||
with no upper limit
|
||||
- **Rows**: 32 or 64 (match your hardware)
|
||||
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
||||
in the 1–128 range, but 64 and 96 are the values the bundled
|
||||
panel hardware ships with
|
||||
- **Chain Length**: Number of panels chained horizontally
|
||||
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||
mod) or `adafruit-hat` (without). See the root README for the full list.
|
||||
|
||||
@@ -59,12 +59,9 @@ sudo ./scripts/install/install_service.sh
|
||||
After updating your scripts, verify they still work:
|
||||
|
||||
```bash
|
||||
# Check the installation scripts are at their new paths
|
||||
# Test installation scripts (if needed)
|
||||
ls scripts/install/*.sh
|
||||
./scripts/install/install_service.sh --help # prints usage only
|
||||
# Note: running install_service.sh for real (with sudo, no --help)
|
||||
# reinstalls, enables and restarts ledmatrix.service, ledmatrix-web.service
|
||||
# and the update-verify units.
|
||||
sudo ./scripts/install/install_service.sh --help
|
||||
|
||||
# Test permission scripts
|
||||
ls scripts/fix_perms/*.sh
|
||||
|
||||
@@ -1,154 +1,169 @@
|
||||
# Multi-Root Workspace Setup Guide
|
||||
|
||||
This document explains how to work on LEDMatrix and the official plugins side
|
||||
by side, with one editor workspace and the plugins loaded straight from your
|
||||
plugin checkout.
|
||||
This document explains how the LEDMatrix project uses a multi-root workspace to manage plugins as separate Git repositories.
|
||||
|
||||
## Overview
|
||||
|
||||
Official plugins live in a single repository,
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins), with one
|
||||
directory per plugin under `plugins/`. There are no separate per-plugin
|
||||
repositories. For development you clone that monorepo **next to** LEDMatrix
|
||||
and symlink its plugin directories into LEDMatrix's `plugin-repos/`, which is
|
||||
where the plugin loader looks by default.
|
||||
The LEDMatrix project has been migrated from a git submodule implementation to a **multi-root workspace** implementation for managing plugins. This allows:
|
||||
|
||||
- ✅ Plugin code stays in the monorepo checkout, with its own git history
|
||||
- ✅ LEDMatrix discovers the plugins through symlinks in `plugin-repos/`
|
||||
- ✅ `LEDMatrix.code-workspace` opens both repositories in VS Code/Cursor
|
||||
- ✅ Plugins to exist as independent Git repositories
|
||||
- ✅ Updates to plugins without modifying the LEDMatrix project
|
||||
- ✅ Easy development workflow with all repos in one workspace
|
||||
- ✅ Plugin system discovers plugins via symlinks in `plugin-repos/`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
~/Github/
|
||||
├── LEDMatrix/ # Main project
|
||||
│ ├── plugin-repos/ # Plugin directory the loader scans
|
||||
│ │ ├── starlark-apps/ # Bundled with LEDMatrix (tracked in git)
|
||||
│ │ ├── web-ui-info/ # Bundled with LEDMatrix (tracked in git)
|
||||
│ │ ├── clock-simple -> ../../ledmatrix-plugins/plugins/clock-simple
|
||||
│ │ ├── ledmatrix-weather -> ../../ledmatrix-plugins/plugins/ledmatrix-weather
|
||||
/home/chuck/Github/
|
||||
├── LEDMatrix/ # Main project
|
||||
│ ├── plugin-repos/ # Symlinks to actual repos (managed automatically)
|
||||
│ │ ├── ledmatrix-clock-simple -> ../../ledmatrix-clock-simple
|
||||
│ │ ├── ledmatrix-weather -> ../../ledmatrix-weather
|
||||
│ │ └── ...
|
||||
│ ├── LEDMatrix.code-workspace # Opens LEDMatrix and ../ledmatrix-plugins
|
||||
│ ├── LEDMatrix.code-workspace # Multi-root workspace configuration
|
||||
│ └── ...
|
||||
└── ledmatrix-plugins/ # Plugin monorepo (git repo)
|
||||
├── plugins/
|
||||
│ ├── clock-simple/
|
||||
│ ├── ledmatrix-weather/
|
||||
│ └── ...
|
||||
├── plugins.json # Store registry
|
||||
└── update_registry.py
|
||||
├── ledmatrix-clock-simple/ # Plugin repository (actual git repo)
|
||||
├── ledmatrix-weather/ # Plugin repository (actual git repo)
|
||||
├── ledmatrix-football-scoreboard/ # Plugin repository (actual git repo)
|
||||
└── ... # Other plugin repos
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. The plugin monorepo
|
||||
### 1. Plugin Repositories
|
||||
|
||||
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
||||
scripts below look for `../ledmatrix-plugins` relative to the LEDMatrix
|
||||
root):
|
||||
All plugin repositories are cloned to `/home/chuck/Github/` (parent directory of LEDMatrix) as regular Git repositories:
|
||||
|
||||
```bash
|
||||
cd ~/Github
|
||||
git clone https://github.com/ChuckBuilds/ledmatrix-plugins.git
|
||||
```
|
||||
- `ledmatrix-clock-simple/`
|
||||
- `ledmatrix-weather/`
|
||||
- `ledmatrix-football-scoreboard/`
|
||||
- etc.
|
||||
|
||||
### 2. Symlinks in plugin-repos/
|
||||
|
||||
`scripts/setup_plugin_repos.py` creates one symlink per plugin in
|
||||
`LEDMatrix/plugin-repos/`, named after the plugin's manifest `id` and pointing
|
||||
at `../ledmatrix-plugins/plugins/<dir>`.
|
||||
The `LEDMatrix/plugin-repos/` directory contains symlinks pointing to the actual repositories in the parent directory. This allows the plugin system to discover plugins without modifying the project structure.
|
||||
|
||||
### 3. Multi-root workspace
|
||||
### 3. Multi-Root Workspace
|
||||
|
||||
`LEDMatrix.code-workspace` has two roots: LEDMatrix itself and
|
||||
`../ledmatrix-plugins`.
|
||||
The `LEDMatrix.code-workspace` file configures VS Code/Cursor to open all plugin repositories as separate workspace roots, allowing easy development across all repos.
|
||||
|
||||
## Setup Scripts
|
||||
|
||||
### Initial Setup
|
||||
|
||||
If you already have plugin repositories cloned, use the setup script:
|
||||
|
||||
```bash
|
||||
cd ~/Github/LEDMatrix
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
```
|
||||
|
||||
This script:
|
||||
- Reads each `manifest.json` under `../ledmatrix-plugins/plugins/`
|
||||
- Creates `plugin-repos/<id>` symlinks (relative) to those directories
|
||||
- Leaves correct links alone, replaces links that point elsewhere, and skips
|
||||
(does not overwrite) a real directory of the same name — for example a
|
||||
plugin you installed from the Plugin Store. Remove that directory first if
|
||||
you want the linked copy.
|
||||
- Reads the workspace configuration
|
||||
- Creates symlinks in `plugin-repos/` pointing to actual repos
|
||||
- Verifies all links are created correctly
|
||||
|
||||
### Updating Plugins
|
||||
|
||||
To update all plugin repositories:
|
||||
|
||||
```bash
|
||||
cd ~/Github/LEDMatrix
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
python3 scripts/update_plugin_repos.py
|
||||
```
|
||||
|
||||
This runs `git pull` in `../ledmatrix-plugins` and prints the result. The
|
||||
symlinks pick up the new code; restart the display to load it.
|
||||
This script:
|
||||
- Finds all plugins in the workspace
|
||||
- Runs `git pull` on each repository
|
||||
- Reports which plugins were updated
|
||||
|
||||
## Configuration
|
||||
|
||||
The loader reads plugins from `plugin_system.plugins_directory` in
|
||||
`config/config.json`. The default is already right for this setup:
|
||||
The plugin system is configured in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos"
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `plugins_directory` points to `plugin-repos/`, which contains symlinks to the actual repositories.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Daily Development
|
||||
|
||||
1. **Open Workspace**: Open `LEDMatrix.code-workspace` in VS Code/Cursor
|
||||
2. **Edit Plugins**: Edit code under `ledmatrix-plugins/plugins/<plugin>/`
|
||||
3. **Test**: `python3 run.py -e` (emulator) or
|
||||
`python3 scripts/check_plugin.py --plugin <id>` from LEDMatrix
|
||||
4. **Ship**: Bump `version` in the plugin's `manifest.json`, run
|
||||
`python update_registry.py` in ledmatrix-plugins, commit there
|
||||
2. **All Repos Available**: All plugin repos appear as separate folders in the workspace
|
||||
3. **Edit Plugins**: Edit plugin code directly in their repositories
|
||||
4. **Update Plugins**: Run `update_plugin_repos.py` to pull latest changes
|
||||
|
||||
### Adding New Plugins
|
||||
|
||||
1. Create `plugins/<your-plugin-id>/` in the monorepo checkout
|
||||
2. Run `python3 scripts/setup_plugin_repos.py` in LEDMatrix to link it
|
||||
1. **Clone Repository**: Clone the new plugin repo to `/home/chuck/Github/`
|
||||
2. **Add to Workspace**: Add the plugin folder to `LEDMatrix.code-workspace`
|
||||
3. **Create Symlink**: Run `setup_plugin_repos.py` to create the symlink
|
||||
|
||||
### Updating Individual Plugins
|
||||
|
||||
Since plugins are regular Git repositories, you can update them individually:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/ledmatrix-weather
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
Or update all at once:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
python3 scripts/update_plugin_repos.py
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **No Submodule Hassle**: No need to update `.gitmodules` or run `git submodule update`
|
||||
2. **Independent Updates**: Update plugins independently without touching LEDMatrix
|
||||
3. **Clean Separation**: Each plugin is a separate repository with its own history
|
||||
4. **Easy Development**: Multi-root workspace makes it easy to work across repos
|
||||
5. **Automatic Discovery**: Plugin system automatically discovers plugins via symlinks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugins not discovered
|
||||
### Symlinks Not Working
|
||||
|
||||
If plugins aren't being discovered:
|
||||
|
||||
```bash
|
||||
cd ~/Github/LEDMatrix
|
||||
ls -la plugin-repos/ # links present and not broken?
|
||||
python3 scripts/setup_plugin_repos.py # recreate them
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
```
|
||||
|
||||
Also check that `plugin_system.plugins_directory` is `plugin-repos`.
|
||||
This will recreate all symlinks.
|
||||
|
||||
### "Monorepo plugins directory not found"
|
||||
### Missing Plugins
|
||||
|
||||
`setup_plugin_repos.py` expects the monorepo at `../ledmatrix-plugins`. Clone
|
||||
it there (or symlink it there).
|
||||
If a plugin is in the workspace but not found:
|
||||
|
||||
### Plugin updates not showing
|
||||
1. Check if the repo exists in `/home/chuck/Github/`
|
||||
2. Check if the symlink exists in `plugin-repos/`
|
||||
3. Run `setup_plugin_repos.py` to recreate symlinks
|
||||
|
||||
1. Verify the link target: `ls -la plugin-repos/<id>`
|
||||
2. Check that you're editing the monorepo checkout, not a store-installed copy
|
||||
3. Restart the LEDMatrix service (or `run.py`)
|
||||
### Plugin Updates Not Showing
|
||||
|
||||
If changes to plugins aren't appearing:
|
||||
|
||||
1. Verify the symlink points to the correct directory: `ls -la plugin-repos/ledmatrix-weather`
|
||||
2. Check that you're editing in the actual repo, not a copy
|
||||
3. Restart the LEDMatrix service if running
|
||||
|
||||
## Notes
|
||||
|
||||
- `plugin-repos/` is tracked in git only for the bundled plugins
|
||||
(`starlark-apps`, `web-ui-info`). The symlinks you create are untracked
|
||||
files; don't commit them.
|
||||
- For linking a single plugin into `plugins/` instead (without a sibling
|
||||
checkout), see `scripts/dev/dev_plugin_setup.sh` in the
|
||||
[Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md).
|
||||
- When changing a plugin in the monorepo, bump its manifest `version` and run
|
||||
`python update_registry.py`, or users won't receive the update.
|
||||
- The `plugin-repos/` directory is tracked in git, but only contains symlinks
|
||||
- Actual plugin code lives in `/home/chuck/Github/ledmatrix-*/`
|
||||
- Each plugin repo can be updated independently via `git pull`
|
||||
- The LEDMatrix project doesn't need to be updated when plugins change
|
||||
|
||||
@@ -36,11 +36,7 @@ self.enabled # Boolean enabled status
|
||||
|
||||
#### `update() -> None`
|
||||
|
||||
Fetch/update data for this plugin. Called on the plugin's update interval:
|
||||
the value `get_update_interval()` returns when it returns a number, otherwise
|
||||
the static interval: the `update_interval` in the plugin's manifest, else
|
||||
`update_interval` in the plugin's section of `config.json`, else 60 seconds
|
||||
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
|
||||
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
@@ -113,46 +109,6 @@ Called when plugin is enabled.
|
||||
|
||||
Called when plugin is disabled.
|
||||
|
||||
#### `get_update_interval() -> Optional[float]`
|
||||
|
||||
How often this plugin wants `update()` called right now, in seconds. The
|
||||
manifest's `update_interval` is one static number; override this when the
|
||||
right cadence depends on state only the plugin knows, e.g. poll every 15s
|
||||
while a game is live and fall back to the manifest value otherwise.
|
||||
|
||||
**Returns**: seconds as a number, or `None` (the default) for no opinion.
|
||||
|
||||
How `PluginManager` (`_get_plugin_update_interval` in
|
||||
`src/plugin_system/plugin_manager.py`) resolves the interval on each
|
||||
scheduling tick:
|
||||
|
||||
1. It calls `get_update_interval()`. A number wins over everything below.
|
||||
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
|
||||
raised to it.
|
||||
2. If the hook returns `None`, raises, or returns something that isn't a
|
||||
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
|
||||
static interval applies: the manifest's `update_interval`, else
|
||||
`update_interval` in the plugin's section of `config.json`, else 60
|
||||
seconds.
|
||||
|
||||
The static value is cached per plugin until the plugin is loaded or
|
||||
unloaded again, so editing `update_interval` in config takes effect on the
|
||||
next reload. The hook's return value is never cached: it is called on every
|
||||
tick of the display loop, so keep it to attribute reads (no config lookups,
|
||||
no I/O, no locks a fetch might hold) and don't let it raise.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
def get_update_interval(self):
|
||||
# Fast while something is live, manifest default otherwise.
|
||||
if any(m.live_games for m in self._live_managers):
|
||||
return self.config.get("live_update_interval", 15)
|
||||
return None
|
||||
```
|
||||
|
||||
Added in core 3.4.0; older cores never call it, so a plugin that relies on
|
||||
it should floor `ledmatrix_min_version` at `3.4.0`.
|
||||
|
||||
#### `get_display_duration() -> float`
|
||||
|
||||
Get display duration for this plugin. Can be overridden for dynamic durations.
|
||||
@@ -514,59 +470,21 @@ self.display_manager.draw_text_with_icons(
|
||||
|
||||
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
||||
|
||||
#### `set_scrolling_state(is_scrolling: bool, frame_hold: int = 1) -> None`
|
||||
#### `set_scrolling_state(is_scrolling: bool) -> None`
|
||||
|
||||
Mark the display as scrolling or not scrolling, and set this scroll's frame
|
||||
pacing. Call it when a scroll starts (calling it on every scroll frame is fine)
|
||||
and with `False` when it stops.
|
||||
Mark the display as scrolling or not scrolling. Call when scrolling starts/stops.
|
||||
|
||||
**Parameters**:
|
||||
- `is_scrolling` (bool): True if currently scrolling, False otherwise
|
||||
- `frame_hold` (int, default 1): how many panel refreshes each pushed frame is
|
||||
held for (clamped to 1-255; ignored when `is_scrolling` is False, which
|
||||
resets it to 1). Pass the `frame_hold` of the settings
|
||||
`src.common.scroll_config.configure()` returned. Added in core 3.4.0.
|
||||
|
||||
**Why `frame_hold` matters**: `scroll_config.configure()` snaps the speed to
|
||||
one the panel can show in whole pixels and sets the `ScrollHelper` to advance a
|
||||
fixed number of pixels on every presented frame -- no clock is consulted. The
|
||||
panel presents frames at its refresh rate divided by the hold, so the hold is
|
||||
part of the speed. Omit it and a 50 px/s scroll (1px every 2nd refresh on a
|
||||
100 Hz panel) runs at 100 px/s. The hold is not applied by `configure()`
|
||||
because it must not outlive the scroll: plugins share one display manager.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
from src.common import scroll_config
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_manager.width, self.display_manager.height, self.logger)
|
||||
# ...later, hand it content with self.scroll_helper.set_scrolling_image(img)
|
||||
self.scroll_settings = scroll_config.configure(
|
||||
self.scroll_helper,
|
||||
plugin_config=self.config,
|
||||
global_config=self.global_config,
|
||||
display_manager=self.display_manager,
|
||||
plugin_logger=self.logger,
|
||||
)
|
||||
|
||||
def display(self, force_clear=False):
|
||||
self.display_manager.set_scrolling_state(
|
||||
True, frame_hold=self.scroll_settings.frame_hold)
|
||||
self.scroll_helper.update_scroll_position()
|
||||
self.display_manager.image = self.scroll_helper.get_visible_portion()
|
||||
self.display_manager.update_display()
|
||||
if self.scroll_helper.is_scroll_complete():
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
# Scroll content...
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
```
|
||||
|
||||
Don't pace the loop with `time.sleep()`: `update_display()` blocks on the
|
||||
panel's vsync, which is what paces a scroll. See `docs/SCROLL_PERFORMANCE.md`
|
||||
for choosing a speed.
|
||||
|
||||
#### `is_currently_scrolling() -> bool`
|
||||
|
||||
Check if the display is currently in a scrolling state.
|
||||
@@ -1036,10 +954,9 @@ if "weather" in enabled_plugins:
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods,
|
||||
passing the frame hold `scroll_config.configure()` returned
|
||||
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods
|
||||
```python
|
||||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
# Scroll content...
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
```
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
> - Code paths reference `web_interface_v2.py`; the current web UI is
|
||||
> `web_interface/app.py` with v3 Blueprint-based templates.
|
||||
> - The example Flask routes use `/api/plugins/*`; the real API
|
||||
> blueprint (`web_interface/blueprints/api_v3/`) is mounted at `/api/v3`
|
||||
> in `web_interface/app.py`.
|
||||
> blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
> - The default plugin location is `plugin-repos/` (configurable via
|
||||
> `plugin_system.plugins_directory`), not `./plugins/`.
|
||||
> - Example imports use `src/plugin_system/base_classes/*_plugin.py`;
|
||||
@@ -190,9 +189,7 @@ class BasePlugin(ABC):
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Fetch/update data for this plugin.
|
||||
Called every get_update_interval() seconds when that returns a
|
||||
number, otherwise at the static interval: the manifest's
|
||||
update_interval, else the plugin config's update_interval, else 60s.
|
||||
Called based on update_interval in manifest.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -207,21 +204,6 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_update_interval(self) -> Optional[float]:
|
||||
"""
|
||||
Seconds until update() should run again, decided at runtime.
|
||||
Return None (the default) to use the static interval.
|
||||
|
||||
PluginManager._get_plugin_update_interval calls this on every
|
||||
scheduling tick. A number overrides the manifest and is clamped up
|
||||
to PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL (5s); None, a raise,
|
||||
or a non-finite/non-numeric value falls back to the manifest's
|
||||
update_interval, then the plugin config's update_interval, then
|
||||
60s. The static value is cached until the plugin reloads; the hook
|
||||
is not cached, so it must be cheap and must not raise.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
|
||||
@@ -67,7 +67,9 @@ The main configuration file (`config/config.json`) now contains only essential s
|
||||
"time_format": "%I:%M %p"
|
||||
},
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos"
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -91,9 +93,9 @@ The main configuration file (`config/config.json`) now contains only essential s
|
||||
|
||||
#### 4. Plugin System
|
||||
- **plugin_system**: Plugin system configuration
|
||||
- **plugins_directory**: Directory where plugins are stored (the only one the loader scans)
|
||||
- `auto_discover`, `auto_load_enabled`, `development_mode` may still appear in
|
||||
older configs; nothing reads them (see [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#plugin_system))
|
||||
- **plugins_directory**: Directory where plugins are stored
|
||||
- **auto_discover**: Automatically discover plugins
|
||||
- **auto_load_enabled**: Automatically load enabled plugins
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
> in the "Implementation Details" section below still reference the
|
||||
> pre-v3 file layout (`web_interface_v2.py`, `templates/index_v2.html`).
|
||||
> The current implementation lives in `web_interface/app.py`,
|
||||
> `web_interface/blueprints/api_v3/` (plugin config handlers in
|
||||
> `plugins.py`), and `web_interface/templates/v3/`.
|
||||
> `web_interface/blueprints/api_v3.py`, and `web_interface/templates/v3/`.
|
||||
> The user-facing description (Overview, Features, Form Generation
|
||||
> Process) is still accurate.
|
||||
|
||||
|
||||
+382
-134
@@ -11,179 +11,427 @@
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Web browser (templates/v3/base.html, Alpine.js + HTMX) │
|
||||
│ │
|
||||
│ Second nav row: one tab per installed plugin │
|
||||
│ Clicking a tab: GET /v3/partials/plugin-config/<plugin_id> │
|
||||
│ → server-rendered form swapped into the tab │
|
||||
│ │
|
||||
│ Save: hx-post="/api/v3/plugins/config?plugin_id=<id>" (form data) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Web Browser │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Tab Navigation Bar │ │
|
||||
│ │ [Overview] [General] ... [Plugins] [Plugin X] [Plugin Y]│ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ Plugins Tab │ │ Plugin X Configuration Tab │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • Install │ │ Form Generated from Schema: │ │
|
||||
│ │ • Update │ │ • Boolean → Toggle │ │
|
||||
│ │ • Uninstall │ │ • Number → Number Input │ │
|
||||
│ │ • Enable │ │ • String → Text Input │ │
|
||||
│ │ • [Configure]──────→ • Array → Comma Input │ │
|
||||
│ │ │ │ • Enum → Dropdown │ │
|
||||
│ └─────────────────┘ │ │ │
|
||||
│ │ [Save] [Back] [Reset] │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP API
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Flask (web_interface/app.py) │
|
||||
│ │
|
||||
│ pages_v3 blueprint (blueprints/pages_v3.py) │
|
||||
│ _load_plugin_config_partial(plugin_id) │
|
||||
│ • SchemaManager.load_schema() → config_schema.json │
|
||||
│ • config.json section for the plugin │
|
||||
│ • masks x-secret fields │
|
||||
│ • renders partials/plugin_config.html (render_field macros) │
|
||||
│ │
|
||||
│ api_v3 blueprint (blueprints/api_v3/plugins.py) │
|
||||
│ save_plugin_config() POST /api/v3/plugins/config │
|
||||
│ get_plugin_config() GET /api/v3/plugins/config │
|
||||
│ get_plugin_schema() GET /api/v3/plugins/schema │
|
||||
│ reset_plugin_config() POST /api/v3/plugins/config/reset │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Flask Backend │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ /api/v3/plugins/installed │ │
|
||||
│ │ • Discover plugins in plugins/ directory │ │
|
||||
│ │ • Load manifest.json for each plugin │ │
|
||||
│ │ • Load config_schema.json if exists │ │
|
||||
│ │ • Load current config from config.json │ │
|
||||
│ │ • Return combined data to frontend │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ /api/v3/plugins/config │ │
|
||||
│ │ • Receive key-value pair │ │
|
||||
│ │ • Update config.json │ │
|
||||
│ │ • Return success/error │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ File System
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Files │
|
||||
│ plugin-repos/<id>/config_schema.json JSON Schema (Draft-7) │
|
||||
│ config/config.json { "<id>": { ... } } │
|
||||
│ config/config_secrets.json { "<id>": { secrets } } │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ File System │
|
||||
│ │
|
||||
│ plugins/ │
|
||||
│ ├── hello-world/ │
|
||||
│ │ ├── manifest.json ───┐ │
|
||||
│ │ ├── config_schema.json ─┼─→ Defines UI structure │
|
||||
│ │ ├── manager.py │ │
|
||||
│ │ └── requirements.txt │ │
|
||||
│ └── clock-simple/ │ │
|
||||
│ ├── manifest.json │ │
|
||||
│ └── config_schema.json ──┘ │
|
||||
│ │
|
||||
│ config/ │
|
||||
│ └── config.json ────────────→ Stores configuration values │
|
||||
│ { │
|
||||
│ "hello-world": { │
|
||||
│ "enabled": true, │
|
||||
│ "message": "Hello!", │
|
||||
│ ... │
|
||||
│ } │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The plugins directory is `plugin_system.plugins_directory` in
|
||||
`config/config.json` (default `plugin-repos/`). Plugin configuration lives in
|
||||
`config/config.json`, not in the plugin directory, so it survives reinstalls.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 1. Rendering a plugin's tab
|
||||
### 1. Page Load Sequence
|
||||
|
||||
```
|
||||
User opens the plugin's tab
|
||||
│
|
||||
▼
|
||||
GET /v3/partials/plugin-config/<plugin_id> (pages_v3)
|
||||
│
|
||||
├─→ Load schema (SchemaManager, no cache)
|
||||
├─→ Load config.json[<plugin_id>]
|
||||
├─→ Mask "x-secret" values (fails closed if the schema is unusable)
|
||||
└─→ render partials/plugin_config.html
|
||||
│
|
||||
└─→ render_field() per property, recursively:
|
||||
boolean → toggle, number/integer → input or slider,
|
||||
string → input / textarea / select (enum),
|
||||
array → list or table widget,
|
||||
object → collapsible nested section,
|
||||
"x-widget" → a registered widget
|
||||
(static/v3/js/widgets/, or one the plugin ships)
|
||||
User Opens Web Interface
|
||||
│
|
||||
▼
|
||||
DOMContentLoaded Event
|
||||
│
|
||||
▼
|
||||
refreshPlugins()
|
||||
│
|
||||
▼
|
||||
GET /api/v3/plugins/installed
|
||||
│
|
||||
├─→ For each plugin directory:
|
||||
│ ├─→ Read manifest.json
|
||||
│ ├─→ Read config_schema.json (if exists)
|
||||
│ └─→ Read config from config.json
|
||||
│
|
||||
▼
|
||||
Return JSON Array:
|
||||
[{
|
||||
id: "hello-world",
|
||||
name: "Hello World",
|
||||
config: { enabled: true, message: "Hello!" },
|
||||
config_schema_data: {
|
||||
properties: {
|
||||
enabled: { type: "boolean", ... },
|
||||
message: { type: "string", ... }
|
||||
}
|
||||
}
|
||||
}, ...]
|
||||
│
|
||||
▼
|
||||
generatePluginTabs(plugins)
|
||||
│
|
||||
├─→ For each plugin:
|
||||
│ ├─→ Create tab button
|
||||
│ ├─→ Create tab content div
|
||||
│ └─→ generatePluginConfigForm(plugin)
|
||||
│ │
|
||||
│ ├─→ Read schema properties
|
||||
│ ├─→ Get current config values
|
||||
│ └─→ Generate HTML form inputs
|
||||
│
|
||||
▼
|
||||
Tabs Rendered in UI
|
||||
```
|
||||
|
||||
Nested objects are supported: a nested field is posted with a dotted name
|
||||
(e.g. `transition.type`).
|
||||
|
||||
### 2. Saving
|
||||
### 2. Configuration Save Sequence
|
||||
|
||||
```
|
||||
User clicks Save
|
||||
│
|
||||
▼
|
||||
validatePluginConfigForm() (client-side checks)
|
||||
│
|
||||
▼
|
||||
POST /api/v3/plugins/config?plugin_id=<id> (form data, all fields of the form)
|
||||
│
|
||||
▼
|
||||
save_plugin_config() (api_v3/plugins.py)
|
||||
├─→ Start from the stored config.json[<id>]
|
||||
├─→ Apply form fields: dotted names → nested keys, "[]" checkbox
|
||||
│ groups → lists, values coerced to the schema's types
|
||||
├─→ Merge schema defaults for keys that are still missing
|
||||
├─→ Validate against the schema (plus core per-plugin properties);
|
||||
│ invalid → 400 with the validation errors, nothing saved
|
||||
├─→ Split "x-secret" fields out; masked/blank secrets are dropped so
|
||||
│ an untouched secret keeps its stored value
|
||||
├─→ Deep-merge regular fields into config.json[<id>] (atomic save)
|
||||
├─→ Merge secrets into config_secrets.json[<id>]
|
||||
└─→ Call the loaded plugin's on_config_change() (and
|
||||
on_enable/on_disable if "enabled" changed)
|
||||
│
|
||||
▼
|
||||
One response for the whole form → notification in the UI
|
||||
User Modifies Form
|
||||
│
|
||||
▼
|
||||
User Clicks "Save"
|
||||
│
|
||||
▼
|
||||
savePluginConfiguration(pluginId)
|
||||
│
|
||||
├─→ Get form data
|
||||
├─→ For each field:
|
||||
│ ├─→ Get schema type
|
||||
│ ├─→ Convert value to correct type
|
||||
│ │ • boolean: checkbox.checked
|
||||
│ │ • integer: parseInt()
|
||||
│ │ • number: parseFloat()
|
||||
│ │ • array: split(',')
|
||||
│ │ • string: as-is
|
||||
│ │
|
||||
│ └─→ POST /api/v3/plugins/config
|
||||
│ {
|
||||
│ plugin_id: "hello-world",
|
||||
│ key: "message",
|
||||
│ value: "Hello, World!"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
Backend Updates config.json
|
||||
│
|
||||
▼
|
||||
Return Success
|
||||
│
|
||||
▼
|
||||
Show Notification
|
||||
│
|
||||
▼
|
||||
Refresh Plugins
|
||||
```
|
||||
|
||||
The display service picks up the new config through its config hot reload
|
||||
(ConfigService) without a restart.
|
||||
## Class and Function Hierarchy
|
||||
|
||||
JSON clients can post `{"plugin_id": ..., "config": {...}}` instead; the keys
|
||||
sent are merged onto the stored config the same way. See
|
||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#save-plugin-configuration).
|
||||
### Frontend (JavaScript)
|
||||
|
||||
### 3. Reset
|
||||
```
|
||||
Window Load
|
||||
└── DOMContentLoaded
|
||||
└── refreshPlugins()
|
||||
├── fetch('/api/v3/plugins/installed')
|
||||
├── renderInstalledPlugins(plugins)
|
||||
└── generatePluginTabs(plugins)
|
||||
└── For each plugin:
|
||||
├── Create tab button
|
||||
├── Create tab content
|
||||
└── generatePluginConfigForm(plugin)
|
||||
├── Read config_schema_data
|
||||
├── Read current config
|
||||
└── Generate form HTML
|
||||
├── Boolean → Toggle switch
|
||||
├── Number → Number input
|
||||
├── String → Text input
|
||||
├── Array → Comma-separated input
|
||||
└── Enum → Select dropdown
|
||||
|
||||
`POST /api/v3/plugins/config/reset` replaces the plugin's section with the
|
||||
schema defaults (keeping secrets unless `preserve_secrets` is false).
|
||||
User Interactions
|
||||
├── configurePlugin(pluginId)
|
||||
│ └── showTab(`plugin-${pluginId}`)
|
||||
│
|
||||
├── savePluginConfiguration(pluginId)
|
||||
│ ├── Process form data
|
||||
│ ├── Convert types per schema
|
||||
│ └── For each field:
|
||||
│ └── POST /api/v3/plugins/config
|
||||
│
|
||||
└── resetPluginConfig(pluginId)
|
||||
├── Get schema defaults
|
||||
└── For each field:
|
||||
└── POST /api/v3/plugins/config
|
||||
```
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
```
|
||||
Flask Routes
|
||||
├── /api/v3/plugins/installed (GET)
|
||||
│ └── api_plugins_installed()
|
||||
│ ├── PluginManager.discover_plugins()
|
||||
│ ├── For each plugin:
|
||||
│ │ ├── PluginManager.get_plugin_info()
|
||||
│ │ ├── Load config_schema.json
|
||||
│ │ └── Load config from config.json
|
||||
│ └── Return JSON response
|
||||
│
|
||||
└── /api/v3/plugins/config (POST)
|
||||
└── api_plugin_config()
|
||||
├── Parse request JSON
|
||||
├── Load current config
|
||||
├── Update config[plugin_id][key] = value
|
||||
└── Save config.json
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
LEDMatrix/
|
||||
│
|
||||
├── web_interface_v2.py
|
||||
│ └── Flask backend with plugin API endpoints
|
||||
│
|
||||
├── templates/
|
||||
│ └── index_v2.html
|
||||
│ └── Frontend with dynamic tab generation
|
||||
│
|
||||
├── config/
|
||||
│ └── config.json
|
||||
│ └── Stores all plugin configurations
|
||||
│
|
||||
├── plugins/
|
||||
│ ├── hello-world/
|
||||
│ │ ├── manifest.json ← Plugin metadata
|
||||
│ │ ├── config_schema.json ← UI schema definition
|
||||
│ │ ├── manager.py ← Plugin logic
|
||||
│ │ └── requirements.txt
|
||||
│ │
|
||||
│ └── clock-simple/
|
||||
│ ├── manifest.json
|
||||
│ ├── config_schema.json
|
||||
│ └── manager.py
|
||||
│
|
||||
└── docs/
|
||||
├── PLUGIN_CONFIGURATION_TABS.md ← Full documentation
|
||||
├── PLUGIN_CONFIG_TABS_SUMMARY.md ← Implementation summary
|
||||
├── PLUGIN_CONFIG_QUICK_START.md ← Quick start guide
|
||||
└── PLUGIN_CONFIG_ARCHITECTURE.md ← This file
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Server-side rendered forms
|
||||
### 1. Dynamic Tab Generation
|
||||
|
||||
**Why**: One renderer for every plugin, no per-plugin frontend code
|
||||
**How**: Jinja macros in `partials/plugin_config.html` walk the schema
|
||||
**Benefit**: The settings search index is built from the same rendered HTML
|
||||
(`/v3/settings/search-index`)
|
||||
**Why**: Plugins are installed/uninstalled dynamically
|
||||
**How**: JavaScript creates/removes tab elements on plugin list refresh
|
||||
**Benefit**: No server-side template rendering needed
|
||||
|
||||
### 2. JSON Schema as source of truth
|
||||
### 2. JSON Schema as Source of Truth
|
||||
|
||||
**Why**: Standard, well-documented, validation-ready
|
||||
**How**: The same schema drives the form, the defaults and server-side validation
|
||||
**Benefit**: Plugin developers use a familiar format
|
||||
**Why**: Standard, well-documented, validation-ready
|
||||
**How**: Frontend interprets schema to generate forms
|
||||
**Benefit**: Plugin developers use familiar format
|
||||
|
||||
### 3. Whole-form saves that merge
|
||||
### 3. Individual Config Updates
|
||||
|
||||
**Why**: A partial form (or a field the form doesn't show) must not wipe
|
||||
stored values
|
||||
**How**: The handler starts from the stored section and merges what was posted
|
||||
**Benefit**: One request per save, atomic write
|
||||
**Why**: Simplifies backend API
|
||||
**How**: Each field saved separately via `/api/v3/plugins/config`
|
||||
**Benefit**: Atomic updates, easier error handling
|
||||
|
||||
### 4. Secrets kept out of config.json
|
||||
### 4. Type Conversion in Frontend
|
||||
|
||||
**Why**: `config.json` is shown in the raw editor and returned by the API
|
||||
**How**: `"x-secret": true` fields go to `config_secrets.json`, which is
|
||||
deep-merged back into the plugin's config at load time
|
||||
**Benefit**: Plugins read secrets with plain `config.get(...)`
|
||||
**Why**: HTML forms only return strings
|
||||
**How**: JavaScript converts based on schema type before sending
|
||||
**Benefit**: Backend receives correctly-typed values
|
||||
|
||||
### 5. No Nested Objects
|
||||
|
||||
**Why**: Keeps UI simple
|
||||
**How**: Only flat property structures supported
|
||||
**Benefit**: Easy form generation, clear to users
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Custom input widgets
|
||||
### Adding New Input Types
|
||||
|
||||
Set `"x-widget": "<name>"` on a property. Core widgets are in
|
||||
`web_interface/static/v3/js/widgets/` (see its README); a plugin can ship its
|
||||
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`.
|
||||
See [widget-guide.md](widget-guide.md).
|
||||
Location: `generatePluginConfigForm()` in `index_v2.html`
|
||||
|
||||
### Custom actions
|
||||
```javascript
|
||||
if (type === 'your-new-type') {
|
||||
formHTML += `
|
||||
<!-- Your custom input HTML -->
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
Buttons that run plugin scripts are declared in the manifest's
|
||||
`web_ui_actions`. See [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md).
|
||||
### Custom Validation
|
||||
|
||||
### Reacting to changes
|
||||
Location: `savePluginConfiguration()` in `index_v2.html`
|
||||
|
||||
Implement `on_config_change(new_config)` in the plugin (see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)).
|
||||
```javascript
|
||||
// Add validation before sending
|
||||
if (!validateCustomConstraint(value, propSchema)) {
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
```
|
||||
|
||||
## Where to Look
|
||||
### Backend Hook
|
||||
|
||||
| Concern | File |
|
||||
|---------|------|
|
||||
| Tab partial loader | `web_interface/blueprints/pages_v3.py` (`_load_plugin_config_partial`) |
|
||||
| Form template and field macros | `web_interface/templates/v3/partials/plugin_config.html` |
|
||||
| Save / get / schema / reset handlers | `web_interface/blueprints/api_v3/plugins.py` |
|
||||
| Schema loading, defaults, validation | `src/plugin_system/schema_manager.py` |
|
||||
| Secret masking and splitting | `src/web_interface/secret_helpers.py` |
|
||||
| Widgets | `web_interface/static/v3/js/widgets/` |
|
||||
Location: `api_plugin_config()` in `web_interface_v2.py`
|
||||
|
||||
```python
|
||||
# Add custom logic before saving
|
||||
if plugin_id == 'special-plugin':
|
||||
value = transform_value(value)
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Tab Generation**: O(n) where n = number of plugins (typically < 20)
|
||||
- **Form Generation**: O(m) where m = number of config properties (typically < 10)
|
||||
- **Memory**: Each plugin tab ~5KB HTML
|
||||
- **Total Impact**: Negligible for typical use cases
|
||||
|
||||
### Backend
|
||||
|
||||
- **Schema Loading**: Cached after first load
|
||||
- **Config Updates**: Single file write (atomic)
|
||||
- **API Calls**: One per config field on save (sequential)
|
||||
- **Optimization**: Could batch updates in single API call
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Input Validation**: Schema constraints enforced client-side (UX) and should be enforced server-side
|
||||
2. **Path Traversal**: Plugin paths validated against known plugin directory
|
||||
3. **XSS**: All user inputs escaped before rendering in HTML
|
||||
4. **CSRF**: Flask CSRF tokens should be used in production
|
||||
5. **File Permissions**: config.json requires write access
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unknown plugin or unreadable schema: the partial renders an error message
|
||||
- Validation failure: `400` with `details` and `context.validation_errors`;
|
||||
the form shows them and nothing is saved
|
||||
- Save failure: `500` with an error message; config.json is written
|
||||
atomically, so a failed save leaves the previous file intact
|
||||
### Frontend
|
||||
|
||||
- Network errors: Show notification, don't crash
|
||||
- Schema errors: Graceful fallback to no config tab
|
||||
- Type errors: Log to console, continue processing other fields
|
||||
|
||||
### Backend
|
||||
|
||||
- Invalid plugin_id: 400 Bad Request
|
||||
- Schema not found: Return null, frontend handles gracefully
|
||||
- Config save error: 500 Internal Server Error with message
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `generatePluginConfigForm()` for each schema type
|
||||
- Type conversion logic in `savePluginConfiguration()`
|
||||
- Backend schema loading logic
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Full save flow: form → API → config.json
|
||||
- Tab generation from API response
|
||||
- Reset to defaults
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Install plugin → verify tab appears
|
||||
- Configure plugin → verify config saved
|
||||
- Uninstall plugin → verify tab removed
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Frontend Metrics
|
||||
|
||||
- Time to generate tabs
|
||||
- Form submission success rate
|
||||
- User interactions (configure, save, reset)
|
||||
|
||||
### Backend Metrics
|
||||
|
||||
- API response times
|
||||
- Config update success rate
|
||||
- Schema loading errors
|
||||
|
||||
### User Feedback
|
||||
|
||||
- Are users finding the configuration interface?
|
||||
- Are validation errors clear?
|
||||
- Are default values sensible?
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
### Phase 2: Enhanced Validation
|
||||
- Real-time validation feedback
|
||||
- Custom error messages
|
||||
- Dependent field validation
|
||||
|
||||
### Phase 3: Advanced Inputs
|
||||
- Color pickers for RGB arrays
|
||||
- File upload for assets
|
||||
- Rich text editor for descriptions
|
||||
|
||||
### Phase 4: Configuration Management
|
||||
- Export/import configurations
|
||||
- Configuration presets
|
||||
- Version history/rollback
|
||||
|
||||
### Phase 5: Developer Tools
|
||||
- Schema editor in web UI
|
||||
- Live preview while editing schema
|
||||
- Validation tester
|
||||
|
||||
|
||||
+186
-112
@@ -2,160 +2,234 @@
|
||||
|
||||
## Overview
|
||||
|
||||
A plugin lists its Python packages in its `requirements.txt`. LEDMatrix
|
||||
installs them for you when a plugin is installed, updated or loaded. This
|
||||
guide explains where they end up and what to do when a plugin can't import a
|
||||
package.
|
||||
The LEDMatrix system has smart dependency installation that adapts based on who is running it. This guide explains how it works and potential pitfalls.
|
||||
|
||||
The rule to remember: **packages must be importable by `ledmatrix.service`,
|
||||
which runs as root.** Anything installed only into another user's
|
||||
`~/.local/` is invisible to it.
|
||||
## How It Works
|
||||
|
||||
## Who Runs What
|
||||
### Execution Context Detection
|
||||
|
||||
| Service | Runs as | Set by |
|
||||
|---------|---------|--------|
|
||||
| `ledmatrix.service` (display) | `root` | `systemd/ledmatrix.service` |
|
||||
| `ledmatrix-web.service` (web UI) | the user who ran the installer (e.g. `ledpi`) | `User=__USER__` in `systemd/ledmatrix-web.service`, filled in by `scripts/install/install_service.sh` |
|
||||
|
||||
## How Dependencies Get Installed
|
||||
|
||||
### 1. Installing or updating a plugin from the web UI
|
||||
|
||||
The web interface is not root, so it installs through a narrow sudo helper:
|
||||
|
||||
1. `PluginStoreManager._install_dependencies()`
|
||||
(`src/plugin_system/store_manager.py`) calls
|
||||
`install_requirements_file()` (`src/common/permission_utils.py`).
|
||||
2. That runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <plugin>/requirements.txt`.
|
||||
The helper checks the path is the project's own `requirements.txt` or a
|
||||
`requirements.txt` under `plugin-repos/` or `plugins/`, then runs
|
||||
`python3 -m pip install --break-system-packages --ignore-installed -r ...`
|
||||
**as root**, so the display service can import the packages.
|
||||
3. The sudoers rule that allows this is written by the installer
|
||||
(`first_time_install.sh`) or by `scripts/install/configure_web_sudo.sh`.
|
||||
|
||||
If sudo refuses (the rule isn't installed), `install_requirements_file()`
|
||||
falls back to installing with the web process's own interpreter, as the web
|
||||
user, and prefixes the pip output with a note like:
|
||||
|
||||
```
|
||||
[Root install unavailable (...); installed for the current process's user only.
|
||||
Packages may not be visible to ledmatrix.service if it runs as a different
|
||||
user — run scripts/install/configure_web_sudo.sh to fix this.]
|
||||
The plugin manager checks if it's running as root:
|
||||
```python
|
||||
running_as_root = os.geteuid() == 0
|
||||
```
|
||||
|
||||
Fix it by running `./scripts/install/configure_web_sudo.sh` as the web
|
||||
user (not with `sudo`; it asks for your password itself), then
|
||||
reinstall the plugin (or use the manual install below).
|
||||
Based on this, it chooses the appropriate installation method:
|
||||
|
||||
The **Reinstall Plugin Deps** button on the web UI's Tools tab goes
|
||||
through the same helper for every installed plugin.
|
||||
|
||||
### 2. Loading a plugin
|
||||
|
||||
When a plugin loads, `PluginLoader.install_dependencies()`
|
||||
(`src/plugin_system/plugin_loader.py`) checks its `requirements.txt`. If the
|
||||
requirements are already satisfied it does nothing; otherwise it runs
|
||||
`python3 -m pip install --break-system-packages -r requirements.txt` with the
|
||||
interpreter of the process doing the loading (retrying with
|
||||
`--ignore-installed` when a system package without a pip RECORD file is in
|
||||
the way).
|
||||
|
||||
In `ledmatrix.service` that process is root, so restarting the display
|
||||
service installs anything missing system-wide:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
If you run `python3 run.py` by hand as a normal user instead, pip cannot
|
||||
write to the system site-packages and installs into your `~/.local/`. That
|
||||
works for your manual run but not for the service.
|
||||
| Running As | Installation Method | Location | Accessible To |
|
||||
|------------|-------------------|----------|---------------|
|
||||
| **root** (systemd service) | System-wide (`--break-system-packages`) | `/usr/local/lib/python3.X/dist-packages/` | All users |
|
||||
| **ledpi** or other user | User-specific (`--user`) | `~/.local/lib/python3.X/site-packages/` | Only that user |
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Installing plugins from the web UI (recommended)
|
||||
### ✅ Scenario 1: Normal Production Use (Recommended)
|
||||
|
||||
Use the **Plugin Manager** tab. Dependencies are installed as root through
|
||||
the sudo helper and the display service can use them.
|
||||
|
||||
### Running the display manually for debugging
|
||||
**What:** Services running via systemd
|
||||
|
||||
```bash
|
||||
cd ~/LEDMatrix
|
||||
sudo python3 run.py # same user as the service
|
||||
sudo systemctl start ledmatrix
|
||||
sudo systemctl start ledmatrix-web
|
||||
```
|
||||
|
||||
Running as your own user works for plugins whose packages are already
|
||||
installed system-wide, but any *missing* package lands in `~/.local/`.
|
||||
- **Runs as:** root (configured in .service files)
|
||||
- **Installs to:** System-wide
|
||||
- **Result:** ✅ Works perfectly, all dependencies accessible
|
||||
|
||||
### A plugin works when run manually but fails in the service
|
||||
### ✅ Scenario 2: Web Interface Plugin Installation
|
||||
|
||||
Its packages were installed for your user only. Install them as root (see
|
||||
below) and restart the service.
|
||||
**What:** Installing/enabling plugins via web interface at `http://pi-ip:5000`
|
||||
|
||||
## Manual Installation
|
||||
- **Web service runs as:** root (ledmatrix-web.service)
|
||||
- **Installs to:** System-wide
|
||||
- **Result:** ✅ Works perfectly, systemd service can access them
|
||||
|
||||
### All plugins
|
||||
### ✅ Scenario 3: Manual Testing as ledpi (Read-only)
|
||||
|
||||
**What:** Running display manually as ledpi to test/debug
|
||||
|
||||
```bash
|
||||
sudo ~/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
# As ledpi user
|
||||
cd /home/ledpi/LEDMatrix
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
- **Runs as:** ledpi
|
||||
- **Can import:** ✅ System-wide packages (installed by root)
|
||||
- **Result:** ✅ Works! Can use existing plugins with root-installed dependencies
|
||||
|
||||
### ⚠️ Scenario 4: Manual Plugin Installation as ledpi (Problematic)
|
||||
|
||||
**What:** Enabling a NEW plugin and running manually as ledpi
|
||||
|
||||
```bash
|
||||
# As ledpi user
|
||||
cd /home/ledpi/LEDMatrix
|
||||
# Edit config to enable new plugin
|
||||
nano config/config.json
|
||||
# Run display - will try to install new plugin dependencies
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
**What Happens:**
|
||||
1. Plugin manager runs as `ledpi`
|
||||
2. Installs dependencies with `--user` flag
|
||||
3. Dependencies go to `~/.local/lib/python3.X/site-packages/`
|
||||
4. ⚠️ **Warning logged:** "Installing plugin dependencies for current user (not root)"
|
||||
|
||||
**Problem:**
|
||||
- When systemd service restarts (as root), it **can't see** `~/.local/` packages
|
||||
- Plugin will fail to load for the systemd service
|
||||
|
||||
**Solution:**
|
||||
After testing, restart the service to install dependencies system-wide:
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
The script installs every `requirements.txt` found in the plugins directory
|
||||
configured by `plugin_system.plugins_directory` in `config/config.json`
|
||||
(default `plugin-repos/`). Run it with `sudo` so the packages are installed
|
||||
system-wide.
|
||||
## Best Practices
|
||||
|
||||
### One plugin
|
||||
### For Production/Normal Use
|
||||
|
||||
```bash
|
||||
cd ~/LEDMatrix/plugin-repos/PLUGIN-NAME # or your configured plugins directory
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo systemctl restart ledmatrix
|
||||
1. **Always use the web interface** to install/enable plugins
|
||||
2. **Or restart the systemd service** after config changes:
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### For Development/Testing
|
||||
|
||||
1. **Read existing plugins:** Safe to run as `ledpi` - can import system packages
|
||||
2. **Test new plugins:** Use sudo or restart service to install dependencies:
|
||||
```bash
|
||||
# Option 1: Run as root
|
||||
sudo python3 run.py
|
||||
|
||||
# Option 2: Install deps manually
|
||||
sudo pip3 install --break-system-packages -r plugins/my-plugin/requirements.txt
|
||||
python3 run.py
|
||||
|
||||
# Option 3: Let service install them
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
## Warning Messages
|
||||
|
||||
### If you see this warning:
|
||||
```
|
||||
Installing plugin dependencies for current user (not root).
|
||||
These will NOT be accessible to the systemd service.
|
||||
For production use, install plugins via the web interface or restart the ledmatrix service.
|
||||
```
|
||||
|
||||
`--no-cache-dir` avoids errors about `/root/.cache/pip` not being writable.
|
||||
**What it means:**
|
||||
- You're running as a non-root user
|
||||
- Dependencies were installed to your user directory only
|
||||
- The systemd service won't be able to use this plugin
|
||||
|
||||
**What to do:**
|
||||
```bash
|
||||
# Restart the service to install dependencies system-wide
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin works when I run manually but fails in systemd service
|
||||
|
||||
**Cause:** Dependencies installed to user directory (`~/.local/`) instead of system-wide
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check where package is installed
|
||||
pip3 list -v | grep <package-name>
|
||||
|
||||
# If it shows ~/.local/, reinstall system-wide:
|
||||
sudo pip3 install --break-system-packages <package-name>
|
||||
|
||||
# Or just restart the service:
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### Permission denied when installing dependencies
|
||||
|
||||
**If you see errors like:**
|
||||
```
|
||||
ERROR: Could not install packages due to an OSError: [Errno 13] Permission denied: '/root/.local'
|
||||
WARNING: The directory '/root/.cache/pip' or its parent directory is not owned or is not writable
|
||||
```
|
||||
|
||||
Use one of the manual installs above (they pass `--no-cache-dir`).
|
||||
|
||||
### Checking where a package is installed
|
||||
|
||||
**Quick Fix - Use the Helper Script:**
|
||||
```bash
|
||||
# How the service sees it
|
||||
sudo python3 -c "import package_name; print(package_name.__file__)"
|
||||
|
||||
# A path under /home/<user>/.local/ means it was installed for that user only
|
||||
python3 -m pip show -f package_name
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md).
|
||||
**Manual Fix:**
|
||||
```bash
|
||||
# Install dependencies with --no-cache-dir to avoid cache permission issues
|
||||
cd /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
## For Plugin Authors
|
||||
**For more detailed troubleshooting, see:** [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
|
||||
|
||||
1. Keep `requirements.txt` minimal and pin only what you need.
|
||||
2. Test that it installs the way the Pi will install it:
|
||||
```bash
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
```
|
||||
3. Note any `apt` packages your plugin needs in its README.
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LEDMatrix Services │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ledmatrix.service (User=root) │
|
||||
│ ledmatrix-web.service (User=root) │
|
||||
│ ├── Install dependencies system-wide │
|
||||
│ └── Accessible to all users │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Manual execution as ledpi │
|
||||
│ ├── Can READ system-wide packages ✅ │
|
||||
│ ├── WRITES go to ~/.local/ ⚠️ │
|
||||
│ └── Not accessible to root service │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **For end users:** Always use the web interface for plugin management
|
||||
2. **For developers:** Be aware of the user context when testing
|
||||
3. **For plugin authors:** Test with `sudo systemctl restart ledmatrix` to ensure dependencies install correctly
|
||||
4. **For CI/CD:** Always run installation as root or use the service
|
||||
|
||||
## Helper Scripts
|
||||
|
||||
### Install Plugin Dependencies Script
|
||||
|
||||
Located at: `scripts/install_plugin_dependencies.sh`
|
||||
|
||||
This script automatically finds and installs dependencies for all plugins:
|
||||
|
||||
```bash
|
||||
# Run as root (recommended for production)
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
|
||||
# Make executable if needed
|
||||
chmod +x /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-detects all plugins with requirements.txt
|
||||
- Uses correct installation method (system-wide vs user)
|
||||
- Bypasses pip cache to avoid permission issues
|
||||
- Provides detailed logging and error messages
|
||||
|
||||
## Files to Reference
|
||||
|
||||
- Service units: `systemd/ledmatrix.service`, `systemd/ledmatrix-web.service`
|
||||
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`)
|
||||
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
|
||||
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
|
||||
- Sudo rules: `scripts/install/configure_web_sudo.sh`
|
||||
- Manual installer: `scripts/install_plugin_dependencies.sh`
|
||||
- Service configs: `ledmatrix.service`, `ledmatrix-web.service`
|
||||
- Plugin manager: `src/plugin_system/plugin_manager.py`
|
||||
- Installation script: `first_time_install.sh`
|
||||
- Dependency installer: `scripts/install_plugin_dependencies.sh`
|
||||
- Troubleshooting guide: `PLUGIN_DEPENDENCY_TROUBLESHOOTING.md`
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Plugin Dependency Installation Troubleshooting
|
||||
|
||||
This guide helps resolve problems installing a plugin's Python packages. For
|
||||
how installation works, see the [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md).
|
||||
This guide helps resolve issues with automatic plugin dependency installation in the LEDMatrix system.
|
||||
|
||||
## Common Error Symptoms
|
||||
|
||||
@@ -11,118 +10,109 @@ ERROR: Could not install packages due to an OSError: [Errno 13] Permission denie
|
||||
WARNING: The directory '/root/.cache/pip' or its parent directory is not owned or is not writable
|
||||
```
|
||||
|
||||
### Installed for the wrong user
|
||||
The pip output shown after a web-UI install starts with:
|
||||
### Context Mismatch
|
||||
```
|
||||
[Root install unavailable (...); installed for the current process's user only.
|
||||
Packages may not be visible to ledmatrix.service if it runs as a different
|
||||
user — run scripts/install/configure_web_sudo.sh to fix this.]
|
||||
WARNING: Installing plugin dependencies for current user (not root).
|
||||
These will NOT be accessible to the systemd service.
|
||||
```
|
||||
|
||||
### Plugin fails to load with `ModuleNotFoundError`
|
||||
The display service can't see a package the plugin needs.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Plugin packages must be importable by `ledmatrix.service`, which runs as
|
||||
root. The web interface (`ledmatrix-web.service`) runs as the user who
|
||||
installed LEDMatrix, so it installs through a sudo helper
|
||||
(`scripts/fix_perms/safe_pip_install.sh`). Problems usually come from:
|
||||
Plugin dependencies must be installed in a context accessible to the LEDMatrix systemd service, which runs as root. Permission errors typically occur when:
|
||||
|
||||
1. The sudoers rule for that helper missing, so the web UI installed the
|
||||
packages for its own user only
|
||||
2. Running `python3 run.py` by hand as a normal user, which installs missing
|
||||
packages into `~/.local/`
|
||||
3. pip's cache directory not being writable for root
|
||||
1. The pip cache directory has incorrect permissions
|
||||
2. The process tries to install to user directories without proper permissions
|
||||
3. Environment variables (like HOME) are not set correctly for the service context
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Restore the sudo rule, then reinstall
|
||||
### Solution 1: Use the Manual Installation Script (Recommended)
|
||||
|
||||
We provide a helper script that handles dependency installation correctly:
|
||||
|
||||
```bash
|
||||
cd ~/LEDMatrix
|
||||
./scripts/install/configure_web_sudo.sh # as the web user, not with sudo
|
||||
```
|
||||
# Run as root to install system-wide (for production)
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
|
||||
Then reinstall the plugin from the **Plugin Manager** tab, or click
|
||||
**Reinstall Plugin Deps** on the **Tools** tab.
|
||||
|
||||
### Solution 2: Install every plugin's dependencies from the terminal
|
||||
|
||||
```bash
|
||||
sudo ~/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
# After installation, restart the service
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
The script finds each `requirements.txt` in the plugins directory set by
|
||||
`plugin_system.plugins_directory` in `config/config.json` (default
|
||||
`plugin-repos/`), installs with `--no-cache-dir`, and reports what it found.
|
||||
This script:
|
||||
- Detects all plugins with requirements.txt files
|
||||
- Installs dependencies with correct permissions
|
||||
- Uses `--no-cache-dir` to avoid cache permission issues
|
||||
- Provides detailed logging for troubleshooting
|
||||
|
||||
### Solution 3: Install one plugin's dependencies
|
||||
### Solution 2: Manual Installation per Plugin
|
||||
|
||||
If you need to install dependencies for a specific plugin:
|
||||
|
||||
```bash
|
||||
# Your configured plugins directory; plugin-repos/ by default
|
||||
cd ~/LEDMatrix/plugin-repos/PLUGIN-NAME
|
||||
# Navigate to the plugin directory
|
||||
cd /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME
|
||||
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
# Install as root (system-wide)
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### Solution 4: Let the display service install them
|
||||
### Solution 3: Fix Cache Directory Permissions
|
||||
|
||||
When a plugin loads, the display service installs any missing requirements
|
||||
itself, as root:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
sudo journalctl -u ledmatrix -f # watch for "Installing dependencies for plugin ..."
|
||||
```
|
||||
|
||||
### Solution 5: Fix pip cache permissions
|
||||
If you specifically have cache permission issues:
|
||||
|
||||
```bash
|
||||
# Option A: Skip the cache (recommended)
|
||||
sudo python3 -m pip install --no-cache-dir --break-system-packages -r requirements.txt
|
||||
sudo pip3 install --no-cache-dir --break-system-packages -r requirements.txt
|
||||
|
||||
# Option B: Fix cache permissions
|
||||
# Option B: Fix cache permissions (if needed)
|
||||
sudo mkdir -p /root/.cache/pip
|
||||
sudo chown -R root:root /root/.cache
|
||||
sudo chmod -R 755 /root/.cache
|
||||
```
|
||||
|
||||
### Solution 4: Install via Web Interface
|
||||
|
||||
The web interface handles dependency installation correctly in the service context:
|
||||
|
||||
1. Access the web interface (`http://ledpi:5000` or `http://your-pi-ip:5000`)
|
||||
2. Open the **Plugin Manager** tab (use the **Plugin Store** section to
|
||||
find the plugin, or **Install from GitHub**)
|
||||
3. Install the plugin through the web UI
|
||||
4. The system automatically handles dependency installation in the
|
||||
service context (which has the right permissions)
|
||||
|
||||
## Prevention
|
||||
|
||||
### For Plugin Developers
|
||||
|
||||
When creating plugins with dependencies:
|
||||
|
||||
1. **Keep requirements minimal**: Only include essential packages
|
||||
2. **Test installation** the way the Pi does it:
|
||||
2. **Test installation**: Verify your requirements.txt works with:
|
||||
```bash
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
```
|
||||
3. **Document dependencies**: Note any system packages needed (via apt)
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Use the web interface** to install plugins
|
||||
2. **Use sudo** for installs from SSH/terminal
|
||||
3. **Restart the service** after manual installations
|
||||
1. **Use web interface**: Install plugins via the web UI when possible
|
||||
2. **Install as root**: When using SSH/terminal, use sudo for plugin installations
|
||||
3. **Restart service**: After manual installations, restart the ledmatrix service
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Where installs happen
|
||||
### How Dependency Installation Works
|
||||
|
||||
- **Web UI install/update:** `PluginStoreManager._install_dependencies()`
|
||||
→ `install_requirements_file()` in `src/common/permission_utils.py`, which
|
||||
runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <requirements.txt>`.
|
||||
The helper only accepts the project's `requirements.txt` or one under
|
||||
`plugin-repos/` or `plugins/`, and runs
|
||||
`pip install --break-system-packages --ignore-installed` as root. If sudo
|
||||
refuses, it falls back to a pip install as the web user and says so.
|
||||
- **Plugin load:** `PluginLoader.install_dependencies()` in
|
||||
`src/plugin_system/plugin_loader.py` skips satisfied requirements and
|
||||
otherwise runs `pip install --break-system-packages` with the loading
|
||||
process's interpreter — root in `ledmatrix.service`.
|
||||
The `PluginManager._install_plugin_dependencies()` method:
|
||||
|
||||
1. Detects if running as root using `os.geteuid() == 0`
|
||||
2. If root: Uses system-wide installation with `--break-system-packages --no-cache-dir`
|
||||
3. If not root: Uses user installation with `--user --break-system-packages --no-cache-dir`
|
||||
4. The `--no-cache-dir` flag prevents cache-related permission issues
|
||||
|
||||
### Why `--break-system-packages`?
|
||||
|
||||
@@ -130,20 +120,26 @@ Debian 12+ (Bookworm) and Raspberry Pi OS based on it implement PEP 668, which p
|
||||
|
||||
### Service Context
|
||||
|
||||
- `ledmatrix.service` runs as **root** with `/usr/bin/python3`
|
||||
- `ledmatrix-web.service` runs as **the installing user**
|
||||
The ledmatrix.service runs as:
|
||||
- **User**: root
|
||||
- **WorkingDirectory**: /home/ledpi/LEDMatrix
|
||||
- **Python**: /usr/bin/python3
|
||||
|
||||
Dependencies must be installed system-wide (as root) to be visible to the
|
||||
display service.
|
||||
Dependencies must be installed in root's Python environment or system-wide to be accessible.
|
||||
|
||||
## Checking Installation
|
||||
|
||||
Verify dependencies are installed correctly:
|
||||
|
||||
```bash
|
||||
# Check as root (how the service sees it)
|
||||
sudo python3 -c "import package_name; print(package_name.__file__)"
|
||||
sudo python3 -c "import package_name"
|
||||
|
||||
# A path under /home/<user>/.local/ means a user-only install
|
||||
python3 -m pip show -f package_name
|
||||
# List installed packages
|
||||
pip3 list
|
||||
|
||||
# Check specific package
|
||||
pip3 show package_name
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
@@ -155,11 +151,19 @@ If you continue to experience issues:
|
||||
sudo journalctl -u ledmatrix -f
|
||||
```
|
||||
|
||||
2. Verify the plugin manifest and requirements (default plugins directory
|
||||
shown):
|
||||
2. Check pip logs (created by manual script):
|
||||
```bash
|
||||
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/manifest.json
|
||||
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/requirements.txt
|
||||
cat /tmp/pip_install_*.log
|
||||
```
|
||||
|
||||
3. Verify plugin manifest is correct:
|
||||
```bash
|
||||
cat /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME/manifest.json
|
||||
```
|
||||
|
||||
4. Check plugin requirements:
|
||||
```bash
|
||||
cat /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME/requirements.txt
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
@@ -167,3 +171,4 @@ If you continue to experience issues:
|
||||
- [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md)
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Troubleshooting](TROUBLESHOOTING.md)
|
||||
|
||||
|
||||
@@ -3,20 +3,18 @@
|
||||
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
||||
|
||||
> **Rendering guidance:** plugins should read the display size dynamically
|
||||
> (`self.display_manager.width/height`) rather than hardcoding one
|
||||
> panel. Don't read `display_manager.matrix.width/height`: `matrix` is
|
||||
> `None` when hardware init fails, while the `width`/`height` properties
|
||||
> fall back to the canvas size. For plugins that want to *scale* their layout to any panel, the
|
||||
> (`self.display_manager.matrix.width/height`) rather than hardcoding one
|
||||
> panel. For plugins that want to *scale* their layout to any panel, the
|
||||
> opt-in adaptive layout system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md))
|
||||
> provides the shared helpers — fonts, images, and composite layouts that
|
||||
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||
> those APIs; nothing migrates automatically.
|
||||
|
||||
> **Want a different look for an existing sports scoreboard?** Skins are
|
||||
> meant for that, but they are **not supported yet**: the current scoreboard
|
||||
> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)).
|
||||
> For now, change the look through the plugin's own display settings or its
|
||||
> code.
|
||||
> **Just want a different look for an existing sports scoreboard?** You may
|
||||
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
||||
> rendering while the plugin keeps handling data, scheduling, caching, and
|
||||
> vegas mode, in ~100 lines of drawing code. See
|
||||
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -45,45 +43,28 @@ The solution uses **symbolic links** to connect plugin repositories to the `plug
|
||||
|
||||
## Quick Start
|
||||
|
||||
Official plugins all live in one repository,
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins), with
|
||||
one directory per plugin under `plugins/` (there are no per-plugin
|
||||
`ledmatrix-<name>` repositories). The helper script links a plugin directory
|
||||
from a checkout of that monorepo into LEDMatrix's `plugins/` directory.
|
||||
### 1. Link a Plugin from GitHub
|
||||
|
||||
### 1. Link an Official Plugin
|
||||
The easiest way to link a plugin that's already on GitHub:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github football-scoreboard
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
```
|
||||
|
||||
This will:
|
||||
- Clone `https://github.com/ChuckBuilds/ledmatrix-plugins.git` to
|
||||
`~/.ledmatrix-dev-plugins/ledmatrix-plugins` (or `git pull` it if it is
|
||||
already there)
|
||||
- Find `plugins/football-scoreboard` in it (also accepted:
|
||||
`plugins/ledmatrix-<name>`, or a plugin whose manifest `id` is the name)
|
||||
- Validate that it has a `manifest.json`
|
||||
- Create a symbolic link named after the plugin's manifest id, e.g.
|
||||
`plugins/football-scoreboard` → `~/.ledmatrix-dev-plugins/ledmatrix-plugins/plugins/football-scoreboard`
|
||||
- Clone `https://github.com/ChuckBuilds/ledmatrix-music.git` to `~/.ledmatrix-dev-plugins/ledmatrix-music`
|
||||
- Create a symbolic link from `plugins/music` to the cloned repository
|
||||
- Validate that the plugin has a proper `manifest.json`
|
||||
|
||||
`link-github music` finds the monorepo's `plugins/ledmatrix-music` directory
|
||||
and links it into LEDMatrix as `plugins/ledmatrix-music`, because
|
||||
`ledmatrix-music` is that plugin's manifest id.
|
||||
### 2. Link a Local Plugin Repository
|
||||
|
||||
To work from your fork of the monorepo, set `github_user` in
|
||||
`dev_plugins.json` (see [Configuration](#configuration)).
|
||||
|
||||
### 2. Link a Local Plugin Directory
|
||||
|
||||
If you already have the monorepo (or a third-party plugin repository) cloned
|
||||
locally:
|
||||
If you already have a plugin repository cloned locally:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link hello-world ../ledmatrix-plugins/plugins/hello-world
|
||||
./scripts/dev/dev_plugin_setup.sh link music ../ledmatrix-music
|
||||
```
|
||||
|
||||
This creates a symlink from `plugins/hello-world` to that directory.
|
||||
This creates a symlink from `plugins/music` to your local repository path.
|
||||
|
||||
### 3. Check Status
|
||||
|
||||
@@ -96,17 +77,13 @@ See which plugins are linked and their git status:
|
||||
### 4. Work on Your Plugin
|
||||
|
||||
```bash
|
||||
cd plugins/football-scoreboard # Actually editing the monorepo checkout
|
||||
# Make your changes, then bump "version" in manifest.json
|
||||
cd plugins/music # Actually editing the linked repository
|
||||
# Make your changes
|
||||
git add .
|
||||
git commit -m "feat(football-scoreboard): add new feature"
|
||||
git push # to your fork, then open a PR against ledmatrix-plugins
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
In the monorepo, every plugin change must bump `version` in the plugin's
|
||||
`manifest.json` and run `python update_registry.py`, or users won't receive
|
||||
the update.
|
||||
|
||||
### 5. Update Plugins
|
||||
|
||||
Pull latest changes from remote:
|
||||
@@ -139,7 +116,7 @@ Links a local plugin repository to the plugins directory.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-plugins/plugins/football-scoreboard
|
||||
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-football-scoreboard
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
@@ -152,25 +129,23 @@ Links a local plugin repository to the plugins directory.
|
||||
Clones a plugin from GitHub and links it.
|
||||
|
||||
**Arguments:**
|
||||
- `plugin-name`: Without `repo-url`, the plugin to link from the monorepo: a
|
||||
directory under `plugins/` (`<name>` or `ledmatrix-<name>`) or a manifest
|
||||
id. The link is named after the plugin's manifest id. With `repo-url`, the
|
||||
name of the link in `plugins/`.
|
||||
- `repo-url`: (Optional) A plugin that has its own repository (e.g. a
|
||||
third-party plugin). The repository root is linked.
|
||||
- `plugin-name`: The name of the plugin (will be the directory name in `plugins/`)
|
||||
- `repo-url`: (Optional) Full GitHub repository URL. If omitted, constructs from pattern: `https://github.com/ChuckBuilds/ledmatrix-<plugin-name>.git`
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Official plugin, from the ledmatrix-plugins monorepo
|
||||
./scripts/dev/dev_plugin_setup.sh link-github stocks
|
||||
# Auto-construct URL from plugin name
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
|
||||
# Third-party plugin with its own repository
|
||||
# Use explicit URL
|
||||
./scripts/dev/dev_plugin_setup.sh link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
|
||||
|
||||
# Link from a different GitHub user
|
||||
./scripts/dev/dev_plugin_setup.sh link-github custom-plugin https://github.com/OtherUser/custom-plugin.git
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Repositories are cloned to `~/.ledmatrix-dev-plugins/` by default (configurable)
|
||||
- The monorepo is cloned once and shared by every plugin you link from it
|
||||
- If the repository already exists, it will be updated with `git pull` instead of re-cloning
|
||||
- The cloned repository is preserved when you unlink the plugin
|
||||
|
||||
@@ -242,28 +217,30 @@ Updates plugin(s) by running `git pull` in their repositories.
|
||||
|
||||
### Custom Development Directory
|
||||
|
||||
By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`
|
||||
and official plugins come from `ChuckBuilds/ledmatrix-plugins`. To change
|
||||
either, copy `dev_plugins.json.example` (in the LEDMatrix root) to
|
||||
`dev_plugins.json` and edit it. `dev_plugins.json` is git-ignored.
|
||||
By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`. You can customize this by creating a `dev_plugins.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
|
||||
"github_user": "your-github-user",
|
||||
"plugins_repo": "ledmatrix-plugins",
|
||||
"plugins_branch": "main"
|
||||
"dev_plugins_dir": "/path/to/your/dev/plugins",
|
||||
"github_user": "ChuckBuilds",
|
||||
"github_pattern": "ledmatrix-",
|
||||
"plugins": {
|
||||
"music": {
|
||||
"source": "github",
|
||||
"url": "https://github.com/ChuckBuilds/ledmatrix-music.git",
|
||||
"branch": "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration options** (all optional):
|
||||
**Configuration options:**
|
||||
- `dev_plugins_dir`: Where to clone GitHub repositories (default: `~/.ledmatrix-dev-plugins`)
|
||||
- `github_user`: Owner of the plugin monorepo that `link-github <name>` clones — set it to use your fork (default: `ChuckBuilds`)
|
||||
- `plugins_repo`: Name of that monorepo (default: `ledmatrix-plugins`)
|
||||
- `plugins_branch`: Branch to clone it at (default: the repository's default branch). Only applies when the clone is first made.
|
||||
- `github_user`: Default GitHub username for auto-constructing URLs
|
||||
- `github_pattern`: Pattern for repository names (default: `ledmatrix-`)
|
||||
- `plugins`: Plugin definitions (optional, for future auto-discovery features)
|
||||
|
||||
`github_pattern` from older versions of this guide is no longer used (the
|
||||
script warns if it is set).
|
||||
**Note:** Copy `dev_plugins.json.example` to `dev_plugins.json` and customize it. The `dev_plugins.json` file is git-ignored.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -271,46 +248,43 @@ script warns if it is set).
|
||||
|
||||
1. **Link your plugin for development:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github clock-simple
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
```
|
||||
|
||||
2. **Test in LEDMatrix:**
|
||||
```bash
|
||||
# Run LEDMatrix with your plugin (emulator shown)
|
||||
python3 run.py -e
|
||||
# Run LEDMatrix with your plugin
|
||||
python run.py
|
||||
```
|
||||
|
||||
3. **Make changes:**
|
||||
```bash
|
||||
cd plugins/clock-simple
|
||||
cd plugins/music
|
||||
# Edit files...
|
||||
# Test changes...
|
||||
```
|
||||
|
||||
4. **Commit to the plugin repository:**
|
||||
4. **Commit to plugin repository:**
|
||||
```bash
|
||||
cd plugins/clock-simple # This is inside your monorepo checkout
|
||||
# bump "version" in manifest.json, then from the monorepo root:
|
||||
# python update_registry.py
|
||||
cd plugins/music # This is actually your repo
|
||||
git add .
|
||||
git commit -m "feat(clock-simple): add new feature"
|
||||
git push
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
5. **Update from remote (if needed):**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh update clock-simple
|
||||
./scripts/dev/dev_plugin_setup.sh update music
|
||||
```
|
||||
|
||||
6. **When done developing:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh unlink clock-simple
|
||||
./scripts/dev/dev_plugin_setup.sh unlink music
|
||||
```
|
||||
|
||||
### Working with Multiple Plugins
|
||||
|
||||
You can have multiple plugins linked simultaneously. Plugins linked from the
|
||||
monorepo share one checkout:
|
||||
You can have multiple plugins linked simultaneously:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
@@ -320,7 +294,7 @@ monorepo share one checkout:
|
||||
# Check status of all
|
||||
./scripts/dev/dev_plugin_setup.sh status
|
||||
|
||||
# Update all at once (the shared monorepo checkout is pulled once)
|
||||
# Update all at once
|
||||
./scripts/dev/dev_plugin_setup.sh update
|
||||
```
|
||||
|
||||
@@ -435,7 +409,7 @@ If you have conflicts when updating:
|
||||
|
||||
1. **Manually resolve in the plugin repository:**
|
||||
```bash
|
||||
cd ~/.ledmatrix-dev-plugins/ledmatrix-plugins
|
||||
cd ~/.ledmatrix-dev-plugins/ledmatrix-music
|
||||
git pull
|
||||
# Resolve conflicts...
|
||||
git add .
|
||||
@@ -496,19 +470,18 @@ You can mix local and GitHub plugins:
|
||||
|
||||
The development workflow is separate from the plugin store installation:
|
||||
|
||||
- **Plugin Store:** Installs plugins as regular directories in the configured
|
||||
plugins directory (`plugin-repos/` by default)
|
||||
- **Development Setup:** Links plugin directories as symlinks in `plugins/`
|
||||
- **Plugin Store:** Installs plugins to `plugins/` as regular directories
|
||||
- **Development Setup:** Links plugin repositories as symlinks
|
||||
|
||||
The plugin loader scans only one directory, so while developing set
|
||||
`plugin_system.plugins_directory` to `plugins` (see the note at the top of
|
||||
this guide). If `plugins/` already holds a regular directory of the same
|
||||
name, `link`/`link-github` offers to rename it to
|
||||
`<name>.backup.<timestamp>` before linking.
|
||||
If you install a plugin via the store, you can still link it for development:
|
||||
|
||||
`unlink` removes only the symlink. To switch back to the store version, set
|
||||
`plugins_directory` back to `plugin-repos` (or reinstall the plugin from the
|
||||
store).
|
||||
```bash
|
||||
# Store installs to plugins/music (regular directory)
|
||||
# Link for development (will prompt to replace)
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
```
|
||||
|
||||
When you unlink, the directory is removed. If you want to switch back to the store version, re-install it via the plugin store.
|
||||
|
||||
## API Reference
|
||||
|
||||
@@ -552,7 +525,7 @@ Want to create and share your own plugin? Here's everything you need to know.
|
||||
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Patterns and examples
|
||||
|
||||
2. **Start with a template**:
|
||||
- Use the [Hello World plugin](https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hello-world) as a starting point
|
||||
- Use the [Hello World plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) as a starting point
|
||||
- Or fork an existing plugin and modify it
|
||||
|
||||
3. **Follow the plugin structure**:
|
||||
@@ -616,16 +589,24 @@ Your plugin must:
|
||||
### Versioning Best Practices
|
||||
|
||||
- **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
- **Bump `version` in `manifest.json` by hand** for every change you ship.
|
||||
There is no automatic version-bump hook or bump script.
|
||||
- **Official (monorepo) plugins**: after bumping the manifest, run
|
||||
`python update_registry.py` in the `ledmatrix-plugins` checkout. It copies
|
||||
each manifest's version into `plugins.json` as `latest_version`, which is
|
||||
what the store compares installed versions against. Without it, users
|
||||
won't be offered the update.
|
||||
- **Plugins in their own repository**: still bump the manifest `version`,
|
||||
so users can see which version they run; tagging releases (`v1.2.3`) to
|
||||
match is a good habit.
|
||||
- **GitHub as source of truth**: the plugin store resolves versions in this
|
||||
order: GitHub Releases → GitHub Tags → manifest from branch → git commit hash
|
||||
- **Automatic version bumping**: install the self-contained pre-push hook in
|
||||
your plugin repo and patch versions bump themselves on push (a git tag
|
||||
`v{version}` is created and `manifest.json` staged automatically):
|
||||
|
||||
```bash
|
||||
# From your plugin repository directory
|
||||
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
```
|
||||
|
||||
Set `SKIP_TAG=1` in the environment to skip auto-tagging for one push.
|
||||
- **Manual versioning**: only needed for major/minor bumps, CI pipelines that
|
||||
bypass hooks, or forks without the hook — use
|
||||
`scripts/bump_plugin_version.py`.
|
||||
- **Registry stores no versions**: `plugins.json` holds only metadata (name,
|
||||
description, repo URL).
|
||||
|
||||
### Submitting to Official Registry
|
||||
|
||||
@@ -637,14 +618,12 @@ To have your plugin added to the official plugin store:
|
||||
- Follows best practices
|
||||
- Tested on Raspberry Pi hardware
|
||||
|
||||
2. **Choose where it lives** (see `SUBMISSION.md` in
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)):
|
||||
- **In the monorepo (preferred):** fork ledmatrix-plugins, add
|
||||
`plugins/<your-plugin-id>/`, and open a pull request
|
||||
- **In your own public repository** (conventionally
|
||||
`ledmatrix-<plugin-name>`), with a README that covers installation
|
||||
2. **Create GitHub repository**:
|
||||
- Repository name: `ledmatrix-<plugin-name>`
|
||||
- Public repository
|
||||
- Proper README.md with installation instructions
|
||||
|
||||
3. **Contact maintainers** (own-repository plugins):
|
||||
3. **Contact maintainers**:
|
||||
- Open a GitHub issue in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository
|
||||
- Or reach out on Discord: https://discord.gg/uW36dVAtcT
|
||||
- Include: Repository URL, plugin description, why it's useful
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Per-element styling for plugin authors
|
||||
|
||||
Users want to change the font, size and colour of individual things on screen,
|
||||
nudge them a few pixels, hide the ones they do not care about, and scale a logo
|
||||
down. This is the one system that does that, and a plugin joins it by
|
||||
**declaring elements in its `config_schema.json`** — not by writing a style
|
||||
resolver, a font cache or a web form.
|
||||
|
||||
The short version:
|
||||
|
||||
```jsonc
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"title": "Display Customization",
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": { "default": "PressStart2P-Regular.ttf" },
|
||||
"size": { "default": 10, "min": 4, "max": 16 },
|
||||
"color": { "default": [255, 255, 255] },
|
||||
"offsets": true,
|
||||
"visible": true,
|
||||
"align": true
|
||||
},
|
||||
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
|
||||
},
|
||||
"x-style-modes": ["live", "upcoming", "recent"]
|
||||
}
|
||||
```
|
||||
|
||||
That is the whole declaration. The core expands it into a full JSON Schema, the
|
||||
web UI renders a compact style editor with a row per element, and the values
|
||||
land in `config.json` under the keys you named.
|
||||
|
||||
## What each key does
|
||||
|
||||
| Key | Effect |
|
||||
|---|---|
|
||||
| `font` | Font picker listing every shipped **and user-uploaded** font. |
|
||||
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
|
||||
| `color` | Colour swatch; stored as `[r, g, b]`. |
|
||||
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
|
||||
| `visible` | Show/hide toggle. |
|
||||
| `align` | `left` / `center` / `right`. |
|
||||
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
|
||||
|
||||
`x-style-modes` is optional. Declare it and every element gains a per-mode
|
||||
override tab — a scoreboard can then style its live, upcoming and recent cards
|
||||
separately. **A mode field left blank means "inherit", not zero.**
|
||||
|
||||
## Reading the values
|
||||
|
||||
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
|
||||
on its own:
|
||||
|
||||
```python
|
||||
style = self.styles.style(
|
||||
"score_text",
|
||||
classic_font="PressStart2P-Regular.ttf", # what you shipped
|
||||
classic_size=10,
|
||||
classic_color=(255, 255, 255),
|
||||
)
|
||||
if style.visible:
|
||||
draw.text((x + style.offset[0], y + style.offset[1]),
|
||||
text, font=style.font, fill=style.color)
|
||||
```
|
||||
|
||||
For a specific mode, use `self.styles_for("recent")`, or set
|
||||
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
|
||||
|
||||
### The one rule that matters
|
||||
|
||||
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
|
||||
them verbatim unless the user actually changed something, which is what keeps an
|
||||
untouched install rendering byte-identically. It can tell the difference because
|
||||
a value only counts as user-forced when it *differs from the schema default* —
|
||||
the save path writes the full default object into `config.json` on every save,
|
||||
so "present in config" proves nothing.
|
||||
|
||||
Never compare against the default yourself; that rule lives in exactly one place.
|
||||
|
||||
### Stateless readers
|
||||
|
||||
For helpers handed a config dict rather than a plugin instance:
|
||||
|
||||
```python
|
||||
from src.element_style import (element_color, element_visible,
|
||||
element_align, element_scale, layout_offset)
|
||||
|
||||
colour = element_color(config, "score_text", (255, 255, 255), mode)
|
||||
shown = element_visible(config, "records", True, mode)
|
||||
dy = layout_offset(config, "score", "y_offset", 0, mode)
|
||||
```
|
||||
|
||||
## Sports scoreboards
|
||||
|
||||
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
|
||||
|
||||
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
|
||||
resolves the colour by name *and* honours the visibility toggle. Without it
|
||||
the colour has to be guessed from the identity of the font object, which
|
||||
cannot tell two elements apart when they share a face — the case every
|
||||
bitmap font is in.
|
||||
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
|
||||
`SKIN_MODE` on each is enough; no call site passes a mode.
|
||||
|
||||
## Adopting an existing hand-written block
|
||||
|
||||
If your schema already spells out `font` / `font_size` / `text_color` per
|
||||
element longhand, **you do not need to change anything**. The core recognises
|
||||
that shape and upgrades it in place: the style editor, the real font picker
|
||||
(including uploaded fonts) and per-mode overrides all appear on a core update.
|
||||
Add `x-style-modes` if you want the mode tabs.
|
||||
|
||||
## Fonts, and why size is sometimes locked
|
||||
|
||||
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
|
||||
one pixel size and ignore `font_size`. The picker knows which, and the editor
|
||||
locks the size field to the native size and labels it `fixed`. A font too tall
|
||||
for the `max` you declared is not offered at all.
|
||||
|
||||
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
|
||||
automatically.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
|
||||
2. Read through `self.styles`, passing your shipped values as `classic_*`.
|
||||
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
|
||||
4. Confirm an untouched config renders identically:
|
||||
`python scripts/check_plugin.py --plugin <id>`.
|
||||
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
|
||||
|
||||
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
|
||||
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
|
||||
@@ -127,8 +127,7 @@ git push origin v1.0.0
|
||||
|
||||
### REST API
|
||||
|
||||
The API is mounted at `/api/v3` (the `api_v3` blueprint in
|
||||
`web_interface/blueprints/api_v3/`, registered in `web_interface/app.py`).
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
|
||||
```bash
|
||||
# Install plugin from the registry
|
||||
|
||||
@@ -103,7 +103,6 @@ All plugins can be installed through the LEDMatrix web interface:
|
||||
Or via API:
|
||||
```bash
|
||||
curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plugin_id": "clock-simple"}'
|
||||
```
|
||||
|
||||
@@ -154,7 +153,6 @@ Before submitting, ensure your plugin:
|
||||
```bash
|
||||
# Install via URL on your Pi
|
||||
curl -X POST http://your-pi:5000/api/v3/plugins/install-from-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repo_url": "https://github.com/you/ledmatrix-your-plugin"}'
|
||||
```
|
||||
|
||||
@@ -314,7 +312,6 @@ git push
|
||||
# 2. Review using VERIFICATION.md checklist
|
||||
# 3. Test installation:
|
||||
curl -X POST http://pi:5000/api/v3/plugins/install-from-url \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repo_url": "https://github.com/contributor/plugin"}'
|
||||
|
||||
# 4. If approved, merge PR
|
||||
|
||||
@@ -131,13 +131,13 @@ else:
|
||||
**Via REST API:**
|
||||
```bash
|
||||
# Search by query
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?query=hockey"
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?q=hockey"
|
||||
|
||||
# Filter by category
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?category=sports"
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?category=sports"
|
||||
|
||||
# Filter by tags
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?tags=nhl&tags=hockey"
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?tags=nhl&tags=hockey"
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
@@ -351,7 +351,8 @@ All API endpoints return JSON with this structure:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v3/plugins/store/list` | List plugins in store; `?query=`, `?category=`, `?tags=` search and filter |
|
||||
| GET | `/api/v3/plugins/store/list` | List all plugins in store |
|
||||
| GET | `/api/v3/plugins/store/search` | Search for plugins |
|
||||
| GET | `/api/v3/plugins/installed` | List installed plugins |
|
||||
| POST | `/api/v3/plugins/install` | Install from registry |
|
||||
| POST | `/api/v3/plugins/install-from-url` | Install from GitHub URL |
|
||||
|
||||
+2
-4
@@ -45,8 +45,6 @@ Going deeper:
|
||||
|
||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
||||
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
|
||||
move, hide and scale individual elements (per display mode, if you have them)
|
||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
||||
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
||||
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
||||
@@ -56,8 +54,8 @@ Going deeper:
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||
cache management, background services, permissions
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins)
|
||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat)
|
||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards
|
||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin
|
||||
|
||||
## Reference
|
||||
|
||||
|
||||
+397
-899
File diff suppressed because it is too large
Load Diff
@@ -1,335 +0,0 @@
|
||||
# Scroll Performance
|
||||
|
||||
How scrolling is paced on this hardware, what was wrong with it, and how to
|
||||
configure a plugin so its marquee is smooth.
|
||||
|
||||
Measured on a Raspberry Pi 4 driving a 2×128×64 chain (256×64 logical) at
|
||||
`limit_refresh_rate_hz: 100`. Numbers below come from that panel.
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| scroll frame rate | 44–46 fps | **100 fps, locked** |
|
||||
| frames ≥ 45 ms | 14–17% | none observed |
|
||||
| dominant frame time | 20 ms | **10 ms** |
|
||||
| disk cache write (~1 MB) | 14.8 ms | **5.4 ms** |
|
||||
|
||||
---
|
||||
|
||||
## The one rule that matters
|
||||
|
||||
**Motion is smooth when the strip advances a whole number of pixels per panel
|
||||
refresh.**
|
||||
|
||||
Advancing one pixel per refresh on a 100 Hz panel gives 100 px/s. Slower crisp
|
||||
speeds come from holding each frame for several refreshes -- 50 px/s is one
|
||||
pixel every second refresh -- which is covered under *Choosing a speed* below.
|
||||
A speed that lands on no such combination has to do one of two bad things:
|
||||
|
||||
- **blend** two adjacent columns to render a half-step — on pixel-font text
|
||||
this alternates crisp and smeared frames and reads as shimmer, or as the
|
||||
text jumping a pixel ahead of itself;
|
||||
- **repeat** a frame — the strip stands still, then jumps, which reads as
|
||||
judder.
|
||||
|
||||
Neither is tunable away. Pick a speed that divides evenly.
|
||||
|
||||
`src.common.scroll_config` solves this for you: `configure()` snaps a requested
|
||||
speed to the nearest one the panel can actually show in whole pixels, and
|
||||
`scripts/scroll_speeds.py` prints the full ladder for your hardware.
|
||||
|
||||
## Choosing a speed
|
||||
|
||||
The crisp speeds are not a fixed list -- they depend on how fast *your* panel
|
||||
refreshes, which depends on its size, `pwm_bits`, `gpio_slowdown` and the Pi
|
||||
model. A Pi Zero driving a long chain has a completely different set of good
|
||||
speeds from a Pi 4 driving a short one.
|
||||
|
||||
```bash
|
||||
# what can this panel do? (reads your configured refresh rate)
|
||||
python3 scripts/scroll_speeds.py
|
||||
|
||||
# what does it ACTUALLY manage, rather than what is configured?
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/scroll_speeds.py --measure
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
# highlight the closest option to the speed you want
|
||||
python3 scripts/scroll_speeds.py --want 45
|
||||
|
||||
# try one on the panel
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/scroll_speeds.py --demo 50
|
||||
sudo systemctl start ledmatrix
|
||||
```
|
||||
|
||||
Sample ladder for a 100 Hz panel:
|
||||
|
||||
```
|
||||
20.0 px/s (1px every 5 refreshes = 20.0 fps, slightly stepped)
|
||||
25.0 px/s (1px every 4 refreshes = 25.0 fps, slightly stepped)
|
||||
33.3 px/s (1px every 3 refreshes = 33.3 fps, smooth)
|
||||
50.0 px/s (1px every 2 refreshes = 50.0 fps, smooth)
|
||||
66.7 px/s (2px every 3 refreshes = 33.3 fps, smooth)
|
||||
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
|
||||
```
|
||||
|
||||
### How a slow speed stays crisp
|
||||
|
||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||
refreshes. **The panel keeps refreshing at its full rate either way**, so
|
||||
holding a frame costs nothing in flicker -- it only changes how often a *new*
|
||||
image is presented. That is what allows 50 px/s to be one whole pixel every
|
||||
second refresh, instead of half a pixel every refresh (which has no good
|
||||
rendering, only a choice between blur and judder).
|
||||
|
||||
`scroll_config.configure()` snaps the requested speed to the nearest entry on
|
||||
the ladder, sets the helper to advance that entry's whole-pixel step on every
|
||||
presented frame (`ScrollHelper.set_pixels_per_frame`), and reports the hold
|
||||
that speed needs. It does **not** apply the hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
|
||||
share one display manager -- one set at construction is reset the moment any
|
||||
other plugin finishes scrolling. Apply it yourself when the scroll starts:
|
||||
|
||||
```python
|
||||
settings = scroll_config.configure(
|
||||
self.scroll_helper,
|
||||
plugin_config=self.config,
|
||||
global_config=self.global_config,
|
||||
display_manager=self.display_manager, # supplies the panel refresh rate
|
||||
)
|
||||
|
||||
# ...then, each time this plugin begins scrolling:
|
||||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||
```
|
||||
|
||||
Passing `display_manager` only lets `configure` read the true refresh rate from
|
||||
`display.hardware`, which a plugin config cannot see. Skipping the
|
||||
`set_scrolling_state(True, frame_hold=...)` call is the mistake that matters.
|
||||
The helper consults no clock in this mode -- it moves the fixed step once per
|
||||
`update_scroll_position()` call, and `SwapOnVSync` is what paces those calls --
|
||||
so without the hold the panel presents a new frame every refresh and the scroll
|
||||
runs `frame_hold` times too fast: 50 px/s (hold 2) plays at 100 px/s.
|
||||
|
||||
Pass `snap_to_crisp=False` to keep an exact requested speed and accept the
|
||||
artefacts. The helper then paces off elapsed time instead of stepping, and the
|
||||
hold is 1.
|
||||
|
||||
The General tab's `target_fps` ("Scroll Frame Rate") plays no part in any of
|
||||
this: frames are presented at the panel refresh divided by the hold.
|
||||
|
||||
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
|
||||
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
|
||||
software limit; the only way to move in smaller increments is sub-pixel
|
||||
blending, which this display does not tolerate (see above).
|
||||
|
||||
## Configuring a plugin
|
||||
|
||||
Use the shared resolver rather than reading config keys yourself:
|
||||
|
||||
```python
|
||||
from src.common import scroll_config
|
||||
|
||||
settings = scroll_config.configure(
|
||||
self.scroll_helper,
|
||||
plugin_config=self.config,
|
||||
global_config=self.global_config,
|
||||
display_manager=self.display_manager,
|
||||
plugin_logger=self.logger,
|
||||
)
|
||||
|
||||
# each frame of a scroll (or at least when it starts):
|
||||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||
```
|
||||
|
||||
It resolves every config shape in one place, applies the speed, and returns
|
||||
what it did. Precedence, highest first:
|
||||
|
||||
1. `display_options.scroll_speed` + `scroll_delay` — **the recommended form**
|
||||
2. `display.scroll_speed` + `scroll_delay` — deprecated shape
|
||||
3. `scroll_speed` + `scroll_delay` at the root — legacy flat
|
||||
4. `scroll_pixels_per_second` — deprecated
|
||||
5. the global `display` block
|
||||
6. the built-in default (100 px/s)
|
||||
|
||||
`scroll_speed` is pixels per frame and `scroll_delay` is the frame period in
|
||||
seconds, so the pair means `scroll_speed / scroll_delay` px/s. The recommended
|
||||
config for a 100 Hz panel:
|
||||
|
||||
```json
|
||||
"display_options": { "scroll_speed": 1.0, "scroll_delay": 0.01 }
|
||||
```
|
||||
|
||||
### Why the deprecated key ranks below the explicit pair
|
||||
|
||||
Because some plugins give `scroll_pixels_per_second` a **schema default**, and
|
||||
schema defaults are merged into plugin config. Ranking it above the pair means
|
||||
it is always present and always wins, so the documented settings become
|
||||
unreachable. That is a real, shipped bug — see
|
||||
[ledmatrix-plugins#408](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/408).
|
||||
|
||||
The flip side: a `scroll_pixels_per_second` you add by hand is ignored whenever
|
||||
the plugin's config also carries the pair, which it does whenever the pair has
|
||||
a schema default. Set the speed through the pair instead.
|
||||
|
||||
The sports scoreboards (`src.common.sports_scroll`) are the exception to all of
|
||||
the above: they read `scroll_settings.scroll_speed` per league as px/s directly,
|
||||
and their `scroll_delay` is kept for compatibility but ignored for pacing.
|
||||
|
||||
If you are writing a plugin: do not give a deprecated key a schema default.
|
||||
|
||||
## What was actually wrong
|
||||
|
||||
Four independent faults, each found by measurement.
|
||||
|
||||
### 1. The frame loop slept on top of a wait it had already done
|
||||
|
||||
`display_controller.py` ran the high-FPS loop as `render → SwapOnVSync (blocks
|
||||
to the panel's refresh) → time.sleep(0.008) → plugin ticks`. The sleep was
|
||||
unconditional and added to a wait that had already happened. Render work
|
||||
measured ~4 ms, so each iteration cost ~12 ms against a 10 ms refresh grid —
|
||||
every swap missed a refresh and landed on the next one. The loop settled at
|
||||
exactly 50 fps while asking for 125, with no headroom, so ~14% of frames
|
||||
slipped a further refresh.
|
||||
|
||||
Now the loop sleeps only the remainder of the frame budget, with a 1 ms floor
|
||||
so plugin threads still get the GIL.
|
||||
|
||||
### 2. `SwapOnVSync` held the GIL while blocking
|
||||
|
||||
The rgbmatrix binding declares it without `nogil` (unlike `SetPixel`, `Clear`
|
||||
and `Fill` immediately above it in `cppinc.pxd`), so the render thread held the
|
||||
GIL for the entire vsync wait — most of every frame. Background threads were
|
||||
starved into long uninterruptible bursts; a 1.5 MB API response costs ~17 ms to
|
||||
parse and ~18 ms to re-encode for the cache, and `json.raw_decode` cannot be
|
||||
preempted mid-document. Those bursts are what the render loop then waited on.
|
||||
|
||||
Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
||||
|
||||
### 3. Sub-pixel blending was wrong for this display
|
||||
|
||||
Enabling it made things worse, not better — see the rule at the top. It is off
|
||||
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
||||
|
||||
### 4. Frame-based stepping raced the vsync clock
|
||||
|
||||
Frame-based mode gated motion on a wall clock at `1/scroll_delay` steps per
|
||||
second. Plugins set `scroll_delay` to the frame period, which puts that
|
||||
comparison exactly on its own threshold: a frame arriving a hair early moved
|
||||
zero pixels and rendered an identical frame, which dirty-tracking skipped, so
|
||||
it returned in ~2 ms and the beat repeated. No `scroll_delay` value tunes this
|
||||
out — a shorter delay just trades stalled frames for periodic double-steps.
|
||||
|
||||
A crisp speed configured through `scroll_config` no longer consults a clock at
|
||||
all. Once `SwapOnVSync` blocks until the panel has taken the frame, the frame
|
||||
count is a truer clock than `time.time()`, so the helper advances a fixed whole
|
||||
number of pixels per presented frame (`set_pixels_per_frame`) and the display
|
||||
manager holds each frame for `frame_hold` refreshes. Every frame moves the eye
|
||||
by the same amount.
|
||||
|
||||
The time-based path remains only for callers that set a speed directly or pass
|
||||
`snap_to_crisp=False`. There, frame-based mode no longer steps either: it
|
||||
advances by elapsed time at `scroll_speed / scroll_delay` px/s.
|
||||
|
||||
## Diagnosing a juddery scroller
|
||||
|
||||
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
|
||||
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
|
||||
healthy 100 fps. The stats line reports the tail for that reason — read the
|
||||
percentiles, not the fps.
|
||||
|
||||
Every scroller emits one line every 5 seconds covering *every* frame in that
|
||||
window, tagged with the plugin it came from:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
|
||||
```
|
||||
|
||||
```
|
||||
[Plugin: news] Scroll frame stats - 100.0 fps over 501 frames | median 10.00ms
|
||||
p95 10.11ms max 12.03ms min 7.98ms | stalls 0 (0.0%) skips 0 (0.0%)
|
||||
```
|
||||
|
||||
Reading it, on a 100 Hz panel:
|
||||
|
||||
A healthy median is the refresh period times the scroll's frame hold: 10 ms
|
||||
for a hold of 1 (100 px/s), **20 ms for 50 px/s** (hold 2), 30 ms for 33.3 px/s.
|
||||
A 20 ms median on a 50 px/s scroll is the hold doing its job, not missed
|
||||
refreshes. The `Scroll configured:` log line gives the hold (`1px every 2
|
||||
refreshes`).
|
||||
|
||||
| you see | it means |
|
||||
|---|---|
|
||||
| median = refresh period × hold, p95 within ~0.5 ms of it | healthy — locked to the panel |
|
||||
| p95 or max a whole refresh period or more above that median | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
|
||||
| non-zero **skips**, or a median *below* the expected one | **duplicate frames** — the swap was skipped because the image did not change, so the frame never waited on vsync. The scroller is advancing less than one pixel per frame, which a crisp fixed-step scroll never does; look for a plugin pacing off time or not passing the hold. |
|
||||
| non-zero **stalls** | frames past 1.5× the median, which is the measure of judder that survives averaging |
|
||||
|
||||
`stalls` and `skips` are both counted against that window's own median, so they
|
||||
stay meaningful on a panel running at any refresh rate.
|
||||
|
||||
To rank every scroller at once rather than reading lines one at a time:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-3h" --no-pager | grep "Scroll frame stats" \
|
||||
| sed -E 's/.*- (\S+) - (\[Plugin: [^]]+\] )?Scroll.*median ([0-9.]+)ms p95 ([0-9.]+)ms.*/\1 \3 \4/' \
|
||||
| awk '$2 < 1000 {n[$1]++; m[$1]+=$2; p[$1]+=$3} END {for (k in n)
|
||||
printf "%-28s %5d windows median %6.2fms p95 %6.2fms\n", k, n[k], m[k]/n[k], p[k]/n[k]}' \
|
||||
| sort -k7 -rn
|
||||
```
|
||||
|
||||
The `$2 < 1000` guard drops windows whose median is a whole second or more.
|
||||
Those are not frames. Until the idle-gap fix in `log_frame_rate()`, the first
|
||||
frame of every scroll was timed against the end of the *previous* scroll, so
|
||||
the gap between them was recorded as one enormous sample — it landed in the
|
||||
`max` field of otherwise healthy windows and counted as one stall per scroll,
|
||||
roughly 0.2% at 500 frames to a window, which is the same order as the real
|
||||
stall rates it sat beside. Current builds emit none, but the guard costs
|
||||
nothing and keeps the command honest against older journals.
|
||||
|
||||
A scroller whose p95 sits several times its median is the one to fix, and it is
|
||||
usually the one doing the most per-frame work rather than the one configured
|
||||
worst. Measured over 20 minutes with two scrollers set identically at 100 px/s,
|
||||
the leaderboard held 10 ms flat while the odds ticker spent ~20% of its frames
|
||||
on duplicates. Same settings, different render cost: odds does more per-frame
|
||||
work, and more variably, so it is first to land a frame that advances less than
|
||||
a whole pixel. Check the render path before the config.
|
||||
|
||||
Then confirm what the plugin actually loaded — config edits do not always reach
|
||||
the running code:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
|
||||
```
|
||||
|
||||
If a plugin logs its scroll config **twice** with different modes, the second
|
||||
line is what is running.
|
||||
|
||||
## Rebuilding the binding
|
||||
|
||||
```bash
|
||||
bash scripts/build_rgbmatrix_nogil.sh # build into a scratch dir
|
||||
sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
||||
sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
```
|
||||
|
||||
The build never touches the installed module. `--install` backs up the original
|
||||
to `~/rgbmatrix-core.so.ORIGINAL` first, and rolls back automatically if the
|
||||
service does not come back healthy. Requires `build-essential`; Cython is
|
||||
installed into a cached venv under `~/.cache/ledmatrix-cython`.
|
||||
|
||||
Re-run it after upgrading `rpi-rgb-led-matrix`, since a library upgrade
|
||||
replaces the patched binding.
|
||||
|
||||
## Faster JSON
|
||||
|
||||
`src/cache/disk_cache.py` uses `orjson` when it is importable and falls back to
|
||||
the stdlib otherwise, so it is optional:
|
||||
|
||||
```bash
|
||||
sudo pip3 install --break-system-packages orjson
|
||||
```
|
||||
|
||||
Encoding is where it pays — about 7× on this hardware. Decoding gains far less
|
||||
(~1.3× on large payloads) because the cost there is building Python objects,
|
||||
not scanning text. That is also why moving parsing to a subprocess does not
|
||||
help: `pickle.loads` of the same payload costs 8.1 ms against `json.loads` at
|
||||
10.9 ms, so the work just moves rather than disappearing.
|
||||
+12
-48
@@ -1,35 +1,5 @@
|
||||
# Skin System Architecture
|
||||
|
||||
## Status: not supported yet
|
||||
|
||||
**Skins don't render with the current scoreboard plugins.** The skin system
|
||||
below works in isolation (it loads, validates and renders skins in
|
||||
`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a
|
||||
running display calls it:
|
||||
|
||||
- The only render hook is `SportsCore._render_game()` in
|
||||
`src/base_classes/sports/core.py`.
|
||||
- None of the current scoreboard plugins build on `src.base_classes`. The
|
||||
official scoreboards in the `ledmatrix-plugins` monorepo, and the
|
||||
third-party scoreboards in the plugin registry, carry their own sports and
|
||||
rendering code (with the shared `src/common/sports_*` helpers) and never
|
||||
reach `SportsCore._render_game()`.
|
||||
|
||||
So a skin can be dropped into `skins/` and named in a plugin's config, but the
|
||||
scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the
|
||||
hook, core does not offer skins to users:
|
||||
|
||||
- The plugin config page shows no **Visual Skin** dropdown.
|
||||
- The Plugin Store hides registry entries with `"type": "skin"` and refuses
|
||||
to install one (`POST /api/v3/plugins/install` answers 400 with the reason).
|
||||
- `GET /api/v3/skins` still lists what is in `skins/`, with
|
||||
`"supported": false` and a `message`.
|
||||
- A config that already contains `"skin"` / `"skin_options"` still loads,
|
||||
validates and saves unchanged; the value is simply unused.
|
||||
|
||||
The rest of this document describes the design as built, for whoever wires a
|
||||
scoreboard to it.
|
||||
|
||||
Skins are user-installable **visual overlays** for the sports scoreboards.
|
||||
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
||||
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
||||
@@ -62,10 +32,8 @@ crashing) simply restores the built-in look.
|
||||
|
||||
## The render funnel
|
||||
|
||||
A sports scoreboard built on the `src/base_classes/sports/` package
|
||||
(`core.py`) renders through exactly one seam. No current scoreboard plugin is
|
||||
built on it (see [Status](#status-not-supported-yet)), so for them this seam is
|
||||
never reached:
|
||||
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||
built on the `src/base_classes/sports/` package, `core.py`) renders through exactly one seam:
|
||||
`SportsCore._render_game(game, force_clear)`.
|
||||
|
||||
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||
@@ -167,26 +135,22 @@ Inside the plugin's own config section in `config/config.json`:
|
||||
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
||||
section, it persists across plugin reinstalls like every other setting.
|
||||
|
||||
`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the
|
||||
*served* schema for plugins with matching skins installed. While skins are
|
||||
unsupported the plugin schema endpoint does not call it, so the dropdown is
|
||||
not shown. Validation never sees the enum either way: the base schema allows
|
||||
any `skin` value, so a config that references an uninstalled skin stays valid.
|
||||
`GET /api/v3/skins` lists installed skins (optionally filtered by
|
||||
`?plugin_id=`) and reports `"supported": false`.
|
||||
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
||||
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
||||
*served* schema only. Validation never sees the enum — so a config that
|
||||
references an uninstalled skin stays valid (rendering just falls back), and
|
||||
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
||||
lists installed skins (optionally filtered by `?plugin_id=`).
|
||||
|
||||
## Distribution
|
||||
|
||||
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
||||
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
||||
plugins.
|
||||
- **Store (disabled while unsupported):** registry entries with
|
||||
`"type": "skin"` are hidden from the store list and refused on install.
|
||||
`PluginStoreManager._install_skin_from_info` is kept: once
|
||||
`SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such
|
||||
entries install through the same `plugins.json` pipeline, land in `skins/`,
|
||||
are validated against `skin.json` (including the API major version) instead
|
||||
of `manifest.json`, and never install dependencies — skins are render-only
|
||||
- **Store:** registry entries with `"type": "skin"` install through the same
|
||||
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
||||
validates `skin.json` (including the API major version) instead of
|
||||
`manifest.json`, and never installs dependencies — skins are render-only
|
||||
(stdlib + PIL + the provided context, no third-party packages in v1).
|
||||
|
||||
## Trust model
|
||||
|
||||
@@ -80,30 +80,11 @@ src/base_classes/sports/
|
||||
src/common/
|
||||
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
||||
(content building stays in the plugins)
|
||||
sports_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin
|
||||
(3.5.0) — the helpers byte-identical in the
|
||||
plugins' sports.py, and the _favorite_key seam
|
||||
```
|
||||
|
||||
`from src.base_classes.sports import SportsCore` keeps working — the package
|
||||
`__init__` re-exports, so the conversion is invisible to every existing importer.
|
||||
|
||||
### Converging on `src/common`
|
||||
|
||||
The scoreboards do not build on `src/base_classes`; their own `sports.py` copies
|
||||
have moved past it. So shared code now lands in hardware-free `src/common`
|
||||
modules taken from the plugin copies, each a **new module** rather than growth
|
||||
on an existing one: a plugin that deletes a method copy and relies on an older
|
||||
module having gained it fails at runtime with an `AttributeError`, while a
|
||||
missing module fails at load, where the version checks can see it.
|
||||
`sports_helpers.py` is the first (it holds `_favorite_key`, the override point
|
||||
listed below, for later phases); its parity test compares every body against
|
||||
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
||||
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
||||
`rgbmatrix`, `src.base_classes` and `src.plugin_system`. How a plugin adopts a
|
||||
module and drops its copy is documented in the plugins repo's
|
||||
`docs/plugin-development/08-shared-sports-code.md`.
|
||||
|
||||
## Override points (the plugin-facing seam)
|
||||
|
||||
The base class calls these; plugins implement or override them. This table is the
|
||||
@@ -218,14 +199,6 @@ The one behavior the upstreamed version adds is native
|
||||
Part A threaded it through each copy by hand, and this makes that threading
|
||||
legacy compatibility rather than the mechanism.
|
||||
|
||||
> **Superseded.** Once presentation became frame-locked (#545) the helper
|
||||
> steps a fixed whole-pixel amount per presented frame and the panel presents
|
||||
> at its own refresh, so honouring `target_fps` only turned it into a speed
|
||||
> multiplier (60 doubled a scoreboard's speed, 200 halved it). `sports_scroll`
|
||||
> no longer reads it: the crisp-speed ladder uses the panel refresh
|
||||
> (`display_manager.refresh_hz`), and speed comes from
|
||||
> `scroll_settings.scroll_speed` alone. See `docs/SCROLL_PERFORMANCE.md`.
|
||||
|
||||
## Phases
|
||||
|
||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||
@@ -472,9 +445,8 @@ After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||
and its one delivered user-visible gain was that adopted plugins honoured the
|
||||
global `target_fps` instead of hardcoding ~100 FPS (since withdrawn: see the
|
||||
note under the B3 design above).
|
||||
and its one delivered user-visible gain is that adopted plugins honour the
|
||||
global `target_fps` instead of hardcoding ~100 FPS.
|
||||
|
||||
### Decision: stop adopting further modules until B6 closes
|
||||
|
||||
|
||||
@@ -158,11 +158,9 @@ This script will check:
|
||||
- Python dependencies
|
||||
- Configuration files
|
||||
- File permissions
|
||||
- Web interface availability (`ledmatrix-web` listening on port 5000)
|
||||
- Web interface availability
|
||||
- Network connectivity
|
||||
|
||||
Once it passes, the web interface is at `http://<pi-ip>:5000`.
|
||||
|
||||
## Quick Reference Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -273,7 +273,7 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
1. **Verify file structure:**
|
||||
```bash
|
||||
ls -l web_interface/app.py
|
||||
ls -ld web_interface/blueprints/api_v3/
|
||||
ls -l web_interface/blueprints/api_v3.py
|
||||
ls -l web_interface/blueprints/pages_v3.py
|
||||
```
|
||||
|
||||
@@ -531,7 +531,7 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
```bash
|
||||
# Clear the cache with the helper script
|
||||
sudo python3 scripts/utils/clear_cache.py --clear-all
|
||||
sudo python3 scripts/utils/clear_cache.py
|
||||
|
||||
# Or remove files manually from the cache dir in use, e.g.:
|
||||
sudo rm -rf /var/cache/ledmatrix/*
|
||||
|
||||
+14
-50
@@ -96,35 +96,6 @@ Configure basic system settings:
|
||||
- **Plugin System Settings** — including the `plugins_directory` (default
|
||||
`plugin-repos/`) used by the plugin loader
|
||||
- **Autostart** options for the display service
|
||||
- **Automatic updates** — once a week, update LEDMatrix and every installed
|
||||
plugin with a newer version. Off by default. Runs 2–5 AM local time when
|
||||
possible, otherwise within a day of being due. The last result and next
|
||||
check are shown under the toggle, and anything other than success raises a
|
||||
banner on **Overview**.
|
||||
- *Checks first:* the code update is skipped, with the reason shown, if
|
||||
tracked files were edited locally (permission-only changes and edits under
|
||||
`plugins/` or `plugin-repos/` don't count; the pull carries those across
|
||||
and puts them back), the checkout has local commits, a
|
||||
rebase/merge is in progress, the branch has no upstream, less than 300 MB
|
||||
is free, or the newest version already failed once. A failed fetch is
|
||||
retried the next day.
|
||||
- *Health check and rollback:* after pulling, `ledmatrix-update-verify.service`
|
||||
restarts the services and checks that the web interface responds and the
|
||||
display (if it was running) stays up. If not — or if the new dependencies
|
||||
failed to install — it resets to the previous commit, reinstalls the
|
||||
previous dependencies and restarts again. A running display is restarted;
|
||||
a stopped one stays stopped.
|
||||
- *Plugins* update through the Plugin Store, which refuses versions that need
|
||||
a newer LEDMatrix and restores the old copy when an install fails. When the
|
||||
code changed, plugins wait until it passes its health check. Plugins are
|
||||
not health-checked after updating.
|
||||
- *Setup needs no SSH.* Turning the toggle on restarts the display service,
|
||||
which installs the health check (`ledmatrix-update-verify.path` and
|
||||
`.service`); the General tab shows when it is ready, or why setup failed.
|
||||
Until then only plugins update. New installs set it up during
|
||||
installation and can switch updates on with
|
||||
`first_time_install.sh --enable-auto-update` (or `LEDMATRIX_AUTO_UPDATE=1`,
|
||||
which `one-shot-install.sh` passes through), or at the installer's prompt.
|
||||
|
||||
Click **Save** to write changes to `config/config.json`. Most changes
|
||||
require a display service restart from **Overview**.
|
||||
@@ -134,26 +105,18 @@ require a display service restart from **Overview**.
|
||||
Configure your LED matrix hardware:
|
||||
|
||||
**Matrix configuration:**
|
||||
- `rows` — LED rows per panel (typically 32 or 64; even, at least 8 — the
|
||||
current rgbmatrix library rejects more than 64)
|
||||
- `cols` — LED columns per panel (typically 64 or 96; at least 16)
|
||||
- `rows` — LED rows (typically 32 or 64)
|
||||
- `cols` — LED columns (typically 64 or 96)
|
||||
- `chain_length` — number of horizontally chained panels
|
||||
- `parallel` — number of parallel chains (1–3)
|
||||
- `parallel` — number of parallel chains
|
||||
- `hardware_mapping` — `adafruit-hat-pwm` (with PWM jumper mod),
|
||||
`adafruit-hat` (without), `regular` (direct wiring, and the Adafruit Triple
|
||||
LED Matrix Bonnet), or `regular-pi1`
|
||||
- `gpio_slowdown` — depends on your Pi and panel (roughly 1–3 on a Pi 3,
|
||||
2–4 on a Pi 4); raise it if rows jump or the image is garbage
|
||||
- `brightness` — 1–100%
|
||||
`adafruit-hat` (without), `regular`, or `regular-pi1`
|
||||
- `gpio_slowdown` — must match your Pi model (3 for Pi 3, 4 for Pi 4, etc.)
|
||||
- `brightness` — 0–100%
|
||||
- `pwm_bits`, `pwm_lsb_nanoseconds`, `pwm_dither_bits` — PWM tuning
|
||||
- Dynamic Duration — global cap for plugins that extend their display
|
||||
time based on content
|
||||
|
||||
The collapsed **Advanced Hardware & Display Options** section holds
|
||||
multiplexing, panel type, row address type, scan mode, PWM tuning, the
|
||||
refresh-rate cap and hardware pulsing. Every field has a help tip, and the
|
||||
README's Display Settings section describes each one with its allowed range.
|
||||
|
||||
**Vegas Scroll Mode:** the Display tab also has a full Vegas Scroll
|
||||
Mode section — enable toggle, scroll speed, separator width, dynamic
|
||||
duration, and related settings — so you can configure Vegas mode
|
||||
@@ -208,11 +171,11 @@ Manage fonts for your display:
|
||||
- See font previews
|
||||
- Check font sizes and styles
|
||||
|
||||
**Font Preview:**
|
||||
- Render sample text in any TTF/OTF font at a chosen size
|
||||
|
||||
Fonts used by a plugin are chosen in that plugin's own settings tab; the
|
||||
Fonts tab has no per-element override editor.
|
||||
**Font Overrides:**
|
||||
- Overrides are set per display *element* (e.g. a specific score or
|
||||
clock text element), not per plugin
|
||||
- Override default font choices for individual elements
|
||||
- Preview font changes
|
||||
|
||||
**Delete Fonts:**
|
||||
- Remove unused fonts
|
||||
@@ -329,8 +292,9 @@ The web interface is built on a REST API that you can access programmatically:
|
||||
http://your-pi-ip:5000/api/v3
|
||||
```
|
||||
|
||||
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
`/api/v3` in `web_interface/app.py`.
|
||||
The API blueprint mounts at `/api/v3` (see
|
||||
`web_interface/app.py:199`). All endpoints below are relative to that
|
||||
base.
|
||||
|
||||
**Common Endpoints:**
|
||||
- `GET /api/v3/config/main` — Get main configuration
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# Web UI Technical Audit — September 2026
|
||||
|
||||
Scope: `web_interface/` (Flask + HTMX + Alpine, `templates/v3/`, `static/v3/`).
|
||||
Method: Impeccable design detector, code review (accessibility; performance,
|
||||
theming, responsive), and a live pass on the running app at desktop and
|
||||
375px mobile widths in light and dark themes. Severe claims were verified
|
||||
against the live page; one was rejected (see below). No code was changed.
|
||||
|
||||
Product context: see [`PRODUCT.md`](../../PRODUCT.md).
|
||||
|
||||
## Health score: 8/20 (Poor)
|
||||
|
||||
| # | Dimension | Score | Key finding |
|
||||
|---|-----------|-------|-------------|
|
||||
| 1 | Accessibility | 2 | Focus rings never render; modals have no dialog semantics or focus management |
|
||||
| 2 | Performance | 2 | ~1.2 MB JS (≈250 KB gzip) on every page; SSE streams and polling never pause |
|
||||
| 3 | Responsive | 2 | Mobile drawer works; header title wraps to 3 lines; many ~24px touch targets |
|
||||
| 4 | Theming | 1 | Tokens exist but hex dominates; dark mode is a class-by-class patch with leaks |
|
||||
| 5 | Implementation integrity | 1 | Templates use Tailwind classes that don't exist in the stylesheet |
|
||||
|
||||
## Implementation integrity verdict: fail
|
||||
|
||||
There is no Tailwind build. `static/v3/app.css` is a hand-written subset of
|
||||
Tailwind, while templates and JS are authored as if full Tailwind were loaded.
|
||||
|
||||
- **333 of 516 utility class names used have no CSS rule** (2,582 uses),
|
||||
confirmed against the live stylesheets. Top offenders: `border` (250),
|
||||
`text-gray-700` (183), `block` (148), `mr-1` (127), `hidden` (79),
|
||||
`py-1`, `text-blue-600`, `px-2`, `text-center`, `hover:bg-blue-700`,
|
||||
`divide-y`, `uppercase`, `font-mono`.
|
||||
- **`.hidden` has never existed in `app.css`**, so the 145
|
||||
`classList.add/remove/toggle('hidden')` calls across 26 files do nothing.
|
||||
Visible proof: the header shows both the moon and sun theme icons.
|
||||
- **15 classes are defined only under `[data-theme="dark"]`** (e.g.
|
||||
`bg-blue-50`, `bg-red-50`, `bg-yellow-50`, `border-blue-200`,
|
||||
`text-red-700`), so tinted notice boxes are unstyled in light mode.
|
||||
- Visible damage: the Getting Started checklist (`partials/overview.html:96-117`)
|
||||
renders native gray outset buttons in both themes; 32 visible buttons on
|
||||
the Plugin Manager page render with default browser chrome; search icons
|
||||
overlap inputs; error/diff modal backdrops are transparent
|
||||
(`bg-gray-500 bg-opacity-75` undefined).
|
||||
|
||||
Other drift:
|
||||
- Four competing `showNotification` definitions (`app.js:6`,
|
||||
`app-shell.js:2464`, `widgets/notification.js:298`, `partials/fonts.html:249`)
|
||||
— the winner depends on load order — plus 53 `alert()`/`confirm()` calls.
|
||||
- At least four modal implementations (on-demand modal in `base.html:1032`,
|
||||
Tailwind-UI style in `error_handler.js`/`diff_viewer.js`, `.jfm-*`/`.pfm-*`
|
||||
with injected CSS, ad-hoc modals in `plugins_manager.js`).
|
||||
- `.btn` mixed with ~90 hand-assembled color-utility button combos.
|
||||
- SSE wiring duplicated in `app-shell.js:5-60` and `app.js:186-205`.
|
||||
|
||||
## Findings by severity
|
||||
|
||||
### P0
|
||||
|
||||
**Undefined utility layer** (above). Every show/hide toggle and every layout
|
||||
built from missing classes silently fails; root cause of most visual bugs.
|
||||
Fix: replace the hand-rolled subset with a real, purged Tailwind build
|
||||
(with dark-mode variants), or at minimum define the high-use missing classes
|
||||
(`hidden`, `border`, `block`, spacing/text utilities) and a button reset.
|
||||
→ `/impeccable harden`
|
||||
|
||||
### P1
|
||||
|
||||
- **Focus rings never render.** `focus:ring-2` (`app.css:289-291`) references
|
||||
`--tw-ring-inset` and `--tw-ring-offset-width`, which are never defined, so
|
||||
the `box-shadow` is invalid. `focus:outline-none` (18 uses) does remove the
|
||||
outline. `peer-focus:ring-4` has no rule, so the plugin enable toggle
|
||||
(`plugins_manager.js:1586-1593`, `sr-only` checkbox) shows no focus.
|
||||
WCAG 2.4.7.
|
||||
- **Modals lack dialog semantics.** Only `json-file-manager.js` has
|
||||
`role="dialog"`/`aria-modal`/Escape/initial focus; none trap focus or
|
||||
return it. On-demand (`base.html:1032`), error (`error_handler.js:164-205`),
|
||||
diff (`diff_viewer.js:211-214`), plugin file manager
|
||||
(`plugin-file-manager.js:372-392, 576-599`), array-table editor
|
||||
(`array-table.js:460-467`) have none of it. WCAG 2.1.2 / 4.1.2.
|
||||
- **Unnamed controls.** ~16 icon-only buttons with no accessible name, e.g.
|
||||
`base.html:1036`, `plugins.html:173,210`, `plugin_config.html:485,656`,
|
||||
`number-input.js:102,129`, `text-input.js:120`, `date-picker.js:95`,
|
||||
`time-picker.js:100`, `password-input.js:141`. ~115 of 245 form fields have
|
||||
no label (hotspots: `plugin_config.html` 19, `starlark_config.html` 14,
|
||||
`plugins.html` 11); confirmed live on the 11 store search/sort/filter inputs.
|
||||
- **Captive WiFi setup page** (first-run surface): `#msg` status has no live
|
||||
region, and `outline:none` is replaced by a 15%-alpha shadow
|
||||
(`captive_setup.html:16,48`).
|
||||
- **Background traffic never stops.** `/stream/stats` and `/stream/display`
|
||||
SSE stay open on every tab (display frames push with no preview visible).
|
||||
Tab timers keep running after leaving the tab (`display.html:1046` 5s,
|
||||
`logs.html:222` 5s, `tools.html:987` 15s, `plugins_manager.js:1882` 15s,
|
||||
update check `base.html:1196` 30min). Only `tools.html:999` checks
|
||||
`visibilitychange`. Costly on a Pi Zero 2 W.
|
||||
- **Page weight.** 47 script tags on every page, including all 33 widgets
|
||||
(`base.html:984-1018`). `app-shell.js` (177 KB) is render-blocking
|
||||
(`base.html:956`); `plugins_manager.js` is 277 KB.
|
||||
- **Dark mode leaks.** `plugin-file-manager.js` (53 hex) and
|
||||
`json-file-manager.js` (63 hex, e.g. `.jfm-modal-box{background:#fff}`)
|
||||
inject CSS that ignores `data-theme`; `.form-control` hard-codes
|
||||
`#fff`/`#111827` (`app.css:668-671`). `app.css` has 186 hex + 46 rgb
|
||||
literals vs 94 `var(--…)` uses.
|
||||
|
||||
### P2
|
||||
|
||||
- Toasts: `role="alert"` inside an `aria-live="polite"` container
|
||||
(`notification.js:78,156`) → double/assertive announcements; auto-dismiss 4s.
|
||||
- `prefers-reduced-motion` covers 3 animations; ~106 `animate-pulse`/`fa-spin`
|
||||
uses, `modalSlideIn`, and toast slides ignore it.
|
||||
- Mobile: header title wraps to three lines and spills out of the header;
|
||||
~33 plugin-card buttons are `text-xs px-2 py-1` (~24px); `#logs-container`
|
||||
forced to 400/350px with `!important` (`app.css:399-411`).
|
||||
- Logs panel contrast: `text-gray-400` on `bg-gray-900` ≈ 3.9:1
|
||||
(`logs.html:75,86`).
|
||||
- Three unnamed nested `<nav>` landmarks (`base.html:474,477,548`); no skip link.
|
||||
- Plugin lists fully rebuilt via `innerHTML` on every filter change
|
||||
(`plugins_manager.js:1554, 3784, 3993, 4389, 5905`); `logs.html:225` adds a
|
||||
reflow-forcing resize listener on every partial load.
|
||||
|
||||
### P3
|
||||
|
||||
- No `loading="lazy"` on images; Font Awesome `font-display:block`.
|
||||
- Unpinned `alpinejs@3.x.x` unpkg fallback (`base.html:241`).
|
||||
- `widgets/example-color-picker.js` is not loaded anywhere.
|
||||
- Detector: 3px accent stripe on `.plugin-card::before` (`app.css:721`).
|
||||
|
||||
## Verified and rejected
|
||||
|
||||
- **"Static assets are never cache-busted" (raised as P0): false.**
|
||||
`app.py:491` (`@app.url_defaults add_static_version`) appends file mtime as
|
||||
`?v=` to every static URL; the live HTML confirms it. The manual
|
||||
`?v=20260307` on two script tags is merely redundant.
|
||||
- Light-mode gray text contrast is mostly fine: `app.css` remaps grays darker
|
||||
(4.8–10:1).
|
||||
- Detector `gray-on-color` hits at `app.css:84,285` and `broken-image` hits
|
||||
(JS-populated `src`) are not real rendered issues.
|
||||
|
||||
## What works
|
||||
|
||||
- Theme set before first paint, follows OS preference, `data-theme` + tokens.
|
||||
- Mobile drawer: Escape closes it, focus returns to the hamburger, 44px rows.
|
||||
- `aria-current="page"` on nav tabs; real `<header>` and `<main>`.
|
||||
- Status colors always paired with text; nearly all images have alt text.
|
||||
- `toggle-switch.js` uses `role="switch"`; vendor assets self-hosted.
|
||||
|
||||
## Open decisions (block the P0 fix approach)
|
||||
|
||||
Recorded as undecided in `PRODUCT.md`:
|
||||
- Must the UI work fully offline (no CDN fallbacks)?
|
||||
- Is a Node/CSS build step acceptable for contributors?
|
||||
- Is WCAG 2.2 AA a formal requirement?
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. **[P0] `/impeccable harden`** — fix the utility layer (real Tailwind build
|
||||
or define missing classes + button reset).
|
||||
2. **[P1] `/impeccable harden`** — focus-ring variables and `peer-focus`;
|
||||
one shared accessible modal helper; name icon buttons and label fields;
|
||||
live region on the captive page.
|
||||
3. **[P1] `/impeccable optimize`** — pause SSE/timers on hidden tab or page;
|
||||
load widget scripts on demand.
|
||||
4. **[P1] `/impeccable colorize`** — move file-manager CSS and `.form-control`
|
||||
onto theme tokens.
|
||||
5. **[P2] `/impeccable adapt`** — header wrap, touch targets, log height.
|
||||
6. **[P2] `/impeccable animate`** — reduced-motion alternatives.
|
||||
7. **`/impeccable polish`** — final pass.
|
||||
@@ -10,8 +10,7 @@ plugin without breaking a size or screen you didn't think to test.
|
||||
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
|
||||
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
|
||||
strips, tall stacks). Plugins are expected to read dimensions dynamically
|
||||
(`self.display_manager.width/height` — not `matrix.width/height`, since
|
||||
`matrix` is `None` when hardware init fails) and lay themselves out
|
||||
(`self.display_manager.matrix.width/height`) and lay themselves out
|
||||
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
||||
here.
|
||||
|
||||
|
||||
+11
-87
@@ -281,49 +281,11 @@ Guidelines:
|
||||
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.
|
||||
Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`:
|
||||
|
||||
```javascript
|
||||
// Ensure LEDMatrixWidgets registry is available
|
||||
@@ -404,29 +366,7 @@ window.LEDMatrixWidgets.register('my-custom-widget', {
|
||||
});
|
||||
```
|
||||
|
||||
### 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
|
||||
### Step 2: Reference Widget in Schema
|
||||
|
||||
In your plugin's `config_schema.json`:
|
||||
|
||||
@@ -443,30 +383,15 @@ In your plugin's `config_schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Widget Loading
|
||||
### Step 3: Widget Loading
|
||||
|
||||
The widget is loaded on demand when the plugin's configuration form renders a
|
||||
field that references it. The system will:
|
||||
The widget will be automatically loaded when the plugin configuration form is rendered. 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.
|
||||
1. Check if widget is registered in the core registry
|
||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
||||
3. Render the widget using the registered `render` function
|
||||
|
||||
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.
|
||||
**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement.
|
||||
|
||||
## Widget API Reference
|
||||
|
||||
@@ -572,11 +497,10 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf
|
||||
- ✅ Plugin widget loading system implemented
|
||||
|
||||
**Current Behavior:**
|
||||
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||
- 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)
|
||||
- Custom widgets can be created and registered
|
||||
- Full client-side rendering is a future enhancement
|
||||
|
||||
**Backwards Compatibility:**
|
||||
- All existing plugins using widgets continue to work without changes
|
||||
|
||||
+13
-173
@@ -125,72 +125,6 @@ fi
|
||||
# Get the home directory of the actual user
|
||||
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
|
||||
# --- rpi-rgb-led-matrix checkout helpers -------------------------------------
|
||||
# Run git as whoever owns the project directory. Run as root against a
|
||||
# user-owned repo, git refuses it ("dubious ownership"), and anything it does
|
||||
# create — such as .git/modules/<submodule> — ends up root-owned, locking the
|
||||
# user out of their own checkout. A root-owned install keeps running as root.
|
||||
_rgb_repo_owner() {
|
||||
stat -c %U "$PROJECT_ROOT_DIR" 2>/dev/null || echo root
|
||||
}
|
||||
|
||||
_git_as_repo_owner() {
|
||||
local owner
|
||||
owner=$(_rgb_repo_owner)
|
||||
if [ "$(id -u)" = "0" ] && [ "$owner" != "root" ] && command -v sudo >/dev/null 2>&1; then
|
||||
sudo -u "$owner" -H git "$@"
|
||||
else
|
||||
git "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Earlier installer versions ran the submodule git commands as root, leaving
|
||||
# root-owned files the repo owner (and so _git_as_repo_owner) cannot write.
|
||||
_reclaim_rgb_checkout() {
|
||||
local owner path
|
||||
owner=$(_rgb_repo_owner)
|
||||
if [ "$(id -u)" != "0" ] || [ "$owner" = "root" ]; then
|
||||
return 0
|
||||
fi
|
||||
for path in "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" "$PROJECT_ROOT_DIR/.git/modules/rpi-rgb-led-matrix-master"; do
|
||||
if [ -e "$path" ]; then
|
||||
chown -R "$owner:" "$path" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# `git pull` on the main repo never moves an existing submodule checkout, so a
|
||||
# submodule bump (e.g. the ARMv6 build fix for Pi Zero/1) would never reach a
|
||||
# device installed before it. Move the checkout forward to the pinned commit —
|
||||
# but never backward or sideways: a user who ran `git submodule update --remote`
|
||||
# is newer than the pin and is left alone. Never fatal.
|
||||
_sync_rgb_submodule() {
|
||||
local sub="$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" pinned current
|
||||
if [ ! -f "$PROJECT_ROOT_DIR/.gitmodules" ] || ! grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules" \
|
||||
|| [ ! -e "$sub/.git" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! pinned=$(_git_as_repo_owner -C "$PROJECT_ROOT_DIR" rev-parse "HEAD:rpi-rgb-led-matrix-master" 2>/dev/null) \
|
||||
|| [ -z "$pinned" ]; then
|
||||
return 0
|
||||
fi
|
||||
current=$(_git_as_repo_owner -C "$sub" rev-parse HEAD 2>/dev/null) || current=""
|
||||
if [ "$current" = "$pinned" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$current" ] && _git_as_repo_owner -C "$sub" cat-file -e "${pinned}^{commit}" 2>/dev/null \
|
||||
&& ! _git_as_repo_owner -C "$sub" merge-base --is-ancestor "$current" "$pinned" 2>/dev/null; then
|
||||
echo "rpi-rgb-led-matrix-master is at ${current:0:7}, not behind the pinned ${pinned:0:7}; leaving it as is"
|
||||
return 0
|
||||
fi
|
||||
echo "Updating rpi-rgb-led-matrix-master to the pinned commit ${pinned:0:7}..."
|
||||
if ! _git_as_repo_owner -C "$PROJECT_ROOT_DIR" submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Could not update rpi-rgb-led-matrix-master to the pinned commit; building the existing checkout"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
# --- end rpi-rgb-led-matrix checkout helpers ---------------------------------
|
||||
|
||||
# Determine the Project Root Directory (where this script is located)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
@@ -220,8 +154,6 @@ SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0}
|
||||
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
|
||||
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
|
||||
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
||||
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
|
||||
AUTO_UPDATE=${LEDMATRIX_AUTO_UPDATE:-}
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
@@ -236,15 +168,12 @@ Options:
|
||||
--skip-swap Never add temporary swap for the C++ build
|
||||
--build-jobs N Compile the C++ library with N parallel jobs
|
||||
(default: scaled to available RAM)
|
||||
--enable-auto-update Turn on weekly automatic updates (with health
|
||||
check and automatic rollback)
|
||||
--no-auto-update Leave weekly automatic updates off
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Environment variables (same effect as flags):
|
||||
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
||||
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N, LEDMATRIX_AUTO_UPDATE=1|0
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N
|
||||
|
||||
Low-memory devices:
|
||||
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
||||
@@ -262,8 +191,6 @@ while [ $# -gt 0 ]; do
|
||||
--skip-perf) SKIP_PERF=1 ;;
|
||||
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
|
||||
--skip-swap) SKIP_SWAP=1 ;;
|
||||
--enable-auto-update) AUTO_UPDATE=1 ;;
|
||||
--no-auto-update) AUTO_UPDATE=0 ;;
|
||||
--build-jobs)
|
||||
shift
|
||||
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
||||
@@ -870,50 +797,6 @@ else
|
||||
echo "✓ Main config file already exists"
|
||||
fi
|
||||
|
||||
# Weekly automatic updates (General tab -> Automatic Updates). Off unless asked
|
||||
# for: --enable-auto-update / LEDMATRIX_AUTO_UPDATE=1, or "y" at the prompt when
|
||||
# installing interactively. Only an explicit choice changes the setting, so
|
||||
# re-running the installer with -y never switches it silently.
|
||||
if [ -z "$AUTO_UPDATE" ] && [ "$ASSUME_YES" != "1" ] && [ -t 0 ]; then
|
||||
read -p "Automatically check for and install LEDMatrix updates once a week, with automatic rollback if an update breaks something? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
|
||||
fi
|
||||
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ]; then
|
||||
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
path, enabled = sys.argv[1], sys.argv[2] == "1"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if not isinstance(config.get("auto_update"), dict):
|
||||
config["auto_update"] = {}
|
||||
config["auto_update"]["enabled"] = enabled
|
||||
# Written beside the original and swapped in whole: the display service's
|
||||
# config watcher may be running and must never read a half-written file.
|
||||
original = os.stat(path)
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)), prefix=".config.")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=4)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp, original.st_mode & 0o777)
|
||||
if hasattr(os, "chown"):
|
||||
os.chown(tmp, original.st_uid, original.st_gid)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
PY
|
||||
then
|
||||
if [ "$AUTO_UPDATE" = "1" ]; then echo "✓ Weekly automatic updates enabled"; else echo "✓ Weekly automatic updates off"; fi
|
||||
else
|
||||
echo "⚠ Could not set auto_update in config/config.json; turn it on from the General tab instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create config_secrets.json from template if missing
|
||||
if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.template.json" ]; then
|
||||
@@ -1155,9 +1038,8 @@ else
|
||||
# so git clone doesn't fail with "destination path already exists".
|
||||
_clone_rpi_rgb() {
|
||||
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||
_git_as_repo_owner clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
}
|
||||
_reclaim_rgb_checkout
|
||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
@@ -1165,7 +1047,7 @@ else
|
||||
# Try to initialize submodule if .gitmodules exists
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||
if ! retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
@@ -1184,14 +1066,12 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
rm -rf rpi-rgb-led-matrix-master
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
else
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
fi
|
||||
|
||||
_sync_rgb_submodule
|
||||
|
||||
# Add temporary swap on low-memory devices so the compiler survives.
|
||||
CURRENT_STEP="Prepare the low-memory build environment"
|
||||
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
|
||||
@@ -1392,7 +1272,7 @@ if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ ! -f "/etc/systemd/system/ledmatrix-update-verify.path" ] || [ "$NEEDS_UPDATE" = true ]; then
|
||||
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ "$NEEDS_UPDATE" = true ]; then
|
||||
bash "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"
|
||||
# Ensure systemd sees any new/changed unit files
|
||||
systemctl daemon-reload || true
|
||||
@@ -1408,7 +1288,7 @@ echo ""
|
||||
CURRENT_STEP="Harden systemd unit file permissions"
|
||||
echo "Step 8.1: Setting systemd unit file permissions..."
|
||||
echo "-----------------------------------------------"
|
||||
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service" "/etc/systemd/system/ledmatrix-update-verify.service" "/etc/systemd/system/ledmatrix-update-verify.path"; do
|
||||
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service"; do
|
||||
if [ -f "$unit" ]; then
|
||||
chown root:root "$unit" || true
|
||||
chmod 644 "$unit" || true
|
||||
@@ -1504,9 +1384,6 @@ echo "------------------------------------------------"
|
||||
# Create sudoers configuration for the web interface
|
||||
echo "Creating sudoers configuration..."
|
||||
SUDOERS_FILE="/etc/sudoers.d/ledmatrix_web"
|
||||
# A predictable name in a world-writable directory is a symlink target;
|
||||
# root writes the rules here, so let mktemp pick the name.
|
||||
SUDOERS_TMP=$(mktemp "${TMPDIR:-/tmp}/ledmatrix_web_sudoers.XXXXXX")
|
||||
|
||||
# Get command paths
|
||||
PYTHON_PATH=$(which python3)
|
||||
@@ -1517,7 +1394,7 @@ BASH_PATH=$(which bash)
|
||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||
|
||||
# Create sudoers content
|
||||
cat > "$SUDOERS_TMP" << EOF
|
||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||
# LED Matrix Web Interface passwordless sudo configuration
|
||||
# This allows the web interface user to run specific commands without a password
|
||||
|
||||
@@ -1539,12 +1416,9 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controll
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||
# Install a requirements.txt as root via vetted helper, so packages are visible
|
||||
# to root-run ledmatrix.service (not just the web interface's own user).
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> "$SUDOERS_TMP" << EOF
|
||||
cat >> /tmp/ledmatrix_web_sudoers << 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
|
||||
@@ -1558,38 +1432,17 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Never install rules we have not parsed. A malformed drop-in in
|
||||
# /etc/sudoers.d makes sudo refuse every command for every user, which on a
|
||||
# headless Pi leaves no way in at all. If the rules do not parse, say so and
|
||||
# keep whatever is already installed.
|
||||
SUDOERS_VALID=1
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
if ! visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
|
||||
SUDOERS_VALID=0
|
||||
echo "⚠ The generated sudoers rules did not parse:" >&2
|
||||
visudo -c -f "$SUDOERS_TMP" >&2 || true
|
||||
echo "⚠ Leaving $SUDOERS_FILE unchanged. The web interface cannot control" >&2
|
||||
echo " the display service until this is fixed." >&2
|
||||
fi
|
||||
else
|
||||
echo "⚠ visudo not found; installing the sudoers rules unvalidated"
|
||||
fi
|
||||
|
||||
if [ "$SUDOERS_VALID" = "0" ]; then
|
||||
rm -f "$SUDOERS_TMP"
|
||||
elif [ -f "$SUDOERS_FILE" ] && cmp -s "$SUDOERS_TMP" "$SUDOERS_FILE"; then
|
||||
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||
echo "Sudoers configuration already up to date"
|
||||
rm -f "$SUDOERS_TMP"
|
||||
rm /tmp/ledmatrix_web_sudoers
|
||||
else
|
||||
echo "Installing/updating sudoers configuration..."
|
||||
cp "$SUDOERS_TMP" "$SUDOERS_FILE"
|
||||
cp /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
rm -f "$SUDOERS_TMP"
|
||||
rm /tmp/ledmatrix_web_sudoers
|
||||
fi
|
||||
|
||||
if [ "$SUDOERS_VALID" = "1" ]; then
|
||||
echo "✓ Passwordless sudo access configured"
|
||||
fi
|
||||
echo "✓ Passwordless sudo access configured"
|
||||
echo ""
|
||||
|
||||
CURRENT_STEP="Configure WiFi management permissions"
|
||||
@@ -1770,19 +1623,6 @@ chmod 755 "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" "$PROJECT_ROOT_
|
||||
# Re-apply special permissions for config directory (lost during normalization)
|
||||
chmod 2775 "$PROJECT_ROOT_DIR/config" || true
|
||||
|
||||
# Harden the sudo-granted helper scripts: root-owned, not writable by the web
|
||||
# user (matches scripts/install/configure_web_sudo.sh). The sudoers rules in
|
||||
# Step 10 run these as root, so a user-owned copy is a root shell for whoever
|
||||
# can edit it. This must come after Step 11's project-wide chown to
|
||||
# $ACTUAL_USER, which would otherwise hand them straight back.
|
||||
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
|
||||
HELPER_PATH="$PROJECT_ROOT_DIR/scripts/fix_perms/$helper"
|
||||
if [ -f "$HELPER_PATH" ]; then
|
||||
chown root:root "$HELPER_PATH" || echo "⚠ Could not set ownership on $HELPER_PATH"
|
||||
chmod 755 "$HELPER_PATH" || echo "⚠ Could not set permissions on $HELPER_PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✓ Project file permissions normalized"
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
bridge_config.json
|
||||
@@ -1,123 +0,0 @@
|
||||
# Home Assistant MQTT Bridge
|
||||
|
||||
Control the matrix from Home Assistant: force any plugin or mode on demand,
|
||||
turn the display on and off, and set brightness — as real HA entities, not
|
||||
hand-written `mqtt.publish` calls.
|
||||
|
||||
The bridge owns no display logic. It subscribes to one command topic and
|
||||
turns each message into a call against the same `api_v3` routes the web UI
|
||||
uses, so behaviour lives in one place. It talks to the API over HTTP only —
|
||||
no filesystem access — so it can run on the Pi or anywhere that can reach
|
||||
the web interface.
|
||||
|
||||
## What appears in Home Assistant
|
||||
|
||||
On connect the bridge publishes [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
|
||||
config, so the matrix shows up under **Settings → Devices & Services → MQTT**
|
||||
with no YAML:
|
||||
|
||||
| Entity | Does |
|
||||
|---|---|
|
||||
| `select.ledmatrix_display_mode` | Every mode across enabled plugins. Choosing one force-displays it. |
|
||||
| `button.ledmatrix_stop_display` | Back to normal rotation. |
|
||||
| `switch.ledmatrix_power` | Starts/stops the display service. |
|
||||
| `number.ledmatrix_brightness` | 0–100. |
|
||||
|
||||
State is read back from the API every 30 seconds, so the entities also track
|
||||
changes made from the web UI or an on-demand window expiring on its own.
|
||||
All four share an availability topic that is the bridge's MQTT last will:
|
||||
if the bridge dies, HA greys the controls out rather than leaving them
|
||||
looking live but inert.
|
||||
|
||||
## Raw commands
|
||||
|
||||
For anything the entities do not cover, publish JSON to the command topic
|
||||
(`ledmatrix/command` by default):
|
||||
|
||||
```jsonc
|
||||
// Force a mode. plugin_id is optional — the bridge fills it in from
|
||||
// /api/v3/display/modes.
|
||||
{"action": "display", "mode": "nfl_live"}
|
||||
|
||||
// duration is seconds; pinned holds this one mode instead of rotating
|
||||
// through every mode the plugin owns. Pin Starlark apps, where each mode
|
||||
// is an unrelated widget; leave a sports plugin unpinned so live/recent/
|
||||
// upcoming still cycle.
|
||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||
"duration": 300, "pinned": true}
|
||||
|
||||
{"action": "stop_display"}
|
||||
{"action": "power", "state": "on"}
|
||||
{"action": "brightness", "value": 75}
|
||||
|
||||
// Re-read the mode list and re-publish discovery, after installing a plugin
|
||||
{"action": "refresh"}
|
||||
```
|
||||
|
||||
Every command publishes its outcome to `<command_topic>/status`, and current
|
||||
state to `<command_topic>/state`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A LEDMatrix install with its web interface reachable (default `http://localhost:5000`)
|
||||
- An MQTT broker that Home Assistant is also connected to
|
||||
- Python 3 with `paho-mqtt` 2.x and `requests`
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
sudo ./scripts/install/install_mqtt_bridge.sh
|
||||
```
|
||||
|
||||
That copies `bridge_config.example.json` to `bridge_config.json` on first
|
||||
run, installs the dependencies, and enables `ledmatrix-mqtt-bridge.service`.
|
||||
Edit the config with your broker details and re-run it.
|
||||
|
||||
```json
|
||||
{
|
||||
"mqtt_host": "192.168.1.10",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_username": "ledmatrix",
|
||||
"mqtt_password": null,
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": true,
|
||||
"ledmatrix_api_base": "http://localhost:5000"
|
||||
}
|
||||
```
|
||||
|
||||
**TLS is on by default.** Without it the broker password and every display
|
||||
command cross the network in cleartext. If your broker only listens on plain
|
||||
1883 — which the Mosquitto add-on does out of the box — set `"mqtt_tls": false`
|
||||
and `"mqtt_port": 1883`. The bridge logs a warning at startup when a password
|
||||
is configured without TLS.
|
||||
|
||||
`bridge_config.json` is gitignored. Any key can also be supplied through the
|
||||
environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
|
||||
which keeps a broker password out of a file on disk — put it in a systemd
|
||||
drop-in with `Environment=` or `EnvironmentFile=` instead.
|
||||
|
||||
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips
|
||||
certificate verification and exists only for a self-signed broker on a
|
||||
trusted LAN; it logs a warning when used.
|
||||
|
||||
To run it in the foreground while setting things up:
|
||||
|
||||
```bash
|
||||
python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/mqtt_bridge/bridge_config.json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one thing can be on-demand at a time — the same constraint the web UI has.
|
||||
- Forcing a mode restarts the display service, so the panel blanks for a moment.
|
||||
- The mode list comes from `/api/v3/display/modes`, which triggers plugin
|
||||
discovery itself. Discovery is lazy and normally happens because somebody
|
||||
opened the dashboard; without that endpoint a bridge that never does would
|
||||
see an empty list.
|
||||
- Brightness writes `display.hardware.brightness` by posting
|
||||
`{"brightness": N}` to `/api/v3/config/main`. A JSON save changes only the
|
||||
keys it sends, so the other display settings are left as they were. The
|
||||
display service's config hot reload notices the change within a few seconds
|
||||
and applies it without a restart (unless `LEDMATRIX_HOT_RELOAD=false`; while
|
||||
a dim schedule is dimming the panel, the dim level wins until the dim period
|
||||
ends).
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"mqtt_host": "192.168.1.10",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_username": "ledmatrix",
|
||||
"mqtt_password": null,
|
||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": true,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": null,
|
||||
"log_level": "INFO"
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Control a LEDMatrix display from Home Assistant over MQTT.
|
||||
|
||||
The bridge owns no display logic. It subscribes to one command topic and
|
||||
turns each message into a call against the same api_v3 routes the web UI
|
||||
uses, so behaviour stays in one place and this stays a translation layer.
|
||||
|
||||
On connect it publishes Home Assistant MQTT Discovery config, so a matrix
|
||||
appears in HA as real entities rather than something you drive with
|
||||
`mqtt.publish` by hand:
|
||||
|
||||
select.ledmatrix_display_mode every mode across enabled plugins;
|
||||
choosing one force-displays it
|
||||
button.ledmatrix_stop_display back to normal rotation
|
||||
switch.ledmatrix_power the display service, on or off
|
||||
number.ledmatrix_brightness 0-100
|
||||
|
||||
Anything the entities do not cover is still reachable by publishing JSON
|
||||
to the command topic:
|
||||
|
||||
{"action": "display", "mode": "nfl_live"}
|
||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||
"duration": 300, "pinned": true}
|
||||
{"action": "stop_display"}
|
||||
{"action": "power", "state": "on" | "off"}
|
||||
{"action": "brightness", "value": 75}
|
||||
{"action": "refresh"} re-publish discovery after installing a plugin
|
||||
|
||||
Every command publishes its result to <command_topic>/status.
|
||||
|
||||
Run it with `python3 ledmatrix_mqtt_bridge.py [--config PATH]`, or install
|
||||
ledmatrix-mqtt-bridge.service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("ledmatrix-mqtt-bridge")
|
||||
|
||||
DISCOVERY_PREFIX = "homeassistant"
|
||||
DEVICE_ID = "ledmatrix"
|
||||
DEVICE_INFO = {
|
||||
"identifiers": [DEVICE_ID],
|
||||
"name": "LEDMatrix",
|
||||
"manufacturer": "ChuckBuilds",
|
||||
"model": "LEDMatrix Display",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"mqtt_username": None,
|
||||
"mqtt_password": None, # nosec B105 - "no password configured", not a credential
|
||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": False,
|
||||
"mqtt_tls_insecure": False,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": None,
|
||||
"log_level": "INFO",
|
||||
}
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""The bridge cannot start with the configuration it was given."""
|
||||
|
||||
|
||||
def load_config(path: str) -> Dict[str, Any]:
|
||||
"""Read bridge_config.json, overlaid on DEFAULTS.
|
||||
|
||||
Every value may also come from the environment as LEDMATRIX_MQTT_<KEY>,
|
||||
which is how a password stays out of a file that has to be world-readable
|
||||
for the service user.
|
||||
"""
|
||||
config = dict(DEFAULTS)
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
try:
|
||||
loaded = json.load(handle)
|
||||
except json.JSONDecodeError as err:
|
||||
raise ConfigError(f"{path} is not valid JSON: {err}") from err
|
||||
if not isinstance(loaded, dict):
|
||||
raise ConfigError(f"{path} must contain a JSON object")
|
||||
config.update(loaded)
|
||||
else:
|
||||
logger.warning("No config file at %s - using defaults and environment", path)
|
||||
|
||||
for key in DEFAULTS:
|
||||
env_value = os.environ.get(f"LEDMATRIX_MQTT_{key.upper()}")
|
||||
if env_value is not None:
|
||||
config[key] = env_value
|
||||
|
||||
for key in ("mqtt_port", "request_timeout"):
|
||||
try:
|
||||
config[key] = int(config[key])
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ConfigError(f"{key} must be a whole number, got {config[key]!r}") from err
|
||||
for key in ("mqtt_tls", "mqtt_tls_insecure"):
|
||||
config[key] = str(config[key]).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
if config.get("mqtt_password") == "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD":
|
||||
raise ConfigError(
|
||||
"mqtt_password is still the example placeholder - set a real password, "
|
||||
"or remove the key if your broker allows anonymous connections")
|
||||
return config
|
||||
|
||||
|
||||
class LEDMatrixClient:
|
||||
"""The api_v3 calls the bridge needs, and nothing else.
|
||||
|
||||
Everything goes through the HTTP API rather than the filesystem, so the
|
||||
bridge does not have to live on the Pi, does not need read access to
|
||||
config.json, and cannot drift from the web UI's own behaviour.
|
||||
"""
|
||||
|
||||
def __init__(self, api_base: str, timeout: int = 15,
|
||||
session: Optional[requests.Session] = None):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.session = session or requests.Session()
|
||||
|
||||
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||
url = f"{self.api_base}/api/v3{path}"
|
||||
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = {}
|
||||
if response.status_code >= 400 or body.get("status") == "error":
|
||||
message = body.get("message") or f"HTTP {response.status_code}"
|
||||
raise RuntimeError(f"{method} {path} failed: {message}")
|
||||
return body.get("data", body)
|
||||
|
||||
def list_modes(self) -> List[Dict[str, Any]]:
|
||||
"""Every display mode that can be force-displayed, newest discovery.
|
||||
|
||||
/display/modes triggers plugin discovery itself, which matters because
|
||||
discovery is lazy: a bridge that never opens the dashboard would
|
||||
otherwise see nothing at all.
|
||||
"""
|
||||
return self._call("GET", "/display/modes").get("modes", [])
|
||||
|
||||
def display_status(self) -> Dict[str, Any]:
|
||||
return self._call("GET", "/display/on-demand/status")
|
||||
|
||||
def start_on_demand(self, mode: str, plugin_id: Optional[str] = None,
|
||||
duration: Optional[int] = None, pinned: bool = False) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"mode": mode, "pinned": pinned}
|
||||
if plugin_id:
|
||||
# find_plugin_for_mode only sees modes declared in a static
|
||||
# manifest, so a plugin whose modes are generated -- each installed
|
||||
# Starlark app is one -- 404s when plugin_id is omitted. Sending it
|
||||
# skips that lookup. /display/modes reports it for every mode.
|
||||
payload["plugin_id"] = plugin_id
|
||||
if duration:
|
||||
payload["duration"] = int(duration)
|
||||
return self._call("POST", "/display/on-demand/start", json=payload)
|
||||
|
||||
def stop_on_demand(self) -> Dict[str, Any]:
|
||||
return self._call("POST", "/display/on-demand/stop", json={})
|
||||
|
||||
def set_power(self, on: bool) -> Dict[str, Any]:
|
||||
action = "start_display" if on else "stop_display"
|
||||
return self._call("POST", "/system/action", json={"action": action})
|
||||
|
||||
def get_brightness(self) -> Optional[int]:
|
||||
config = self._call("GET", "/config/main")
|
||||
value = config.get("display", {}).get("hardware", {}).get("brightness")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def set_brightness(self, value: int) -> Dict[str, Any]:
|
||||
return self._call("POST", "/config/main", json={"brightness": int(value)})
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
"""Turns one decoded MQTT payload into one API call.
|
||||
|
||||
Kept free of MQTT so it can be tested against a fake client: the failure
|
||||
modes worth pinning are all in here (an unknown mode, an out-of-range
|
||||
brightness, a mode name that needs its plugin_id attached).
|
||||
"""
|
||||
|
||||
def __init__(self, client: LEDMatrixClient, default_duration: Optional[int] = None):
|
||||
self.client = client
|
||||
self.default_duration = default_duration
|
||||
self._modes_by_name: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def refresh_modes(self) -> List[Dict[str, Any]]:
|
||||
modes = self.client.list_modes()
|
||||
self._modes_by_name = {m["mode"]: m for m in modes}
|
||||
# Home Assistant's select shows labels, so accept them back as well --
|
||||
# otherwise picking "Simple Clock" in a dashboard is not a mode name.
|
||||
for entry in modes:
|
||||
self._modes_by_name.setdefault(entry.get("name") or entry["mode"], entry)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def known_modes(self) -> List[Dict[str, Any]]:
|
||||
return list({id(v): v for v in self._modes_by_name.values()}.values())
|
||||
|
||||
def handle(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
action = payload.get("action")
|
||||
handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||
"display": self._display,
|
||||
"stop_display": lambda _p: self._ok(self.client.stop_on_demand()),
|
||||
"power": self._power,
|
||||
"brightness": self._brightness,
|
||||
"refresh": lambda _p: self._ok({"modes": len(self.refresh_modes())}),
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if handler is None:
|
||||
return self._error(f"Unknown action {action!r}; expected one of "
|
||||
f"{', '.join(sorted(handlers))}")
|
||||
try:
|
||||
return handler(payload)
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.error("Command %s failed: %s", action, err)
|
||||
return self._error(str(err))
|
||||
|
||||
def _display(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
mode = payload.get("mode")
|
||||
plugin_id = payload.get("plugin_id")
|
||||
if not mode and not plugin_id:
|
||||
return self._error("display requires 'mode' or 'plugin_id'")
|
||||
|
||||
known = self._modes_by_name.get(mode) if mode else None
|
||||
if known is None and mode and not plugin_id:
|
||||
# One retry against a fresh listing: a plugin installed since the
|
||||
# last refresh is the common reason a valid mode looks unknown.
|
||||
self.refresh_modes()
|
||||
known = self._modes_by_name.get(mode)
|
||||
if known is not None:
|
||||
mode = known["mode"]
|
||||
plugin_id = plugin_id or known.get("plugin_id")
|
||||
|
||||
duration = payload.get("duration", self.default_duration)
|
||||
pinned = bool(payload.get("pinned", False))
|
||||
result = self.client.start_on_demand(
|
||||
mode=mode, plugin_id=plugin_id, duration=duration, pinned=pinned)
|
||||
return self._ok(result, mode=mode, plugin_id=plugin_id)
|
||||
|
||||
def _power(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state = str(payload.get("state", "")).strip().lower()
|
||||
if state not in ("on", "off"):
|
||||
return self._error("power requires 'state' of 'on' or 'off'")
|
||||
return self._ok(self.client.set_power(state == "on"), state=state)
|
||||
|
||||
def _brightness(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = payload.get("value")
|
||||
try:
|
||||
value = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return self._error(f"brightness requires a number, got {raw!r}")
|
||||
if not 0 <= value <= 100:
|
||||
return self._error(f"brightness must be between 0 and 100, got {value}")
|
||||
return self._ok(self.client.set_brightness(value), value=value)
|
||||
|
||||
@staticmethod
|
||||
def _ok(result: Any, **extra) -> Dict[str, Any]:
|
||||
return {"status": "success", "result": result, **extra}
|
||||
|
||||
@staticmethod
|
||||
def _error(message: str) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": message}
|
||||
|
||||
|
||||
def discovery_messages(command_topic: str, state_topic: str, availability_topic: str,
|
||||
mode_labels: List[str]) -> List[Dict[str, Any]]:
|
||||
"""The retained MQTT Discovery configs, as {topic, payload} pairs.
|
||||
|
||||
Pure, so the entity shapes can be asserted without a broker. Every entity
|
||||
shares one availability topic, which is also the bridge's last will -- HA
|
||||
then shows the matrix as unavailable when the bridge dies, instead of
|
||||
leaving stale controls that silently do nothing.
|
||||
"""
|
||||
common = {
|
||||
"device": DEVICE_INFO,
|
||||
"availability_topic": availability_topic,
|
||||
"payload_available": "online",
|
||||
"payload_not_available": "offline",
|
||||
}
|
||||
return [
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/select/{DEVICE_ID}/display_mode/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Display Mode",
|
||||
"unique_id": f"{DEVICE_ID}_display_mode",
|
||||
"command_topic": command_topic,
|
||||
"command_template": '{"action": "display", "mode": "{{ value }}"}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json.mode }}",
|
||||
"options": mode_labels,
|
||||
"icon": "mdi:view-dashboard",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/button/{DEVICE_ID}/stop_display/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Stop Display",
|
||||
"unique_id": f"{DEVICE_ID}_stop_display",
|
||||
"command_topic": command_topic,
|
||||
"payload_press": '{"action": "stop_display"}',
|
||||
"icon": "mdi:stop",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/switch/{DEVICE_ID}/power/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Power",
|
||||
"unique_id": f"{DEVICE_ID}_power",
|
||||
"command_topic": command_topic,
|
||||
"payload_on": '{"action": "power", "state": "on"}',
|
||||
"payload_off": '{"action": "power", "state": "off"}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ 'ON' if value_json.power else 'OFF' }}",
|
||||
"state_on": "ON",
|
||||
"state_off": "OFF",
|
||||
"icon": "mdi:power",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/number/{DEVICE_ID}/brightness/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Brightness",
|
||||
"unique_id": f"{DEVICE_ID}_brightness",
|
||||
"command_topic": command_topic,
|
||||
"command_template": '{"action": "brightness", "value": {{ value }}}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json.brightness }}",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"icon": "mdi:brightness-6",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def warn_if_cleartext(config: Dict[str, Any]) -> bool:
|
||||
"""Say so, once, when a broker password is going over an unencrypted link.
|
||||
|
||||
The shipped example has TLS on, so reaching here means somebody turned it
|
||||
off deliberately -- which is legitimate (the Mosquitto add-on is plaintext
|
||||
on 1883) but should not be silent when there is a password to lose. Returns
|
||||
whether it warned, so the decision is testable without a broker.
|
||||
"""
|
||||
if config.get("mqtt_tls") or not config.get("mqtt_password"):
|
||||
return False
|
||||
logger.warning(
|
||||
'mqtt_tls is off and a password is set: the broker password and every '
|
||||
'command are sent unencrypted. Set "mqtt_tls": true (port 8883 on most '
|
||||
'brokers) unless this is a trusted, isolated network.')
|
||||
return True
|
||||
|
||||
|
||||
def read_state(client: LEDMatrixClient) -> Dict[str, Any]:
|
||||
"""The state every entity reads, so HA opens on real values.
|
||||
|
||||
Each field is fetched independently: a matrix with its display service
|
||||
stopped still has a brightness worth showing, and one unreachable field
|
||||
should not blank the rest.
|
||||
"""
|
||||
state: Dict[str, Any] = {"power": False, "mode": None, "brightness": None}
|
||||
try:
|
||||
status = client.display_status()
|
||||
state["power"] = bool(status.get("service", {}).get("active"))
|
||||
on_demand = status.get("state", {})
|
||||
if on_demand.get("active"):
|
||||
state["mode"] = on_demand.get("mode")
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.debug("Could not read display status: %s", err)
|
||||
try:
|
||||
state["brightness"] = client.get_brightness()
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.debug("Could not read brightness: %s", err)
|
||||
return state
|
||||
|
||||
|
||||
class Bridge:
|
||||
"""MQTT wiring around CommandHandler."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.command_topic = config["mqtt_topic"]
|
||||
self.status_topic = f"{self.command_topic}/status"
|
||||
self.state_topic = f"{self.command_topic}/state"
|
||||
self.availability_topic = f"{self.command_topic}/availability"
|
||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
|
||||
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
|
||||
self._stop = threading.Event()
|
||||
self._mqtt = None
|
||||
|
||||
# -- MQTT callbacks (paho-mqtt 2.x VERSION2 signatures) ------------------
|
||||
|
||||
def _on_connect(self, client, _userdata, _flags, reason_code, _properties=None):
|
||||
if getattr(reason_code, "is_failure", reason_code != 0):
|
||||
logger.error("MQTT connection refused: %s", reason_code)
|
||||
return
|
||||
logger.info("Connected to MQTT broker; subscribing to %s", self.command_topic)
|
||||
client.subscribe(self.command_topic, qos=1)
|
||||
client.publish(self.availability_topic, "online", qos=1, retain=True)
|
||||
# Re-publish on every reconnect, not just the first connect: a broker
|
||||
# restart drops retained discovery configs, and HA would otherwise be
|
||||
# left with entities it can no longer describe.
|
||||
self.publish_discovery()
|
||||
self.publish_state()
|
||||
|
||||
def _on_message(self, _client, _userdata, message):
|
||||
try:
|
||||
payload = json.loads(message.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
logger.warning("Ignoring unparseable message on %s: %s", message.topic, err)
|
||||
self._publish(self.status_topic, {"status": "error", "message": f"bad payload: {err}"})
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
self._publish(self.status_topic,
|
||||
{"status": "error", "message": "payload must be a JSON object"})
|
||||
return
|
||||
|
||||
logger.info("Command: %s", payload)
|
||||
result = self.handler.handle(payload)
|
||||
self._publish(self.status_topic, result)
|
||||
# The API applies changes asynchronously (the controller polls its
|
||||
# mailbox), so read state back rather than assuming the command took.
|
||||
self.publish_state()
|
||||
|
||||
# -- publishing ---------------------------------------------------------
|
||||
|
||||
def _publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
if self._mqtt is None:
|
||||
return
|
||||
body = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
self._mqtt.publish(topic, body, qos=1, retain=retain)
|
||||
|
||||
def publish_discovery(self) -> None:
|
||||
try:
|
||||
modes = self.handler.refresh_modes()
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.error("Could not list display modes: %s", err)
|
||||
modes = self.handler.known_modes
|
||||
labels = sorted({m.get("name") or m["mode"] for m in modes})
|
||||
for message in discovery_messages(self.command_topic, self.state_topic,
|
||||
self.availability_topic, labels):
|
||||
self._publish(message["topic"], message["payload"], retain=True)
|
||||
logger.info("Published discovery for %d display mode(s)", len(labels))
|
||||
|
||||
def publish_state(self) -> None:
|
||||
self._publish(self.state_topic, read_state(self.client), retain=True)
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
|
||||
def run(self) -> int:
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
except ImportError:
|
||||
logger.error("paho-mqtt is not installed: pip install -r requirements.txt")
|
||||
return 1
|
||||
|
||||
# VERSION2 is the current callback API. The compatibility note in
|
||||
# CLAUDE.md is about code written against the v1 signatures; this file
|
||||
# is written against v2 and requires paho-mqtt >= 2.0.
|
||||
self._mqtt = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=self.config["mqtt_client_id"])
|
||||
if self.config.get("mqtt_username"):
|
||||
self._mqtt.username_pw_set(self.config["mqtt_username"],
|
||||
self.config.get("mqtt_password"))
|
||||
if self.config.get("mqtt_tls"):
|
||||
self._mqtt.tls_set()
|
||||
if self.config.get("mqtt_tls_insecure"):
|
||||
logger.warning("TLS certificate verification is disabled (mqtt_tls_insecure)")
|
||||
self._mqtt.tls_insecure_set(True)
|
||||
else:
|
||||
warn_if_cleartext(self.config)
|
||||
|
||||
self._mqtt.will_set(self.availability_topic, "offline", qos=1, retain=True)
|
||||
self._mqtt.on_connect = self._on_connect
|
||||
self._mqtt.on_message = self._on_message
|
||||
|
||||
logger.info("Connecting to %s:%s", self.config["mqtt_host"], self.config["mqtt_port"])
|
||||
try:
|
||||
self._mqtt.connect(self.config["mqtt_host"], self.config["mqtt_port"], keepalive=60)
|
||||
except OSError as err:
|
||||
logger.error("Could not reach the MQTT broker: %s", err)
|
||||
return 1
|
||||
|
||||
self._mqtt.loop_start()
|
||||
try:
|
||||
while not self._stop.wait(30):
|
||||
# HA is told the truth about state that changed outside the
|
||||
# bridge -- somebody using the web UI, or an on-demand window
|
||||
# expiring on its own.
|
||||
self.publish_state()
|
||||
finally:
|
||||
self._publish(self.availability_topic, "offline", retain=True)
|
||||
self._mqtt.loop_stop()
|
||||
self._mqtt.disconnect()
|
||||
return 0
|
||||
|
||||
def stop(self, *_args) -> None:
|
||||
logger.info("Shutting down")
|
||||
self._stop.set()
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge_config.json"),
|
||||
help="Path to bridge_config.json (default: alongside this script)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except ConfigError as err:
|
||||
logger.error("%s", err)
|
||||
return 1
|
||||
logging.getLogger().setLevel(str(config.get("log_level", "INFO")).upper())
|
||||
|
||||
bridge = Bridge(config)
|
||||
signal.signal(signal.SIGTERM, bridge.stop)
|
||||
signal.signal(signal.SIGINT, bridge.stop)
|
||||
return bridge.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +0,0 @@
|
||||
# Floors are security floors, not API floors. requests < 2.33.0 carries
|
||||
# CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645; matches the pin in the
|
||||
# project's own requirements.txt.
|
||||
paho-mqtt>=2.0.0,<3.0.0
|
||||
requests>=2.33.0,<3.0.0
|
||||
@@ -194,15 +194,6 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Each installed app becomes a dynamic display mode.
|
||||
"""
|
||||
|
||||
#: Starlark apps are animations: a .webp render carries per-frame delays
|
||||
#: and _display_frame advances at most one frame per call. The controller
|
||||
#: reads this attribute to decide whether a mode needs its high-FPS loop;
|
||||
#: without it display() was called once per rotation slot, so a multi-frame
|
||||
#: app showed a single frame and never moved. static-image is force-run at
|
||||
#: high FPS for the same reason (GIFs), but that plugin is special-cased by
|
||||
#: name in the controller and this one has to declare it.
|
||||
enable_scrolling = True
|
||||
|
||||
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
||||
display_manager, cache_manager, plugin_manager):
|
||||
"""Initialize the Starlark Apps plugin."""
|
||||
@@ -220,8 +211,6 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
# App storage
|
||||
self.apps_dir = self._get_apps_directory()
|
||||
self.manifest_file = self.apps_dir / "manifest.json"
|
||||
# A dedicated, never-replaced file to flock -- see _update_manifest_safe.
|
||||
self.manifest_lock_file = self.apps_dir / "manifest.json.lock"
|
||||
self.apps: Dict[str, StarlarkApp] = {}
|
||||
|
||||
# Display state
|
||||
@@ -239,7 +228,7 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
# Calculate optimal magnification based on display size
|
||||
self.calculated_magnify = self._calculate_optimal_magnify()
|
||||
if self.calculated_magnify > 1:
|
||||
self.logger.info(f"Display size: {self.display_manager.width}x{self.display_manager.height}, "
|
||||
self.logger.info(f"Display size: {self.display_manager.matrix.width}x{self.display_manager.matrix.height}, "
|
||||
f"recommended magnify: {self.calculated_magnify}")
|
||||
|
||||
# Load installed apps
|
||||
@@ -323,8 +312,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Recommended magnify value (1-8)
|
||||
"""
|
||||
try:
|
||||
display_width = self.display_manager.width
|
||||
display_height = self.display_manager.height
|
||||
display_width = self.display_manager.matrix.width
|
||||
display_height = self.display_manager.matrix.height
|
||||
|
||||
# Tronbyte native resolution
|
||||
NATIVE_WIDTH = 64
|
||||
@@ -362,8 +351,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Dictionary with recommendation details
|
||||
"""
|
||||
try:
|
||||
display_width = self.display_manager.width
|
||||
display_height = self.display_manager.height
|
||||
display_width = self.display_manager.matrix.width
|
||||
display_height = self.display_manager.matrix.height
|
||||
|
||||
NATIVE_WIDTH = 64
|
||||
NATIVE_HEIGHT = 32
|
||||
@@ -566,8 +555,7 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Save apps manifest to file with file locking to prevent race conditions.
|
||||
Acquires exclusive lock on the manifest lock sidecar before writing to
|
||||
prevent concurrent modifications.
|
||||
Acquires exclusive lock on manifest file before writing to prevent concurrent modifications.
|
||||
"""
|
||||
temp_file = None
|
||||
lock_fd = None
|
||||
@@ -575,14 +563,9 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
# Create parent directory if needed
|
||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Lock the sidecar file, not manifest_file itself: manifest_file is
|
||||
# replaced by an atomic rename below, which swaps in a fresh inode
|
||||
# a second locker's fresh os.open() would pick up unguarded. The
|
||||
# sidecar is never written to or renamed over, so it always
|
||||
# resolves to the same inode for every locker (see
|
||||
# _update_manifest_safe and web_interface's _starlark_manifest_lock,
|
||||
# which must lock this same file for the guarantee to hold).
|
||||
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
||||
# Use os.open with O_CREAT | O_RDWR to create if missing, but don't truncate
|
||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
|
||||
# Acquire exclusive lock on manifest file BEFORE creating temp file
|
||||
# This serializes all writers and prevents concurrent races
|
||||
@@ -638,9 +621,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
# Create parent directory if needed
|
||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Lock the sidecar file, not manifest_file itself -- see the
|
||||
# comment in _save_manifest for why.
|
||||
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
|
||||
# Acquire exclusive lock for entire read-modify-write cycle
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
@@ -699,62 +681,38 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
if app.is_enabled() and app.should_render(current_time):
|
||||
self._render_app(app, force=False)
|
||||
|
||||
def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool:
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""
|
||||
Display current Starlark app.
|
||||
|
||||
This method is called during the display rotation.
|
||||
Displays frames from the currently active app.
|
||||
|
||||
`display_mode` names the app to show when it matches an installed
|
||||
app_id. The controller passes the mode it is rotating to and inspects
|
||||
this signature to decide whether to, so accepting it is what lets a
|
||||
specific app be addressed -- including by an on-demand request pinned
|
||||
to one app. Anything else (the plugin id itself, when the plugin
|
||||
exposes no per-app modes) falls through to normal rotation.
|
||||
|
||||
Returns False when there is no app to show -- which is the state of
|
||||
every install without Pixlet, and of a fresh one before any app is
|
||||
added. The display controller only skips a mode on a boolean False
|
||||
(it checks isinstance(result, bool)), so returning None held a black
|
||||
panel for the full display_duration instead of rotating on.
|
||||
"""
|
||||
try:
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
if display_mode and display_mode in self.apps:
|
||||
self.current_app = self.apps[display_mode]
|
||||
elif force_clear or not self.current_app:
|
||||
# Advance on entry to the mode. _select_next_app only ran when
|
||||
# current_app was unset, so the first enabled app was picked
|
||||
# once and then shown forever -- every other installed app was
|
||||
# rendered on schedule and never displayed. force_clear is the
|
||||
# controller's "we just switched to you" signal (it is reset
|
||||
# immediately after this call), so one app gets each turn.
|
||||
# If no current app, try to select one
|
||||
if not self.current_app:
|
||||
self._select_next_app()
|
||||
|
||||
if not self.current_app:
|
||||
# No apps available
|
||||
self.logger.debug("No Starlark apps to display")
|
||||
return False
|
||||
return
|
||||
|
||||
# Render app if needed
|
||||
if not self.current_app.frames:
|
||||
success = self._render_app(self.current_app, force=True)
|
||||
if not success:
|
||||
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
||||
return False
|
||||
return
|
||||
|
||||
# Display current frame. The result is propagated: a failed frame
|
||||
# update is not a displayed frame, and returning True regardless
|
||||
# told the controller the mode had rendered, so it held the dead
|
||||
# frame for the whole display_duration instead of rotating on.
|
||||
return self._display_frame()
|
||||
# Display current frame
|
||||
self._display_frame()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying Starlark app: {e}")
|
||||
return False
|
||||
|
||||
def _select_next_app(self) -> None:
|
||||
"""Select the next enabled app for display."""
|
||||
@@ -805,25 +763,15 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
magnify = self._get_effective_magnify()
|
||||
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
||||
|
||||
# Optional native render size for an app whose own declared canvas
|
||||
# differs from Pixlet's 64x32 default -- without this an app
|
||||
# declaring a wider native canvas got half its own content
|
||||
# clipped at render time, before magnify ever got a chance to
|
||||
# scale anything.
|
||||
render_width = app.config.get("render_width")
|
||||
render_height = app.config.get("render_height")
|
||||
|
||||
# Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet
|
||||
INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'}
|
||||
# Filter out LEDMatrix-internal timing keys before passing to pixlet
|
||||
INTERNAL_KEYS = {'render_interval', 'display_duration'}
|
||||
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
||||
|
||||
success, error = self.pixlet.render(
|
||||
star_file=str(app.star_file),
|
||||
output_path=str(app.cache_file),
|
||||
config=pixlet_config,
|
||||
magnify=magnify,
|
||||
width=render_width,
|
||||
height=render_height
|
||||
magnify=magnify
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -852,8 +800,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
|
||||
# Scale frames if needed
|
||||
if self.config.get("scale_output", True):
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
|
||||
# Get scaling method from config
|
||||
scale_method_str = self.config.get("scale_method", "nearest")
|
||||
@@ -887,13 +835,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
self.logger.error(f"Error loading frames for {app.app_id}: {e}")
|
||||
return False
|
||||
|
||||
def _display_frame(self) -> bool:
|
||||
"""Display the current frame of the current app.
|
||||
|
||||
:returns: whether a frame actually reached the display manager.
|
||||
"""
|
||||
def _display_frame(self) -> None:
|
||||
"""Display the current frame of the current app."""
|
||||
if not self.current_app or not self.current_app.frames:
|
||||
return False
|
||||
return
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
@@ -911,11 +856,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
)
|
||||
self.current_app.last_frame_time = current_time
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying frame: {e}")
|
||||
return False
|
||||
|
||||
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
|
||||
"""
|
||||
|
||||
@@ -218,9 +218,7 @@ class PixletRenderer:
|
||||
star_file: str,
|
||||
output_path: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
magnify: int = 1,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None
|
||||
magnify: int = 1
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Render a .star file to WebP output.
|
||||
@@ -230,21 +228,6 @@ class PixletRenderer:
|
||||
output_path: Where to save WebP output
|
||||
config: Configuration dictionary to pass to app
|
||||
magnify: Magnification factor (default 1)
|
||||
width: Optional native render width in pixels. Previously
|
||||
there was no way to tell Pixlet to render at anything
|
||||
other than its own default (64), relying entirely on
|
||||
magnify to scale up afterward -- fine for apps designed
|
||||
at that native size, but wrong for an app whose own
|
||||
declared canvas size is genuinely different (confirmed
|
||||
on real hardware, 2026-09-06, with an imported app
|
||||
declaring width=128: rendering at the default 64 and
|
||||
then magnifying silently clipped half the app's own
|
||||
content before scaling ever happened, rather than
|
||||
producing a correctly-sized image). Passed through as
|
||||
Pixlet's own -w flag when provided; omitted (Pixlet's
|
||||
default) otherwise, preserving existing behavior for
|
||||
every other app.
|
||||
height: Same as width, for Pixlet's -t flag.
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_message: Optional[str])
|
||||
@@ -281,18 +264,10 @@ class PixletRenderer:
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
# Validate value doesn't contain dangerous shell metacharacters.
|
||||
# Kept as defence in depth only: cmd is a list and there is no
|
||||
# shell=True below, so nothing here is ever interpreted by a
|
||||
# shell. That made the list worth trimming rather than growing
|
||||
# -- "|" is a normal character inside a config value, and apps
|
||||
# do use it as a separator (a PennDOT sign id is
|
||||
# "I-476 North|175659"). Blocking it dropped the whole key
|
||||
# silently, and the app then rendered its own "not configured"
|
||||
# screen with nothing to say why.
|
||||
# Block: backticks, $(), redirects, semicolons, ampersands, null bytes
|
||||
# Allow: most printable chars including spaces, quotes, brackets, braces, pipes
|
||||
if re.search(r'[`$<>&;\x00]|\$\(', value_str):
|
||||
# Validate value doesn't contain dangerous shell metacharacters
|
||||
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
|
||||
# Allow: most printable chars including spaces, quotes, brackets, braces
|
||||
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
|
||||
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
||||
continue
|
||||
|
||||
@@ -304,10 +279,6 @@ class PixletRenderer:
|
||||
"-o", output_path,
|
||||
"-m", str(magnify)
|
||||
])
|
||||
if width is not None:
|
||||
cmd.extend(["-w", str(width)])
|
||||
if height is not None:
|
||||
cmd.extend(["-t", str(height)])
|
||||
|
||||
# Build sanitized command for logging (redact sensitive values)
|
||||
sanitized_cmd = [self.pixlet_binary, "render", star_file]
|
||||
@@ -315,10 +286,6 @@ class PixletRenderer:
|
||||
config_keys = list(config.keys())
|
||||
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
|
||||
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
|
||||
if width is not None:
|
||||
sanitized_cmd.extend(["-w", str(width)])
|
||||
if height is not None:
|
||||
sanitized_cmd.extend(["-t", str(height)])
|
||||
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
|
||||
|
||||
# Execute rendering
|
||||
@@ -332,21 +299,13 @@ class PixletRenderer:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
if not os.path.isfile(output_path):
|
||||
if os.path.isfile(output_path):
|
||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||
return True, None
|
||||
else:
|
||||
error = "Rendering succeeded but output file not found"
|
||||
logger.error(error)
|
||||
return False, error
|
||||
# Pixlet exits 0 and writes a 0-byte file when the app renders
|
||||
# nothing -- an app whose config leaves it with no content to
|
||||
# show does exactly that. Treating existence alone as success
|
||||
# handed the caller a file with no frames in it, which reads
|
||||
# downstream as a working app that draws a black panel.
|
||||
if os.path.getsize(output_path) == 0:
|
||||
error = "Rendering produced an empty (0-byte) file - the app rendered no content"
|
||||
logger.error(error)
|
||||
return False, error
|
||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||
return True, None
|
||||
else:
|
||||
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
|
||||
logger.error(error)
|
||||
@@ -360,76 +319,11 @@ class PixletRenderer:
|
||||
logger.exception("Rendering exception")
|
||||
return False, "Rendering failed - see logs for details"
|
||||
|
||||
#: Schema extraction runs an app's own get_schema(), which may make a
|
||||
#: network call. Short enough that a hung app does not stall an upload,
|
||||
#: long enough for a real API round trip on a slow connection.
|
||||
SCHEMA_TIMEOUT = 20
|
||||
|
||||
def extract_schema_via_pixlet(self, star_file: str) -> Optional[Dict[str, Any]]:
|
||||
"""Ask Pixlet itself for the app's schema, or None if it cannot say.
|
||||
|
||||
`pixlet schema` executes get_schema() instead of reading it, which is
|
||||
the only way to see options an app computes at runtime -- a dropdown
|
||||
whose choices come from a live API call has no option list anywhere in
|
||||
the source for the regex parser below to find, so that parser reports
|
||||
an empty dropdown and the config form offers nothing to pick.
|
||||
|
||||
Pixlet's own field keys are remapped to the ones the rest of this
|
||||
plugin and the config UI already use ("typeOf"/"desc"), so the two
|
||||
extractors return the same shape and callers cannot tell them apart.
|
||||
"""
|
||||
if not self.pixlet_binary:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.pixlet_binary, "schema", star_file],
|
||||
capture_output=True, text=True, timeout=self.SCHEMA_TIMEOUT,
|
||||
cwd=self._get_safe_working_directory(star_file),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"pixlet schema timed out after %ss for %s - get_schema() may be "
|
||||
"making a slow network call", self.SCHEMA_TIMEOUT, star_file)
|
||||
return None
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Could not run pixlet schema for {star_file}: {e}")
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
# Not an error worth failing on: older Pixlet builds have no
|
||||
# `schema` subcommand at all, and the source parser still works.
|
||||
logger.debug(
|
||||
"pixlet schema exited %d for %s: %s",
|
||||
result.returncode, star_file, (result.stderr or '').strip()[:300])
|
||||
return None
|
||||
|
||||
try:
|
||||
schema = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning(f"pixlet schema returned unparseable output for {star_file}: {e}")
|
||||
return None
|
||||
|
||||
if not isinstance(schema, dict) or not isinstance(schema.get("schema"), list):
|
||||
logger.warning(f"pixlet schema returned an unexpected shape for {star_file}")
|
||||
return None
|
||||
|
||||
for field in schema["schema"]:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if "type" in field and "typeOf" not in field:
|
||||
field["typeOf"] = field.pop("type")
|
||||
if "description" in field and "desc" not in field:
|
||||
field["desc"] = field.pop("description")
|
||||
return schema
|
||||
|
||||
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
Extract configuration schema from a .star file.
|
||||
Extract configuration schema from a .star file by parsing source code.
|
||||
|
||||
Prefers `pixlet schema`, which runs the app and therefore sees options
|
||||
it computes at runtime. Falls back to parsing the source when Pixlet is
|
||||
unavailable, too old to have the subcommand, or the app fails to run --
|
||||
that parser handles:
|
||||
Supports:
|
||||
- Static field definitions (location, text, toggle, dropdown, color, datetime)
|
||||
- Variable-referenced dropdown options
|
||||
- Graceful degradation for unsupported field types
|
||||
@@ -443,13 +337,6 @@ class PixletRenderer:
|
||||
if not os.path.isfile(star_file):
|
||||
return False, None, f"Star file not found: {star_file}"
|
||||
|
||||
schema = self.extract_schema_via_pixlet(star_file)
|
||||
if schema is not None:
|
||||
logger.debug(
|
||||
"Extracted schema with %d field(s) from %s via pixlet schema",
|
||||
len(schema.get('schema', [])), star_file)
|
||||
return True, schema, None
|
||||
|
||||
try:
|
||||
# Read .star file
|
||||
with open(star_file, 'r', encoding='utf-8') as f:
|
||||
|
||||
@@ -5,7 +5,6 @@ Handles interaction with the Tronbyte apps repository on GitHub.
|
||||
Fetches app listings, metadata, and downloads .star files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
@@ -50,13 +49,6 @@ class TronbyteRepository:
|
||||
self.base_url = "https://api.github.com"
|
||||
self.raw_url = "https://raw.githubusercontent.com"
|
||||
|
||||
# Why the last GitHub API call failed, in words a user can act on.
|
||||
# _make_request used to log the reason and return a bare None, so
|
||||
# every caller up the stack knew only that "something" went wrong --
|
||||
# which is how an exhausted rate limit reached the store page as an
|
||||
# empty grid with no explanation.
|
||||
self.last_error: Optional[str] = None
|
||||
|
||||
self.session = requests.Session()
|
||||
if github_token:
|
||||
self.session.headers.update({
|
||||
@@ -78,50 +70,29 @@ class TronbyteRepository:
|
||||
Returns:
|
||||
JSON response or None on error
|
||||
"""
|
||||
self.last_error = None
|
||||
try:
|
||||
response = self.session.get(url, timeout=timeout)
|
||||
|
||||
if response.status_code in (403, 429):
|
||||
# 403 is both "rate limited" and "forbidden"; the remaining
|
||||
# counter is what tells them apart, and the difference matters
|
||||
# to whoever reads the message -- one is fixed by waiting or
|
||||
# adding a token, the other is not.
|
||||
remaining = response.headers.get('X-RateLimit-Remaining')
|
||||
if remaining == '0':
|
||||
self.last_error = (
|
||||
"GitHub API rate limit exceeded"
|
||||
f" ({response.headers.get('X-RateLimit-Limit', '?')} requests/hour"
|
||||
f"{'' if self.github_token else ', unauthenticated'})."
|
||||
" Add a GitHub token in settings, or wait for the limit to reset."
|
||||
)
|
||||
else:
|
||||
self.last_error = f"GitHub refused the request ({response.status_code})"
|
||||
logger.warning(f"[Tronbyte Repo] {self.last_error}")
|
||||
if response.status_code == 403:
|
||||
# Rate limit exceeded
|
||||
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
|
||||
return None
|
||||
elif response.status_code == 404:
|
||||
self.last_error = "Not found on GitHub"
|
||||
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
||||
return None
|
||||
elif response.status_code != 200:
|
||||
self.last_error = f"GitHub API error {response.status_code}"
|
||||
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
except requests.Timeout:
|
||||
self.last_error = "Timed out reaching GitHub"
|
||||
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
||||
return None
|
||||
except requests.RequestException as e:
|
||||
self.last_error = f"Network error reaching GitHub: {e.__class__.__name__}"
|
||||
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
|
||||
return None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
# Reachable whenever something on the path answers with HTML --
|
||||
# a captive portal, a proxy error page, a DNS-hijacking router.
|
||||
self.last_error = "GitHub returned a response that was not JSON"
|
||||
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -154,61 +125,6 @@ class TronbyteRepository:
|
||||
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _list_app_dirs_via_trees(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""App directories via the git trees API, or None on failure.
|
||||
|
||||
The contents API caps a directory listing at 1000 entries and says
|
||||
nothing about having truncated it, so the store showed the first 1000
|
||||
apps of a repository that has more and looked complete while doing it.
|
||||
The trees API caps far higher and sets `truncated` when it does, at
|
||||
the cost of one extra call to resolve the `apps` tree.
|
||||
"""
|
||||
repo = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}"
|
||||
|
||||
root = self._make_request(f"{repo}/git/trees/{self.DEFAULT_BRANCH}")
|
||||
if not isinstance(root, dict):
|
||||
return None
|
||||
|
||||
apps_sha = next(
|
||||
(e.get('sha') for e in root.get('tree', []) or []
|
||||
if e.get('path') == self.APPS_PATH and e.get('type') == 'tree'),
|
||||
None)
|
||||
if not apps_sha:
|
||||
self.last_error = f"No '{self.APPS_PATH}' directory in the repository"
|
||||
return None
|
||||
|
||||
tree = self._make_request(f"{repo}/git/trees/{apps_sha}")
|
||||
if not isinstance(tree, dict):
|
||||
return None
|
||||
|
||||
if tree.get('truncated'):
|
||||
logger.warning(
|
||||
"[Tronbyte Repo] GitHub truncated the app tree; the listing is incomplete")
|
||||
|
||||
return [
|
||||
{'id': e['path'], 'path': f"{self.APPS_PATH}/{e['path']}", 'url': None}
|
||||
for e in tree.get('tree', []) or []
|
||||
if e.get('type') == 'tree' and e.get('path') and not e['path'].startswith('.')
|
||||
]
|
||||
|
||||
def _list_app_dirs_via_contents(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""App directories via the contents API. Capped at 1000 entries."""
|
||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
||||
|
||||
data = self._make_request(url)
|
||||
if data is None:
|
||||
return None
|
||||
if not isinstance(data, list):
|
||||
self.last_error = "GitHub returned an unexpected listing format"
|
||||
return None
|
||||
|
||||
return [
|
||||
{'id': item.get('name'), 'path': item.get('path'), 'url': item.get('url')}
|
||||
for item in data
|
||||
if item.get('type') == 'dir' and item.get('name')
|
||||
and not item['name'].startswith('.')
|
||||
]
|
||||
|
||||
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
|
||||
"""
|
||||
List all available apps in the repository.
|
||||
@@ -216,17 +132,26 @@ class TronbyteRepository:
|
||||
Returns:
|
||||
Tuple of (success, apps_list, error_message)
|
||||
"""
|
||||
apps = self._list_app_dirs_via_trees()
|
||||
if apps is None:
|
||||
# Fall back rather than fail: the contents API was what shipped,
|
||||
# so a trees-only outage should not take the store down with it.
|
||||
trees_error = self.last_error
|
||||
logger.warning(
|
||||
f"[Tronbyte Repo] Trees listing failed ({trees_error}); "
|
||||
"falling back to the contents API")
|
||||
apps = self._list_app_dirs_via_contents()
|
||||
if apps is None:
|
||||
return False, None, self.last_error or trees_error or "Failed to fetch repository contents"
|
||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
||||
|
||||
data = self._make_request(url)
|
||||
if data is None:
|
||||
return False, None, "Failed to fetch repository contents"
|
||||
|
||||
if not isinstance(data, list):
|
||||
return False, None, "Invalid response format"
|
||||
|
||||
# Filter directories (apps)
|
||||
apps = []
|
||||
for item in data:
|
||||
if item.get('type') == 'dir':
|
||||
app_id = item.get('name')
|
||||
if app_id and not app_id.startswith('.'):
|
||||
apps.append({
|
||||
'id': app_id,
|
||||
'path': item.get('path'),
|
||||
'url': item.get('url')
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(apps)} apps in repository")
|
||||
return True, apps, None
|
||||
@@ -342,22 +267,14 @@ class TronbyteRepository:
|
||||
'categories': _apps_cache['categories'],
|
||||
'authors': _apps_cache['authors'],
|
||||
'count': len(_apps_cache['data']),
|
||||
'cached': True,
|
||||
'error': None,
|
||||
'cached': True
|
||||
}
|
||||
|
||||
# Fetch directory listing (a small number of GitHub API calls)
|
||||
# Fetch directory listing (1 GitHub API call)
|
||||
success, app_dirs, error = self.list_apps()
|
||||
if not success or not app_dirs:
|
||||
# Returning an empty list here used to read downstream as "the
|
||||
# repository has no apps", and the route reported that as a
|
||||
# success -- so a rate limit, a DNS failure and an empty
|
||||
# repository were all drawn as the same blank grid. Hand the
|
||||
# reason back instead and let the caller surface it.
|
||||
reason = error or "No apps found in the repository"
|
||||
logger.error(f"Failed to list apps for bulk fetch: {reason}")
|
||||
return {'apps': [], 'categories': [], 'authors': [],
|
||||
'count': 0, 'cached': False, 'error': reason}
|
||||
logger.error(f"Failed to list apps for bulk fetch: {error}")
|
||||
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
|
||||
|
||||
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
|
||||
|
||||
@@ -424,8 +341,7 @@ class TronbyteRepository:
|
||||
'categories': categories,
|
||||
'authors': authors,
|
||||
'count': len(apps_with_metadata),
|
||||
'cached': False,
|
||||
'error': None,
|
||||
'cached': False
|
||||
}
|
||||
|
||||
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
|
||||
|
||||
@@ -7,8 +7,3 @@ freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
|
||||
# /system/status endpoint's real path is exercised
|
||||
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
||||
PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
|
||||
# plugin-repos/starlark-apps/tronbyte_repository.py the way
|
||||
# the blueprint does, and that plugin imports yaml. The
|
||||
# plugin declares it in its own requirements.txt, which the
|
||||
# store installs on a real rig but CI never does.
|
||||
|
||||
+4
-20
@@ -43,14 +43,10 @@ packaging>=23.0,<27.0
|
||||
# full feature set, or skip them for a minimal install.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# scipy — nothing, as of #570. It was listed for the sub-pixel
|
||||
# interpolation path in src/common/scroll_helper.py, but
|
||||
# get_visible_portion never consulted HAS_SCIPY, so that
|
||||
# path was dead before it was deleted. The blend that
|
||||
# replaced it is numpy-only. Do not install it expecting
|
||||
# smoother scrolling: sub-pixel blending is off by default
|
||||
# because it reads worse on a coarse panel, not because it
|
||||
# is missing a library. See docs/SCROLL_PERFORMANCE.md.
|
||||
# scipy — sub-pixel interpolation in
|
||||
# src/common/scroll_helper.py for smoother
|
||||
# scrolling. Falls back to a simpler shift algorithm.
|
||||
# pip install 'scipy>=1.10.0,<2.0.0'
|
||||
#
|
||||
# psutil — per-plugin resource monitoring in
|
||||
# src/plugin_system/resource_monitor.py. The monitor
|
||||
@@ -59,18 +55,6 @@ packaging>=23.0,<27.0
|
||||
# range as a hard dependency — keep the two in sync.
|
||||
# pip install 'psutil>=6.0.0,<7.0.0'
|
||||
#
|
||||
# orjson — faster JSON for the disk cache
|
||||
# (src/cache/disk_cache.py). Encoding a ~1MB cache
|
||||
# record drops from ~12ms to ~1.6ms on a Pi 4, which
|
||||
# matters because that work holds the GIL and stalls
|
||||
# the render thread mid-scroll. Falls back to the
|
||||
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
|
||||
# The 3.11.6 floor is CVE-2025-67221: orjson.dumps did not
|
||||
# limit recursion on deeply nested documents, and the disk
|
||||
# cache encodes payloads parsed straight from third-party
|
||||
# APIs. 3.11.6 covers the Python range above.
|
||||
# pip install 'orjson>=3.11.6,<4.0'
|
||||
#
|
||||
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||
# (accidental-abuse protection, not security). The
|
||||
# web interface starts without rate limiting when
|
||||
|
||||
Submodule rpi-rgb-led-matrix-master updated: 1ee4f76f2b...8907235630
@@ -257,30 +257,6 @@
|
||||
},
|
||||
"description": "Web UI action definitions"
|
||||
},
|
||||
"widgets": {
|
||||
"type": "array",
|
||||
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}$",
|
||||
"description": "Widget name, as used in x-widget and in the URL."
|
||||
},
|
||||
"script": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$",
|
||||
"description": "Filename inside widgets/. Defaults to <name>.js."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable summary shown to plugin authors."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledmatrix_version": {
|
||||
"type": "string",
|
||||
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find blocking work reachable from a plugin's render path.
|
||||
|
||||
`display()` runs on the render thread. Anything slow reached from it stalls the
|
||||
panel for its whole duration, and on a vsync-paced loop that is immediately
|
||||
visible: a single 15ms call on a 100Hz panel drops a frame, and a network round
|
||||
trip freezes the marquee outright.
|
||||
|
||||
This has bitten twice already. odds-ticker called `_has_live_games()` every
|
||||
frame, whose slow path read the scoreboard cache from disk and parsed JSON per
|
||||
enabled league -- one stalled frame every few minutes. soccer-scoreboard timed
|
||||
out inside `update()` during a cache refresh. Both were found by staring at
|
||||
frame-time histograms, which is a slow way to find a bug that is visible in the
|
||||
source.
|
||||
|
||||
The audit walks the call graph from `display()` through same-class `self.*`
|
||||
methods and reports anything that reaches a known-blocking API. It is a
|
||||
heuristic, not a proof: it cannot see through indirection, and a hit is not
|
||||
automatically a bug -- a call guarded by an interval check may be fine. It is a
|
||||
list of places worth a human look.
|
||||
|
||||
python3 scripts/audit_render_path.py # all plugins
|
||||
python3 scripts/audit_render_path.py --dir plugin-repos # a specific tree
|
||||
python3 scripts/audit_render_path.py --plugin odds-ticker
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
#: Calls that can block for longer than a frame. Matched on the attribute or
|
||||
#: function name, so `requests.get`, `self.session.get` and a bare `get` on a
|
||||
#: requests-ish object all register.
|
||||
BLOCKING = {
|
||||
"get": "network or cache read",
|
||||
"post": "network",
|
||||
"put": "network",
|
||||
"request": "network",
|
||||
"urlopen": "network",
|
||||
"read": "I/O",
|
||||
"open": "file I/O",
|
||||
"load": "JSON/file parse",
|
||||
"loads": "JSON parse",
|
||||
"dump": "file write",
|
||||
"dumps": "serialise",
|
||||
"sleep": "sleep on the render thread",
|
||||
"run": "subprocess",
|
||||
"check_output": "subprocess",
|
||||
"connect": "network",
|
||||
"download_logo": "network",
|
||||
"_fetch": "fetch",
|
||||
}
|
||||
|
||||
#: Names that make a hit far more likely to matter.
|
||||
HIGH_SIGNAL = ("requests", "urllib", "session", "cache_manager", "subprocess",
|
||||
"socket", "http")
|
||||
|
||||
RENDER_ENTRY = "display"
|
||||
|
||||
|
||||
class Analyzer:
|
||||
def __init__(self, tree: ast.AST):
|
||||
self.methods: dict[str, ast.FunctionDef] = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
self.methods.setdefault(node.name, node)
|
||||
|
||||
def calls_in(self, fn: ast.AST):
|
||||
"""(self-method names called, blocking hits) inside one function."""
|
||||
self_calls, hits = set(), []
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
name = func.attr
|
||||
base = ast.unparse(func.value) if hasattr(ast, "unparse") else ""
|
||||
if base == "self" and name in self.methods:
|
||||
self_calls.add(name)
|
||||
continue
|
||||
if name in BLOCKING:
|
||||
hits.append((name, base, BLOCKING[name], node.lineno))
|
||||
elif isinstance(func, ast.Name) and func.id in BLOCKING:
|
||||
hits.append((func.id, "", BLOCKING[func.id], node.lineno))
|
||||
return self_calls, hits
|
||||
|
||||
def reachable_from(self, entry: str, max_depth: int = 3):
|
||||
"""Blocking hits reachable from `entry`, with the path that reaches them."""
|
||||
if entry not in self.methods:
|
||||
return []
|
||||
found, seen = [], set()
|
||||
stack = [(entry, [entry], 0)]
|
||||
while stack:
|
||||
name, path, depth = stack.pop()
|
||||
if name in seen or depth > max_depth:
|
||||
continue
|
||||
seen.add(name)
|
||||
self_calls, hits = self.calls_in(self.methods[name])
|
||||
for hit in hits:
|
||||
found.append((path, hit))
|
||||
for callee in sorted(self_calls):
|
||||
stack.append((callee, path + [callee], depth + 1))
|
||||
return found
|
||||
|
||||
|
||||
def audit_file(path: Path):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (OSError, SyntaxError):
|
||||
return []
|
||||
analyzer = Analyzer(tree)
|
||||
return analyzer.reachable_from(RENDER_ENTRY)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
ap.add_argument("--dir", default=str(root / "plugin-repos"))
|
||||
ap.add_argument("--plugin", help="only this plugin directory")
|
||||
ap.add_argument("--all-hits", action="store_true",
|
||||
help="include low-signal hits (open/read/load on locals)")
|
||||
args = ap.parse_args()
|
||||
|
||||
base = Path(args.dir)
|
||||
if not base.is_dir():
|
||||
sys.exit("not a directory: %s" % base)
|
||||
|
||||
plugins = [base / args.plugin] if args.plugin else sorted(
|
||||
d for d in base.iterdir() if d.is_dir())
|
||||
|
||||
total = 0
|
||||
for plugin in plugins:
|
||||
rows = []
|
||||
for src in sorted(plugin.glob("*.py")):
|
||||
if src.name.startswith("test_"):
|
||||
continue
|
||||
for path, (name, s_base, why, lineno) in audit_file(src):
|
||||
signal = any(h in s_base.lower() for h in HIGH_SIGNAL)
|
||||
if not signal and not args.all_hits:
|
||||
continue
|
||||
rows.append((src.name, lineno, "->".join(path),
|
||||
("%s.%s" % (s_base, name)) if s_base else name, why))
|
||||
if rows:
|
||||
total += len(rows)
|
||||
print("\n%s" % plugin.name)
|
||||
for fname, lineno, chain, call, why in sorted(rows):
|
||||
print(" %s:%-5d %-34s via %s" % (fname, lineno, call + " (" + why + ")", chain))
|
||||
|
||||
print("\n%d blocking call(s) reachable from display() across %d plugin(s)"
|
||||
% (total, len(plugins)))
|
||||
print("Heuristic: a hit guarded by an interval check may be fine. Look, do "
|
||||
"not assume.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Rebuild the rgbmatrix Python binding so it releases the GIL.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# The upstream binding declares FrameCanvas::SwapOnVSync WITHOUT `nogil`
|
||||
# (cppinc.pxd), unlike SetPixel/Clear/Fill on the lines just above it.
|
||||
# SwapOnVSync blocks until the panel's next vertical sync -- up to a full
|
||||
# refresh period on every frame -- so the render thread was holding the GIL
|
||||
# for most of every frame. Background threads (API fetches, JSON parsing,
|
||||
# image decode) were starved into long uninterruptible bursts, which in turn
|
||||
# made the render loop miss refreshes.
|
||||
#
|
||||
# Measured on a Pi 4 driving a 2x128x64 chain at limit_refresh_rate_hz=100:
|
||||
#
|
||||
# before ~44 fps average, 14-17% of frames 41-53ms
|
||||
# after 100 fps, median 10.00ms, p95 10.05ms, 0% stalls
|
||||
#
|
||||
# The per-pixel blit (SetPixelsPillow) can also release the GIL and walk the
|
||||
# Pillow buffer row-major, but that is OFF by default and you almost certainly
|
||||
# want to leave it that way. Row-major changes what a partially-written frame
|
||||
# looks like: column-major tearing shows as a vertical seam, row-major tearing
|
||||
# shows as a horizontal split between the panel's upper and lower halves. On a
|
||||
# 1/32 scan panel that reads as a one-pixel "fold" across the middle of every
|
||||
# panel -- reported on hardware, and it went away when the blit was reverted.
|
||||
# Enable with RGB_PATCH_BLIT=1 only if you have measured that you need it;
|
||||
# essentially all of the gain above comes from the SwapOnVSync change alone.
|
||||
#
|
||||
# SAFETY
|
||||
# ------
|
||||
# Builds into a scratch directory; touches the installed module only in the
|
||||
# --install step, and backs up the original first. Roll back at any time with:
|
||||
#
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
#
|
||||
# USAGE
|
||||
# bash scripts/build_rgbmatrix_nogil.sh # build only
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
# Resolve the invoking user's home, not root's. --install runs under sudo,
|
||||
# where $HOME is /root, so every default path below pointed somewhere the
|
||||
# build had never written and the install died with "no built module found".
|
||||
if [ -n "${SUDO_USER:-}" ]; then
|
||||
OWNER_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
|
||||
fi
|
||||
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
||||
|
||||
SRC_TREE="${RGB_SRC_TREE:-$OWNER_HOME/LEDMatrix/rpi-rgb-led-matrix-master}"
|
||||
BUILD_DIR="${RGB_BUILD_DIR:-$OWNER_HOME/rgbmatrix-nogil-build}"
|
||||
VENV="${RGB_CYTHON_VENV:-$OWNER_HOME/.cache/ledmatrix-cython}"
|
||||
BACKUP="${RGB_BACKUP:-$OWNER_HOME/rgbmatrix-core.so.ORIGINAL}"
|
||||
PATCH_BLIT="${RGB_PATCH_BLIT:-0}"
|
||||
|
||||
die() { echo "FATAL: $*" >&2; exit 1; }
|
||||
|
||||
# This script runs under `set -uo pipefail` -- no -e -- so an unchecked
|
||||
# systemctl failure is silently ignored. That matters most for `stop`: leaving
|
||||
# the old service running means cp overwrites a module the running process has
|
||||
# mapped, the following `start` succeeds as a no-op, and the health check sees
|
||||
# an active unit and reports SUCCESS for a binding that was never loaded.
|
||||
# A machine with no ledmatrix.service at all is a normal build host, so that
|
||||
# case is skipped rather than treated as a failure.
|
||||
service_present() { systemctl cat ledmatrix.service >/dev/null 2>&1; }
|
||||
|
||||
service_do() {
|
||||
local verb="$1"
|
||||
if ! service_present; then
|
||||
echo " (no ledmatrix.service installed - skipping $verb)"
|
||||
return 0
|
||||
fi
|
||||
systemctl "$verb" ledmatrix || die "systemctl $verb ledmatrix failed"
|
||||
}
|
||||
|
||||
py_site() {
|
||||
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
||||
}
|
||||
|
||||
# The extension filename the interpreter that builds -- and then loads -- this
|
||||
# module actually uses, e.g. core.cpython-313-aarch64-linux-gnu.so. The build
|
||||
# venv is made with --system-site-packages from python3, so the two agree;
|
||||
# falling back keeps --install working when the venv has been cleaned up.
|
||||
abi_name() {
|
||||
local py="$VENV/bin/python"
|
||||
[ -x "$py" ] || py=python3
|
||||
"$py" -c \
|
||||
'import sysconfig; print("core" + sysconfig.get_config_var("EXT_SUFFIX"))' \
|
||||
2>/dev/null
|
||||
}
|
||||
|
||||
# Exactly the current interpreter's artifact, never merely the first one that
|
||||
# sorts. Staging copies $SRC_TREE wholesale, so a core.cpython-*.so left in the
|
||||
# source tree by an earlier build comes along for the ride; build_ext --inplace
|
||||
# only ever overwrites the current ABI's name, and a glob piped to `head -1`
|
||||
# sorts cpython-311 ahead of cpython-313. That installed a stale, unpatched
|
||||
# module as core.so while the GIL check below -- which reads the freshly
|
||||
# generated core.cpp, not the .so -- still reported success.
|
||||
abi_so() {
|
||||
local name path
|
||||
name="$(abi_name)" || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
path="$BUILD_DIR/bindings/python/rgbmatrix/$name"
|
||||
[ -f "$path" ] || return 1
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
local dst; dst="$(py_site)"
|
||||
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
||||
service_do stop
|
||||
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||
service_do start
|
||||
echo "rolled back to the original core.so"
|
||||
exit 0
|
||||
}
|
||||
|
||||
do_install() {
|
||||
local so dst
|
||||
so="$(abi_so)"; [ -n "$so" ] || die "no built module found - run the build first"
|
||||
dst="$(py_site)"; [ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||
|
||||
if [ ! -f "$BACKUP" ]; then
|
||||
cp -a "$dst/core.so" "$BACKUP" || die "could not back up the original"
|
||||
echo "backed up original core.so -> $BACKUP"
|
||||
else
|
||||
echo "backup already present at $BACKUP (keeping the true original)"
|
||||
fi
|
||||
|
||||
service_do stop
|
||||
cp "$so" "$dst/core.so" || die "install failed"
|
||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||
service_do start
|
||||
|
||||
echo "waiting 25s for the display to come back..."
|
||||
sleep 25
|
||||
local healthy=1
|
||||
systemctl is-active --quiet ledmatrix || healthy=0
|
||||
if journalctl -u ledmatrix --since "40 sec ago" --no-pager \
|
||||
| grep -qiE "Traceback|ImportError|Segmentation fault|undefined symbol"; then
|
||||
healthy=0
|
||||
fi
|
||||
if [ "$healthy" = "1" ]; then
|
||||
echo "SUCCESS - running on the rebuilt binding"
|
||||
else
|
||||
echo "UNHEALTHY - rolling back"
|
||||
cp -a "$BACKUP" "$dst/core.so" \
|
||||
|| echo "ROLLBACK FAILED: could not restore $BACKUP -> $dst/core.so" >&2
|
||||
if service_present && ! systemctl restart ledmatrix; then
|
||||
echo "ROLLBACK FAILED: ledmatrix did not restart - the display is" \
|
||||
"down; restore manually with 'sudo bash $0 --rollback'" >&2
|
||||
fi
|
||||
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--rollback) do_rollback ;;
|
||||
--install) do_install ;;
|
||||
"" ) ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------- build ----
|
||||
[ -d "$SRC_TREE" ] || die "matrix source tree not found at $SRC_TREE (set RGB_SRC_TREE)"
|
||||
command -v g++ >/dev/null || die "g++ not installed (apt install build-essential)"
|
||||
|
||||
echo "==> staging a scratch copy at $BUILD_DIR"
|
||||
rm -rf "$BUILD_DIR"
|
||||
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
||||
|
||||
# Drop any extension artifacts that came across from the source tree. Nothing
|
||||
# downstream should be able to pick one up, and build_ext --inplace can decide
|
||||
# a copied .so is already up to date and skip the compile entirely.
|
||||
find "$BUILD_DIR/bindings/python/rgbmatrix" -maxdepth 1 \
|
||||
-name 'core*.so' -delete 2>/dev/null
|
||||
|
||||
echo "==> patching the bindings to release the GIL"
|
||||
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
|
||||
import io
|
||||
import sys
|
||||
|
||||
base = sys.argv[1] + "/bindings/python/rgbmatrix/"
|
||||
patch_blit = len(sys.argv) > 2 and sys.argv[2] == "1"
|
||||
|
||||
# --- declare SwapOnVSync as nogil ---------------------------------------
|
||||
p = base + "cppinc.pxd"
|
||||
s = io.open(p, encoding="utf-8").read()
|
||||
OLD_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)\n"
|
||||
NEW_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t) nogil\n"
|
||||
if OLD_DECL in s:
|
||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s.replace(OLD_DECL, NEW_DECL, 1))
|
||||
print(" cppinc.pxd: SwapOnVSync declared nogil")
|
||||
elif NEW_DECL in s:
|
||||
print(" cppinc.pxd: already nogil")
|
||||
else:
|
||||
sys.exit("could not find the SwapOnVSync declaration")
|
||||
|
||||
# --- release the GIL across the vsync wait ------------------------------
|
||||
p = base + "core.pyx"
|
||||
s = io.open(p, encoding="utf-8").read()
|
||||
|
||||
OLD_SWAP = (
|
||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
||||
" return __createFrameCanvas("
|
||||
"self.__matrix.SwapOnVSync(newFrame.__canvas, framerate_fraction))\n"
|
||||
)
|
||||
NEW_SWAP = (
|
||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
||||
" # Blocks until the panel's next vertical sync. Holding the GIL\n"
|
||||
" # across that wait starves every other Python thread for most of\n"
|
||||
" # each frame. Pointers are hoisted into C locals so the blocking\n"
|
||||
" # call itself needs no Python state.\n"
|
||||
" cdef cppinc.RGBMatrix* matrix = self.__matrix\n"
|
||||
" cdef cppinc.FrameCanvas* frame = newFrame.__canvas\n"
|
||||
" cdef uint8_t fraction = framerate_fraction\n"
|
||||
" cdef cppinc.FrameCanvas* swapped\n"
|
||||
" with nogil:\n"
|
||||
" swapped = matrix.SwapOnVSync(frame, fraction)\n"
|
||||
" return __createFrameCanvas(swapped)\n"
|
||||
)
|
||||
if OLD_SWAP in s:
|
||||
s = s.replace(OLD_SWAP, NEW_SWAP, 1)
|
||||
print(" core.pyx: SwapOnVSync releases the GIL")
|
||||
elif "swapped = matrix.SwapOnVSync(frame, fraction)" in s:
|
||||
print(" core.pyx: SwapOnVSync already patched")
|
||||
else:
|
||||
sys.exit("could not find the SwapOnVSync body")
|
||||
|
||||
# --- optional: release the GIL across the blit --------------------------
|
||||
OLD_BLIT = (
|
||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
||||
"\n"
|
||||
" for col in range(max(0, -xstart), min(width, frame_width - xstart)):\n"
|
||||
" for row in range(max(0, -ystart), min(height, frame_height - ystart)):\n"
|
||||
" pixel = buffer[row][col]\n"
|
||||
" r = (pixel ) & 0xFF\n"
|
||||
" g = (pixel >> 8) & 0xFF\n"
|
||||
" b = (pixel >> 16) & 0xFF\n"
|
||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
||||
)
|
||||
NEW_BLIT = (
|
||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
||||
"\n"
|
||||
" # Bounds hoisted so the blit needs no Python state and can run\n"
|
||||
" # without the GIL: it touches only a C buffer and a C++ canvas.\n"
|
||||
" # NOTE: row-major order makes a torn frame show as a horizontal\n"
|
||||
" # split across the panel's halves. See the header before enabling.\n"
|
||||
" cdef int col_start = max(0, -xstart)\n"
|
||||
" cdef int col_end = min(width, frame_width - xstart)\n"
|
||||
" cdef int row_start = max(0, -ystart)\n"
|
||||
" cdef int row_end = min(height, frame_height - ystart)\n"
|
||||
"\n"
|
||||
" with nogil:\n"
|
||||
" for row in range(row_start, row_end):\n"
|
||||
" for col in range(col_start, col_end):\n"
|
||||
" pixel = buffer[row][col]\n"
|
||||
" r = (pixel ) & 0xFF\n"
|
||||
" g = (pixel >> 8) & 0xFF\n"
|
||||
" b = (pixel >> 16) & 0xFF\n"
|
||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
||||
)
|
||||
if patch_blit:
|
||||
if OLD_BLIT in s:
|
||||
s = s.replace(OLD_BLIT, NEW_BLIT, 1)
|
||||
print(" core.pyx: pixel blit releases the GIL, row-major")
|
||||
elif "for row in range(row_start, row_end):" in s:
|
||||
print(" core.pyx: blit already patched")
|
||||
else:
|
||||
sys.exit("could not find the SetPixelsPillow loop")
|
||||
else:
|
||||
print(" core.pyx: blit left unpatched (RGB_PATCH_BLIT=1 to enable)")
|
||||
|
||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
|
||||
PYEOF
|
||||
|
||||
echo "==> building librgbmatrix.a (this takes a few minutes)"
|
||||
nice -n 10 make -C "$BUILD_DIR/lib" -j2 >/dev/null 2>&1 \
|
||||
|| die "library build failed - rerun 'make -C $BUILD_DIR/lib' to see why"
|
||||
[ -f "$BUILD_DIR/lib/librgbmatrix.a" ] || die "librgbmatrix.a was not produced"
|
||||
|
||||
echo "==> preparing Cython"
|
||||
[ -d "$VENV" ] || python3 -m venv --system-site-packages "$VENV" || die "venv failed"
|
||||
"$VENV/bin/pip" install --quiet cython || die "cython install failed"
|
||||
|
||||
cat > "$BUILD_DIR/bindings/python/setup.py" <<'EOF'
|
||||
from setuptools import setup, Extension
|
||||
from Cython.Build import cythonize
|
||||
|
||||
core = Extension(
|
||||
"rgbmatrix.core",
|
||||
sources=["rgbmatrix/core.pyx", "rgbmatrix/shims/pillow.c"],
|
||||
include_dirs=["../../include", "rgbmatrix/shims"],
|
||||
extra_objects=["../../lib/librgbmatrix.a"],
|
||||
language="c++",
|
||||
extra_compile_args=["-O3", "-Wall", "-fno-exceptions", "-std=c++11"],
|
||||
extra_link_args=["-lrt", "-lm", "-lpthread"],
|
||||
)
|
||||
|
||||
setup(name="rgbmatrix",
|
||||
ext_modules=cythonize([core], language_level="3str",
|
||||
compiler_directives={"binding": False}))
|
||||
EOF
|
||||
|
||||
echo "==> compiling the extension"
|
||||
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
||||
>/dev/null 2>&1 || die "extension build failed"
|
||||
|
||||
SO="$(abi_so)" || true
|
||||
[ -n "$SO" ] || die "no .so produced - expected $(abi_name) in $BUILD_DIR/bindings/python/rgbmatrix"
|
||||
|
||||
# Verify the GIL really is released before anyone installs this.
|
||||
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
|
||||
PAIRS=$(grep -c "PyEval_SaveThread\|Py_UNBLOCK_THREADS" \
|
||||
"$BUILD_DIR/bindings/python/rgbmatrix/core.cpp")
|
||||
[ "$PAIRS" -ge "$EXPECTED" ] \
|
||||
|| die "generated C++ has $PAIRS GIL-release sites, expected >= $EXPECTED"
|
||||
|
||||
echo
|
||||
echo "BUILT: $SO"
|
||||
echo " ($PAIRS GIL-release site(s) in the generated C++)"
|
||||
echo
|
||||
echo "Install with: sudo bash $0 --install"
|
||||
echo "Roll back with: sudo bash $0 --rollback"
|
||||
+2
-30
@@ -35,31 +35,6 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
|
||||
def _make_output_encoding_safe() -> None:
|
||||
"""Stop an unencodable character from killing the run.
|
||||
|
||||
This script's own report is ASCII, but it echoes text it does not control
|
||||
-- plugin ids, mode names and exception messages -- and a Windows console
|
||||
is cp1252, which cannot encode most of what a plugin might put there. The
|
||||
default 'strict' error handler turns that into a UnicodeEncodeError from
|
||||
inside `print`, so a rendering run that had already succeeded exited
|
||||
non-zero with a traceback instead of printing its results.
|
||||
|
||||
'replace' degrades the offending character to '?' and keeps going; the
|
||||
encoding itself is left alone so output still matches the terminal.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(errors='replace')
|
||||
except (AttributeError, ValueError, OSError):
|
||||
# Not a reconfigurable TextIOWrapper (redirected, wrapped by a
|
||||
# test harness). Nothing to do -- this is best-effort hardening.
|
||||
pass
|
||||
|
||||
|
||||
_make_output_encoding_safe()
|
||||
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
||||
@@ -78,9 +53,6 @@ logger = get_logger("[Check Plugin]")
|
||||
DEFAULT_SEARCH_DIRS = [
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
# The scoreboards live in the sibling ledmatrix-plugins checkout, not
|
||||
# in this repo. Without this, --all silently skips every one of them.
|
||||
str(PROJECT_ROOT.parent / 'ledmatrix-plugins' / 'plugins'),
|
||||
]
|
||||
|
||||
|
||||
@@ -206,7 +178,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
status = "PASS"
|
||||
detail = ""
|
||||
if r.golden_checked:
|
||||
detail = " (golden ok)"
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||
@@ -224,7 +196,7 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max delta={r.golden_max_delta})"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
elif r.fill_ok is False:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
|
||||
@@ -54,13 +54,8 @@ def main():
|
||||
config = config_manager.load_config()
|
||||
print(" ✅ Config loaded")
|
||||
|
||||
# Same rule ledmatrix-web.service applies: only an explicit false/off
|
||||
# keeps the web interface down; a missing key means on.
|
||||
sys.path.insert(0, str(project_root / 'scripts' / 'utils'))
|
||||
from start_web_conditionally import autostart_enabled
|
||||
raw = config.get('web_display_autostart', '(not set, defaults to on)')
|
||||
state = 'starts' if autostart_enabled(config) else 'will NOT start'
|
||||
print(f" 🔧 web_display_autostart: {raw} (web interface {state})")
|
||||
autostart = config.get('web_display_autostart', False)
|
||||
print(f" 🔧 web_display_autostart: {autostart}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Config check failed: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -12,19 +12,9 @@ This directory contains scripts and utilities for development and testing.
|
||||
|
||||
### Plugin Development Setup
|
||||
```bash
|
||||
# Official plugin: clones ChuckBuilds/ledmatrix-plugins (once) and links
|
||||
# its plugins/<plugin-name> into plugins/
|
||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||
|
||||
# Plugin with its own repository
|
||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> <repo-url>
|
||||
```
|
||||
|
||||
Set `plugin_system.plugins_directory` to `plugins` so the loader finds the
|
||||
links. To use a fork or another clone location, copy
|
||||
`dev_plugins.json.example` to `dev_plugins.json`. Details:
|
||||
[docs/PLUGIN_DEVELOPMENT_GUIDE.md](../../docs/PLUGIN_DEVELOPMENT_GUIDE.md).
|
||||
|
||||
### Running Emulator
|
||||
```bash
|
||||
./scripts/dev/run_emulator.sh
|
||||
|
||||
+75
-193
@@ -10,14 +10,8 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
||||
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
|
||||
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
|
||||
# Official plugins live in one monorepo: <github_user>/<plugins_repo>, one
|
||||
# directory per plugin under plugins/. Both can be overridden in
|
||||
# dev_plugins.json (e.g. to work from a fork).
|
||||
DEFAULT_GITHUB_USER="ChuckBuilds"
|
||||
DEFAULT_PLUGINS_REPO="ledmatrix-plugins"
|
||||
GITHUB_USER="$DEFAULT_GITHUB_USER"
|
||||
PLUGINS_REPO="$DEFAULT_PLUGINS_REPO"
|
||||
PLUGINS_BRANCH=""
|
||||
GITHUB_USER="ChuckBuilds"
|
||||
GITHUB_PATTERN="ledmatrix-"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -43,55 +37,18 @@ log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Print a top-level string field of a JSON file, or nothing if it is absent.
|
||||
# Uses jq when installed, else python3.
|
||||
json_field() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -r --arg k "$key" '.[$k] // empty | select(type == "string")' "$file" 2>/dev/null || true
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$file" "$key" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
value = json.load(f).get(sys.argv[2])
|
||||
except Exception:
|
||||
value = None
|
||||
if isinstance(value, str):
|
||||
print(value)
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
# Load configuration file
|
||||
load_config() {
|
||||
DEV_PLUGINS_DIR="$DEFAULT_DEV_DIR"
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
local value
|
||||
value=$(json_field "$CONFIG_FILE" dev_plugins_dir)
|
||||
[[ -n "$value" ]] && DEV_PLUGINS_DIR="$value"
|
||||
value=$(json_field "$CONFIG_FILE" github_user)
|
||||
[[ -n "$value" ]] && GITHUB_USER="$value"
|
||||
value=$(json_field "$CONFIG_FILE" plugins_repo)
|
||||
[[ -n "$value" ]] && PLUGINS_REPO="$value"
|
||||
value=$(json_field "$CONFIG_FILE" plugins_branch)
|
||||
[[ -n "$value" ]] && PLUGINS_BRANCH="$value"
|
||||
if [[ -n "$(json_field "$CONFIG_FILE" github_pattern)" ]]; then
|
||||
log_warn "dev_plugins.json: github_pattern is no longer used (official plugins are in the $PLUGINS_REPO monorepo)"
|
||||
fi
|
||||
DEV_PLUGINS_DIR=$(jq -r '.dev_plugins_dir // "'"$DEFAULT_DEV_DIR"'"' "$CONFIG_FILE" 2>/dev/null || echo "$DEFAULT_DEV_DIR")
|
||||
# Expand ~ in path
|
||||
DEV_PLUGINS_DIR="${DEV_PLUGINS_DIR/#\~/$HOME}"
|
||||
else
|
||||
DEV_PLUGINS_DIR="$DEFAULT_DEV_DIR"
|
||||
fi
|
||||
# Expand ~ in path
|
||||
DEV_PLUGINS_DIR="${DEV_PLUGINS_DIR/#\~/$HOME}"
|
||||
mkdir -p "$DEV_PLUGINS_DIR"
|
||||
}
|
||||
|
||||
# Top level of the git checkout containing a path, or nothing.
|
||||
# A monorepo plugin is a subdirectory, so its .git is not in the plugin dir.
|
||||
git_root_of() {
|
||||
git -C "$1" rev-parse --show-toplevel 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Validate plugin structure
|
||||
validate_plugin() {
|
||||
local plugin_path="$1"
|
||||
@@ -106,7 +63,7 @@ validate_plugin() {
|
||||
get_plugin_id() {
|
||||
local plugin_path="$1"
|
||||
if [[ -f "$plugin_path/manifest.json" ]]; then
|
||||
json_field "$plugin_path/manifest.json" id
|
||||
jq -r '.id // empty' "$plugin_path/manifest.json" 2>/dev/null || echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -219,104 +176,50 @@ clone_from_github() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Clone a repository into DEV_PLUGINS_DIR, or update the existing clone.
|
||||
# Prints the clone's path on stdout (log output goes to stderr).
|
||||
ensure_clone() {
|
||||
local repo_url="$1"
|
||||
local branch="${2:-}"
|
||||
local repo_name
|
||||
repo_name=$(basename "$repo_url" .git)
|
||||
local target_dir="$DEV_PLUGINS_DIR/$repo_name"
|
||||
|
||||
if [[ -d "$target_dir" ]]; then
|
||||
log_info "Repository already exists at $target_dir" >&2
|
||||
if [[ -d "$target_dir/.git" ]]; then
|
||||
log_info "Updating repository..." >&2
|
||||
(cd "$target_dir" && git pull --rebase) >&2 || true
|
||||
fi
|
||||
else
|
||||
if ! clone_from_github "$repo_url" "$target_dir" "$branch" >&2; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
echo "$target_dir"
|
||||
}
|
||||
|
||||
# Find a plugin's directory inside a monorepo clone: plugins/<name>,
|
||||
# plugins/ledmatrix-<name>, or the directory whose manifest id is <name>.
|
||||
find_monorepo_plugin() {
|
||||
local repo_dir="$1"
|
||||
local name="$2"
|
||||
local candidate
|
||||
for candidate in "$repo_dir/plugins/$name" "$repo_dir/plugins/ledmatrix-$name"; do
|
||||
if [[ -f "$candidate/manifest.json" ]]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
for candidate in "$repo_dir"/plugins/*/; do
|
||||
candidate="${candidate%/}"
|
||||
[[ -f "$candidate/manifest.json" ]] || continue
|
||||
if [[ "$(get_plugin_id "$candidate")" == "$name" ]]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Link plugin from GitHub
|
||||
link_github_plugin() {
|
||||
local plugin_name="${1:-}"
|
||||
local plugin_name="$1"
|
||||
local repo_url="${2:-}"
|
||||
|
||||
|
||||
if [[ -z "$plugin_name" ]]; then
|
||||
log_error "Usage: $0 link-github <plugin-name> [repo-url]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
load_config
|
||||
|
||||
if [[ -n "$repo_url" ]]; then
|
||||
# A plugin with its own repository (e.g. a third-party plugin): the
|
||||
# repository root is the plugin.
|
||||
local target_dir
|
||||
if ! target_dir=$(ensure_clone "$repo_url"); then
|
||||
|
||||
# Construct repo URL if not provided
|
||||
if [[ -z "$repo_url" ]]; then
|
||||
repo_url="https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}${plugin_name}.git"
|
||||
log_info "Using default GitHub URL: $repo_url"
|
||||
fi
|
||||
|
||||
# Determine target directory name from URL
|
||||
local repo_name=$(basename "$repo_url" .git)
|
||||
local target_dir="$DEV_PLUGINS_DIR/$repo_name"
|
||||
|
||||
# Check if already cloned
|
||||
if [[ -d "$target_dir" ]]; then
|
||||
log_info "Repository already exists at $target_dir"
|
||||
if [[ -d "$target_dir/.git" ]]; then
|
||||
log_info "Updating repository..."
|
||||
(cd "$target_dir" && git pull --rebase) || true
|
||||
fi
|
||||
else
|
||||
# Clone the repository
|
||||
if ! clone_from_github "$repo_url" "$target_dir"; then
|
||||
exit 1
|
||||
fi
|
||||
if ! validate_plugin "$target_dir"; then
|
||||
log_error "Cloned repository does not appear to be a valid plugin"
|
||||
exit 1
|
||||
fi
|
||||
link_plugin "$plugin_name" "$target_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
# Official plugins: clone the monorepo once, link plugins/<dir> from it.
|
||||
repo_url="https://github.com/${GITHUB_USER}/${PLUGINS_REPO}.git"
|
||||
log_info "Using plugin monorepo: $repo_url"
|
||||
local repo_dir
|
||||
if ! repo_dir=$(ensure_clone "$repo_url" "$PLUGINS_BRANCH"); then
|
||||
|
||||
# Validate plugin structure
|
||||
if ! validate_plugin "$target_dir"; then
|
||||
log_error "Cloned repository does not appear to be a valid plugin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local plugin_dir
|
||||
if ! plugin_dir=$(find_monorepo_plugin "$repo_dir" "$plugin_name"); then
|
||||
log_error "No plugin named '$plugin_name' in $repo_dir/plugins"
|
||||
log_info "Plugins are the directory names under $repo_dir/plugins, or their manifest ids"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Link under the manifest id: that is the name the plugin loader and
|
||||
# config.json use, and it can differ from the directory name
|
||||
# (plugins/ledmatrix-music has id ledmatrix-music, not music).
|
||||
local link_name
|
||||
link_name=$(get_plugin_id "$plugin_dir")
|
||||
[[ -n "$link_name" ]] || link_name=$(basename "$plugin_dir")
|
||||
if [[ "$link_name" != "$plugin_name" ]]; then
|
||||
log_info "Linking as '$link_name' (the plugin's manifest id)"
|
||||
fi
|
||||
link_plugin "$link_name" "$plugin_dir"
|
||||
|
||||
# Link the plugin
|
||||
link_plugin "$plugin_name" "$target_dir"
|
||||
}
|
||||
|
||||
# Unlink a plugin
|
||||
@@ -371,7 +274,7 @@ list_plugins() {
|
||||
echo " → $target"
|
||||
|
||||
# Check git status if it's a git repo
|
||||
if [[ -n "$(git_root_of "$target")" ]]; then
|
||||
if [[ -d "$target/.git" ]]; then
|
||||
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||
local status=$(cd "$target" && git status --porcelain 2>/dev/null | head -1)
|
||||
if [[ -n "$status" ]]; then
|
||||
@@ -424,7 +327,7 @@ check_status() {
|
||||
echo -e "${GREEN}✓${NC} ${BLUE}$plugin_name${NC}"
|
||||
echo " Path: $target"
|
||||
|
||||
if [[ -n "$(git_root_of "$target")" ]]; then
|
||||
if [[ -d "$target/.git" ]]; then
|
||||
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||
local remote=$(cd "$target" && git remote get-url origin 2>/dev/null || echo "no remote")
|
||||
local commits_behind=$(cd "$target" && git rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0")
|
||||
@@ -457,13 +360,9 @@ check_status() {
|
||||
done
|
||||
|
||||
echo "Summary:"
|
||||
echo -e " ${GREEN}Clean: $clean_count${NC}"
|
||||
echo -e " ${YELLOW}Needs attention: $dirty_count${NC}"
|
||||
# An if, not `[[ ]] &&`: as the function's last command a false test made
|
||||
# `status` exit 1 whenever nothing was broken.
|
||||
if [[ $broken_count -gt 0 ]]; then
|
||||
echo -e " ${RED}Broken: $broken_count${NC}"
|
||||
fi
|
||||
echo " ${GREEN}Clean: $clean_count${NC}"
|
||||
echo " ${YELLOW}Needs attention: $dirty_count${NC}"
|
||||
[[ $broken_count -gt 0 ]] && echo -e " ${RED}Broken: $broken_count${NC}"
|
||||
}
|
||||
|
||||
# Update plugin(s)
|
||||
@@ -485,46 +384,39 @@ update_plugins() {
|
||||
fi
|
||||
|
||||
local target=$(get_symlink_target "$plugin_name")
|
||||
local root
|
||||
root=$(git_root_of "$target")
|
||||
|
||||
if [[ -z "$root" ]]; then
|
||||
|
||||
if [[ ! -d "$target/.git" ]]; then
|
||||
log_error "Plugin repository is not a git repository: $target"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Updating $plugin_name from $root"
|
||||
(cd "$root" && git pull --rebase)
|
||||
|
||||
log_info "Updating $plugin_name from $target"
|
||||
(cd "$target" && git pull --rebase)
|
||||
log_success "Updated $plugin_name"
|
||||
else
|
||||
# Update all linked plugins. Plugins linked from the monorepo share one
|
||||
# checkout, which is pulled once.
|
||||
# Update all linked plugins
|
||||
log_info "Updating all linked plugins..."
|
||||
local updated=0
|
||||
local failed=0
|
||||
local pulled_roots=" "
|
||||
|
||||
|
||||
for item in "$PLUGINS_DIR"/*; do
|
||||
[[ -e "$item" ]] || continue
|
||||
[[ -d "$item" ]] || continue
|
||||
|
||||
|
||||
local name=$(basename "$item")
|
||||
[[ "$name" =~ ^\.|^_ ]] && continue
|
||||
|
||||
|
||||
if is_symlink "$item"; then
|
||||
local target=$(get_symlink_target "$name")
|
||||
local root
|
||||
root=$(git_root_of "$target")
|
||||
[[ -n "$root" ]] || continue
|
||||
[[ "$pulled_roots" == *" $root "* ]] && continue
|
||||
pulled_roots="$pulled_roots$root "
|
||||
log_info "Updating $root (for $name)..."
|
||||
if (cd "$root" && git pull --rebase); then
|
||||
log_success "Updated $root"
|
||||
updated=$((updated + 1))
|
||||
else
|
||||
log_error "Failed to update $root"
|
||||
failed=$((failed + 1))
|
||||
if [[ -d "$target/.git" ]]; then
|
||||
log_info "Updating $name..."
|
||||
if (cd "$target" && git pull --rebase); then
|
||||
log_success "Updated $name"
|
||||
updated=$((updated + 1))
|
||||
else
|
||||
log_error "Failed to update $name"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -547,12 +439,7 @@ Commands:
|
||||
|
||||
link-github <plugin-name> [repo-url]
|
||||
Clone and link a plugin from GitHub
|
||||
Without repo-url: clones (or updates) the official plugin monorepo,
|
||||
https://github.com/${DEFAULT_GITHUB_USER}/${DEFAULT_PLUGINS_REPO}.git, and links its
|
||||
plugins/<plugin-name> (or plugins/ledmatrix-<plugin-name>) under the
|
||||
plugin's manifest id
|
||||
With repo-url: clones a plugin that has its own repository and links
|
||||
the repository root
|
||||
If repo-url is not provided, uses: https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}<plugin-name>.git
|
||||
|
||||
unlink <plugin-name>
|
||||
Remove symlink for a plugin (preserves repository)
|
||||
@@ -571,30 +458,25 @@ Commands:
|
||||
Show this help message
|
||||
|
||||
Examples:
|
||||
# Link an official plugin from the monorepo
|
||||
$0 link-github football-scoreboard
|
||||
|
||||
# Link a plugin from a local monorepo checkout
|
||||
$0 link hello-world ../ledmatrix-plugins/plugins/hello-world
|
||||
|
||||
# Link a third-party plugin from its own repository
|
||||
$0 link-github my-plugin https://github.com/OtherUser/ledmatrix-my-plugin.git
|
||||
|
||||
# Link a local plugin
|
||||
$0 link music ../ledmatrix-music
|
||||
|
||||
# Link from GitHub (auto-detects URL)
|
||||
$0 link-github music
|
||||
|
||||
# Link from GitHub with custom URL
|
||||
$0 link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
|
||||
|
||||
# Check status
|
||||
$0 status
|
||||
|
||||
|
||||
# Update all plugins
|
||||
$0 update
|
||||
|
||||
Configuration:
|
||||
Copy dev_plugins.json.example to dev_plugins.json (git-ignored) to customize:
|
||||
Create dev_plugins.json in project root to customize:
|
||||
- dev_plugins_dir: Where to clone GitHub repos (default: ~/.ledmatrix-dev-plugins)
|
||||
- github_user: Owner of the plugin monorepo, e.g. your fork (default: ${DEFAULT_GITHUB_USER})
|
||||
- plugins_repo: Name of the plugin monorepo (default: ${DEFAULT_PLUGINS_REPO})
|
||||
- plugins_branch: Branch to clone the monorepo at (default: its default branch)
|
||||
|
||||
Symlinks are created in plugins/. Set plugin_system.plugins_directory to
|
||||
"plugins" in config/config.json so the plugin loader discovers them.
|
||||
- plugins: Plugin definitions (optional, for auto-discovery)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -72,8 +72,12 @@ def load_main_config(path: Path) -> Dict[str, Any]:
|
||||
|
||||
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||
"""Derive the logical ticker size the way DisplayManager does."""
|
||||
from src.display_geometry import logical_size
|
||||
return logical_size(config)
|
||||
hw = config.get('display', {}).get('hardware', {})
|
||||
cols = int(hw.get('cols', 64))
|
||||
chain = int(hw.get('chain_length', 1))
|
||||
rows = int(hw.get('rows', 32))
|
||||
parallel = int(hw.get('parallel', 1))
|
||||
return cols * chain, rows * parallel
|
||||
|
||||
|
||||
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||
|
||||
+17
-24
@@ -32,8 +32,6 @@ os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
|
||||
from src.common.path_safety import resolve_under, safe_path_component
|
||||
|
||||
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -120,8 +118,7 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
one of the plugin search dirs, so a crafted id can never name a path
|
||||
outside them.
|
||||
"""
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
if not plugin_id or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return None
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
@@ -142,18 +139,17 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json.
|
||||
|
||||
The same extraction a device and the plugin harness use
|
||||
(src/plugin_system/testing/loading.py), nested defaults included.
|
||||
"""
|
||||
from src.plugin_system.testing.loading import (
|
||||
load_config_defaults as _load_config_defaults,
|
||||
)
|
||||
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||
if schema_path is None or not schema_path.exists():
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
return _load_config_defaults(schema_path.parent)
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -179,8 +175,8 @@ def api_plugin_schema(plugin_id):
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||
if schema_path is None or not schema_path.exists():
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
||||
|
||||
with open(schema_path, 'r') as f:
|
||||
@@ -304,13 +300,10 @@ def _parse_render_request(data):
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config the way a device would: schema defaults under a forced
|
||||
# enabled, with the user's overrides deep-merged on top
|
||||
from src.plugin_system.testing.loading import build_config
|
||||
overrides = data.get('config') or {}
|
||||
if not isinstance(overrides, dict):
|
||||
raise ValueError('config must be a JSON object')
|
||||
config = build_config(trusted_dir, overrides)
|
||||
# Build config: schema defaults + user overrides
|
||||
config = {'enabled': True}
|
||||
config.update(load_config_defaults(trusted_dir))
|
||||
config.update(data.get('config', {}))
|
||||
|
||||
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
||||
|
||||
|
||||
Executable → Regular
+11
-55
@@ -20,38 +20,6 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Report web_display_autostart the way scripts/utils/start_web_conditionally.py
|
||||
# (what ledmatrix-web.service runs) decides it: only an explicit false/off keeps
|
||||
# the web interface down; a missing key or an unreadable config starts it.
|
||||
# Prints "on <value>", "off <value>", "default" (key not set) or "unreadable".
|
||||
web_autostart_state() {
|
||||
(cd "$1" && python3 - 2>/dev/null <<'PY'
|
||||
import json, os, sys
|
||||
sys.path.insert(0, os.path.join(os.getcwd(), "scripts", "utils"))
|
||||
try:
|
||||
from start_web_conditionally import autostart_enabled
|
||||
except Exception:
|
||||
def autostart_enabled(config):
|
||||
value = config.get("web_display_autostart", True)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() not in ("off", "false", "no", "0")
|
||||
return bool(value)
|
||||
try:
|
||||
with open(os.path.join("config", "config.json"), encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
except Exception:
|
||||
config = None
|
||||
if not isinstance(config, dict):
|
||||
print("unreadable")
|
||||
elif "web_display_autostart" not in config:
|
||||
print("default")
|
||||
else:
|
||||
raw = json.dumps(config["web_display_autostart"])
|
||||
print(("on " if autostart_enabled(config) else "off ") + raw)
|
||||
PY
|
||||
) || echo "unknown"
|
||||
}
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}1. SERVICE STATUS${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
@@ -73,26 +41,14 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then
|
||||
echo -e "${GREEN}✓ Config file found${NC}"
|
||||
|
||||
# Check web_display_autostart setting
|
||||
AUTOSTART=$(web_autostart_state "$PROJECT_DIR")
|
||||
|
||||
case "$AUTOSTART" in
|
||||
on\ *)
|
||||
echo -e "${GREEN}✓ web_display_autostart: ${AUTOSTART#on }${NC}"
|
||||
;;
|
||||
off\ *)
|
||||
echo -e "${YELLOW}⚠ web_display_autostart: ${AUTOSTART#off }${NC}"
|
||||
echo -e "${YELLOW} Web interface will not start with this value${NC}"
|
||||
;;
|
||||
default)
|
||||
echo -e "${GREEN}✓ web_display_autostart: not set (defaults to on)${NC}"
|
||||
;;
|
||||
unreadable)
|
||||
echo -e "${YELLOW}⚠ config.json could not be parsed (the web interface still starts so it can be repaired)${NC}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${YELLOW}⚠ web_display_autostart: could not be evaluated (python3 unavailable?)${NC}"
|
||||
;;
|
||||
esac
|
||||
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$')
|
||||
|
||||
if [ "$AUTOSTART" == "true" ]; then
|
||||
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ web_display_autostart: ${AUTOSTART:-not set}${NC}"
|
||||
echo -e "${YELLOW} Web interface will not start unless this is set to true${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗ Config file not found at: $PROJECT_DIR/config/config.json${NC}"
|
||||
fi
|
||||
@@ -107,7 +63,7 @@ declare -a REQUIRED_FILES=(
|
||||
"web_interface/app.py"
|
||||
"web_interface/start.py"
|
||||
"web_interface/requirements.txt"
|
||||
"web_interface/blueprints/api_v3/__init__.py"
|
||||
"web_interface/blueprints/api_v3.py"
|
||||
"web_interface/blueprints/pages_v3.py"
|
||||
"scripts/utils/start_web_conditionally.py"
|
||||
)
|
||||
@@ -178,8 +134,8 @@ if ! sudo systemctl is-active --quiet ledmatrix-web; then
|
||||
echo " sudo systemctl start ledmatrix-web"
|
||||
fi
|
||||
|
||||
if [ "${AUTOSTART%% *}" = "off" ]; then
|
||||
echo -e "${YELLOW}→ Set web_display_autostart to true in config/config.json (or remove it; missing means on)${NC}"
|
||||
if [ "$AUTOSTART" != "true" ]; then
|
||||
echo -e "${YELLOW}→ Enable web_display_autostart in config/config.json${NC}"
|
||||
fi
|
||||
|
||||
if [ "$ALL_FILES_OK" = false ]; then
|
||||
|
||||
+12
-53
@@ -22,38 +22,6 @@ fi
|
||||
|
||||
PROJECT_DIR="${HOME}/LEDMatrix"
|
||||
|
||||
# Report web_display_autostart the way scripts/utils/start_web_conditionally.py
|
||||
# (what ledmatrix-web.service runs) decides it: only an explicit false/off keeps
|
||||
# the web interface down; a missing key or an unreadable config starts it.
|
||||
# Prints "on <value>", "off <value>", "default" (key not set) or "unreadable".
|
||||
web_autostart_state() {
|
||||
(cd "$1" && python3 - 2>/dev/null <<'PY'
|
||||
import json, os, sys
|
||||
sys.path.insert(0, os.path.join(os.getcwd(), "scripts", "utils"))
|
||||
try:
|
||||
from start_web_conditionally import autostart_enabled
|
||||
except Exception:
|
||||
def autostart_enabled(config):
|
||||
value = config.get("web_display_autostart", True)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() not in ("off", "false", "no", "0")
|
||||
return bool(value)
|
||||
try:
|
||||
with open(os.path.join("config", "config.json"), encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
except Exception:
|
||||
config = None
|
||||
if not isinstance(config, dict):
|
||||
print("unreadable")
|
||||
elif "web_display_autostart" not in config:
|
||||
print("default")
|
||||
else:
|
||||
raw = json.dumps(config["web_display_autostart"])
|
||||
print(("on " if autostart_enabled(config) else "off ") + raw)
|
||||
PY
|
||||
) || echo "unknown"
|
||||
}
|
||||
|
||||
echo "1. Checking service status..."
|
||||
echo "------------------------------"
|
||||
if systemctl is-active --quiet ledmatrix-web 2>/dev/null || sudo systemctl is-active --quiet ledmatrix-web 2>/dev/null; then
|
||||
@@ -79,25 +47,16 @@ echo "3. Checking configuration file..."
|
||||
echo "------------------------------"
|
||||
if [ -f "${PROJECT_DIR}/config/config.json" ]; then
|
||||
echo -e "${GREEN}✓ Config file exists${NC}"
|
||||
AUTOSTART=$(web_autostart_state "$PROJECT_DIR")
|
||||
case "$AUTOSTART" in
|
||||
on\ *)
|
||||
echo -e "${GREEN}✓ web_display_autostart is ${AUTOSTART#on } (web UI starts)${NC}"
|
||||
;;
|
||||
off\ *)
|
||||
echo -e "${RED}✗ web_display_autostart is ${AUTOSTART#off } (web UI won't start!)${NC}"
|
||||
echo " Fix: Edit config.json and set 'web_display_autostart': true"
|
||||
;;
|
||||
default)
|
||||
echo -e "${GREEN}✓ web_display_autostart is not set (defaults to on; web UI starts)${NC}"
|
||||
;;
|
||||
unreadable)
|
||||
echo -e "${YELLOW}⚠ config.json could not be parsed (the web UI still starts so it can be repaired)${NC}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${YELLOW}⚠ Could not evaluate web_display_autostart (python3 unavailable?)${NC}"
|
||||
;;
|
||||
esac
|
||||
AUTOSTART=$(grep -o '"web_display_autostart":\s*\(true\|false\)' "${PROJECT_DIR}/config/config.json" | grep -o '\(true\|false\)' || echo "not found")
|
||||
if [ "$AUTOSTART" = "true" ]; then
|
||||
echo -e "${GREEN}✓ web_display_autostart is set to TRUE${NC}"
|
||||
elif [ "$AUTOSTART" = "false" ]; then
|
||||
echo -e "${RED}✗ web_display_autostart is set to FALSE (web UI won't start!)${NC}"
|
||||
echo " Fix: Edit config.json and set 'web_display_autostart': true"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ web_display_autostart setting not found (defaults to false)${NC}"
|
||||
echo " Fix: Add 'web_display_autostart': true to config.json"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗ Config file NOT FOUND at ${PROJECT_DIR}/config/config.json${NC}"
|
||||
fi
|
||||
@@ -123,7 +82,7 @@ FILES_TO_CHECK=(
|
||||
"web_interface/start.py"
|
||||
"web_interface/app.py"
|
||||
"web_interface/requirements.txt"
|
||||
"web_interface/blueprints/api_v3/__init__.py"
|
||||
"web_interface/blueprints/api_v3.py"
|
||||
"web_interface/blueprints/pages_v3.py"
|
||||
)
|
||||
|
||||
@@ -216,7 +175,7 @@ echo "Diagnostic Summary"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Most common issues:"
|
||||
echo " 1. web_display_autostart is set to false in config.json (a missing key means on)"
|
||||
echo " 1. web_display_autostart is false or missing in config.json"
|
||||
echo " 2. Service not enabled or not started"
|
||||
echo " 3. Missing dependencies (Flask, etc.)"
|
||||
echo " 4. Import errors in web_interface/app.py"
|
||||
|
||||
@@ -7,10 +7,8 @@ install as the wrong user, after a manual file copy that didn't preserve
|
||||
ownership, or after a permissions-related error from the display or
|
||||
web service.
|
||||
|
||||
Most of these scripts require `sudo` since they touch directories owned
|
||||
by `root` (the display service's user) or by the user you installed
|
||||
LEDMatrix as (the web service's user). There is no dedicated `ledmatrix`
|
||||
system user.
|
||||
Most of these scripts require `sudo` since they touch directories
|
||||
owned by the `ledmatrix` service user or by `root`.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -18,12 +16,11 @@ system user.
|
||||
permissions on the `assets/` tree so plugins can download and cache
|
||||
team logos, fonts, and other static content.
|
||||
|
||||
- **`fix_cache_permissions.sh`** — Creates (if missing) and fixes
|
||||
permissions on `/var/cache/ledmatrix/` and `~/.ledmatrix_cache/` of the
|
||||
user running `sudo`, and creates
|
||||
`/var/cache/ledmatrix/placeholder_logos/` for the sports plugins. It does
|
||||
not touch the cache manager's other fallbacks (`/opt/ledmatrix/cache`,
|
||||
`$TMPDIR/ledmatrix_cache`).
|
||||
- **`fix_cache_permissions.sh`** — Fixes permissions on every cache
|
||||
directory the project may use (`/var/cache/ledmatrix/`,
|
||||
`~/.cache/ledmatrix/`, `/opt/ledmatrix/cache/`, project-local
|
||||
`cache/`). Also creates placeholder logo subdirectories used by the
|
||||
sports plugins.
|
||||
|
||||
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
||||
directory so both the root display service and the web service user
|
||||
@@ -34,11 +31,6 @@ system user.
|
||||
systemd journal access, and the sudoers entries the web interface
|
||||
needs to control the display service.
|
||||
|
||||
- **`safe_pip_install.sh`** — Installs a `requirements.txt` as root
|
||||
after checking it is the project's own or one under `plugin-repos/` or
|
||||
`plugins/`. Used by the web interface (via sudo) to install plugin
|
||||
dependencies where `ledmatrix.service` can import them.
|
||||
|
||||
- **`safe_plugin_rm.sh`** — Validates that a plugin removal path is
|
||||
inside an allowed base directory before deleting it. Used by the web
|
||||
interface (via sudo) when a user clicks **Uninstall** on a plugin —
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# safe_pip_install.sh — Install a requirements.txt as root after validating
|
||||
# that the resolved path is one of the project's own requirements files
|
||||
# (requirements.txt, web_interface/requirements.txt) or a plugin's
|
||||
# that the resolved path is the project's own requirements.txt or a plugin's
|
||||
# requirements.txt under plugin-repos/ or plugins/.
|
||||
#
|
||||
# This script is intended to be called via sudo from the web interface, so
|
||||
@@ -26,17 +25,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Allowed locations (resolved, no trailing slash):
|
||||
# - the project's own requirements files. Update Code, the automatic
|
||||
# update's health check and Install Base Requirements install both, so
|
||||
# one missing here is refused on every device -- and the automatic
|
||||
# updater rolls back any update that changes it.
|
||||
# Only their folders are resolved: resolving the files too would follow
|
||||
# a requirements.txt symlinked out of the project and allow its target.
|
||||
# - the project's own requirements.txt
|
||||
# - any requirements.txt under plugin-repos/ or plugins/
|
||||
ALLOWED_EXACT=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT")/requirements.txt"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/web_interface")/requirements.txt"
|
||||
)
|
||||
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
||||
ALLOWED_BASES=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
||||
@@ -52,13 +43,9 @@ if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
||||
fi
|
||||
|
||||
ALLOWED=false
|
||||
for EXACT in "${ALLOWED_EXACT[@]}"; do
|
||||
if [ "$RESOLVED_TARGET" = "$EXACT" ]; then
|
||||
ALLOWED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
||||
ALLOWED=true
|
||||
else
|
||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
||||
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
||||
ALLOWED=true
|
||||
@@ -69,7 +56,7 @@ fi
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
|
||||
echo "Allowed: ${ALLOWED_EXACT[*]}, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
||||
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
|
||||
@@ -7,18 +7,14 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
|
||||
- **`one-shot-install.sh`** - Single-command installer; clones the
|
||||
repo, checks prerequisites, then runs `first_time_install.sh`.
|
||||
Invoked via `curl ... | bash` from the project root README.
|
||||
- **`install_service.sh`** - Installs, enables and starts the display
|
||||
service (`ledmatrix.service`), the web interface service
|
||||
(`ledmatrix-web.service`) and the update-verify units (systemd)
|
||||
- **`install_web_service.sh`** - Installs only the web interface service
|
||||
and the update-verify units (systemd)
|
||||
- **`install_service.sh`** - Installs the main LED Matrix display service (systemd)
|
||||
- **`install_web_service.sh`** - Installs the web interface service (systemd)
|
||||
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
|
||||
- **`setup_cache.sh`** - Sets up persistent cache directory with proper permissions
|
||||
- **`configure_web_sudo.sh`** - Configures passwordless sudo access for web interface actions
|
||||
- **`configure_wifi_permissions.sh`** - Grants the web interface's user
|
||||
(the user who runs the script, i.e. the one you installed LEDMatrix as;
|
||||
there is no `ledmatrix` system user) the passwordless `nmcli` and related
|
||||
WiFi permissions the web interface needs
|
||||
- **`configure_wifi_permissions.sh`** - Grants the `ledmatrix` user
|
||||
the WiFi management permissions needed by the web interface and
|
||||
the WiFi monitor service
|
||||
- **`migrate_config.sh`** - Migrates configuration files to new formats (if needed)
|
||||
- **`debug_install.sh`** - Diagnostic helper used when an install
|
||||
fails; collects environment info and recent logs
|
||||
|
||||
Executable → Regular
-13
@@ -130,19 +130,6 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
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
|
||||
# /etc/sudoers.d makes sudo refuse every command for every user.
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
if ! visudo -c -f "$TEMP_SUDOERS" >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "✗ The generated sudoers rules did not parse:" >&2
|
||||
visudo -c -f "$TEMP_SUDOERS" >&2 || true
|
||||
echo "Nothing was changed." >&2
|
||||
rm -f "$TEMP_SUDOERS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Generated sudoers configuration:"
|
||||
echo "--------------------------------"
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# DNS single-request fix installation script.
|
||||
#
|
||||
# Optional. Install this only if plugins that call external APIs (Starlark
|
||||
# apps, weather, sports, music) are timing out or feel slow to first paint
|
||||
# while the network is otherwise fine. See the header of
|
||||
# scripts/utils/apply_dns_single_request.sh for what it changes and why.
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
SERVICE_NAME="ledmatrix-dns-fix"
|
||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
DROPIN_DIR="/etc/systemd/system/ledmatrix.service.d"
|
||||
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
SYSTEMCTL_CMD="systemctl"
|
||||
SUDO=""
|
||||
else
|
||||
SYSTEMCTL_CMD="sudo systemctl"
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Installing LED Matrix DNS fix service"
|
||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||
|
||||
if [ ! -f "$UNIT_SRC" ]; then
|
||||
echo "✗ Missing unit file: $UNIT_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$PROJECT_ROOT_DIR/scripts/utils/apply_dns_single_request.sh"
|
||||
|
||||
echo "Installing $UNIT_DEST..."
|
||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||
|
||||
# Order ledmatrix.service after the fix. `Before=` in the unit itself only
|
||||
# orders units already in the same transaction, so a plain
|
||||
# `systemctl restart ledmatrix` would not wait for it -- and since this fix is
|
||||
# opt-in, ledmatrix.service cannot carry the dependency in the repo.
|
||||
# Wants=, not Requires=: a DNS workaround failing should not stop the display.
|
||||
echo "Installing the ledmatrix.service ordering drop-in..."
|
||||
$SUDO mkdir -p "$DROPIN_DIR"
|
||||
printf '[Unit]\nWants=%s.service\nAfter=%s.service\n' "$SERVICE_NAME" "$SERVICE_NAME" \
|
||||
| $SUDO tee "$DROPIN_DIR/10-dns-fix.conf" > /dev/null
|
||||
|
||||
$SYSTEMCTL_CMD daemon-reload
|
||||
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
||||
|
||||
# Do not mask a failure here. The unit exits non-zero when it could not apply
|
||||
# the option -- a systemd-resolved host, an unwritable resolv.conf, a failed
|
||||
# `resolvconf -u` -- and reporting "installation complete" over that would
|
||||
# leave the operator believing a workaround is active when it is not.
|
||||
START_STATUS=0
|
||||
$SYSTEMCTL_CMD start "$SERVICE_NAME.service" || START_STATUS=$?
|
||||
|
||||
echo ""
|
||||
if grep -qs "^options single-request$" /etc/resolv.conf; then
|
||||
echo "✓ 'options single-request' is active in /etc/resolv.conf"
|
||||
elif [ "$START_STATUS" -ne 0 ]; then
|
||||
echo "✗ The DNS fix could not be applied on this host."
|
||||
echo " The service reported why:"
|
||||
echo " journalctl -u $SERVICE_NAME -n 20"
|
||||
echo ""
|
||||
echo " The unit is installed and will try again on the next boot. Nothing"
|
||||
echo " else about your install has changed."
|
||||
exit "$START_STATUS"
|
||||
else
|
||||
echo "⚠ 'options single-request' is not in /etc/resolv.conf yet."
|
||||
echo " Check what the service reported:"
|
||||
echo " journalctl -u $SERVICE_NAME -n 20"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "DNS fix installation complete."
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " sudo systemctl status $SERVICE_NAME # Check status"
|
||||
echo " sudo journalctl -u $SERVICE_NAME -n 50 # View logs"
|
||||
echo " sudo systemctl disable --now $SERVICE_NAME # Undo the service"
|
||||
echo " sudo rm $DROPIN_DIR/10-dns-fix.conf # Undo the ordering drop-in"
|
||||
echo " # then remove the 'options single-request' line from /etc/resolv.conf"
|
||||
echo ""
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Home Assistant MQTT bridge installation script.
|
||||
#
|
||||
# Optional. Installs integrations/mqtt_bridge as a service so Home Assistant
|
||||
# can force display modes, toggle power and set brightness over MQTT.
|
||||
# See integrations/mqtt_bridge/README.md.
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
BRIDGE_DIR="$PROJECT_ROOT_DIR/integrations/mqtt_bridge"
|
||||
SERVICE_NAME="ledmatrix-mqtt-bridge"
|
||||
UNIT_SRC="$PROJECT_ROOT_DIR/systemd/$SERVICE_NAME.service"
|
||||
UNIT_DEST="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
SYSTEMCTL_CMD="systemctl"
|
||||
SUDO=""
|
||||
else
|
||||
SYSTEMCTL_CMD="sudo systemctl"
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "Installing LED Matrix MQTT bridge"
|
||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||
|
||||
if [ ! -f "$BRIDGE_DIR/bridge_config.json" ]; then
|
||||
cp "$BRIDGE_DIR/bridge_config.example.json" "$BRIDGE_DIR/bridge_config.json"
|
||||
chmod 600 "$BRIDGE_DIR/bridge_config.json"
|
||||
echo ""
|
||||
echo "⚠ Created $BRIDGE_DIR/bridge_config.json from the example."
|
||||
echo " Edit it with your broker details, then re-run this script."
|
||||
echo " The service will refuse to start until the placeholder password is replaced."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Installing Python dependencies..."
|
||||
python3 -m pip install -r "$BRIDGE_DIR/requirements.txt" 2>/dev/null \
|
||||
|| python3 -m pip install --break-system-packages -r "$BRIDGE_DIR/requirements.txt"
|
||||
|
||||
echo "Installing $UNIT_DEST..."
|
||||
sed "s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g" "$UNIT_SRC" \
|
||||
| $SUDO tee "$UNIT_DEST" > /dev/null
|
||||
|
||||
$SYSTEMCTL_CMD daemon-reload
|
||||
$SYSTEMCTL_CMD enable "$SERVICE_NAME.service"
|
||||
$SYSTEMCTL_CMD restart "$SERVICE_NAME.service" || true
|
||||
|
||||
echo ""
|
||||
if $SYSTEMCTL_CMD is-active --quiet "$SERVICE_NAME.service" 2>/dev/null; then
|
||||
echo "✓ MQTT bridge is running"
|
||||
echo " The matrix should appear in Home Assistant under Settings > Devices > MQTT."
|
||||
else
|
||||
echo "⚠ MQTT bridge is not running. Check the logs:"
|
||||
echo " sudo journalctl -u $SERVICE_NAME -n 50"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " sudo systemctl status $SERVICE_NAME"
|
||||
echo " sudo journalctl -u $SERVICE_NAME -f"
|
||||
echo " sudo systemctl disable --now $SERVICE_NAME # Undo"
|
||||
echo ""
|
||||
@@ -3,41 +3,6 @@
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: sudo ./scripts/install/install_service.sh [-h|--help]
|
||||
|
||||
Installs (or reinstalls) the LEDMatrix systemd units from the templates in
|
||||
systemd/, then enables and starts them:
|
||||
- ledmatrix.service main display (runs as root)
|
||||
- ledmatrix-web.service web interface (runs as the invoking user)
|
||||
- ledmatrix-update-verify.service automatic-update health check
|
||||
- ledmatrix-update-verify.path
|
||||
|
||||
Existing unit files in /etc/systemd/system are overwritten. The script takes
|
||||
no other options; run it with no arguments to install.
|
||||
|
||||
Options:
|
||||
-h, --help Show this help and exit without changing anything.
|
||||
USAGE
|
||||
}
|
||||
|
||||
# Parse arguments before touching anything: this script rewrites and restarts
|
||||
# services, so an unrecognised option must not fall through to a full install.
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown option: $arg" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Get the actual user who invoked sudo
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
ACTUAL_USER="$SUDO_USER"
|
||||
@@ -51,39 +16,20 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
# Determine the Project Root Directory (parent of scripts/install/)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
|
||||
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||
|
||||
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
|
||||
echo "Using home directory: $USER_HOME"
|
||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||
|
||||
# Render the main display unit from its template. The display service runs as
|
||||
# root (it needs GPIO), so __USER__ is always root here -- unlike the web unit
|
||||
# below, which runs as whoever installed it.
|
||||
#
|
||||
# A missing template or a failed render is fatal: falling through would leave
|
||||
# whatever unit already sits at /etc/systemd/system/ledmatrix.service (from a
|
||||
# previous install) untouched, and the enable/start step below would then
|
||||
# silently reuse that stale unit instead of the one this run was asked to
|
||||
# install.
|
||||
# Create a temporary service file for the main display with the correct paths
|
||||
# Assuming ledmatrix.service template exists and uses /home/ledpi as a placeholder for user home
|
||||
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
||||
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||
MAIN_UNIT_TMP=$(mktemp)
|
||||
trap 'rm -f "$MAIN_UNIT_TMP"' EXIT
|
||||
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" \
|
||||
"$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > "$MAIN_UNIT_TMP"; then
|
||||
echo "ERROR: failed to render ledmatrix.service from its template." >&2
|
||||
exit 1
|
||||
fi
|
||||
sed "s|/home/ledpi|$USER_HOME|g; s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > /tmp/ledmatrix.service.tmp
|
||||
# Copy the service file to the systemd directory
|
||||
sudo cp "$MAIN_UNIT_TMP" /etc/systemd/system/ledmatrix.service
|
||||
sudo cp /tmp/ledmatrix.service.tmp /etc/systemd/system/ledmatrix.service
|
||||
# Clean up
|
||||
rm -f "$MAIN_UNIT_TMP"
|
||||
trap - EXIT
|
||||
rm /tmp/ledmatrix.service.tmp
|
||||
else
|
||||
echo "ERROR: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service." >&2
|
||||
exit 1
|
||||
echo "WARNING: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service. Main display service not configured."
|
||||
fi
|
||||
|
||||
|
||||
@@ -102,67 +48,42 @@ fi
|
||||
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
|
||||
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
|
||||
|
||||
# Rendered from systemd/ledmatrix-web.service, the same template
|
||||
# install_web_service.sh uses. This was an inline heredoc until it drifted from
|
||||
# the template: it had lost Wants=network-online.target, RestartSec,
|
||||
# SyslogIdentifier and Environment=USE_THREADING. Because
|
||||
# src/startup_validator.py compares the installed unit against the template,
|
||||
# every boot warned "re-run install_service.sh" -- and doing so reinstalled the
|
||||
# same stale copy, so the warning could never clear.
|
||||
#
|
||||
# As with the main unit above, a missing template or a failed render is
|
||||
# fatal -- otherwise the enable/start check below would fall back to
|
||||
# whatever unit (possibly stale) already exists at the destination path.
|
||||
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" ]; then
|
||||
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||
WEB_UNIT_TMP=$(mktemp)
|
||||
trap 'rm -f "$WEB_UNIT_TMP"' EXIT
|
||||
if ! sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||
"$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service" > "$WEB_UNIT_TMP"; then
|
||||
echo "ERROR: failed to render ledmatrix-web.service from its template." >&2
|
||||
exit 1
|
||||
fi
|
||||
sudo cp "$WEB_UNIT_TMP" /etc/systemd/system/ledmatrix-web.service
|
||||
rm -f "$WEB_UNIT_TMP"
|
||||
trap - EXIT
|
||||
else
|
||||
echo "ERROR: ledmatrix-web.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix-web.service." >&2
|
||||
exit 1
|
||||
fi
|
||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
||||
[Unit]
|
||||
Description=LED Matrix Web Interface (Conditional Start)
|
||||
After=network.target
|
||||
# Wants=ledmatrix.service
|
||||
# After=network.target ledmatrix.service
|
||||
|
||||
# Health check / rollback units for automatic updates; see install_web_service.sh.
|
||||
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
|
||||
if [ -f "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" ]; then
|
||||
VERIFY_UNIT_TMP=$(mktemp)
|
||||
if sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" "$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT" > "$VERIFY_UNIT_TMP"; then
|
||||
sudo cp "$VERIFY_UNIT_TMP" "/etc/systemd/system/$VERIFY_UNIT"
|
||||
else
|
||||
echo "WARNING: failed to render $VERIFY_UNIT; automatic code updates will stay paused." >&2
|
||||
fi
|
||||
rm -f "$VERIFY_UNIT_TMP"
|
||||
fi
|
||||
done
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
User=${ACTUAL_USER}
|
||||
Restart=on-failure
|
||||
# Environment="PYTHONUNBUFFERED=1"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
)
|
||||
|
||||
# Write the new service file
|
||||
echo "$WEB_SERVICE_FILE_CONTENT" | sudo tee /etc/systemd/system/ledmatrix-web.service > /dev/null
|
||||
|
||||
echo "Reloading systemd daemon for web service..."
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
|
||||
echo "Enabling ledmatrix-web.service to start on boot..."
|
||||
sudo systemctl enable ledmatrix-web.service
|
||||
echo "Enabling ledmatrix-web.service to start on boot..."
|
||||
sudo systemctl enable ledmatrix-web.service
|
||||
|
||||
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
|
||||
echo "Enabling ledmatrix-update-verify.path (automatic update health check)..."
|
||||
sudo systemctl enable --now ledmatrix-update-verify.path || echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused" >&2
|
||||
fi
|
||||
echo "Starting ledmatrix-web.service..."
|
||||
sudo systemctl start ledmatrix-web.service
|
||||
|
||||
echo "Starting ledmatrix-web.service..."
|
||||
sudo systemctl start ledmatrix-web.service
|
||||
|
||||
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
||||
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
||||
else
|
||||
echo "Skipping enable/start for ledmatrix-web.service as it was not configured."
|
||||
fi
|
||||
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
|
||||
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
|
||||
# === End of LEDMatrix Web Interface service ===
|
||||
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ fi
|
||||
# Determine the Project Root Directory (parent of scripts/install/)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
|
||||
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||
|
||||
echo "Installing for user: $ACTUAL_USER"
|
||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||
|
||||
@@ -29,80 +26,63 @@ if [ "$EUID" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Render the unit from systemd/ledmatrix-web.service. That template is the
|
||||
# only description of the unit; this script used to carry its own heredoc copy,
|
||||
# and install_service.sh a third, which is how the installed unit on real rigs
|
||||
# ended up missing RestartSec and SyslogIdentifier while
|
||||
# src/startup_validator.py warned about drift on every boot.
|
||||
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"
|
||||
if [ ! -f "$TEMPLATE" ]; then
|
||||
echo "ERROR: unit template not found at $TEMPLATE"
|
||||
exit 1
|
||||
fi
|
||||
# Generate the service file dynamically with the correct paths
|
||||
echo "Generating service file with dynamic paths..."
|
||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
||||
[Unit]
|
||||
Description=LED Matrix Web Interface Service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${ACTUAL_USER}
|
||||
WorkingDirectory=${PROJECT_ROOT_DIR}
|
||||
Environment=USE_THREADING=1
|
||||
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=ledmatrix-web
|
||||
# Automatically create and manage cache directory
|
||||
CacheDirectory=ledmatrix
|
||||
CacheDirectoryMode=0775
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
)
|
||||
|
||||
# Write the service file to systemd directory
|
||||
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
|
||||
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||
ESCAPED_ACTUAL_USER=$(sed_escape_replacement "$ACTUAL_USER")
|
||||
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||
"$TEMPLATE" > /etc/systemd/system/ledmatrix-web.service
|
||||
echo "$WEB_SERVICE_FILE_CONTENT" > /etc/systemd/system/ledmatrix-web.service
|
||||
|
||||
# Health check and rollback for the web UI's automatic updates. Its own unit so
|
||||
# it survives the web service restart it performs; never enabled -- the web
|
||||
# interface starts it after an update. Without it, automatic code updates
|
||||
# stay paused rather than running with nothing to undo them.
|
||||
for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path; do
|
||||
VERIFY_TEMPLATE="$PROJECT_ROOT_DIR/systemd/$VERIFY_UNIT"
|
||||
if [ -f "$VERIFY_TEMPLATE" ]; then
|
||||
echo "Writing unit file to /etc/systemd/system/$VERIFY_UNIT"
|
||||
sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|$ESCAPED_ACTUAL_USER|g" \
|
||||
"$VERIFY_TEMPLATE" > "/etc/systemd/system/$VERIFY_UNIT"
|
||||
chmod 644 "/etc/systemd/system/$VERIFY_UNIT"
|
||||
else
|
||||
echo "WARNING: $VERIFY_TEMPLATE not found; automatic code updates will stay paused"
|
||||
fi
|
||||
done
|
||||
|
||||
# Shared cache directory. The display service (root) and this web service both
|
||||
# write here and read each other's files, which are created 0660, so the two
|
||||
# share it through the directory's group: ledmatrix when the installing user
|
||||
# is in it (first_time_install.sh / setup_cache.sh set that up), otherwise the
|
||||
# user's own group. setgid makes new files inherit that group.
|
||||
#
|
||||
# An existing directory keeps its group whenever the web user can read through
|
||||
# it -- ledmatrix, or the user's own group where systemd's old CacheDirectory=
|
||||
# left it -- because re-grouping a working directory strands every file already
|
||||
# in it on the old group. Only a group the user is not in (root's, or ledmatrix
|
||||
# for a user outside it) is replaced. This used to force the user's group on
|
||||
# every run, replacing the ledmatrix group setup_cache.sh had set.
|
||||
# Ensure cache directory exists with proper permissions
|
||||
# This is a fallback for older systemd versions that don't support CacheDirectory
|
||||
# Systemd 239+ will automatically create it via CacheDirectory directive
|
||||
echo "Setting up cache directory..."
|
||||
CACHE_DIR="/var/cache/ledmatrix"
|
||||
USER_GROUPS=$(id -nG "$ACTUAL_USER" 2>/dev/null | tr ' ' '\n')
|
||||
if printf '%s\n' "$USER_GROUPS" | grep -qx ledmatrix; then
|
||||
CACHE_GROUP="ledmatrix"
|
||||
else
|
||||
CACHE_GROUP=$(id -gn "$ACTUAL_USER" 2>/dev/null || echo root)
|
||||
fi
|
||||
if [ ! -d "$CACHE_DIR" ]; then
|
||||
mkdir -p "$CACHE_DIR"
|
||||
chown root:"$CACHE_GROUP" "$CACHE_DIR" 2>/dev/null || true
|
||||
# Set group ownership to allow both root and web user access
|
||||
# Try to use ACTUAL_USER's group, fallback to root if that fails
|
||||
if getent group "$ACTUAL_USER" > /dev/null 2>&1; then
|
||||
chown root:"$ACTUAL_USER" "$CACHE_DIR" 2>/dev/null || chown root:root "$CACHE_DIR"
|
||||
else
|
||||
chown root:root "$CACHE_DIR"
|
||||
fi
|
||||
chmod 775 "$CACHE_DIR"
|
||||
echo "✓ Cache directory created: $CACHE_DIR"
|
||||
else
|
||||
DIR_GROUP=$(stat -c %G "$CACHE_DIR" 2>/dev/null)
|
||||
if ! printf '%s\n' "$USER_GROUPS" | grep -qx "$DIR_GROUP"; then
|
||||
if chgrp "$CACHE_GROUP" "$CACHE_DIR" 2>/dev/null; then
|
||||
echo "✓ Cache directory group changed from $DIR_GROUP to $CACHE_GROUP"
|
||||
# Files already there keep the old group. The display service
|
||||
# re-groups its own files when it starts (DiskCache.share_existing_files,
|
||||
# which refuses symlinks and hard links); a recursive chgrp here
|
||||
# would not. try-restart does nothing if the service is not running.
|
||||
if find "$CACHE_DIR" -maxdepth 1 -name '*.json' -user root ! -group "$CACHE_GROUP" -print -quit 2>/dev/null | grep -q .; then
|
||||
systemctl try-restart ledmatrix.service 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
# Ensure permissions are correct
|
||||
chmod 775 "$CACHE_DIR" 2>/dev/null || true
|
||||
# Try to set group ownership if possible
|
||||
if getent group "$ACTUAL_USER" > /dev/null 2>&1; then
|
||||
chown root:"$ACTUAL_USER" "$CACHE_DIR" 2>/dev/null || true
|
||||
fi
|
||||
echo "✓ Cache directory exists: $CACHE_DIR"
|
||||
fi
|
||||
chmod 2775 "$CACHE_DIR" 2>/dev/null || true
|
||||
|
||||
# Reload systemd to recognize the new service
|
||||
echo "Reloading systemd..."
|
||||
@@ -112,13 +92,6 @@ systemctl daemon-reload
|
||||
echo "Enabling ledmatrix-web.service..."
|
||||
systemctl enable ledmatrix-web.service
|
||||
|
||||
# The path unit is what starts the health check after an automatic update.
|
||||
if [ -f /etc/systemd/system/ledmatrix-update-verify.path ]; then
|
||||
echo "Enabling ledmatrix-update-verify.path..."
|
||||
systemctl enable --now ledmatrix-update-verify.path || \
|
||||
echo "WARNING: could not enable ledmatrix-update-verify.path; automatic code updates will stay paused"
|
||||
fi
|
||||
|
||||
# Start the service
|
||||
echo "Starting ledmatrix-web.service..."
|
||||
systemctl start ledmatrix-web.service
|
||||
|
||||
@@ -18,9 +18,6 @@ USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
# Determine the Project Root Directory (parent of scripts/install/)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
|
||||
# shellcheck source=scripts/install/lib_systemd_render.sh
|
||||
source "$PROJECT_ROOT_DIR/scripts/install/lib_systemd_render.sh"
|
||||
|
||||
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
|
||||
echo "Using home directory: $USER_HOME"
|
||||
echo "Project root directory: $PROJECT_ROOT_DIR"
|
||||
@@ -67,19 +64,30 @@ if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
|
||||
echo "✓ Package installation completed"
|
||||
fi
|
||||
|
||||
# Render the unit from systemd/ledmatrix-wifi-monitor.service rather than
|
||||
# inlining a second copy here. The copy this replaced had already drifted --
|
||||
# it wrote StandardOutput/StandardError=syslog where the template says journal.
|
||||
# Create service file with correct paths
|
||||
echo ""
|
||||
echo "Creating systemd service file..."
|
||||
TEMPLATE="$PROJECT_ROOT_DIR/systemd/ledmatrix-wifi-monitor.service"
|
||||
if [ ! -f "$TEMPLATE" ]; then
|
||||
echo "ERROR: unit template not found at $TEMPLATE"
|
||||
exit 1
|
||||
fi
|
||||
SERVICE_FILE_CONTENT=$(cat <<EOF
|
||||
[Unit]
|
||||
Description=LED Matrix WiFi Monitor Daemon
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
ESCAPED_PROJECT_ROOT_DIR=$(sed_escape_replacement "$PROJECT_ROOT_DIR")
|
||||
SERVICE_FILE_CONTENT=$(sed "s|__PROJECT_ROOT_DIR__|$ESCAPED_PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$TEMPLATE")
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$PROJECT_ROOT_DIR
|
||||
ExecStart=/usr/bin/python3 $PROJECT_ROOT_DIR/scripts/utils/wifi_monitor_daemon.py --interval 30
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=ledmatrix-wifi-monitor
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
)
|
||||
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
echo "$SERVICE_FILE_CONTENT" | tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Shared helper for rendering systemd unit templates via sed.
|
||||
#
|
||||
# Sourced by install_service.sh, install_web_service.sh and
|
||||
# install_wifi_monitor.sh so all three escape sed replacement text the same
|
||||
# way instead of carrying three copies of the same fix.
|
||||
|
||||
# sed_escape_replacement VALUE
|
||||
#
|
||||
# Print VALUE escaped for safe use as the replacement side of `sed
|
||||
# s|pattern|replacement|`. Every one of these scripts builds its sed
|
||||
# expression by interpolating a shell variable (a path, a username, ...)
|
||||
# straight into the replacement text. sed gives three characters special
|
||||
# meaning there: backslash (escape character), & (whole match) and the
|
||||
# delimiter itself (here `|`). A value containing any of them -- e.g. a
|
||||
# username or path with an `&`, a literal backslash, or a `|` -- would
|
||||
# otherwise corrupt the rendered unit file instead of being substituted
|
||||
# literally. Escape the backslash first so the later escapes aren't
|
||||
# double-escaped.
|
||||
sed_escape_replacement() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//&/\\&}"
|
||||
value="${value//|/\\|}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
Executable → Regular
@@ -408,7 +408,6 @@ main() {
|
||||
# which would silently reinstate the duplicate apt update.
|
||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
|
||||
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
|
||||
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
|
||||
bash ./first_time_install.sh -y </dev/null
|
||||
fi
|
||||
INSTALL_EXIT_CODE=$?
|
||||
|
||||
Executable → Regular
Executable → Regular
+6
-53
@@ -3,8 +3,6 @@
|
||||
# Use this if automatic dependency installation fails
|
||||
|
||||
set -e
|
||||
# A failed pip must fail the `pip ... | tee` pipeline below, not be hidden by tee.
|
||||
set -o pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -30,50 +28,15 @@ echo ""
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
LEDMATRIX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CONFIG_FILE="$LEDMATRIX_DIR/config/config.json"
|
||||
|
||||
# The Plugin Store installs into plugin_system.plugins_directory from
|
||||
# config.json (default plugin-repos), resolved against the project root like
|
||||
# the display and web services do. plugins/ is also scanned: it holds the
|
||||
# symlinks scripts/dev/dev_plugin_setup.sh creates.
|
||||
CONFIGURED_DIR="plugin-repos"
|
||||
if [ -f "$CONFIG_FILE" ] && command -v python3 >/dev/null 2>&1; then
|
||||
CONFIGURED_DIR="$(LEDMATRIX_CONFIG_FILE="$CONFIG_FILE" python3 -c '
|
||||
import json, os
|
||||
try:
|
||||
with open(os.environ["LEDMATRIX_CONFIG_FILE"], encoding="utf-8") as f:
|
||||
value = (json.load(f).get("plugin_system") or {}).get("plugins_directory")
|
||||
except Exception:
|
||||
value = None
|
||||
print(value if isinstance(value, str) and value.strip() else "plugin-repos")
|
||||
' 2>/dev/null)" || CONFIGURED_DIR="plugin-repos"
|
||||
[ -n "$CONFIGURED_DIR" ] || CONFIGURED_DIR="plugin-repos"
|
||||
fi
|
||||
case "$CONFIGURED_DIR" in
|
||||
/*) PLUGINS_DIR="$CONFIGURED_DIR" ;;
|
||||
*) PLUGINS_DIR="$LEDMATRIX_DIR/$CONFIGURED_DIR" ;;
|
||||
esac
|
||||
DEV_PLUGINS_DIR="$LEDMATRIX_DIR/plugins"
|
||||
PLUGINS_DIR="$LEDMATRIX_DIR/plugins"
|
||||
|
||||
echo "LEDMatrix directory: $LEDMATRIX_DIR"
|
||||
echo "Plugins directory: $PLUGINS_DIR (plugin_system.plugins_directory)"
|
||||
|
||||
SCAN_DIRS=()
|
||||
PLUGINS_DIR_REAL=""
|
||||
if [ -d "$PLUGINS_DIR" ]; then
|
||||
SCAN_DIRS+=("$PLUGINS_DIR")
|
||||
PLUGINS_DIR_REAL="$(cd "$PLUGINS_DIR" && pwd -P)"
|
||||
fi
|
||||
if [ -d "$DEV_PLUGINS_DIR" ] && [ "$(cd "$DEV_PLUGINS_DIR" && pwd -P)" != "$PLUGINS_DIR_REAL" ]; then
|
||||
echo "Also scanning dev plugins: $DEV_PLUGINS_DIR"
|
||||
SCAN_DIRS+=("$DEV_PLUGINS_DIR")
|
||||
fi
|
||||
echo "Plugins directory: $PLUGINS_DIR"
|
||||
echo ""
|
||||
|
||||
# Check if a plugins directory exists
|
||||
if [ ${#SCAN_DIRS[@]} -eq 0 ]; then
|
||||
# Check if plugins directory exists
|
||||
if [ ! -d "$PLUGINS_DIR" ]; then
|
||||
echo -e "${RED}Error: Plugins directory not found at $PLUGINS_DIR${NC}"
|
||||
echo "Install a plugin from the Plugin Store first, or check plugin_system.plugins_directory in $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -84,21 +47,12 @@ echo ""
|
||||
PLUGINS_FOUND=0
|
||||
PLUGINS_INSTALLED=0
|
||||
PLUGINS_FAILED=0
|
||||
SEEN_PLUGIN_PATHS=" "
|
||||
|
||||
for scan_dir in "${SCAN_DIRS[@]}"; do
|
||||
for plugin_dir in "$scan_dir"/*/ ; do
|
||||
for plugin_dir in "$PLUGINS_DIR"/*/ ; do
|
||||
if [ -d "$plugin_dir" ]; then
|
||||
plugin_name=$(basename "$plugin_dir")
|
||||
requirements_file="$plugin_dir/requirements.txt"
|
||||
|
||||
# A dev symlink can point at a plugin already scanned; install it once.
|
||||
real_plugin_dir="$(cd "$plugin_dir" && pwd -P)"
|
||||
case "$SEEN_PLUGIN_PATHS" in
|
||||
*" $real_plugin_dir "*) continue ;;
|
||||
esac
|
||||
SEEN_PLUGIN_PATHS="$SEEN_PLUGIN_PATHS$real_plugin_dir "
|
||||
|
||||
|
||||
if [ -f "$requirements_file" ]; then
|
||||
PLUGINS_FOUND=$((PLUGINS_FOUND + 1))
|
||||
echo -e "${GREEN}Found plugin: ${plugin_name}${NC}"
|
||||
@@ -125,7 +79,6 @@ for plugin_dir in "$scan_dir"/*/ ; do
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
|
||||
@@ -6,9 +6,6 @@ Discovers and runs tests for LEDMatrix plugins.
|
||||
Supports both unittest and pytest.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
@@ -71,79 +68,6 @@ def _find_tests_in_dir(directory: Path) -> list:
|
||||
return sorted(set(test_files))
|
||||
|
||||
|
||||
def _is_script_style(path) -> bool:
|
||||
"""True when a test file is a standalone script, not a pytest module.
|
||||
|
||||
Most plugin tests are written as `def main()` plus an `if __name__ ==
|
||||
"__main__"` guard and signal through an exit code. pytest collects zero
|
||||
items from those, so handing them to pytest printed "no tests ran" and this
|
||||
runner reported success over work it had not done -- 151 of 248 files on a
|
||||
fully populated rig.
|
||||
"""
|
||||
try:
|
||||
src = Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
has_pytest_items = re.search(r"^\s*(def test_|class Test|async def test_)", src, re.M)
|
||||
has_main_guard = "__main__" in src and "__name__" in src
|
||||
return bool(has_main_guard and not has_pytest_items)
|
||||
|
||||
|
||||
def run_script_tests(test_files: list, verbose: bool = False) -> int:
|
||||
"""Run standalone test scripts, honouring the 0 pass / 2 skip / 1 fail
|
||||
convention that ledmatrix-plugins' own runner established.
|
||||
|
||||
Scripts opt into skipping by printing "SKIP: <reason>" and exiting 2 --
|
||||
a script that needs a tty or an LED matrix is not a regression.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
# Prepend rather than setdefault. An inherited PYTHONPATH -- a developer's
|
||||
# shell, a tox run, another checkout -- otherwise wins outright, and the
|
||||
# subprocess imports a different copy of the core than the one under test.
|
||||
# That is exactly the failure ledmatrix-plugins#467 describes, and it is
|
||||
# invisible: the tests pass or fail against a tree nobody meant to test.
|
||||
inherited = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = (f"{PROJECT_ROOT}{os.pathsep}{inherited}"
|
||||
if inherited else str(PROJECT_ROOT))
|
||||
env["LEDMATRIX_CORE"] = str(PROJECT_ROOT)
|
||||
|
||||
passed = skipped = failed = 0
|
||||
failures = []
|
||||
for path in test_files:
|
||||
try:
|
||||
# Fixed interpreter (sys.executable) plus a test path this script
|
||||
# discovered by globbing the repo; argument list, no shell, so
|
||||
# nothing is word-split or expanded. Same suppression pair the
|
||||
# rest of the repo uses for this shape (see permission_utils.py).
|
||||
proc = subprocess.run( # noqa: S603 # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
[sys.executable, str(path)], # nosemgrep
|
||||
cwd=str(Path(path).parent),
|
||||
capture_output=True, text=True, env=env,
|
||||
stdin=subprocess.DEVNULL, timeout=300,
|
||||
)
|
||||
rc = proc.returncode
|
||||
tail = " | ".join((proc.stdout or proc.stderr or "").strip().splitlines()[-2:])[:200]
|
||||
except subprocess.TimeoutExpired:
|
||||
rc, tail = 1, "timed out after 300s"
|
||||
if rc == 0:
|
||||
passed += 1
|
||||
label = "pass"
|
||||
elif rc == 2:
|
||||
skipped += 1
|
||||
label = "SKIP"
|
||||
else:
|
||||
failed += 1
|
||||
label = "FAIL"
|
||||
failures.append(f"{Path(path).name}: exit {rc} | {tail}")
|
||||
if verbose or rc != 0:
|
||||
print(f" [{label}] {Path(path).name}" + (f" -- {tail}" if rc != 0 else ""))
|
||||
|
||||
print(f"\n{passed} passed, {skipped} skipped, {failed} failed (scripts)")
|
||||
for f in failures:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
|
||||
"""
|
||||
Run tests using unittest.
|
||||
@@ -262,16 +186,11 @@ def main():
|
||||
print("No test files found in plugins directory")
|
||||
return 0
|
||||
|
||||
scripts = [f for f in test_files if _is_script_style(f)]
|
||||
modules = [f for f in test_files if f not in scripts]
|
||||
|
||||
print(f"Found {len(test_files)} test file(s)"
|
||||
+ (f" -- {len(modules)} collectable, {len(scripts)} standalone script(s)"
|
||||
if scripts else ""))
|
||||
print(f"Found {len(test_files)} test file(s)")
|
||||
for test_file in test_files:
|
||||
print(f" - {test_file}")
|
||||
print()
|
||||
|
||||
|
||||
# Determine runner
|
||||
runner = args.runner
|
||||
if runner == 'auto':
|
||||
@@ -280,21 +199,12 @@ def main():
|
||||
runner = 'pytest'
|
||||
except ImportError:
|
||||
runner = 'unittest'
|
||||
|
||||
# Standalone scripts cannot be collected by pytest or unittest -- run them
|
||||
# as the scripts they are. Doing this rather than silently collecting zero
|
||||
# items is the whole point: this runner used to report success having
|
||||
# executed nothing.
|
||||
rc = 0
|
||||
if scripts:
|
||||
rc |= run_script_tests(scripts, args.verbose)
|
||||
|
||||
if modules:
|
||||
if runner == 'pytest':
|
||||
rc |= run_pytest_tests(modules, args.verbose, args.coverage)
|
||||
else:
|
||||
rc |= run_unittest_tests(modules, args.verbose)
|
||||
return rc
|
||||
|
||||
# Run tests
|
||||
if runner == 'pytest':
|
||||
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
||||
else:
|
||||
return run_unittest_tests(test_files, args.verbose)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show and try the scroll speeds your panel can display cleanly.
|
||||
|
||||
Motion looks smooth when the strip advances a WHOLE number of pixels per panel
|
||||
refresh. Anything else has to blend two columns (which on pixel-font text reads
|
||||
as shimmer) or repeat frames unevenly (which reads as judder). So the speeds
|
||||
worth using are not arbitrary -- they are
|
||||
|
||||
refresh_hz / frame_hold * pixels_per_frame
|
||||
|
||||
for whole numbers of frame_hold and pixels_per_frame, and that ladder depends
|
||||
on how fast YOUR panel actually refreshes. A Pi Zero driving a big chain will
|
||||
have a completely different set of good speeds from a Pi 4 driving a small one.
|
||||
|
||||
# what can this panel do? (no hardware needed, uses your configured rate)
|
||||
python3 scripts/scroll_speeds.py
|
||||
|
||||
# measure what the panel ACTUALLY manages, rather than what is configured
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/scroll_speeds.py --measure
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
# what would a 60Hz panel offer?
|
||||
python3 scripts/scroll_speeds.py --hz 60
|
||||
|
||||
# try one on the panel
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/scroll_speeds.py --demo 50
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
This script never starts or stops the display service itself -- that is left to
|
||||
you, so a crash here can never leave the panel dark.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.common import scroll_config # noqa: E402
|
||||
|
||||
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
|
||||
|
||||
|
||||
def load_config():
|
||||
"""The whole config.json, or {} when it is missing or unreadable."""
|
||||
try:
|
||||
with open(CONFIG, encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
|
||||
def hardware_of(config):
|
||||
return (config.get("display") or {}).get("hardware") or {}
|
||||
|
||||
|
||||
def build_options(config, refresh_override=None):
|
||||
"""The matrix options the display service would use for this config.
|
||||
|
||||
Built by DisplayManager.apply_matrix_options, not a copy of it, so the
|
||||
measurement and the demo drive the panel exactly as the service does
|
||||
(runtime gpio_slowdown, rp1_rio, panel_type, orientation, defaults).
|
||||
``refresh_override`` replaces limit_refresh_rate_hz; 0 means uncapped.
|
||||
"""
|
||||
from src.display_manager import DisplayManager, RGBMatrixOptions
|
||||
|
||||
options = DisplayManager.apply_matrix_options(RGBMatrixOptions(), config)
|
||||
if refresh_override is not None:
|
||||
options.limit_refresh_rate_hz = int(refresh_override)
|
||||
return options
|
||||
|
||||
|
||||
def open_matrix(config, refresh_override=None):
|
||||
"""Construct the matrix, or explain why it will not open."""
|
||||
if os.geteuid() != 0:
|
||||
sys.exit("this needs root for GPIO access - rerun with sudo")
|
||||
try:
|
||||
from src.display_manager import RGBMatrix
|
||||
except ImportError as exc:
|
||||
sys.exit("could not load the display stack ({}); is rgbmatrix "
|
||||
"installed on this machine?".format(exc))
|
||||
try:
|
||||
return RGBMatrix(options=build_options(config, refresh_override))
|
||||
except Exception as exc: # pragma: no cover - hardware dependent
|
||||
sys.exit(
|
||||
"could not open the panel ({}).\n"
|
||||
"If the display service is running it owns the GPIO - stop it first:\n"
|
||||
" sudo systemctl stop ledmatrix".format(exc)
|
||||
)
|
||||
|
||||
|
||||
def measure_refresh(config, seconds=6.0):
|
||||
"""Actual refresh rate, by running uncapped and timing the swaps.
|
||||
|
||||
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
|
||||
runs at exactly the panel's rate. This is what an older Pi or a longer
|
||||
chain will really give you, as opposed to whatever limit_refresh_rate_hz
|
||||
optimistically asks for.
|
||||
"""
|
||||
matrix = open_matrix(config, refresh_override=0)
|
||||
canvas = matrix.CreateFrameCanvas()
|
||||
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
|
||||
frames = 0
|
||||
started = time.perf_counter()
|
||||
while time.perf_counter() - started < seconds:
|
||||
canvas = matrix.SwapOnVSync(canvas)
|
||||
frames += 1
|
||||
measured = frames / (time.perf_counter() - started)
|
||||
matrix.Clear()
|
||||
return measured
|
||||
|
||||
|
||||
def demo(config, target, seconds):
|
||||
"""Scroll text at the crisp speed nearest `target`."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.common.font_layout import load_truetype
|
||||
|
||||
hz = scroll_config.refresh_hz_from_config(config)
|
||||
choice = scroll_config.solve_crisp(target, hz)
|
||||
print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
|
||||
|
||||
matrix = open_matrix(config)
|
||||
canvas = matrix.CreateFrameCanvas()
|
||||
W, H = canvas.width, canvas.height
|
||||
|
||||
font = None
|
||||
for path, size in (
|
||||
(str(Path(__file__).resolve().parent.parent / "assets/fonts/PressStart2P-Regular.ttf"), 16),
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 26),
|
||||
):
|
||||
try:
|
||||
font = load_truetype(path, size)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
text = " {:.0f} px/s *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG ***".format(
|
||||
choice.pixels_per_second)
|
||||
box = ImageDraw.Draw(Image.new("RGB", (8, 8))).textbbox((0, 0), text, font=font)
|
||||
tw, th = box[2] - box[0], box[3] - box[1]
|
||||
reps = max(2, (W * 3) // max(tw, 1) + 1)
|
||||
strip = Image.new("RGB", (tw * reps, H), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(strip)
|
||||
for i in range(reps):
|
||||
draw.text((i * tw, (H - th) // 2 - box[1]), text, font=font, fill=(255, 210, 60))
|
||||
|
||||
offset = 0
|
||||
frames = 0
|
||||
started = time.time()
|
||||
while time.time() - started < seconds:
|
||||
window = strip.crop((offset, 0, offset + W, H))
|
||||
if window.width < W:
|
||||
whole = Image.new("RGB", (W, H), (0, 0, 0))
|
||||
head = strip.crop((offset, 0, strip.width, H))
|
||||
whole.paste(head, (0, 0))
|
||||
whole.paste(strip.crop((0, 0, W - head.width, H)), (head.width, 0))
|
||||
window = whole
|
||||
canvas.SetImage(window)
|
||||
canvas = matrix.SwapOnVSync(canvas, choice.frame_hold)
|
||||
offset = (offset + choice.pixels_per_frame) % strip.width
|
||||
frames += 1
|
||||
elapsed = time.time() - started
|
||||
print(" {} frames in {:.1f}s = {:.1f} fps = {:.1f} px/s actual".format(
|
||||
frames, elapsed, frames / elapsed, frames * choice.pixels_per_frame / elapsed))
|
||||
matrix.Clear()
|
||||
|
||||
|
||||
def print_ladder(hz, highlight=None):
|
||||
print("")
|
||||
print("Whole-pixel scroll speeds at {:.1f}Hz refresh".format(hz))
|
||||
print("(the panel refreshes at {:.0f}Hz for every one of these - holding a "
|
||||
"frame costs no flicker)".format(hz))
|
||||
print("")
|
||||
for entry in scroll_config.crisp_ladder(hz):
|
||||
if entry.pixels_per_second > hz * 3:
|
||||
break
|
||||
mark = " <-- nearest to {:.0f}".format(highlight) if (
|
||||
highlight is not None
|
||||
and entry.pixels_per_second == scroll_config.solve_crisp(highlight, hz).pixels_per_second
|
||||
) else ""
|
||||
print(" " + entry.describe() + mark)
|
||||
print("")
|
||||
print_config_advice(scroll_config.solve_crisp(highlight if highlight else hz / 2, hz))
|
||||
|
||||
|
||||
def config_advice(choice):
|
||||
"""The config that selects ``choice``, in the keys the resolver honours.
|
||||
|
||||
Tickers take a ``scroll_speed`` (px per step) + ``scroll_delay`` (seconds)
|
||||
pair, and scroll_config ranks that pair ABOVE ``scroll_pixels_per_second``
|
||||
-- deliberately, because some plugins give the flat key a schema default.
|
||||
Many schemas default the pair too, so a flat key added by hand is usually
|
||||
ignored. Advise the pair: pixels_per_frame every frame_hold/refresh
|
||||
seconds is exactly the crisp speed.
|
||||
"""
|
||||
pair = {
|
||||
"scroll_speed": choice.pixels_per_frame,
|
||||
"scroll_delay": round(choice.frame_hold / choice.refresh_hz, 6),
|
||||
}
|
||||
scoreboard = {"scroll_speed": round(choice.pixels_per_second, 2)}
|
||||
return pair, scoreboard
|
||||
|
||||
|
||||
def print_config_advice(choice):
|
||||
pair, scoreboard = config_advice(choice)
|
||||
print("To use {:.1f} px/s, set it where the plugin keeps its scroll speed.".format(
|
||||
choice.pixels_per_second))
|
||||
print("Tickers take a scroll_speed (px per step) + scroll_delay (seconds) pair:")
|
||||
print(' "display_options": {}'.format(json.dumps(pair)))
|
||||
print("(some plugins keep the pair at the top level or under \"display\").")
|
||||
print("The pair outranks scroll_pixels_per_second, which is ignored whenever the")
|
||||
print("pair is present -- and schema defaults usually put it there.")
|
||||
print("Sports scoreboards take pixels per second per league instead:")
|
||||
print(' "scroll_settings": {}'.format(json.dumps(scoreboard)))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--hz", type=float,
|
||||
help="refresh rate to compute the ladder for (default: your config)")
|
||||
ap.add_argument("--measure", action="store_true",
|
||||
help="measure the panel's real refresh rate (needs root, service stopped)")
|
||||
ap.add_argument("--demo", type=float, metavar="PXPS",
|
||||
help="scroll text at the crisp speed nearest this (needs root)")
|
||||
ap.add_argument("--seconds", type=float, default=15.0, help="demo duration")
|
||||
ap.add_argument("--want", type=float, metavar="PXPS",
|
||||
help="highlight the entry nearest this speed")
|
||||
args = ap.parse_args()
|
||||
|
||||
config = load_config()
|
||||
configured = float(hardware_of(config).get("limit_refresh_rate_hz") or 0)
|
||||
|
||||
if args.demo is not None:
|
||||
demo(config, args.demo, args.seconds)
|
||||
return
|
||||
|
||||
if args.measure:
|
||||
measured = measure_refresh(config)
|
||||
print("measured panel refresh: {:.1f}Hz".format(measured))
|
||||
if configured:
|
||||
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
|
||||
if measured < configured * 0.95:
|
||||
print(" -> the panel cannot reach the configured rate; the ladder")
|
||||
print(" below uses what it actually manages")
|
||||
print_ladder(measured, args.want)
|
||||
return
|
||||
|
||||
hz = args.hz or configured or scroll_config.DEFAULT_REFRESH_HZ
|
||||
if not args.hz and not configured:
|
||||
print("no limit_refresh_rate_hz in config; assuming {:.0f}Hz".format(hz))
|
||||
print("run with --measure to find your panel's real rate")
|
||||
print_ladder(hz, args.want)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive a sports scoreboard scroll on the panel and report what it did.
|
||||
|
||||
The eight sports scoreboards scroll through ``src/common/sports_scroll.py``,
|
||||
and that path is per-league opt-in: a rig showing static game cards never
|
||||
constructs a SportsScrollDisplay at all, so nothing about its pacing can be
|
||||
observed from a normal run. This drives it directly, with synthetic games, so
|
||||
the pacing can be measured without changing anyone's configuration.
|
||||
|
||||
What it checks is what the shared resolver is supposed to buy:
|
||||
|
||||
* the requested speed lands on a whole number of pixels per refresh
|
||||
* the frame hold that makes that true is published to the display manager
|
||||
* frames actually arrive at the interval the hold implies
|
||||
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/sports_scroll_check.py --seconds 20
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
Like scripts/scroll_speeds.py, this never starts or stops the display service
|
||||
itself -- that is left to the caller, so a crash here cannot leave the panel
|
||||
dark.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from src.common.sports_scroll import SportsScrollDisplay # noqa: E402
|
||||
from src.display_manager import DisplayManager # noqa: E402
|
||||
|
||||
|
||||
class _Check(SportsScrollDisplay):
|
||||
"""A scoreboard whose cards are plain blocks -- pacing is what matters."""
|
||||
|
||||
SCROLL_LEAGUE_KEYS = ("nfl",)
|
||||
|
||||
def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None):
|
||||
width = self.display_height * 2
|
||||
cards = []
|
||||
for i, _ in enumerate(games):
|
||||
card = Image.new("RGB", (width, self.display_height), (0, 0, 0))
|
||||
shade = 40 + (i * 37) % 180
|
||||
for x in range(2, width - 2):
|
||||
for y in range(2, self.display_height - 2):
|
||||
card.putpixel((x, y), (shade, 90, 220 - shade // 2))
|
||||
cards.append(card)
|
||||
self._current_games = list(games)
|
||||
self._current_game_type = game_type
|
||||
self._current_leagues = list(leagues)
|
||||
self.scroll_helper.create_scrolling_image(content_items=cards, item_gap=24)
|
||||
return bool(cards)
|
||||
|
||||
|
||||
class _HoldSpy:
|
||||
"""Records what the scroll publishes, without changing what it does."""
|
||||
|
||||
def __init__(self, display_manager):
|
||||
self.dm = display_manager
|
||||
self.calls = []
|
||||
self._real = display_manager.set_scrolling_state
|
||||
|
||||
def __enter__(self):
|
||||
def spy(is_scrolling, frame_hold=1):
|
||||
self.calls.append((is_scrolling, frame_hold))
|
||||
return self._real(is_scrolling, frame_hold=frame_hold)
|
||||
self.dm.set_scrolling_state = spy
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.dm.set_scrolling_state = self._real
|
||||
return False
|
||||
|
||||
|
||||
MESSAGE = """ledmatrix is running and owns the panel's GPIO.
|
||||
|
||||
Stop it first, or this run can leave the display dark:
|
||||
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/sports_scroll_check.py
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
Use --fallback to check the pacing logic without the panel, or --force if
|
||||
you really mean it."""
|
||||
|
||||
|
||||
def _refuse_if_the_service_is_running(force):
|
||||
"""Refuse to touch the panel while ledmatrix has it.
|
||||
|
||||
rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside
|
||||
RGBMatrix(), and on the root check it calls exit() from C -- no cleanup.
|
||||
Do that while the service is driving those same pins and the panel goes
|
||||
dark while the service carries on rendering happily: fresh framebuffer,
|
||||
every pixel lit, "RGB Matrix initialized successfully", nothing in the log.
|
||||
A restart brings it back, but only once you work out that is what happened.
|
||||
|
||||
The module docstring says to stop the service first. This makes it true.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
try:
|
||||
active = subprocess.run( # nosec B603 B607 - hardcoded systemctl args # nosemgrep
|
||||
["systemctl", "is-active", "ledmatrix"],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
except OSError:
|
||||
return # not a systemd box; nothing to protect
|
||||
if active == "active":
|
||||
sys.exit(MESSAGE)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--seconds", type=float, default=20.0)
|
||||
ap.add_argument("--speed", type=float, default=None,
|
||||
help="px/s to request; default is the module's own")
|
||||
ap.add_argument("--games", type=int, default=6)
|
||||
ap.add_argument("--force", action="store_true",
|
||||
help="run even though the display service is up. It owns "
|
||||
"the GPIO; expect a dark panel until you restart it.")
|
||||
ap.add_argument("--fallback", action="store_true",
|
||||
help="run without the panel. Driving the real matrix needs "
|
||||
"root; this checks everything except the vsync pacing "
|
||||
"-- what speed resolves to, that the hold is published, "
|
||||
"and that it is released afterwards.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.fallback:
|
||||
_refuse_if_the_service_is_running(args.force)
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
config = json.loads((root / "config" / "config.json").read_text(encoding="utf-8"))
|
||||
|
||||
display_manager = DisplayManager(config, force_fallback=args.fallback)
|
||||
settings = {} if args.speed is None else {
|
||||
"nfl": {"scroll_settings": {"scroll_speed": args.speed}}}
|
||||
|
||||
display = _Check(display_manager, settings, global_config=config)
|
||||
resolved = display._scroll_settings
|
||||
print("resolved: %s" % resolved.describe())
|
||||
print("frame hold: %d refresh(es) per frame" % resolved.frame_hold)
|
||||
if resolved.warning:
|
||||
print("warning: %s" % resolved.warning)
|
||||
|
||||
display.prepare_scroll_content(
|
||||
[{"id": "g%d" % i} for i in range(args.games)], "live", ["nfl"])
|
||||
|
||||
gaps, drawn = [], 0
|
||||
last = None
|
||||
with _HoldSpy(display_manager) as spy:
|
||||
started = time.perf_counter()
|
||||
while time.perf_counter() - started < args.seconds:
|
||||
if not display.display_scroll_frame():
|
||||
break
|
||||
now = time.perf_counter()
|
||||
if last is not None:
|
||||
gaps.append((now - last) * 1000.0)
|
||||
last = now
|
||||
drawn += 1
|
||||
display.clear()
|
||||
|
||||
if not gaps:
|
||||
sys.exit("no frames were drawn -- the scroll never started")
|
||||
|
||||
gaps.sort()
|
||||
expected = 1000.0 * resolved.frame_hold / (resolved.crisp.refresh_hz
|
||||
if resolved.crisp else 100.0)
|
||||
print("\n%d frames in %.1fs -> %.1f fps" % (
|
||||
drawn, args.seconds, drawn / args.seconds))
|
||||
print("frame gap median %.2fms p95 %.2fms max %.2fms (hold implies %.2fms)"
|
||||
% (statistics.median(gaps), gaps[int(len(gaps) * 0.95)], gaps[-1], expected))
|
||||
|
||||
holds = {h for on, h in spy.calls if on}
|
||||
print("published while scrolling: frame_hold=%s" % (sorted(holds) or "NOTHING"))
|
||||
print("released on clear: %s" % any(not on for on, _ in spy.calls))
|
||||
print("display manager hold now: %d (1 means released)"
|
||||
% getattr(display_manager, "_frame_hold", -1))
|
||||
|
||||
if not holds:
|
||||
sys.exit("FAIL: the scroll never told the core it was scrolling")
|
||||
if holds != {resolved.frame_hold}:
|
||||
sys.exit("FAIL: published %s but resolved %d" % (holds, resolved.frame_hold))
|
||||
print("\nOK: the resolved hold reached the panel and was released after")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,8 +9,6 @@ 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
|
||||
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
|
||||
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
|
||||
- **`pixlet_config_editor.sh`** - Opens Pixlet's own config UI for one installed Starlark app
|
||||
- **`apply_dns_single_request.sh`** - Adds `options single-request` to the resolver (run by `ledmatrix-dns-fix.service`)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -27,31 +25,3 @@ This script is typically called by the systemd service (`ledmatrix-web.service`)
|
||||
### WiFi Monitor Daemon
|
||||
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.
|
||||
|
||||
|
||||
### Pixlet Config Editor
|
||||
Run it when you want Pixlet's own config form for a Starlark app -- live
|
||||
render preview, cascading dropdowns -- rather than the LEDMatrix one.
|
||||
|
||||
```bash
|
||||
./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||
./scripts/utils/pixlet_config_editor.sh penndot_signs # edit, on localhost:8080
|
||||
```
|
||||
|
||||
Deliberately not a service. It stops the display for the length of the
|
||||
session and `pixlet serve` listens with no authentication, so it should only
|
||||
be running while you are actually editing. It backs the config up first and
|
||||
restarts the display on exit, however it exits.
|
||||
|
||||
It binds loopback only, with no flag to change that: anything that can reach
|
||||
`pixlet serve` can rewrite the app's config, and a printed warning is not
|
||||
access control. To edit from another machine, forward the port -- SSH does the
|
||||
authenticating and nothing is left listening on the LAN:
|
||||
|
||||
```bash
|
||||
ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||
```
|
||||
|
||||
### Apply DNS Single-Request Fix
|
||||
Installed and run by `ledmatrix-dns-fix.service`; see `systemd/README.md`.
|
||||
Safe to run by hand (`sudo ./scripts/utils/apply_dns_single_request.sh`) and
|
||||
idempotent.
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Add `options single-request` to the system resolver configuration.
|
||||
#
|
||||
# glibc's getaddrinfo() sends the A and AAAA queries for a name in
|
||||
# parallel on one socket. Some routers answer the A query and drop the
|
||||
# AAAA one, so the resolver waits out its full timeout -- about five
|
||||
# seconds -- before returning an address that was already available.
|
||||
# Disabling IPv6 in the kernel does not help: the resolver still asks.
|
||||
#
|
||||
# `single-request` makes it send the two queries one after the other,
|
||||
# which those routers answer correctly. Anything on the matrix that
|
||||
# calls an external API pays that five seconds per lookup otherwise, and
|
||||
# a Starlark app with a render timeout will simply fail instead.
|
||||
#
|
||||
# Idempotent, and safe to run on a machine that does not need it. Run by
|
||||
# ledmatrix-dns-fix.service on every boot, because whatever manages
|
||||
# resolv.conf regenerates it and drops the option again.
|
||||
#
|
||||
# Usage: sudo ./scripts/utils/apply_dns_single_request.sh
|
||||
|
||||
set -eu
|
||||
|
||||
OPTION="options single-request"
|
||||
RESOLVCONF_TAIL="/etc/resolvconf/resolv.conf.d/tail"
|
||||
RESOLV_CONF="/etc/resolv.conf"
|
||||
|
||||
log() { echo "[dns-single-request] $*"; }
|
||||
|
||||
already_applied() {
|
||||
grep -qs "^${OPTION}\$" "$1"
|
||||
}
|
||||
|
||||
# resolvconf regenerates /etc/resolv.conf from these fragments, so the
|
||||
# tail file is the only place an addition survives. Prefer it when the
|
||||
# directory exists, whether or not resolvconf has run yet.
|
||||
if [ -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
||||
if already_applied "$RESOLVCONF_TAIL"; then
|
||||
log "already present in $RESOLVCONF_TAIL"
|
||||
else
|
||||
echo "$OPTION" >> "$RESOLVCONF_TAIL"
|
||||
log "added to $RESOLVCONF_TAIL"
|
||||
fi
|
||||
# Only a missing resolvconf is ignorable. If it is present and the
|
||||
# regeneration fails, /etc/resolv.conf still lacks the option, and
|
||||
# reporting success would be a lie.
|
||||
if command -v resolvconf >/dev/null 2>&1; then
|
||||
if ! resolvconf -u; then
|
||||
log "resolvconf -u failed; $RESOLV_CONF was not regenerated"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# systemd-resolved owns its stub file and rewrites anything appended to it,
|
||||
# and `single-request` is a glibc resolv.conf option with no resolved.conf
|
||||
# equivalent -- so there is nothing this script can do here. Exit non-zero:
|
||||
# the unit would otherwise record success while the workaround is inactive,
|
||||
# which is the failure mode this whole script exists to avoid.
|
||||
if [ -L "$RESOLV_CONF" ] && readlink -f "$RESOLV_CONF" | grep -q "systemd"; then
|
||||
log "$RESOLV_CONF is managed by systemd-resolved."
|
||||
log "'options single-request' is a glibc resolv.conf option and has no"
|
||||
log "resolved.conf equivalent, so it cannot be applied on this host."
|
||||
log "If external API calls are slow, the workaround is to stop using the"
|
||||
log "systemd-resolved stub (see 'man systemd-resolved', NSS/resolv.conf modes)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if already_applied "$RESOLV_CONF"; then
|
||||
log "already present in $RESOLV_CONF"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -w "$RESOLV_CONF" ] && [ -e "$RESOLV_CONF" ]; then
|
||||
log "cannot write $RESOLV_CONF (run with sudo?)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A NetworkManager-generated resolv.conf is regenerated on every connection
|
||||
# change, not only at boot -- and this unit is oneshot with RemainAfterExit,
|
||||
# so it will not re-run within the same boot to put the option back. Say so
|
||||
# rather than implying the fix is permanent. Nothing is silently swallowed:
|
||||
# the append below still happens and still works until the next renewal.
|
||||
if grep -qs "Generated by NetworkManager" "$RESOLV_CONF" \
|
||||
&& [ ! -d "$(dirname "$RESOLVCONF_TAIL")" ]; then
|
||||
log "NOTE: $RESOLV_CONF is generated by NetworkManager and has no"
|
||||
log "resolvconf tail directory to write to. The option is being added, but"
|
||||
log "NetworkManager will drop it on the next connection renewal, and this"
|
||||
log "unit does not run again until the next boot. If lookups go slow again"
|
||||
log "before a reboot, re-run this script."
|
||||
fi
|
||||
|
||||
echo "$OPTION" >> "$RESOLV_CONF"
|
||||
log "added to $RESOLV_CONF"
|
||||
@@ -1,315 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that an automatic LEDMatrix update left the device working; roll it back if not.
|
||||
|
||||
Started by the web interface's weekly updater (web_interface/auto_update.py)
|
||||
through ledmatrix-update-verify.service, right after it pulls new code. It has
|
||||
to run outside the web service: checking the update means restarting that
|
||||
service, and a check running inside it would be killed by its own restart.
|
||||
|
||||
It must not be the code it is checking, either. The updater copies this file
|
||||
to data/auto_update_verifier.py *before* pulling and the unit runs that copy,
|
||||
so a broken update cannot break its own rollback. Standard library only for
|
||||
the same reason: the rollback cannot depend on packages the update changed.
|
||||
|
||||
The updater leaves data/auto_update_pending.json:
|
||||
|
||||
{"status": "pending", "old_head": ..., "new_head": ...,
|
||||
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
|
||||
|
||||
This moves its status to "verifying" and then to one of "success",
|
||||
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
|
||||
The web interface reports that outcome and raises a banner for anything but
|
||||
success.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import sys
|
||||
from collections import namedtuple
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PENDING_NAME = 'auto_update_pending.json'
|
||||
REQUIREMENT_FILES = ('requirements.txt', 'web_interface/requirements.txt')
|
||||
WEB_HEALTH_URL = 'http://127.0.0.1:5000/api/v3/system/version'
|
||||
#: How long the services get to come up after a restart...
|
||||
HEALTH_TIMEOUT_SECONDS = 180
|
||||
#: ...and how long they must then stay up. Restart=on-failure makes a crash
|
||||
#: loop look healthy between attempts, so a single "is-active" proves nothing.
|
||||
STABLE_SECONDS = 45
|
||||
POLL_SECONDS = 5
|
||||
WEB_CHECK_TIMEOUT_SECONDS = 5
|
||||
SYSTEMCTL_QUERY_TIMEOUT_SECONDS = 10
|
||||
RESTART_TIMEOUT_SECONDS = 90
|
||||
GIT_TIMEOUT_SECONDS = 60
|
||||
GIT_RESET_TIMEOUT_SECONDS = 120
|
||||
PIP_TIMEOUT_SECONDS = 600
|
||||
#: All of a rollback's dependency reinstalls together. A pip that times out
|
||||
#: or fails is not retried: systemd stops this unit at TimeoutStartSec, and a
|
||||
#: rollback killed half-way leaves the update reported as still verifying.
|
||||
PIP_BUDGET_SECONDS = 600
|
||||
#: sudoers matches the exact command line, so bash is named by path, the same
|
||||
#: candidates src/common/permission_utils.install_requirements_file tries...
|
||||
BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
|
||||
#: ...and, like it, moves to the next one only when sudo refused the command
|
||||
#: line (permission_utils.SUDO_REFUSAL_PHRASES), never after pip itself ran.
|
||||
SUDO_REFUSAL_PHRASES = ('a password is required', 'is not allowed to run', 'no tty present')
|
||||
|
||||
#: The longest one health check can take: restart and wait, roll back
|
||||
#: (diff, reset, reinstalls), restart and wait again. A wait's last poll can
|
||||
#: start just before its deadline and run every query to its timeout.
|
||||
_WAIT_WORST_SECONDS = (HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS + WEB_CHECK_TIMEOUT_SECONDS
|
||||
+ 2 * SYSTEMCTL_QUERY_TIMEOUT_SECONDS + POLL_SECONDS)
|
||||
WORST_CASE_SECONDS = (2 * (2 * RESTART_TIMEOUT_SECONDS + _WAIT_WORST_SECONDS)
|
||||
+ GIT_TIMEOUT_SECONDS + GIT_RESET_TIMEOUT_SECONDS + PIP_BUDGET_SECONDS)
|
||||
|
||||
#: What a command that could not run at all reports: its callers only read
|
||||
#: these three fields, the same ones a completed subprocess has.
|
||||
_Failed = namedtuple('_Failed', 'returncode stdout stderr')
|
||||
|
||||
|
||||
def pending_path(project_root):
|
||||
return Path(project_root) / 'data' / PENDING_NAME
|
||||
|
||||
|
||||
def read_pending(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def write_pending(path, data):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.auto_update_pending_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _web_responds(url=WEB_HEALTH_URL):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=WEB_CHECK_TIMEOUT_SECONDS) as resp: # nosec B310 - fixed loopback URL
|
||||
return resp.status == 200
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _short(sha):
|
||||
return (sha or 'unknown')[:7]
|
||||
|
||||
|
||||
class Verifier:
|
||||
def __init__(self, project_root, run=subprocess.run, sleep=time.sleep,
|
||||
clock=time.monotonic, web_responds=_web_responds, log=None):
|
||||
self.project_root = Path(project_root)
|
||||
self.pending_file = pending_path(project_root)
|
||||
self.run = run
|
||||
self.sleep = sleep
|
||||
self.clock = clock
|
||||
self.web_responds = web_responds
|
||||
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
|
||||
|
||||
def _run(self, args, timeout=GIT_TIMEOUT_SECONDS):
|
||||
try:
|
||||
return self.run(args, cwd=str(self.project_root), capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return _Failed(returncode=1, stdout='', stderr=str(e))
|
||||
|
||||
# -- services ---------------------------------------------------------
|
||||
|
||||
def service_active(self, unit):
|
||||
return self._run(['systemctl', 'is-active', unit],
|
||||
timeout=SYSTEMCTL_QUERY_TIMEOUT_SECONDS).stdout.strip() == 'active'
|
||||
|
||||
def restart_count(self, unit):
|
||||
out = self._run(['systemctl', 'show', '-p', 'NRestarts', '--value', unit],
|
||||
timeout=SYSTEMCTL_QUERY_TIMEOUT_SECONDS).stdout.strip()
|
||||
return int(out) if out.isdigit() else None
|
||||
|
||||
def restart(self, unit):
|
||||
result = self._run(['sudo', '-n', 'systemctl', 'restart', f'{unit}.service'],
|
||||
timeout=RESTART_TIMEOUT_SECONDS)
|
||||
if result.returncode != 0:
|
||||
self.log(f'restarting {unit} failed: {(result.stderr or "").strip()}')
|
||||
return result.returncode == 0
|
||||
|
||||
def restart_services(self, display):
|
||||
"""Restart what should be running. False if any restart command failed."""
|
||||
ok = True
|
||||
# A display the user had stopped stays stopped.
|
||||
if display:
|
||||
ok = self.restart('ledmatrix') and ok
|
||||
return self.restart('ledmatrix-web') and ok
|
||||
|
||||
def wait_healthy(self, display):
|
||||
"""None once the services are up and stay up, else what went wrong."""
|
||||
deadline = self.clock() + HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS
|
||||
healthy_since = baseline = None
|
||||
web = disp = False
|
||||
count_known = True
|
||||
while self.clock() < deadline:
|
||||
web = self.web_responds()
|
||||
disp = self.service_active('ledmatrix') if display else True
|
||||
restarts = self.restart_count('ledmatrix') if display else None
|
||||
# Without a restart count a crash loop looks healthy between
|
||||
# attempts, so an unreadable count never counts as stable.
|
||||
count_known = not display or restarts is not None
|
||||
if web and disp and count_known and (healthy_since is None or restarts == baseline):
|
||||
if healthy_since is None:
|
||||
healthy_since, baseline = self.clock(), restarts
|
||||
elif self.clock() - healthy_since >= STABLE_SECONDS:
|
||||
return None
|
||||
else:
|
||||
healthy_since = None
|
||||
self.sleep(POLL_SECONDS)
|
||||
problems = []
|
||||
if not web:
|
||||
problems.append('the web interface did not respond')
|
||||
if not disp:
|
||||
problems.append('the display service did not stay running')
|
||||
if web and disp and not count_known:
|
||||
problems.append("the display service's restart count could not be read")
|
||||
return '; '.join(problems) or 'the display service kept restarting'
|
||||
|
||||
# -- rollback ---------------------------------------------------------
|
||||
|
||||
def changed_requirements(self, old, new):
|
||||
result = self._run(['git', 'diff', '--name-only', old, new])
|
||||
# If the diff is unavailable, reinstall both rather than guess.
|
||||
changed = set(result.stdout.split()) if result.returncode == 0 else set(REQUIREMENT_FILES)
|
||||
return [rel for rel in REQUIREMENT_FILES if rel in changed]
|
||||
|
||||
def install_requirements(self, rel, deadline=None):
|
||||
"""Install one requirements file through the root wrapper, by ``deadline``."""
|
||||
wrapper = self.project_root / 'scripts' / 'fix_perms' / 'safe_pip_install.sh'
|
||||
req = self.project_root / rel
|
||||
if not req.exists():
|
||||
return True
|
||||
for bash in BASH_CANDIDATES:
|
||||
timeout = PIP_TIMEOUT_SECONDS
|
||||
if deadline is not None:
|
||||
timeout = min(timeout, deadline - self.clock())
|
||||
if timeout <= 0:
|
||||
self.log(f'no time left to reinstall {rel}')
|
||||
return False
|
||||
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)], timeout=timeout)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
# Only a refused command line is worth the next candidate. A pip
|
||||
# that ran and failed, or timed out, would just do it again.
|
||||
if not any(phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
|
||||
# Not pip's output: it can echo an index URL's credentials.
|
||||
self.log(f'reinstalling {rel} failed (exit {result.returncode})')
|
||||
return False
|
||||
return False
|
||||
|
||||
def rollback(self, pending):
|
||||
"""Reset to the previous commit and its dependencies. Returns (ok, detail)."""
|
||||
old, new = pending.get('old_head'), pending.get('new_head')
|
||||
if not old:
|
||||
return False, 'the commit to roll back to is unknown'
|
||||
requirements = self.changed_requirements(old, new) if new else list(REQUIREMENT_FILES)
|
||||
# --hard: the updater refuses to run with local edits to tracked core
|
||||
# files (web_interface/auto_update.local_changes), so outside the
|
||||
# plugin folders the only thing this discards is the update. Edits
|
||||
# under plugins/ and plugin-repos/, which that check leaves to the
|
||||
# pull's --autostash, are reset along with it.
|
||||
result = self._run(['git', 'reset', '--hard', old], timeout=GIT_RESET_TIMEOUT_SECONDS)
|
||||
if result.returncode != 0:
|
||||
return False, (f'"git reset --hard {old}" failed: '
|
||||
f'{(result.stderr or result.stdout or "").strip()}')
|
||||
deadline = self.clock() + PIP_BUDGET_SECONDS
|
||||
failed = [rel for rel in requirements if not self.install_requirements(rel, deadline)]
|
||||
if failed:
|
||||
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
|
||||
+ ' failed; run Install Base Requirements from the Tools tab')
|
||||
return True, ''
|
||||
|
||||
# -- the check itself -------------------------------------------------
|
||||
|
||||
def _finish(self, pending, status, reason=None, detail=None):
|
||||
pending.update({'status': status, 'reason': reason, 'detail': detail or None,
|
||||
'finished_at': time.time()})
|
||||
write_pending(self.pending_file, pending)
|
||||
self.log(' '.join(p for p in (status, reason or '', detail or '') if p))
|
||||
|
||||
def verify(self):
|
||||
pending = read_pending(self.pending_file)
|
||||
if not pending or pending.get('status') != 'pending':
|
||||
self.log('no update is waiting to be verified')
|
||||
return 0
|
||||
pending['status'] = 'verifying'
|
||||
write_pending(self.pending_file, pending)
|
||||
|
||||
display = bool(pending.get('display_was_active'))
|
||||
dependency_failures = pending.get('dependency_failures') or []
|
||||
if dependency_failures:
|
||||
# Never restart onto code whose packages did not install.
|
||||
reason = 'installing its dependencies failed (' + ', '.join(dependency_failures) + ')'
|
||||
elif not self.restart_services(display):
|
||||
# The old process may still be answering; checking it would pass
|
||||
# an update that never started.
|
||||
reason = 'restarting the services failed'
|
||||
else:
|
||||
reason = self.wait_healthy(display)
|
||||
if reason is None:
|
||||
self._finish(pending, 'success')
|
||||
return 0
|
||||
|
||||
self.log(f'update to {_short(pending.get("new_head"))} is unhealthy ({reason}); '
|
||||
f'rolling back to {_short(pending.get("old_head"))}')
|
||||
ok, detail = self.rollback(pending)
|
||||
if not ok:
|
||||
self._finish(pending, 'rollback_failed', reason, detail)
|
||||
return 1
|
||||
still = (self.wait_healthy(display) if self.restart_services(display)
|
||||
else 'restarting the services failed')
|
||||
if still:
|
||||
self._finish(pending, 'rollback_failed', reason,
|
||||
f'still unhealthy after rolling back: {still}'
|
||||
+ (f'; {detail}' if detail else ''))
|
||||
return 1
|
||||
self._finish(pending, 'rolled_back', reason, detail)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
print('usage: auto_update_verify.py PROJECT_ROOT', file=sys.stderr)
|
||||
return 2
|
||||
verifier = Verifier(Path(argv[1]))
|
||||
try:
|
||||
return verifier.verify()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
# Whatever happened, the web interface must not be left thinking the
|
||||
# check is still running.
|
||||
try:
|
||||
pending = read_pending(verifier.pending_file) or {}
|
||||
if pending.get('status') in ('pending', 'verifying'):
|
||||
pending.update({'status': 'rollback_failed', 'reason': 'the health check crashed',
|
||||
'detail': str(e), 'finished_at': time.time()})
|
||||
write_pending(verifier.pending_file, pending)
|
||||
except OSError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
Executable → Regular
Executable → Regular
@@ -1,223 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Edit an installed Starlark app's config in Pixlet's own config UI.
|
||||
#
|
||||
# `pixlet serve` runs the app for real, so its form has working cascading
|
||||
# dropdowns and option lists fetched live -- useful for an app whose choices
|
||||
# only exist at runtime, or when you want to see the render change as you
|
||||
# type. The LEDMatrix config form now reads the same runtime schema (see
|
||||
# PixletRenderer.extract_schema_via_pixlet), so reach for this when you want
|
||||
# Pixlet's live preview, not because the normal form is missing options.
|
||||
#
|
||||
# Deliberately a script you run and then Ctrl+C, not a service: it stops the
|
||||
# display for the length of the session, and `pixlet serve` listens on a port
|
||||
# with no authentication. Nothing here should be listening when you are not
|
||||
# actually editing.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/utils/pixlet_config_editor.sh # list installed apps
|
||||
# ./scripts/utils/pixlet_config_editor.sh <app_id> # edit
|
||||
#
|
||||
# Binds the LAN by default, matching the web interface, which already serves
|
||||
# 0.0.0.0:5000 with no authentication -- anything that can reach this can
|
||||
# already reconfigure the display there. `pixlet serve` has no authentication
|
||||
# either, so treat both the same way: fine on a home network, not on an open
|
||||
# one. Override the bind and the session length with:
|
||||
#
|
||||
# PIXLET_EDITOR_HOST=127.0.0.1 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||
# PIXLET_EDITOR_TIMEOUT=600 ./scripts/utils/pixlet_config_editor.sh <app>
|
||||
#
|
||||
# For loopback-only editing from another machine, forward the port instead:
|
||||
#
|
||||
# ssh -L 8080:localhost:8080 pi@ledpi.local
|
||||
#
|
||||
# The session always ends by itself after PIXLET_EDITOR_TIMEOUT seconds
|
||||
# (default 30 minutes). The display is stopped while editing, so a session
|
||||
# left open would otherwise leave the panel dark indefinitely -- the timeout
|
||||
# is what makes it safe to start one from the web interface.
|
||||
|
||||
set -eu
|
||||
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
APPS_DIR="$PROJECT_ROOT_DIR/starlark-apps"
|
||||
PORT="${PIXLET_EDITOR_PORT:-8080}"
|
||||
# LAN by default; see the header for why, and how to force loopback.
|
||||
BIND_HOST="${PIXLET_EDITOR_HOST:-0.0.0.0}"
|
||||
# Hard stop, so the display cannot be left off by a forgotten session.
|
||||
EDITOR_TIMEOUT="${PIXLET_EDITOR_TIMEOUT:-1800}"
|
||||
|
||||
APP_ID="${1:-}"
|
||||
|
||||
list_apps() {
|
||||
if [ -d "$APPS_DIR" ]; then
|
||||
find "$APPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf ' %f\n' 2>/dev/null | sort
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "$APP_ID" ]; then
|
||||
echo "Usage: $0 <app_id>"
|
||||
echo ""
|
||||
echo "Installed apps:"
|
||||
list_apps || true
|
||||
[ -n "$(list_apps)" ] || echo " (none found in $APPS_DIR)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR="$APPS_DIR/$APP_ID"
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
echo "No such app: $APP_ID"
|
||||
echo ""
|
||||
echo "Installed apps:"
|
||||
list_apps
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STAR_FILE=$(find "$APP_DIR" -maxdepth 1 -iname "*.star" | head -1)
|
||||
if [ -z "$STAR_FILE" ]; then
|
||||
echo "No .star file found in $APP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Same search order the plugin itself uses: the bundled binary for this
|
||||
# architecture first, then PATH -- so this works on an install that never put
|
||||
# pixlet on PATH.
|
||||
find_pixlet() {
|
||||
local arch bundled
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Linux-aarch64|Linux-arm64) arch="pixlet-linux-arm64" ;;
|
||||
Linux-x86_64|Linux-amd64) arch="pixlet-linux-amd64" ;;
|
||||
Darwin-arm64) arch="pixlet-darwin-arm64" ;;
|
||||
Darwin-x86_64) arch="pixlet-darwin-amd64" ;;
|
||||
*) arch="" ;;
|
||||
esac
|
||||
bundled="$PROJECT_ROOT_DIR/bin/pixlet/$arch"
|
||||
if [ -n "$arch" ] && [ -x "$bundled" ]; then
|
||||
echo "$bundled"
|
||||
return 0
|
||||
fi
|
||||
command -v pixlet 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
PIXLET_BIN=$(find_pixlet) || {
|
||||
echo "Pixlet not found. Install it with:"
|
||||
echo " ./scripts/download_pixlet.sh"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# find_pixlet supports Darwin, so this script has to as well. macOS ships no
|
||||
# timeout(1); GNU coreutils installs it as gtimeout. Resolve whichever exists
|
||||
# and fail here with instructions rather than at the invocation far below,
|
||||
# where the failure would land after the display has already been stopped.
|
||||
find_timeout() {
|
||||
local candidate
|
||||
for candidate in timeout gtimeout; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
command -v "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
TIMEOUT_BIN=$(find_timeout) || {
|
||||
echo "Neither 'timeout' nor 'gtimeout' was found on PATH."
|
||||
echo "This script needs one to bound the editing session."
|
||||
echo "On macOS, install GNU coreutils:"
|
||||
echo " brew install coreutils"
|
||||
exit 1
|
||||
}
|
||||
|
||||
CONFIG_FILE="$APP_DIR/config.json"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
cp "$CONFIG_FILE" "$CONFIG_FILE.backup"
|
||||
echo "Backed up existing config to $CONFIG_FILE.backup"
|
||||
else
|
||||
echo "{}" > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
DISPLAY_WAS_RUNNING=false
|
||||
if systemctl is-active --quiet ledmatrix 2>/dev/null; then
|
||||
DISPLAY_WAS_RUNNING=true
|
||||
fi
|
||||
|
||||
# Restart the display however this exits -- Ctrl+C, an error, or pixlet
|
||||
# dying on its own. Leaving the panel dark because the editor crashed is the
|
||||
# failure worth guarding against.
|
||||
cleanup() {
|
||||
echo ""
|
||||
# Kill the serve child explicitly. `timeout` is started with --foreground so
|
||||
# it shares this script's process group (without that it makes its own, and
|
||||
# a group signal aimed at this script would orphan pixlet with the port
|
||||
# still bound). Belt and braces: signal the recorded pid too, because a
|
||||
# group signal only reaches it while the group is shared.
|
||||
if [ -n "${SERVE_PID:-}" ] && kill -0 "$SERVE_PID" 2>/dev/null; then
|
||||
kill -TERM "$SERVE_PID" 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "$SERVE_PID" 2>/dev/null || break
|
||||
sleep 0.3
|
||||
done
|
||||
kill -KILL "$SERVE_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||
echo "Restarting the display service..."
|
||||
sudo systemctl restart ledmatrix || echo "⚠ Could not restart ledmatrix - do it by hand"
|
||||
fi
|
||||
echo "Your config as it was before this session: $CONFIG_FILE.backup"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ "$DISPLAY_WAS_RUNNING" = true ]; then
|
||||
echo "Stopping the display service so it does not read config.json mid-write..."
|
||||
sudo systemctl stop ledmatrix
|
||||
fi
|
||||
|
||||
# Wildcard, loopback and an explicit interface address are three different
|
||||
# cases. Collapsing the last two into "localhost" printed a URL pointing at the
|
||||
# user's own machine whenever PIXLET_EDITOR_HOST named a LAN address.
|
||||
case "$BIND_HOST" in
|
||||
0.0.0.0|::|"") REACH_HOST="$(hostname).local" ;;
|
||||
127.0.0.1|::1|localhost) REACH_HOST="localhost" ;;
|
||||
*) REACH_HOST="$BIND_HOST" ;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "Editing: $APP_ID"
|
||||
echo "App file: $STAR_FILE"
|
||||
echo "URL: http://$REACH_HOST:$PORT/"
|
||||
echo ""
|
||||
if [ "$BIND_HOST" = "0.0.0.0" ]; then
|
||||
echo "Reachable on the LAN, and pixlet serve has no authentication -- the"
|
||||
echo "same footing as the web interface on port 5000. Set"
|
||||
echo "PIXLET_EDITOR_HOST=127.0.0.1 to keep it to this machine."
|
||||
else
|
||||
echo "Listening on $BIND_HOST only. From another machine, forward the port:"
|
||||
echo " ssh -L $PORT:localhost:$PORT $(whoami)@$(hostname)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Changes save straight to the real config as you make them."
|
||||
echo "Press Ctrl+C when finished - the display restarts automatically."
|
||||
echo "This session stops on its own after ${EDITOR_TIMEOUT}s regardless."
|
||||
echo ""
|
||||
|
||||
cd "$APP_DIR"
|
||||
# `timeout` owns the hard stop rather than the caller: the trap above restarts
|
||||
# the display however this exits, so a session that outlives the person who
|
||||
# started it still gives the panel back. Exit 124 is timeout's own code for
|
||||
# "expired", which is a normal end here, not a failure.
|
||||
# --foreground: stay in this script's process group so one signal reaches the
|
||||
# whole session. Backgrounded + `wait` so the EXIT trap can run while the child
|
||||
# is still alive; a foreground child would leave bash waiting on it instead.
|
||||
"$TIMEOUT_BIN" --foreground "$EDITOR_TIMEOUT" "$PIXLET_BIN" serve "$(basename "$STAR_FILE")" \
|
||||
--host "$BIND_HOST" \
|
||||
--port "$PORT" \
|
||||
--no-browser \
|
||||
--saveconfig "$CONFIG_FILE" &
|
||||
SERVE_PID=$!
|
||||
|
||||
status=0
|
||||
wait "$SERVE_PID" || status=$?
|
||||
if [ "$status" -eq 124 ]; then
|
||||
echo "Session reached its ${EDITOR_TIMEOUT}s limit."
|
||||
status=0
|
||||
fi
|
||||
exit "$status"
|
||||
@@ -74,41 +74,23 @@ def install_dependencies():
|
||||
print(f"Failed to install dependencies: {e}")
|
||||
return False
|
||||
|
||||
#: String spellings that turn autostart OFF. Anything else -- including the key
|
||||
#: being absent entirely -- leaves it on.
|
||||
DISABLED_STRINGS = ("off", "false", "no", "0")
|
||||
|
||||
|
||||
def autostart_enabled(config_data):
|
||||
"""Whether to bring the web interface up. Defaults to True.
|
||||
|
||||
config.template.json and first_time_install.sh both ship
|
||||
``web_display_autostart`` as true, so a config that lacks the key is an
|
||||
older or hand-edited one rather than a request to stay down. Defaulting to
|
||||
False meant any such config silently got no web interface -- and because
|
||||
the "not starting" path exits 0, systemd reported the unit as successfully
|
||||
started while nothing was listening. Only an explicit false/off disables it.
|
||||
"""
|
||||
value = config_data.get("web_display_autostart", True)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() not in DISABLED_STRINGS
|
||||
return bool(value)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
with open(CONFIG_FILE, 'r') as f:
|
||||
config_data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
# The web interface is how a config gets created and repaired, so a
|
||||
# missing one is the case where the user needs it most.
|
||||
print(f"Config file {CONFIG_FILE} not found. Starting the web interface so it can be configured.")
|
||||
config_data = {}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Error reading config file {CONFIG_FILE}: {e}. Starting the web interface anyway so the config can be repaired.")
|
||||
config_data = {}
|
||||
print(f"Config file {CONFIG_FILE} not found. Web interface will not start.")
|
||||
sys.exit(0) # Exit gracefully, don't start
|
||||
except Exception as e:
|
||||
print(f"Error reading config file {CONFIG_FILE}: {e}. Web interface will not start.")
|
||||
sys.exit(1) # Exit with error, service might restart depending on config
|
||||
|
||||
if autostart_enabled(config_data):
|
||||
autostart_enabled = config_data.get("web_display_autostart", False)
|
||||
|
||||
# Handle both boolean True and string "on"/"true" values
|
||||
is_enabled = (autostart_enabled is True) or (isinstance(autostart_enabled, str) and autostart_enabled.lower() in ("on", "true", "yes", "1"))
|
||||
|
||||
if is_enabled:
|
||||
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
|
||||
|
||||
# Only install dependencies if not already done during first-time setup
|
||||
@@ -134,7 +116,7 @@ def main():
|
||||
print(f"Failed to exec web interface: {e}")
|
||||
sys.exit(1) # Failed to start
|
||||
else:
|
||||
print("Configuration 'web_display_autostart' is explicitly disabled. Web interface will not be started.")
|
||||
print("Configuration 'web_display_autostart' is false or not set. Web interface will not be started.")
|
||||
sys.exit(0) # Exit gracefully, service considered successful
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -27,8 +27,6 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||
|
||||
from src.common.font_layout import load_truetype # noqa: E402
|
||||
|
||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||
MODES = ("live", "recent", "upcoming")
|
||||
SPORTS = ("baseball", "basketball", "football", "hockey")
|
||||
@@ -54,12 +52,12 @@ class FixtureHost:
|
||||
try:
|
||||
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
||||
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
||||
fonts['score'] = load_truetype(press, 10)
|
||||
fonts['time'] = load_truetype(press, 8)
|
||||
fonts['team'] = load_truetype(press, 8)
|
||||
fonts['status'] = load_truetype(small, 6)
|
||||
fonts['detail'] = load_truetype(small, 6)
|
||||
fonts['rank'] = load_truetype(press, 10)
|
||||
fonts['score'] = ImageFont.truetype(press, 10)
|
||||
fonts['time'] = ImageFont.truetype(press, 8)
|
||||
fonts['team'] = ImageFont.truetype(press, 8)
|
||||
fonts['status'] = ImageFont.truetype(small, 6)
|
||||
fonts['detail'] = ImageFont.truetype(small, 6)
|
||||
fonts['rank'] = ImageFont.truetype(press, 10)
|
||||
except IOError:
|
||||
default = ImageFont.load_default()
|
||||
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
||||
|
||||
@@ -121,24 +121,19 @@ fi
|
||||
echo ""
|
||||
|
||||
# 5. Check web interface
|
||||
# ledmatrix-web.service runs scripts/utils/start_web_conditionally.py, which
|
||||
# starts web_interface/start.py; the app binds port 5000 (web_interface/start.py).
|
||||
echo "=== Web Interface ==="
|
||||
WEB_PORT=5000
|
||||
for web_file in scripts/utils/start_web_conditionally.py web_interface/start.py web_interface/app.py; do
|
||||
if [ -f "$PROJECT_ROOT/$web_file" ]; then
|
||||
check_pass "$web_file exists"
|
||||
else
|
||||
check_fail "$web_file is missing"
|
||||
fi
|
||||
done
|
||||
if [ -f "$PROJECT_ROOT/web_interface_v2.py" ]; then
|
||||
check_pass "web_interface_v2.py exists"
|
||||
else
|
||||
check_fail "web_interface_v2.py is missing"
|
||||
fi
|
||||
|
||||
# Check if web service is listening
|
||||
if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
|
||||
if netstat -tuln 2>/dev/null | grep -qE ":${WEB_PORT}([^0-9]|$)" || ss -tuln 2>/dev/null | grep -qE ":${WEB_PORT}([^0-9]|$)"; then
|
||||
check_pass "Web interface is listening on port $WEB_PORT"
|
||||
if netstat -tuln 2>/dev/null | grep -q ":5001" || ss -tuln 2>/dev/null | grep -q ":5001"; then
|
||||
check_pass "Web interface is listening on port 5001"
|
||||
else
|
||||
check_warn "Web service is running but port $WEB_PORT may not be listening"
|
||||
check_warn "Web service is running but port 5001 may not be listening"
|
||||
fi
|
||||
else
|
||||
check_warn "Web service is not running (cannot check port)"
|
||||
@@ -209,7 +204,7 @@ if [ "$ALL_PASSED" = true ]; then
|
||||
echo -e "${GREEN}Installation verification PASSED${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):$WEB_PORT"
|
||||
echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):5001"
|
||||
echo "2. Check service status: sudo systemctl status ledmatrix.service"
|
||||
echo "3. View logs: journalctl -u ledmatrix.service -f"
|
||||
exit 0
|
||||
|
||||
+21
-25
@@ -8,10 +8,6 @@ echo "Web UI Verification"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# The web interface binds port 5000 (web_interface/start.py).
|
||||
WEB_PORT=5000
|
||||
PORT_PATTERN=":${WEB_PORT}([^0-9]|$)"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
@@ -36,25 +32,25 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. Check if port $WEB_PORT is listening
|
||||
echo "2. Checking if port $WEB_PORT is listening..."
|
||||
# 2. Check if port 5001 is listening
|
||||
echo "2. Checking if port 5001 is listening..."
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
|
||||
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
|
||||
if ss -tuln 2>/dev/null | grep -q ":5001"; then
|
||||
echo -e "${GREEN}✓${NC} Port 5001 is listening"
|
||||
echo ""
|
||||
echo "Active connections on port $WEB_PORT:"
|
||||
ss -tuln | grep -E "$PORT_PATTERN"
|
||||
echo "Active connections on port 5001:"
|
||||
ss -tuln | grep ":5001"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
|
||||
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
|
||||
fi
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
if netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
|
||||
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
|
||||
if netstat -tuln 2>/dev/null | grep -q ":5001"; then
|
||||
echo -e "${GREEN}✓${NC} Port 5001 is listening"
|
||||
echo ""
|
||||
echo "Active connections on port $WEB_PORT:"
|
||||
netstat -tuln | grep -E "$PORT_PATTERN"
|
||||
echo "Active connections on port 5001:"
|
||||
netstat -tuln | grep ":5001"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
|
||||
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Cannot check port (ss/netstat not available)"
|
||||
@@ -63,15 +59,15 @@ echo ""
|
||||
|
||||
# 3. Test HTTP connection
|
||||
echo "3. Testing HTTP connection..."
|
||||
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT > /dev/null 2>&1; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT 2>/dev/null)
|
||||
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
|
||||
echo -e "${GREEN}✓${NC} Web interface is responding (HTTP $HTTP_CODE)"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Web interface responded with HTTP $HTTP_CODE"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗${NC} Cannot connect to web interface on port $WEB_PORT"
|
||||
echo -e "${RED}✗${NC} Cannot connect to web interface on port 5001"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -83,7 +79,7 @@ if [ -n "$IP_ADDRESSES" ]; then
|
||||
echo ""
|
||||
echo "Access web interface at:"
|
||||
for ip in $IP_ADDRESSES; do
|
||||
echo " http://$ip:$WEB_PORT"
|
||||
echo " http://$ip:5001"
|
||||
done
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Could not determine IP address"
|
||||
@@ -122,12 +118,12 @@ if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
|
||||
SERVICE_RUNNING=true
|
||||
fi
|
||||
|
||||
if (ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN") || (netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"); then
|
||||
if (ss -tuln 2>/dev/null | grep -q ":5001") || (netstat -tuln 2>/dev/null | grep -q ":5001"); then
|
||||
PORT_LISTENING=true
|
||||
fi
|
||||
|
||||
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT > /dev/null 2>&1; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:$WEB_PORT 2>/dev/null)
|
||||
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
|
||||
HTTP_RESPONDING=true
|
||||
fi
|
||||
@@ -138,7 +134,7 @@ if [ "$SERVICE_RUNNING" = true ] && [ "$PORT_LISTENING" = true ] && [ "$HTTP_RES
|
||||
echo ""
|
||||
echo "You can access it at:"
|
||||
for ip in $IP_ADDRESSES; do
|
||||
echo " http://$ip:$WEB_PORT"
|
||||
echo " http://$ip:5001"
|
||||
done
|
||||
exit 0
|
||||
elif [ "$SERVICE_RUNNING" = false ]; then
|
||||
@@ -149,7 +145,7 @@ elif [ "$SERVICE_RUNNING" = false ]; then
|
||||
echo " sudo systemctl enable ledmatrix-web.service # to start on boot"
|
||||
exit 1
|
||||
elif [ "$PORT_LISTENING" = false ]; then
|
||||
echo -e "${RED}✗ Service is running but port $WEB_PORT is not listening${NC}"
|
||||
echo -e "${RED}✗ Service is running but port 5001 is not listening${NC}"
|
||||
echo ""
|
||||
echo "Check logs for errors:"
|
||||
echo " sudo journalctl -u ledmatrix-web.service -f"
|
||||
|
||||
+3
-8
@@ -1,10 +1,5 @@
|
||||
# skins/
|
||||
|
||||
> **Not supported yet.** The current scoreboard plugins don't render skins,
|
||||
> so a skin placed here and selected in config has no effect, and the web UI
|
||||
> and Plugin Store don't offer them. See
|
||||
> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet).
|
||||
|
||||
User-installable **visual skins** for the sports scoreboards. Each
|
||||
subdirectory is one skin:
|
||||
|
||||
@@ -15,10 +10,10 @@ skins/<skin-id>/
|
||||
preview.png # optional
|
||||
```
|
||||
|
||||
- Install a skin: `git clone <skin repo> skins/<skin-id>`. The Plugin Store
|
||||
refuses registry entries with `"type": "skin"` while skins don't render.
|
||||
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
|
||||
Store for registry entries with `"type": "skin"`).
|
||||
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
||||
`config/config.json`. The web UI no longer shows a Visual Skin dropdown.
|
||||
`config/config.json`, or use the web UI's Visual Skin dropdown.
|
||||
- Build one: start from `example-classic-baseball/` and read
|
||||
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
||||
`python scripts/validate_skin.py --skin <skin-id>`.
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.5.0"
|
||||
__version__ = "3.3.0"
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Install the automatic-update health check, from the display service.
|
||||
|
||||
The weekly updater (web_interface/auto_update.py) will not update LEDMatrix
|
||||
code unless ledmatrix-update-verify.path and .service are installed: they
|
||||
restart the services after an update and roll it back if the device is
|
||||
unhealthy. Installing units takes root and the web interface is not root, and
|
||||
"SSH in and run an installer" means most people never get updates with a
|
||||
safety net.
|
||||
|
||||
The display service already runs this repository's code as root, so it
|
||||
installs them -- but only while the user has automatic updates turned on, only
|
||||
these two units, rendered from the repository's templates for the web
|
||||
interface's own user, and it reports what happened in
|
||||
data/auto_update_setup.json for the General tab. It grants nothing new: the
|
||||
units run as the web user, who can already change the code this process runs.
|
||||
|
||||
Called at display startup; the web interface restarts the display service
|
||||
when the toggle is switched on, so setup happens straight away. Refreshing a
|
||||
unit whose template changed happens the same way, which is why this compares
|
||||
content rather than only checking that the files exist.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SYSTEMD_DIR = Path('/etc/systemd/system')
|
||||
SERVICE_UNIT = 'ledmatrix-update-verify.service'
|
||||
PATH_UNIT = 'ledmatrix-update-verify.path'
|
||||
UNITS = (SERVICE_UNIT, PATH_UNIT)
|
||||
WEB_UNIT = 'ledmatrix-web.service'
|
||||
RESULT_REL = Path('data') / 'auto_update_setup.json'
|
||||
_USER_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
|
||||
|
||||
|
||||
class SetupError(Exception):
|
||||
"""A reason setup cannot proceed, worded for the General tab."""
|
||||
|
||||
|
||||
def _is_root():
|
||||
return hasattr(os, 'geteuid') and os.geteuid() == 0
|
||||
|
||||
|
||||
def _lookup_ids(user):
|
||||
try:
|
||||
import pwd
|
||||
entry = pwd.getpwnam(user)
|
||||
return entry.pw_uid, entry.pw_gid
|
||||
except (ImportError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def _directive(text, key):
|
||||
match = re.search(rf'^{key}=(.*)$', text or '', re.M)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
return Path(path).read_text(encoding='utf-8')
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def is_enabled(config):
|
||||
return bool((config.get('auto_update') or {}).get('enabled', False))
|
||||
|
||||
|
||||
class UpdateHelperSetup:
|
||||
def __init__(self, project_root=PROJECT_ROOT, systemd_dir=SYSTEMD_DIR, run=subprocess.run,
|
||||
is_root=_is_root, lookup_ids=_lookup_ids, clock=time.time):
|
||||
self.project_root = Path(project_root)
|
||||
self.systemd_dir = Path(systemd_dir)
|
||||
self.run = run
|
||||
self.is_root = is_root
|
||||
self.lookup_ids = lookup_ids
|
||||
self.clock = clock
|
||||
self.result_file = self.project_root / RESULT_REL
|
||||
self._web_ids = None
|
||||
|
||||
def _systemctl(self, *args):
|
||||
return self.run(['systemctl', *args], capture_output=True, text=True, timeout=60)
|
||||
|
||||
def _check(self, result, what):
|
||||
if result.returncode != 0:
|
||||
raise SetupError(f'"{what}" failed: {(result.stderr or result.stdout or "").strip()}')
|
||||
|
||||
def path_active(self):
|
||||
try:
|
||||
return self._systemctl('is-active', PATH_UNIT).stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
def ensure(self, config):
|
||||
"""Install or refresh the units while automatic updates are on.
|
||||
|
||||
Returns the result recorded for the General tab, or None when there
|
||||
was nothing to do (updates off, or not a systemd host at all).
|
||||
"""
|
||||
if not is_enabled(config) or not self.systemd_dir.is_dir():
|
||||
return None
|
||||
try:
|
||||
changed = self._install()
|
||||
except SetupError as e:
|
||||
return self._report('failed', str(e))
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return self._report('failed', f'Could not install the update health check: {e}')
|
||||
if changed:
|
||||
return self._report('installed', 'Installed the update health check.')
|
||||
return self._report('installed', 'The update health check is installed.', quiet=True)
|
||||
|
||||
def _install(self):
|
||||
if not self.is_root():
|
||||
raise SetupError('The display service is not running as root, so it cannot install the '
|
||||
'update health check. Run "sudo ./scripts/install/install_web_service.sh" once.')
|
||||
|
||||
web_text = _read(self.systemd_dir / WEB_UNIT)
|
||||
if web_text is None:
|
||||
raise SetupError('The web interface service (ledmatrix-web.service) is not installed.')
|
||||
user = _directive(web_text, 'User') or 'root'
|
||||
ids = self.lookup_ids(user) if _USER_RE.match(user) else None
|
||||
if ids is None:
|
||||
raise SetupError(f'The web interface runs as "{user}", which is not a usable account.')
|
||||
self._web_ids = ids
|
||||
workdir = _directive(web_text, 'WorkingDirectory')
|
||||
if not workdir or Path(workdir).resolve() != self.project_root.resolve():
|
||||
raise SetupError(f'The web interface service runs from {workdir or "an unknown folder"}, '
|
||||
f'not {self.project_root}.')
|
||||
# Spaces are fine -- the templates quote every command-line path --
|
||||
# but systemd expands % specifiers, and a quote, backslash or line
|
||||
# break would be reinterpreted in a unit file. (On Windows, where the
|
||||
# tests also run, a backslash is the path separator, not a name.)
|
||||
root_text = str(self.project_root)
|
||||
unsafe = set('%"') | ({'\\'} if os.sep == '/' else set())
|
||||
if any(ch in unsafe or ord(ch) < 32 for ch in root_text):
|
||||
raise SetupError(f'LEDMatrix is installed in {root_text!r}, a folder name systemd cannot use '
|
||||
'in a unit file. Move it to a path without %, quotes, backslashes or '
|
||||
'control characters.')
|
||||
|
||||
rendered = {}
|
||||
for name in UNITS:
|
||||
template = _read(self.project_root / 'systemd' / name)
|
||||
if template is None:
|
||||
raise SetupError(f'The unit template systemd/{name} is missing.')
|
||||
rendered[name] = (template.replace('__PROJECT_ROOT_DIR__', str(self.project_root))
|
||||
.replace('__USER__', user))
|
||||
# The templates are ordinary repository files. Whatever they say,
|
||||
# this root process only installs a service that runs as the web user
|
||||
# and a path unit that starts exactly that service.
|
||||
if _directive(rendered[SERVICE_UNIT], 'User') != user:
|
||||
raise SetupError(f'systemd/{SERVICE_UNIT} does not run as the web interface user; '
|
||||
'refusing to install it.')
|
||||
if _directive(rendered[PATH_UNIT], 'Unit') != SERVICE_UNIT:
|
||||
raise SetupError(f'systemd/{PATH_UNIT} does not start {SERVICE_UNIT}; refusing to install it.')
|
||||
|
||||
changed = [name for name in UNITS if _read(self.systemd_dir / name) != rendered[name]]
|
||||
for name in changed:
|
||||
self._write_unit(self.systemd_dir / name, rendered[name])
|
||||
if changed:
|
||||
self._check(self._systemctl('daemon-reload'), 'systemctl daemon-reload')
|
||||
self._check(self._systemctl('enable', PATH_UNIT), f'systemctl enable {PATH_UNIT}')
|
||||
self._check(self._systemctl('restart', PATH_UNIT), f'systemctl restart {PATH_UNIT}')
|
||||
elif not self.path_active():
|
||||
self._check(self._systemctl('enable', '--now', PATH_UNIT), f'systemctl enable --now {PATH_UNIT}')
|
||||
changed = [PATH_UNIT]
|
||||
if not self.path_active():
|
||||
raise SetupError(f'{PATH_UNIT} did not start; see "journalctl -u {PATH_UNIT}".')
|
||||
return bool(changed)
|
||||
|
||||
def _write_unit(self, path, text):
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f'.{path.name}.')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _report(self, status, message, quiet=False):
|
||||
previous = None
|
||||
try:
|
||||
previous = json.loads(_read(self.result_file) or 'null')
|
||||
except ValueError:
|
||||
pass
|
||||
if quiet and isinstance(previous, dict) and previous.get('status') == status:
|
||||
return previous # nothing new; don't rewrite it on every boot
|
||||
result = {'status': status, 'message': message, 'at': self.clock()}
|
||||
(logger.info if status == 'installed' else logger.warning)("Automatic update setup: %s", message)
|
||||
try:
|
||||
self.result_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(self.result_file.parent), prefix='.auto_update_setup_')
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2)
|
||||
os.chmod(tmp, 0o644)
|
||||
if self._web_ids and hasattr(os, 'chown'):
|
||||
# Only root can give the file away; the result is readable
|
||||
# (0644) either way, so a failed chown must not lose it.
|
||||
try:
|
||||
os.chown(tmp, *self._web_ids)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, self.result_file)
|
||||
except OSError as e:
|
||||
logger.warning("Could not record automatic update setup result: %s", e)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_update_helper(config):
|
||||
return UpdateHelperSetup().ensure(config)
|
||||
@@ -25,14 +25,6 @@ from enum import Enum
|
||||
import queue
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from src.cache_manager import CacheManager
|
||||
from src.common.espn_dates import (
|
||||
RANGE_RETRY_SECONDS,
|
||||
_note_range_rejected,
|
||||
_ranges_known_rejected,
|
||||
clamp_espn_limit,
|
||||
fetch_espn_date_chunks,
|
||||
parse_espn_date_range,
|
||||
)
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,12 +98,7 @@ class BackgroundDataService:
|
||||
This service manages a pool of background threads to fetch data asynchronously,
|
||||
with intelligent caching, retry logic, and progress tracking.
|
||||
"""
|
||||
|
||||
# Plugins feature-detect this. A core without it sends season ranges to
|
||||
# ESPN as-is and gets 400s since 2026-09-15, so plugins fetch those
|
||||
# ranges themselves instead of submitting them here.
|
||||
handles_espn_date_ranges = True
|
||||
|
||||
|
||||
def __init__(self, cache_manager: CacheManager, max_workers: int = 3, request_timeout: int = 30):
|
||||
"""
|
||||
Initialize the background data service.
|
||||
@@ -170,10 +157,14 @@ class BackgroundDataService:
|
||||
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=3))
|
||||
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=3))
|
||||
|
||||
# Default headers: core's shared set (real User-Agent, no hand-set
|
||||
# Accept-Encoding) -- see src/common/api_helper.py.
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
self.default_headers = dict(DEFAULT_HTTP_HEADERS)
|
||||
# Default headers
|
||||
self.default_headers = {
|
||||
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix)',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
|
||||
logger.info(f"BackgroundDataService initialized with {max_workers} workers")
|
||||
|
||||
@@ -256,12 +247,6 @@ class BackgroundDataService:
|
||||
logger.debug(f"Cache hit for {sport} {year} data")
|
||||
return request_id
|
||||
|
||||
# limit above 500 makes an ESPN *scoreboard* return a truncated list
|
||||
# (src/common/espn_dates.py). Other endpoints need more: /teams has 762
|
||||
# college-football teams, so only scoreboards are clamped.
|
||||
if url.split('?', 1)[0].rstrip('/').endswith('/scoreboard'):
|
||||
params = clamp_espn_limit(params)
|
||||
|
||||
# Create fetch request
|
||||
request = FetchRequest(
|
||||
id=request_id,
|
||||
@@ -269,7 +254,7 @@ class BackgroundDataService:
|
||||
year=year,
|
||||
cache_key=cache_key,
|
||||
url=url,
|
||||
params=dict(params or {}),
|
||||
params=params or {},
|
||||
headers={**self.default_headers, **(headers or {})},
|
||||
timeout=timeout or self.request_timeout,
|
||||
max_retries=max_retries,
|
||||
@@ -353,38 +338,12 @@ class BackgroundDataService:
|
||||
|
||||
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
||||
|
||||
# ESPN stopped accepting dates=YYYYMMDD-YYYYMMDD on 2026-09-15 and
|
||||
# answers 400 for every sport. Re-ask in months and days rather
|
||||
# than let a whole season fail. See src/common/espn_dates.py.
|
||||
# The "ranges are rejected" memo is shared with
|
||||
# fetch_espn_scoreboard(): once either path has seen a range
|
||||
# rejected, the other skips the doomed range request too.
|
||||
is_range = parse_espn_date_range(request.params.get("dates")) is not None
|
||||
data = None
|
||||
chunks_tried = False
|
||||
if is_range and _ranges_known_rejected():
|
||||
data = self._fetch_in_date_chunks(request)
|
||||
# Every chunk failed: ask for the range itself below so the
|
||||
# failure carries a real HTTP error, without re-spending chunks.
|
||||
chunks_tried = data is None
|
||||
|
||||
if data is None:
|
||||
# Perform HTTP request with retry logic
|
||||
response = self._make_request_with_retry(request)
|
||||
if is_range and response.status_code == 400 and not chunks_tried:
|
||||
_note_range_rejected()
|
||||
logger.warning(
|
||||
"ESPN rejected the date range %s (400); fetching it as "
|
||||
"month/day chunks, and fetching ranges that way for the "
|
||||
"next %d hours",
|
||||
request.params.get("dates"), RANGE_RETRY_SECONDS // 3600,
|
||||
)
|
||||
data = self._fetch_in_date_chunks(request)
|
||||
if data is None:
|
||||
response.raise_for_status()
|
||||
else:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Perform HTTP request with retry logic
|
||||
response = self._make_request_with_retry(request)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
data = response.json()
|
||||
|
||||
# Validate data structure
|
||||
if not isinstance(data, dict):
|
||||
@@ -560,23 +519,6 @@ class BackgroundDataService:
|
||||
"""
|
||||
result.data = None
|
||||
|
||||
def _fetch_in_date_chunks(self, request: FetchRequest) -> Optional[Dict[str, Any]]:
|
||||
"""Re-fetch a rejected ``YYYYMMDD-YYYYMMDD`` range as month/day chunks.
|
||||
|
||||
None means the request was not a day range, or every chunk failed; the
|
||||
caller then re-raises the original 400 instead of caching an empty
|
||||
season. See src/common/espn_dates.py.
|
||||
"""
|
||||
logger.info("Recovering %s %s from a rejected date range", request.sport, request.year)
|
||||
return fetch_espn_date_chunks(
|
||||
self.session,
|
||||
request.url,
|
||||
params=request.params,
|
||||
headers=request.headers,
|
||||
timeout=request.timeout,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
def _make_request_with_retry(self, request: FetchRequest) -> requests.Response:
|
||||
"""
|
||||
Make HTTP request with retry logic and exponential backoff.
|
||||
|
||||
@@ -530,15 +530,12 @@ def _copy_file(src: Path, dst: Path) -> None:
|
||||
os.chmod(tmp_path, existing_mode)
|
||||
else:
|
||||
shutil.copymode(src, tmp_path)
|
||||
if existing_owner is not None and hasattr(os, 'chown'):
|
||||
if existing_owner is not None:
|
||||
# Replacing a file creates a new inode owned by whoever is running,
|
||||
# which would silently move a root-owned config to the web user.
|
||||
# Carry the previous owner across when the OS permits it — only
|
||||
# root can hand a file to another user, so this is best-effort and
|
||||
# a plain restore as the web user simply keeps its own ownership.
|
||||
# os.chown does not exist on Windows (where st_uid/st_gid are just
|
||||
# 0); looking it up there raises AttributeError, which no caller
|
||||
# catches, so every restore over an existing file aborted.
|
||||
try:
|
||||
os.chown(tmp_path, existing_owner[0], existing_owner[1])
|
||||
except (OSError, PermissionError):
|
||||
|
||||
@@ -37,6 +37,51 @@ class Baseball(SportsCore):
|
||||
self.data_source = ESPNDataSource(logger)
|
||||
self.sport = "baseball"
|
||||
|
||||
def _get_baseball_display_text(self, game: Dict) -> str:
|
||||
"""Get baseball-specific display text."""
|
||||
try:
|
||||
display_parts = []
|
||||
|
||||
# Inning information
|
||||
if self.show_innings:
|
||||
inning = game.get("inning", "")
|
||||
if inning:
|
||||
display_parts.append(f"Inning: {inning}")
|
||||
|
||||
# Outs information
|
||||
if self.show_outs:
|
||||
outs = game.get("outs", 0)
|
||||
if outs is not None:
|
||||
display_parts.append(f"Outs: {outs}")
|
||||
|
||||
# Bases information
|
||||
if self.show_bases:
|
||||
bases = game.get("bases", "")
|
||||
if bases:
|
||||
display_parts.append(f"Bases: {bases}")
|
||||
|
||||
# Count information
|
||||
if self.show_count:
|
||||
strikes = game.get("strikes", 0)
|
||||
balls = game.get("balls", 0)
|
||||
if strikes is not None and balls is not None:
|
||||
display_parts.append(f"Count: {balls}-{strikes}")
|
||||
|
||||
# Pitcher/Batter information
|
||||
if self.show_pitcher_batter:
|
||||
pitcher = game.get("pitcher", "")
|
||||
batter = game.get("batter", "")
|
||||
if pitcher:
|
||||
display_parts.append(f"Pitcher: {pitcher}")
|
||||
if batter:
|
||||
display_parts.append(f"Batter: {batter}")
|
||||
|
||||
return " | ".join(display_parts) if display_parts else ""
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting baseball display text: {e}")
|
||||
return ""
|
||||
|
||||
def _is_baseball_game_live(self, game: Dict) -> bool:
|
||||
"""Check if a baseball game is currently live."""
|
||||
try:
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Dict, List
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from src.common.espn_dates import fetch_espn_scoreboard
|
||||
|
||||
class DataSource(ABC):
|
||||
"""Abstract base class for data sources."""
|
||||
@@ -72,10 +71,10 @@ class ESPNDataSource(DataSource):
|
||||
now = datetime.now()
|
||||
formatted_date = now.strftime("%Y%m%d")
|
||||
url = f"{self.base_url}/{sport}/{league}/scoreboard"
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params={"dates": formatted_date, "limit": 1000},
|
||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
||||
)
|
||||
response = self.session.get(url, params={"dates": formatted_date, "limit": 1000}, headers=self.get_headers(), timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
events = data.get('events', [])
|
||||
|
||||
# Filter for live games
|
||||
@@ -100,10 +99,10 @@ class ESPNDataSource(DataSource):
|
||||
"limit": 1000
|
||||
}
|
||||
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params=params,
|
||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
||||
)
|
||||
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
events = data.get('events', [])
|
||||
|
||||
self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}")
|
||||
@@ -114,68 +113,35 @@ class ESPNDataSource(DataSource):
|
||||
return []
|
||||
|
||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
||||
"""Fetch standings, or the poll for leagues that have one.
|
||||
|
||||
Order matters and used to be wrong. College leagues publish a poll at
|
||||
/rankings and a records table at /standings; professional leagues have
|
||||
only /standings. The old code tried /standings first and fell back to
|
||||
/rankings only on a 404 -- but college /standings answers 200, so the
|
||||
fallback never fired and college rankings came back empty forever.
|
||||
Nothing failed; the AP rank badge simply never appeared, and anything
|
||||
else keyed off rankings quietly did nothing.
|
||||
|
||||
A 200 that lacks the key is treated as a miss, so a league answering
|
||||
both endpoints still ends up with whichever one actually carries a poll.
|
||||
"""
|
||||
league_name = (league or "").lower()
|
||||
wants_poll = "college" in league_name or "ncaa" in league_name
|
||||
endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"]
|
||||
|
||||
for endpoint in endpoints:
|
||||
url = f"{self.base_url}/{sport}/{league}/{endpoint}"
|
||||
# Only the request is guarded. Inspecting the payload happens
|
||||
# below, outside the handler, so that a bug in this method cannot
|
||||
# be mistaken for an endpoint that failed -- that mistake would
|
||||
# silently drop rankings for a league that has them, which is the
|
||||
# exact failure this function was written to fix.
|
||||
try:
|
||||
response = self.session.get(
|
||||
url, headers=self.get_headers(), timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
# Only a 404 is routine -- it is how a league says "no poll
|
||||
# here". Everything else is worth an error, and `status is
|
||||
# None` covers the ones that matter most: ConnectionError,
|
||||
# Timeout, a body that would not parse. Silencing those left a
|
||||
# board that could not reach ESPN with one debug line, and the
|
||||
# ranked filter running on an empty table.
|
||||
if status != 404:
|
||||
self.logger.error(
|
||||
f"Error fetching {endpoint} from ESPN for "
|
||||
f"{sport}/{league}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not isinstance(data, dict):
|
||||
# A list or a bare string is not something the callers can
|
||||
# read. Treat it as a miss so the other endpoint still gets a
|
||||
# turn, but say so -- this means ESPN changed shape.
|
||||
self.logger.error(
|
||||
f"Unexpected {endpoint} payload for {sport}/{league}: "
|
||||
f"got {type(data).__name__}, expected an object"
|
||||
)
|
||||
continue
|
||||
if endpoint == "rankings" and not data.get("rankings"):
|
||||
continue
|
||||
self.logger.debug(f"Fetched {endpoint} for {sport}/{league}")
|
||||
"""Fetch standings from ESPN API."""
|
||||
# Try standings endpoint first (for professional leagues like NFL, NBA, etc.)
|
||||
try:
|
||||
url = f"{self.base_url}/{sport}/{league}/standings"
|
||||
response = self.session.get(url, headers=self.get_headers(), timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f"Fetched standings for {sport}/{league}")
|
||||
return data
|
||||
self.logger.debug(
|
||||
f"Standings/rankings not available for {sport}/{league} from ESPN API"
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
# If standings doesn't exist, try rankings (for college sports)
|
||||
if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 404:
|
||||
try:
|
||||
url = f"{self.base_url}/{sport}/{league}/rankings"
|
||||
response = self.session.get(url, headers=self.get_headers(), timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f"Fetched rankings for {sport}/{league}")
|
||||
return data
|
||||
except Exception:
|
||||
# Both endpoints failed - standings/rankings may not be available for this sport/league
|
||||
self.logger.debug(f"Standings/rankings not available for {sport}/{league} from ESPN API")
|
||||
return {}
|
||||
else:
|
||||
# Non-404 error - log at debug level since standings are optional
|
||||
self.logger.debug(f"Error fetching standings from ESPN for {sport}/{league}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
class MLBAPIDataSource(DataSource):
|
||||
|
||||
@@ -8,16 +8,13 @@ import os
|
||||
import tempfile
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from src.common.espn_dates import fetch_espn_scoreboard
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
@@ -169,14 +166,7 @@ class SportsCore(ABC):
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
|
||||
# LRU-bounded: entries are decoded RGBA thumbnails, not file bytes.
|
||||
# Each is up to display_width*1.5 x display_height*1.5 -- about 36KB on
|
||||
# a 256x64 panel, more for wide wordmarks. The key is a team
|
||||
# abbreviation and assets/sports/ncaa_logos alone ships 307 of them, so
|
||||
# an unbounded dict here held the whole league: ~11-18MB per manager
|
||||
# instance, and a league runs three (live/recent/upcoming) each with
|
||||
# its own cache. That is real money on a 1GB Pi.
|
||||
self._logo_cache: "OrderedDict[str, Image.Image]" = OrderedDict()
|
||||
self._logo_cache = {}
|
||||
|
||||
# Font caches for _load_custom_font_from_element_config: per-frame
|
||||
# callers (font-ladder walks) resolve the same (name, size) over and
|
||||
@@ -459,12 +449,12 @@ class SportsCore(ABC):
|
||||
press_start = self._resolve_font_path("PressStart2P-Regular.ttf")
|
||||
four_by_six = self._resolve_font_path("4x6-font.ttf")
|
||||
try:
|
||||
fonts['score'] = load_truetype(press_start, 10)
|
||||
fonts['time'] = load_truetype(press_start, 8)
|
||||
fonts['team'] = load_truetype(press_start, 8)
|
||||
fonts['status'] = load_truetype(four_by_six, 6) # Using 4x6 for status
|
||||
fonts['detail'] = load_truetype(four_by_six, 6) # Added detail font
|
||||
fonts['rank'] = load_truetype(press_start, 10)
|
||||
fonts['score'] = ImageFont.truetype(press_start, 10)
|
||||
fonts['time'] = ImageFont.truetype(press_start, 8)
|
||||
fonts['team'] = ImageFont.truetype(press_start, 8)
|
||||
fonts['status'] = ImageFont.truetype(four_by_six, 6) # Using 4x6 for status
|
||||
fonts['detail'] = ImageFont.truetype(four_by_six, 6) # Added detail font
|
||||
fonts['rank'] = ImageFont.truetype(press_start, 10)
|
||||
self.logger.info("Successfully loaded fonts")
|
||||
except OSError:
|
||||
# Name the directory we searched: the usual cause is an install
|
||||
@@ -569,17 +559,11 @@ class SportsCore(ABC):
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
#: Decoded logos to keep. A scroll of "other games" shows on the order of
|
||||
#: 20 games (40 teams), so this holds a full cycle without thrashing while
|
||||
#: capping the cache well below a 307-team league.
|
||||
_LOGO_CACHE_MAX = 64
|
||||
|
||||
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
||||
"""Load and resize a team logo, with caching and automatic download if missing."""
|
||||
self.logger.debug(f"Logo path: {logo_path}")
|
||||
if team_abbrev in self._logo_cache:
|
||||
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
||||
self._logo_cache.move_to_end(team_abbrev)
|
||||
return self._logo_cache[team_abbrev]
|
||||
|
||||
try:
|
||||
@@ -619,8 +603,6 @@ class SportsCore(ABC):
|
||||
max_height = int(self.display_height * 1.5)
|
||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||
self._logo_cache[team_abbrev] = logo
|
||||
while len(self._logo_cache) > self._LOGO_CACHE_MAX:
|
||||
self._logo_cache.popitem(last=False)
|
||||
return logo
|
||||
|
||||
except Exception as e:
|
||||
@@ -845,11 +827,9 @@ class SportsCore(ABC):
|
||||
formatted_date_yesterday = yesterday.strftime("%Y%m%d")
|
||||
# Fetch todays games only
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url,
|
||||
params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000},
|
||||
headers=self.headers, timeout=10, logger=self.logger,
|
||||
)
|
||||
response = self.session.get(url, params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
events = data.get('events', [])
|
||||
|
||||
self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}")
|
||||
@@ -872,10 +852,9 @@ class SportsCore(ABC):
|
||||
end_date = now + timedelta(weeks=1)
|
||||
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params={"dates": date_str, "limit": 1000},
|
||||
headers=self.headers, timeout=10, logger=self.logger,
|
||||
)
|
||||
response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
immediate_events = data.get('events', [])
|
||||
|
||||
if immediate_events:
|
||||
@@ -1052,7 +1031,7 @@ class SportsCore(ABC):
|
||||
if os.path.exists(font_path):
|
||||
# Try loading as TTF first (works for both TTF and some BDF files with PIL)
|
||||
if font_path.lower().endswith('.ttf'):
|
||||
font = load_truetype(font_path, font_size)
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
self.logger.debug(f"Loaded font: {font_name} at size {font_size}")
|
||||
self._font_cache[cache_key] = font
|
||||
return font
|
||||
@@ -1070,7 +1049,7 @@ class SportsCore(ABC):
|
||||
# correct one: the newer copies call truetype() on a BDF at
|
||||
# any size (which simply fails) or refuse BDF outright.
|
||||
try:
|
||||
font = load_truetype(font_path, font_size)
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}")
|
||||
self._font_cache[cache_key] = font
|
||||
return font
|
||||
@@ -1082,7 +1061,7 @@ class SportsCore(ABC):
|
||||
self._bdf_native_size_cache[font_path] = native_size
|
||||
if native_size and native_size != font_size:
|
||||
try:
|
||||
font = load_truetype(font_path, native_size)
|
||||
font = ImageFont.truetype(font_path, native_size)
|
||||
self.logger.debug(
|
||||
f"Loaded BDF font: {font_name} at its native size {native_size} "
|
||||
f"(requested {font_size} isn't a valid strike for this file)"
|
||||
@@ -1110,7 +1089,7 @@ class SportsCore(ABC):
|
||||
_resolve_font_family_alias(base_default))
|
||||
try:
|
||||
if os.path.exists(default_font_path):
|
||||
font = load_truetype(default_font_path, font_size)
|
||||
font = ImageFont.truetype(default_font_path, font_size)
|
||||
else:
|
||||
self.logger.warning("Default font not found, using PIL default")
|
||||
font = ImageFont.load_default()
|
||||
|
||||
Vendored
+27
-250
@@ -5,9 +5,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
import tempfile
|
||||
import logging
|
||||
@@ -16,13 +14,6 @@ import zlib
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from datetime import datetime
|
||||
|
||||
from src.common.path_safety import safe_path_component
|
||||
|
||||
try: # optional: large speedup on the cache write path, see _dumps below
|
||||
import orjson
|
||||
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
||||
orjson = None
|
||||
|
||||
# How old an abandoned write's temp file must be before the sweep removes it.
|
||||
# A real write holds its temp file for milliseconds, so an hour is far beyond
|
||||
# any in-flight write while still clearing the same day's debris. Deliberately
|
||||
@@ -49,156 +40,13 @@ class CacheStrategyProtocol(Protocol):
|
||||
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON encoder that handles datetime objects.
|
||||
|
||||
Retained for the stdlib fallback path and for any caller importing it.
|
||||
"""
|
||||
"""JSON encoder that handles datetime objects."""
|
||||
def default(self, obj: Any) -> Any:
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
def _datetime_default(obj: Any) -> Any:
|
||||
"""Serialise datetimes exactly as DateTimeEncoder did."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def _replace_nonfinite(obj: Any) -> Any:
|
||||
"""Non-finite floats -> None, matching what ``orjson.dumps`` writes.
|
||||
|
||||
Only reached once a strict pass has proved there is something to replace,
|
||||
so the ordinary write path never pays for this walk.
|
||||
"""
|
||||
if isinstance(obj, float):
|
||||
return obj if math.isfinite(obj) else None
|
||||
if isinstance(obj, dict):
|
||||
return {k: _replace_nonfinite(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_replace_nonfinite(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
# NON-FINITE FLOATS
|
||||
# -----------------
|
||||
# JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
|
||||
# orjson refuses to and writes null. That divergence is not acceptable in a
|
||||
# cache whose files outlive the decision of which encoder is installed, so the
|
||||
# policy here is one behaviour on both paths:
|
||||
#
|
||||
# writing non-finite floats become null, whichever encoder is in use
|
||||
# reading files already on disk that carry the stdlib's NaN/Infinity
|
||||
# tokens stay readable, whichever encoder is in use
|
||||
#
|
||||
# Without the write half, installing orjson silently changed cached values.
|
||||
# Without the read half, installing orjson turned every legacy record holding a
|
||||
# NaN into a "corrupted cache file" that DiskCache.get logged as an error and
|
||||
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
|
||||
|
||||
|
||||
if orjson is not None:
|
||||
# Encoding the cache record dominated the background fetch worker: on a
|
||||
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
|
||||
# it, which stalls the render thread mid-scroll. orjson measures ~7x
|
||||
# faster on the same payloads (11.9ms -> 1.6ms for 985KB). Decoding gains
|
||||
# far less (~1.3x on large payloads) because the cost there is building
|
||||
# the Python objects, not scanning the text, but it is still free to take.
|
||||
#
|
||||
# OPT_NON_STR_KEYS: stdlib json coerces int/float dict keys to strings;
|
||||
# orjson raises without this, and cache records do carry numeric keys.
|
||||
# OPT_PASSTHROUGH_DATETIME: orjson would otherwise emit its own RFC 3339
|
||||
# form for datetimes instead of calling default(). Routing them through
|
||||
# _datetime_default keeps byte-for-byte parity with the records already
|
||||
# on disk.
|
||||
_DUMPS_OPTS = orjson.OPT_NON_STR_KEYS | orjson.OPT_PASSTHROUGH_DATETIME
|
||||
|
||||
def _dumps(data: Any) -> bytes:
|
||||
return orjson.dumps(data, default=_datetime_default, option=_DUMPS_OPTS)
|
||||
|
||||
def _loads(raw: bytes) -> Any:
|
||||
try:
|
||||
return orjson.loads(raw)
|
||||
except orjson.JSONDecodeError:
|
||||
# Legacy record written by the stdlib path, carrying NaN or
|
||||
# Infinity. Genuinely malformed files raise again from here, as
|
||||
# json.JSONDecodeError, which is what DiskCache.get expects.
|
||||
return json.loads(raw)
|
||||
else:
|
||||
def _dumps(data: Any) -> bytes:
|
||||
try:
|
||||
return json.dumps(data, cls=DateTimeEncoder,
|
||||
allow_nan=False).encode("utf-8")
|
||||
except ValueError:
|
||||
# allow_nan=False is what detects the non-finite values; the walk
|
||||
# runs only now that we know there is one to replace.
|
||||
return json.dumps(_replace_nonfinite(data), cls=DateTimeEncoder,
|
||||
allow_nan=False).encode("utf-8")
|
||||
|
||||
def _loads(raw: bytes) -> Any:
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
# SHARING CACHE FILES BETWEEN THE TWO SERVICES
|
||||
# --------------------------------------------
|
||||
# The display service runs as root and the web interface as the installing
|
||||
# user, and the web interface reads records only the display writes
|
||||
# (display_current_state, display_on_demand_state, plugin_metrics:*). Files are
|
||||
# written 0660, so the web interface can read one only through its group.
|
||||
#
|
||||
# The installers rely on the directory's setgid bit to set that group. That is
|
||||
# not something the cache can count on: systemd's CacheDirectory=, which
|
||||
# ledmatrix-web.service carried until Sept 2026, re-owns the directory and
|
||||
# everything in it to the web user and its primary group whenever the
|
||||
# directory's owner does not match, and the setgid layout never survives that.
|
||||
# From then on every file root creates is root:root 0660, unreadable by the web
|
||||
# interface. Measured on one rig: 365 such files, and the web UI's display
|
||||
# status, on-demand state and plugin health all silently empty.
|
||||
#
|
||||
# So a cache file takes its group from the directory explicitly, whether or
|
||||
# not setgid is set. Only a group-writable directory counts as shared: that
|
||||
# group can already replace any file in it, so reading them grants nothing new.
|
||||
#
|
||||
# Everything here works on an open descriptor, never a path. The directory is
|
||||
# writable by the web user, so between a path check and a path operation that
|
||||
# user could put a symlink in the file's place, and root would then chown and
|
||||
# chmod whatever it points at.
|
||||
|
||||
_CACHE_FILE_MODE = 0o660
|
||||
|
||||
|
||||
def _shared_group(directory: str) -> Optional[int]:
|
||||
"""The group a cache file in ``directory`` should carry, if it is shared."""
|
||||
try:
|
||||
st = os.stat(directory)
|
||||
except OSError:
|
||||
return None
|
||||
if not st.st_mode & stat.S_IWGRP:
|
||||
return None
|
||||
return st.st_gid
|
||||
|
||||
|
||||
def _share_open_file(fd: int, group: Optional[int]) -> None:
|
||||
"""Make an open cache file readable by the other service. Best effort."""
|
||||
fchmod = getattr(os, 'fchmod', None) # absent on Windows before 3.13
|
||||
if fchmod is not None:
|
||||
try:
|
||||
fchmod(fd, _CACHE_FILE_MODE)
|
||||
except OSError:
|
||||
pass
|
||||
fchown = getattr(os, 'fchown', None) # absent on Windows
|
||||
if fchown is None or group is None:
|
||||
return
|
||||
try:
|
||||
if os.fstat(fd).st_gid != group:
|
||||
fchown(fd, -1, group)
|
||||
except OSError:
|
||||
# Not a member of the directory's group and not root: nothing to do,
|
||||
# and the file keeps the group it was created with.
|
||||
pass
|
||||
|
||||
|
||||
class DiskCache:
|
||||
"""Manages persistent disk-based cache."""
|
||||
|
||||
@@ -222,30 +70,16 @@ class DiskCache:
|
||||
def get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path for a cache file.
|
||||
|
||||
The key becomes a filename, so it has to be one. Keys reach this
|
||||
method from the web API -- POST /api/v3/cache/delete passes the
|
||||
request body's ``key`` straight through CacheManager.clear_cache to
|
||||
os.remove -- and a key of ``../../../../etc/whatever`` named a file
|
||||
well outside the cache directory. Every real key is the stem of a
|
||||
file already sitting flat in cache_dir (that is how list_cache_files
|
||||
derives them), so rejecting anything with a path component turns
|
||||
away only inputs that could never have been written here.
|
||||
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
|
||||
Returns:
|
||||
Path to cache file, or None if cache is disabled or the key is
|
||||
not a usable filename
|
||||
Path to cache file or None if cache is disabled
|
||||
"""
|
||||
if not self.cache_dir:
|
||||
return None
|
||||
safe_key = safe_path_component(key)
|
||||
if safe_key is None:
|
||||
self.logger.warning("Rejected unsafe cache key %r", key)
|
||||
return None
|
||||
return os.path.join(self.cache_dir, f"{safe_key}.json")
|
||||
return os.path.join(self.cache_dir, f"{key}.json")
|
||||
|
||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -265,8 +99,8 @@ class DiskCache:
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
with open(cache_path, 'rb') as f:
|
||||
record = _loads(f.read())
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
record = json.load(f)
|
||||
|
||||
# Determine record timestamp (prefer embedded, else file mtime)
|
||||
record_ts = None
|
||||
@@ -355,12 +189,12 @@ class DiskCache:
|
||||
# write path below, and cache files are machine-read only — indenting
|
||||
# them just multiplied the bytes written to the SD card.
|
||||
try:
|
||||
payload = _dumps(data)
|
||||
payload = json.dumps(data, cls=DateTimeEncoder)
|
||||
except (TypeError, ValueError) as e:
|
||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||
return
|
||||
|
||||
digest = zlib.adler32(payload)
|
||||
digest = zlib.adler32(payload.encode('utf-8'))
|
||||
|
||||
try:
|
||||
# Atomic write to avoid partial/corrupt files
|
||||
@@ -408,14 +242,15 @@ class DiskCache:
|
||||
# wear source (dozens of fsyncs/min on API-heavy
|
||||
# installs) for data that can be re-downloaded.
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as tmp_file:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write(payload)
|
||||
# Before the rename, not after: mkstemp
|
||||
# creates the file 0600, and a reader that
|
||||
# opened it in between was refused.
|
||||
_share_open_file(tmp_file.fileno(), _shared_group(tmp_dir))
|
||||
os.replace(tmp_path, cache_path)
|
||||
self._write_digests[key] = digest
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
@@ -425,10 +260,14 @@ class DiskCache:
|
||||
else:
|
||||
# Fallback: direct write (not atomic, but better than failing)
|
||||
try:
|
||||
with open(cache_path, 'wb') as cache_file:
|
||||
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
||||
cache_file.write(payload)
|
||||
_share_open_file(cache_file.fileno(), _shared_group(tmp_dir))
|
||||
self._write_digests[key] = digest
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||
except (IOError, OSError, PermissionError) as write_error:
|
||||
# If direct write also fails, try fallback location
|
||||
@@ -451,9 +290,13 @@ class DiskCache:
|
||||
# is a different path, so future sets must keep
|
||||
# retrying the primary location.
|
||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||
with open(fallback_path, 'wb') as tmp_file:
|
||||
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
||||
tmp_file.write(payload)
|
||||
_share_open_file(tmp_file.fileno(), _shared_group(fallback_dir))
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
|
||||
return # Successfully wrote to fallback, exit gracefully
|
||||
except (IOError, OSError, PermissionError) as e2:
|
||||
@@ -510,72 +353,6 @@ class DiskCache:
|
||||
def get_cache_dir(self) -> Optional[str]:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
def share_existing_files(self) -> int:
|
||||
"""Give cache files already on disk the group set() now gives new ones.
|
||||
|
||||
set() fixes every file it writes from now on; this repairs the ones an
|
||||
older version left behind as root:root, which the web interface cannot
|
||||
read until each key happens to be rewritten -- and some, like a
|
||||
plugin's metrics, may not be for a long time. Meant to run once per
|
||||
process, off the startup path.
|
||||
|
||||
Only this process's own regular files are touched, and each one through
|
||||
a descriptor opened with O_NOFOLLOW and checked for a single link: the
|
||||
directory is writable by the web user, and a root process must not be
|
||||
steered into changing a file outside it.
|
||||
|
||||
Returns:
|
||||
Number of files whose group or mode was changed.
|
||||
"""
|
||||
fchown = getattr(os, 'fchown', None)
|
||||
geteuid = getattr(os, 'geteuid', None)
|
||||
nofollow = getattr(os, 'O_NOFOLLOW', None)
|
||||
if not self.cache_dir or fchown is None or geteuid is None or nofollow is None:
|
||||
return 0
|
||||
group = _shared_group(self.cache_dir)
|
||||
if group is None:
|
||||
return 0
|
||||
euid = geteuid()
|
||||
|
||||
changed = 0
|
||||
try:
|
||||
entries = list(os.scandir(self.cache_dir))
|
||||
except OSError as e:
|
||||
self.logger.debug("Could not scan %s to share cache files: %s", self.cache_dir, e)
|
||||
return 0
|
||||
for entry in entries:
|
||||
if not entry.name.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if (not stat.S_ISREG(st.st_mode) or st.st_uid != euid
|
||||
or (st.st_gid == group and stat.S_IMODE(st.st_mode) == _CACHE_FILE_MODE)):
|
||||
continue
|
||||
try:
|
||||
fd = os.open(entry.path, os.O_RDONLY | nofollow | getattr(os, 'O_NONBLOCK', 0))
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != euid or st.st_nlink != 1:
|
||||
continue
|
||||
_share_open_file(fd, group)
|
||||
st = os.fstat(fd)
|
||||
if st.st_gid == group and stat.S_IMODE(st.st_mode) == _CACHE_FILE_MODE:
|
||||
changed += 1
|
||||
except OSError:
|
||||
continue
|
||||
finally:
|
||||
os.close(fd)
|
||||
if changed:
|
||||
self.logger.info(
|
||||
"Made %d cache file(s) in %s readable by the directory's group "
|
||||
"(gid %d) so the web interface can read them",
|
||||
changed, self.cache_dir, group)
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _is_orphaned_temp(filename: str) -> bool:
|
||||
|
||||
@@ -762,14 +762,6 @@ class CacheManager:
|
||||
self.logger.info("Disk cache cleanup thread started (interval: %d hours)",
|
||||
self._disk_cleanup_interval_hours)
|
||||
|
||||
# Repair files an older version wrote unreadable by the web
|
||||
# interface (see disk_cache.py, "SHARING CACHE FILES"). Once per
|
||||
# directory per process, which is what this thread already is.
|
||||
try:
|
||||
self._disk_cache_component.share_existing_files()
|
||||
except Exception as e:
|
||||
self.logger.error("Error sharing existing cache files: %s", e, exc_info=True)
|
||||
|
||||
# Run initial cleanup on startup (deferred from __init__ to avoid blocking)
|
||||
try:
|
||||
self.logger.debug("Running initial disk cache cleanup")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user