mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-12 10:06:35 +00:00
Compare commits
19
Commits
v3.4.0
...
47b56182e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b56182e4 | ||
|
|
967f3a0567 | ||
|
|
21c8a54f68 | ||
|
|
cf02538d2e | ||
|
|
81e1bc596f | ||
|
|
19686ab761 | ||
|
|
92f1960d00 | ||
|
|
116abb0daa | ||
|
|
7e5967e160 | ||
|
|
f475038895 | ||
|
|
1d51efe4c7 | ||
|
|
7ae614aa35 | ||
|
|
2082665252 | ||
|
|
f9b3d6ae52 | ||
|
|
9616a5a054 | ||
|
|
c200b5837d | ||
|
|
9f2743471c | ||
|
|
fddb0e06db | ||
|
|
8360220809 |
@@ -36,6 +36,12 @@ 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 }}'
|
||||
|
||||
+4
-3
@@ -39,11 +39,12 @@ htmlcov/
|
||||
# Cache directory (root level only, not src/cache which is source code)
|
||||
/cache/
|
||||
|
||||
# Development plugins directory
|
||||
# Plugins are managed as separate repositories via multi-root workspace
|
||||
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
|
||||
# Development plugins directory: symlinks into a ledmatrix-plugins checkout
|
||||
# See docs/PLUGIN_DEVELOPMENT_GUIDE.md and docs/MULTI_ROOT_WORKSPACE_SETUP.md
|
||||
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/
|
||||
|
||||
+419
@@ -19,6 +19,425 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
||||
core, the monorepo or the registry's third-party plugins calls them:
|
||||
|
||||
- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`,
|
||||
`get_sport_live_interval`, `get_sport_key_from_cache_key`,
|
||||
`get_background_cached_data`, `is_background_data_available`,
|
||||
`record_cache_hit`, `record_cache_miss`, `record_fetch_time`,
|
||||
`get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`.
|
||||
- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`,
|
||||
`draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`.
|
||||
- `FontManager`: `set_override`, `remove_override`, `get_overrides`,
|
||||
`add_font`, `remove_font`, `validate_font`, `get_font_catalog`,
|
||||
`get_available_fonts`, `get_size_tokens`, `get_performance_stats`,
|
||||
`get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`,
|
||||
`unregister_plugin_fonts`.
|
||||
- `PluginManager.get_enabled_plugins`.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
`config.get(...)`, never a separate accessor
|
||||
|
||||
## Dev Workflow
|
||||
- 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
|
||||
- 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`)
|
||||
- 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>`
|
||||
@@ -63,3 +63,4 @@
|
||||
`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
|
||||
|
||||
@@ -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 set `gpio_slowdown` to `1` or `2`.
|
||||
- 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).
|
||||
- **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`.
|
||||
|
||||
|
||||
@@ -485,6 +485,10 @@ 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.
|
||||
@@ -494,15 +498,18 @@ 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`
|
||||
@@ -511,68 +518,70 @@ 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, 0-100, default: 90)
|
||||
- **`brightness`** (integer, 1-100, default: 90)
|
||||
- Display brightness level
|
||||
- Lower values (0-50) are dimmer, higher values (50-100) are brighter
|
||||
- Lower values (1-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-pwm")
|
||||
- **`hardware_mapping`** (string, default: "adafruit-hat")
|
||||
- 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)
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
|
||||
- **`"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, 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_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_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_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_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
|
||||
- **`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
|
||||
- Typical range: 100-300 nanoseconds
|
||||
- May need adjustment if you see flickering or color issues
|
||||
|
||||
#### Advanced Hardware Settings
|
||||
|
||||
- **`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
|
||||
- **`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
|
||||
|
||||
- **`limit_refresh_rate_hz`** (integer, default: 100)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
- **`disable_hardware_pulsing`** (boolean, default: false)
|
||||
- Disables hardware pulsing (usually leave as false)
|
||||
- Set to `true` only if you experience timing issues
|
||||
- Most users should leave this as `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
|
||||
|
||||
- **`inverse_colors`** (boolean, default: false)
|
||||
- Inverts all colors (red becomes cyan, etc.)
|
||||
@@ -580,9 +589,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)
|
||||
- Displays the current refresh rate on the matrix (for debugging)
|
||||
- Set to `true` to see FPS on the display
|
||||
- Useful for troubleshooting performance issues
|
||||
- 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
|
||||
|
||||
#### Advanced Panel Configuration (Advanced Users Only)
|
||||
|
||||
@@ -592,6 +601,7 @@ 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: "")
|
||||
@@ -606,35 +616,68 @@ 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:180` mapper), so custom mapper configs keep working alongside it
|
||||
`Rotate:<degrees>` 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)
|
||||
- Some panels require 1 (AB addressing) or 2 (ABC 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
|
||||
- Check your panel datasheet if display appears corrupted
|
||||
|
||||
- **`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
|
||||
- **`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
|
||||
|
||||
### Runtime Configuration (`display.runtime`)
|
||||
|
||||
These settings control runtime behavior and GPIO timing:
|
||||
|
||||
- **`gpio_slowdown`** (integer, default: 3)
|
||||
- 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
|
||||
- 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
|
||||
- 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.
|
||||
@@ -667,7 +710,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 (typically 90 seconds)
|
||||
- Leave unset to use the default cap (180 seconds; the web UI accepts 30-1800)
|
||||
|
||||
### Example Configuration
|
||||
|
||||
@@ -714,6 +757,14 @@ 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)
|
||||
@@ -788,9 +839,11 @@ sudo ./scripts/install/install_service.sh
|
||||
|
||||
The script will:
|
||||
- Detect your user account and home directory
|
||||
- Install the service file with the correct paths
|
||||
- Enable the service to start on boot
|
||||
- Start the service immediately
|
||||
- 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
|
||||
|
||||
### Managing the Service
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
|
||||
"github_user": "ChuckBuilds",
|
||||
"plugins_repo": "ledmatrix-plugins",
|
||||
"plugins_branch": "main"
|
||||
}
|
||||
+36
-14
@@ -377,9 +377,16 @@ Vegas mode consists of four core components working together to provide smooth 1
|
||||
5. Compose into continuous stream with separators
|
||||
|
||||
**Key Methods:**
|
||||
- `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
|
||||
- `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`)
|
||||
|
||||
#### 3. PluginAdapter
|
||||
|
||||
@@ -433,10 +440,14 @@ 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:**
|
||||
**Scroll Speed Calculation:** motion is by elapsed time; `target_fps` paces
|
||||
the render loop, not the speed.
|
||||
```python
|
||||
pixels_per_frame = (scroll_speed / target_fps)
|
||||
scroll_position += pixels_per_frame * elapsed_time
|
||||
# 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
|
||||
```
|
||||
|
||||
#### Component Interactions
|
||||
@@ -552,7 +563,8 @@ time when something is active.
|
||||
|
||||
### REST API Reference
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
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).
|
||||
|
||||
#### Start On-Demand Display
|
||||
|
||||
@@ -608,20 +620,30 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
||||
|
||||
# Response:
|
||||
{
|
||||
"active": true,
|
||||
"plugin_id": "weather",
|
||||
"mode": "weather",
|
||||
"remaining": 25.5,
|
||||
"pinned": false,
|
||||
"status": "active"
|
||||
"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": ""}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.py`) write a request into the cache
|
||||
> `web_interface/blueprints/api_v3/display.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,14 +250,21 @@ WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard
|
||||
|
||||
## Checking Configuration via API
|
||||
|
||||
The API blueprint mounts at `/api/v3` (`web_interface/app.py:144`).
|
||||
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
`/api/v3` in `web_interface/app.py`.
|
||||
|
||||
```bash
|
||||
# Get full main config (includes all plugin sections)
|
||||
# Get full main config (includes all plugin sections; credential-named
|
||||
# fields are blanked in the response)
|
||||
curl http://localhost:5000/api/v3/config/main
|
||||
|
||||
# Save updated main config
|
||||
# Change some settings: only the keys you send are changed
|
||||
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
|
||||
|
||||
@@ -269,8 +276,10 @@ curl "http://localhost:5000/api/v3/plugins/config?plugin_id=football-scoreboard"
|
||||
```
|
||||
|
||||
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
|
||||
> endpoint — config validation runs server-side automatically when you
|
||||
> POST to `/config/main` or `/plugins/config`. See
|
||||
> 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
|
||||
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
+36
-33
@@ -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` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||
| `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` |
|
||||
| `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,39 +47,44 @@ 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` (`src/display_manager.py`, ~lines 270–295).
|
||||
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.
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `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` |
|
||||
| `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 |
|
||||
| `inverse_colors` | bool, `false` |
|
||||
| `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.
|
||||
| `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 `""` |
|
||||
|
||||
## `display.runtime`
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
| `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 |
|
||||
|
||||
## `display.double_sided`
|
||||
|
||||
@@ -134,8 +139,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` — frame-count-based scroll stepping |
|
||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -152,14 +157,12 @@ 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 |
|
||||
| `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) |
|
||||
| `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 |
|
||||
|
||||
## Plugin config blocks
|
||||
|
||||
|
||||
+38
-33
@@ -12,10 +12,28 @@
|
||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||
- Manager font registration and detection
|
||||
- Plugin font management
|
||||
- Manual font overrides via web interface
|
||||
- Programmatic per-element font overrides
|
||||
- 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
|
||||
@@ -40,8 +58,9 @@ 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):
|
||||
self.font_manager = display_manager.font_manager # Access shared FontManager
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
||||
self.display_manager = display_manager
|
||||
self.font_manager = plugin_manager.font_manager # Shared FontManager
|
||||
self.manager_id = "my_manager"
|
||||
|
||||
def display(self):
|
||||
@@ -80,8 +99,9 @@ class MyManager:
|
||||
|
||||
```python
|
||||
class AdvancedManager:
|
||||
def __init__(self, config, display_manager, cache_manager):
|
||||
self.font_manager = display_manager.font_manager
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
||||
self.display_manager = display_manager
|
||||
self.font_manager = plugin_manager.font_manager
|
||||
self.manager_id = "advanced_manager"
|
||||
|
||||
# Define your font specifications
|
||||
@@ -152,19 +172,13 @@ font = self.font_manager.resolve_font(
|
||||
> URIs documented below are resolved relative to the plugin's
|
||||
> install directory.
|
||||
>
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
### Plugin Font Registration
|
||||
|
||||
@@ -200,10 +214,10 @@ In your plugin's `manifest.json`:
|
||||
### Using Plugin Fonts
|
||||
|
||||
```python
|
||||
class PluginManager:
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_id):
|
||||
self.font_manager = display_manager.font_manager
|
||||
self.plugin_id = plugin_id
|
||||
class MyPlugin(BasePlugin):
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.font_manager = self._get_font_manager()
|
||||
|
||||
def display(self):
|
||||
# Use plugin font (automatically namespaced)
|
||||
@@ -219,17 +233,8 @@ class PluginManager:
|
||||
|
||||
## Manual Font Overrides
|
||||
|
||||
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.
|
||||
Overrides are set in code (there is no web UI or REST endpoint for them).
|
||||
They are stored in `config/font_overrides.json` and persist across restarts.
|
||||
|
||||
### Programmatic Overrides
|
||||
|
||||
|
||||
@@ -83,10 +83,10 @@ You should see:
|
||||
|
||||
1. Open the **Display** tab
|
||||
2. Set your matrix configuration:
|
||||
- **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
|
||||
- **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
|
||||
- **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,9 +59,12 @@ sudo ./scripts/install/install_service.sh
|
||||
After updating your scripts, verify they still work:
|
||||
|
||||
```bash
|
||||
# Test installation scripts (if needed)
|
||||
# Check the installation scripts are at their new paths
|
||||
ls scripts/install/*.sh
|
||||
sudo ./scripts/install/install_service.sh --help
|
||||
./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.
|
||||
|
||||
# Test permission scripts
|
||||
ls scripts/fix_perms/*.sh
|
||||
|
||||
@@ -1,169 +1,154 @@
|
||||
# Multi-Root Workspace Setup Guide
|
||||
|
||||
This document explains how the LEDMatrix project uses a multi-root workspace to manage plugins as separate Git repositories.
|
||||
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.
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix project has been migrated from a git submodule implementation to a **multi-root workspace** implementation for managing plugins. This allows:
|
||||
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.
|
||||
|
||||
- ✅ 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/`
|
||||
- ✅ 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
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
/home/chuck/Github/
|
||||
├── LEDMatrix/ # Main project
|
||||
│ ├── plugin-repos/ # Symlinks to actual repos (managed automatically)
|
||||
│ │ ├── ledmatrix-clock-simple -> ../../ledmatrix-clock-simple
|
||||
│ │ ├── ledmatrix-weather -> ../../ledmatrix-weather
|
||||
~/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
|
||||
│ │ └── ...
|
||||
│ ├── LEDMatrix.code-workspace # Multi-root workspace configuration
|
||||
│ ├── LEDMatrix.code-workspace # Opens LEDMatrix and ../ledmatrix-plugins
|
||||
│ └── ...
|
||||
├── 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
|
||||
└── ledmatrix-plugins/ # Plugin monorepo (git repo)
|
||||
├── plugins/
|
||||
│ ├── clock-simple/
|
||||
│ ├── ledmatrix-weather/
|
||||
│ └── ...
|
||||
├── plugins.json # Store registry
|
||||
└── update_registry.py
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Plugin Repositories
|
||||
### 1. The plugin monorepo
|
||||
|
||||
All plugin repositories are cloned to `/home/chuck/Github/` (parent directory of LEDMatrix) as regular Git repositories:
|
||||
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
||||
scripts below look for `../ledmatrix-plugins` relative to the LEDMatrix
|
||||
root):
|
||||
|
||||
- `ledmatrix-clock-simple/`
|
||||
- `ledmatrix-weather/`
|
||||
- `ledmatrix-football-scoreboard/`
|
||||
- etc.
|
||||
```bash
|
||||
cd ~/Github
|
||||
git clone https://github.com/ChuckBuilds/ledmatrix-plugins.git
|
||||
```
|
||||
|
||||
### 2. Symlinks in plugin-repos/
|
||||
|
||||
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.
|
||||
`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>`.
|
||||
|
||||
### 3. Multi-Root Workspace
|
||||
### 3. Multi-root workspace
|
||||
|
||||
The `LEDMatrix.code-workspace` file configures VS Code/Cursor to open all plugin repositories as separate workspace roots, allowing easy development across all repos.
|
||||
`LEDMatrix.code-workspace` has two roots: LEDMatrix itself and
|
||||
`../ledmatrix-plugins`.
|
||||
|
||||
## Setup Scripts
|
||||
|
||||
### Initial Setup
|
||||
|
||||
If you already have plugin repositories cloned, use the setup script:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
cd ~/Github/LEDMatrix
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
```
|
||||
|
||||
This script:
|
||||
- Reads the workspace configuration
|
||||
- Creates symlinks in `plugin-repos/` pointing to actual repos
|
||||
- Verifies all links are created correctly
|
||||
- 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.
|
||||
|
||||
### Updating Plugins
|
||||
|
||||
To update all plugin repositories:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
cd ~/Github/LEDMatrix
|
||||
python3 scripts/update_plugin_repos.py
|
||||
```
|
||||
|
||||
This script:
|
||||
- Finds all plugins in the workspace
|
||||
- Runs `git pull` on each repository
|
||||
- Reports which plugins were updated
|
||||
This runs `git pull` in `../ledmatrix-plugins` and prints the result. The
|
||||
symlinks pick up the new code; restart the display to load it.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin system is configured in `config/config.json`:
|
||||
The loader reads plugins from `plugin_system.plugins_directory` in
|
||||
`config/config.json`. The default is already right for this setup:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
"plugins_directory": "plugin-repos"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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. **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
|
||||
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
|
||||
|
||||
### Adding New Plugins
|
||||
|
||||
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
|
||||
1. Create `plugins/<your-plugin-id>/` in the monorepo checkout
|
||||
2. Run `python3 scripts/setup_plugin_repos.py` in LEDMatrix to link it
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Symlinks Not Working
|
||||
|
||||
If plugins aren't being discovered:
|
||||
### Plugins not discovered
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
cd ~/Github/LEDMatrix
|
||||
ls -la plugin-repos/ # links present and not broken?
|
||||
python3 scripts/setup_plugin_repos.py # recreate them
|
||||
```
|
||||
|
||||
This will recreate all symlinks.
|
||||
Also check that `plugin_system.plugins_directory` is `plugin-repos`.
|
||||
|
||||
### Missing Plugins
|
||||
### "Monorepo plugins directory not found"
|
||||
|
||||
If a plugin is in the workspace but not found:
|
||||
`setup_plugin_repos.py` expects the monorepo at `../ledmatrix-plugins`. Clone
|
||||
it there (or symlink it there).
|
||||
|
||||
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
|
||||
### Plugin updates not showing
|
||||
|
||||
### 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
|
||||
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`)
|
||||
|
||||
## Notes
|
||||
|
||||
- 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
|
||||
- `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.
|
||||
|
||||
@@ -13,6 +13,7 @@ Complete API reference for plugin developers. This document describes all method
|
||||
- [Display Manager](#display-manager)
|
||||
- [Cache Manager](#cache-manager)
|
||||
- [Plugin Manager](#plugin-manager)
|
||||
- [Deprecated APIs](#deprecated-apis)
|
||||
|
||||
---
|
||||
|
||||
@@ -514,21 +515,59 @@ 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) -> None`
|
||||
#### `set_scrolling_state(is_scrolling: bool, frame_hold: int = 1) -> None`
|
||||
|
||||
Mark the display as scrolling or not scrolling. Call when scrolling starts/stops.
|
||||
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.
|
||||
|
||||
**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)
|
||||
# Scroll content...
|
||||
self.display_manager.set_scrolling_state(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)
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -998,9 +1037,10 @@ if "weather" in enabled_plugins:
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods
|
||||
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods,
|
||||
passing the frame hold `scroll_config.configure()` returned
|
||||
```python
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||
# Scroll content...
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
```
|
||||
@@ -1035,3 +1075,23 @@ if "weather" in enabled_plugins:
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - Complete development guide
|
||||
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
||||
|
||||
---
|
||||
|
||||
## Deprecated APIs
|
||||
|
||||
These still work in 3.6 but log a warning the first time they are called
|
||||
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.7.0**.
|
||||
Nothing in core, the official plugins or the third-party plugins in the
|
||||
registry calls them.
|
||||
|
||||
| Object | Methods | Instead |
|
||||
|---|---|---|
|
||||
| `cache_manager` | `update_cache` | `set()` |
|
||||
| `cache_manager` | `get_background_cached_data`, `is_background_data_available` | `get()` |
|
||||
| `cache_manager` | `has_data_changed`, `setup_persistent_cache`, `get_sport_live_interval`, `get_sport_key_from_cache_key`, `record_cache_hit`, `record_cache_miss`, `record_fetch_time`, `get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats` | no replacement |
|
||||
| `display_manager` | `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`, `draw_snow`, `draw_text_with_icons` | draw your own icons (the weather plugin ships `WeatherIcons`) |
|
||||
| `display_manager` | `get_scrolling_stats` | no replacement |
|
||||
| `font_manager` | `get_font_catalog`, `get_available_fonts` | read `font_catalog` |
|
||||
| `font_manager` | `set_override`, `remove_override`, `get_overrides`, `add_font`, `remove_font`, `validate_font`, `get_size_tokens`, `get_performance_stats`, `get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`, `unregister_plugin_fonts` | no replacement |
|
||||
| `plugin_manager` | `get_enabled_plugins` | check `enabled` on the entries in `plugin_manager.plugins` |
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
> - 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 is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
> blueprint (`web_interface/blueprints/api_v3/`) is mounted at `/api/v3`
|
||||
> in `web_interface/app.py`.
|
||||
> - 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`;
|
||||
|
||||
@@ -67,9 +67,7 @@ The main configuration file (`config/config.json`) now contains only essential s
|
||||
"time_format": "%I:%M %p"
|
||||
},
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
"plugins_directory": "plugin-repos"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -93,9 +91,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
|
||||
- **auto_discover**: Automatically discover plugins
|
||||
- **auto_load_enabled**: Automatically load enabled plugins
|
||||
- **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))
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
> 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.py`, and `web_interface/templates/v3/`.
|
||||
> `web_interface/blueprints/api_v3/` (plugin config handlers in
|
||||
> `plugins.py`), and `web_interface/templates/v3/`.
|
||||
> The user-facing description (Overview, Features, Form Generation
|
||||
> Process) is still accurate.
|
||||
|
||||
|
||||
+133
-381
@@ -11,427 +11,179 @@
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 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] │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 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) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP API
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 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 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ File System
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 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!", │
|
||||
│ ... │
|
||||
│ } │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Files │
|
||||
│ plugin-repos/<id>/config_schema.json JSON Schema (Draft-7) │
|
||||
│ config/config.json { "<id>": { ... } } │
|
||||
│ config/config_secrets.json { "<id>": { secrets } } │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
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. Page Load Sequence
|
||||
### 1. Rendering a plugin's tab
|
||||
|
||||
```
|
||||
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
|
||||
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)
|
||||
```
|
||||
|
||||
### 2. Configuration Save Sequence
|
||||
Nested objects are supported: a nested field is posted with a dotted name
|
||||
(e.g. `transition.type`).
|
||||
|
||||
### 2. Saving
|
||||
|
||||
```
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
## Class and Function Hierarchy
|
||||
The display service picks up the new config through its config hot reload
|
||||
(ConfigService) without a restart.
|
||||
|
||||
### Frontend (JavaScript)
|
||||
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).
|
||||
|
||||
```
|
||||
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
|
||||
### 3. Reset
|
||||
|
||||
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
|
||||
```
|
||||
`POST /api/v3/plugins/config/reset` replaces the plugin's section with the
|
||||
schema defaults (keeping secrets unless `preserve_secrets` is false).
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Dynamic Tab Generation
|
||||
### 1. Server-side rendered forms
|
||||
|
||||
**Why**: Plugins are installed/uninstalled dynamically
|
||||
**How**: JavaScript creates/removes tab elements on plugin list refresh
|
||||
**Benefit**: No server-side template rendering needed
|
||||
**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`)
|
||||
|
||||
### 2. JSON Schema as Source of Truth
|
||||
### 2. JSON Schema as source of truth
|
||||
|
||||
**Why**: Standard, well-documented, validation-ready
|
||||
**How**: Frontend interprets schema to generate forms
|
||||
**Benefit**: Plugin developers use familiar format
|
||||
**How**: The same schema drives the form, the defaults and server-side validation
|
||||
**Benefit**: Plugin developers use a familiar format
|
||||
|
||||
### 3. Individual Config Updates
|
||||
### 3. Whole-form saves that merge
|
||||
|
||||
**Why**: Simplifies backend API
|
||||
**How**: Each field saved separately via `/api/v3/plugins/config`
|
||||
**Benefit**: Atomic updates, easier error handling
|
||||
**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
|
||||
|
||||
### 4. Type Conversion in Frontend
|
||||
### 4. Secrets kept out of config.json
|
||||
|
||||
**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
|
||||
**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(...)`
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Input Types
|
||||
### Custom input widgets
|
||||
|
||||
Location: `generatePluginConfigForm()` in `index_v2.html`
|
||||
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).
|
||||
|
||||
```javascript
|
||||
if (type === 'your-new-type') {
|
||||
formHTML += `
|
||||
<!-- Your custom input HTML -->
|
||||
`;
|
||||
}
|
||||
```
|
||||
### Custom actions
|
||||
|
||||
### Custom Validation
|
||||
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).
|
||||
|
||||
Location: `savePluginConfiguration()` in `index_v2.html`
|
||||
### Reacting to changes
|
||||
|
||||
```javascript
|
||||
// Add validation before sending
|
||||
if (!validateCustomConstraint(value, propSchema)) {
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
```
|
||||
Implement `on_config_change(new_config)` in the plugin (see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)).
|
||||
|
||||
### Backend Hook
|
||||
## Where to Look
|
||||
|
||||
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
|
||||
| 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/` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
|
||||
+110
-184
@@ -2,234 +2,160 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix system has smart dependency installation that adapts based on who is running it. This guide explains how it works and potential pitfalls.
|
||||
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.
|
||||
|
||||
## How It Works
|
||||
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.
|
||||
|
||||
### Execution Context Detection
|
||||
## Who Runs What
|
||||
|
||||
The plugin manager checks if it's running as root:
|
||||
```python
|
||||
running_as_root = os.geteuid() == 0
|
||||
| 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.]
|
||||
```
|
||||
|
||||
Based on this, it chooses the appropriate installation method:
|
||||
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).
|
||||
|
||||
| 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 |
|
||||
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.
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### ✅ Scenario 1: Normal Production Use (Recommended)
|
||||
### Installing plugins from the web UI (recommended)
|
||||
|
||||
**What:** Services running via systemd
|
||||
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
|
||||
|
||||
```bash
|
||||
sudo systemctl start ledmatrix
|
||||
sudo systemctl start ledmatrix-web
|
||||
cd ~/LEDMatrix
|
||||
sudo python3 run.py # same user as the service
|
||||
```
|
||||
|
||||
- **Runs as:** root (configured in .service files)
|
||||
- **Installs to:** System-wide
|
||||
- **Result:** ✅ Works perfectly, all dependencies accessible
|
||||
Running as your own user works for plugins whose packages are already
|
||||
installed system-wide, but any *missing* package lands in `~/.local/`.
|
||||
|
||||
### ✅ Scenario 2: Web Interface Plugin Installation
|
||||
### A plugin works when run manually but fails in the service
|
||||
|
||||
**What:** Installing/enabling plugins via web interface at `http://pi-ip:5000`
|
||||
Its packages were installed for your user only. Install them as root (see
|
||||
below) and restart the service.
|
||||
|
||||
- **Web service runs as:** root (ledmatrix-web.service)
|
||||
- **Installs to:** System-wide
|
||||
- **Result:** ✅ Works perfectly, systemd service can access them
|
||||
## Manual Installation
|
||||
|
||||
### ✅ Scenario 3: Manual Testing as ledpi (Read-only)
|
||||
|
||||
**What:** Running display manually as ledpi to test/debug
|
||||
### All plugins
|
||||
|
||||
```bash
|
||||
# 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 ~/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
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.
|
||||
|
||||
### For Production/Normal Use
|
||||
### One plugin
|
||||
|
||||
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.
|
||||
```
|
||||
|
||||
**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
|
||||
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
|
||||
```
|
||||
|
||||
`--no-cache-dir` avoids errors about `/root/.cache/pip` not being writable.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
**Quick Fix - Use the Helper Script:**
|
||||
```bash
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
Use one of the manual installs above (they pass `--no-cache-dir`).
|
||||
|
||||
**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 more detailed troubleshooting, see:** [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
|
||||
|
||||
## 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:
|
||||
### Checking where a package is installed
|
||||
|
||||
```bash
|
||||
# Run as root (recommended for production)
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
# How the service sees it
|
||||
sudo python3 -c "import package_name; print(package_name.__file__)"
|
||||
|
||||
# Make executable if needed
|
||||
chmod +x /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
# A path under /home/<user>/.local/ means it was installed for that user only
|
||||
python3 -m pip show -f package_name
|
||||
```
|
||||
|
||||
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
|
||||
For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md).
|
||||
|
||||
## For Plugin Authors
|
||||
|
||||
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.
|
||||
|
||||
## Files to Reference
|
||||
|
||||
- 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`
|
||||
|
||||
- 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`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Plugin Dependency Installation Troubleshooting
|
||||
|
||||
This guide helps resolve issues with automatic plugin dependency installation in the LEDMatrix system.
|
||||
This guide helps resolve problems installing a plugin's Python packages. For
|
||||
how installation works, see the [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md).
|
||||
|
||||
## Common Error Symptoms
|
||||
|
||||
@@ -10,109 +11,118 @@ 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
|
||||
```
|
||||
|
||||
### Context Mismatch
|
||||
### Installed for the wrong user
|
||||
The pip output shown after a web-UI install starts with:
|
||||
```
|
||||
WARNING: Installing plugin dependencies for current user (not root).
|
||||
These will NOT be accessible to the systemd service.
|
||||
[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.]
|
||||
```
|
||||
|
||||
### Plugin fails to load with `ModuleNotFoundError`
|
||||
The display service can't see a package the plugin needs.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Plugin dependencies must be installed in a context accessible to the LEDMatrix systemd service, which runs as root. Permission errors typically occur when:
|
||||
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:
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Use the Manual Installation Script (Recommended)
|
||||
|
||||
We provide a helper script that handles dependency installation correctly:
|
||||
### Solution 1: Restore the sudo rule, then reinstall
|
||||
|
||||
```bash
|
||||
# Run as root to install system-wide (for production)
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
cd ~/LEDMatrix
|
||||
./scripts/install/configure_web_sudo.sh # as the web user, not with sudo
|
||||
```
|
||||
|
||||
# After installation, restart the service
|
||||
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
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
### Solution 2: Manual Installation per Plugin
|
||||
|
||||
If you need to install dependencies for a specific plugin:
|
||||
### Solution 3: Install one plugin's dependencies
|
||||
|
||||
```bash
|
||||
# Navigate to the plugin directory
|
||||
cd /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME
|
||||
# Your configured plugins directory; plugin-repos/ by default
|
||||
cd ~/LEDMatrix/plugin-repos/PLUGIN-NAME
|
||||
|
||||
# Install as root (system-wide)
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### Solution 3: Fix Cache Directory Permissions
|
||||
### Solution 4: Let the display service install them
|
||||
|
||||
If you specifically have cache permission issues:
|
||||
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
|
||||
|
||||
```bash
|
||||
# Option A: Skip the cache (recommended)
|
||||
sudo pip3 install --no-cache-dir --break-system-packages -r requirements.txt
|
||||
sudo python3 -m pip install --no-cache-dir --break-system-packages -r requirements.txt
|
||||
|
||||
# Option B: Fix cache permissions (if needed)
|
||||
# Option B: Fix cache permissions
|
||||
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**: Verify your requirements.txt works with:
|
||||
2. **Test installation** the way the Pi does it:
|
||||
```bash
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
```
|
||||
3. **Document dependencies**: Note any system packages needed (via apt)
|
||||
|
||||
### For Users
|
||||
|
||||
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
|
||||
1. **Use the web interface** to install plugins
|
||||
2. **Use sudo** for installs from SSH/terminal
|
||||
3. **Restart the service** after manual installations
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How Dependency Installation Works
|
||||
### Where installs happen
|
||||
|
||||
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
|
||||
- **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`.
|
||||
|
||||
### Why `--break-system-packages`?
|
||||
|
||||
@@ -120,26 +130,20 @@ Debian 12+ (Bookworm) and Raspberry Pi OS based on it implement PEP 668, which p
|
||||
|
||||
### Service Context
|
||||
|
||||
The ledmatrix.service runs as:
|
||||
- **User**: root
|
||||
- **WorkingDirectory**: /home/ledpi/LEDMatrix
|
||||
- **Python**: /usr/bin/python3
|
||||
- `ledmatrix.service` runs as **root** with `/usr/bin/python3`
|
||||
- `ledmatrix-web.service` runs as **the installing user**
|
||||
|
||||
Dependencies must be installed in root's Python environment or system-wide to be accessible.
|
||||
Dependencies must be installed system-wide (as root) to be visible to the
|
||||
display service.
|
||||
|
||||
## Checking Installation
|
||||
|
||||
Verify dependencies are installed correctly:
|
||||
|
||||
```bash
|
||||
# Check as root (how the service sees it)
|
||||
sudo python3 -c "import package_name"
|
||||
sudo python3 -c "import package_name; print(package_name.__file__)"
|
||||
|
||||
# List installed packages
|
||||
pip3 list
|
||||
|
||||
# Check specific package
|
||||
pip3 show package_name
|
||||
# A path under /home/<user>/.local/ means a user-only install
|
||||
python3 -m pip show -f package_name
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
@@ -151,19 +155,11 @@ If you continue to experience issues:
|
||||
sudo journalctl -u ledmatrix -f
|
||||
```
|
||||
|
||||
2. Check pip logs (created by manual script):
|
||||
2. Verify the plugin manifest and requirements (default plugins directory
|
||||
shown):
|
||||
```bash
|
||||
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
|
||||
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/manifest.json
|
||||
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/requirements.txt
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
@@ -171,4 +167,3 @@ If you continue to experience issues:
|
||||
- [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md)
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Troubleshooting](TROUBLESHOOTING.md)
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
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.matrix.width/height`) rather than hardcoding one
|
||||
> panel. For plugins that want to *scale* their layout to any panel, the
|
||||
> (`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
|
||||
> 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
|
||||
@@ -43,28 +45,45 @@ The solution uses **symbolic links** to connect plugin repositories to the `plug
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Link a Plugin from GitHub
|
||||
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.
|
||||
|
||||
The easiest way to link a plugin that's already on GitHub:
|
||||
### 1. Link an Official Plugin
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
./scripts/dev/dev_plugin_setup.sh link-github football-scoreboard
|
||||
```
|
||||
|
||||
This will:
|
||||
- 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`
|
||||
- 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`
|
||||
|
||||
### 2. Link a Local Plugin Repository
|
||||
`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.
|
||||
|
||||
If you already have a plugin repository cloned locally:
|
||||
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:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link music ../ledmatrix-music
|
||||
./scripts/dev/dev_plugin_setup.sh link hello-world ../ledmatrix-plugins/plugins/hello-world
|
||||
```
|
||||
|
||||
This creates a symlink from `plugins/music` to your local repository path.
|
||||
This creates a symlink from `plugins/hello-world` to that directory.
|
||||
|
||||
### 3. Check Status
|
||||
|
||||
@@ -77,13 +96,17 @@ See which plugins are linked and their git status:
|
||||
### 4. Work on Your Plugin
|
||||
|
||||
```bash
|
||||
cd plugins/music # Actually editing the linked repository
|
||||
# Make your changes
|
||||
cd plugins/football-scoreboard # Actually editing the monorepo checkout
|
||||
# Make your changes, then bump "version" in manifest.json
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
git commit -m "feat(football-scoreboard): add new feature"
|
||||
git push # to your fork, then open a PR against ledmatrix-plugins
|
||||
```
|
||||
|
||||
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:
|
||||
@@ -116,7 +139,7 @@ Links a local plugin repository to the plugins directory.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-football-scoreboard
|
||||
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-plugins/plugins/football-scoreboard
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
@@ -129,23 +152,25 @@ Links a local plugin repository to the plugins directory.
|
||||
Clones a plugin from GitHub and links it.
|
||||
|
||||
**Arguments:**
|
||||
- `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`
|
||||
- `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.
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Auto-construct URL from plugin name
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
# Official plugin, from the ledmatrix-plugins monorepo
|
||||
./scripts/dev/dev_plugin_setup.sh link-github stocks
|
||||
|
||||
# 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
|
||||
# Third-party plugin with its own repository
|
||||
./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
|
||||
|
||||
@@ -217,30 +242,28 @@ Updates plugin(s) by running `git pull` in their repositories.
|
||||
|
||||
### Custom Development Directory
|
||||
|
||||
By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`. You can customize this by creating a `dev_plugins.json` file:
|
||||
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.
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
|
||||
"github_user": "your-github-user",
|
||||
"plugins_repo": "ledmatrix-plugins",
|
||||
"plugins_branch": "main"
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration options:**
|
||||
**Configuration options** (all optional):
|
||||
- `dev_plugins_dir`: Where to clone GitHub repositories (default: `~/.ledmatrix-dev-plugins`)
|
||||
- `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_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.
|
||||
|
||||
**Note:** Copy `dev_plugins.json.example` to `dev_plugins.json` and customize it. The `dev_plugins.json` file is git-ignored.
|
||||
`github_pattern` from older versions of this guide is no longer used (the
|
||||
script warns if it is set).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -248,43 +271,46 @@ By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`. You c
|
||||
|
||||
1. **Link your plugin for development:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
./scripts/dev/dev_plugin_setup.sh link-github clock-simple
|
||||
```
|
||||
|
||||
2. **Test in LEDMatrix:**
|
||||
```bash
|
||||
# Run LEDMatrix with your plugin
|
||||
python run.py
|
||||
# Run LEDMatrix with your plugin (emulator shown)
|
||||
python3 run.py -e
|
||||
```
|
||||
|
||||
3. **Make changes:**
|
||||
```bash
|
||||
cd plugins/music
|
||||
cd plugins/clock-simple
|
||||
# Edit files...
|
||||
# Test changes...
|
||||
```
|
||||
|
||||
4. **Commit to plugin repository:**
|
||||
4. **Commit to the plugin repository:**
|
||||
```bash
|
||||
cd plugins/music # This is actually your repo
|
||||
cd plugins/clock-simple # This is inside your monorepo checkout
|
||||
# bump "version" in manifest.json, then from the monorepo root:
|
||||
# python update_registry.py
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
git commit -m "feat(clock-simple): add new feature"
|
||||
git push
|
||||
```
|
||||
|
||||
5. **Update from remote (if needed):**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh update music
|
||||
./scripts/dev/dev_plugin_setup.sh update clock-simple
|
||||
```
|
||||
|
||||
6. **When done developing:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh unlink music
|
||||
./scripts/dev/dev_plugin_setup.sh unlink clock-simple
|
||||
```
|
||||
|
||||
### Working with Multiple Plugins
|
||||
|
||||
You can have multiple plugins linked simultaneously:
|
||||
You can have multiple plugins linked simultaneously. Plugins linked from the
|
||||
monorepo share one checkout:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
@@ -294,7 +320,7 @@ You can have multiple plugins linked simultaneously:
|
||||
# Check status of all
|
||||
./scripts/dev/dev_plugin_setup.sh status
|
||||
|
||||
# Update all at once
|
||||
# Update all at once (the shared monorepo checkout is pulled once)
|
||||
./scripts/dev/dev_plugin_setup.sh update
|
||||
```
|
||||
|
||||
@@ -409,7 +435,7 @@ If you have conflicts when updating:
|
||||
|
||||
1. **Manually resolve in the plugin repository:**
|
||||
```bash
|
||||
cd ~/.ledmatrix-dev-plugins/ledmatrix-music
|
||||
cd ~/.ledmatrix-dev-plugins/ledmatrix-plugins
|
||||
git pull
|
||||
# Resolve conflicts...
|
||||
git add .
|
||||
@@ -470,18 +496,19 @@ You can mix local and GitHub plugins:
|
||||
|
||||
The development workflow is separate from the plugin store installation:
|
||||
|
||||
- **Plugin Store:** Installs plugins to `plugins/` as regular directories
|
||||
- **Development Setup:** Links plugin repositories as symlinks
|
||||
- **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/`
|
||||
|
||||
If you install a plugin via the store, you can still link it for development:
|
||||
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.
|
||||
|
||||
```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.
|
||||
`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).
|
||||
|
||||
## API Reference
|
||||
|
||||
@@ -525,7 +552,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-hello-world) as a starting point
|
||||
- Use the [Hello World plugin](https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hello-world) as a starting point
|
||||
- Or fork an existing plugin and modify it
|
||||
|
||||
3. **Follow the plugin structure**:
|
||||
@@ -589,24 +616,16 @@ Your plugin must:
|
||||
### Versioning Best Practices
|
||||
|
||||
- **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
### Submitting to Official Registry
|
||||
|
||||
@@ -618,12 +637,14 @@ To have your plugin added to the official plugin store:
|
||||
- Follows best practices
|
||||
- Tested on Raspberry Pi hardware
|
||||
|
||||
2. **Create GitHub repository**:
|
||||
- Repository name: `ledmatrix-<plugin-name>`
|
||||
- Public repository
|
||||
- Proper README.md with installation instructions
|
||||
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
|
||||
|
||||
3. **Contact maintainers**:
|
||||
3. **Contact maintainers** (own-repository plugins):
|
||||
- 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
|
||||
|
||||
@@ -127,7 +127,8 @@ git push origin v1.0.0
|
||||
|
||||
### REST API
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
The API is mounted at `/api/v3` (the `api_v3` blueprint in
|
||||
`web_interface/blueprints/api_v3/`, registered in `web_interface/app.py`).
|
||||
|
||||
```bash
|
||||
# Install plugin from the registry
|
||||
|
||||
@@ -103,6 +103,7 @@ 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"}'
|
||||
```
|
||||
|
||||
@@ -153,6 +154,7 @@ 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"}'
|
||||
```
|
||||
|
||||
@@ -312,6 +314,7 @@ 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/search?q=hockey"
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?query=hockey"
|
||||
|
||||
# Filter by category
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?category=sports"
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?category=sports"
|
||||
|
||||
# Filter by tags
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/search?tags=nhl&tags=hockey"
|
||||
curl "http://your-pi-ip:5000/api/v3/plugins/store/list?tags=nhl&tags=hockey"
|
||||
```
|
||||
|
||||
**Via Python:**
|
||||
@@ -351,8 +351,7 @@ All API endpoints return JSON with this structure:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v3/plugins/store/list` | List all plugins in store |
|
||||
| GET | `/api/v3/plugins/store/search` | Search for plugins |
|
||||
| GET | `/api/v3/plugins/store/list` | List plugins in store; `?query=`, `?category=`, `?tags=` search and filter |
|
||||
| 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 |
|
||||
|
||||
+832
-380
File diff suppressed because it is too large
Load Diff
+46
-12
@@ -83,8 +83,9 @@ 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 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
|
||||
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:
|
||||
|
||||
@@ -102,10 +103,18 @@ 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` call is the mistake that matters: the speed still
|
||||
resolves, but the panel keeps presenting a new frame every refresh, so a slow
|
||||
snapped speed falls back to fractional pixels. Pass `snap_to_crisp=False` to
|
||||
keep an exact requested speed and accept the artefacts.
|
||||
`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
|
||||
@@ -123,9 +132,12 @@ settings = scroll_config.configure(
|
||||
self.scroll_helper,
|
||||
plugin_config=self.config,
|
||||
global_config=self.global_config,
|
||||
refresh_hz=scroll_config.refresh_hz_from_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
|
||||
@@ -154,6 +166,14 @@ 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
|
||||
@@ -198,8 +218,16 @@ 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.
|
||||
|
||||
`ScrollHelper` now accumulates elapsed time in both modes at the same
|
||||
configured speed, so position stays proportional to real time.
|
||||
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
|
||||
|
||||
@@ -222,11 +250,17 @@ 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 10 ms, p95 within ~0.5 ms of it | healthy — locked to the panel |
|
||||
| p95 or max at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
|
||||
| non-zero **skips**, or a median *below* 10 ms | **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. |
|
||||
| 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
|
||||
|
||||
@@ -80,11 +80,30 @@ 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
|
||||
@@ -199,6 +218,14 @@ 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
|
||||
@@ -445,8 +472,9 @@ 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 is that adopted plugins honour the
|
||||
global `target_fps` instead of hardcoding ~100 FPS.
|
||||
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).
|
||||
|
||||
### Decision: stop adopting further modules until B6 closes
|
||||
|
||||
|
||||
@@ -158,9 +158,11 @@ This script will check:
|
||||
- Python dependencies
|
||||
- Configuration files
|
||||
- File permissions
|
||||
- Web interface availability
|
||||
- Web interface availability (`ledmatrix-web` listening on port 5000)
|
||||
- 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 -l web_interface/blueprints/api_v3.py
|
||||
ls -ld web_interface/blueprints/api_v3/
|
||||
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
|
||||
sudo python3 scripts/utils/clear_cache.py --clear-all
|
||||
|
||||
# Or remove files manually from the cache dir in use, e.g.:
|
||||
sudo rm -rf /var/cache/ledmatrix/*
|
||||
|
||||
+24
-15
@@ -102,7 +102,9 @@ Configure basic system settings:
|
||||
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, the checkout has local commits, a
|
||||
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.
|
||||
@@ -132,18 +134,26 @@ require a display service restart from **Overview**.
|
||||
Configure your LED matrix hardware:
|
||||
|
||||
**Matrix configuration:**
|
||||
- `rows` — LED rows (typically 32 or 64)
|
||||
- `cols` — LED columns (typically 64 or 96)
|
||||
- `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)
|
||||
- `chain_length` — number of horizontally chained panels
|
||||
- `parallel` — number of parallel chains
|
||||
- `parallel` — number of parallel chains (1–3)
|
||||
- `hardware_mapping` — `adafruit-hat-pwm` (with PWM jumper mod),
|
||||
`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%
|
||||
`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%
|
||||
- `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
|
||||
@@ -198,11 +208,11 @@ Manage fonts for your display:
|
||||
- See font previews
|
||||
- Check font sizes and styles
|
||||
|
||||
**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
|
||||
**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.
|
||||
|
||||
**Delete Fonts:**
|
||||
- Remove unused fonts
|
||||
@@ -319,9 +329,8 @@ The web interface is built on a REST API that you can access programmatically:
|
||||
http://your-pi-ip:5000/api/v3
|
||||
```
|
||||
|
||||
The API blueprint mounts at `/api/v3` (see
|
||||
`web_interface/app.py:199`). All endpoints below are relative to that
|
||||
base.
|
||||
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
`/api/v3` in `web_interface/app.py`.
|
||||
|
||||
**Common Endpoints:**
|
||||
- `GET /api/v3/config/main` — Get main configuration
|
||||
|
||||
@@ -10,7 +10,8 @@ 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.matrix.width/height`) and lay themselves out
|
||||
(`self.display_manager.width/height` — not `matrix.width/height`, since
|
||||
`matrix` is `None` when hardware init fails) and lay themselves out
|
||||
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
||||
here.
|
||||
|
||||
|
||||
@@ -281,6 +281,42 @@ 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
|
||||
|
||||
+34
-10
@@ -1504,6 +1504,9 @@ 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)
|
||||
@@ -1514,7 +1517,7 @@ BASH_PATH=$(which bash)
|
||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||
|
||||
# Create sudoers content
|
||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||
cat > "$SUDOERS_TMP" << EOF
|
||||
# LED Matrix Web Interface passwordless sudo configuration
|
||||
# This allows the web interface user to run specific commands without a password
|
||||
|
||||
@@ -1541,7 +1544,7 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||
cat >> "$SUDOERS_TMP" << EOF
|
||||
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||
# shell -- the standard journalctl escalation. The web interface always passes
|
||||
@@ -1555,17 +1558,38 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||
echo "Sudoers configuration already up to date"
|
||||
rm /tmp/ledmatrix_web_sudoers
|
||||
# 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 "Installing/updating sudoers configuration..."
|
||||
cp /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
rm /tmp/ledmatrix_web_sudoers
|
||||
echo "⚠ visudo not found; installing the sudoers rules unvalidated"
|
||||
fi
|
||||
|
||||
echo "✓ Passwordless sudo access configured"
|
||||
if [ "$SUDOERS_VALID" = "0" ]; then
|
||||
rm -f "$SUDOERS_TMP"
|
||||
elif [ -f "$SUDOERS_FILE" ] && cmp -s "$SUDOERS_TMP" "$SUDOERS_FILE"; then
|
||||
echo "Sudoers configuration already up to date"
|
||||
rm -f "$SUDOERS_TMP"
|
||||
else
|
||||
echo "Installing/updating sudoers configuration..."
|
||||
cp "$SUDOERS_TMP" "$SUDOERS_FILE"
|
||||
chmod 440 "$SUDOERS_FILE"
|
||||
rm -f "$SUDOERS_TMP"
|
||||
fi
|
||||
|
||||
if [ "$SUDOERS_VALID" = "1" ]; then
|
||||
echo "✓ Passwordless sudo access configured"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
CURRENT_STEP="Configure WiFi management permissions"
|
||||
|
||||
@@ -114,5 +114,10 @@ python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/
|
||||
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` through `/api/v3/config/main`.
|
||||
The display service picks it up on its next restart, not instantly.
|
||||
- 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).
|
||||
|
||||
@@ -239,7 +239,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.matrix.width}x{self.display_manager.matrix.height}, "
|
||||
self.logger.info(f"Display size: {self.display_manager.width}x{self.display_manager.height}, "
|
||||
f"recommended magnify: {self.calculated_magnify}")
|
||||
|
||||
# Load installed apps
|
||||
@@ -323,8 +323,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Recommended magnify value (1-8)
|
||||
"""
|
||||
try:
|
||||
display_width = self.display_manager.matrix.width
|
||||
display_height = self.display_manager.matrix.height
|
||||
display_width = self.display_manager.width
|
||||
display_height = self.display_manager.height
|
||||
|
||||
# Tronbyte native resolution
|
||||
NATIVE_WIDTH = 64
|
||||
@@ -362,8 +362,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Dictionary with recommendation details
|
||||
"""
|
||||
try:
|
||||
display_width = self.display_manager.matrix.width
|
||||
display_height = self.display_manager.matrix.height
|
||||
display_width = self.display_manager.width
|
||||
display_height = self.display_manager.height
|
||||
|
||||
NATIVE_WIDTH = 64
|
||||
NATIVE_HEIGHT = 32
|
||||
@@ -852,8 +852,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
|
||||
# Scale frames if needed
|
||||
if self.config.get("scale_output", True):
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
# Get scaling method from config
|
||||
scale_method_str = self.config.get("scale_method", "nearest")
|
||||
|
||||
@@ -54,8 +54,13 @@ def main():
|
||||
config = config_manager.load_config()
|
||||
print(" ✅ Config loaded")
|
||||
|
||||
autostart = config.get('web_display_autostart', False)
|
||||
print(f" 🔧 web_display_autostart: {autostart}")
|
||||
# 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})")
|
||||
except Exception as e:
|
||||
print(f" ❌ Config check failed: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -12,9 +12,19 @@ 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
|
||||
|
||||
+179
-61
@@ -10,8 +10,14 @@ 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"
|
||||
GITHUB_USER="ChuckBuilds"
|
||||
GITHUB_PATTERN="ledmatrix-"
|
||||
# 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=""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -37,18 +43,55 @@ 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
|
||||
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"
|
||||
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
|
||||
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"
|
||||
@@ -63,7 +106,7 @@ validate_plugin() {
|
||||
get_plugin_id() {
|
||||
local plugin_path="$1"
|
||||
if [[ -f "$plugin_path/manifest.json" ]]; then
|
||||
jq -r '.id // empty' "$plugin_path/manifest.json" 2>/dev/null || echo ""
|
||||
json_field "$plugin_path/manifest.json" id
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -176,9 +219,55 @@ 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
|
||||
@@ -188,38 +277,46 @@ link_github_plugin() {
|
||||
|
||||
load_config
|
||||
|
||||
# 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
|
||||
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
|
||||
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
|
||||
|
||||
# Validate plugin structure
|
||||
if ! validate_plugin "$target_dir"; then
|
||||
log_error "Cloned repository does not appear to be a valid plugin"
|
||||
# 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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Link the plugin
|
||||
link_plugin "$plugin_name" "$target_dir"
|
||||
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"
|
||||
}
|
||||
|
||||
# Unlink a plugin
|
||||
@@ -274,7 +371,7 @@ list_plugins() {
|
||||
echo " → $target"
|
||||
|
||||
# Check git status if it's a git repo
|
||||
if [[ -d "$target/.git" ]]; then
|
||||
if [[ -n "$(git_root_of "$target")" ]]; 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
|
||||
@@ -327,7 +424,7 @@ check_status() {
|
||||
echo -e "${GREEN}✓${NC} ${BLUE}$plugin_name${NC}"
|
||||
echo " Path: $target"
|
||||
|
||||
if [[ -d "$target/.git" ]]; then
|
||||
if [[ -n "$(git_root_of "$target")" ]]; 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")
|
||||
@@ -360,9 +457,13 @@ check_status() {
|
||||
done
|
||||
|
||||
echo "Summary:"
|
||||
echo " ${GREEN}Clean: $clean_count${NC}"
|
||||
echo " ${YELLOW}Needs attention: $dirty_count${NC}"
|
||||
[[ $broken_count -gt 0 ]] && echo -e " ${RED}Broken: $broken_count${NC}"
|
||||
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
|
||||
}
|
||||
|
||||
# Update plugin(s)
|
||||
@@ -384,20 +485,24 @@ update_plugins() {
|
||||
fi
|
||||
|
||||
local target=$(get_symlink_target "$plugin_name")
|
||||
local root
|
||||
root=$(git_root_of "$target")
|
||||
|
||||
if [[ ! -d "$target/.git" ]]; then
|
||||
if [[ -z "$root" ]]; then
|
||||
log_error "Plugin repository is not a git repository: $target"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Updating $plugin_name from $target"
|
||||
(cd "$target" && git pull --rebase)
|
||||
log_info "Updating $plugin_name from $root"
|
||||
(cd "$root" && git pull --rebase)
|
||||
log_success "Updated $plugin_name"
|
||||
else
|
||||
# Update all linked plugins
|
||||
# Update all linked plugins. Plugins linked from the monorepo share one
|
||||
# checkout, which is pulled once.
|
||||
log_info "Updating all linked plugins..."
|
||||
local updated=0
|
||||
local failed=0
|
||||
local pulled_roots=" "
|
||||
|
||||
for item in "$PLUGINS_DIR"/*; do
|
||||
[[ -e "$item" ]] || continue
|
||||
@@ -408,15 +513,18 @@ update_plugins() {
|
||||
|
||||
if is_symlink "$item"; then
|
||||
local target=$(get_symlink_target "$name")
|
||||
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
|
||||
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))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -439,7 +547,12 @@ Commands:
|
||||
|
||||
link-github <plugin-name> [repo-url]
|
||||
Clone and link a plugin from GitHub
|
||||
If repo-url is not provided, uses: https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}<plugin-name>.git
|
||||
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
|
||||
|
||||
unlink <plugin-name>
|
||||
Remove symlink for a plugin (preserves repository)
|
||||
@@ -458,14 +571,14 @@ Commands:
|
||||
Show this help message
|
||||
|
||||
Examples:
|
||||
# Link a local plugin
|
||||
$0 link music ../ledmatrix-music
|
||||
# Link an official plugin from the monorepo
|
||||
$0 link-github football-scoreboard
|
||||
|
||||
# Link from GitHub (auto-detects URL)
|
||||
$0 link-github music
|
||||
# Link a plugin from a local monorepo checkout
|
||||
$0 link hello-world ../ledmatrix-plugins/plugins/hello-world
|
||||
|
||||
# Link from GitHub with custom URL
|
||||
$0 link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
|
||||
# Link a third-party plugin from its own repository
|
||||
$0 link-github my-plugin https://github.com/OtherUser/ledmatrix-my-plugin.git
|
||||
|
||||
# Check status
|
||||
$0 status
|
||||
@@ -474,9 +587,14 @@ Examples:
|
||||
$0 update
|
||||
|
||||
Configuration:
|
||||
Create dev_plugins.json in project root to customize:
|
||||
Copy dev_plugins.json.example to dev_plugins.json (git-ignored) to customize:
|
||||
- dev_plugins_dir: Where to clone GitHub repos (default: ~/.ledmatrix-dev-plugins)
|
||||
- plugins: Plugin definitions (optional, for auto-discovery)
|
||||
- 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.
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
+16
-12
@@ -142,17 +142,18 @@ 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."""
|
||||
"""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():
|
||||
return {}
|
||||
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
|
||||
return _load_config_defaults(schema_path.parent)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -303,10 +304,13 @@ def _parse_render_request(data):
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config = {'enabled': True}
|
||||
config.update(load_config_defaults(trusted_dir))
|
||||
config.update(data.get('config', {}))
|
||||
# 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)
|
||||
|
||||
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
||||
|
||||
|
||||
@@ -20,6 +20,38 @@ 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}"
|
||||
@@ -41,14 +73,26 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then
|
||||
echo -e "${GREEN}✓ Config file found${NC}"
|
||||
|
||||
# Check web_display_autostart setting
|
||||
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$')
|
||||
AUTOSTART=$(web_autostart_state "$PROJECT_DIR")
|
||||
|
||||
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
|
||||
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
|
||||
else
|
||||
echo -e "${RED}✗ Config file not found at: $PROJECT_DIR/config/config.json${NC}"
|
||||
fi
|
||||
@@ -63,7 +107,7 @@ declare -a REQUIRED_FILES=(
|
||||
"web_interface/app.py"
|
||||
"web_interface/start.py"
|
||||
"web_interface/requirements.txt"
|
||||
"web_interface/blueprints/api_v3.py"
|
||||
"web_interface/blueprints/api_v3/__init__.py"
|
||||
"web_interface/blueprints/pages_v3.py"
|
||||
"scripts/utils/start_web_conditionally.py"
|
||||
)
|
||||
@@ -134,8 +178,8 @@ if ! sudo systemctl is-active --quiet ledmatrix-web; then
|
||||
echo " sudo systemctl start ledmatrix-web"
|
||||
fi
|
||||
|
||||
if [ "$AUTOSTART" != "true" ]; then
|
||||
echo -e "${YELLOW}→ Enable web_display_autostart in config/config.json${NC}"
|
||||
if [ "${AUTOSTART%% *}" = "off" ]; then
|
||||
echo -e "${YELLOW}→ Set web_display_autostart to true in config/config.json (or remove it; missing means on)${NC}"
|
||||
fi
|
||||
|
||||
if [ "$ALL_FILES_OK" = false ]; then
|
||||
|
||||
+53
-12
@@ -22,6 +22,38 @@ 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
|
||||
@@ -47,16 +79,25 @@ echo "3. Checking configuration file..."
|
||||
echo "------------------------------"
|
||||
if [ -f "${PROJECT_DIR}/config/config.json" ]; then
|
||||
echo -e "${GREEN}✓ Config file exists${NC}"
|
||||
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
|
||||
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
|
||||
else
|
||||
echo -e "${RED}✗ Config file NOT FOUND at ${PROJECT_DIR}/config/config.json${NC}"
|
||||
fi
|
||||
@@ -82,7 +123,7 @@ FILES_TO_CHECK=(
|
||||
"web_interface/start.py"
|
||||
"web_interface/app.py"
|
||||
"web_interface/requirements.txt"
|
||||
"web_interface/blueprints/api_v3.py"
|
||||
"web_interface/blueprints/api_v3/__init__.py"
|
||||
"web_interface/blueprints/pages_v3.py"
|
||||
)
|
||||
|
||||
@@ -175,7 +216,7 @@ echo "Diagnostic Summary"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Most common issues:"
|
||||
echo " 1. web_display_autostart is false or missing in config.json"
|
||||
echo " 1. web_display_autostart is set to false in config.json (a missing key means on)"
|
||||
echo " 2. Service not enabled or not started"
|
||||
echo " 3. Missing dependencies (Flask, etc.)"
|
||||
echo " 4. Import errors in web_interface/app.py"
|
||||
|
||||
@@ -7,8 +7,10 @@ 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 the `ledmatrix` service user or by `root`.
|
||||
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.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -16,11 +18,12 @@ owned by the `ledmatrix` service user or by `root`.
|
||||
permissions on the `assets/` tree so plugins can download and cache
|
||||
team logos, fonts, and other static content.
|
||||
|
||||
- **`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_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_plugin_permissions.sh`** — Fixes ownership on the plugins
|
||||
directory so both the root display service and the web service user
|
||||
@@ -31,6 +34,11 @@ owned by the `ledmatrix` service user or by `root`.
|
||||
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,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
# safe_pip_install.sh — Install a requirements.txt as root after validating
|
||||
# that the resolved path is the project's own requirements.txt or a plugin's
|
||||
# that the resolved path is one of the project's own requirements files
|
||||
# (requirements.txt, web_interface/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
|
||||
@@ -25,9 +26,17 @@ 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.txt
|
||||
# - 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.
|
||||
# - any requirements.txt under plugin-repos/ or plugins/
|
||||
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
||||
ALLOWED_EXACT=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT")/requirements.txt"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/web_interface")/requirements.txt"
|
||||
)
|
||||
ALLOWED_BASES=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
||||
@@ -43,9 +52,13 @@ if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
||||
fi
|
||||
|
||||
ALLOWED=false
|
||||
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
||||
ALLOWED=true
|
||||
else
|
||||
for EXACT in "${ALLOWED_EXACT[@]}"; do
|
||||
if [ "$RESOLVED_TARGET" = "$EXACT" ]; then
|
||||
ALLOWED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
||||
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
||||
ALLOWED=true
|
||||
@@ -56,7 +69,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,14 +7,18 @@ 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 the main LED Matrix display service (systemd)
|
||||
- **`install_web_service.sh`** - Installs the web interface service (systemd)
|
||||
- **`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_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 `ledmatrix` user
|
||||
the WiFi management permissions needed by the web interface and
|
||||
the WiFi monitor service
|
||||
- **`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
|
||||
- **`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
|
||||
|
||||
@@ -130,6 +130,19 @@ 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 "--------------------------------"
|
||||
|
||||
@@ -3,6 +3,41 @@
|
||||
# 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"
|
||||
@@ -70,7 +105,7 @@ 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, CacheDirectory and Environment=USE_THREADING. Because
|
||||
# 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.
|
||||
|
||||
@@ -32,7 +32,7 @@ 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, SyslogIdentifier and CacheDirectory while
|
||||
# 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
|
||||
@@ -62,31 +62,47 @@ for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path;
|
||||
fi
|
||||
done
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
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"
|
||||
# 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"
|
||||
chown root:"$CACHE_GROUP" "$CACHE_DIR" 2>/dev/null || true
|
||||
echo "✓ Cache directory created: $CACHE_DIR"
|
||||
else
|
||||
# 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
|
||||
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
|
||||
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..."
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# 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'
|
||||
@@ -28,15 +30,50 @@ echo ""
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
LEDMATRIX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PLUGINS_DIR="$LEDMATRIX_DIR/plugins"
|
||||
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"
|
||||
|
||||
echo "LEDMatrix directory: $LEDMATRIX_DIR"
|
||||
echo "Plugins directory: $PLUGINS_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 ""
|
||||
|
||||
# Check if plugins directory exists
|
||||
if [ ! -d "$PLUGINS_DIR" ]; then
|
||||
# Check if a plugins directory exists
|
||||
if [ ${#SCAN_DIRS[@]} -eq 0 ]; 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
|
||||
|
||||
@@ -47,12 +84,21 @@ echo ""
|
||||
PLUGINS_FOUND=0
|
||||
PLUGINS_INSTALLED=0
|
||||
PLUGINS_FAILED=0
|
||||
SEEN_PLUGIN_PATHS=" "
|
||||
|
||||
for plugin_dir in "$PLUGINS_DIR"/*/ ; do
|
||||
for scan_dir in "${SCAN_DIRS[@]}"; do
|
||||
for plugin_dir in "$scan_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}"
|
||||
@@ -79,6 +125,7 @@ for plugin_dir in "$PLUGINS_DIR"/*/ ; do
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
|
||||
+69
-45
@@ -47,52 +47,47 @@ from src.common import scroll_config # noqa: E402
|
||||
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
|
||||
|
||||
|
||||
def load_hardware():
|
||||
def load_config():
|
||||
"""The whole config.json, or {} when it is missing or unreadable."""
|
||||
try:
|
||||
with open(CONFIG, encoding="utf-8") as handle:
|
||||
return (json.load(handle).get("display") or {}).get("hardware") or {}
|
||||
config = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
|
||||
def build_options(hardware, refresh_override=None):
|
||||
from rgbmatrix import RGBMatrixOptions
|
||||
|
||||
o = RGBMatrixOptions()
|
||||
from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
)
|
||||
o.rows = int(hardware.get("rows", DEFAULT_ROWS))
|
||||
o.cols = int(hardware.get("cols", DEFAULT_COLS))
|
||||
o.chain_length = int(hardware.get("chain_length", DEFAULT_CHAIN_LENGTH))
|
||||
o.parallel = int(hardware.get("parallel", DEFAULT_PARALLEL))
|
||||
o.brightness = int(hardware.get("brightness", 80))
|
||||
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
|
||||
o.pwm_bits = int(hardware.get("pwm_bits", 11))
|
||||
o.pwm_dither_bits = int(hardware.get("pwm_dither_bits", 0))
|
||||
o.pwm_lsb_nanoseconds = int(hardware.get("pwm_lsb_nanoseconds", 130))
|
||||
o.led_rgb_sequence = hardware.get("led_rgb_sequence", "RGB")
|
||||
o.scan_mode = int(hardware.get("scan_mode", 0))
|
||||
o.row_address_type = int(hardware.get("row_address_type", 0))
|
||||
o.multiplexing = int(hardware.get("multiplexing", 0))
|
||||
o.gpio_slowdown = int(hardware.get("gpio_slowdown", 2))
|
||||
o.limit_refresh_rate_hz = (
|
||||
int(refresh_override) if refresh_override is not None
|
||||
else int(hardware.get("limit_refresh_rate_hz", 0))
|
||||
)
|
||||
return o
|
||||
def hardware_of(config):
|
||||
return (config.get("display") or {}).get("hardware") or {}
|
||||
|
||||
|
||||
def open_matrix(hardware, refresh_override=None):
|
||||
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 rgbmatrix import RGBMatrix
|
||||
except ImportError:
|
||||
sys.exit("rgbmatrix is not installed on this machine")
|
||||
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(hardware, refresh_override))
|
||||
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"
|
||||
@@ -101,7 +96,7 @@ def open_matrix(hardware, refresh_override=None):
|
||||
)
|
||||
|
||||
|
||||
def measure_refresh(hardware, seconds=6.0):
|
||||
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
|
||||
@@ -109,7 +104,7 @@ def measure_refresh(hardware, seconds=6.0):
|
||||
chain will really give you, as opposed to whatever limit_refresh_rate_hz
|
||||
optimistically asks for.
|
||||
"""
|
||||
matrix = open_matrix(hardware, refresh_override=0)
|
||||
matrix = open_matrix(config, refresh_override=0)
|
||||
canvas = matrix.CreateFrameCanvas()
|
||||
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
|
||||
frames = 0
|
||||
@@ -122,17 +117,17 @@ def measure_refresh(hardware, seconds=6.0):
|
||||
return measured
|
||||
|
||||
|
||||
def demo(hardware, target, seconds):
|
||||
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 = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ)
|
||||
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(hardware)
|
||||
matrix = open_matrix(config)
|
||||
canvas = matrix.CreateFrameCanvas()
|
||||
W, H = canvas.width, canvas.height
|
||||
|
||||
@@ -195,9 +190,38 @@ def print_ladder(hz, highlight=None):
|
||||
) else ""
|
||||
print(" " + entry.describe() + mark)
|
||||
print("")
|
||||
print("Set one in config.json as pixels per second, e.g.")
|
||||
print(' "display_options": {{"scroll_pixels_per_second": {:.0f}}}'.format(
|
||||
scroll_config.solve_crisp(highlight if highlight else hz / 2, hz).pixels_per_second))
|
||||
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():
|
||||
@@ -214,15 +238,15 @@ def main():
|
||||
help="highlight the entry nearest this speed")
|
||||
args = ap.parse_args()
|
||||
|
||||
hardware = load_hardware()
|
||||
configured = float(hardware.get("limit_refresh_rate_hz") or 0)
|
||||
config = load_config()
|
||||
configured = float(hardware_of(config).get("limit_refresh_rate_hz") or 0)
|
||||
|
||||
if args.demo is not None:
|
||||
demo(hardware, args.demo, args.seconds)
|
||||
demo(config, args.demo, args.seconds)
|
||||
return
|
||||
|
||||
if args.measure:
|
||||
measured = measure_refresh(hardware)
|
||||
measured = measure_refresh(config)
|
||||
print("measured panel refresh: {:.1f}Hz".format(measured))
|
||||
if configured:
|
||||
print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
|
||||
|
||||
@@ -42,10 +42,30 @@ HEALTH_TIMEOUT_SECONDS = 180
|
||||
#: 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.
|
||||
#: 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.
|
||||
@@ -83,7 +103,7 @@ def write_pending(path, data):
|
||||
|
||||
def _web_responds(url=WEB_HEALTH_URL):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310 - fixed loopback URL
|
||||
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
|
||||
@@ -104,7 +124,7 @@ class Verifier:
|
||||
self.web_responds = web_responds
|
||||
self.log = log or (lambda msg: print(f'[auto-update-verify] {msg}', flush=True))
|
||||
|
||||
def _run(self, args, timeout=60):
|
||||
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)
|
||||
@@ -114,15 +134,17 @@ class Verifier:
|
||||
# -- services ---------------------------------------------------------
|
||||
|
||||
def service_active(self, unit):
|
||||
return self._run(['systemctl', 'is-active', unit], timeout=10).stdout.strip() == 'active'
|
||||
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=10).stdout.strip()
|
||||
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=90)
|
||||
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
|
||||
@@ -173,16 +195,28 @@ class Verifier:
|
||||
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):
|
||||
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:
|
||||
result = self._run(['sudo', '-n', bash, str(wrapper), str(req)],
|
||||
timeout=PIP_TIMEOUT_SECONDS)
|
||||
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):
|
||||
@@ -191,13 +225,17 @@ class Verifier:
|
||||
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)
|
||||
# Safe to --hard: the updater refuses to run with local edits to
|
||||
# tracked files, so the only thing this discards is the update.
|
||||
result = self._run(['git', 'reset', '--hard', old], timeout=120)
|
||||
# --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()}')
|
||||
failed = [rel for rel in requirements if not self.install_requirements(rel)]
|
||||
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')
|
||||
|
||||
@@ -121,19 +121,24 @@ 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 ==="
|
||||
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
|
||||
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
|
||||
|
||||
# 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 -q ":5001" || ss -tuln 2>/dev/null | grep -q ":5001"; then
|
||||
check_pass "Web interface is listening on port 5001"
|
||||
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"
|
||||
else
|
||||
check_warn "Web service is running but port 5001 may not be listening"
|
||||
check_warn "Web service is running but port $WEB_PORT may not be listening"
|
||||
fi
|
||||
else
|
||||
check_warn "Web service is not running (cannot check port)"
|
||||
@@ -204,7 +209,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}'):5001"
|
||||
echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):$WEB_PORT"
|
||||
echo "2. Check service status: sudo systemctl status ledmatrix.service"
|
||||
echo "3. View logs: journalctl -u ledmatrix.service -f"
|
||||
exit 0
|
||||
|
||||
+25
-21
@@ -8,6 +8,10 @@ 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'
|
||||
@@ -32,25 +36,25 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. Check if port 5001 is listening
|
||||
echo "2. Checking if port 5001 is listening..."
|
||||
# 2. Check if port $WEB_PORT is listening
|
||||
echo "2. Checking if port $WEB_PORT is listening..."
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if ss -tuln 2>/dev/null | grep -q ":5001"; then
|
||||
echo -e "${GREEN}✓${NC} Port 5001 is listening"
|
||||
if ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
|
||||
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
|
||||
echo ""
|
||||
echo "Active connections on port 5001:"
|
||||
ss -tuln | grep ":5001"
|
||||
echo "Active connections on port $WEB_PORT:"
|
||||
ss -tuln | grep -E "$PORT_PATTERN"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
|
||||
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
|
||||
fi
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
if netstat -tuln 2>/dev/null | grep -q ":5001"; then
|
||||
echo -e "${GREEN}✓${NC} Port 5001 is listening"
|
||||
if netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"; then
|
||||
echo -e "${GREEN}✓${NC} Port $WEB_PORT is listening"
|
||||
echo ""
|
||||
echo "Active connections on port 5001:"
|
||||
netstat -tuln | grep ":5001"
|
||||
echo "Active connections on port $WEB_PORT:"
|
||||
netstat -tuln | grep -E "$PORT_PATTERN"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Port 5001 is NOT listening"
|
||||
echo -e "${RED}✗${NC} Port $WEB_PORT is NOT listening"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Cannot check port (ss/netstat not available)"
|
||||
@@ -59,15 +63,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: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 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 [ "$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 5001"
|
||||
echo -e "${RED}✗${NC} Cannot connect to web interface on port $WEB_PORT"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -79,7 +83,7 @@ if [ -n "$IP_ADDRESSES" ]; then
|
||||
echo ""
|
||||
echo "Access web interface at:"
|
||||
for ip in $IP_ADDRESSES; do
|
||||
echo " http://$ip:5001"
|
||||
echo " http://$ip:$WEB_PORT"
|
||||
done
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Could not determine IP address"
|
||||
@@ -118,12 +122,12 @@ if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
|
||||
SERVICE_RUNNING=true
|
||||
fi
|
||||
|
||||
if (ss -tuln 2>/dev/null | grep -q ":5001") || (netstat -tuln 2>/dev/null | grep -q ":5001"); then
|
||||
if (ss -tuln 2>/dev/null | grep -qE "$PORT_PATTERN") || (netstat -tuln 2>/dev/null | grep -qE "$PORT_PATTERN"); then
|
||||
PORT_LISTENING=true
|
||||
fi
|
||||
|
||||
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 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 [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
|
||||
HTTP_RESPONDING=true
|
||||
fi
|
||||
@@ -134,7 +138,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:5001"
|
||||
echo " http://$ip:$WEB_PORT"
|
||||
done
|
||||
exit 0
|
||||
elif [ "$SERVICE_RUNNING" = false ]; then
|
||||
@@ -145,7 +149,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 5001 is not listening${NC}"
|
||||
echo -e "${RED}✗ Service is running but port $WEB_PORT is not listening${NC}"
|
||||
echo ""
|
||||
echo "Check logs for errors:"
|
||||
echo " sudo journalctl -u ledmatrix-web.service -f"
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.4.0"
|
||||
__version__ = "3.5.0"
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ 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__)
|
||||
|
||||
@@ -99,6 +107,11 @@ class BackgroundDataService:
|
||||
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.
|
||||
@@ -247,6 +260,12 @@ 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,
|
||||
@@ -254,7 +273,7 @@ class BackgroundDataService:
|
||||
year=year,
|
||||
cache_key=cache_key,
|
||||
url=url,
|
||||
params=params or {},
|
||||
params=dict(params or {}),
|
||||
headers={**self.default_headers, **(headers or {})},
|
||||
timeout=timeout or self.request_timeout,
|
||||
max_retries=max_retries,
|
||||
@@ -338,12 +357,38 @@ class BackgroundDataService:
|
||||
|
||||
logger.info(f"Starting background fetch for {request.sport} {request.year}")
|
||||
|
||||
# Perform HTTP request with retry logic
|
||||
response = self._make_request_with_retry(request)
|
||||
response.raise_for_status()
|
||||
# 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
|
||||
|
||||
# Parse response
|
||||
data = response.json()
|
||||
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()
|
||||
|
||||
# Validate data structure
|
||||
if not isinstance(data, dict):
|
||||
@@ -519,6 +564,23 @@ 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,12 +530,15 @@ 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:
|
||||
if existing_owner is not None and hasattr(os, 'chown'):
|
||||
# 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):
|
||||
|
||||
@@ -10,6 +10,7 @@ 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."""
|
||||
@@ -71,10 +72,10 @@ class ESPNDataSource(DataSource):
|
||||
now = datetime.now()
|
||||
formatted_date = now.strftime("%Y%m%d")
|
||||
url = f"{self.base_url}/{sport}/{league}/scoreboard"
|
||||
response = self.session.get(url, params={"dates": formatted_date, "limit": 1000}, headers=self.get_headers(), timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params={"dates": formatted_date, "limit": 1000},
|
||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
||||
)
|
||||
events = data.get('events', [])
|
||||
|
||||
# Filter for live games
|
||||
@@ -99,10 +100,10 @@ class ESPNDataSource(DataSource):
|
||||
"limit": 1000
|
||||
}
|
||||
|
||||
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params=params,
|
||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
||||
)
|
||||
events = data.get('events', [])
|
||||
|
||||
self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}")
|
||||
@@ -113,35 +114,68 @@ class ESPNDataSource(DataSource):
|
||||
return []
|
||||
|
||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
||||
"""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()
|
||||
"""Fetch standings, or the poll for leagues that have one.
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug(f"Fetched standings for {sport}/{league}")
|
||||
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}")
|
||||
return data
|
||||
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 {}
|
||||
self.logger.debug(
|
||||
f"Standings/rankings not available for {sport}/{league} from ESPN API"
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
class MLBAPIDataSource(DataSource):
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -844,9 +845,11 @@ 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"
|
||||
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()
|
||||
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,
|
||||
)
|
||||
events = data.get('events', [])
|
||||
|
||||
self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}")
|
||||
@@ -869,9 +872,10 @@ 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"
|
||||
response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session, url, params={"dates": date_str, "limit": 1000},
|
||||
headers=self.headers, timeout=10, logger=self.logger,
|
||||
)
|
||||
immediate_events = data.get('events', [])
|
||||
|
||||
if immediate_events:
|
||||
|
||||
Vendored
+132
-15
@@ -7,6 +7,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
|
||||
@@ -139,6 +140,65 @@ else:
|
||||
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."""
|
||||
|
||||
@@ -350,13 +410,12 @@ class DiskCache:
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') 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:
|
||||
@@ -368,12 +427,8 @@ class DiskCache:
|
||||
try:
|
||||
with open(cache_path, 'wb') 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
|
||||
@@ -398,11 +453,7 @@ class DiskCache:
|
||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||
with open(fallback_path, 'wb') as tmp_file:
|
||||
tmp_file.write(payload)
|
||||
# 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
|
||||
_share_open_file(tmp_file.fileno(), _shared_group(fallback_dir))
|
||||
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
|
||||
return # Successfully wrote to fallback, exit gracefully
|
||||
except (IOError, OSError, PermissionError) as e2:
|
||||
@@ -460,6 +511,72 @@ class DiskCache:
|
||||
"""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:
|
||||
"""Whether a name is one of set()'s temp files rather than real data.
|
||||
|
||||
@@ -38,6 +38,7 @@ from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
from src.deprecation import deprecated
|
||||
|
||||
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
||||
# because this module's docstring documents it and external code may import
|
||||
@@ -473,6 +474,7 @@ class CacheManager:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
|
||||
"""Check if data has changed from cached version."""
|
||||
cached_data = self.load_cache(data_type)
|
||||
@@ -578,6 +580,7 @@ class CacheManager:
|
||||
"""Check if the US stock market is currently open."""
|
||||
return self._strategy_component.is_market_open()
|
||||
|
||||
@deprecated("3.7.0", "use set()")
|
||||
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
|
||||
"""Update cache with new data."""
|
||||
cache_data = {
|
||||
@@ -623,6 +626,7 @@ class CacheManager:
|
||||
cache_data['ttl'] = ttl
|
||||
self.save_cache(key, cache_data)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def setup_persistent_cache(self) -> bool:
|
||||
"""
|
||||
Set up a persistent cache directory with proper permissions.
|
||||
@@ -762,6 +766,14 @@ 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")
|
||||
@@ -826,6 +838,7 @@ class CacheManager:
|
||||
else:
|
||||
self.logger.info("Disk cache cleanup thread stopped successfully")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
||||
"""
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
@@ -847,6 +860,7 @@ class CacheManager:
|
||||
"""
|
||||
return self._strategy_component.get_data_type_from_key(key)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Extract sport key from cache key to determine appropriate live_update_interval.
|
||||
@@ -886,6 +900,7 @@ class CacheManager:
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
return self.get_cached_data_with_strategy(key, data_type)
|
||||
|
||||
@deprecated("3.7.0", "use get()")
|
||||
def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from background service cache with appropriate strategy.
|
||||
@@ -923,6 +938,7 @@ class CacheManager:
|
||||
self.record_cache_miss('background')
|
||||
return None
|
||||
|
||||
@deprecated("3.7.0", "use get()")
|
||||
def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if background service has fresh data available.
|
||||
@@ -952,26 +968,32 @@ class CacheManager:
|
||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||
return f"{sport}_{date_str}"
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def record_cache_hit(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache hit for performance monitoring."""
|
||||
self._metrics_component.record_hit(cache_type)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def record_cache_miss(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache miss for performance monitoring."""
|
||||
self._metrics_component.record_miss(cache_type)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def record_fetch_time(self, duration: float) -> None:
|
||||
"""Record fetch operation duration for performance monitoring."""
|
||||
self._metrics_component.record_fetch_time(duration)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_cache_metrics(self) -> Dict[str, Any]:
|
||||
"""Get current cache performance metrics."""
|
||||
return self._metrics_component.get_metrics()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def log_cache_metrics(self) -> None:
|
||||
"""Log current cache performance metrics."""
|
||||
self._metrics_component.log_metrics()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_memory_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the memory cache.
|
||||
|
||||
@@ -8,6 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
@@ -153,9 +154,11 @@ class APIHelper:
|
||||
cache_key = f"espn_{sport}_{league}_{date}"
|
||||
|
||||
# Set parameters
|
||||
# limit above 500 makes ESPN truncate instead of erroring: college
|
||||
# football came back with 25 of 68 games. See src/common/espn_dates.py.
|
||||
params = {
|
||||
'dates': date,
|
||||
'limit': 1000
|
||||
'limit': ESPN_MAX_LIMIT
|
||||
}
|
||||
|
||||
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Working around two ESPN site-API behaviours that silently break scoreboards.
|
||||
|
||||
Both were found on 2026-09-15, when every scoreboard on the Pi started logging
|
||||
``400 Client Error: Bad Request`` against URLs that had worked the day before.
|
||||
|
||||
1. **Date ranges are rejected.** ``?dates=YYYYMMDD-YYYYMMDD`` answers
|
||||
``400 {"code":400,"message":"Failed to get events endpoint."}`` for *every*
|
||||
sport -- football, baseball, hockey, basketball, soccer alike. Single days
|
||||
(``?dates=YYYYMMDD``), whole months (``?dates=YYYYMM``) and season years
|
||||
(``?dates=YYYY``) still answer 200.
|
||||
|
||||
2. **``limit`` above 500 corrupts the response.** ``limit=1000`` -- what every
|
||||
caller in this codebase used to send -- makes college-football return 25
|
||||
events where the truthful answer is 68 for a single Saturday and 323 for a
|
||||
month. No error, just a short list. The cutoff sits between 500 and 600.
|
||||
NFL-sized days never noticed, which is why this hid for so long.
|
||||
|
||||
The fix for (1) is to re-ask in units ESPN still honours. Whole calendar months
|
||||
covered by the range become one ``YYYYMM`` request each and the leftover days at
|
||||
either end become one ``YYYYMMDD`` request each, so the chunks cover the
|
||||
requested window *exactly* -- no client-side date filtering, and therefore no
|
||||
guessing at which timezone ESPN means by "a game day". A full NFL season
|
||||
(20260801-20270301) costs 8 requests rather than 213 per-day ones.
|
||||
|
||||
A month can hold more than 500 events (college baseball's March does), and
|
||||
ESPN answers that with exactly ``limit`` events and no hint that more exist. A
|
||||
month chunk that comes back full is therefore re-asked day by day.
|
||||
|
||||
Once a range has been rejected, later ranges skip straight to chunks for
|
||||
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
|
||||
scoreboards ask every 30 seconds. After that the range is tried again, so the
|
||||
workaround retires itself if ESPN reverts.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||
# docstring: 500 is the largest value measured to return complete data.
|
||||
ESPN_MAX_LIMIT = 500
|
||||
|
||||
# How long a rejected range keeps later ranges from being tried as ranges.
|
||||
RANGE_RETRY_SECONDS = 6 * 60 * 60
|
||||
|
||||
# How many chunk requests may be in flight at once. Four busy months of
|
||||
# college baseball are ~130 chunks once each is re-asked day by day: 17.7s one
|
||||
# at a time on a Pi 4, 2.6-3.3s six at a time. Kept under requests' default
|
||||
# pool_maxsize of 10 so the shared Session never has to discard connections.
|
||||
ESPN_CHUNK_WORKERS = 6
|
||||
|
||||
_range_lock = threading.Lock()
|
||||
_ranges_rejected_until = 0.0
|
||||
|
||||
__all__ = [
|
||||
"ESPN_MAX_LIMIT",
|
||||
"ESPN_CHUNK_WORKERS",
|
||||
"RANGE_RETRY_SECONDS",
|
||||
"clamp_espn_limit",
|
||||
"parse_espn_date_range",
|
||||
"espn_date_chunks",
|
||||
"merge_scoreboard_payloads",
|
||||
"fetch_espn_date_chunks",
|
||||
"fetch_espn_scoreboard",
|
||||
]
|
||||
|
||||
|
||||
def clamp_espn_limit(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Return a copy of ``params`` with any ``limit`` over 500 pulled back to 500."""
|
||||
out = dict(params or {})
|
||||
raw = out.get("limit")
|
||||
if raw is None:
|
||||
return out
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return out
|
||||
if value > ESPN_MAX_LIMIT:
|
||||
out["limit"] = ESPN_MAX_LIMIT
|
||||
return out
|
||||
|
||||
|
||||
def parse_espn_date_range(dates: Any) -> Optional[Tuple[date, date]]:
|
||||
"""Parse ``"YYYYMMDD-YYYYMMDD"`` into dates, or return None.
|
||||
|
||||
None means "not a day range" -- a single day, a month, a season year, or
|
||||
anything unparseable. Those forms still work upstream and must be passed
|
||||
through untouched rather than rewritten.
|
||||
"""
|
||||
if not isinstance(dates, str):
|
||||
return None
|
||||
halves = dates.split("-")
|
||||
if len(halves) != 2 or len(halves[0]) != 8 or len(halves[1]) != 8:
|
||||
return None
|
||||
try:
|
||||
start = date(int(halves[0][:4]), int(halves[0][4:6]), int(halves[0][6:]))
|
||||
end = date(int(halves[1][:4]), int(halves[1][4:6]), int(halves[1][6:]))
|
||||
except ValueError:
|
||||
return None
|
||||
if end < start:
|
||||
return None
|
||||
return start, end
|
||||
|
||||
|
||||
def _ranges_known_rejected() -> bool:
|
||||
with _range_lock:
|
||||
return time.monotonic() < _ranges_rejected_until
|
||||
|
||||
|
||||
def _note_range_rejected() -> None:
|
||||
global _ranges_rejected_until
|
||||
with _range_lock:
|
||||
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
|
||||
|
||||
|
||||
def _first_of_next_month(day: date) -> date:
|
||||
return date(day.year + (day.month == 12), day.month % 12 + 1, 1)
|
||||
|
||||
|
||||
def _days_of_month(chunk: str) -> List[str]:
|
||||
day = date(int(chunk[:4]), int(chunk[4:6]), 1)
|
||||
stop = _first_of_next_month(day)
|
||||
days = []
|
||||
while day < stop:
|
||||
days.append(day.strftime("%Y%m%d"))
|
||||
day += timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
def espn_date_chunks(start: date, end: date) -> List[str]:
|
||||
"""Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts.
|
||||
|
||||
Whole calendar months inside the window collapse to one ``YYYYMM`` chunk;
|
||||
partial months at the edges are spelled out day by day. The chunks tile the
|
||||
window exactly -- they never reach outside it -- so merging their events
|
||||
needs no date filtering afterwards.
|
||||
"""
|
||||
chunks: List[str] = []
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
month_end = _first_of_next_month(cursor) - timedelta(days=1)
|
||||
if cursor.day == 1 and month_end <= end:
|
||||
chunks.append(cursor.strftime("%Y%m"))
|
||||
cursor = month_end + timedelta(days=1)
|
||||
else:
|
||||
chunks.append(cursor.strftime("%Y%m%d"))
|
||||
cursor += timedelta(days=1)
|
||||
return chunks
|
||||
|
||||
|
||||
def merge_scoreboard_payloads(payloads: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Fold chunk responses into one scoreboard payload.
|
||||
|
||||
Events are de-duplicated by id and keep first-seen order. Non-event keys
|
||||
(``leagues``, ``season``, ``week``) come from the first payload that has
|
||||
them, matching what a single un-chunked response would have looked like.
|
||||
"""
|
||||
merged: Dict[str, Any] = {}
|
||||
events: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for payload in payloads:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for key, value in payload.items():
|
||||
if key != "events" and key not in merged:
|
||||
merged[key] = value
|
||||
for event in payload.get("events") or []:
|
||||
event_id = event.get("id") if isinstance(event, dict) else None
|
||||
if event_id is not None:
|
||||
if event_id in seen:
|
||||
continue
|
||||
seen.add(event_id)
|
||||
events.append(event)
|
||||
merged["events"] = events
|
||||
return merged
|
||||
|
||||
|
||||
def _fetch_one_chunk(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""GET a single ``dates=`` chunk, or None when it failed.
|
||||
|
||||
One bad chunk must not sink the rest of the season, so every error is
|
||||
logged and swallowed here rather than raised to the gather below.
|
||||
"""
|
||||
try:
|
||||
response = session.get(
|
||||
url,
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
if logger:
|
||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_chunks(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
||||
chunks: List[str],
|
||||
) -> List[Optional[Dict[str, Any]]]:
|
||||
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
||||
|
||||
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
|
||||
over a hundred of them. The order they come back in is not significant --
|
||||
callers keep ``chunks`` order from the returned list -- but it does mean
|
||||
the session is shared across threads, which is why this only ever issues
|
||||
GETs and never touches session state.
|
||||
"""
|
||||
if not chunks:
|
||||
return []
|
||||
fetch = partial(
|
||||
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
||||
)
|
||||
if len(chunks) == 1:
|
||||
return [fetch(chunks[0])]
|
||||
workers = min(ESPN_CHUNK_WORKERS, len(chunks))
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=workers, thread_name_prefix="espn-chunk",
|
||||
) as pool:
|
||||
return list(pool.map(fetch, chunks))
|
||||
|
||||
|
||||
def fetch_espn_date_chunks(
|
||||
session,
|
||||
url: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger=None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a ``YYYYMMDD-YYYYMMDD`` window as month and day chunks.
|
||||
|
||||
Returns None when ``params["dates"]`` is not a day range, or when every
|
||||
chunk failed. Callers treat None as "re-raise the original error": caching
|
||||
an empty payload would read as "no games this season".
|
||||
|
||||
Chunks are always asked with ``limit=500``. ESPN's default page is smaller
|
||||
than a busy month (100 for NFL, 300 for college football), and 500 is the
|
||||
largest value that does not corrupt the answer. A month that comes back
|
||||
with 500 events is assumed truncated and re-asked day by day. A failed
|
||||
chunk is logged and skipped so one bad day cannot cost a whole season.
|
||||
|
||||
Chunks go out ``ESPN_CHUNK_WORKERS`` at a time, in two passes: the months
|
||||
and edge days first, then the days of any month that came back capped.
|
||||
Merged events keep ``espn_date_chunks`` order regardless of which request
|
||||
finished first, so the result does not depend on the race.
|
||||
"""
|
||||
params = dict(params or {})
|
||||
span = parse_espn_date_range(params.get("dates"))
|
||||
if span is None:
|
||||
return None
|
||||
|
||||
chunks = espn_date_chunks(*span)
|
||||
if logger:
|
||||
logger.debug(
|
||||
"Fetching ESPN date range %s as %d month/day chunks",
|
||||
params.get("dates"), len(chunks),
|
||||
)
|
||||
|
||||
results = _fetch_chunks(
|
||||
session, url, params, headers, timeout, logger, chunks,
|
||||
)
|
||||
attempted = len(chunks)
|
||||
|
||||
# A month that came back at the cap is truncated; its days replace it in
|
||||
# place, so merged events stay in chunk order however the requests raced.
|
||||
slots: List[Any] = results
|
||||
capped: Dict[int, List[str]] = {}
|
||||
for index, chunk in enumerate(chunks):
|
||||
payload = slots[index]
|
||||
if payload is None or len(chunk) != 6:
|
||||
continue
|
||||
events = payload.get("events") if isinstance(payload, dict) else None
|
||||
if len(events or []) >= ESPN_MAX_LIMIT:
|
||||
if logger:
|
||||
logger.info(
|
||||
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
||||
chunk, ESPN_MAX_LIMIT,
|
||||
)
|
||||
capped[index] = _days_of_month(chunk)
|
||||
# Drop the truncated month now rather than after its days arrive:
|
||||
# a capped college-baseball month is ~2MB of parsed JSON, and
|
||||
# holding four of them through ~120 day requests added ~25MB to
|
||||
# the peak -- more than the concurrency itself. Low-memory boards
|
||||
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
|
||||
slots[index] = None
|
||||
payload = events = None
|
||||
|
||||
if capped:
|
||||
days = [day for index in sorted(capped) for day in capped[index]]
|
||||
attempted += len(days)
|
||||
by_day = dict(zip(days, _fetch_chunks(
|
||||
session, url, params, headers, timeout, logger, days,
|
||||
)))
|
||||
for index, month_days in capped.items():
|
||||
slots[index] = [by_day.get(day) for day in month_days]
|
||||
|
||||
payloads: List[Dict[str, Any]] = []
|
||||
for slot in slots:
|
||||
if slot is None:
|
||||
continue
|
||||
if isinstance(slot, list):
|
||||
payloads.extend(payload for payload in slot if payload is not None)
|
||||
else:
|
||||
payloads.append(slot)
|
||||
|
||||
if not payloads:
|
||||
return None
|
||||
|
||||
merged = merge_scoreboard_payloads(payloads)
|
||||
if logger:
|
||||
logger.debug(
|
||||
"Recovered %d events for %s from %d/%d chunk requests",
|
||||
len(merged["events"]), params.get("dates"), len(payloads), attempted,
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def fetch_espn_scoreboard(
|
||||
session,
|
||||
url: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 15,
|
||||
logger=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""GET an ESPN scoreboard, re-asking in month/day chunks if a range 400s.
|
||||
|
||||
Anything that is not a ``YYYYMMDD-YYYYMMDD`` range is one request with the
|
||||
caller's own parameters (``limit`` clamped), so single-day and season-year
|
||||
callers see no change. A range that ESPN rejects is re-fetched in chunks,
|
||||
and later ranges go straight to chunks for ``RANGE_RETRY_SECONDS``. A 400 on
|
||||
a non-range request, any other error, and a range whose every chunk fails
|
||||
all raise as before.
|
||||
"""
|
||||
params = clamp_espn_limit(params)
|
||||
is_range = parse_espn_date_range(params.get("dates")) is not None
|
||||
|
||||
chunks_tried = False
|
||||
if is_range and _ranges_known_rejected():
|
||||
data = fetch_espn_date_chunks(
|
||||
session, url, params=params, headers=headers,
|
||||
timeout=timeout, logger=logger,
|
||||
)
|
||||
if data is not None:
|
||||
return data
|
||||
# Every chunk failed: ask for the range itself so the caller gets a
|
||||
# real error to log, without spending the chunks a second time.
|
||||
chunks_tried = True
|
||||
|
||||
response = session.get(url, params=params, headers=headers, timeout=timeout)
|
||||
if is_range and response.status_code == 400 and not chunks_tried:
|
||||
_note_range_rejected()
|
||||
if logger:
|
||||
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",
|
||||
params.get("dates"), RANGE_RETRY_SECONDS // 3600,
|
||||
)
|
||||
data = fetch_espn_date_chunks(
|
||||
session, url, params=params, headers=headers,
|
||||
timeout=timeout, logger=logger,
|
||||
)
|
||||
if data is not None:
|
||||
return data
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -186,7 +186,17 @@ def ensure_shared_group_ownership(path: Path) -> None:
|
||||
is group-readable, but without this the group is root's, not the web
|
||||
user's. Silently does nothing if not running as root or on any error —
|
||||
this is a hardening step, not a required one.
|
||||
|
||||
``os.geteuid``/``os.chown`` only exist on POSIX. On Windows there is no
|
||||
root and no shared group to move the file to, so the whole step is moot —
|
||||
but looking the names up unguarded raises ``AttributeError``, which is not
|
||||
an ``OSError`` and so escapes every caller's error handling. That took
|
||||
``ConfigManager.load_config()`` down on any Windows checkout that has a
|
||||
``config/config_secrets.json``, i.e. every developer machine that has ever
|
||||
run the app, and with it the import of ``web_interface.app``.
|
||||
"""
|
||||
if not hasattr(os, 'geteuid') or not hasattr(os, 'chown'):
|
||||
return
|
||||
if os.geteuid() != 0:
|
||||
return
|
||||
gid = get_shared_group_gid()
|
||||
@@ -363,6 +373,14 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
|
||||
return False
|
||||
|
||||
|
||||
#: What sudo prints when it refuses a command line outright (not in sudoers for
|
||||
#: that exact argv, or it wants a password). Only then is another bash path
|
||||
#: worth trying: after pip itself ran, a retry just repeats the failure. The
|
||||
#: automatic update's health check keeps its own copy of this list
|
||||
#: (scripts/utils/auto_update_verify.py runs without importing src/).
|
||||
SUDO_REFUSAL_PHRASES = ("a password is required", "is not allowed to run", "no tty present")
|
||||
|
||||
|
||||
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Install a requirements.txt file for a plugin (or the project itself).
|
||||
@@ -424,10 +442,7 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
# Distinguish "sudo rejected this exact command line" (worth
|
||||
# trying the next bash candidate) from "sudo ran it but pip
|
||||
# itself failed" (a real error — stop and surface it).
|
||||
denied = any(
|
||||
phrase in result.stderr
|
||||
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
||||
)
|
||||
denied = any(phrase in result.stderr for phrase in SUDO_REFUSAL_PHRASES)
|
||||
if not denied:
|
||||
# Deliberately don't interpolate req_file or the pip output here:
|
||||
# this log line is scanner-visible, and a static analyzer can't
|
||||
|
||||
+33
-13
@@ -22,12 +22,27 @@ shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
|
||||
the requested speed will not divide evenly, because that is a real display
|
||||
artefact and not a rounding detail.
|
||||
|
||||
Speed is always expressed to the helper as pixels per second and applied in
|
||||
time-based mode. Frame-based stepping gates 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 and makes the
|
||||
step count flip on sub-millisecond jitter. Accumulating elapsed time keeps
|
||||
position proportional to real time instead.
|
||||
How the speed is applied
|
||||
------------------------
|
||||
:func:`configure` snaps the requested speed to a :class:`CrispSpeed` -- a whole
|
||||
number of pixels per presented frame, with each frame held for ``frame_hold``
|
||||
panel refreshes -- and puts the helper in fixed-step mode
|
||||
(``ScrollHelper.set_pixels_per_frame``). In that mode the helper consults no
|
||||
clock: every ``update_scroll_position()`` call moves exactly that many pixels.
|
||||
``SwapOnVSync`` blocks until the panel has taken each frame, so the frame count
|
||||
is the clock, and the speed on the panel is::
|
||||
|
||||
px/s = refresh_hz / frame_hold * pixels_per_frame
|
||||
|
||||
That makes the hold part of the speed. The caller must pass
|
||||
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True, ...)``;
|
||||
a caller that omits it is presented every refresh and scrolls ``frame_hold``
|
||||
times too fast. The refresh is the panel's (``display_manager.refresh_hz``,
|
||||
i.e. ``display.hardware.limit_refresh_rate_hz``) -- never the global
|
||||
``target_fps``, which no longer paces anything.
|
||||
|
||||
With ``snap_to_crisp=False`` there is no fixed step: the helper is set to
|
||||
px/s and advances by elapsed time, and the hold is 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -330,17 +345,18 @@ def configure(
|
||||
) -> ScrollSettings:
|
||||
"""Resolve the config and apply it to ``scroll_helper``.
|
||||
|
||||
Applied in time-based mode: see the module docstring for why frame-based
|
||||
stepping is not used. ``hasattr`` guards keep this usable against older
|
||||
ScrollHelper builds that a plugin may be running on.
|
||||
Frame-based mode is switched off, the (snapped) speed is set, and with
|
||||
``snap_to_crisp`` the helper steps a fixed whole-pixel amount per presented
|
||||
frame -- see the module docstring. ``hasattr`` guards keep this usable
|
||||
against older ScrollHelper builds that a plugin may be running on.
|
||||
|
||||
:param display_manager: consulted for the panel's refresh rate only (it can
|
||||
see display.hardware; a plugin cannot). The frame hold is NOT applied
|
||||
here -- see the note in the body. The caller must pass
|
||||
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
|
||||
...)`` when it starts scrolling, or a sub-refresh speed still presents
|
||||
a new frame every refresh and the motion falls back to fractional
|
||||
pixels.
|
||||
...)`` when it scrolls. The helper moves its fixed step on every call,
|
||||
so without the hold each step is presented every refresh and the
|
||||
scroll runs ``frame_hold`` times faster than the resolved speed.
|
||||
:param snap_to_crisp: move the requested speed to the nearest speed the
|
||||
panel can show in whole pixels. On by default because a speed that does
|
||||
not divide evenly has no good rendering, only a choice of artefacts.
|
||||
@@ -357,7 +373,8 @@ def configure(
|
||||
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
|
||||
# so deriving it afterwards described a 100Hz panel to everyone running at
|
||||
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
|
||||
# set_target_fps() paced the helper to 100 FPS on a 60Hz panel.
|
||||
# the settings and the helper's (informational) target_fps said 100 FPS on
|
||||
# a 60Hz panel.
|
||||
hz = _coerce(refresh_hz)
|
||||
if hz is None and display_manager is not None:
|
||||
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
||||
@@ -399,6 +416,9 @@ def configure(
|
||||
if hasattr(scroll_helper, "set_pixels_per_frame"):
|
||||
scroll_helper.set_pixels_per_frame(
|
||||
choice.pixels_per_frame if choice else None)
|
||||
# Informational only: nothing in the helper paces off target_fps. It is
|
||||
# still recorded because plugins read it back (ledmatrix-elections'
|
||||
# test_scroll_pacing.py asserts it equals the crisp presentation rate).
|
||||
if choice and hasattr(scroll_helper, "set_target_fps"):
|
||||
scroll_helper.set_target_fps(choice.frames_per_second)
|
||||
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
|
||||
|
||||
+52
-36
@@ -22,13 +22,6 @@ from typing import Optional, Dict, Any
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# Try to import scipy for sub-pixel interpolation, fallback to simpler method if not available
|
||||
try:
|
||||
from scipy.ndimage import shift
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
HAS_SCIPY = False
|
||||
|
||||
|
||||
# How often the frame-stats line is emitted, and therefore also the ceiling
|
||||
# on a believable frame time: a scroll that renders at all cannot take this
|
||||
@@ -138,21 +131,22 @@ class ScrollHelper:
|
||||
# blend (blur) or repeat frames (judder); blending is the worse of the
|
||||
# two here. Vegas mode still opts in via set_sub_pixel_scrolling().
|
||||
self.sub_pixel_scrolling = False
|
||||
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
||||
|
||||
# Frame-based scrolling settings
|
||||
self.frame_based_scrolling = False
|
||||
#: Whole pixels to advance per presented frame, or None to pace
|
||||
#: off elapsed time. See set_pixels_per_frame.
|
||||
self.fixed_pixels_per_frame = None # If True, use scroll_delay to throttle and move scroll_speed pixels
|
||||
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
||||
self.fixed_pixels_per_frame = None
|
||||
self.last_step_time = 0.0 # Time of the last position update
|
||||
|
||||
# Time tracking for scroll updates
|
||||
self.last_update_time: Optional[float] = None
|
||||
|
||||
# High FPS settings
|
||||
self.target_fps = 120 # Target 120 FPS for smooth scrolling
|
||||
self.frame_time_target = 1.0 / self.target_fps
|
||||
#: Informational only: the presentation rate scroll_config chose
|
||||
#: (panel refresh / frame hold). Nothing paces off it -- the helper
|
||||
#: steps per call and SwapOnVSync paces the calls. Kept because
|
||||
#: plugins and their tests read it back.
|
||||
self.target_fps = 120
|
||||
|
||||
# Dynamic duration settings
|
||||
self.dynamic_duration_enabled = True
|
||||
@@ -288,7 +282,14 @@ class ScrollHelper:
|
||||
|
||||
def update_scroll_position(self) -> None:
|
||||
"""
|
||||
Update scroll position with high FPS control and handle wrap-around.
|
||||
Advance the scroll by one presented frame and handle wrap-around.
|
||||
|
||||
With a fixed per-frame step (set_pixels_per_frame, which
|
||||
scroll_config.configure sets for a crisp speed) every call moves
|
||||
exactly that many pixels and no clock is read; the caller's
|
||||
vsync-blocking swap, held for ``frame_hold`` refreshes, sets the rate.
|
||||
Otherwise the position advances by elapsed time at the configured
|
||||
speed.
|
||||
"""
|
||||
if not self.cached_image:
|
||||
return
|
||||
@@ -461,10 +462,9 @@ class ScrollHelper:
|
||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||
|
||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||||
installed on the target devices (HAS_SCIPY is False there), which is why
|
||||
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
||||
consulted the flag, and the scipy fallback would not have interpolated
|
||||
anyway.
|
||||
installed on the target devices, and the old scipy-based sub-pixel path
|
||||
was dead code -- get_visible_portion never consulted the flag. The scipy
|
||||
import was removed with it; installing scipy has no effect.
|
||||
|
||||
Args:
|
||||
start_x: Left column of the earlier of the two frames
|
||||
@@ -830,10 +830,12 @@ class ScrollHelper:
|
||||
|
||||
def set_scroll_speed(self, speed: float) -> None:
|
||||
"""
|
||||
Set the scroll speed.
|
||||
Set the scroll speed, and leave fixed-step mode.
|
||||
|
||||
In time-based mode: pixels per second (typically 10-200)
|
||||
In frame-based mode: pixels per frame (typically 0.5-5 for smooth scrolling)
|
||||
In time-based mode: pixels per second (clamped to 1-500).
|
||||
In frame-based mode: pixels per ``scroll_delay`` seconds (clamped to
|
||||
0.1-5), still applied by elapsed time as scroll_speed / scroll_delay
|
||||
px/s.
|
||||
|
||||
Args:
|
||||
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
||||
@@ -896,14 +898,19 @@ class ScrollHelper:
|
||||
|
||||
def set_target_fps(self, fps: float) -> None:
|
||||
"""
|
||||
Set the target frames per second for scrolling.
|
||||
Record the presentation rate, for diagnostics only.
|
||||
|
||||
Nothing paces off this value: with a fixed per-frame step the helper
|
||||
advances once per call, and without one it advances by elapsed time.
|
||||
The rate frames are shown at is the panel refresh divided by the frame
|
||||
hold passed to ``display_manager.set_scrolling_state``. scroll_config
|
||||
sets it to that rate so it can be read back.
|
||||
|
||||
Args:
|
||||
fps: Target FPS (typically 30-200, default 120)
|
||||
fps: Frames per second (clamped to 30-200)
|
||||
"""
|
||||
self.target_fps = max(30.0, min(200.0, fps))
|
||||
self.frame_time_target = 1.0 / self.target_fps
|
||||
self.logger.debug(f"Target FPS set to: {self.target_fps} FPS (frame_time_target: {self.frame_time_target:.4f}s)")
|
||||
self.logger.debug("Target FPS recorded: %s FPS (informational)", self.target_fps)
|
||||
|
||||
def set_sub_pixel_scrolling(self, enabled: bool) -> None:
|
||||
"""
|
||||
@@ -914,7 +921,7 @@ class ScrollHelper:
|
||||
When disabled, uses integer pixel positioning (faster but may skip pixels).
|
||||
|
||||
Args:
|
||||
enabled: True to enable sub-pixel scrolling (default: True)
|
||||
enabled: True to enable sub-pixel scrolling (default: False)
|
||||
"""
|
||||
self.sub_pixel_scrolling = enabled
|
||||
self.logger.debug(f"Sub-pixel scrolling {'enabled' if enabled else 'disabled'}")
|
||||
@@ -923,9 +930,11 @@ class ScrollHelper:
|
||||
"""
|
||||
Enable or disable frame-based scrolling.
|
||||
|
||||
When enabled, update_scroll_position() respects scroll_delay and moves
|
||||
scroll_speed pixels per step. This provides a "stepped" look similar to
|
||||
traditional tickers and can be visually smoother on LED matrices.
|
||||
This does not step. When enabled, ``scroll_speed`` is read as pixels
|
||||
per ``scroll_delay`` seconds (set_scroll_speed clamps it to 0.1-5), and
|
||||
update_scroll_position() still advances by elapsed time at
|
||||
``scroll_speed / scroll_delay`` px/s. A fixed per-frame step set by
|
||||
set_pixels_per_frame() takes precedence over both modes.
|
||||
|
||||
Args:
|
||||
enabled: True to enable frame-based scrolling (default: False)
|
||||
@@ -985,11 +994,7 @@ class ScrollHelper:
|
||||
# the real stall rates being measured, so the number could not be
|
||||
# trusted at all. Seed the clock and take no sample.
|
||||
if self.last_frame_time is None:
|
||||
self.last_frame_time = current_time
|
||||
# Restart the window with the scroll. Otherwise the boundary is
|
||||
# already long overdue when the second frame arrives, and the new
|
||||
# scroll opens by reporting a window of exactly one frame.
|
||||
self.last_fps_log_time = current_time
|
||||
self._restart_stats_window(current_time)
|
||||
return
|
||||
|
||||
# Calculate instantaneous frame time
|
||||
@@ -998,9 +1003,10 @@ class ScrollHelper:
|
||||
# A caller that scrolls without ever calling reset_scroll() never arms
|
||||
# the sentinel above, so catch the same gap by its size. Nothing that
|
||||
# renders a scroll produces a frame longer than the log interval; a
|
||||
# sample that large is an idle period, not a frame.
|
||||
# sample that large is an idle period, not a frame. It starts a new
|
||||
# scroll exactly as the sentinel does, window timer included.
|
||||
if frame_time >= FPS_LOG_INTERVAL:
|
||||
self.last_frame_time = current_time
|
||||
self._restart_stats_window(current_time)
|
||||
return
|
||||
|
||||
self.frame_times.append(frame_time)
|
||||
@@ -1035,6 +1041,16 @@ class ScrollHelper:
|
||||
self.last_frame_time = current_time
|
||||
self.frame_count += 1
|
||||
|
||||
def _restart_stats_window(self, current_time: float) -> None:
|
||||
"""Seed the frame clock at the start of a scroll, taking no sample.
|
||||
|
||||
The window timer restarts with it. Otherwise the 5s boundary is
|
||||
already long overdue when the next frame arrives, and the new scroll
|
||||
opens by reporting a window of exactly one frame.
|
||||
"""
|
||||
self.last_frame_time = current_time
|
||||
self.last_fps_log_time = current_time
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
Clear the cached scrolling image.
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Sports helpers every scoreboard's ``sports.py`` carries an identical copy of.
|
||||
|
||||
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
|
||||
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
|
||||
``src/common``. The bodies below were copied from those plugin copies -- not
|
||||
from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main,
|
||||
2026-09-14):
|
||||
|
||||
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
|
||||
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
|
||||
(with ``_MIN_WINDOW_DAYS`` / ``_MAX_WINDOW_DAYS``), and the ``SportsCore``
|
||||
methods ``_mode_customization``, ``_setting_int``,
|
||||
``_reset_dwell_on_reentry`` (with ``_DWELL_REENTRY_GAP_SECONDS``),
|
||||
``_next_switch_index`` and ``_spread_weighted_order``.
|
||||
- In the eight that share a lineage (all but ufc, which has neither):
|
||||
``_odds_color`` and ``_upcoming_date_and_time_text``.
|
||||
|
||||
The module-level helpers become public free functions here (``clamp_window``,
|
||||
``clamp_seconds``, ``logo_needs_refresh``, ``spread_weighted_order``,
|
||||
``MIN_WINDOW_DAYS``, ``MAX_WINDOW_DAYS``); the methods keep the plugins' exact
|
||||
names and signatures on ``SportsHelpersMixin`` so adopting it is deleting the
|
||||
copies and adding one base. ``test/test_sports_helpers.py`` compares every body
|
||||
here against every plugin copy when a plugins checkout is available
|
||||
(``LEDMATRIX_PLUGINS``), so drift in either direction fails a test.
|
||||
|
||||
WHY A NEW MODULE, NOT MORE METHODS ON sports_shared
|
||||
---------------------------------------------------
|
||||
A plugin that deletes its copy of a method and relies on an *existing* module
|
||||
having grown it fails at runtime on an older core: ``AttributeError`` mid-render,
|
||||
usually swallowed into a blank board. The plugin loader cannot see that, and
|
||||
neither can the plugins' ``scripts/check_min_core_version.py``, which checks
|
||||
modules. A plugin importing a module that does not exist yet fails at load,
|
||||
where both can. Plugins import this and floor ``ledmatrix_min_version`` on the
|
||||
first core release that ships it (see ``CHANGELOG.md``).
|
||||
|
||||
``_favorite_key`` is the one method not taken from the plugins: it is the
|
||||
override point from ``src/base_classes/sports/core.py``, carried here so later
|
||||
phases (shared celebrations and game selection) have a hardware-free home for
|
||||
the seam. No plugin defines it today and nothing in this module calls it.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_helpers.py`` fails if a read is added without being
|
||||
listed here.
|
||||
|
||||
- ``config`` (dict) -- ``_mode_customization``.
|
||||
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
|
||||
read with ``getattr`` for the warning text only.
|
||||
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
|
||||
``_is_favorite_game`` (called with a game), which stays per-plugin and is
|
||||
only called when
|
||||
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
|
||||
defaults to 1 on the mixin.
|
||||
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
|
||||
and written back.
|
||||
- ``_card_option``, ``_format_game_date`` and ``_format_game_time`` --
|
||||
``_upcoming_date_and_time_text``. All three are on
|
||||
``SportsCoreSharedMixin`` (``src/common/sports_shared.py``).
|
||||
- ``_element_color`` -- ``_odds_color``, looked up with ``getattr`` and falling
|
||||
back to green, so a host without it still works. Also on
|
||||
``SportsCoreSharedMixin``.
|
||||
- ``SKIN_MODE`` -- optional, read with ``getattr`` by ``_mode_customization``;
|
||||
without it the mode overrides are simply not merged.
|
||||
|
||||
State the mixin keeps on the host, created on first use (all read with
|
||||
``getattr``, so nothing needs initialising): ``_last_display_call_monotonic``,
|
||||
``_switch_order``, ``_switch_order_key``, ``_switch_position``.
|
||||
|
||||
Class-level defaults: ``_DWELL_REENTRY_GAP_SECONDS = 5.0`` and
|
||||
``favorite_rotation_boost = 1``, matching the plugins. A host's own value
|
||||
shadows them.
|
||||
|
||||
MRO
|
||||
---
|
||||
No ``__init__`` and no bare ``super()``, so base order does not change what
|
||||
runs. By convention list it after ``SportsCoreSharedMixin``::
|
||||
|
||||
class SportsCore(SportsCoreSharedMixin, SportsHelpersMixin, ABC):
|
||||
|
||||
HARDWARE-FREE
|
||||
-------------
|
||||
Nothing here may import ``src.display_manager``, ``src.base_classes``,
|
||||
``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module
|
||||
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
|
||||
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
|
||||
exactly as the plugin copy does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
__all__ = [
|
||||
"MIN_WINDOW_DAYS",
|
||||
"MAX_WINDOW_DAYS",
|
||||
"clamp_window",
|
||||
"clamp_seconds",
|
||||
"logo_needs_refresh",
|
||||
"spread_weighted_order",
|
||||
"SportsHelpersMixin",
|
||||
]
|
||||
|
||||
MIN_WINDOW_DAYS = 1
|
||||
MAX_WINDOW_DAYS = 60
|
||||
|
||||
|
||||
def clamp_window(value: Any, fallback: int) -> int:
|
||||
"""Days for one side of the schedule window, or the default if unusable."""
|
||||
try:
|
||||
days = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: json parses a bare Infinity and int(inf) raises,
|
||||
# which crashed manager init from a hand-edited config.
|
||||
return fallback
|
||||
return max(MIN_WINDOW_DAYS, min(MAX_WINDOW_DAYS, days))
|
||||
|
||||
|
||||
def clamp_seconds(value: Any, fallback: int, low: int = 5,
|
||||
high: int = 86400) -> int:
|
||||
"""An interval in seconds, or the fallback when the value is unusable."""
|
||||
try:
|
||||
seconds = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: json parses bare Infinity by default and int(inf)
|
||||
# raises -- the same gap clamp_window above already covers.
|
||||
return fallback
|
||||
return max(low, min(high, seconds))
|
||||
|
||||
|
||||
def logo_needs_refresh(logo_file) -> bool:
|
||||
"""True if this file is a placeholder stale enough to retry the real logo.
|
||||
|
||||
A failed logo download is cached as a placeholder wearing the real logo's
|
||||
filename, so "the file exists" is not proof the logo was ever fetched.
|
||||
Without this check one transient failure leaves a team a grey box forever.
|
||||
|
||||
Returns False on a core that predates placeholder marking, which keeps the
|
||||
previous behaviour rather than breaking the load.
|
||||
"""
|
||||
# Imported from the core by its full path, never as a bare name: a
|
||||
# deferred bare-name import can bind another plugin's vendored
|
||||
# logo_downloader once the core isolates top-level plugin modules.
|
||||
try:
|
||||
from src.logo_downloader import (
|
||||
PLACEHOLDER_RETRY_SECONDS,
|
||||
is_placeholder_logo,
|
||||
placeholder_age_seconds,
|
||||
)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not is_placeholder_logo(logo_file):
|
||||
return False
|
||||
age = placeholder_age_seconds(logo_file)
|
||||
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def spread_weighted_order(weights: List[int]) -> List[int]:
|
||||
"""Indices into ``weights``, each repeated by its weight and spread out.
|
||||
|
||||
Each index keeps its own slot and places its extra turns at even
|
||||
fractions of the rotation after it, wrapping round. That keeps the
|
||||
list's schedule order for everything else and spaces a favourite's
|
||||
repeats evenly *around the loop* -- the live rotation's smooth
|
||||
weighted round-robin schedules a boosted game first and last, so a
|
||||
rotation that wraps shows it back to back. Equal weights come back in
|
||||
plain order, so a boost that applies to no card changes nothing.
|
||||
|
||||
Repeats are kept apart only where the ratio leaves room: once one
|
||||
weight exceeds all the others combined, no cyclic order can separate
|
||||
its turns ([3, 1, 1] gives [0, 1, 0, 2, 0]). Each index still gets
|
||||
exactly its weight in turns -- the configured ratio wins over spacing.
|
||||
"""
|
||||
count = len(weights)
|
||||
slots = []
|
||||
for index, weight in enumerate(weights):
|
||||
for turn in range(weight):
|
||||
slots.append(((index + turn * count / weight) % count, turn > 0, index))
|
||||
return [index for _, _, index in sorted(slots)]
|
||||
|
||||
|
||||
class SportsHelpersMixin:
|
||||
"""The ``SportsCore`` methods identical across the scoreboards' sports.py.
|
||||
|
||||
Constructor-free; keeps lazy state on its host (see the module docstring
|
||||
for that state, the host contract and base-order guidance).
|
||||
"""
|
||||
|
||||
#: Longest gap between two display() calls that still counts as one
|
||||
#: on-screen stint. Frames arrive many times a second while a mode is on
|
||||
#: the panel; between mode blocks the gap is the length of every other
|
||||
#: mode's block -- a minute or more. Anything past a few seconds can only
|
||||
#: be a block boundary, or the very first frame after startup.
|
||||
_DWELL_REENTRY_GAP_SECONDS: ClassVar[float] = 5.0
|
||||
|
||||
#: Turns a favourite's card gets per turn of any other card in switch
|
||||
#: mode. The plugins set it per instance from config; this default keeps
|
||||
#: a host that does not on the plain rotation.
|
||||
favorite_rotation_boost: int = 1
|
||||
|
||||
def _favorite_key(self, game: Dict, side: str) -> Optional[str]:
|
||||
"""Override point: which view-model field identifies a team when
|
||||
matching against ``favorite_teams``.
|
||||
|
||||
``side`` is ``"home"`` or ``"away"``. The default is the team
|
||||
abbreviation -- what eight of the nine scoreboards match on, and what
|
||||
users type into their favorites list.
|
||||
|
||||
NRL needs the team **id** instead, because NRL abbreviations are not
|
||||
unique: "NEW" is both Newcastle Knights and New Zealand Warriors,
|
||||
"CAN" both Canberra Raiders and Canterbury Bulldogs. It is a seam
|
||||
rather than a branch so core never has to learn the string "nrl"::
|
||||
|
||||
def _favorite_key(self, game, side):
|
||||
return str(game.get(f"{side}_id"))
|
||||
|
||||
An override that stringifies should note that a missing id becomes the
|
||||
literal ``"None"``, which would spuriously match a favorites list
|
||||
containing that string. The default returns ``None`` for a missing
|
||||
abbreviation, which never matches.
|
||||
|
||||
Carried from ``src/base_classes/sports/core.py`` for later phases;
|
||||
nothing in this module calls it yet.
|
||||
"""
|
||||
return game.get(f"{side}_abbr")
|
||||
|
||||
def _mode_customization(self) -> dict:
|
||||
"""``customization`` with this mode's overrides merged over it.
|
||||
|
||||
SportsUpcoming / SportsRecent / SportsLive are separate instances
|
||||
with their own SKIN_MODE, so merging once here makes every
|
||||
per-element lookup mode-aware without changing one of them.
|
||||
|
||||
``None`` in a mode block means "inherit", which is what lets a user
|
||||
restyle one element on live cards and leave everything else
|
||||
following the settings above. It has to stay distinct from 0: a mode
|
||||
y_offset of 0 means "sit at the base position", not "no preference".
|
||||
"""
|
||||
customization = self.config.get('customization', {})
|
||||
if not isinstance(customization, dict):
|
||||
return {}
|
||||
mode = getattr(self, 'SKIN_MODE', None)
|
||||
if not mode:
|
||||
return customization
|
||||
modes = customization.get('modes')
|
||||
block = modes.get(mode) if isinstance(modes, dict) else None
|
||||
if not isinstance(block, dict):
|
||||
return customization
|
||||
|
||||
merged = dict(customization)
|
||||
for element, override in block.items():
|
||||
if element == 'layout' or not isinstance(override, dict):
|
||||
continue
|
||||
base = merged.get(element)
|
||||
base = dict(base) if isinstance(base, dict) else {}
|
||||
base.update({k: v for k, v in override.items() if v is not None})
|
||||
merged[element] = base
|
||||
|
||||
mode_layout = block.get('layout')
|
||||
if isinstance(mode_layout, dict):
|
||||
base_layout = merged.get('layout')
|
||||
new_layout = dict(base_layout) if isinstance(base_layout, dict) else {}
|
||||
for element, axes in mode_layout.items():
|
||||
if not isinstance(axes, dict):
|
||||
continue
|
||||
current = new_layout.get(element)
|
||||
current = dict(current) if isinstance(current, dict) else {}
|
||||
current.update({k: v for k, v in axes.items() if v is not None})
|
||||
new_layout[element] = current
|
||||
merged['layout'] = new_layout
|
||||
return merged
|
||||
|
||||
def _setting_int(self, key: str, default: int, low: int, high: int) -> int:
|
||||
"""A count from config, clamped to the range its schema declares.
|
||||
|
||||
The schema constrains these, but config.json can be hand-edited or
|
||||
written by an older tool, and a string or a negative here does not
|
||||
raise where anyone would see it -- it raises inside update()'s own
|
||||
try/except, which shows up as a mode that silently renders nothing.
|
||||
Same shape as the favorite_live_boost clamp in the plugins.
|
||||
"""
|
||||
try:
|
||||
return max(low, min(high, int(self.mode_config.get(key, default))))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: json parses a bare Infinity, and int(inf) raises
|
||||
# -- from __init__, outside any try/except, so the manager would
|
||||
# fail to construct instead of falling back.
|
||||
self.logger.warning(
|
||||
"%s: ignoring unusable %s=%r, using %s",
|
||||
getattr(self, "league", "?"), key,
|
||||
self.mode_config.get(key), default,
|
||||
)
|
||||
return default
|
||||
|
||||
def _reset_dwell_on_reentry(self) -> bool:
|
||||
"""Give the current card a full turn when this mode (re)takes the panel.
|
||||
|
||||
The dwell clock (last_game_switch) keeps running while the mode is off
|
||||
screen, so on re-entry it was always long expired and the first
|
||||
display() call advanced immediately: the card cut off by the end of
|
||||
the previous block was skipped instead of shown -- measured at one in
|
||||
five card transitions on a 30s block of 15s cards -- and after a
|
||||
service restart the clock started at manager construction, seconds
|
||||
before the first frame, shaving that much off the first card. Both are
|
||||
the same defect: the dwell clock counting time the viewer never saw.
|
||||
|
||||
Returns True when the dwell was reset, so the caller forces a redraw.
|
||||
The one-frame card at the end of a block (the advance that races the
|
||||
controller's mode switch) still renders -- this reset is what turns it
|
||||
into the card that opens the next block with a full turn, instead of
|
||||
one the rotation skipped.
|
||||
"""
|
||||
# getattr, and zero treated as "never displayed": the managers are
|
||||
# constructed in several places -- the plugin tests among them -- not
|
||||
# all of which set every attribute, and a freshly booted Pi can reach
|
||||
# the first frame while time.monotonic() itself is still under the
|
||||
# gap threshold, which would make `now - 0.0` look like one stint.
|
||||
last = getattr(self, "_last_display_call_monotonic", 0.0)
|
||||
now = time.monotonic()
|
||||
self._last_display_call_monotonic = now
|
||||
if last > 0.0 and now - last < self._DWELL_REENTRY_GAP_SECONDS:
|
||||
return False
|
||||
if getattr(self, "last_game_switch", 0) <= 0:
|
||||
# Zero is the live screen's "no game shown yet" sentinel with its
|
||||
# own handling; overwriting it here would hide the first game's
|
||||
# arrival from that logic.
|
||||
return False
|
||||
self.last_game_switch = time.time()
|
||||
return True
|
||||
|
||||
_spread_weighted_order = staticmethod(spread_weighted_order)
|
||||
|
||||
def _next_switch_index(self) -> int:
|
||||
"""The games_list index switch mode shows next.
|
||||
|
||||
favorite_rotation_boost gives a favourite's card that many turns for
|
||||
every one turn another card gets, spread through the rotation and kept
|
||||
apart wherever the other cards leave room (a boost above the number of
|
||||
other cards makes some repeats adjacent; the ratio is kept either way).
|
||||
games_list itself stays one entry per game -- the
|
||||
cycle-duration count, the scroll strip and the other-games re-cut all
|
||||
read it -- so the weighting is an order walked over it instead.
|
||||
|
||||
The order is rebuilt whenever the list's games change, and the walk
|
||||
resyncs from current_game_index whenever the two disagree: update()
|
||||
and the other-games rotation both set the index directly when they
|
||||
swap a list in, and the card on screen is where the walk resumes.
|
||||
|
||||
Called with _games_lock held and games_list non-empty.
|
||||
"""
|
||||
count = len(self.games_list)
|
||||
boost = getattr(self, "favorite_rotation_boost", 1)
|
||||
if boost <= 1 or count < 2:
|
||||
return (self.current_game_index + 1) % count
|
||||
key = (boost, tuple(g.get("id") for g in self.games_list))
|
||||
if getattr(self, "_switch_order_key", None) != key:
|
||||
self._switch_order = self._spread_weighted_order(
|
||||
[boost if self._is_favorite_game(g) else 1 for g in self.games_list]
|
||||
)
|
||||
self._switch_order_key = key
|
||||
self._switch_position = -1
|
||||
order = self._switch_order
|
||||
position = getattr(self, "_switch_position", -1)
|
||||
if not 0 <= position < len(order) or order[position] != self.current_game_index:
|
||||
position = (order.index(self.current_game_index)
|
||||
if self.current_game_index in order else -1)
|
||||
position = (position + 1) % len(order)
|
||||
self._switch_position = position
|
||||
return order[position]
|
||||
|
||||
def _odds_color(self) -> Tuple[int, int, int]:
|
||||
"""Colour for the odds text; the green it always drew unless configured.
|
||||
|
||||
Guarded with getattr because not every class that reaches
|
||||
_draw_dynamic_odds carries the element-colour helper -- the plugins'
|
||||
own test harnesses build minimal manager objects, and a bare
|
||||
AttributeError here is swallowed by the surrounding except, which
|
||||
drops the odds off the card instead of failing loudly.
|
||||
"""
|
||||
getter = getattr(self, "_element_color", None)
|
||||
if getter is None:
|
||||
return (0, 255, 0)
|
||||
try:
|
||||
return getter("odds_text", (0, 255, 0)) # pylint: disable=not-callable
|
||||
except Exception:
|
||||
return (0, 255, 0)
|
||||
|
||||
def _upcoming_date_and_time_text(self, game_date: str, game_time: str,
|
||||
game: Optional[Dict] = None) -> Tuple[str, str]:
|
||||
"""The formatted (date, time) pair, blanked by switch_show_date/_time.
|
||||
|
||||
Deliberately not the shared show_date/show_time: those governed only
|
||||
the scroll and Vegas cards before this display read the block, so a
|
||||
config that had turned them off there would silently blank a scorebug
|
||||
that has always drawn both lines. The switch keys default to True for
|
||||
the same reason switch_upcoming_center defaults to "date_time" -- an
|
||||
untouched panel keeps rendering exactly what it rendered before.
|
||||
"""
|
||||
date_text = (self._format_game_date(game_date, game)
|
||||
if self._card_option("switch_show_date", True) else "")
|
||||
time_text = (self._format_game_time(game_time)
|
||||
if self._card_option("switch_show_time", True) else "")
|
||||
return date_text, time_text
|
||||
+47
-41
@@ -18,10 +18,15 @@ Promoting the content layer would be exactly the mistake
|
||||
``docs/SPORTS_UNIFICATION.md`` warns against — merging on the intuition that
|
||||
same-named methods are the same method. Same name, different job.
|
||||
|
||||
The one behavior this module adds over the plugin copies is native support for
|
||||
``global_config['target_fps']``: the bundled copies hardcode ~100 FPS via
|
||||
``scroll_delay=0.01`` and never consult the global smooth-scrolling target. A
|
||||
plugin inheriting from here gets it for free.
|
||||
What this module adds over the plugin copies is pacing through
|
||||
:mod:`src.common.scroll_config`, the resolver every other scroller uses: the
|
||||
configured px/s is snapped to a whole-pixel speed for the panel's refresh, the
|
||||
helper steps a fixed number of pixels per presented frame, and each drawn frame
|
||||
publishes the frame hold to the display manager. Speed depends only on
|
||||
``scroll_speed`` and the panel refresh
|
||||
(``display.hardware.limit_refresh_rate_hz``) -- not on the global
|
||||
``target_fps``, and not on ``scroll_delay``, which is kept in the settings for
|
||||
compatibility only.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -94,9 +99,9 @@ class SportsScrollDisplay:
|
||||
:param display_manager: the core display manager
|
||||
:param config: the plugin's configuration
|
||||
:param custom_logger: the plugin's logger, so scroll lines are attributed
|
||||
:param global_config: the LEDMatrix global config — the source of
|
||||
``target_fps``. Optional so an older caller that does not pass it
|
||||
keeps working at the config-derived pacing.
|
||||
:param global_config: the LEDMatrix global config, consulted for the
|
||||
panel refresh when the display manager cannot report one.
|
||||
Optional so an older caller that does not pass it keeps working.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
@@ -196,21 +201,6 @@ class SportsScrollDisplay:
|
||||
return {**settings, **override}
|
||||
return settings
|
||||
|
||||
def _resolve_target_fps(self) -> Optional[float]:
|
||||
"""The global smooth-scrolling FPS target, or None to keep config pacing.
|
||||
|
||||
Coerced before use: a malformed value in the global config must degrade
|
||||
to the existing ``scroll_delay`` pacing, never raise on a display path.
|
||||
"""
|
||||
raw = self.global_config.get("target_fps") or self.global_config.get(
|
||||
"scroll_target_fps"
|
||||
)
|
||||
try:
|
||||
return float(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
self.logger.debug("Ignoring unusable target_fps: %r", raw)
|
||||
return None
|
||||
|
||||
def _coerce_float(self, value: Any, default: float) -> float:
|
||||
"""A usable float from config, or ``default``.
|
||||
|
||||
@@ -246,8 +236,8 @@ class SportsScrollDisplay:
|
||||
# settings dict: the two read the same key names with different
|
||||
# meanings, and the collision is a factor of 1/scroll_delay.
|
||||
#
|
||||
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the
|
||||
# frame period used to reach px/frame.
|
||||
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is ignored
|
||||
# for pacing (see _resolve_pixels_per_second).
|
||||
# scroll_config: scroll_speed is px per STEP, so px/s = speed/delay.
|
||||
#
|
||||
# Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through
|
||||
@@ -276,8 +266,13 @@ class SportsScrollDisplay:
|
||||
def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float:
|
||||
"""This module's config shape, expressed as plain pixels per second.
|
||||
|
||||
``scroll_speed`` is already px/s here. ``scroll_delay`` only matters
|
||||
when a caller supplied px/frame instead, which the 0 case covers.
|
||||
``scroll_speed`` is already px/s here, and it is the whole answer.
|
||||
``scroll_delay`` is kept in the settings for compatibility but is
|
||||
ignored for pacing: the frame rate is the panel refresh divided by the
|
||||
frame hold scroll_config picks, so no positive delay changes the speed.
|
||||
The one exception is a delay of exactly 0 (below every scoreboard
|
||||
schema's minimum), which is read as ``scroll_speed`` being px per
|
||||
frame at ``ASSUMED_FPS_WHEN_UNPACED``.
|
||||
"""
|
||||
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
||||
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
||||
@@ -285,19 +280,29 @@ class SportsScrollDisplay:
|
||||
return scroll_speed * ASSUMED_FPS_WHEN_UNPACED
|
||||
return scroll_speed
|
||||
|
||||
def _resolve_refresh_hz(self) -> Optional[float]:
|
||||
def _resolve_refresh_hz(self) -> float:
|
||||
"""The panel refresh the crisp ladder should be computed against.
|
||||
|
||||
Prefers the configured hardware refresh. Falls back to the global
|
||||
``target_fps``/``scroll_target_fps`` this module has always honoured:
|
||||
under the old model that key *was* the rate frames were presented at,
|
||||
so it is the faithful translation for anyone who set it. Returning
|
||||
None lets scroll_config apply its own default.
|
||||
This has to be the rate frames are actually presented at, because the
|
||||
helper advances a fixed number of whole pixels per presented frame and
|
||||
the display manager holds each frame for ``frame_hold`` refreshes. A
|
||||
ladder built for any other rate plays back at the wrong speed: built
|
||||
for 60 Hz and shown on a 100 Hz panel, it runs 100/60 too fast.
|
||||
|
||||
So it is the display manager's ``refresh_hz`` (what the panel is
|
||||
driven at), then ``display.hardware.limit_refresh_rate_hz`` from the
|
||||
global config, then scroll_config's default. The global ``target_fps``
|
||||
is deliberately NOT consulted: before frame-locked presentation it was
|
||||
the rate frames were shown at, but now honouring it only turned the
|
||||
General tab's "Scroll Frame Rate" into a scoreboard speed multiplier.
|
||||
"""
|
||||
hardware = scroll_config.refresh_hz_from_config(self.global_config)
|
||||
if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ:
|
||||
return hardware
|
||||
return self._resolve_target_fps() or hardware or None
|
||||
reported = getattr(self.display_manager, "refresh_hz", None)
|
||||
# A real number only: a stub or a mock answering for anything must not
|
||||
# become the refresh rate.
|
||||
if (isinstance(reported, (int, float)) and not isinstance(reported, bool)
|
||||
and reported > 0):
|
||||
return float(reported)
|
||||
return scroll_config.refresh_hz_from_config(self.global_config)
|
||||
|
||||
def _scroll_frame_hold(self) -> int:
|
||||
"""Refreshes to hold each frame for, from the resolved settings."""
|
||||
@@ -327,11 +332,12 @@ class SportsScrollDisplay:
|
||||
return False
|
||||
|
||||
# Tell the core the panel is scrolling, and for how many
|
||||
# refreshes to hold each frame. Without this the frame hold is
|
||||
# never applied -- so a speed the ladder made crisp still presents
|
||||
# a new frame every refresh and judders -- and, because deferred
|
||||
# updates only run while nothing is scrolling, core would run
|
||||
# blocking work in the middle of this scroll.
|
||||
# refreshes to hold each frame. The helper advances a fixed number
|
||||
# of whole pixels per presented frame, so without the hold a new
|
||||
# frame is presented every refresh and the scroll runs frame_hold
|
||||
# times too fast. And because deferred updates only run while
|
||||
# nothing is scrolling, core would otherwise run blocking work in
|
||||
# the middle of this scroll.
|
||||
if hasattr(self.display_manager, "set_scrolling_state"):
|
||||
self.display_manager.set_scrolling_state(
|
||||
True, frame_hold=self._scroll_frame_hold())
|
||||
|
||||
@@ -84,6 +84,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from src.common.espn_dates import fetch_espn_scoreboard
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import load_truetype
|
||||
@@ -98,6 +99,13 @@ _IDLE_LONG_STREAK = 24
|
||||
_IDLE_LONG_FACTOR = 6
|
||||
_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
|
||||
|
||||
#: How long after a scheduled start to keep looking on the live cadence. ESPN
|
||||
#: does not flip a game to in-progress exactly at kickoff, and an escalated
|
||||
#: back-off treats each of those early looks as another empty one.
|
||||
_KICKOFF_GRACE_SECONDS = 900
|
||||
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
||||
_KICKOFF_POLL_FLOOR = 30
|
||||
|
||||
|
||||
def _resolve_font_path(path: str) -> str:
|
||||
"""Resolve a bundled font path without depending on the process cwd.
|
||||
@@ -947,14 +955,14 @@ class SportsCoreSharedMixin:
|
||||
end_date = now + timedelta(days=self.schedule_lookahead_days)
|
||||
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"
|
||||
response = self.session.get(
|
||||
data = fetch_espn_scoreboard(
|
||||
self.session,
|
||||
url,
|
||||
params={"dates": date_str, "limit": 1000},
|
||||
headers=self.headers,
|
||||
timeout=10,
|
||||
logger=self.logger,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
immediate_events = data.get("events", [])
|
||||
|
||||
if immediate_events:
|
||||
@@ -1309,6 +1317,11 @@ class SportsLiveSharedMixin:
|
||||
Capped rather than unbounded: the cost of backing off is how late the
|
||||
first game after a quiet spell is noticed, and past the cap the saving
|
||||
stops being worth that.
|
||||
|
||||
The escalation is then clamped by the next kickoff the league already
|
||||
knows about -- see _clamp_to_scheduled_start. Without that clamp the cap
|
||||
*is* the miss: a league idle overnight reaches the ceiling, and the
|
||||
first game of the next day is not noticed for up to that long.
|
||||
"""
|
||||
streak = getattr(self, "_empty_live_streak", 0)
|
||||
base = self.no_data_interval
|
||||
@@ -1320,10 +1333,87 @@ class SportsLiveSharedMixin:
|
||||
# *shrink* as the streak grew (3600s at streak 0, 900s at streak 24),
|
||||
# the opposite of what the setting named "maximum" promises.
|
||||
if streak >= _IDLE_LONG_STREAK:
|
||||
return min(int(base * _IDLE_LONG_FACTOR), ceiling)
|
||||
if streak >= _IDLE_SHORT_STREAK:
|
||||
return min(int(base * _IDLE_SHORT_FACTOR), ceiling)
|
||||
return min(base, ceiling)
|
||||
interval = min(int(base * _IDLE_LONG_FACTOR), ceiling)
|
||||
elif streak >= _IDLE_SHORT_STREAK:
|
||||
interval = min(int(base * _IDLE_SHORT_FACTOR), ceiling)
|
||||
else:
|
||||
interval = min(base, ceiling)
|
||||
return self._clamp_to_scheduled_start(interval)
|
||||
|
||||
def _clamp_to_scheduled_start(self, interval: int) -> int:
|
||||
"""Shorten an idle wait that would sleep through a known kickoff.
|
||||
|
||||
The back-off counts consecutive empty looks and nothing else, so it
|
||||
cannot tell an out-of-season league from an in-season one a few hours
|
||||
before kickoff. Both reach the ceiling, and the ceiling then becomes the
|
||||
blind spot: measured on two rigs on 2026-09-19, gaps of up to 928s
|
||||
between looks, 10 of them at or above 900s. A game starting inside such
|
||||
a gap is not noticed until it ends -- which is the "it doesn't pick up
|
||||
new live games until I restart it" report, restarting being the one
|
||||
thing that forces an immediate look.
|
||||
|
||||
The fix costs no extra request: the live fetch already downloads the
|
||||
whole day's scoreboard, upcoming games included, and
|
||||
_note_scheduled_start_candidate keeps the earliest start still ahead of
|
||||
us out of exactly that payload.
|
||||
|
||||
Two cases, either side of the kickoff:
|
||||
|
||||
* before it -- wait at most until it starts, never past it;
|
||||
* just after it -- hold the live cadence for _KICKOFF_GRACE_SECONDS,
|
||||
because a provider that has not yet flipped the status would
|
||||
otherwise look like another empty check and escalate the back-off
|
||||
again, right when the game is actually starting.
|
||||
"""
|
||||
start = getattr(self, "_next_scheduled_start_ts", None)
|
||||
if not start:
|
||||
return interval
|
||||
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
||||
now = time.time()
|
||||
if now < start:
|
||||
return max(live, min(interval, int(start - now)))
|
||||
if now - start <= _KICKOFF_GRACE_SECONDS:
|
||||
return live
|
||||
return interval
|
||||
|
||||
def _note_scheduled_start_candidate(self, details) -> None:
|
||||
"""Offer a game from the current look as the next kickoff to wake for.
|
||||
|
||||
Called for every event the live fetch returns, live or not, so the
|
||||
earliest start still ahead of us falls out of the payload the manager
|
||||
already has. Self-correcting: a stored start that has passed is
|
||||
replaced by the next one offered, so a postponed game cannot pin the
|
||||
cadence to a kickoff that never happens.
|
||||
"""
|
||||
if not isinstance(details, dict):
|
||||
return
|
||||
if details.get("is_live") or details.get("is_halftime"):
|
||||
return
|
||||
start = details.get("start_time_utc")
|
||||
timestamp = getattr(start, "timestamp", None)
|
||||
if timestamp is None:
|
||||
return
|
||||
try:
|
||||
candidate = float(timestamp())
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
return
|
||||
now = time.time()
|
||||
if candidate <= now:
|
||||
return
|
||||
current = getattr(self, "_next_scheduled_start_ts", None)
|
||||
# A kickoff that has only just passed is *kept*, not replaced by the
|
||||
# next one on the card. Replacing it immediately is what made the grace
|
||||
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
|
||||
# round, the stored start jumped to the 16:05 games, `now < start` went
|
||||
# true again, and the back-off returned to its ceiling -- at exactly the
|
||||
# moment the games were starting. Observed live on 2026-09-20: the rig
|
||||
# polled at 13:00:45, found nothing live because ESPN had not flipped
|
||||
# the status yet, and then went quiet for the next quarter of an hour,
|
||||
# which is the behaviour this whole clamp exists to prevent.
|
||||
if (current is None
|
||||
or current <= now - _KICKOFF_GRACE_SECONDS
|
||||
or candidate < current):
|
||||
self._next_scheduled_start_ts = candidate
|
||||
|
||||
def _note_live_fetch(self, found_live: bool) -> None:
|
||||
"""Record whether a look for live games found any."""
|
||||
|
||||
@@ -29,6 +29,7 @@ import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
|
||||
from src.exceptions import ConfigError
|
||||
from src.logging_config import get_logger
|
||||
from src.config_manager_atomic import (
|
||||
@@ -756,12 +757,13 @@ class ConfigManager:
|
||||
|
||||
valid_set = set(valid_plugin_ids)
|
||||
|
||||
# Find orphaned plugins in main config
|
||||
main_plugins = set(main_config.keys())
|
||||
# Find orphaned plugins in main config. Core sections (display,
|
||||
# schedule, auto_update, ...) are not plugins and never orphans.
|
||||
main_plugins = set(main_config.keys()) - CORE_CONFIG_KEYS
|
||||
orphaned_main = main_plugins - valid_set
|
||||
|
||||
# Find orphaned plugins in secrets config
|
||||
secrets_plugins = set(secrets_config.keys())
|
||||
secrets_plugins = set(secrets_config.keys()) - CORE_CONFIG_KEYS - CORE_SECRETS_KEYS
|
||||
orphaned_secrets = secrets_plugins - valid_set
|
||||
|
||||
all_orphaned = orphaned_main | orphaned_secrets
|
||||
@@ -815,8 +817,8 @@ class ConfigManager:
|
||||
if not isinstance(plugin_config, dict):
|
||||
continue
|
||||
|
||||
# Skip non-plugin config sections
|
||||
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']:
|
||||
# Skip core config sections
|
||||
if plugin_id in CORE_CONFIG_KEYS:
|
||||
continue
|
||||
|
||||
schema = plugin_schema_manager.load_schema(plugin_id, use_cache=True)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Top-level config.json keys that belong to the LEDMatrix core, not to plugins.
|
||||
|
||||
config.json mixes two kinds of top-level section: core settings (``display``,
|
||||
``schedule``, ``auto_update``, ...) and one section per plugin, keyed by plugin
|
||||
id. Anything that needs to tell them apart -- plugin-state reconciliation above
|
||||
all -- must use this list, not a private copy of it.
|
||||
|
||||
A private copy is what went wrong: #581 added a top-level ``auto_update``
|
||||
section, reconciliation's own list did not know about it, and every device with
|
||||
the new setting was told "In config but not installed: auto_update. Reinstall
|
||||
via the Plugin Store, or remove these entries from config.json" -- advice that
|
||||
deletes a real core setting.
|
||||
|
||||
When you add a top-level core setting, add its key here. The tests in
|
||||
``test/test_core_config_keys.py`` fail if a top-level key in
|
||||
``config/config.template.json``, or one written by the general-settings save
|
||||
endpoint, is missing from this list.
|
||||
"""
|
||||
|
||||
CORE_CONFIG_KEYS = frozenset({
|
||||
# config/config.template.json
|
||||
'web_display_autostart',
|
||||
'auto_update',
|
||||
'schedule',
|
||||
'dim_schedule',
|
||||
'timezone',
|
||||
'target_fps',
|
||||
'location',
|
||||
'display',
|
||||
'sync',
|
||||
'plugin_system',
|
||||
# Older or optional core sections still found in existing config files.
|
||||
'logging',
|
||||
'network',
|
||||
'system',
|
||||
'vegas_scroll_speed',
|
||||
'vegas_separator_width',
|
||||
'vegas_target_fps',
|
||||
'vegas_buffer_ahead',
|
||||
'vegas_plugin_order',
|
||||
'vegas_excluded_plugins',
|
||||
'vegas_scroll_enabled',
|
||||
})
|
||||
|
||||
#: Top-level keys of ``config_secrets.json`` that belong to the core rather than
|
||||
#: to a plugin: the GitHub token the Plugin Store reads, and the historical
|
||||
#: ``youtube`` section. Plugin secrets are namespaced by plugin id, so anything
|
||||
#: deciding whether a secrets section is a plugin's needs this as well as
|
||||
#: ``CORE_CONFIG_KEYS``.
|
||||
CORE_SECRETS_KEYS = frozenset({
|
||||
'github',
|
||||
'youtube',
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Marking plugin-facing core APIs for removal.
|
||||
|
||||
Plugins live in other repositories, so a method nothing in core calls may
|
||||
still be called by a plugin nobody has checked. Such methods get
|
||||
``@deprecated`` for one release before they are removed: the first call in a
|
||||
process logs a warning naming the method and the release that removes it
|
||||
(visible in ``journalctl -u ledmatrix``), and emits a DeprecationWarning for
|
||||
tooling.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import threading
|
||||
import warnings
|
||||
from typing import Callable, Optional, TypeVar
|
||||
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
F = TypeVar("F", bound=Callable)
|
||||
|
||||
_warned = set()
|
||||
_warned_lock = threading.Lock()
|
||||
|
||||
|
||||
def deprecated(removal: str, alternative: Optional[str] = None) -> Callable[[F], F]:
|
||||
"""Decorate a function or method that will be removed in ``removal``."""
|
||||
|
||||
def decorate(func: F) -> F:
|
||||
message = f"{func.__qualname__}() is deprecated and will be removed in LEDMatrix {removal}"
|
||||
if alternative:
|
||||
message += f"; {alternative}"
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
with _warned_lock:
|
||||
first = func.__qualname__ not in _warned
|
||||
_warned.add(func.__qualname__)
|
||||
if first:
|
||||
logger.warning(message)
|
||||
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper.__deprecated__ = message
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorate
|
||||
@@ -2976,6 +2976,14 @@ class DisplayController:
|
||||
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
|
||||
"""Callback for plugin config changes."""
|
||||
try:
|
||||
# ConfigService hands over the raw config.json section.
|
||||
# Prepare it as loading did (legacy booleans read as
|
||||
# objects, schema defaults filled in), so the plugin gets
|
||||
# the same shape it was constructed with.
|
||||
prepare = getattr(self.plugin_manager, 'prepare_plugin_config', None)
|
||||
prepared = prepare(_pid, new_config) if callable(prepare) else None
|
||||
if isinstance(prepared, dict):
|
||||
new_config = prepared
|
||||
_plugin.on_config_change(new_config)
|
||||
logger.debug("Plugin %s notified of config change", _pid)
|
||||
except Exception as e:
|
||||
|
||||
+142
-8
@@ -1,17 +1,25 @@
|
||||
"""Display size from config: the one computation every caller shares.
|
||||
"""Display size from config: the one computation the size readers share.
|
||||
|
||||
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
|
||||
``display.double_sided``. The web preview, the Starlark magnify default and
|
||||
the multi-display sync handshake used to re-derive that size themselves,
|
||||
each with its own defaults (``chain_length`` fell back to 2 in one place and
|
||||
1 in three others) and none of them applying double-sided mode. They now all
|
||||
call this module.
|
||||
``display.double_sided``. The web preview endpoints (``/display/current``, the
|
||||
SSE fallback), the Starlark magnify default and ``scripts/dev/vegas_audit.py``
|
||||
used to re-derive that size themselves, each with its own defaults
|
||||
(``chain_length`` fell back to 2 in one place and 1 in others) and none of
|
||||
them applying double-sided mode. They now call this module. (The multi-display
|
||||
sync handshake doesn't compute a size; it shares only
|
||||
``DEFAULT_CHAIN_LENGTH``.)
|
||||
|
||||
The size includes what the rgbmatrix library's pixel mappers do to it --
|
||||
``orientation`` and ``pixel_mapper_config`` (``Rotate:90`` swaps the axes,
|
||||
``U-mapper`` folds the chain) -- because ``RGBMatrix.width``/``height``, which
|
||||
``DisplayManager`` reports on hardware, are measured after them.
|
||||
|
||||
Kept free of hardware imports on purpose: the web interface imports it, and
|
||||
``display_manager`` pulls in ``rgbmatrix``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,6 +30,10 @@ DEFAULT_COLS = 64
|
||||
DEFAULT_CHAIN_LENGTH = 2
|
||||
DEFAULT_PARALLEL = 1
|
||||
|
||||
#: ``display.hardware.orientation`` -> degrees of the ``Rotate`` mapper
|
||||
#: DisplayManager appends to ``pixel_mapper_config`` (None: no mapper).
|
||||
ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
|
||||
|
||||
|
||||
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||
# A hand-edited config.json can hold anything here; treat a non-mapping
|
||||
@@ -35,10 +47,128 @@ def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||
return hw if isinstance(hw, Mapping) else {}
|
||||
|
||||
|
||||
def compose_pixel_mapper_config(hardware: Mapping[str, Any]) -> str:
|
||||
"""The ``pixel_mapper_config`` DisplayManager hands the library.
|
||||
|
||||
``pixel_mapper_config`` stays a free-form advanced field (e.g. "U-mapper"
|
||||
for chain layouts); ``orientation`` is the user-facing mounting rotation,
|
||||
appended as a trailing ``Rotate:<deg>`` mapper rather than overwriting it.
|
||||
"""
|
||||
base = hardware.get('pixel_mapper_config') or ''
|
||||
base = base.strip() if isinstance(base, str) else ''
|
||||
degrees = ORIENTATION_ROTATE_DEGREES.get(hardware.get('orientation', 'normal'))
|
||||
if degrees is None:
|
||||
return base
|
||||
rotate = f'Rotate:{degrees}'
|
||||
return f'{base};{rotate}' if base else rotate
|
||||
|
||||
|
||||
def _c_div(a: int, b: int) -> int:
|
||||
"""C integer division (truncates toward zero)."""
|
||||
q = abs(a) // abs(b)
|
||||
return q if (a >= 0) == (b >= 0) else -q
|
||||
|
||||
|
||||
def _c_strtol(text: str) -> Tuple[int, str]:
|
||||
"""strtol(text, &end, 10): the parsed value (0 if none) and the rest."""
|
||||
match = re.match(r'\s*([+-]?\d+)', text)
|
||||
if not match:
|
||||
return 0, text
|
||||
return int(match.group(1)), text[match.end():]
|
||||
|
||||
|
||||
def _remap_size(param: Optional[str], width: int, height: int,
|
||||
chain: int, parallel: int) -> Optional[Tuple[int, int]]:
|
||||
"""RemapMapper::SetParameters and GetSizeMapping (lib/pixel-mapper.cc)."""
|
||||
if not param:
|
||||
return None
|
||||
new_w, rest = _c_strtol(param)
|
||||
if not rest.startswith(','):
|
||||
return None
|
||||
new_h, rest = _c_strtol(rest[1:])
|
||||
if not rest.startswith('|'):
|
||||
return None
|
||||
rest = rest[1:]
|
||||
tiles = []
|
||||
while rest:
|
||||
x, rest = _c_strtol(rest)
|
||||
if not rest.startswith(','):
|
||||
return None
|
||||
y, rest = _c_strtol(rest[1:])
|
||||
if not rest or rest[0].lower() not in 'neswx':
|
||||
return None
|
||||
tiles.append((x, y, rest[0].lower()))
|
||||
rest = rest[1:]
|
||||
if rest.startswith('|'):
|
||||
rest = rest[1:]
|
||||
elif rest:
|
||||
return None
|
||||
if len(tiles) != chain * parallel:
|
||||
return None
|
||||
panel_w, panel_h = _c_div(width, chain), _c_div(height, parallel)
|
||||
for x, y, kind in tiles:
|
||||
if kind == 'x':
|
||||
continue
|
||||
# MapTile::MapToVisible of the panel's (0, 0) and far corner.
|
||||
x0, y0, x1, y1 = {
|
||||
'n': (x, y, x + panel_w - 1, y + panel_h - 1),
|
||||
'w': (x, y + panel_w - 1, x + panel_h - 1, y),
|
||||
's': (x + panel_w - 1, y + panel_h - 1, x, y),
|
||||
'e': (x + panel_h - 1, y, x, y + panel_w - 1),
|
||||
}[kind]
|
||||
if x1 < 0 or x0 >= new_w or y1 < 0 or y0 >= new_h:
|
||||
return None
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
def apply_pixel_mappers(width: int, height: int, mapper_config: str,
|
||||
chain: int, parallel: int) -> Tuple[int, int]:
|
||||
"""The canvas size after the library applies ``mapper_config``.
|
||||
|
||||
Mirrors ``RGBMatrix::Impl::ApplyNamedPixelMappers`` and each built-in
|
||||
mapper's ``SetParameters``/``GetSizeMapping`` in the pinned
|
||||
``lib/pixel-mapper.cc``: mappers apply left to right, and one the library
|
||||
doesn't know or can't configure is skipped and leaves the size alone.
|
||||
``multiplexing`` isn't modelled: its mappers give back the configured
|
||||
size for the panel sizes they are made for.
|
||||
"""
|
||||
for entry in (mapper_config or '').split(';'):
|
||||
name, colon, param = entry.partition(':')
|
||||
name = name.lower()
|
||||
param = param if colon else None
|
||||
if name == 'rotate':
|
||||
if not param:
|
||||
continue
|
||||
angle, rest = _c_strtol(param)
|
||||
if rest or angle % 90:
|
||||
continue
|
||||
if angle % 180:
|
||||
width, height = height, width
|
||||
elif name == 'u-mapper':
|
||||
if chain < 2 or chain % 2 or height % parallel:
|
||||
continue
|
||||
width, height = _c_div(width, 64) * 32, 2 * height
|
||||
elif name == 'v-mapper':
|
||||
width, height = (_c_div(width * parallel, chain),
|
||||
_c_div(height * chain, parallel))
|
||||
elif name == 'stacktorow':
|
||||
if param and any(c not in 'ZzFf, ' for c in param):
|
||||
continue
|
||||
width, height = width * parallel, _c_div(height, parallel)
|
||||
elif name == 'remap':
|
||||
size = _remap_size(param, width, height, chain, parallel)
|
||||
if size is not None:
|
||||
width, height = size
|
||||
# "mirror" keeps the size; the library skips names it doesn't know.
|
||||
return width, height
|
||||
|
||||
|
||||
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
|
||||
"""Width and height of the whole panel chain, in pixels.
|
||||
|
||||
``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
|
||||
``cols * chain_length`` by ``rows * parallel``, then through the pixel
|
||||
mappers ``orientation`` and ``pixel_mapper_config`` set up -- what
|
||||
``RGBMatrix.width``/``height`` report. Raises ``ValueError`` or
|
||||
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
|
||||
decide their own fallback.
|
||||
|
||||
@@ -54,7 +184,11 @@ def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
|
||||
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
|
||||
except OverflowError as e:
|
||||
raise ValueError(f"display.hardware size is not finite: {e}") from e
|
||||
return max(1, cols * chain_length), max(1, rows * parallel)
|
||||
width, height = max(1, cols * chain_length), max(1, rows * parallel)
|
||||
if chain_length >= 1 and parallel >= 1:
|
||||
width, height = apply_pixel_mappers(
|
||||
width, height, compose_pixel_mapper_config(hw), chain_length, parallel)
|
||||
return max(1, width), max(1, height)
|
||||
|
||||
|
||||
def resolve_double_sided(physical_width: int, physical_height: int,
|
||||
|
||||
+145
-70
@@ -37,8 +37,11 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
||||
from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
physical_size, resolve_double_sided,
|
||||
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
|
||||
resolve_double_sided,
|
||||
)
|
||||
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
|
||||
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
@@ -49,6 +52,7 @@ import zlib
|
||||
import freetype
|
||||
|
||||
from src.common import snapshot_policy
|
||||
from src.deprecation import deprecated
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
@@ -90,9 +94,11 @@ class _LogicalMatrix:
|
||||
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
||||
|
||||
In double-sided mode the physical panel chain shows N identical copies of a
|
||||
smaller logical screen. Plugins size themselves from ``matrix.width`` /
|
||||
``matrix.height`` (the documented convention, used at 30+ call sites), so
|
||||
this proxy reports the logical dimensions while delegating every real
|
||||
smaller logical screen. Plugins size themselves from
|
||||
``display_manager.width`` / ``height`` (the documented convention), which
|
||||
defer to ``matrix.width`` / ``matrix.height`` -- and many older plugins read
|
||||
``matrix.width`` directly -- so this proxy reports the logical dimensions
|
||||
while delegating every real
|
||||
operation — ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
|
||||
``Clear`` and so on — to the underlying physical matrix. The duplication
|
||||
itself happens once per frame in :meth:`DisplayManager.update_display`.
|
||||
@@ -246,28 +252,41 @@ class DisplayManager:
|
||||
# Calendar manager is now initialized by DisplayController
|
||||
|
||||
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
|
||||
# "normal" needs no suffix since 0 degrees is the identity transform.
|
||||
_ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
|
||||
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
|
||||
|
||||
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
|
||||
"""Compose the raw pixel_mapper_config string with the orientation setting.
|
||||
"""Compose pixel_mapper_config with the orientation setting.
|
||||
|
||||
`pixel_mapper_config` stays available as a free-form advanced field (e.g.
|
||||
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
|
||||
for physical mounting (e.g. panels mounted upside down) and is appended as
|
||||
a "Rotate:<deg>" mapper rather than overwriting any existing config.
|
||||
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
|
||||
web preview shares so it sizes the canvas the same way.
|
||||
"""
|
||||
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
|
||||
orientation = hardware_config.get('orientation', 'normal')
|
||||
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
|
||||
if degrees is None:
|
||||
return base_mapper
|
||||
rotate_mapper = f'Rotate:{degrees}'
|
||||
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
|
||||
return compose_pixel_mapper_config(hardware_config)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_advice(cause: str, error: Exception) -> str:
|
||||
"""What to do about a failed matrix init, for the log.
|
||||
|
||||
Only a library failure gets the rebuild hint: advice about the build
|
||||
or GPIO timing sends someone whose settings were refused the wrong way.
|
||||
"""
|
||||
if cause == "settings":
|
||||
return (f"{error} Change these in the web interface's Display tab "
|
||||
"(or display.hardware / display.runtime in config.json) "
|
||||
"and restart the display service.")
|
||||
if cause == "forced":
|
||||
return f"Error: {error}."
|
||||
advice = (f"Error: {error}. If the rgbmatrix library printed a message "
|
||||
"just before this, it names the problem.")
|
||||
if is_raspberry_pi_5():
|
||||
advice += (" On a Raspberry Pi 5, an mmap error means the library was "
|
||||
"built without Pi 5 support: sudo RPI_RGB_FORCE_REBUILD=1 "
|
||||
"./first_time_install.sh")
|
||||
return advice
|
||||
|
||||
def _setup_matrix(self):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
_init_error_str = None
|
||||
_init_cause = None
|
||||
try:
|
||||
# Allow callers (e.g., web UI) to force non-hardware fallback mode
|
||||
if getattr(self, '_force_fallback', False):
|
||||
@@ -278,50 +297,21 @@ class DisplayManager:
|
||||
hardware_config = self.config.get('display', {}).get('hardware', {})
|
||||
runtime_config = self.config.get('display', {}).get('runtime', {})
|
||||
|
||||
# Basic hardware settings
|
||||
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
|
||||
options.cols = hardware_config.get('cols', DEFAULT_COLS)
|
||||
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
|
||||
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
|
||||
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
|
||||
# The library has no error path for many settings it can't use:
|
||||
# it returns no matrix (which the binding doesn't check, so the
|
||||
# process crashes on its next call) or calls abort(), and systemd
|
||||
# restarts the service into the same crash. Refuse those first so
|
||||
# they become a logged, reported fallback (src/matrix_support.py).
|
||||
refused = refusal_message(library_refusals(
|
||||
hardware_config, runtime_config, pi5=is_raspberry_pi_5()))
|
||||
if refused:
|
||||
if os.getenv("EMULATOR", "false") != "true":
|
||||
raise MatrixSettingsRefused(refused)
|
||||
logger.warning("Emulator mode: continuing, but on a real panel the display would not start. %s", refused)
|
||||
|
||||
# Performance and stability settings
|
||||
options.brightness = hardware_config.get('brightness', 90)
|
||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
|
||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||
options.panel_type = hardware_config.get('panel_type', '')
|
||||
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
|
||||
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
|
||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
|
||||
|
||||
# Disable internal privilege dropping - we manage this via systemd or remain root
|
||||
# This prevents the library from dropping to 'daemon' user which breaks file permissions
|
||||
options.drop_privileges = False
|
||||
|
||||
# Additional settings from config
|
||||
if 'scan_mode' in hardware_config:
|
||||
options.scan_mode = hardware_config.get('scan_mode')
|
||||
if 'pwm_dither_bits' in hardware_config:
|
||||
options.pwm_dither_bits = hardware_config.get('pwm_dither_bits')
|
||||
if 'inverse_colors' in hardware_config:
|
||||
options.inverse_colors = hardware_config.get('inverse_colors')
|
||||
# Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU),
|
||||
# 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode)
|
||||
if 'rp1_rio' in runtime_config:
|
||||
if hasattr(options, 'rp1_rio'):
|
||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||
else:
|
||||
logger.warning(
|
||||
"rp1_rio is set in config but the installed rgbmatrix library does "
|
||||
"not support it — the library was likely built without Pi 5 RP1 "
|
||||
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
||||
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||
)
|
||||
# Every option comes from display.hardware / display.runtime, in
|
||||
# one place that scripts/scroll_speeds.py shares.
|
||||
self.apply_matrix_options(options, self.config)
|
||||
|
||||
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
||||
|
||||
@@ -369,7 +359,12 @@ class DisplayManager:
|
||||
|
||||
except Exception as e:
|
||||
_init_error_str = str(e)
|
||||
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
|
||||
if isinstance(e, MatrixSettingsRefused):
|
||||
_init_cause = "settings"
|
||||
logger.error("Failed to initialize RGB Matrix: %s", e)
|
||||
else:
|
||||
_init_cause = "forced" if getattr(self, '_force_fallback', False) else "library"
|
||||
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
|
||||
# Create a fallback image for web preview using configured dimensions when available
|
||||
self.matrix = None
|
||||
try:
|
||||
@@ -396,15 +391,18 @@ class DisplayManager:
|
||||
# Best-effort; ignore drawing errors in fallback
|
||||
pass
|
||||
logger.error(
|
||||
f"Matrix initialization failed — running in fallback/simulation mode "
|
||||
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
|
||||
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
|
||||
"submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
|
||||
)
|
||||
"Matrix initialization failed — running in fallback/simulation mode "
|
||||
"(size %dx%d). %s",
|
||||
fallback_width, fallback_height, self._fallback_advice(_init_cause, e))
|
||||
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
|
||||
|
||||
# Write hardware status file so the web UI can surface init failures
|
||||
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
|
||||
# cause: None when ok; "settings" when LEDMatrix refused the config
|
||||
# (fix the named settings), "library" when the library itself failed,
|
||||
# "forced" for a caller-requested fallback. The Display tab keys its
|
||||
# advice on it.
|
||||
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str,
|
||||
"cause": None if self.matrix is not None else _init_cause}
|
||||
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
|
||||
try:
|
||||
if os.path.islink(_status_path):
|
||||
@@ -672,8 +670,10 @@ class DisplayManager:
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
"""Temporarily present a smaller logical canvas to plugins.
|
||||
|
||||
Plugins lay out against ``display_manager.matrix.width`` (and the
|
||||
``width``/``height`` properties, which defer to it), so the only way to
|
||||
Plugins lay out against the ``display_manager.width``/``height``
|
||||
properties (which defer to ``matrix.width`` when hardware is present,
|
||||
and to the canvas when it is not; some older plugins read
|
||||
``matrix.width`` directly), so the only way to
|
||||
get a *narrower layout* rather than a cropped one is to tell the plugin
|
||||
the screen is narrower while it renders. Trimming after the fact cannot
|
||||
fix a forecast spread across five columns or a progress bar drawn at
|
||||
@@ -1140,6 +1140,7 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing text: {e}", exc_info=True)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def draw_sun(self, x: int, y: int, size: int = 16):
|
||||
"""Draw a sun icon using yellow circles and lines."""
|
||||
center = (x + size//2, y + size//2)
|
||||
@@ -1160,6 +1161,7 @@ class DisplayManager:
|
||||
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
|
||||
"""Draw a cloud icon."""
|
||||
# Draw multiple circles to form a cloud shape
|
||||
@@ -1167,6 +1169,7 @@ class DisplayManager:
|
||||
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
|
||||
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def draw_rain(self, x: int, y: int, size: int = 16):
|
||||
"""Draw rain icon with cloud and droplets."""
|
||||
# Draw cloud
|
||||
@@ -1181,6 +1184,7 @@ class DisplayManager:
|
||||
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
|
||||
fill=drop_color, width=2)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def draw_snow(self, x: int, y: int, size: int = 16):
|
||||
"""Draw snow icon with cloud and snowflakes."""
|
||||
# Draw cloud
|
||||
@@ -1301,6 +1305,7 @@ class DisplayManager:
|
||||
]
|
||||
self.draw.polygon(bolt_points, fill=bolt_color)
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
||||
"""Draw a weather icon based on the condition."""
|
||||
if condition.lower() in ['clear', 'sunny']:
|
||||
@@ -1317,6 +1322,7 @@ class DisplayManager:
|
||||
self._draw_sun(x, y, size)
|
||||
# Note: No update_display() here - let the caller handle the update
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
|
||||
color: tuple = (255, 255, 255)):
|
||||
"""Draw text with weather icons at specified positions."""
|
||||
@@ -1360,6 +1366,68 @@ class DisplayManager:
|
||||
|
||||
return dt.strftime(f"%b %-d{suffix}")
|
||||
|
||||
@classmethod
|
||||
def apply_matrix_options(cls, options, config: Dict[str, Any]):
|
||||
"""Fill ``options`` (an ``RGBMatrixOptions``) from the LEDMatrix config.
|
||||
|
||||
This is exactly what the display service drives the panel with, so a
|
||||
tool that opens the matrix itself (``scripts/scroll_speeds.py``) gets
|
||||
the same panel -- same runtime ``gpio_slowdown``, ``rp1_rio``,
|
||||
``panel_type``, orientation and defaults -- rather than a private copy
|
||||
that drifts. Does not open the matrix. Returns ``options``.
|
||||
"""
|
||||
display = config.get('display', {}) if isinstance(config, dict) else {}
|
||||
hardware_config = display.get('hardware', {})
|
||||
runtime_config = display.get('runtime', {})
|
||||
|
||||
# Basic hardware settings
|
||||
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
|
||||
options.cols = hardware_config.get('cols', DEFAULT_COLS)
|
||||
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
|
||||
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
|
||||
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
|
||||
|
||||
# Performance and stability settings
|
||||
options.brightness = hardware_config.get('brightness', 90)
|
||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||
# _build_pixel_mapper_config reads only class attributes, so the class
|
||||
# stands in for an instance here.
|
||||
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
|
||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||
options.panel_type = hardware_config.get('panel_type', '')
|
||||
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
|
||||
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
|
||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
|
||||
|
||||
# Disable internal privilege dropping - we manage this via systemd or remain root
|
||||
# This prevents the library from dropping to 'daemon' user which breaks file permissions
|
||||
options.drop_privileges = False
|
||||
|
||||
# Additional settings from config
|
||||
if 'scan_mode' in hardware_config:
|
||||
options.scan_mode = hardware_config.get('scan_mode')
|
||||
if 'pwm_dither_bits' in hardware_config:
|
||||
options.pwm_dither_bits = hardware_config.get('pwm_dither_bits')
|
||||
if 'inverse_colors' in hardware_config:
|
||||
options.inverse_colors = hardware_config.get('inverse_colors')
|
||||
# Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU),
|
||||
# 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode)
|
||||
if 'rp1_rio' in runtime_config:
|
||||
if hasattr(options, 'rp1_rio'):
|
||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||
else:
|
||||
logger.warning(
|
||||
"rp1_rio is set in config but the installed rgbmatrix library does "
|
||||
"not support it — the library was likely built without Pi 5 RP1 "
|
||||
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
||||
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||
)
|
||||
return options
|
||||
|
||||
@property
|
||||
def refresh_hz(self) -> float:
|
||||
"""The panel's refresh rate in Hz, from the hardware config.
|
||||
@@ -1405,6 +1473,12 @@ class DisplayManager:
|
||||
pixel every second refresh, which is how a scroll runs at half the
|
||||
refresh rate without fractional pixel positions.
|
||||
|
||||
The hold is part of the scroll's speed. A ScrollHelper configured by
|
||||
``scroll_config.configure()`` advances a fixed whole-pixel step per
|
||||
presented frame and reads no clock, so pass the returned
|
||||
``settings.frame_hold`` here: a scroll that leaves it at 1 is
|
||||
presented every refresh and runs ``frame_hold`` times too fast.
|
||||
|
||||
The hold is set here rather than once at plugin construction because
|
||||
it must not outlive the scroll that asked for it: plugins share one
|
||||
display manager, so a hold left set by whoever scrolled last would
|
||||
@@ -1533,6 +1607,7 @@ class DisplayManager:
|
||||
if removed_count > 0:
|
||||
logger.debug(f"Cleaned up {removed_count} expired deferred updates")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_scrolling_stats(self) -> dict:
|
||||
"""Get current scrolling statistics for debugging."""
|
||||
return {
|
||||
|
||||
@@ -40,6 +40,7 @@ from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
from src.deprecation import deprecated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -167,6 +168,7 @@ class FontManager:
|
||||
|
||||
logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get registered fonts for a specific manager or all managers.
|
||||
@@ -181,6 +183,7 @@ class FontManager:
|
||||
return self.manager_fonts.get(manager_id, {})
|
||||
return self.manager_fonts.copy()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all detected font usage across managers."""
|
||||
return self.detected_fonts.copy()
|
||||
@@ -357,6 +360,7 @@ class FontManager:
|
||||
logger.error(f"Plugin font not found: {font_path}")
|
||||
return None
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Unregister all fonts for a plugin."""
|
||||
try:
|
||||
@@ -393,6 +397,7 @@ class FontManager:
|
||||
for key in keys_to_remove:
|
||||
del self.font_cache[key]
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
||||
"""Get list of font families registered by a plugin."""
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
@@ -628,6 +633,7 @@ class FontManager:
|
||||
|
||||
# ==================== Override Management ====================
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def set_override(self, element_key: str, family: str = None, size_px: int = None):
|
||||
"""Set font override for a specific element."""
|
||||
if element_key not in self.font_overrides:
|
||||
@@ -647,6 +653,7 @@ class FontManager:
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def remove_override(self, element_key: str):
|
||||
"""Remove font override for a specific element."""
|
||||
if element_key in self.font_overrides:
|
||||
@@ -655,6 +662,7 @@ class FontManager:
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override removed for {element_key}")
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_overrides(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Get current font overrides."""
|
||||
return self.font_overrides.copy()
|
||||
@@ -744,10 +752,12 @@ class FontManager:
|
||||
self.metrics_cache.clear()
|
||||
logger.info("Font cache cleared")
|
||||
|
||||
@deprecated("3.7.0", "read font_catalog")
|
||||
def get_available_fonts(self) -> Dict[str, str]:
|
||||
"""Get dictionary of available font families and their paths."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_size_tokens(self) -> Dict[str, int]:
|
||||
"""Get available size tokens."""
|
||||
return self.size_tokens.copy()
|
||||
@@ -758,6 +768,7 @@ class FontManager:
|
||||
self.performance_stats[operation] = {}
|
||||
self.performance_stats[operation][font_key] = duration
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def get_performance_stats(self) -> Dict[str, Any]:
|
||||
"""Get performance statistics."""
|
||||
uptime = time.time() - self.performance_stats["start_time"]
|
||||
@@ -779,10 +790,12 @@ class FontManager:
|
||||
"detected_fonts": len(self.detected_fonts)
|
||||
}
|
||||
|
||||
@deprecated("3.7.0", "read font_catalog")
|
||||
def get_font_catalog(self) -> Dict[str, str]:
|
||||
"""Get the current font catalog."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
||||
"""Add a new font to the catalog."""
|
||||
try:
|
||||
@@ -815,6 +828,7 @@ class FontManager:
|
||||
logger.error(f"Error adding font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def remove_font(self, family_name: str) -> bool:
|
||||
"""Remove a font from the catalog."""
|
||||
try:
|
||||
@@ -842,6 +856,7 @@ class FontManager:
|
||||
logger.error(f"Error removing font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.7.0")
|
||||
def validate_font(self, font_path: str) -> Dict[str, Any]:
|
||||
"""Validate a font file."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Display settings the pinned rgbmatrix library refuses, on every board.
|
||||
|
||||
The library does not raise on a setting it can't use. For most it returns no
|
||||
matrix -- ``RGBMatrix::CreateFromOptions()`` gives back NULL when
|
||||
``Options::Validate()`` (``lib/options-initialize.cc``) or its GPIO slowdown
|
||||
check (``lib/led-matrix.cc``) fails -- and the Python binding stores that
|
||||
pointer without checking, so the display process crashes on its first call
|
||||
into the matrix. For two it calls ``abort()``: an unknown hardware mapping
|
||||
name, and more parallel chains than the mapping has outputs
|
||||
(``Framebuffer::InitHardwareMapping()`` and the ``Framebuffer`` constructor in
|
||||
``lib/framebuffer.cc``). Either way systemd restarts the service into the same
|
||||
crash, and no fallback or hardware status is ever reported.
|
||||
|
||||
``DisplayManager`` runs :func:`library_refusals` before creating the matrix,
|
||||
turning those crashes into a logged error and the usual fallback mode. The
|
||||
config API uses the same rules to refuse the settings up front, and
|
||||
:data:`INT_SETTING_LIMITS` is the one place the numeric ranges live.
|
||||
|
||||
Pi 5-only limits come from :mod:`src.pi5_matrix_support` and are added when
|
||||
``pi5=True``.
|
||||
|
||||
**Re-check this when the submodule is bumped** (pinned at 1ee4f76): the
|
||||
ranges in ``Options::Validate()``, the mapping table in
|
||||
``lib/hardware-mapping.c`` and the setter types in
|
||||
``bindings/python/rgbmatrix/core.pyx``. A stale rule here blocks settings a
|
||||
new library accepts; a missing one lets the display service crash-loop.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple
|
||||
|
||||
from src.pi5_matrix_support import pi5_unsupported_settings
|
||||
|
||||
#: The binding's setters for rows, chain_length, parallel and the other small
|
||||
#: integers are declared ``uint8_t`` (``core.pyx``), cols ``uint32_t``, and
|
||||
#: limit_refresh_rate_hz lands in a C ``int``; a larger value raises
|
||||
#: ``OverflowError`` before the library sees it.
|
||||
UINT8_MAX = 255
|
||||
UINT32_MAX = 2 ** 32 - 1
|
||||
INT32_MAX = 2 ** 31 - 1
|
||||
|
||||
#: field -> (config section, lowest, highest, must be even). The library's own
|
||||
#: ranges where it has one, otherwise the binding's integer type.
|
||||
INT_SETTING_LIMITS: Dict[str, Tuple[str, int, int, bool]] = {
|
||||
'rows': ('hardware', 8, 64, True),
|
||||
'cols': ('hardware', 16, UINT32_MAX, False),
|
||||
'chain_length': ('hardware', 1, UINT8_MAX, False),
|
||||
# 3 is the most any mapping in the default build has (see MAPPING_OUTPUTS).
|
||||
'parallel': ('hardware', 1, 3, False),
|
||||
'brightness': ('hardware', 1, 100, False),
|
||||
'scan_mode': ('hardware', 0, 1, False),
|
||||
'pwm_bits': ('hardware', 1, 11, False),
|
||||
'pwm_dither_bits': ('hardware', 0, 2, False),
|
||||
'pwm_lsb_nanoseconds': ('hardware', 50, 3000, False),
|
||||
# 0 = no cap; the library has no upper bound.
|
||||
'limit_refresh_rate_hz': ('hardware', 0, INT32_MAX, False),
|
||||
'row_address_type': ('hardware', 0, 5, False),
|
||||
# 22 registered multiplexers (lib/multiplex-mappers.cc).
|
||||
'multiplexing': ('hardware', 0, 22, False),
|
||||
'gpio_slowdown': ('runtime', 0, 10, False),
|
||||
'rp1_rio': ('runtime', 0, 1, False),
|
||||
}
|
||||
|
||||
#: Hardware mapping name -> parallel chains it has outputs for, as
|
||||
#: ``lib/hardware-mapping.c`` defines them. ``compute-module`` exists only when
|
||||
#: the library is built with ENABLE_WIDE_GPIO_COMPUTE_MODULE, which the pinned
|
||||
#: Makefile leaves commented out and the installer does not set, so the library
|
||||
#: LEDMatrix installs aborts on it like any other unknown name.
|
||||
MAPPING_OUTPUTS: Dict[str, int] = {
|
||||
'regular': 3,
|
||||
'adafruit-hat': 1,
|
||||
'adafruit-hat-pwm': 1,
|
||||
'regular-pi1': 1,
|
||||
'classic': 3,
|
||||
'classic-pi1': 1,
|
||||
}
|
||||
|
||||
#: What DisplayManager passes when a key is missing from display.hardware /
|
||||
#: display.runtime. Config migration normally fills these from
|
||||
#: config/config.template.json first, so they rarely apply.
|
||||
DISPLAY_MANAGER_DEFAULTS: Dict[str, Any] = {
|
||||
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
|
||||
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90, 'pwm_bits': 10,
|
||||
'pwm_lsb_nanoseconds': 150, 'led_rgb_sequence': 'RGB',
|
||||
'row_address_type': 0, 'multiplexing': 0, 'limit_refresh_rate_hz': 90,
|
||||
'gpio_slowdown': 3,
|
||||
}
|
||||
|
||||
|
||||
class Refusal(NamedTuple):
|
||||
"""One reason the library can't start with a config.
|
||||
|
||||
``fields`` are the settings involved; the config API reports a refusal
|
||||
only when the request sets one of them, so a problem already stored
|
||||
doesn't block an unrelated save.
|
||||
"""
|
||||
fields: Tuple[str, ...]
|
||||
message: str
|
||||
#: A Raspberry Pi 5-only limit, whose message is already a full sentence.
|
||||
pi5: bool = False
|
||||
|
||||
|
||||
class MatrixSettingsRefused(RuntimeError):
|
||||
"""Raised by DisplayManager instead of handing the library such a config."""
|
||||
|
||||
|
||||
def _as_int(value: Any) -> Optional[int]:
|
||||
"""The integer the binding would receive, or None if it isn't one.
|
||||
|
||||
A value that isn't a number is left to the binding, which raises a
|
||||
``TypeError`` DisplayManager already turns into fallback mode.
|
||||
"""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _setting(hardware: Mapping[str, Any], runtime: Mapping[str, Any], field: str) -> Any:
|
||||
section = runtime if INT_SETTING_LIMITS.get(field, ('hardware',))[0] == 'runtime' else hardware
|
||||
if field in section:
|
||||
return section[field]
|
||||
return DISPLAY_MANAGER_DEFAULTS.get(field)
|
||||
|
||||
|
||||
def describe_range(low: int, high: int, even: bool = False) -> str:
|
||||
kind = "an even integer" if even else "an integer"
|
||||
if high >= INT32_MAX:
|
||||
return f"{kind} of at least {low}"
|
||||
return f"{kind} from {low} to {high}"
|
||||
|
||||
|
||||
def library_refusals(hardware: Optional[Mapping[str, Any]],
|
||||
runtime: Optional[Mapping[str, Any]] = None,
|
||||
pi5: bool = False) -> List[Refusal]:
|
||||
"""Every reason the pinned library would refuse (NULL, abort or overflow)
|
||||
to start with this ``display.hardware`` / ``display.runtime`` config.
|
||||
|
||||
Missing keys take DisplayManager's defaults. ``pi5`` adds the Raspberry
|
||||
Pi 5 limits from :func:`pi5_unsupported_settings`.
|
||||
"""
|
||||
hardware = hardware if isinstance(hardware, Mapping) else {}
|
||||
runtime = runtime if isinstance(runtime, Mapping) else {}
|
||||
refusals: List[Refusal] = []
|
||||
|
||||
for field, (section, low, high, even) in INT_SETTING_LIMITS.items():
|
||||
# DisplayManager sets these only when present, and scan_mode /
|
||||
# pwm_dither_bits / rp1_rio have no default of its own.
|
||||
source = runtime if section == 'runtime' else hardware
|
||||
if field not in source and field not in DISPLAY_MANAGER_DEFAULTS:
|
||||
continue
|
||||
value = _as_int(_setting(hardware, runtime, field))
|
||||
if value is None:
|
||||
continue
|
||||
if not low <= value <= high or (even and value % 2):
|
||||
refusals.append(Refusal(
|
||||
(field,), f"{field} {value} (must be {describe_range(low, high, even)})"))
|
||||
|
||||
mapping = _setting(hardware, runtime, 'hardware_mapping')
|
||||
outputs = None
|
||||
if not isinstance(mapping, str):
|
||||
refusals.append(Refusal(
|
||||
('hardware_mapping',), f"hardware mapping {mapping!r} (must be a mapping name)"))
|
||||
else:
|
||||
# The library matches names case-insensitively and reads an empty
|
||||
# name as "regular".
|
||||
outputs = MAPPING_OUTPUTS.get((mapping or 'regular').lower())
|
||||
if outputs is None:
|
||||
refusals.append(Refusal(
|
||||
('hardware_mapping',),
|
||||
f'hardware mapping "{mapping}" (the installed library has '
|
||||
+ ", ".join(MAPPING_OUTPUTS) + ")"))
|
||||
|
||||
parallel = _as_int(_setting(hardware, runtime, 'parallel'))
|
||||
if outputs is not None and parallel is not None and 1 <= parallel <= 3 and parallel > outputs:
|
||||
refusals.append(Refusal(
|
||||
('parallel', 'hardware_mapping'),
|
||||
f'parallel {parallel} with hardware mapping "{mapping}", which has '
|
||||
f'{outputs} output{"s" if outputs != 1 else ""}'))
|
||||
|
||||
sequence = _setting(hardware, runtime, 'led_rgb_sequence')
|
||||
if isinstance(sequence, str) and not (
|
||||
len(sequence) == 3 and set(sequence.upper()) == {'R', 'G', 'B'}):
|
||||
refusals.append(Refusal(
|
||||
('led_rgb_sequence',),
|
||||
f'LED RGB sequence "{sequence}" (must be R, G and B in some order)'))
|
||||
|
||||
if pi5:
|
||||
unsupported = pi5_unsupported_settings(hardware)
|
||||
if unsupported:
|
||||
refusals.append(Refusal(
|
||||
('row_address_type', 'parallel', 'hardware_mapping'), unsupported, pi5=True))
|
||||
|
||||
return refusals
|
||||
|
||||
|
||||
def refusal_message(refusals: List[Refusal]) -> Optional[str]:
|
||||
"""One sentence naming every refusal, or None when there are none."""
|
||||
general = [r.message for r in refusals if not r.pi5]
|
||||
pi5 = [r.message for r in refusals if r.pi5]
|
||||
parts = []
|
||||
if general:
|
||||
parts.append("The installed rgbmatrix library can't start with these "
|
||||
"display settings: " + "; ".join(general) + ".")
|
||||
parts.extend(pi5)
|
||||
return " ".join(parts) or None
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Which matrix settings the pinned rgbmatrix library can drive on a Raspberry Pi 5.
|
||||
|
||||
On a Pi 5 the library drives the panel through the RP1 chip, and at the pinned
|
||||
rpi-rgb-led-matrix-master commit (1ee4f76) that path supports only some
|
||||
settings -- ``Rp1PioConfigSupported()`` in ``lib/rp1/rp1_pio_backend.cc``. For
|
||||
anything else ``RGBMatrix::CreateFromOptions()`` returns NULL. The Python
|
||||
binding does not check for that, so instead of raising, the display process
|
||||
crashes on its first call into the matrix, and systemd restarts it into the
|
||||
same crash every 10 seconds.
|
||||
|
||||
``DisplayManager`` checks this rule before creating the matrix, turning that
|
||||
crash into a logged error and the usual fallback mode. The config API and the
|
||||
Display form use the same rule to refuse the settings up front.
|
||||
|
||||
**Re-check this when the submodule is bumped.** Upstream is extending Pi 5
|
||||
support (e.g. 4e326c1b, "Pi5 - Improvements, additional led-row-addr-type
|
||||
support"); a stale rule here would block settings the new library drives.
|
||||
"""
|
||||
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
#: Read the way the library's ``Rp1PioPlatformDetected()`` reads it.
|
||||
MODEL_PATH = "/proc/device-tree/model"
|
||||
PI5_MODEL_MARKERS = ("Raspberry Pi 5", "Compute Module 5")
|
||||
|
||||
PI5_ROW_ADDRESS_TYPES = (0, 2)
|
||||
PI5_MAX_PARALLEL = 3
|
||||
PI5_HARDWARE_MAPPINGS = ("regular", "regular-pi1", "classic", "adafruit-hat", "adafruit-hat-pwm")
|
||||
|
||||
|
||||
def is_raspberry_pi_5() -> bool:
|
||||
"""True on a Pi 5-family board: Pi 5, Pi 500 or Compute Module 5."""
|
||||
try:
|
||||
with open(MODEL_PATH, "rb") as f:
|
||||
model = f.read(256).decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return False
|
||||
return any(marker in model for marker in PI5_MODEL_MARKERS)
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> Optional[int]:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def pi5_unsupported_settings(hardware: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Why a Pi 5 can't drive this ``display.hardware`` config, or None if it can.
|
||||
|
||||
Missing keys take DisplayManager's defaults. A value that isn't a number is
|
||||
left to the caller's own validation rather than reported here.
|
||||
"""
|
||||
problems = []
|
||||
row_address_type = _as_int(hardware.get("row_address_type"), 0)
|
||||
if row_address_type is not None and row_address_type not in PI5_ROW_ADDRESS_TYPES:
|
||||
problems.append(f"row address type {row_address_type} (only 0 and 2 are supported)")
|
||||
parallel = _as_int(hardware.get("parallel"), 1)
|
||||
if parallel is not None and not 1 <= parallel <= PI5_MAX_PARALLEL:
|
||||
problems.append(f"parallel {parallel} (1 to {PI5_MAX_PARALLEL} are supported)")
|
||||
mapping = hardware.get("hardware_mapping", "adafruit-hat-pwm")
|
||||
# The library treats an empty mapping as "regular".
|
||||
if isinstance(mapping, str) and (mapping or "regular") not in PI5_HARDWARE_MAPPINGS:
|
||||
problems.append(f'hardware mapping "{mapping}"')
|
||||
if not problems:
|
||||
return None
|
||||
return ("Not supported on a Raspberry Pi 5 by the installed rgbmatrix library: "
|
||||
+ "; ".join(problems) + ".")
|
||||
@@ -22,7 +22,11 @@ from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from src.plugin_system.schema_manager import (
|
||||
CORE_VEGAS_TUNING_KEYS, SchemaManager, normalize_legacy_booleans,
|
||||
)
|
||||
from src.common.path_safety import safe_path_component
|
||||
from src.deprecation import deprecated
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
@@ -351,6 +355,7 @@ class PluginManager:
|
||||
config = {}
|
||||
|
||||
# Check if plugin has a config schema
|
||||
schema = None
|
||||
schema_path = self.schema_manager.get_schema_path(plugin_id)
|
||||
if schema_path is None:
|
||||
# Schema file doesn't exist
|
||||
@@ -368,14 +373,11 @@ class PluginManager:
|
||||
f"The schema may be invalid. Please verify the schema file at: {schema_path}"
|
||||
)
|
||||
|
||||
# Merge config with schema defaults to ensure all defaults are applied
|
||||
try:
|
||||
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
|
||||
config = self.schema_manager.merge_with_defaults(config, defaults)
|
||||
self.logger.debug(f"Merged config with schema defaults for {plugin_id}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not apply schema defaults for {plugin_id}: {e}")
|
||||
# Continue with original config if defaults can't be applied
|
||||
# Legacy booleans read as objects, then schema defaults: the same
|
||||
# preparation saves, GET /plugins/config and hot reload apply
|
||||
# (prepare_plugin_config). In memory only: config.json is written
|
||||
# by saves, never by loading a plugin.
|
||||
config = self.prepare_plugin_config(plugin_id, config, schema=schema)
|
||||
|
||||
# Use PluginLoader to load plugin
|
||||
plugin_instance, module = self.plugin_loader.load_plugin(
|
||||
@@ -470,11 +472,57 @@ class PluginManager:
|
||||
#:
|
||||
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
|
||||
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``).
|
||||
CORE_OWNED_CONFIG_KEYS = frozenset({
|
||||
'vegas_width_pct',
|
||||
'vegas_overflow',
|
||||
'vegas_max_width_screens',
|
||||
})
|
||||
#:
|
||||
#: The list itself lives with the other core-owned per-plugin properties in
|
||||
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
|
||||
#: uses to keep these keys.
|
||||
CORE_OWNED_CONFIG_KEYS = CORE_VEGAS_TUNING_KEYS
|
||||
|
||||
def prepare_plugin_config(self, plugin_id: str, config: Any,
|
||||
schema: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""The config a plugin runs with, built from its raw config.json section.
|
||||
|
||||
A plugin that turned an on/off boolean into an ``{enabled, ...}``
|
||||
object still finds the boolean in config.json until its settings are
|
||||
next saved; it is read as the object, and schema defaults fill in the
|
||||
rest (``SchemaManager.prepare_plugin_config``). Used when loading a
|
||||
plugin and on hot reload (DisplayController), so ``on_config_change``
|
||||
receives the same shape the plugin was constructed with.
|
||||
|
||||
Never raises: on failure the legacy-boolean pass alone is applied, or
|
||||
failing that the section is returned as it was.
|
||||
"""
|
||||
if schema is None:
|
||||
try:
|
||||
schema = self.schema_manager.load_schema(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
|
||||
schema = None
|
||||
upgraded: List[str] = []
|
||||
try:
|
||||
prepared = self.schema_manager.prepare_plugin_config(
|
||||
plugin_id, config, schema=schema, changed_paths=upgraded)
|
||||
self.logger.debug("Merged config with schema defaults for %s", plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.warning("Could not apply schema defaults for %s: %s", plugin_id, e)
|
||||
# Continue without defaults if they can't be applied
|
||||
upgraded = []
|
||||
prepared = config if isinstance(config, dict) else {}
|
||||
if schema:
|
||||
try:
|
||||
prepared = normalize_legacy_booleans(prepared, schema, upgraded)
|
||||
except Exception as legacy_error:
|
||||
self.logger.warning(
|
||||
"Could not read legacy boolean settings for %s: %s",
|
||||
plugin_id, legacy_error)
|
||||
if upgraded:
|
||||
self.logger.info(
|
||||
"Plugin %s: reading legacy boolean setting %s as "
|
||||
"{\"enabled\": ...}; saving the plugin's settings "
|
||||
"stores the new shape",
|
||||
plugin_id, ", ".join(upgraded),
|
||||
)
|
||||
return prepared
|
||||
|
||||
def _strip_core_owned_keys(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A shallow copy of ``config`` without the core's own tuning keys.
|
||||
@@ -664,6 +712,7 @@ class PluginManager:
|
||||
"""
|
||||
return self.plugins.copy()
|
||||
|
||||
@deprecated("3.7.0", "check each plugin's enabled flag in plugins")
|
||||
def get_enabled_plugins(self) -> List[str]:
|
||||
"""
|
||||
Get list of enabled plugin IDs.
|
||||
@@ -724,11 +773,20 @@ class PluginManager:
|
||||
|
||||
Returns:
|
||||
Directory path as string or None if not found
|
||||
|
||||
``plugin_id`` often comes straight from a request, so anything that is
|
||||
not one plain path segment (``..``, ``a/b``, an absolute path) is
|
||||
refused instead of being joined onto ``plugins_dir``. The join is not
|
||||
resolved further: dev plugins are symlinks into ``plugins_dir``.
|
||||
"""
|
||||
with self._discovery_lock:
|
||||
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
|
||||
return str(self.plugin_directories[plugin_id])
|
||||
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
if plugin_id is None:
|
||||
return None
|
||||
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
return str(plugin_dir)
|
||||
|
||||
+340
-181
@@ -13,6 +13,303 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import jsonschema
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
|
||||
|
||||
def _renders_as_object(prop: Dict[str, Any]) -> bool:
|
||||
"""``field_type == 'object'`` as ``plugin_config.html`` computes it.
|
||||
|
||||
The template takes a type list's *first* entry, so ``["object", "null"]``
|
||||
is an object and ``["boolean", "object"]`` is a checkbox.
|
||||
"""
|
||||
field_type = prop.get('type')
|
||||
if isinstance(field_type, list):
|
||||
field_type = field_type[0] if field_type else None
|
||||
return field_type == 'object'
|
||||
|
||||
|
||||
def legacy_bool_as_object(value: Any, prop: Any) -> Any:
|
||||
"""Read a boolean stored where the schema now has an ``{enabled, ...}`` object.
|
||||
|
||||
Plugins turn an on/off switch into a settings object (news'
|
||||
``global.dynamic_duration: true`` became ``{enabled, min_duration_seconds,
|
||||
...}``), but config.json keeps the boolean until the user next saves that
|
||||
plugin's form. The boolean was the switch the object's ``enabled`` now
|
||||
holds, so it becomes ``{"enabled": value}`` and schema defaults fill the rest.
|
||||
|
||||
Returns ``value`` itself when the rule does not apply: anything that is not
|
||||
a real ``bool`` (``1``, ``"true"``, ``None``) or a schema property that is not
|
||||
an object with an ``enabled`` child. Those stay as they are, so validation
|
||||
still reports a genuine mismatch.
|
||||
|
||||
This is the rule ``render_nested_section`` in
|
||||
``web_interface/templates/v3/partials/plugin_config.html`` applies when it
|
||||
draws the form. ``test/test_legacy_boolean_config.py`` renders that macro
|
||||
against this function, so change both together.
|
||||
"""
|
||||
if not isinstance(value, bool) or not isinstance(prop, dict):
|
||||
return value
|
||||
properties = prop.get('properties')
|
||||
if (_renders_as_object(prop) and isinstance(properties, dict)
|
||||
and 'enabled' in properties):
|
||||
return {'enabled': value}
|
||||
return value
|
||||
|
||||
|
||||
def normalize_legacy_booleans(config: Any, schema: Any,
|
||||
changed_paths: Optional[List[str]] = None,
|
||||
_prefix: str = '') -> Any:
|
||||
"""Apply :func:`legacy_bool_as_object` at every depth of a plugin config.
|
||||
|
||||
Walks the config along the schema's ``properties`` the way the settings
|
||||
form does: into nested objects, not into array items (the form hands arrays
|
||||
to widgets and never applies the rule there).
|
||||
|
||||
Never mutates ``config``. Returns the same object when nothing changed, and
|
||||
otherwise copies only the dicts on the path to each upgraded value. When
|
||||
``changed_paths`` is given, the dotted path of each upgraded value is
|
||||
appended to it.
|
||||
"""
|
||||
if not isinstance(config, dict) or not isinstance(schema, dict):
|
||||
return config
|
||||
properties = schema.get('properties')
|
||||
if not isinstance(properties, dict):
|
||||
return config
|
||||
|
||||
result = config
|
||||
for key, value in config.items():
|
||||
prop = properties.get(key)
|
||||
if not isinstance(prop, dict) or not _renders_as_object(prop):
|
||||
continue
|
||||
path = f"{_prefix}.{key}" if _prefix else key
|
||||
new_value = legacy_bool_as_object(value, prop)
|
||||
if new_value is not value:
|
||||
if changed_paths is not None:
|
||||
changed_paths.append(path)
|
||||
elif isinstance(value, dict):
|
||||
new_value = normalize_legacy_booleans(value, prop, changed_paths, path)
|
||||
if new_value is not value:
|
||||
if result is config:
|
||||
result = dict(config)
|
||||
result[key] = new_value
|
||||
return result
|
||||
|
||||
|
||||
#: Per-plugin settings the **core** owns: it reads them out of each plugin's
|
||||
#: config section, so they are allowed in every plugin's config whether or not
|
||||
#: the plugin's schema declares them. The one list for validation, for the web
|
||||
#: save filter and for the load-time checks -- a private copy is how JSON saves
|
||||
#: came to drop ``skin`` and the ``vegas_*`` keys while the validator accepted
|
||||
#: them.
|
||||
#:
|
||||
#: Values are the schema used when the plugin does not declare the property.
|
||||
CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
# Defaults match BasePlugin behavior: enabled=True, display_duration=15,
|
||||
# live_priority=False.
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable or disable this plugin"
|
||||
},
|
||||
"display_duration": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"description": "How long to display this plugin in seconds"
|
||||
},
|
||||
"live_priority": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an enum here:
|
||||
# validation must keep passing when a configured skin gets uninstalled
|
||||
# (rendering falls back to built-in). The install-dependent enum is
|
||||
# injected only at serve time (inject_skin_selector) for the web UI
|
||||
# dropdown.
|
||||
"skin": {
|
||||
"type": ["string", "object", "null"],
|
||||
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
||||
},
|
||||
"skin_options": {
|
||||
"type": "object",
|
||||
"description": "Options passed through to the selected skin"
|
||||
},
|
||||
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
|
||||
# Left untyped: the adapter validates them itself and ignores a bad
|
||||
# value with a log line, so a stored one must never block a save.
|
||||
"vegas_width_pct": {
|
||||
"description": "Vegas mode: width of this plugin's card, as a percentage of the panel"
|
||||
},
|
||||
"vegas_overflow": {
|
||||
"description": "Vegas mode: 'rotate' or 'truncate' when this plugin's content overflows"
|
||||
},
|
||||
"vegas_max_width_screens": {
|
||||
"description": "Vegas mode: widest this plugin's card may be, in screens"
|
||||
},
|
||||
}
|
||||
|
||||
#: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin
|
||||
#: state. PluginManager strips these before its soft validation (see
|
||||
#: PluginManager.CORE_OWNED_CONFIG_KEYS).
|
||||
CORE_VEGAS_TUNING_KEYS = frozenset({
|
||||
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
|
||||
})
|
||||
|
||||
|
||||
def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed.
|
||||
|
||||
Properties the plugin declares itself are left as declared. Core
|
||||
properties are removed from ``required``: they are system-managed.
|
||||
"""
|
||||
enhanced = copy.deepcopy(schema) if isinstance(schema, dict) else {}
|
||||
properties = enhanced.setdefault("properties", {})
|
||||
for name, definition in CORE_PLUGIN_PROPERTIES.items():
|
||||
if name not in properties:
|
||||
properties[name] = copy.deepcopy(definition)
|
||||
if "required" in enhanced:
|
||||
enhanced["required"] = [field for field in enhanced["required"]
|
||||
if field not in CORE_PLUGIN_PROPERTIES]
|
||||
return enhanced
|
||||
|
||||
|
||||
def extract_schema_defaults(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Default values of a JSON Schema's properties, recursively.
|
||||
|
||||
A property's own ``default`` wins; otherwise a nested object contributes
|
||||
its children's defaults, and an array contributes ``[]`` (or a one-item
|
||||
list of its ``items`` default). This is what a device runs with, so the
|
||||
dev tools use it too (src/plugin_system/testing/loading.py).
|
||||
"""
|
||||
defaults: Dict[str, Any] = {}
|
||||
properties = schema.get('properties', {}) if isinstance(schema, dict) else {}
|
||||
if not isinstance(properties, dict):
|
||||
return defaults
|
||||
|
||||
for key, prop_schema in properties.items():
|
||||
if not isinstance(prop_schema, dict):
|
||||
continue
|
||||
# If property has a default, use it
|
||||
if 'default' in prop_schema:
|
||||
defaults[key] = prop_schema['default']
|
||||
continue
|
||||
|
||||
# Handle nested objects
|
||||
if prop_schema.get('type') == 'object' and 'properties' in prop_schema:
|
||||
nested_defaults = extract_schema_defaults(prop_schema)
|
||||
if nested_defaults:
|
||||
defaults[key] = nested_defaults
|
||||
|
||||
# Handle arrays with object items
|
||||
elif prop_schema.get('type') == 'array' and 'items' in prop_schema:
|
||||
items_schema = prop_schema['items']
|
||||
if items_schema.get('type') == 'object' and 'properties' in items_schema:
|
||||
# For arrays of objects, use empty array as default
|
||||
# Individual objects will use their defaults when created
|
||||
defaults[key] = []
|
||||
elif 'default' in items_schema:
|
||||
# Array with default item value
|
||||
defaults[key] = [items_schema['default']]
|
||||
else:
|
||||
# Empty array as default
|
||||
defaults[key] = []
|
||||
|
||||
# For other types without defaults, don't add to defaults dict
|
||||
# This allows plugins to handle missing values as needed
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
def plugin_config_defaults(schema: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Every default a plugin's config gets: the schema's plus the core ones.
|
||||
|
||||
A plugin with no schema gets the minimal ``enabled: False,
|
||||
display_duration: 15``. Device location is not applied here; that needs a
|
||||
config manager (SchemaManager.generate_default_config).
|
||||
"""
|
||||
if not schema:
|
||||
return {
|
||||
'enabled': False,
|
||||
'display_duration': 15
|
||||
}
|
||||
|
||||
defaults = extract_schema_defaults(schema)
|
||||
|
||||
# Ensure core properties have defaults (they may not be in the schema)
|
||||
# These match BasePlugin behavior
|
||||
for name in ('enabled', 'display_duration', 'live_priority'):
|
||||
if name not in defaults:
|
||||
defaults[name] = CORE_PLUGIN_PROPERTIES[name]['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def merge_config_defaults(config: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Merge configuration with defaults, preserving user values.
|
||||
|
||||
Also replaces None values with defaults so a config never starts with
|
||||
None where a default exists. Neither argument is mutated.
|
||||
"""
|
||||
merged = copy.deepcopy(defaults)
|
||||
|
||||
def deep_merge(target: Dict[str, Any], source: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
|
||||
"""Recursively merge source into target, replacing None with defaults."""
|
||||
for key, value in source.items():
|
||||
default_value = default_dict.get(key)
|
||||
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
# Both are dicts, recursively merge
|
||||
if isinstance(default_value, dict):
|
||||
deep_merge(target[key], value, default_value)
|
||||
else:
|
||||
deep_merge(target[key], value, {})
|
||||
elif value is None and default_value is not None:
|
||||
# Value is None and we have a default, use the default
|
||||
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
|
||||
else:
|
||||
# Normal merge: user value takes precedence (copy if dict/list)
|
||||
if isinstance(value, (dict, list)):
|
||||
target[key] = copy.deepcopy(value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
deep_merge(merged, config, defaults)
|
||||
|
||||
# Final pass: replace any remaining None values at any level with defaults
|
||||
def replace_none_with_defaults(target: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
|
||||
"""Recursively replace None values with defaults."""
|
||||
for key in list(target.keys()):
|
||||
value = target[key]
|
||||
default_value = default_dict.get(key)
|
||||
|
||||
if value is None and default_value is not None:
|
||||
# Replace None with default
|
||||
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
|
||||
elif isinstance(value, dict) and isinstance(default_value, dict):
|
||||
# Recursively process nested dicts
|
||||
replace_none_with_defaults(value, default_value)
|
||||
|
||||
replace_none_with_defaults(merged, defaults)
|
||||
return merged
|
||||
|
||||
|
||||
def prepare_plugin_config(config: Any, schema: Optional[Dict[str, Any]],
|
||||
defaults: Dict[str, Any],
|
||||
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""The config a plugin runs with, from its stored (or submitted) section.
|
||||
|
||||
Legacy booleans are read as ``{"enabled": ...}`` objects
|
||||
(normalize_legacy_booleans), then schema defaults fill in whatever is
|
||||
missing. Loading a plugin, both config saves, GET /plugins/config, hot
|
||||
reload and the dev tools all go through this, so a plugin sees the same
|
||||
shape however its config reached it.
|
||||
"""
|
||||
config = config if isinstance(config, dict) else {}
|
||||
if schema:
|
||||
config = normalize_legacy_booleans(config, schema, changed_paths)
|
||||
return merge_config_defaults(config, defaults)
|
||||
|
||||
|
||||
class SchemaManager:
|
||||
"""
|
||||
@@ -184,55 +481,10 @@ class SchemaManager:
|
||||
"""
|
||||
Recursively extract default values from a JSON Schema.
|
||||
|
||||
Handles nested objects, arrays, and all schema types.
|
||||
|
||||
Args:
|
||||
schema: JSON Schema dictionary
|
||||
prefix: Optional prefix for logging/debugging
|
||||
|
||||
Returns:
|
||||
Dictionary of default values
|
||||
See :func:`extract_schema_defaults`; ``prefix`` is accepted for
|
||||
compatibility and unused.
|
||||
"""
|
||||
defaults = {}
|
||||
|
||||
# Handle schema with properties
|
||||
properties = schema.get('properties', {})
|
||||
if not properties:
|
||||
return defaults
|
||||
|
||||
for key, prop_schema in properties.items():
|
||||
field_path = f"{prefix}.{key}" if prefix else key
|
||||
|
||||
# If property has a default, use it
|
||||
if 'default' in prop_schema:
|
||||
defaults[key] = prop_schema['default']
|
||||
self.logger.debug(f"Found default for {field_path}: {prop_schema['default']}")
|
||||
continue
|
||||
|
||||
# Handle nested objects
|
||||
if prop_schema.get('type') == 'object' and 'properties' in prop_schema:
|
||||
nested_defaults = self.extract_defaults_from_schema(prop_schema, field_path)
|
||||
if nested_defaults:
|
||||
defaults[key] = nested_defaults
|
||||
|
||||
# Handle arrays with object items
|
||||
elif prop_schema.get('type') == 'array' and 'items' in prop_schema:
|
||||
items_schema = prop_schema['items']
|
||||
if items_schema.get('type') == 'object' and 'properties' in items_schema:
|
||||
# For arrays of objects, use empty array as default
|
||||
# Individual objects will use their defaults when created
|
||||
defaults[key] = []
|
||||
elif 'default' in items_schema:
|
||||
# Array with default item value
|
||||
defaults[key] = [items_schema['default']]
|
||||
else:
|
||||
# Empty array as default
|
||||
defaults[key] = []
|
||||
|
||||
# For other types without defaults, don't add to defaults dict
|
||||
# This allows plugins to handle missing values as needed
|
||||
|
||||
return defaults
|
||||
return extract_schema_defaults(schema)
|
||||
|
||||
def get_device_location(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -312,24 +564,11 @@ class SchemaManager:
|
||||
schema = self.load_schema(plugin_id, use_cache=use_cache)
|
||||
if not schema:
|
||||
# Return minimal defaults if no schema
|
||||
return {
|
||||
'enabled': False,
|
||||
'display_duration': 15
|
||||
}
|
||||
return plugin_config_defaults(None)
|
||||
|
||||
# Extract defaults from schema
|
||||
defaults = self.extract_defaults_from_schema(schema)
|
||||
|
||||
# Ensure core properties have defaults (they may not be in the schema)
|
||||
# These match BasePlugin behavior
|
||||
if 'enabled' not in defaults:
|
||||
defaults['enabled'] = schema.get('properties', {}).get('enabled', {}).get('default', True)
|
||||
|
||||
if 'display_duration' not in defaults:
|
||||
defaults['display_duration'] = schema.get('properties', {}).get('display_duration', {}).get('default', 15)
|
||||
|
||||
if 'live_priority' not in defaults:
|
||||
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
|
||||
# Schema defaults plus the core properties' (they may not be in the
|
||||
# schema)
|
||||
defaults = plugin_config_defaults(schema)
|
||||
|
||||
# Cache the defaults *before* the device location is layered on, so a
|
||||
# later change to the device location is picked up by the next call.
|
||||
@@ -337,6 +576,27 @@ class SchemaManager:
|
||||
|
||||
return self.apply_device_location(defaults)
|
||||
|
||||
def prepare_plugin_config(self, plugin_id: str, config: Any,
|
||||
schema: Optional[Dict[str, Any]] = None,
|
||||
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
The config a plugin runs with: see :func:`prepare_plugin_config`.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
config: The plugin's stored or submitted config section
|
||||
schema: The plugin's schema, when the caller already has it
|
||||
changed_paths: Receives the dotted path of each legacy boolean
|
||||
read as an object
|
||||
|
||||
Returns:
|
||||
A new dict; ``config`` is not mutated
|
||||
"""
|
||||
if schema is None:
|
||||
schema = self.load_schema(plugin_id, use_cache=True)
|
||||
defaults = self.generate_default_config(plugin_id, use_cache=True)
|
||||
return prepare_plugin_config(config, schema, defaults, changed_paths)
|
||||
|
||||
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
|
||||
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
@@ -357,80 +617,18 @@ class SchemaManager:
|
||||
errors = []
|
||||
|
||||
try:
|
||||
# Core plugin properties that should always be allowed
|
||||
# These are handled by the base plugin system and should not cause validation failures
|
||||
# Defaults match BasePlugin behavior: enabled=True, display_duration=15, live_priority=False
|
||||
core_properties = {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable or disable this plugin"
|
||||
},
|
||||
"display_duration": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"description": "How long to display this plugin in seconds"
|
||||
},
|
||||
"live_priority": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
|
||||
# enum here: validation must keep passing when a configured
|
||||
# skin gets uninstalled (rendering falls back to built-in).
|
||||
# The install-dependent enum is injected only at serve time
|
||||
# (inject_skin_selector) for the web UI dropdown.
|
||||
"skin": {
|
||||
"type": ["string", "object", "null"],
|
||||
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
||||
},
|
||||
"skin_options": {
|
||||
"type": "object",
|
||||
"description": "Options passed through to the selected skin"
|
||||
}
|
||||
}
|
||||
|
||||
# Create a deep copy of the schema to modify (to avoid mutating the original)
|
||||
enhanced_schema = copy.deepcopy(schema)
|
||||
if "properties" not in enhanced_schema:
|
||||
enhanced_schema["properties"] = {}
|
||||
|
||||
# Inject core properties if they're not already defined in the schema
|
||||
# This ensures core properties are always allowed even if not in the plugin's schema
|
||||
properties_added = []
|
||||
for prop_name, prop_def in core_properties.items():
|
||||
if prop_name not in enhanced_schema["properties"]:
|
||||
enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def)
|
||||
properties_added.append(prop_name)
|
||||
|
||||
# Log if we added any core properties (for debugging)
|
||||
if properties_added and plugin_id:
|
||||
# Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by
|
||||
# the base plugin system and should not cause validation failures:
|
||||
# they are allowed even when the plugin's schema doesn't declare
|
||||
# them, and never required.
|
||||
enhanced_schema = with_core_plugin_properties(schema)
|
||||
if plugin_id:
|
||||
declared = schema.get("properties", {}) if isinstance(schema, dict) else {}
|
||||
self.logger.debug(
|
||||
f"Injected core properties into schema for {plugin_id}: {properties_added}"
|
||||
"Injected core properties into schema for %s: %s", plugin_id,
|
||||
[name for name in CORE_PLUGIN_PROPERTIES if name not in declared]
|
||||
)
|
||||
|
||||
# Remove core properties from required array (they're system-managed)
|
||||
# Core properties should be allowed but not required for validation
|
||||
if "required" in enhanced_schema:
|
||||
core_prop_names = list(core_properties.keys())
|
||||
removed_from_required = [
|
||||
field for field in enhanced_schema["required"]
|
||||
if field in core_prop_names
|
||||
]
|
||||
enhanced_schema["required"] = [
|
||||
field for field in enhanced_schema["required"]
|
||||
if field not in core_prop_names
|
||||
]
|
||||
|
||||
# Log if we removed any core properties from required (for debugging)
|
||||
if removed_from_required and plugin_id:
|
||||
self.logger.debug(
|
||||
f"Removed core properties from required array for {plugin_id}: {removed_from_required}"
|
||||
)
|
||||
|
||||
# Create validator with enhanced schema
|
||||
validator = Draft7Validator(enhanced_schema)
|
||||
|
||||
@@ -555,47 +753,7 @@ class SchemaManager:
|
||||
Returns:
|
||||
Merged configuration with defaults applied where missing or None
|
||||
"""
|
||||
merged = copy.deepcopy(defaults)
|
||||
|
||||
def deep_merge(target: Dict[str, Any], source: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
|
||||
"""Recursively merge source into target, replacing None with defaults."""
|
||||
for key, value in source.items():
|
||||
default_value = default_dict.get(key)
|
||||
|
||||
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
||||
# Both are dicts, recursively merge
|
||||
if isinstance(default_value, dict):
|
||||
deep_merge(target[key], value, default_value)
|
||||
else:
|
||||
deep_merge(target[key], value, {})
|
||||
elif value is None and default_value is not None:
|
||||
# Value is None and we have a default, use the default
|
||||
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
|
||||
else:
|
||||
# Normal merge: user value takes precedence (copy if dict/list)
|
||||
if isinstance(value, (dict, list)):
|
||||
target[key] = copy.deepcopy(value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
deep_merge(merged, config, defaults)
|
||||
|
||||
# Final pass: replace any remaining None values at any level with defaults
|
||||
def replace_none_with_defaults(target: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
|
||||
"""Recursively replace None values with defaults."""
|
||||
for key in list(target.keys()):
|
||||
value = target[key]
|
||||
default_value = default_dict.get(key)
|
||||
|
||||
if value is None and default_value is not None:
|
||||
# Replace None with default
|
||||
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
|
||||
elif isinstance(value, dict) and isinstance(default_value, dict):
|
||||
# Recursively process nested dicts
|
||||
replace_none_with_defaults(value, default_value)
|
||||
|
||||
replace_none_with_defaults(merged, defaults)
|
||||
return merged
|
||||
return merge_config_defaults(config, defaults)
|
||||
|
||||
def detect_config_key_collisions(
|
||||
self,
|
||||
@@ -619,10 +777,11 @@ class SchemaManager:
|
||||
"""
|
||||
collisions = []
|
||||
|
||||
# Reserved top-level config keys that plugins should not use as IDs
|
||||
reserved_keys = {
|
||||
'display', 'schedule', 'timezone', 'plugin_system',
|
||||
'display_modes', 'system', 'hardware', 'debug',
|
||||
# Reserved top-level config keys that plugins should not use as IDs:
|
||||
# every core section (src/core_config_keys.py), plus a few names that
|
||||
# read as core even though no current section uses them.
|
||||
reserved_keys = set(CORE_CONFIG_KEYS) | {
|
||||
'display_modes', 'hardware', 'debug',
|
||||
'log_level', 'emulator', 'web_interface'
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.logging_config import get_logger
|
||||
|
||||
@@ -72,9 +73,16 @@ def secrets_top_level_keys(config_manager) -> Set[str]:
|
||||
return set(secrets) if isinstance(secrets, dict) else set()
|
||||
|
||||
|
||||
def ignored_config_keys(config_manager) -> Set[str]:
|
||||
"""Config keys that are not plugin ids: system keys plus secrets keys."""
|
||||
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
|
||||
def ignored_config_keys(config_manager, installed_ids=frozenset()) -> Set[str]:
|
||||
"""Config keys that are not plugin ids: core keys plus secrets keys.
|
||||
|
||||
A secrets key that is also an installed plugin's id is not ignored: plugin
|
||||
secrets are namespaced by plugin id, so that key is the plugin's own entry.
|
||||
Ignoring it made every installed plugin with secrets read as "installed but
|
||||
missing from config" on each run.
|
||||
"""
|
||||
secrets_only = secrets_top_level_keys(config_manager) - set(installed_ids)
|
||||
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_only
|
||||
|
||||
|
||||
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
|
||||
@@ -132,6 +140,13 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
||||
plugins were all present in config kept being told, for hours, that four of
|
||||
them were missing and should be removed from config.json.
|
||||
|
||||
An "in config but not on disk" finding is stale once the plugin is
|
||||
installed, and equally once the id is no longer a plugin entry in config --
|
||||
whether the user removed it or it was never a plugin (a core key such as
|
||||
``auto_update`` that an older build misread). Without the second check a
|
||||
verdict written by such a build kept telling users to delete a real core
|
||||
setting until the next full reconciliation run.
|
||||
|
||||
Entry kinds this cannot re-check are kept, so filtering only ever removes
|
||||
findings that are provably stale.
|
||||
"""
|
||||
@@ -143,7 +158,7 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
||||
if plugin_id not in config_keys:
|
||||
live.append(entry)
|
||||
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
|
||||
if plugin_id not in installed_ids:
|
||||
if plugin_id in config_keys and plugin_id not in installed_ids:
|
||||
live.append(entry)
|
||||
else:
|
||||
live.append(entry)
|
||||
@@ -280,39 +295,22 @@ class StateReconciliation:
|
||||
message=f"Reconciliation failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Top-level config keys that are NOT plugins.
|
||||
# Includes both config.json structural keys and config_secrets.json top-level
|
||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||
'web_display_autostart', 'timezone', 'location', 'display',
|
||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||
'dim_schedule', 'network', 'system', 'schedule',
|
||||
# Multi-display sync config (config.json structural key)
|
||||
'sync',
|
||||
# Secrets file top-level keys (merged in by load_config)
|
||||
'github', 'youtube',
|
||||
})
|
||||
|
||||
def _secrets_top_level_keys(self) -> Set[str]:
|
||||
"""Top-level keys that load_config() merges in from the secrets file.
|
||||
|
||||
load_config() merges config_secrets.json into the config it returns, so
|
||||
those keys sit alongside plugin ids. _SYSTEM_CONFIG_KEYS named them
|
||||
individually ('github', 'youtube'), which broke the moment anything else
|
||||
was written there: a 'data' key became a phantom plugin, permanently
|
||||
reported as "in config but not on disk". Reading the file keeps this
|
||||
correct no matter what it holds.
|
||||
"""
|
||||
return secrets_top_level_keys(self.config_manager)
|
||||
# Top-level config keys that are NOT plugins. The core keys come from the
|
||||
# shared list in src/core_config_keys.py -- a private copy here missed
|
||||
# #581's 'auto_update' and reported it as a plugin missing from disk.
|
||||
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
|
||||
# itself is read at run time too (ignored_config_keys): load_config() merges
|
||||
# it in, and naming its keys one by one let a 'data' key become a phantom
|
||||
# plugin permanently reported as "in config but not on disk".
|
||||
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from config file."""
|
||||
state = {}
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
ignored = self._SYSTEM_CONFIG_KEYS | self._secrets_top_level_keys()
|
||||
ignored = ignored_config_keys(self.config_manager,
|
||||
disk_plugin_ids(self.plugins_dir))
|
||||
for plugin_id in config_plugin_ids(config, ignored):
|
||||
plugin_config = config[plugin_id]
|
||||
state[plugin_id] = {
|
||||
@@ -390,6 +388,19 @@ class StateReconciliation:
|
||||
"""Check consistency for a single plugin."""
|
||||
inconsistencies = []
|
||||
|
||||
if plugin_id in CORE_CONFIG_KEYS:
|
||||
# A plugin whose id is a core setting's key ('display', 'sync',
|
||||
# ...) can never have a config section of its own: that section
|
||||
# is the core setting. Every check below would misfire -- "not in
|
||||
# config" forever, and an auto-fix that writes {'enabled': False}
|
||||
# where a core setting belongs -- so report it in the log instead.
|
||||
if disk_state.get(plugin_id, {}).get('exists_on_disk'):
|
||||
self.logger.warning(
|
||||
"Plugin id %r is reserved for a core config setting; "
|
||||
"the plugin cannot be configured and is skipped by "
|
||||
"reconciliation. Rename the plugin.", plugin_id)
|
||||
return inconsistencies
|
||||
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
state_mgr = state_manager_state.get(plugin_id, {})
|
||||
@@ -468,6 +479,12 @@ class StateReconciliation:
|
||||
"""Attempt to fix an inconsistency."""
|
||||
try:
|
||||
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
|
||||
if inconsistency.plugin_id in CORE_CONFIG_KEYS:
|
||||
# Never create or replace a core setting with a plugin stub.
|
||||
self.logger.warning(
|
||||
"Refusing to add plugin entry %r: that key is a core "
|
||||
"config setting", inconsistency.plugin_id)
|
||||
return False
|
||||
config = self.config_manager.load_config()
|
||||
if inconsistency.plugin_id in config:
|
||||
# Detection said "not in config" but it is there -- the
|
||||
|
||||
@@ -27,7 +27,7 @@ from PIL import Image, ImageChops
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from .loading import load_config_defaults, load_manifest, merge_config
|
||||
from .loading import build_config, load_manifest
|
||||
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||
|
||||
logger = get_logger("[Plugin Harness]")
|
||||
@@ -305,9 +305,7 @@ def render_plugin_matrix(
|
||||
manifest = load_manifest(plugin_dir)
|
||||
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||
# install; explicit caller config still wins over a schema default.
|
||||
config = merge_config(
|
||||
merge_config({"enabled": True}, load_config_defaults(plugin_dir)),
|
||||
config or {})
|
||||
config = build_config(plugin_dir, config)
|
||||
sizes = sizes or DEFAULT_TEST_SIZES
|
||||
results: List[RenderResult] = []
|
||||
|
||||
|
||||
@@ -42,29 +42,6 @@ def load_manifest(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _defaults_from_properties(properties: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Defaults for one `properties` block, recursing into nested objects.
|
||||
|
||||
An object property carries its defaults on its children, not on itself, so
|
||||
reading only the top level dropped everything nested. That is most of the
|
||||
fleet: config organised by league, or under customization/display_options,
|
||||
lost 2,386 defaults across 37 of 44 plugins -- soccer-scoreboard alone lost
|
||||
539 of 565 -- and the harness rendered them with a config no install would
|
||||
ever have.
|
||||
"""
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in (properties or {}).items():
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
if prop.get('type') == 'object' and isinstance(prop.get('properties'), dict):
|
||||
nested = _defaults_from_properties(prop['properties'])
|
||||
if nested:
|
||||
defaults[key] = nested
|
||||
elif 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Deep-merge override onto base, without dropping sibling defaults.
|
||||
|
||||
@@ -81,14 +58,45 @@ def merge_config(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, An
|
||||
return merged
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||
def load_schema(plugin_dir: Union[str, Path]) -> Optional[Dict[str, Any]]:
|
||||
"""A plugin's config_schema.json, or None when it has none."""
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
return None
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
return _defaults_from_properties(schema.get('properties', {}))
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Default values from a plugin's config_schema.json (empty if none).
|
||||
|
||||
The device's own extraction (schema_manager.extract_schema_defaults), so a
|
||||
harness run starts from the config an install would have: nested objects
|
||||
contribute their children's defaults (organised-by-league configs lost
|
||||
thousands of them when only the top level was read), and arrays without a
|
||||
default start as [].
|
||||
"""
|
||||
from src.plugin_system.schema_manager import extract_schema_defaults
|
||||
schema = load_schema(plugin_dir)
|
||||
return extract_schema_defaults(schema) if schema else {}
|
||||
|
||||
|
||||
def build_config(plugin_dir: Union[str, Path],
|
||||
overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""The config a device would give this plugin, with ``overrides`` applied.
|
||||
|
||||
Starts from a forced ``enabled: True``, deep-merges ``overrides`` onto it
|
||||
(merge_config), then prepares the result exactly as the device does when it
|
||||
loads a plugin: legacy booleans read as objects, and schema plus core
|
||||
defaults filled in (schema_manager.prepare_plugin_config). Used by the
|
||||
harness, check_plugin, render_plugin and the dev preview server.
|
||||
"""
|
||||
from src.plugin_system.schema_manager import (
|
||||
plugin_config_defaults, prepare_plugin_config,
|
||||
)
|
||||
schema = load_schema(plugin_dir)
|
||||
requested = merge_config({"enabled": True}, overrides or {})
|
||||
return prepare_plugin_config(requested, schema, plugin_config_defaults(schema))
|
||||
|
||||
|
||||
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
@@ -143,16 +151,14 @@ def build_full_config(
|
||||
|
||||
Merge order: config_schema.json defaults, then a forced ``enabled: True``,
|
||||
then harness.json's config overlay, then the caller's explicit config --
|
||||
most specific wins. `enabled` is re-asserted *after* the schema defaults
|
||||
so a plugin that reasonably ships `enabled: false` (e.g. a seasonal or
|
||||
opt-in plugin) can't silently make every harness run test "disabled, do
|
||||
nothing" by accident -- callers that genuinely want to test the disabled
|
||||
path can still do so via `cli_config={"enabled": False}`.
|
||||
most specific wins, and each layer deep-merges (a nested override such as
|
||||
``{"nhl": {"enabled": true}}`` keeps the other nhl defaults). `enabled` is
|
||||
asserted over the schema defaults so a plugin that reasonably ships
|
||||
`enabled: false` (e.g. a seasonal or opt-in plugin) can't silently make
|
||||
every harness run test "disabled, do nothing" by accident -- callers that
|
||||
genuinely want to test the disabled path can still do so via
|
||||
`cli_config={"enabled": False}`. See build_config.
|
||||
"""
|
||||
spec = spec or {}
|
||||
config: Dict[str, Any] = {}
|
||||
config.update(load_config_defaults(plugin_dir))
|
||||
config["enabled"] = True
|
||||
config.update(spec.get("config", {}))
|
||||
config.update(cli_config or {})
|
||||
return config
|
||||
overrides = merge_config(spec.get("config", {}) or {}, cli_config or {})
|
||||
return build_config(plugin_dir, overrides)
|
||||
|
||||
@@ -8,6 +8,7 @@ Fails fast with clear error messages to prevent runtime issues.
|
||||
import os
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.exceptions import ConfigError, PluginError, CacheError
|
||||
from src.logging_config import get_logger
|
||||
|
||||
@@ -275,8 +276,9 @@ class StartupValidator:
|
||||
|
||||
# Check for enabled plugins that don't exist
|
||||
for plugin_id, plugin_config in config.items():
|
||||
# Skip non-plugin config sections
|
||||
if plugin_id in ['display', 'schedule', 'timezone', 'plugin_system']:
|
||||
# Skip core sections: auto_update and dim_schedule have an
|
||||
# 'enabled' key too, and are not plugins that went missing.
|
||||
if plugin_id in CORE_CONFIG_KEYS:
|
||||
continue
|
||||
|
||||
if not isinstance(plugin_config, dict):
|
||||
|
||||
@@ -155,9 +155,14 @@ class VegasModeConfig:
|
||||
target_fps: int = 125 # Target frame rate
|
||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
||||
|
||||
# Scroll behavior
|
||||
# Scroll behavior. Neither key steps the scroll or sets a frame rate:
|
||||
# motion is always by elapsed time at scroll_speed px/s. With
|
||||
# frame_based_scrolling the speed is first converted to px per
|
||||
# scroll_delay and clamped to 0.1-5 (ScrollHelper.set_scroll_speed), so
|
||||
# the speed actually applied is clamp(scroll_speed * scroll_delay, 0.1, 5)
|
||||
# / scroll_delay -- at the 0.02 default, speeds under 5 px/s run at 5.
|
||||
frame_based_scrolling: bool = True
|
||||
scroll_delay: float = 0.02 # 50 FPS effective scroll updates
|
||||
scroll_delay: float = 0.02 # only feeds the clamp above; not a frame period
|
||||
|
||||
# Dynamic duration
|
||||
dynamic_duration_enabled: bool = True
|
||||
|
||||
@@ -127,12 +127,14 @@ class RenderPipeline:
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
||||
|
||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||
# interprets it differently based on frame_based_scrolling mode:
|
||||
# - Frame-based: pixels per frame step
|
||||
# - Time-based: pixels per second
|
||||
# takes it in different units depending on frame_based_scrolling:
|
||||
# - Frame-based: pixels per scroll_delay seconds (clamped to 0.1-5)
|
||||
# - Time-based: pixels per second (clamped to 1-500)
|
||||
# Both modes then advance by elapsed time; frame-based mode does not
|
||||
# step. So frame-based with scroll_delay only adds the clamp: the
|
||||
# applied speed is clamp(scroll_speed * scroll_delay, 0.1, 5) /
|
||||
# scroll_delay px/s.
|
||||
if self.config.frame_based_scrolling:
|
||||
# Convert pixels/second to pixels/frame
|
||||
# pixels_per_frame = pixels_per_second * seconds_per_frame
|
||||
pixels_per_frame = self.config.scroll_speed * self.config.scroll_delay
|
||||
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
||||
else:
|
||||
|
||||
+5
-3
@@ -52,9 +52,11 @@ This directory contains systemd service unit files for LEDMatrix services.
|
||||
## Installation
|
||||
|
||||
These service files are installed by the installation scripts in `scripts/install/`:
|
||||
- `install_service.sh` installs `ledmatrix.service`
|
||||
- `install_web_service.sh` installs `ledmatrix-web.service` and the
|
||||
`ledmatrix-update-verify` service and path units
|
||||
- `install_service.sh` installs `ledmatrix.service`, `ledmatrix-web.service`
|
||||
and the `ledmatrix-update-verify` service and path units, then enables and
|
||||
starts them
|
||||
- `install_web_service.sh` installs only `ledmatrix-web.service` and the
|
||||
`ledmatrix-update-verify` units
|
||||
- `install_wifi_monitor.sh` installs `ledmatrix-wifi-monitor.service`
|
||||
- `install_dns_fix.sh` installs `ledmatrix-dns-fix.service` (opt-in, not run
|
||||
by the normal installer)
|
||||
|
||||
@@ -25,9 +25,13 @@ RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=ledmatrix-web
|
||||
# Automatically create and manage cache directory
|
||||
CacheDirectory=ledmatrix
|
||||
CacheDirectoryMode=0775
|
||||
# No CacheDirectory=. /var/cache/ledmatrix is shared with ledmatrix.service,
|
||||
# which runs as root, and the installers set it up as root:ledmatrix 2775 so
|
||||
# both services can read what the other writes. CacheDirectory= makes systemd
|
||||
# re-own that directory and everything in it to this unit's User= and that
|
||||
# user's primary group whenever the owner differs -- which, for a directory
|
||||
# root created, is on the first start. That erased the shared group, and from
|
||||
# then on the web interface could not read a single file the display wrote.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -35,10 +35,12 @@ nothing is listening, so it stays useful in a bare checkout.
|
||||
| Suite | Needs a server | Covers |
|
||||
|---|---|---|
|
||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@ const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||
'unit/test_style_editor_layout_leaf_columns.js',
|
||||
'unit/test_style_editor_layout_leaf_collision.js'];
|
||||
'unit/test_style_editor_layout_leaf_collision.js',
|
||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js'];
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Registry- and upload-supplied strings must not escape the inline handlers
|
||||
// plugins_manager.js builds for them.
|
||||
//
|
||||
// The store, saved-repository and custom-registry renderers wrote
|
||||
//
|
||||
// <button onclick='... installFromCustomRegistry(${JSON.stringify(id)}) ...'>
|
||||
//
|
||||
// JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id
|
||||
// of x' onmouseover='alert(1) closed the single-quoted attribute and added a
|
||||
// handler of its own. The live window.updateImageList (plugins_manager.js loads
|
||||
// last, so its copy beats the file-upload widget's) put the uploaded file's
|
||||
// original name into the markup unescaped.
|
||||
//
|
||||
// Each case renders with the shipped function, parses the tag the way a browser
|
||||
// does (quoted attribute values, entities decoded), and checks two things: no
|
||||
// attribute appeared that the template did not write, and the decoded handler
|
||||
// runs and passes the hostile value through intact as data.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
|
||||
|
||||
function extract(opener) {
|
||||
const start = SRC.indexOf(opener);
|
||||
if (start < 0) { console.error('FAIL: cannot find ' + JSON.stringify(opener)); process.exit(1); }
|
||||
let depth = 0;
|
||||
for (let j = SRC.indexOf('{', start); j < SRC.length; j++) {
|
||||
if (SRC[j] === '{') depth++;
|
||||
else if (SRC[j] === '}' && --depth === 0) return SRC.slice(start, j + 1);
|
||||
}
|
||||
console.error('FAIL: unbalanced braces after ' + opener); process.exit(1);
|
||||
}
|
||||
|
||||
// ── DOM shim ───────────────────────────────────────────────────────────────
|
||||
class FakeEl {
|
||||
constructor() { this.innerHTML = ''; this.value = ''; this.textContent = ''; }
|
||||
}
|
||||
class TextEl {
|
||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||
get innerHTML() {
|
||||
return (this._t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
const els = {};
|
||||
global.document = {
|
||||
getElementById: id => (els[id] ||= new FakeEl()),
|
||||
createElement: () => new TextEl(),
|
||||
};
|
||||
global.window = global;
|
||||
global.pluginLog = () => {};
|
||||
global.isStorePluginInstalled = () => false;
|
||||
global.isNewPlugin = () => false;
|
||||
global.formatDate = () => '';
|
||||
global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html; };
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval([
|
||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||
'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {',
|
||||
'function renderCustomRegistryPlugins(plugins, registryUrl) {',
|
||||
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
|
||||
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
|
||||
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(extract('window.updateImageList = function(fieldId, images) {'));
|
||||
|
||||
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
||||
function decodeEntities(s) {
|
||||
return s.replace(/&(#x[0-9a-f]+|#\d+|quot|amp|lt|gt|apos);/gi, (m, e) => {
|
||||
const k = e.toLowerCase();
|
||||
if (k === 'quot') return '"';
|
||||
if (k === 'amp') return '&';
|
||||
if (k === 'lt') return '<';
|
||||
if (k === 'gt') return '>';
|
||||
if (k === 'apos') return "'";
|
||||
return String.fromCodePoint(k[1] === 'x' ? parseInt(k.slice(2), 16) : parseInt(k.slice(1), 10));
|
||||
});
|
||||
}
|
||||
function tags(html, name) {
|
||||
const out = [];
|
||||
let i = 0;
|
||||
while ((i = html.indexOf('<' + name, i)) >= 0) {
|
||||
let j = i + name.length + 1;
|
||||
const attrs = [];
|
||||
for (;;) {
|
||||
while (/\s/.test(html[j])) j++;
|
||||
if (html[j] === '>' || j >= html.length) break;
|
||||
if (html[j] === '/') { j++; continue; }
|
||||
let n = '';
|
||||
while (j < html.length && !/[\s=>]/.test(html[j])) n += html[j++];
|
||||
let v = '';
|
||||
while (/\s/.test(html[j])) j++;
|
||||
if (html[j] === '=') {
|
||||
j++;
|
||||
while (/\s/.test(html[j])) j++;
|
||||
const q = html[j];
|
||||
if (q === '"' || q === "'") {
|
||||
const end = html.indexOf(q, j + 1);
|
||||
v = html.slice(j + 1, end); j = end + 1;
|
||||
} else {
|
||||
while (j < html.length && !/[\s>]/.test(html[j])) v += html[j++];
|
||||
}
|
||||
}
|
||||
attrs.push([n.toLowerCase(), decodeEntities(v)]);
|
||||
}
|
||||
out.push(attrs);
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const names = attrs => attrs.map(a => a[0]);
|
||||
const attr = (attrs, n) => (attrs.find(a => a[0] === n) || [])[1];
|
||||
|
||||
// Run a decoded inline handler with window/document stubs; return the calls.
|
||||
function runHandler(code) {
|
||||
const calls = [];
|
||||
const win = new Proxy({}, {
|
||||
get: (_, prop) => (...args) => { calls.push([prop, ...args]); },
|
||||
has: () => true,
|
||||
});
|
||||
const doc = { getElementById: () => ({ value: '' }) };
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('window', 'document', 'console', code)(win, doc, { error() {} });
|
||||
return calls;
|
||||
}
|
||||
|
||||
const SQ = "x' onmouseover='alert(1)";
|
||||
const DQ = 'x" onmouseover="alert(1)';
|
||||
const AMP = 'x' onmouseover='alert(1)';
|
||||
|
||||
console.log('\n1. custom registry install button');
|
||||
for (const hostile of [SQ, DQ, AMP]) {
|
||||
renderCustomRegistryPlugins([{ id: hostile, name: 'n', plugin_path: hostile }], hostile);
|
||||
const buttons = tags(els['custom-registry-grid'].innerHTML, 'button');
|
||||
const b = buttons.find(a => attr(a, 'onclick'));
|
||||
ok(`${JSON.stringify(hostile)}: only onclick and class attributes`,
|
||||
b && names(b).join(',') === 'onclick,class', b && names(b));
|
||||
let calls = [];
|
||||
try { calls = runHandler(attr(b, 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
|
||||
ok(`${JSON.stringify(hostile)}: handler passes id, url and path intact`,
|
||||
calls.length === 1 && calls[0][0] === 'installFromCustomRegistry'
|
||||
&& calls[0][1] === hostile && calls[0][2] === hostile && calls[0][3] === hostile, calls);
|
||||
}
|
||||
|
||||
console.log('\n2. store install and view buttons');
|
||||
for (const hostile of [SQ, DQ, AMP]) {
|
||||
renderPluginStore([{ id: hostile, name: 'n', repo: 'https://github.com/o/r', plugin_path: 'p' }]);
|
||||
const buttons = tags(els['plugin-store-grid'].innerHTML, 'button');
|
||||
ok(`${JSON.stringify(hostile)}: two buttons, no extra attributes`,
|
||||
buttons.length === 2 && buttons.every(b => names(b).every(n => ['onclick', 'class', 'disabled'].includes(n))),
|
||||
buttons.map(names));
|
||||
let calls = [];
|
||||
try { calls = runHandler(attr(buttons[0], 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
|
||||
ok(`${JSON.stringify(hostile)}: install handler gets the id intact`,
|
||||
calls.length === 1 && calls[0][0] === 'installPlugin' && calls[0][1] === hostile, calls);
|
||||
}
|
||||
|
||||
console.log('\n3. store view button only opens http(s) links');
|
||||
renderPluginStore([{ id: 'a', name: 'n', repo: 'https://github.com/o/r', plugin_path: 'plugins/a' }]);
|
||||
{
|
||||
const view = tags(els['plugin-store-grid'].innerHTML, 'button')[1];
|
||||
const calls = runHandler(attr(view, 'onclick'));
|
||||
ok('https repo opens the plugin tree',
|
||||
calls.length === 1 && calls[0][0] === 'open' && calls[0][1] === 'https://github.com/o/r/tree/main/plugins/a', calls);
|
||||
}
|
||||
renderPluginStore([{ id: 'a', name: 'n', repo: 'javascript:alert(document.domain)//' }]);
|
||||
{
|
||||
const view = tags(els['plugin-store-grid'].innerHTML, 'button')[1];
|
||||
const calls = runHandler(attr(view, 'onclick'));
|
||||
ok('javascript: repo opens nothing', calls.length === 0, calls);
|
||||
ok('javascript: repo button is disabled', names(view).includes('disabled'), names(view));
|
||||
}
|
||||
|
||||
console.log('\n4. saved repository remove button');
|
||||
for (const hostile of [SQ, DQ, AMP]) {
|
||||
renderSavedRepositories([{ url: hostile, name: hostile }]);
|
||||
const b = tags(els['saved-repositories-list'].innerHTML, 'button')[0];
|
||||
ok(`${JSON.stringify(hostile)}: no extra attributes`,
|
||||
b && names(b).join(',') === 'onclick,class,title,aria-label', b && names(b));
|
||||
let calls = [];
|
||||
try { calls = runHandler(attr(b, 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
|
||||
ok(`${JSON.stringify(hostile)}: handler gets the url intact`,
|
||||
calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls);
|
||||
}
|
||||
|
||||
console.log('\n5. live window.updateImageList escapes the uploaded file name');
|
||||
window.getUploadConfig = () => ({ plugin_id: SQ });
|
||||
window.currentPluginConfig = null;
|
||||
{
|
||||
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
|
||||
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
|
||||
const html = els['f_image_list'].innerHTML;
|
||||
ok('no raw markup from original_filename', !html.includes('<img src=x onerror'), html);
|
||||
const imgs = tags(html, 'img');
|
||||
ok('one <img>, only the template attributes',
|
||||
imgs.length === 1 && names(imgs[0]).join(',') === 'src,alt,loading,decoding,class,onerror', imgs.map(names));
|
||||
ok('alt carries the stored filename as text', attr(imgs[0], 'alt') === DQ, imgs[0]);
|
||||
const buttons = tags(html, 'button');
|
||||
ok('two buttons, no extra attributes',
|
||||
buttons.length === 2 && buttons.every(b => names(b).join(',') === 'type,onclick,class,title,aria-label'),
|
||||
buttons.map(names));
|
||||
const del = runHandler(attr(buttons[1], 'onclick'));
|
||||
ok('delete handler gets field, image and plugin ids intact',
|
||||
del.length === 1 && del[0][0] === 'deleteUploadedImage' && del[0][1] === 'f' && del[0][2] === SQ && del[0][3] === SQ, del);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -0,0 +1,234 @@
|
||||
// "Check & Update All" -- which ids it sends to POST /api/v3/plugins/update.
|
||||
//
|
||||
// Pins two bugs seen on a real device (core 3.4.0):
|
||||
//
|
||||
// 1. /plugins/installed lists installed Starlark apps as virtual
|
||||
// `starlark:<app_id>` entries. Update-all sent those to the plugin
|
||||
// updater, which answered 500 "plugin not found" for each one.
|
||||
//
|
||||
// 2. A web-service restart landed mid-run. The request in flight died with
|
||||
// the old process and the next one (stock-news) was refused while the
|
||||
// service was coming back; both were recorded as failures and never sent
|
||||
// again, so stock-news -- installed, disabled, with an update waiting --
|
||||
// was silently not updated.
|
||||
//
|
||||
// Runs the shipped install_manager.js (it exports itself under node) against
|
||||
// a fake PluginAPI. The installed list is the device's, trimmed.
|
||||
|
||||
const path = require('path');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
global.window = {};
|
||||
const Manager = require(path.join(V3, 'js/plugins/install_manager.js'));
|
||||
|
||||
const INSTALLED = [
|
||||
{ id: 'ledmatrix-flights', enabled: true, version: '1.14.0' },
|
||||
{ id: 'stock-news', enabled: false, version: '2.6.2', latest_version: '2.8.0', update_available: true },
|
||||
{ id: 'static-image', enabled: false, version: '1.1.3' },
|
||||
{ id: 'starlark-apps', enabled: false, version: '1.0.0' }, // a real plugin -- keep it
|
||||
{ id: 'pomodoro-timer', enabled: false, version: '1.3.6' },
|
||||
{ id: 'starlark:analogtime', enabled: false, version: 'starlark', is_starlark_app: true },
|
||||
{ id: 'starlark:analogclock', enabled: true, version: 'starlark', is_starlark_app: true },
|
||||
];
|
||||
const EXPECTED = ['ledmatrix-flights', 'stock-news', 'static-image', 'starlark-apps', 'pomodoro-timer'];
|
||||
|
||||
const netErr = () => ({ error_code: 'NETWORK_ERROR', message: 'Failed to fetch' });
|
||||
|
||||
function fakeApi(behaviour = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
updatePlugin: async (id) => {
|
||||
calls.push(id);
|
||||
const b = behaviour[id];
|
||||
if (typeof b === 'function') return b(calls.filter(c => c === id).length);
|
||||
return { status: 'success', message: `Plugin ${id} updated successfully` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(api, { stateList, windowList } = {}) {
|
||||
global.window = {
|
||||
PluginAPI: api,
|
||||
installedPlugins: windowList,
|
||||
PluginStateManager: stateList === undefined ? undefined : {
|
||||
installedPlugins: stateList,
|
||||
loadInstalledPlugins: async () => stateList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const noSleep = { sleep: async () => {} };
|
||||
|
||||
(async () => {
|
||||
console.log('\nselection');
|
||||
ok('starlark app entries are not updatable',
|
||||
!Manager.isUpdatablePlugin(INSTALLED[5]) && !Manager.isUpdatablePlugin(INSTALLED[6]));
|
||||
ok('a starlark: id without the flag is still excluded',
|
||||
!Manager.isUpdatablePlugin({ id: 'starlark:foo' }));
|
||||
ok('the starlark-apps plugin itself is updatable', Manager.isUpdatablePlugin(INSTALLED[3]));
|
||||
ok('a disabled plugin with an update is updatable', Manager.isUpdatablePlugin(INSTALLED[1]));
|
||||
ok('entries without a usable id are skipped',
|
||||
!Manager.isUpdatablePlugin({}) && !Manager.isUpdatablePlugin(null) && !Manager.isUpdatablePlugin({ id: '' }));
|
||||
const sel = Manager.updatablePlugins(INSTALLED).map(p => p.id);
|
||||
ok('updatablePlugins keeps list order and drops only starlark apps',
|
||||
JSON.stringify(sel) === JSON.stringify(EXPECTED), sel);
|
||||
|
||||
console.log('\nupdateAll sends only plugin ids');
|
||||
{
|
||||
const api = fakeApi();
|
||||
setup(api, { windowList: INSTALLED });
|
||||
const progress = [];
|
||||
const results = await Manager.updateAll((i, n, id) => progress.push([i, n, id]), noSleep);
|
||||
ok('POSTs exactly the non-starlark ids, in order',
|
||||
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
||||
ok('no starlark: id reached the plugin updater', !api.calls.some(id => id.startsWith('starlark:')));
|
||||
ok('one result per plugin sent', results.length === EXPECTED.length, results.length);
|
||||
ok('progress total counts only what is sent',
|
||||
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
|
||||
}
|
||||
{
|
||||
const api = fakeApi();
|
||||
setup(api, { stateList: INSTALLED, windowList: [] });
|
||||
await Manager.updateAll(null, noSleep);
|
||||
ok('the PluginStateManager list is filtered the same way',
|
||||
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
||||
}
|
||||
{
|
||||
const api = fakeApi();
|
||||
setup(api, { windowList: INSTALLED.filter(p => p.is_starlark_app) });
|
||||
const results = await Manager.updateAll(null, noSleep);
|
||||
ok('a list of only starlark apps sends nothing', api.calls.length === 0 && results.length === 0);
|
||||
}
|
||||
|
||||
console.log('\nweb service restarting mid-run (the stock-news case)');
|
||||
{
|
||||
// ledmatrix-flights is in flight when the service stops (reset); stock-news
|
||||
// is refused twice while it comes back; then everything answers.
|
||||
const api = fakeApi({
|
||||
'ledmatrix-flights': (n) => { if (n === 1) throw netErr(); return { status: 'success', message: 'ok' }; },
|
||||
'stock-news': (n) => { if (n <= 2) throw netErr(); return { status: 'success', message: 'Plugin stock-news updated successfully' }; },
|
||||
});
|
||||
setup(api, { windowList: INSTALLED });
|
||||
const slept = [];
|
||||
const results = await Manager.updateAll(null, { sleep: async ms => { slept.push(ms); }, retryDelaysMs: [5, 10, 20] });
|
||||
const byId = Object.fromEntries(results.map(r => [r.pluginId, r]));
|
||||
ok('stock-news is sent again until the server answers',
|
||||
api.calls.filter(id => id === 'stock-news').length === 3, api.calls);
|
||||
ok('stock-news ends up updated, not skipped', byId['stock-news'] && byId['stock-news'].success === true, byId['stock-news']);
|
||||
ok('the request lost with the old process is re-sent too',
|
||||
byId['ledmatrix-flights'] && byId['ledmatrix-flights'].success === true);
|
||||
ok('it backs off between attempts', JSON.stringify(slept) === JSON.stringify([5, 5, 10]), slept);
|
||||
ok('every plugin still gets exactly one result',
|
||||
JSON.stringify(results.map(r => r.pluginId)) === JSON.stringify(EXPECTED), results.map(r => r.pluginId));
|
||||
}
|
||||
{
|
||||
const api = fakeApi({ 'stock-news': () => { throw netErr(); } });
|
||||
setup(api, { windowList: INSTALLED });
|
||||
const results = await Manager.updateAll(null, { sleep: async () => {}, retryDelaysMs: [1, 1] });
|
||||
const r = results.find(x => x.pluginId === 'stock-news');
|
||||
ok('a server that never comes back is retried a bounded number of times',
|
||||
api.calls.filter(id => id === 'stock-news').length === 3, api.calls);
|
||||
ok('...then reported as a failure, and the run continues',
|
||||
r && r.success === false && api.calls[api.calls.length - 1] === 'pomodoro-timer');
|
||||
}
|
||||
{
|
||||
const api = fakeApi({ 'static-image': () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'check logs' }; } });
|
||||
setup(api, { windowList: INSTALLED });
|
||||
const results = await Manager.updateAll(null, { sleep: async () => { throw new Error('must not sleep'); } });
|
||||
ok('an HTTP error answer is not retried',
|
||||
api.calls.filter(id => id === 'static-image').length === 1, api.calls);
|
||||
ok('...and is reported as a failure', results.find(x => x.pluginId === 'static-image').success === false);
|
||||
}
|
||||
|
||||
console.log('\nthe real api_client.js: only a missing HTTP answer is retried');
|
||||
{
|
||||
// The fake PluginAPI above decides error_code itself. This runs the shipped
|
||||
// client so its classification is what gets tested: a proxy's 502 page or
|
||||
// a JSON 500 without error_code used to come back as NETWORK_ERROR and be
|
||||
// re-sent five more times.
|
||||
const PluginAPI = require(path.join(V3, 'js/plugins/api_client.js'));
|
||||
const run = async (makeFetch) => {
|
||||
let requests = 0, sleeps = 0;
|
||||
global.fetch = async () => { requests++; return makeFetch(requests); };
|
||||
global.window = { PluginAPI, installedPlugins: [{ id: 'stock-news' }] };
|
||||
const results = await Manager.updateAll(null, { sleep: async () => { sleeps++; }, retryDelaysMs: [1, 1, 1] });
|
||||
return { requests, sleeps, result: results[0] };
|
||||
};
|
||||
const httpAnswer = (status, json) => ({ ok: status < 400, status, json });
|
||||
|
||||
let r = await run(() => httpAnswer(502, async () => { throw new SyntaxError('Unexpected token <'); }));
|
||||
ok('a 502 with an HTML body is sent once', r.requests === 1 && r.sleeps === 0, r);
|
||||
ok('...and is an API_ERROR carrying the status, not NETWORK_ERROR',
|
||||
r.result.success === false && r.result.error.error_code === 'API_ERROR' && r.result.error.status === 502, r.result.error);
|
||||
|
||||
r = await run(() => httpAnswer(500, async () => ({ status: 'error', message: 'boom' })));
|
||||
ok('a JSON 500 without error_code is sent once', r.requests === 1 && r.sleeps === 0, r);
|
||||
ok('...and keeps the server message', r.result.error.message === 'boom', r.result.error);
|
||||
|
||||
r = await run(() => httpAnswer(500, async () => ({ status: 'error', error_code: 'PLUGIN_UPDATE_FAILED', message: 'x' })));
|
||||
ok('a structured error is passed through unchanged',
|
||||
r.requests === 1 && r.result.error.error_code === 'PLUGIN_UPDATE_FAILED', r.result.error);
|
||||
|
||||
r = await run((n) => {
|
||||
if (n === 1) throw new TypeError('Failed to fetch');
|
||||
return httpAnswer(200, async () => ({ status: 'success', message: 'ok', data: { update_status: 'updated' } }));
|
||||
});
|
||||
ok('a fetch() that rejects is NETWORK_ERROR and re-sent', r.requests === 2 && r.sleeps === 1 && r.result.success, r);
|
||||
|
||||
r = await run(() => httpAnswer(200, async () => { throw new SyntaxError('Unexpected end of JSON input'); }));
|
||||
ok('an unreadable 200 is not retried either', r.requests === 1 && r.result.error.error_code === 'API_ERROR', r);
|
||||
|
||||
// Every endpoint is one of the client's own API paths; one that could
|
||||
// leave baseURL never reaches fetch(), and plugin ids are encoded.
|
||||
const urls = [];
|
||||
global.fetch = async (url) => { urls.push(url); return httpAnswer(200, async () => ({ status: 'success' })); };
|
||||
const refusal = async (endpoint) => {
|
||||
try { await PluginAPI.request(endpoint, 'POST'); return null; } catch (e) { return e.error_code; }
|
||||
};
|
||||
const bad = ['//evil.example/x', '/plugins/../../x', '/a\b', '/a b', 'plugins', null];
|
||||
const codes = [];
|
||||
for (const endpoint of bad) codes.push(await refusal(endpoint));
|
||||
ok('an endpoint that could leave the API path is refused before fetch()',
|
||||
codes.every(c => c === 'INVALID_ENDPOINT') && urls.length === 0, { codes, urls });
|
||||
await PluginAPI.resetPluginConfig('a/../b&x=1');
|
||||
ok('a plugin id is encoded into the URL, not spliced into it',
|
||||
urls[0] === '/api/v3/plugins/config/reset?plugin_id=a%2F..%2Fb%26x%3D1', urls);
|
||||
delete global.fetch;
|
||||
}
|
||||
|
||||
console.log('\nsummary: a no-op update is not counted as updated');
|
||||
{
|
||||
const answer = (update_status, message) => ({ success: true, result: { status: 'success', message, data: { update_status } } });
|
||||
const results = [
|
||||
answer('updated', 'Plugin a updated to version 2.8.0'),
|
||||
// ZIP-installed monorepo plugin already at the registry version: the
|
||||
// route used to call this "updated successfully".
|
||||
answer('up_to_date', 'Plugin stock-news already up to date (version 2.8.0)'),
|
||||
answer('up_to_date', 'Plugin clock already up to date (commit abcdef1)'),
|
||||
answer('local_only', 'Plugin mine is managed locally and does not receive registry updates'),
|
||||
{ success: false, error: { error_code: 'PLUGIN_UPDATE_FAILED' } },
|
||||
];
|
||||
const s = Manager.summarizeUpdateResults(results);
|
||||
ok('counts come from update_status',
|
||||
s.updated === 1 && s.upToDate === 2 && s.localOnly === 1 && s.failed === 1, s);
|
||||
ok('toast text names each outcome',
|
||||
s.text === '1 updated, 2 already up to date, 1 managed locally, 1 failed', s.text);
|
||||
ok('a failure alongside an update is a warning', s.type === 'warning', s.type);
|
||||
ok('an older server that only says so in the message is still up to date',
|
||||
Manager.updateOutcome({ success: true, result: { message: 'Plugin x already up to date (commit 1234567)' } }) === 'up_to_date');
|
||||
ok('a success without a status or telltale message counts as updated',
|
||||
Manager.updateOutcome({ success: true, result: { message: 'Plugin x updated successfully' } }) === 'updated');
|
||||
const allNoop = Manager.summarizeUpdateResults([answer('up_to_date', ''), answer('up_to_date', '')]);
|
||||
ok('nothing to do is a success toast with no "updated"',
|
||||
allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -17,6 +17,7 @@ import requests
|
||||
from freezegun import freeze_time
|
||||
|
||||
import src.common.api_helper as api_helper_module
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT
|
||||
from src.common.api_helper import APIHelper
|
||||
|
||||
|
||||
@@ -152,7 +153,7 @@ class TestEspnHelpers:
|
||||
assert result == {'ok': 1}
|
||||
helper.get.assert_called_once_with(
|
||||
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard',
|
||||
params={'dates': '20260807', 'limit': 1000},
|
||||
params={'dates': '20260807', 'limit': ESPN_MAX_LIMIT},
|
||||
cache_key='espn_football_nfl_20260807',
|
||||
cache_ttl=300,
|
||||
)
|
||||
@@ -163,7 +164,7 @@ class TestEspnHelpers:
|
||||
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
|
||||
|
||||
kwargs = helper.get.call_args.kwargs
|
||||
assert kwargs['params'] == {'dates': '20250115', 'limit': 1000}
|
||||
assert kwargs['params'] == {'dates': '20250115', 'limit': ESPN_MAX_LIMIT}
|
||||
assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
|
||||
|
||||
def test_fetch_espn_standings_url_and_cache_key(self, helper):
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Display hardware settings accept what the rgbmatrix library accepts.
|
||||
|
||||
Held to what the pinned library and its Python binding accept
|
||||
(src/matrix_support.py: RGBMatrix::Options::Validate in
|
||||
lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc, the
|
||||
mapping table in lib/hardware-mapping.c and the binding's uint8_t setters).
|
||||
Ways this used to go wrong:
|
||||
|
||||
- The Display form capped cols at 128, chain_length at 24 and
|
||||
pwm_lsb_nanoseconds at 500, and its submit handler (fixInvalidNumberInputs)
|
||||
rewrites anything past an input's min/max to that bound -- so a wide panel or
|
||||
a long chain silently saved as the wrong size.
|
||||
- The API checked none of these, so a value the library rejects (odd rows,
|
||||
parallel 4, pwm_dither_bits 3) saved, and the matrix then refused to start.
|
||||
- After that, rows above 64, chain_length above 255, a misspelled hardware
|
||||
mapping and parallel 2-3 on a single-output HAT mapping still saved. The
|
||||
library answers those with no matrix or abort(), not an error, so the
|
||||
display service crash-looped instead of falling back.
|
||||
|
||||
Row address type 5 is the SM5368 / B707 row shift register the Waveshare 96x48
|
||||
V2 needs (Waveshare's own "96X48_1_24_SM5368" panel type in their library fork
|
||||
just sets rows/cols, row_address_type=5 and BGR); the API used to stop at 4.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
from test.test_web_settings_ui import REALISTIC_CONFIG # noqa: E402
|
||||
from src import pi5_matrix_support # noqa: E402
|
||||
|
||||
#: What a Waveshare RGB-Matrix-P2.5-96x48 V2 (back silkscreen 24S-A1) needed on
|
||||
#: a Pi 4 with an Adafruit Triple LED Matrix Bonnet, checked on the panel.
|
||||
WAVESHARE_96X48_V2 = {
|
||||
'rows': 48, 'cols': 96, 'chain_length': 1, 'parallel': 1,
|
||||
'hardware_mapping': 'regular', 'panel_type': '', 'row_address_type': 5,
|
||||
'led_rgb_sequence': 'BGR', 'gpio_slowdown': 8,
|
||||
}
|
||||
|
||||
#: Fields stored under display.runtime; the rest go under display.hardware.
|
||||
RUNTIME_FIELDS = {'gpio_slowdown'}
|
||||
|
||||
PI5_MODEL = 'Raspberry Pi 5 Model B Rev 1.0'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def board(tmp_path, monkeypatch):
|
||||
"""Not a Pi 5 unless a test says so, whatever machine runs the suite.
|
||||
|
||||
Returns a setter: board(PI5_MODEL) makes the API and the form see a Pi 5.
|
||||
"""
|
||||
path = tmp_path / 'device-tree-model'
|
||||
|
||||
def set_model(model):
|
||||
path.write_bytes(model.encode() + b'\x00')
|
||||
|
||||
set_model('Raspberry Pi 4 Model B Rev 1.5')
|
||||
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
|
||||
return set_model
|
||||
|
||||
|
||||
def _stored(config, field):
|
||||
section = 'runtime' if field in RUNTIME_FIELDS else 'hardware'
|
||||
return config['display'][section][field]
|
||||
|
||||
|
||||
def _post(client, body):
|
||||
return client.post('/api/v3/config/main', data=json.dumps(body),
|
||||
content_type='application/json')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saved(api_v3_module, monkeypatch):
|
||||
"""Capture what save_main_config would write.
|
||||
|
||||
Asserting on the stored value, not just the status code, is what shows the
|
||||
value passed validation and landed where DisplayManager reads it.
|
||||
"""
|
||||
captured = {}
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {}
|
||||
|
||||
def fake_save(_manager, config, **_kwargs):
|
||||
captured['config'] = config
|
||||
return True, ''
|
||||
|
||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.parametrize('as_strings', [False, True], ids=['json-numbers', 'form-strings'])
|
||||
def test_waveshare_96x48_v2_settings_all_save(api_v3_client, saved, as_strings):
|
||||
"""The Display form posts every value as a string (json-enc); API clients send numbers."""
|
||||
body = {k: str(v) if as_strings else v for k, v in WAVESHARE_96X48_V2.items()}
|
||||
response = _post(api_v3_client, body)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
for field, value in WAVESHARE_96X48_V2.items():
|
||||
assert _stored(saved['config'], field) == value, field
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,value', [
|
||||
('rows', 8), ('rows', 64),
|
||||
('cols', 16), ('cols', 192), ('cols', 512),
|
||||
('chain_length', 1), ('chain_length', 32), ('chain_length', 255),
|
||||
('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0),
|
||||
('multiplexing', 0), ('multiplexing', 22),
|
||||
('gpio_slowdown', 0), ('gpio_slowdown', 10),
|
||||
('pwm_bits', 1), ('pwm_bits', 11),
|
||||
('pwm_dither_bits', 0), ('pwm_dither_bits', 2),
|
||||
('pwm_lsb_nanoseconds', 50), ('pwm_lsb_nanoseconds', 3000),
|
||||
('scan_mode', 1),
|
||||
('brightness', 1), ('brightness', 100),
|
||||
('limit_refresh_rate_hz', 0), ('limit_refresh_rate_hz', 1000),
|
||||
])
|
||||
def test_values_in_range_are_saved(api_v3_client, saved, field, value):
|
||||
response = _post(api_v3_client, {field: value})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
assert _stored(saved['config'], field) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,value', [
|
||||
('rows', 6), ('rows', 47), ('rows', 66), ('rows', 96), ('rows', 128), ('rows', '48.5'),
|
||||
('cols', 15), ('cols', 96.5), ('cols', True), ('cols', 'wide'),
|
||||
('chain_length', 0), ('chain_length', 256), ('chain_length', 300),
|
||||
('parallel', 0), ('parallel', 4),
|
||||
('row_address_type', -1), ('row_address_type', 6),
|
||||
('row_address_type', True), ('row_address_type', 5.5),
|
||||
('multiplexing', -1), ('multiplexing', 23), ('multiplexing', True),
|
||||
('gpio_slowdown', -1), ('gpio_slowdown', 11),
|
||||
('pwm_bits', 0), ('pwm_bits', 12),
|
||||
('pwm_dither_bits', 3),
|
||||
('pwm_lsb_nanoseconds', 49), ('pwm_lsb_nanoseconds', 3001),
|
||||
('scan_mode', 2),
|
||||
('brightness', 0), ('brightness', 101),
|
||||
('limit_refresh_rate_hz', -1),
|
||||
])
|
||||
def test_values_out_of_range_are_refused(api_v3_client, saved, field, value):
|
||||
"""Refused with a message naming the field, and nothing written."""
|
||||
response = _post(api_v3_client, {field: value})
|
||||
assert response.status_code == 400
|
||||
assert field in response.get_json()['message']
|
||||
assert 'config' not in saved
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
{'hardware_mapping': 'regular', 'parallel': 3},
|
||||
{'hardware_mapping': 'classic', 'parallel': 2},
|
||||
{'hardware_mapping': 'Regular', 'parallel': 3},
|
||||
{'hardware_mapping': 'classic-pi1'},
|
||||
{'hardware_mapping': 'adafruit-hat', 'parallel': 1},
|
||||
])
|
||||
def test_mappings_the_library_has_are_saved(api_v3_client, saved, body):
|
||||
response = _post(api_v3_client, body)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
for field, value in body.items():
|
||||
assert saved['config']['display']['hardware'][field] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body,named', [
|
||||
# Framebuffer() abort()s: the HAT mappings define one output.
|
||||
({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, 'parallel 2'),
|
||||
({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, 'parallel 3'),
|
||||
({'hardware_mapping': 'regular-pi1', 'parallel': 2}, 'parallel 2'),
|
||||
# InitHardwareMapping() abort()s on a name it doesn't have; compute-module
|
||||
# isn't compiled into the default build.
|
||||
({'hardware_mapping': 'adafruit-hat-pwn'}, 'adafruit-hat-pwn'),
|
||||
({'hardware_mapping': 'compute-module'}, 'compute-module'),
|
||||
({'hardware_mapping': 5}, 'hardware mapping'),
|
||||
])
|
||||
def test_combinations_the_library_aborts_on_are_refused(api_v3_client, saved, body, named):
|
||||
response = _post(api_v3_client, body)
|
||||
assert response.status_code == 400
|
||||
assert named in response.get_json()['message']
|
||||
assert 'config' not in saved
|
||||
|
||||
|
||||
def test_parallel_is_checked_against_the_stored_mapping(api_v3_client, api_v3_module, saved):
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
'display': {'hardware': {'hardware_mapping': 'adafruit-hat'}}}
|
||||
response = _post(api_v3_client, {'parallel': 2})
|
||||
assert response.status_code == 400
|
||||
assert 'adafruit-hat' in response.get_json()['message']
|
||||
assert 'config' not in saved
|
||||
|
||||
|
||||
def test_stored_refusal_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved):
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
'display': {'hardware': {'hardware_mapping': 'adafruit-hat', 'parallel': 2}}}
|
||||
response = _post(api_v3_client, {'brightness': 70})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
|
||||
|
||||
def test_a_request_value_is_checked_not_the_stored_one(api_v3_client, api_v3_module, saved):
|
||||
"""Fixing a stored bad value in the same save as a mapping change must work."""
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
'display': {'hardware': {'rows': 128, 'parallel': 2}}}
|
||||
response = _post(api_v3_client, {'rows': 64, 'hardware_mapping': 'regular'})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('orientation', ['normal', '90', '180', '270'])
|
||||
def test_every_orientation_display_manager_applies_is_saved(api_v3_client, saved, orientation):
|
||||
response = _post(api_v3_client, {'orientation': orientation})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
assert saved['config']['display']['hardware']['orientation'] == orientation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def display_page(monkeypatch):
|
||||
"""Render the Display settings partial for a given config."""
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
|
||||
def render(config):
|
||||
base = PROJECT_ROOT / 'web_interface'
|
||||
app = Flask(__name__, template_folder=str(base / 'templates'),
|
||||
static_folder=str(base / 'static'))
|
||||
app.config['TESTING'] = True
|
||||
config_manager = MagicMock()
|
||||
config_manager.load_config.return_value = config
|
||||
config_manager.get_raw_file_content.return_value = config
|
||||
config_manager.get_config_path.return_value = 'config/config.json'
|
||||
config_manager.get_secrets_path.return_value = 'config/config_secrets.json'
|
||||
monkeypatch.setattr(pv.pages_v3, 'config_manager', config_manager, raising=False)
|
||||
monkeypatch.setattr(pv.pages_v3, 'plugin_manager', MagicMock(plugins={}), raising=False)
|
||||
app.register_blueprint(pv.pages_v3, url_prefix='/v3')
|
||||
response = app.test_client().get('/v3/partials/display')
|
||||
assert response.status_code == 200
|
||||
return response.get_data(as_text=True)
|
||||
|
||||
return render
|
||||
|
||||
|
||||
def _config_with(hardware=None, runtime=None):
|
||||
config = copy.deepcopy(REALISTIC_CONFIG)
|
||||
config['display']['hardware'].update(hardware or {})
|
||||
config['display']['runtime'].update(runtime or {})
|
||||
return config
|
||||
|
||||
|
||||
def _input_tag(body, input_id):
|
||||
match = re.search(r'<input[^>]*\bid="%s"[^>]*>' % re.escape(input_id), body)
|
||||
assert match, f'no <input id="{input_id}">'
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def _attr(tag, name):
|
||||
match = re.search(r'\s%s="([^"]*)"' % name, tag)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _selected_option(body, select_id):
|
||||
select = re.search(r'<select id="%s".*?</select>' % select_id, body, re.S)
|
||||
assert select, f'no <select id="{select_id}">'
|
||||
return re.findall(r'<option value="([^"]*)"\s+selected\s*>', select.group(0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('input_id,expected', [
|
||||
('rows', {'min': '8', 'max': None, 'step': '2'}),
|
||||
('cols', {'min': '16', 'max': None}),
|
||||
('chain_length', {'min': '1', 'max': None}),
|
||||
('parallel', {'min': '1', 'max': '3'}),
|
||||
('gpio_slowdown', {'min': '0', 'max': '10'}),
|
||||
('pwm_bits', {'min': '1', 'max': '11'}),
|
||||
('pwm_dither_bits', {'min': '0', 'max': '2'}),
|
||||
('pwm_lsb_nanoseconds', {'min': '50', 'max': '3000'}),
|
||||
('limit_refresh_rate_hz', {'min': '0', 'max': '1000'}),
|
||||
])
|
||||
def test_form_limits_match_the_library(display_page, input_id, expected):
|
||||
"""fixInvalidNumberInputs rewrites a value past min/max on submit, so these
|
||||
attributes are the real limits: a max below the library's clamps panels
|
||||
that would work, and one above it saves a value the matrix rejects."""
|
||||
tag = _input_tag(display_page(_config_with()), input_id)
|
||||
for name, value in expected.items():
|
||||
assert _attr(tag, name) == value, f'{input_id} {name}'
|
||||
|
||||
|
||||
def test_waveshare_96x48_v2_config_renders_back_unchanged(display_page):
|
||||
"""Saving the Display tab posts what it rendered, so each value must render as stored.
|
||||
|
||||
Before row address type 5 was in the dropdown no option was selected, the
|
||||
browser posted the first one (0), and one save scrambled the panel again.
|
||||
"""
|
||||
hardware = {k: v for k, v in WAVESHARE_96X48_V2.items() if k not in RUNTIME_FIELDS}
|
||||
body = display_page(_config_with(hardware=hardware, runtime={'gpio_slowdown': 8}))
|
||||
|
||||
assert _selected_option(body, 'row_address_type') == ['5']
|
||||
assert _selected_option(body, 'led_rgb_sequence') == ['BGR']
|
||||
assert _selected_option(body, 'hardware_mapping') == ['regular']
|
||||
for input_id in ('rows', 'cols', 'chain_length', 'parallel', 'gpio_slowdown'):
|
||||
assert _attr(_input_tag(body, input_id), 'value') == str(WAVESHARE_96X48_V2[input_id]), input_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hardware,select_id,expected', [
|
||||
({'hardware_mapping': 'classic'}, 'hardware_mapping', 'classic'),
|
||||
({'hardware_mapping': 'classic-pi1'}, 'hardware_mapping', 'classic-pi1'),
|
||||
({'hardware_mapping': 'adafruit-hat'}, 'hardware_mapping', 'adafruit-hat'),
|
||||
({'hardware_mapping': 'Regular'}, 'hardware_mapping', 'regular'),
|
||||
({'hardware_mapping': ''}, 'hardware_mapping', 'regular'),
|
||||
({'orientation': '90'}, 'orientation', '90'),
|
||||
({'orientation': '270'}, 'orientation', '270'),
|
||||
])
|
||||
def test_stored_mapping_and_orientation_render_back_unchanged(display_page, hardware, select_id, expected):
|
||||
"""With nothing selected the browser posts the first option, so one unrelated
|
||||
Display save turned classic into adafruit-hat-pwm and 90 degrees into normal."""
|
||||
body = display_page(_config_with(hardware=hardware))
|
||||
assert _selected_option(body, select_id) == [expected]
|
||||
assert "saved hardware mapping" not in body
|
||||
|
||||
|
||||
def test_missing_mapping_renders_display_managers_default(display_page):
|
||||
config = _config_with()
|
||||
del config['display']['hardware']['hardware_mapping']
|
||||
assert _selected_option(display_page(config), 'hardware_mapping') == ['adafruit-hat-pwm']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stored', ['compute-module', 'adafruit-hat-pwn'])
|
||||
def test_unusable_stored_mapping_renders_selected_with_a_warning(display_page, stored):
|
||||
"""Kept selected rather than silently swapped for the first option; the API
|
||||
refuses it on save, and the warning says why."""
|
||||
body = display_page(_config_with(hardware={'hardware_mapping': stored}))
|
||||
assert _selected_option(body, 'hardware_mapping') == [stored]
|
||||
assert f'Your saved hardware mapping ("{stored}")' in body
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,section', [
|
||||
('gpio_slowdown', 'runtime'), ('pwm_dither_bits', 'hardware'),
|
||||
('limit_refresh_rate_hz', 'hardware'),
|
||||
])
|
||||
def test_a_stored_zero_renders_as_zero(display_page, field, section):
|
||||
"""`value or default` showed a stored 0 as the default, and the next save wrote it back."""
|
||||
body = display_page(_config_with(**{section: {field: 0}}))
|
||||
assert _attr(_input_tag(body, field), 'value') == '0'
|
||||
|
||||
|
||||
# --- Raspberry Pi 5 -------------------------------------------------------
|
||||
# The pinned library's Pi 5 path drives only row address types 0 and 2,
|
||||
# parallel 1-3 and the standard mappings; anything else crashes the display
|
||||
# service (src/pi5_matrix_support.py), so the API and the form refuse it.
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
{'row_address_type': 5}, {'row_address_type': '1'},
|
||||
{'hardware_mapping': 'classic-pi1'},
|
||||
])
|
||||
def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body):
|
||||
board(PI5_MODEL)
|
||||
response = _post(api_v3_client, body)
|
||||
assert response.status_code == 400
|
||||
assert 'Raspberry Pi 5' in response.get_json()['message']
|
||||
assert 'config' not in saved
|
||||
|
||||
|
||||
def test_pi5_saves_what_it_can_drive(api_v3_client, saved, board):
|
||||
board(PI5_MODEL)
|
||||
response = _post(api_v3_client, dict(WAVESHARE_96X48_V2, row_address_type=2))
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
assert saved['config']['display']['hardware']['row_address_type'] == 2
|
||||
|
||||
|
||||
def test_pi5_check_uses_the_stored_value_for_fields_not_sent(api_v3_client, api_v3_module, saved, board):
|
||||
"""Changing only the mapping is still checked against the stored row address type."""
|
||||
board(PI5_MODEL)
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
'display': {'hardware': {'row_address_type': 5}}}
|
||||
response = _post(api_v3_client, {'hardware_mapping': 'regular'})
|
||||
assert response.status_code == 400
|
||||
assert 'config' not in saved
|
||||
|
||||
|
||||
def test_pi5_stored_combination_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved, board):
|
||||
board(PI5_MODEL)
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
'display': {'hardware': {'row_address_type': 5}}}
|
||||
response = _post(api_v3_client, {'brightness': 70})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
|
||||
|
||||
def _option_values(body, select_id):
|
||||
select = re.search(r'<select id="%s".*?</select>' % select_id, body, re.S)
|
||||
assert select, f'no <select id="{select_id}">'
|
||||
return re.findall(r'<option value="([^"]*)"', select.group(0))
|
||||
|
||||
|
||||
def test_pi5_form_offers_only_supported_row_address_types(display_page, board):
|
||||
board(PI5_MODEL)
|
||||
body = display_page(_config_with())
|
||||
assert _option_values(body, 'row_address_type') == ['0', '2']
|
||||
assert "can't be used on this Raspberry Pi 5" not in body
|
||||
|
||||
|
||||
def test_pi5_form_offers_only_mappings_it_can_drive(display_page, board):
|
||||
board(PI5_MODEL)
|
||||
body = display_page(_config_with())
|
||||
assert 'classic-pi1' not in _option_values(body, 'hardware_mapping')
|
||||
body = display_page(_config_with(hardware={'hardware_mapping': 'classic-pi1'}))
|
||||
assert _selected_option(body, 'hardware_mapping') == ['classic-pi1']
|
||||
assert 'can use on a Raspberry Pi 5' in body
|
||||
|
||||
|
||||
def test_other_boards_offer_every_row_address_type(display_page):
|
||||
body = display_page(_config_with())
|
||||
assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5']
|
||||
|
||||
|
||||
def test_pi5_form_warns_about_a_stored_unsupported_row_address_type(display_page, board):
|
||||
"""The unsupported option isn't offered, so the browser posts 0 -- say so."""
|
||||
board(PI5_MODEL)
|
||||
body = display_page(_config_with(hardware={'row_address_type': 5}))
|
||||
assert "Your saved row address type (5) can't be used on this Raspberry Pi 5" in body
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests that api_v3 routes find installed plugins before anything else has.
|
||||
|
||||
The web process discovers plugins lazily (web_interface/app.py): nothing scans
|
||||
the plugins directory at startup, and plugin_manifests stays empty until some
|
||||
endpoint calls discover_plugins(). Routes that consulted plugin_manifests
|
||||
without discovering therefore misbehaved for as long as nothing else had run.
|
||||
Measured on a rig after restarting ledmatrix-web:
|
||||
|
||||
POST /api/v3/display/on-demand/start {"plugin_id": "ledmatrix-stocks"}
|
||||
-> 404 "Plugin ledmatrix-stocks not found", for over three minutes,
|
||||
until GET /api/v3/plugins/installed happened to discover plugins.
|
||||
|
||||
The browser UI loads the plugin list first, so people rarely saw it; API-only
|
||||
callers (the Home Assistant MQTT bridge, scripts) saw it after every restart.
|
||||
/plugins/toggle answered the same 404, and /config/main was worse: an
|
||||
undiscovered plugin section skipped secret separation and wrote its API key
|
||||
into config.json in plain text.
|
||||
|
||||
A real PluginManager over a temporary plugins directory, so "empty until
|
||||
discovered" is the real behaviour rather than a mock's.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
||||
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
PLUGIN_ID = 'ledmatrix-stocks'
|
||||
MODE = 'stocks'
|
||||
|
||||
|
||||
def _install(plugins_dir, plugin_id, modes, schema=None):
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / 'manifest.json').write_text(json.dumps({
|
||||
'id': plugin_id, 'name': plugin_id, 'version': '1.0.0',
|
||||
'entry_point': 'manager.py', 'class_name': 'Plugin',
|
||||
'display_modes': modes,
|
||||
}))
|
||||
(plugin_dir / 'manager.py').write_text('')
|
||||
if schema is not None:
|
||||
(plugin_dir / 'config_schema.json').write_text(json.dumps(schema))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir(tmp_path):
|
||||
path = tmp_path / 'plugin-repos'
|
||||
path.mkdir()
|
||||
_install(path, PLUGIN_ID, [MODE], schema={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'enabled': {'type': 'boolean'},
|
||||
'symbols': {'type': 'string'},
|
||||
'api_key': {'type': 'string', 'x-secret': True},
|
||||
},
|
||||
})
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_web_process(api_v3_module, plugins_dir):
|
||||
"""The web process right after a restart: nothing discovered yet."""
|
||||
manager = PluginManager(plugins_dir=str(plugins_dir))
|
||||
assert not manager.plugin_manifests
|
||||
api_v3_module.api_v3.plugin_manager = manager
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def display_service():
|
||||
"""Keep on-demand start away from systemctl and the real cache."""
|
||||
with patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') as cache, \
|
||||
patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status:
|
||||
cache.return_value = MagicMock()
|
||||
status.return_value = {'active': True}
|
||||
yield cache.return_value
|
||||
|
||||
|
||||
def _start(client, **body):
|
||||
body.setdefault('start_service', False)
|
||||
return client.post('/api/v3/display/on-demand/start', json=body)
|
||||
|
||||
|
||||
class TestOnDemandStart:
|
||||
def test_by_plugin_id(self, api_v3_client, fresh_web_process, display_service):
|
||||
response = _start(api_v3_client, plugin_id=PLUGIN_ID)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
request = display_service.set.call_args.args[1]
|
||||
assert (request['plugin_id'], request['mode']) == (PLUGIN_ID, MODE)
|
||||
|
||||
def test_by_mode_alone(self, api_v3_client, fresh_web_process, display_service):
|
||||
response = _start(api_v3_client, mode=MODE)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert display_service.set.call_args.args[1]['plugin_id'] == PLUGIN_ID
|
||||
|
||||
def test_a_plugin_installed_since_the_last_scan(
|
||||
self, api_v3_client, fresh_web_process, plugins_dir, display_service):
|
||||
fresh_web_process.discover_plugins()
|
||||
_install(plugins_dir, 'ledmatrix-weather', ['weather'])
|
||||
|
||||
assert _start(api_v3_client, plugin_id='ledmatrix-weather').status_code == 200
|
||||
assert _start(api_v3_client, mode=MODE).status_code == 200
|
||||
|
||||
def test_a_mode_installed_since_the_last_scan(
|
||||
self, api_v3_client, fresh_web_process, plugins_dir, display_service):
|
||||
fresh_web_process.discover_plugins()
|
||||
_install(plugins_dir, 'ledmatrix-weather', ['weather'])
|
||||
|
||||
response = _start(api_v3_client, mode='weather')
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
def test_an_unknown_plugin_is_still_not_found(
|
||||
self, api_v3_client, fresh_web_process, display_service):
|
||||
assert _start(api_v3_client, plugin_id='no-such-plugin').status_code == 404
|
||||
assert _start(api_v3_client, mode='no-such-mode').status_code == 404
|
||||
display_service.set.assert_not_called()
|
||||
|
||||
|
||||
def test_toggle_finds_the_plugin(api_v3_client, api_v3_module, fresh_web_process):
|
||||
config_manager = api_v3_module.api_v3.config_manager
|
||||
config_manager.load_config.return_value = {PLUGIN_ID: {'enabled': False}}
|
||||
config_manager.save_config_atomic.return_value = MagicMock(
|
||||
status=MagicMock(value='success'))
|
||||
|
||||
response = api_v3_client.post('/api/v3/plugins/toggle',
|
||||
json={'plugin_id': PLUGIN_ID, 'enabled': True})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
saved = config_manager.save_config_atomic.call_args.args[0]
|
||||
assert saved[PLUGIN_ID]['enabled'] is True
|
||||
|
||||
|
||||
def test_main_config_save_keeps_a_plugin_secret_out_of_config_json(
|
||||
api_v3_client, api_v3_module, fresh_web_process, plugins_dir, tmp_path):
|
||||
# /config/main validates a plugin section like POST /plugins/config does,
|
||||
# so it needs a real schema manager rather than the helper's mock.
|
||||
api_v3_module.api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir)
|
||||
config_file = tmp_path / 'config.json'
|
||||
config_file.write_text('{}')
|
||||
secrets_file = tmp_path / 'config_secrets.json'
|
||||
config_manager = ConfigManager(config_path=str(config_file),
|
||||
secrets_path=str(secrets_file))
|
||||
config_manager.template_path = str(tmp_path / 'no-template.json')
|
||||
api_v3_module.api_v3.config_manager = config_manager
|
||||
|
||||
response = api_v3_client.post('/api/v3/config/main', json={
|
||||
PLUGIN_ID: {'symbols': 'AAPL', 'api_key': 's3cret-key'},
|
||||
})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert 's3cret' not in config_file.read_text()
|
||||
assert json.loads(secrets_file.read_text())[PLUGIN_ID]['api_key'] == 's3cret-key'
|
||||
assert json.loads(config_file.read_text())[PLUGIN_ID]['symbols'] == 'AAPL'
|
||||
@@ -0,0 +1,288 @@
|
||||
"""POST /config/main: a partial JSON body changes only what it sends.
|
||||
|
||||
The settings forms post every field, and a browser leaves an unchecked box out,
|
||||
so for a form a missing checkbox means False. JSON API clients send only what
|
||||
they change. Treating their missing keys as unchecked meant:
|
||||
|
||||
- the MQTT bridge's Home Assistant brightness slider (``{"brightness": N}``)
|
||||
turned off disable_hardware_pulsing, inverse_colors, show_refresh_rate and
|
||||
use_short_date_format on every change;
|
||||
- the documented timezone/location update turned off web_display_autostart and
|
||||
weekly automatic updates.
|
||||
|
||||
The v3 forms post JSON as well (htmx json-enc), so they mark themselves with a
|
||||
hidden ``__form_section`` input; these tests pin both halves of that contract.
|
||||
|
||||
Also here: the Vegas cycle-time fields no longer land in display_durations
|
||||
(and a blank one no longer 400s the Display save), the Raw JSON editor starts
|
||||
auto-update setup like the General form does, and both schedule POSTs accept
|
||||
the per-day shape their GETs return.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PARTIALS = REPO / 'web_interface' / 'templates' / 'v3' / 'partials'
|
||||
|
||||
STORED = {
|
||||
'web_display_autostart': True,
|
||||
'auto_update': {'enabled': True},
|
||||
'timezone': 'America/Chicago',
|
||||
'plugin_system': {'auto_discover': True, 'auto_load_enabled': True,
|
||||
'development_mode': True},
|
||||
'display': {
|
||||
'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90,
|
||||
'disable_hardware_pulsing': True, 'inverse_colors': True,
|
||||
'show_refresh_rate': True},
|
||||
'runtime': {'gpio_slowdown': 4},
|
||||
'use_short_date_format': True,
|
||||
'double_sided': {'enabled': True, 'copies': 2, 'axis': 'horizontal'},
|
||||
'vegas_scroll': {'enabled': True, 'auto_trim': True,
|
||||
'dynamic_duration_enabled': True,
|
||||
'continuous_scroll': True, 'smooth_scroll': True},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saved(api_v3_module, monkeypatch):
|
||||
captured = {}
|
||||
api_v3_module.api_v3.config_manager.load_config.side_effect = \
|
||||
lambda *a, **k: copy.deepcopy(STORED)
|
||||
|
||||
def fake_save(_manager, config, **_kwargs):
|
||||
captured['config'] = config
|
||||
return True, ''
|
||||
|
||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
||||
from web_interface import auto_update
|
||||
monkeypatch.setattr(auto_update, 'start_setup_if_needed', lambda *a, **k: None)
|
||||
return captured
|
||||
|
||||
|
||||
def _post_json(client, body):
|
||||
return client.post('/api/v3/config/main', data=json.dumps(body),
|
||||
content_type='application/json')
|
||||
|
||||
|
||||
class TestJsonPartialSaves:
|
||||
def test_mqtt_bridge_brightness_changes_only_brightness(self, api_v3_client, saved):
|
||||
# integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py set_brightness
|
||||
resp = _post_json(api_v3_client, {'brightness': 40})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
display = saved['config']['display']
|
||||
assert display['hardware']['brightness'] == 40
|
||||
assert display['hardware']['disable_hardware_pulsing'] is True
|
||||
assert display['hardware']['inverse_colors'] is True
|
||||
assert display['hardware']['show_refresh_rate'] is True
|
||||
assert display['use_short_date_format'] is True
|
||||
|
||||
def test_timezone_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'timezone': 'UTC'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
config = saved['config']
|
||||
assert config['timezone'] == 'UTC'
|
||||
assert config['web_display_autostart'] is True
|
||||
assert config['auto_update'] == {'enabled': True}
|
||||
|
||||
def test_location_only_keeps_autostart_and_auto_update(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'city': 'Paris', 'country': 'FR'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
config = saved['config']
|
||||
assert config['location'] == {'city': 'Paris', 'country': 'FR'}
|
||||
assert config['web_display_autostart'] is True
|
||||
assert config['auto_update'] == {'enabled': True}
|
||||
|
||||
@pytest.mark.parametrize('key', ['auto_discover', 'auto_load_enabled', 'development_mode'])
|
||||
def test_a_legacy_plugin_system_toggle_is_not_a_general_save(self, api_v3_client, saved, key):
|
||||
# These left the General form; a client still sending one must not
|
||||
# have the general-settings checkboxes treated as unchecked.
|
||||
resp = api_v3_client.post('/api/v3/config/main', data={key: 'on'},
|
||||
content_type='application/x-www-form-urlencoded')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['web_display_autostart'] is True
|
||||
assert saved['config']['auto_update'] == {'enabled': True}
|
||||
|
||||
def test_vegas_speed_only_keeps_vegas_toggles(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'vegas_scroll_speed': 80})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
vegas = saved['config']['display']['vegas_scroll']
|
||||
assert vegas['scroll_speed'] == 80
|
||||
for key in ('enabled', 'auto_trim', 'dynamic_duration_enabled',
|
||||
'continuous_scroll', 'smooth_scroll'):
|
||||
assert vegas[key] is True, key
|
||||
|
||||
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['double_sided']['enabled'] is True
|
||||
|
||||
def test_json_can_still_turn_a_checkbox_off(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'inverse_colors': False, 'auto_update_enabled': False})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['hardware']['inverse_colors'] is False
|
||||
assert saved['config']['display']['hardware']['disable_hardware_pulsing'] is True
|
||||
assert saved['config']['auto_update'] == {'enabled': False}
|
||||
|
||||
def test_json_with_charset_is_still_json(self, api_v3_client, saved):
|
||||
resp = api_v3_client.post('/api/v3/config/main', data=json.dumps({'brightness': 41}),
|
||||
content_type='application/json; charset=utf-8')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['hardware']['brightness'] == 41
|
||||
assert saved['config']['display']['hardware']['inverse_colors'] is True
|
||||
|
||||
def test_a_non_object_body_is_refused(self, api_v3_client, saved):
|
||||
assert _post_json(api_v3_client, [1, 2]).status_code == 400
|
||||
|
||||
|
||||
class TestFormSavesStillUncheck:
|
||||
"""Unchecking a box in the UI must still save false."""
|
||||
|
||||
def test_marked_json_form_post_unchecks_missing_display_boxes(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
|
||||
'vegas_scroll_speed': '50'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
display = saved['config']['display']
|
||||
for key in ('disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate'):
|
||||
assert display['hardware'][key] is False, key
|
||||
assert display['use_short_date_format'] is False
|
||||
assert display['vegas_scroll']['enabled'] is False
|
||||
assert '__form_section' not in saved['config']
|
||||
|
||||
def test_marked_json_form_post_keeps_checked_boxes(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'__form_section': 'display', 'brightness': '40',
|
||||
'inverse_colors': 'on'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['hardware']['inverse_colors'] is True
|
||||
assert saved['config']['display']['hardware']['show_refresh_rate'] is False
|
||||
|
||||
def test_marked_general_form_unchecks_autostart_and_auto_update(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'__form_section': 'general', 'timezone': 'UTC'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['web_display_autostart'] is False
|
||||
assert saved['config']['auto_update'] == {'enabled': False}
|
||||
|
||||
def test_form_encoded_post_unchecks_missing_boxes(self, api_v3_client, saved):
|
||||
resp = api_v3_client.post('/api/v3/config/main', data={'brightness': '40'},
|
||||
content_type='application/x-www-form-urlencoded')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['hardware']['inverse_colors'] is False
|
||||
|
||||
@pytest.mark.parametrize('partial', ['general.html', 'display.html', 'durations.html'])
|
||||
def test_every_config_main_form_carries_the_marker(self, partial):
|
||||
html = (PARTIALS / partial).read_text(encoding='utf-8')
|
||||
form = re.search(r'<form hx-post="/api/v3/config/main".*?</form>', html, re.S)
|
||||
assert form, f'{partial} no longer posts to /config/main'
|
||||
assert re.search(r'<input type="hidden" name="__form_section" value="\w+">',
|
||||
form.group(0)), f'{partial} form lost its __form_section marker'
|
||||
|
||||
|
||||
class TestVegasCycleDurations:
|
||||
def test_cycle_durations_are_not_display_durations(self, api_v3_client, saved):
|
||||
resp = api_v3_client.post('/api/v3/config/main', data={
|
||||
'vegas_scroll_enabled': 'on', 'vegas_min_cycle_duration': '90',
|
||||
'vegas_max_cycle_duration': '300'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
display = saved['config']['display']
|
||||
assert display['vegas_scroll']['min_cycle_duration'] == 90
|
||||
assert display['vegas_scroll']['max_cycle_duration'] == 300
|
||||
durations = display.get('display_durations', {})
|
||||
assert 'vegas_min_cycle_duration' not in durations
|
||||
assert 'vegas_max_cycle_duration' not in durations
|
||||
assert 'vegas_min_cycle_duration' not in saved['config']
|
||||
|
||||
def test_blank_cycle_duration_does_not_reject_the_save(self, api_v3_client, saved):
|
||||
resp = api_v3_client.post('/api/v3/config/main', data={
|
||||
'vegas_scroll_enabled': 'on', 'vegas_scroll_speed': '60',
|
||||
'vegas_min_cycle_duration': ''})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['vegas_scroll']['scroll_speed'] == 60
|
||||
|
||||
def test_real_display_durations_still_save(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'clock_duration': '45'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['display_durations']['clock_duration'] == 45
|
||||
|
||||
|
||||
class TestRawSaveStartsAutoUpdateSetup:
|
||||
@pytest.fixture
|
||||
def raw_env(self, api_v3_module, monkeypatch):
|
||||
cm = api_v3_module.api_v3.config_manager
|
||||
cm.get_raw_file_content.return_value = {'timezone': 'UTC', 'auto_update': {'enabled': False}}
|
||||
calls = []
|
||||
from web_interface import auto_update
|
||||
monkeypatch.setattr(auto_update, 'start_setup_if_needed',
|
||||
lambda was, config: calls.append((was, config)) or 'Setup note.')
|
||||
return cm, calls
|
||||
|
||||
def test_enabling_from_raw_json_calls_setup(self, api_v3_client, raw_env):
|
||||
cm, calls = raw_env
|
||||
body = {'timezone': 'UTC', 'auto_update': {'enabled': True}}
|
||||
resp = api_v3_client.post('/api/v3/config/raw/main', json=body)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
cm.save_raw_file_content.assert_called_once_with('main', body)
|
||||
assert calls == [(False, body)]
|
||||
assert 'Setup note.' in resp.get_json()['message']
|
||||
|
||||
def test_previously_enabled_is_passed_through(self, api_v3_client, raw_env):
|
||||
cm, calls = raw_env
|
||||
cm.get_raw_file_content.return_value = {'auto_update': {'enabled': True}}
|
||||
body = {'auto_update': {'enabled': True}}
|
||||
assert api_v3_client.post('/api/v3/config/raw/main', json=body).status_code == 200
|
||||
assert calls == [(True, body)]
|
||||
|
||||
|
||||
class TestSchedulesAcceptTheirGetShape:
|
||||
PER_DAY = {
|
||||
'enabled': True, 'mode': 'per-day', 'dim_brightness': 20,
|
||||
'days': {
|
||||
'monday': {'enabled': True, 'start_time': '21:15', 'end_time': '06:45'},
|
||||
'tuesday': {'enabled': False},
|
||||
'wednesday': {'enabled': True, 'start_time': '22:00', 'end_time': '05:30'},
|
||||
'thursday': {'enabled': True, 'start_time': '20:00', 'end_time': '07:00'},
|
||||
'friday': {'enabled': True, 'start_time': '23:00', 'end_time': '08:00'},
|
||||
'saturday': {'enabled': True, 'start_time': '23:30', 'end_time': '09:00'},
|
||||
'sunday': {'enabled': True, 'start_time': '21:00', 'end_time': '07:00'},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def store(self, api_v3_module, monkeypatch):
|
||||
state = {'config': {}}
|
||||
api_v3_module.api_v3.config_manager.load_config.side_effect = \
|
||||
lambda *a, **k: copy.deepcopy(state['config'])
|
||||
|
||||
def fake_save(_manager, config, **_kwargs):
|
||||
state['config'] = copy.deepcopy(config)
|
||||
return True, ''
|
||||
|
||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
||||
return state
|
||||
|
||||
@pytest.mark.parametrize('route,section,extra', [
|
||||
('/api/v3/config/dim-schedule', 'dim_schedule', {'dim_brightness': 20}),
|
||||
('/api/v3/config/schedule', 'schedule', {}),
|
||||
])
|
||||
def test_get_output_posts_back_unchanged(self, api_v3_client, store, route, section, extra):
|
||||
body = {k: v for k, v in self.PER_DAY.items() if k != 'dim_brightness'}
|
||||
body.update(extra)
|
||||
store['config'] = {section: copy.deepcopy(body)}
|
||||
read = api_v3_client.get(route).get_json()['data']
|
||||
resp = api_v3_client.post(route, json=read)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert store['config'][section]['days'] == body['days']
|
||||
|
||||
def test_flat_form_keys_still_work(self, api_v3_client, store):
|
||||
body = {'enabled': True, 'mode': 'per-day', 'dim_brightness': 25,
|
||||
'monday_enabled': 'on', 'monday_start': '19:00', 'monday_end': '06:00'}
|
||||
resp = api_v3_client.post('/api/v3/config/dim-schedule', json=body)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert store['config']['dim_schedule']['days']['monday'] == {
|
||||
'enabled': True, 'start_time': '19:00', 'end_time': '06:00'}
|
||||
+175
-4
@@ -72,7 +72,7 @@ class Repo:
|
||||
|
||||
|
||||
def real_pull(device, **extra):
|
||||
def core_update():
|
||||
def core_update(**kwargs):
|
||||
before = git(device, 'rev-parse', 'HEAD')
|
||||
r = subprocess.run(['git', 'pull', '-q', '--rebase', '--autostash'],
|
||||
cwd=str(device), capture_output=True, text=True)
|
||||
@@ -339,6 +339,145 @@ class TestPreflightRefuses:
|
||||
assert repo.head() == old and h.sudo == []
|
||||
|
||||
|
||||
# -- what counts as a local change ---------------------------------------------
|
||||
|
||||
def _device_with_plugin_and_script(tmp_path):
|
||||
"""A clone carrying a bundled plugin and an executable script, as LEDMatrix does."""
|
||||
repo = Repo(tmp_path)
|
||||
for rel, text in (('plugin-repos/web-ui-info/manager.py', 'x = 1\n'),
|
||||
('web_interface/static/v3/js/plugins/store.js', 'var a;\n'),
|
||||
('scripts/run.sh', 'echo hi\n')):
|
||||
(repo.seed / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(repo.seed / rel).write_text(text)
|
||||
# Executable on disk too, not only in the index: Repo.publish() commits
|
||||
# with -a, which on Linux would otherwise record run.sh as 100644 upstream
|
||||
# and hide the device's own mode change behind the pull.
|
||||
import os
|
||||
os.chmod(repo.seed / 'scripts' / 'run.sh', 0o755)
|
||||
git(repo.seed, 'add', '.')
|
||||
git(repo.seed, 'update-index', '--chmod=+x', 'scripts/run.sh')
|
||||
git(repo.seed, 'commit', '-qm', 'layout')
|
||||
git(repo.seed, 'push', '-q', 'origin', 'main')
|
||||
git(repo.device, 'pull', '-q')
|
||||
return repo
|
||||
|
||||
|
||||
def _chmod_like_the_installer(device):
|
||||
"""first_time_install.sh sets tracked 100755 files to 644: a mode-only change.
|
||||
|
||||
Windows has no exec bit, so there core.fileMode=true alone shows it."""
|
||||
import os
|
||||
git(device, 'config', 'core.fileMode', 'true')
|
||||
os.chmod(device / 'scripts' / 'run.sh', 0o644)
|
||||
assert 'mode change 100755 => 100644 scripts/run.sh' in git(device, 'diff', '--summary')
|
||||
|
||||
|
||||
@needs_git
|
||||
class TestLocalChangesAreCountedOnce:
|
||||
"""The preflight promises the pull will not stash. That holds only if both
|
||||
count local changes the same way (auto_update.local_changes)."""
|
||||
|
||||
def test_mode_changes_and_plugin_folders_do_not_count(self, tmp_path):
|
||||
repo = _device_with_plugin_and_script(tmp_path)
|
||||
_chmod_like_the_installer(repo.device)
|
||||
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
|
||||
assert au.local_changes(repo.device) == []
|
||||
|
||||
def test_a_core_folder_named_plugins_still_counts(self, tmp_path):
|
||||
"""The old filter matched 'plugins/' anywhere in the line."""
|
||||
repo = _device_with_plugin_and_script(tmp_path)
|
||||
(repo.device / 'web_interface/static/v3/js/plugins/store.js').write_text('var mine;\n')
|
||||
assert au.local_changes(repo.device) == ['web_interface/static/v3/js/plugins/store.js']
|
||||
|
||||
def test_a_staged_rename_is_reported_by_its_new_name(self, tmp_path):
|
||||
repo = Repo(tmp_path)
|
||||
git(repo.device, 'mv', 'app.py', 'main.py')
|
||||
assert au.local_changes(repo.device) == ['main.py']
|
||||
|
||||
def test_the_preflight_refuses_a_core_edit_under_a_plugins_folder(self, tmp_path):
|
||||
repo = _device_with_plugin_and_script(tmp_path)
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
(repo.device / 'web_interface/static/v3/js/plugins/store.js').write_text('var mine;\n')
|
||||
result = Harness(tmp_path, repo).updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'store.js' in result['core_message']
|
||||
assert repo.head() == old
|
||||
|
||||
@pytest.fixture
|
||||
def core_update(self, monkeypatch):
|
||||
"""The real perform_core_update, pointed at a test clone."""
|
||||
from web_interface.blueprints import api_v3 as pkg
|
||||
from web_interface.blueprints.api_v3 import system
|
||||
|
||||
def point_at(device):
|
||||
monkeypatch.setattr(system, 'PROJECT_ROOT', device)
|
||||
monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False)
|
||||
monkeypatch.setattr(system, '_pip_install_requirements',
|
||||
lambda *a, **k: pytest.fail('no requirements changed'))
|
||||
return system.perform_core_update
|
||||
return point_at
|
||||
|
||||
@pytest.mark.parametrize('change', [
|
||||
'bundled_plugin_edit',
|
||||
pytest.param('installer_chmod', marks=pytest.mark.skipif(
|
||||
sys.platform == 'win32',
|
||||
reason='no exec bit: the reset inside --autostash cannot clear a mode change, '
|
||||
'so git will not reapply it (on Linux it reapplies cleanly)')),
|
||||
])
|
||||
def test_an_update_that_passed_the_preflight_leaves_no_stash(self, tmp_path, core_update, change):
|
||||
"""Found by audit: a bundled-plugin edit or the installer's chmods
|
||||
passed the preflight, then perform_core_update stashed them for good.
|
||||
Now --autostash carries them across the pull and puts them back."""
|
||||
repo = _device_with_plugin_and_script(tmp_path)
|
||||
new = repo.publish()
|
||||
if change == 'installer_chmod':
|
||||
_chmod_like_the_installer(repo.device)
|
||||
else:
|
||||
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
|
||||
h = Harness(tmp_path, repo, core_update=core_update(repo.device))
|
||||
assert h.updater.preflight({})[0] == 'ready'
|
||||
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'verifying', result['core_message']
|
||||
assert repo.head() == new
|
||||
assert git(repo.device, 'stash', 'list') == ''
|
||||
if change == 'installer_chmod':
|
||||
assert 'mode change 100755 => 100644 scripts/run.sh' in git(repo.device, 'diff', '--summary')
|
||||
else:
|
||||
assert 'store update' in (repo.device / 'plugin-repos/web-ui-info/manager.py').read_text()
|
||||
|
||||
def test_edits_made_after_the_preflight_are_refused_not_stashed(self, tmp_path, core_update):
|
||||
repo = Repo(tmp_path)
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
perform = core_update(repo.device)
|
||||
|
||||
def edit_then_update(**kwargs):
|
||||
(repo.device / 'app.py').write_text('mine\n')
|
||||
return perform(**kwargs)
|
||||
result = Harness(tmp_path, repo, core_update=edit_then_update).updater.run()
|
||||
assert result['core_outcome'] == 'blocked'
|
||||
assert 'app.py' in result['core_message'] and 'will not stash' in result['core_message']
|
||||
assert repo.head() == old
|
||||
assert git(repo.device, 'stash', 'list') == ''
|
||||
assert (repo.device / 'app.py').read_text() == 'mine\n'
|
||||
|
||||
def test_update_code_still_stashes_core_edits_but_not_plugin_folders(self, tmp_path, core_update):
|
||||
repo = _device_with_plugin_and_script(tmp_path)
|
||||
new = repo.publish()
|
||||
(repo.device / 'scripts/run.sh').write_text('echo mine\n')
|
||||
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
|
||||
payload = core_update(repo.device)()
|
||||
assert payload['status'] == 'success', payload['message']
|
||||
assert 'stashed' in payload['message']
|
||||
assert repo.head() == new
|
||||
assert 'LEDMatrix auto-stash before update' in git(repo.device, 'stash', 'list')
|
||||
stashed = git(repo.device, 'stash', 'show', '--name-only', 'stash@{0}')
|
||||
assert stashed.split() == ['scripts/run.sh']
|
||||
assert 'store update' in (repo.device / 'plugin-repos/web-ui-info/manager.py').read_text()
|
||||
|
||||
|
||||
# -- the update and its hand-off to the health check ---------------------------
|
||||
|
||||
@needs_git
|
||||
@@ -395,7 +534,7 @@ class TestUpdateIsVerified:
|
||||
old = repo.head()
|
||||
repo.publish()
|
||||
h = Harness(tmp_path, repo,
|
||||
core_update=lambda: {'status': 'error', 'message': 'Update failed: network'})
|
||||
core_update=lambda **kw: {'status': 'error', 'message': 'Update failed: network'})
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'error'
|
||||
assert repo.head() == old
|
||||
@@ -407,13 +546,43 @@ class TestUpdateIsVerified:
|
||||
repo.publish()
|
||||
pull = real_pull(repo.device)
|
||||
|
||||
def half_failed():
|
||||
def half_failed(**kwargs):
|
||||
pull()
|
||||
return {'status': 'error', 'message': 'Update failed: interrupted'}
|
||||
result = Harness(tmp_path, repo, core_update=half_failed).updater.run()
|
||||
assert repo.head() == old
|
||||
assert 'rolled back' in result['core_message']
|
||||
|
||||
@pytest.mark.parametrize('how', ['partial_pull', 'check_never_started'])
|
||||
def test_a_failed_rollback_leaves_plugins_and_the_display_alone(self, tmp_path, how):
|
||||
"""update_core returns rollback_failed itself on two paths. The device
|
||||
is then in an unknown state, exactly as when the health check reports
|
||||
rollback_failed, so no plugin updates and no restart may follow."""
|
||||
repo = Repo(tmp_path)
|
||||
repo.publish()
|
||||
store = FakeStore(tmp_path / 'plugins', {'clock': '1.0.0'}, bump={'clock'})
|
||||
pull = real_pull(repo.device)
|
||||
|
||||
def half_failed(**kwargs):
|
||||
pull()
|
||||
return {'status': 'error', 'message': 'Update failed: interrupted'}
|
||||
h = Harness(tmp_path, repo, store=store, pickup=how != 'check_never_started',
|
||||
core_update=half_failed if how == 'partial_pull' else None)
|
||||
real_run = h.updater.run_command
|
||||
|
||||
def reset_fails(args, **kwargs):
|
||||
if args[:3] == ['git', 'reset', '--hard']:
|
||||
return subprocess.CompletedProcess(args, 1, stdout='', stderr='index.lock exists')
|
||||
return real_run(args, **kwargs)
|
||||
h.updater.run_command = reset_fails
|
||||
|
||||
result = h.updater.run()
|
||||
assert result['core_outcome'] == 'rollback_failed'
|
||||
assert store.updated_calls == [], "plugins must not pile onto a core in an unknown state"
|
||||
assert h.restarts == []
|
||||
assert h.state['plugins_pending'] is False and result['plugins_deferred'] is False
|
||||
assert h.state['alert']
|
||||
|
||||
|
||||
# -- reporting the health check's outcome --------------------------------------
|
||||
|
||||
@@ -668,7 +837,9 @@ class TestSettingsSave:
|
||||
|
||||
def test_unchecked_toggle_on_general_save_disables(self, api_client):
|
||||
client, cm, setup_calls = api_client
|
||||
resp = client.post('/api/v3/config/main', json={'timezone': 'UTC'})
|
||||
# The General form marks its posts; an unchecked box is then absent.
|
||||
resp = client.post('/api/v3/config/main',
|
||||
json={'__form_section': 'general', 'timezone': 'UTC'})
|
||||
assert resp.status_code == 200
|
||||
assert self._saved(cm)['auto_update'] == {'enabled': False}
|
||||
assert setup_calls == [True]
|
||||
|
||||
@@ -69,6 +69,8 @@ class FakeHost:
|
||||
self.running_head = None # not restarted yet: still the old, healthy code
|
||||
self.restarts = [] # (unit, commit it was restarted onto)
|
||||
self.pip_installs = []
|
||||
self.pip_timeouts = []
|
||||
self.pip = None # optional (args, host) -> result, or raises, instead of pip_ok
|
||||
self.now = 0.0
|
||||
self.nrestarts = 0
|
||||
|
||||
@@ -90,6 +92,9 @@ class FakeHost:
|
||||
return done(args, f'{self.nrestarts}\n')
|
||||
if args[0] == 'sudo' and any(a.endswith('safe_pip_install.sh') for a in args):
|
||||
self.pip_installs.append(args[-1])
|
||||
self.pip_timeouts.append(kwargs.get('timeout'))
|
||||
if self.pip:
|
||||
return self.pip(args, self)
|
||||
return done(args, rc=0 if self.pip_ok else 1)
|
||||
if args[:4] == ['sudo', '-n', 'systemctl', 'restart']:
|
||||
if self.restart_failures:
|
||||
@@ -171,6 +176,77 @@ def test_a_failed_dependency_reinstall_is_reported(tmp_path):
|
||||
assert 'Install Base Requirements' in result['detail']
|
||||
|
||||
|
||||
def _rollback_with_pip(tmp_path, pip, web_requirements_too=False):
|
||||
"""Roll back an update that changed the requirements, with pip faked by ``pip``."""
|
||||
repo, old, new = updated_repo(tmp_path, new_requirements=True)
|
||||
if web_requirements_too:
|
||||
(repo / 'web_interface').mkdir()
|
||||
(repo / 'web_interface' / 'requirements.txt').write_text('flask\n')
|
||||
git(repo, 'add', '.')
|
||||
git(repo, 'commit', '-qm', 'web reqs')
|
||||
(repo / 'web_interface' / 'requirements.txt').write_text('flask\nnew\n')
|
||||
(repo / 'requirements.txt').write_text('requests\nnewer\n')
|
||||
git(repo, 'commit', '-qam', 'bump both')
|
||||
old, new = git(repo, 'rev-parse', 'HEAD~1'), git(repo, 'rev-parse', 'HEAD')
|
||||
host = FakeHost(repo, new)
|
||||
host.pip = pip
|
||||
ok, detail = host.verifier().rollback({'old_head': old, 'new_head': new})
|
||||
return ok, detail, host
|
||||
|
||||
|
||||
def test_a_failed_pip_is_not_run_again_with_the_other_bash(tmp_path):
|
||||
"""Retrying after pip itself ran only repeats it, and every repeat can
|
||||
take PIP_TIMEOUT_SECONDS of the unit's time limit."""
|
||||
ok, detail, host = _rollback_with_pip(
|
||||
tmp_path, lambda args, h: subprocess.CompletedProcess(args, 1, '', 'ERROR: No matching distribution'))
|
||||
assert ok and 'requirements.txt' in detail
|
||||
assert len(host.pip_installs) == 1
|
||||
|
||||
|
||||
def test_a_pip_timeout_is_not_retried(tmp_path):
|
||||
def slow(args, h):
|
||||
h.now += h.pip_timeouts[-1]
|
||||
raise subprocess.TimeoutExpired(args, h.pip_timeouts[-1])
|
||||
ok, detail, host = _rollback_with_pip(tmp_path, slow)
|
||||
assert ok and 'Install Base Requirements' in detail
|
||||
assert len(host.pip_installs) == 1
|
||||
|
||||
|
||||
def test_a_sudo_refusal_tries_the_next_bash(tmp_path):
|
||||
def refused_once(args, h):
|
||||
if len(h.pip_installs) == 1:
|
||||
return subprocess.CompletedProcess(args, 1, '', 'sudo: a password is required')
|
||||
return done(args)
|
||||
ok, detail, host = _rollback_with_pip(tmp_path, refused_once)
|
||||
assert ok and detail == ''
|
||||
assert len(host.pip_installs) == 2
|
||||
|
||||
|
||||
def test_reinstalls_share_one_time_budget(tmp_path):
|
||||
def hangs(args, h):
|
||||
h.now += h.pip_timeouts[-1]
|
||||
raise subprocess.TimeoutExpired(args, h.pip_timeouts[-1])
|
||||
ok, detail, host = _rollback_with_pip(tmp_path, hangs, web_requirements_too=True)
|
||||
assert ok and 'requirements.txt' in detail and 'web_interface/requirements.txt' in detail
|
||||
assert sum(host.pip_timeouts) <= av.PIP_BUDGET_SECONDS
|
||||
assert len(host.pip_installs) == 1, "the first file used the whole budget"
|
||||
|
||||
|
||||
def test_the_worst_case_fits_the_unit_time_limit():
|
||||
"""systemd kills the check at TimeoutStartSec, mid-rollback, and the update
|
||||
then sits in "verifying" until the web interface calls it lost."""
|
||||
from web_interface import auto_update as au
|
||||
service = (ROOT / 'systemd' / 'ledmatrix-update-verify.service').read_text(encoding='utf-8')
|
||||
minutes = int(re.search(r'^TimeoutStartSec=(\d+)min$', service, re.M).group(1))
|
||||
assert av.WORST_CASE_SECONDS < minutes * 60
|
||||
assert minutes * 60 < au.VERIFY_LOST_SECONDS, "the web UI must not call a running check lost"
|
||||
|
||||
|
||||
def test_sudo_refusal_wording_matches_permission_utils():
|
||||
from src.common import permission_utils
|
||||
assert set(av.SUDO_REFUSAL_PHRASES) == set(permission_utils.SUDO_REFUSAL_PHRASES)
|
||||
|
||||
|
||||
def test_still_broken_after_rolling_back_is_rollback_failed(tmp_path):
|
||||
code, result, host, head, old, new = check(tmp_path, 'always')
|
||||
assert code == 1 and result['status'] == 'rollback_failed'
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""BackgroundDataService recovers from ESPN rejecting a date range.
|
||||
|
||||
This is the path that broke on the Pi: NFLRecentManager submits the whole
|
||||
season as ``dates=20260801-20270301``, the service fetches it on a worker
|
||||
thread, and from 2026-09-15 every one of those fetches came back
|
||||
``400 Client Error: Bad Request``. The service must re-ask in chunks and cache
|
||||
the merged season rather than mark it failed.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.background_data_service import BackgroundDataService
|
||||
from src.common import espn_dates
|
||||
from src.common.espn_dates import (
|
||||
ESPN_MAX_LIMIT,
|
||||
fetch_espn_scoreboard,
|
||||
parse_espn_date_range,
|
||||
)
|
||||
|
||||
URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else {"events": []}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(str(self.status_code) + " Client Error: Bad Request")
|
||||
|
||||
|
||||
class RangeRejectingSession:
|
||||
"""Rejects day ranges the way ESPN has since 2026-09-15."""
|
||||
|
||||
def __init__(self, events_by_chunk=None, fail_chunks=()):
|
||||
self.calls = []
|
||||
self.events_by_chunk = events_by_chunk or {}
|
||||
self.fail_chunks = set(fail_chunks)
|
||||
|
||||
def get(self, url, params=None, headers=None, timeout=None):
|
||||
params = dict(params or {})
|
||||
dates = str(params.get("dates", ""))
|
||||
self.calls.append(params)
|
||||
if parse_espn_date_range(dates) is not None:
|
||||
return FakeResponse(400)
|
||||
if dates in self.fail_chunks:
|
||||
return FakeResponse(500)
|
||||
return FakeResponse(200, {"events": self.events_by_chunk.get(dates, [])})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ranges_not_yet_rejected(monkeypatch):
|
||||
# The "ranges are rejected" memo is process-wide; every test starts clean.
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
manager = MagicMock()
|
||||
manager.get.return_value = None # always a miss: force the fetch path
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(cache):
|
||||
svc = BackgroundDataService(cache, max_workers=1, request_timeout=5)
|
||||
yield svc
|
||||
svc.shutdown(wait=False)
|
||||
|
||||
|
||||
def submit_and_wait(service, session, dates, limit=1000):
|
||||
results = []
|
||||
with patch.object(service, "session", session):
|
||||
request_id = service.submit_fetch_request(
|
||||
sport="nfl",
|
||||
year=2026,
|
||||
url=URL,
|
||||
cache_key="nfl_schedule_2026",
|
||||
params={"dates": dates, "limit": limit},
|
||||
max_retries=0,
|
||||
callback=results.append,
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(request_id) and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert results, "the fetch never completed"
|
||||
return results[0]
|
||||
|
||||
|
||||
def test_the_service_advertises_that_it_handles_ranges():
|
||||
# Plugins read this to decide whether to submit a season range here or
|
||||
# fetch it themselves on a core that predates the fix.
|
||||
assert BackgroundDataService.handles_espn_date_ranges is True
|
||||
|
||||
|
||||
def test_a_rejected_season_is_recovered_and_cached(service, cache):
|
||||
session = RangeRejectingSession(
|
||||
{"202609": [{"id": "a"}, {"id": "b"}], "20261001": [{"id": "c"}]}
|
||||
)
|
||||
result = submit_and_wait(service, session, "20260901-20261001")
|
||||
|
||||
assert result.success, result.error
|
||||
cached_key, cached_payload = cache.set.call_args[0][:2]
|
||||
assert cached_key == "nfl_schedule_2026"
|
||||
assert [event["id"] for event in cached_payload["events"]] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_a_full_season_costs_chunks_not_one_request_per_day(service):
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
submit_and_wait(service, session, "20260801-20270301")
|
||||
sent = [call["dates"] for call in session.calls]
|
||||
# Eight chunks rather than 213 per-day requests. They are fetched
|
||||
# concurrently, so the range is the only one pinned to a position.
|
||||
assert sent[0] == "20260801-20270301"
|
||||
assert sorted(sent[1:]) == [
|
||||
"202608",
|
||||
"202609",
|
||||
"202610",
|
||||
"202611",
|
||||
"202612",
|
||||
"202701",
|
||||
"202702",
|
||||
"20270301",
|
||||
]
|
||||
|
||||
|
||||
def test_the_limit_that_truncates_never_reaches_espn(service):
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
submit_and_wait(service, session, "20260901-20260930", limit=1000)
|
||||
assert all(call["limit"] == ESPN_MAX_LIMIT for call in session.calls)
|
||||
|
||||
|
||||
def test_a_non_scoreboard_endpoint_keeps_its_limit(service):
|
||||
# /teams needs limit=1000: college football has 762 teams, and limit=500
|
||||
# returns 500 of them. Only scoreboards truncate above 500.
|
||||
session = RangeRejectingSession()
|
||||
with patch.object(service, "session", session):
|
||||
request_id = service.submit_fetch_request(
|
||||
sport="ncaa_fb",
|
||||
year=2026,
|
||||
url="https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams",
|
||||
cache_key="ncaa_fb_teams",
|
||||
params={"limit": 1000},
|
||||
max_retries=0,
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(request_id) and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert session.calls[0]["limit"] == 1000
|
||||
|
||||
|
||||
def test_losing_every_chunk_is_a_failure_not_an_empty_season(service, cache):
|
||||
session = RangeRejectingSession(fail_chunks={"202609"})
|
||||
result = submit_and_wait(service, session, "20260901-20260930")
|
||||
# An empty payload would be cached and read as "no games all year".
|
||||
assert not result.success
|
||||
cache.set.assert_not_called()
|
||||
|
||||
|
||||
def test_a_400_on_a_single_day_is_still_a_failure(service, cache):
|
||||
class AlwaysBad(RangeRejectingSession):
|
||||
def get(self, url, params=None, headers=None, timeout=None):
|
||||
self.calls.append(dict(params or {}))
|
||||
return FakeResponse(400)
|
||||
|
||||
session = AlwaysBad()
|
||||
result = submit_and_wait(service, session, "20260913")
|
||||
assert not result.success
|
||||
assert len(session.calls) == 1
|
||||
cache.set.assert_not_called()
|
||||
|
||||
|
||||
def test_a_rejection_seen_by_the_service_is_remembered_for_scoreboards(service):
|
||||
submit_and_wait(service, RangeRejectingSession({"202609": [{"id": "a"}]}),
|
||||
"20260901-20260930")
|
||||
|
||||
# A live scoreboard asking for a range next must not spend a doomed 400.
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20260930"})
|
||||
assert [call["dates"] for call in session.calls] == ["202609"]
|
||||
assert [event["id"] for event in data["events"]] == ["a"]
|
||||
|
||||
|
||||
def test_a_rejection_seen_by_a_scoreboard_skips_the_range_in_the_service(service, cache):
|
||||
fetch_espn_scoreboard(RangeRejectingSession(), URL,
|
||||
params={"dates": "20260901-20260930"})
|
||||
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
result = submit_and_wait(service, session, "20260901-20261001")
|
||||
assert result.success, result.error
|
||||
assert [call["dates"] for call in session.calls] == ["202609", "20261001"]
|
||||
|
||||
|
||||
def test_known_rejection_with_every_chunk_failing_asks_the_range_once(service, cache):
|
||||
espn_dates._note_range_rejected()
|
||||
session = RangeRejectingSession(fail_chunks={"202609"})
|
||||
result = submit_and_wait(service, session, "20260901-20260930")
|
||||
|
||||
assert not result.success
|
||||
# Chunks once, then the range for a real error -- not the chunks again.
|
||||
assert [call["dates"] for call in session.calls] == ["202609", "20260901-20260930"]
|
||||
cache.set.assert_not_called()
|
||||
|
||||
|
||||
def test_ranges_are_tried_again_once_the_memo_expires(service, monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", time.monotonic() - 1)
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
submit_and_wait(service, session, "20260901-20260930")
|
||||
assert [call["dates"] for call in session.calls] == ["20260901-20260930", "202609"]
|
||||
@@ -3,9 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -300,6 +303,11 @@ def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> N
|
||||
assert any("unsafe" in e.lower() for e in result.errors)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="simulates root-owned POSIX files with chmod 0o444, which on Windows sets the "
|
||||
"read-only attribute, and Windows refuses to rename over a read-only file",
|
||||
)
|
||||
def test_restore_over_a_file_the_user_cannot_write(
|
||||
project: Path, empty_project: Path, tmp_path: Path
|
||||
) -> None:
|
||||
@@ -334,3 +342,53 @@ def test_restore_over_a_file_the_user_cannot_write(
|
||||
|
||||
# The destination's mode is preserved rather than widened to the umask.
|
||||
assert stat.S_IMODE((empty_project / "config" / "config_secrets.json").stat().st_mode) == 0o444
|
||||
|
||||
|
||||
def _existing_config(empty_project: Path) -> None:
|
||||
(empty_project / "config").mkdir(parents=True, exist_ok=True)
|
||||
for name in ("config.json", "config_secrets.json", "wifi_config.json", "ytm_auth.json"):
|
||||
(empty_project / "config" / name).write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
def test_restore_over_existing_files_without_os_chown(
|
||||
project: Path, empty_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Restore must work where the OS has no file ownership API (Windows).
|
||||
|
||||
Replacing a file tries to carry its previous owner across with os.chown.
|
||||
That name does not exist on Windows, and the AttributeError is not an
|
||||
OSError, so it escaped every per-section handler: restoring over any
|
||||
existing config aborted the whole restore and left the old files in place.
|
||||
"""
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
_existing_config(empty_project)
|
||||
monkeypatch.delattr(os, "chown", raising=False)
|
||||
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions())
|
||||
|
||||
assert result.success, result.errors
|
||||
for section in ("config", "secrets", "wifi", "ytm_auth"):
|
||||
assert section in result.restored, f"{section} not restored: {result.errors}"
|
||||
restored = json.loads((empty_project / "config" / "config.json").read_text())
|
||||
assert restored["my-plugin"]["favorites"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_restore_still_carries_the_previous_owner_across(
|
||||
project: Path, empty_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Where os.chown exists, the replaced file keeps the old file's owner."""
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
_existing_config(empty_project)
|
||||
target = empty_project / "config" / "config.json"
|
||||
old = target.stat()
|
||||
chown = MagicMock()
|
||||
monkeypatch.setattr(os, "chown", chown, raising=False)
|
||||
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions(
|
||||
restore_secrets=False, restore_wifi=False,
|
||||
restore_fonts=False, restore_plugin_uploads=False, reinstall_plugins=False,
|
||||
))
|
||||
|
||||
assert result.success, result.errors
|
||||
owners = {(c.args[1], c.args[2]) for c in chown.call_args_list}
|
||||
assert owners == {(old.st_uid, old.st_gid)}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests that the web interface can read what the display service caches.
|
||||
|
||||
The display service runs as root and the web interface as the installing user;
|
||||
cache files are 0660, so the web interface reads them only through their group.
|
||||
The installers made that the directory's group via setgid, but the web unit's
|
||||
CacheDirectory= let systemd re-own /var/cache/ledmatrix to the web user and its
|
||||
primary group on first start, erasing it. Every file root wrote afterwards was
|
||||
root:root 0660:
|
||||
|
||||
WARNING - Permission denied loading cache for display_current_state from
|
||||
/var/cache/ledmatrix/display_current_state.json: [Errno 13] Permission denied
|
||||
|
||||
Measured on one rig: 365 unreadable files, and the web UI's display status,
|
||||
on-demand state and plugin health all silently empty.
|
||||
|
||||
Most of these need POSIX ownership calls, so they skip on Windows. The ones
|
||||
that only make sense as root (another user reading, another user's files) skip
|
||||
without it; run them with `sudo python3 -m pytest test/test_cache_shared_group.py`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache import disk_cache as disk_cache_module
|
||||
from src.cache.disk_cache import DiskCache
|
||||
|
||||
posix_only = pytest.mark.skipif(
|
||||
not hasattr(os, 'fchown') or not hasattr(os, 'geteuid'),
|
||||
reason="needs POSIX file ownership")
|
||||
|
||||
IS_ROOT = hasattr(os, 'geteuid') and os.geteuid() == 0
|
||||
root_only = pytest.mark.skipif(not IS_ROOT, reason="needs root")
|
||||
|
||||
# Ids nothing on a test host is likely to use, for root-only scenarios.
|
||||
OTHER_GID = 48213
|
||||
OTHER_UID = 48214
|
||||
|
||||
|
||||
def _another_group():
|
||||
"""A group this process may chown its files to, other than its own."""
|
||||
if IS_ROOT:
|
||||
return OTHER_GID
|
||||
for gid in os.getgroups():
|
||||
if gid != os.getegid():
|
||||
return gid
|
||||
pytest.skip("process belongs to no supplementary group")
|
||||
|
||||
|
||||
def _shared_dir(path, gid, mode=0o775):
|
||||
"""The rig's broken layout: group-writable, no setgid, a foreign group."""
|
||||
os.chown(path, -1, gid)
|
||||
os.chmod(path, mode)
|
||||
assert not os.stat(path).st_mode & stat.S_ISGID
|
||||
return path
|
||||
|
||||
|
||||
@posix_only
|
||||
class TestNewFilesTakeTheDirectoryGroup:
|
||||
def test_without_setgid(self, tmp_path):
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
|
||||
|
||||
cache.set('display_current_state', {'mode': 'clock'})
|
||||
|
||||
st = os.stat(tmp_path / 'display_current_state.json')
|
||||
assert st.st_gid == gid
|
||||
assert stat.S_IMODE(st.st_mode) == 0o660
|
||||
|
||||
def test_the_file_is_never_visible_with_a_narrower_mode(self, tmp_path, monkeypatch):
|
||||
"""mkstemp creates 0600; the rename used to publish it before the chmod."""
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
|
||||
seen = []
|
||||
real_replace = os.replace
|
||||
|
||||
def spy(src, dst):
|
||||
st = os.stat(src)
|
||||
seen.append((stat.S_IMODE(st.st_mode), st.st_gid))
|
||||
return real_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(disk_cache_module.os, 'replace', spy)
|
||||
cache.set('k', {'v': 1})
|
||||
|
||||
assert seen == [(0o660, gid)]
|
||||
|
||||
def test_a_rewrite_fixes_a_file_left_with_the_wrong_group(self, tmp_path):
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
|
||||
path = tmp_path / 'k.json'
|
||||
path.write_text('{}')
|
||||
os.chown(path, -1, os.getegid())
|
||||
|
||||
cache.set('k', {'v': 2})
|
||||
|
||||
assert os.stat(path).st_gid == gid
|
||||
|
||||
def test_the_direct_write_fallback_shares_too(self, tmp_path, monkeypatch):
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
|
||||
|
||||
def no_temp(*args, **kwargs):
|
||||
raise OSError("no temp files")
|
||||
|
||||
monkeypatch.setattr(disk_cache_module.tempfile, 'mkstemp', no_temp)
|
||||
cache.set('k', {'v': 3})
|
||||
|
||||
st = os.stat(tmp_path / 'k.json')
|
||||
assert (stat.S_IMODE(st.st_mode), st.st_gid) == (0o660, gid)
|
||||
|
||||
def test_a_directory_not_shared_with_its_group_is_left_alone(self, tmp_path):
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid, mode=0o755)))
|
||||
|
||||
cache.set('k', {'v': 4})
|
||||
|
||||
assert os.stat(tmp_path / 'k.json').st_gid == os.getegid()
|
||||
|
||||
|
||||
@posix_only
|
||||
class TestExistingFilesAreRepaired:
|
||||
def test_a_root_style_file_becomes_readable(self, tmp_path):
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
|
||||
path = tmp_path / 'display_on_demand_state.json'
|
||||
path.write_text('{}')
|
||||
os.chown(path, -1, os.getegid())
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
assert cache.share_existing_files() == 1
|
||||
|
||||
st = os.stat(path)
|
||||
assert (stat.S_IMODE(st.st_mode), st.st_gid) == (0o660, gid)
|
||||
|
||||
def test_files_already_shared_are_not_counted(self, tmp_path):
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
|
||||
cache.set('k', {'v': 1})
|
||||
|
||||
assert cache.share_existing_files() == 0
|
||||
|
||||
def test_only_json_files(self, tmp_path):
|
||||
gid = _another_group()
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, gid)))
|
||||
other = tmp_path / 'tile.png'
|
||||
other.write_bytes(b'x')
|
||||
os.chown(other, -1, os.getegid())
|
||||
|
||||
cache.share_existing_files()
|
||||
|
||||
assert os.stat(other).st_gid == os.getegid()
|
||||
|
||||
def test_a_symlink_is_not_followed(self, tmp_path):
|
||||
"""The directory is writable by the web user; root must not be aimed elsewhere."""
|
||||
gid = _another_group()
|
||||
cache_dir = tmp_path / 'cache'
|
||||
cache_dir.mkdir()
|
||||
_shared_dir(cache_dir, gid)
|
||||
outside = tmp_path / 'outside'
|
||||
outside.write_text('secret')
|
||||
os.chown(outside, -1, os.getegid())
|
||||
os.chmod(outside, 0o600)
|
||||
(cache_dir / 'evil.json').symlink_to(outside)
|
||||
|
||||
assert DiskCache(str(cache_dir)).share_existing_files() == 0
|
||||
|
||||
st = os.stat(outside)
|
||||
assert (stat.S_IMODE(st.st_mode), st.st_gid) == (0o600, os.getegid())
|
||||
|
||||
def test_a_hard_linked_file_is_skipped(self, tmp_path):
|
||||
gid = _another_group()
|
||||
cache_dir = tmp_path / 'cache'
|
||||
cache_dir.mkdir()
|
||||
_shared_dir(cache_dir, gid)
|
||||
outside = tmp_path / 'outside'
|
||||
outside.write_text('secret')
|
||||
os.chown(outside, -1, os.getegid())
|
||||
os.chmod(outside, 0o600)
|
||||
os.link(outside, cache_dir / 'linked.json')
|
||||
|
||||
assert DiskCache(str(cache_dir)).share_existing_files() == 0
|
||||
assert stat.S_IMODE(os.stat(outside).st_mode) == 0o600
|
||||
|
||||
@root_only
|
||||
def test_another_users_file_is_skipped(self, tmp_path):
|
||||
cache = DiskCache(str(_shared_dir(tmp_path, OTHER_GID)))
|
||||
path = tmp_path / 'theirs.json'
|
||||
path.write_text('{}')
|
||||
os.chown(path, OTHER_UID, 0)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
assert cache.share_existing_files() == 0
|
||||
assert os.stat(path).st_gid == 0
|
||||
|
||||
|
||||
@posix_only
|
||||
@root_only
|
||||
def test_a_non_root_member_of_the_group_can_read_what_root_wrote():
|
||||
"""The rig, end to end: root writes, the web user reads."""
|
||||
# Not tmp_path: pytest's root-owned base directory is 0700, which the
|
||||
# reader could not traverse no matter what the cache file's group was.
|
||||
base = tempfile.mkdtemp()
|
||||
try:
|
||||
_root_writes_web_user_reads(Path(base))
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
def _root_writes_web_user_reads(base):
|
||||
os.chmod(base, 0o755)
|
||||
cache_dir = base / 'cache'
|
||||
cache_dir.mkdir()
|
||||
# What systemd's CacheDirectory= left behind: the web user's own group.
|
||||
os.chown(cache_dir, OTHER_UID, OTHER_GID)
|
||||
os.chmod(cache_dir, 0o775)
|
||||
DiskCache(str(cache_dir)).set('display_current_state', {'mode': 'clock'})
|
||||
|
||||
pid = os.fork()
|
||||
if pid == 0: # pragma: no cover - child
|
||||
code = 1
|
||||
try:
|
||||
os.setgroups([])
|
||||
os.setgid(OTHER_GID)
|
||||
os.setuid(OTHER_UID)
|
||||
with open(cache_dir / 'display_current_state.json', 'rb') as f:
|
||||
code = 0 if b'clock' in f.read() else 2
|
||||
except PermissionError:
|
||||
code = 13
|
||||
finally:
|
||||
os._exit(code)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
assert os.WEXITSTATUS(status) == 0, "web user could not read the display's cache file"
|
||||
|
||||
|
||||
def test_the_web_unit_does_not_let_systemd_re_own_the_cache():
|
||||
template = Path(__file__).resolve().parent.parent / 'systemd' / 'ledmatrix-web.service'
|
||||
directives = [line.strip() for line in template.read_text(encoding='utf-8').splitlines()
|
||||
if line.strip() and not line.strip().startswith('#')]
|
||||
offending = [d for d in directives if d.startswith(('CacheDirectory', 'StateDirectory'))]
|
||||
assert not offending, (
|
||||
f"{offending}: systemd re-owns that directory to the web user, and the "
|
||||
"display service (root) shares it")
|
||||
|
||||
|
||||
def test_the_cleanup_thread_repairs_existing_files(tmp_path, monkeypatch):
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
|
||||
monkeypatch.setattr(DiskCache, 'share_existing_files',
|
||||
lambda self: calls.append(self.cache_dir) or 0)
|
||||
CacheManager._cleanup_owners.clear()
|
||||
manager = CacheManager()
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not calls and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
finally:
|
||||
manager.stop_cleanup_thread()
|
||||
CacheManager._cleanup_owners.clear()
|
||||
|
||||
assert calls == [str(tmp_path)]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""src/common must stay importable without display hardware.
|
||||
|
||||
Plugins import src.common.sports_* in place of their bundled copies. If any of
|
||||
those modules reaches src.display_manager (and through it rgbmatrix) or the
|
||||
src.base_classes package (whose core.py imports DisplayManager), a scoreboard
|
||||
adopting it acquires a hardware dependency it never had, and the headless
|
||||
tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops
|
||||
being able to load it.
|
||||
|
||||
Two checks, because each misses what the other catches:
|
||||
|
||||
1. A real import in a fresh interpreter with rgbmatrix made unimportable.
|
||||
Catches indirect imports (common -> X -> display_manager) that no text scan
|
||||
of src/common would see.
|
||||
2. An AST scan of src/common/*.py for module-level imports of the hardware and
|
||||
plugin-system packages. Catches a direct import even where the target
|
||||
happens to import cleanly on this machine.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
COMMON = REPO_ROOT / "src" / "common"
|
||||
|
||||
FORBIDDEN = ("src.base_classes", "src.display_manager", "src.plugin_system")
|
||||
|
||||
#: Existing module-level violations, by file name, each with the reason it is
|
||||
#: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers):
|
||||
#: no src/common module imported any of FORBIDDEN at module level. Add to this
|
||||
#: only with a comment saying why, never to get a new import past the test.
|
||||
ALLOWLIST = {}
|
||||
|
||||
|
||||
def test_sports_modules_import_without_hardware():
|
||||
modules = sorted(p.stem for p in COMMON.glob("sports_*.py"))
|
||||
assert "sports_helpers" in modules
|
||||
script = textwrap.dedent(f"""
|
||||
import importlib, json, sys
|
||||
sys.modules["rgbmatrix"] = None # any import of it now raises
|
||||
importlib.import_module("src.common")
|
||||
for name in {modules!r}:
|
||||
importlib.import_module("src.common." + name)
|
||||
loaded = sorted(m for m in sys.modules
|
||||
if m.startswith({FORBIDDEN!r}))
|
||||
print(json.dumps(loaded))
|
||||
""")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script], cwd=str(REPO_ROOT),
|
||||
capture_output=True, text=True, timeout=120)
|
||||
assert result.returncode == 0, (
|
||||
f"importing src.common without rgbmatrix failed:\n{result.stderr}")
|
||||
loaded = result.stdout.strip().splitlines()[-1]
|
||||
assert loaded == "[]", f"src.common pulled in hardware modules: {loaded}"
|
||||
|
||||
|
||||
def _module_level_imports(tree):
|
||||
"""Imports executed at import time: top level, including inside if/try
|
||||
blocks, but not inside functions or classes (those are deferred)."""
|
||||
stack = list(tree.body)
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
yield node
|
||||
elif isinstance(node, (ast.If, ast.Try, ast.With)):
|
||||
stack.extend(node.body)
|
||||
stack.extend(getattr(node, "orelse", []))
|
||||
stack.extend(getattr(node, "finalbody", []))
|
||||
for handler in getattr(node, "handlers", []):
|
||||
stack.extend(handler.body)
|
||||
|
||||
|
||||
def _targets(node, package="src.common"):
|
||||
"""Absolute dotted names an import can load. Relative imports resolve
|
||||
against ``package``, the scanned module's package."""
|
||||
if isinstance(node, ast.Import):
|
||||
return [alias.name for alias in node.names]
|
||||
base = node.module
|
||||
if node.level:
|
||||
parts = package.split(".")
|
||||
if node.level > len(parts):
|
||||
return [] # beyond the top-level package; Python rejects it too
|
||||
anchor = ".".join(parts[:len(parts) - node.level + 1])
|
||||
base = f"{anchor}.{node.module}" if node.module else anchor
|
||||
if base is None:
|
||||
return []
|
||||
# `from pkg import name` may load the submodule pkg.name.
|
||||
return [base] + [f"{base}.{alias.name}" for alias in node.names
|
||||
if alias.name != "*"]
|
||||
|
||||
|
||||
def _violations():
|
||||
found = {}
|
||||
for path in sorted(COMMON.glob("*.py")):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in _module_level_imports(tree):
|
||||
for target in _targets(node, "src.common"):
|
||||
if target == FORBIDDEN or any(
|
||||
target == f or target.startswith(f + ".") for f in FORBIDDEN):
|
||||
found.setdefault(path.name, []).append(f"line {node.lineno}: {target}")
|
||||
return found
|
||||
|
||||
|
||||
def test_no_module_level_hardware_or_plugin_system_imports():
|
||||
found = {name: hits for name, hits in _violations().items() if name not in ALLOWLIST}
|
||||
assert found == {}, (
|
||||
"src/common must not import these at module level (defer the import "
|
||||
f"into the function that needs it): {found}")
|
||||
|
||||
|
||||
def test_allowlist_has_no_stale_entries():
|
||||
stale = sorted(set(ALLOWLIST) - set(_violations()))
|
||||
assert stale == [], f"allowlisted but no longer violating, remove: {stale}"
|
||||
|
||||
|
||||
def test_the_scan_sees_a_direct_import(tmp_path):
|
||||
# Guard the guard: the walker must find imports nested in try/if.
|
||||
tree = ast.parse(textwrap.dedent("""
|
||||
try:
|
||||
from src.display_manager import DisplayManager
|
||||
except ImportError:
|
||||
pass
|
||||
if True:
|
||||
import src.base_classes.sports
|
||||
from src import plugin_system
|
||||
def later():
|
||||
from src.display_manager import DisplayManager
|
||||
"""))
|
||||
targets = {t for n in _module_level_imports(tree) for t in _targets(n)}
|
||||
assert {"src.base_classes.sports", "src.display_manager",
|
||||
"src.plugin_system"} <= targets
|
||||
|
||||
|
||||
def test_the_scan_resolves_relative_imports():
|
||||
# A module in src/common reaches src.plugin_system through `..`.
|
||||
tree = ast.parse(textwrap.dedent("""
|
||||
from .. import plugin_system
|
||||
from ..plugin_system import plugin_manager
|
||||
from . import sports_shared
|
||||
"""))
|
||||
targets = {t for n in _module_level_imports(tree)
|
||||
for t in _targets(n, "src.common")}
|
||||
assert {"src.plugin_system", "src.plugin_system.plugin_manager",
|
||||
"src.common.sports_shared"} <= targets
|
||||
# The package decides where `..` points.
|
||||
node = ast.parse("from .. import plugin_system").body[0]
|
||||
assert _targets(node, "src.common") == ["src", "src.plugin_system"]
|
||||
assert _targets(node, "a.b") == ["a", "a.plugin_system"]
|
||||
|
||||
|
||||
def test_the_scan_flags_relative_imports_in_src_common(monkeypatch, tmp_path):
|
||||
# End to end through _violations(): both relative forms are reported.
|
||||
(tmp_path / "bad.py").write_text(
|
||||
"from .. import plugin_system\nfrom ..plugin_system import x\n",
|
||||
encoding="utf-8")
|
||||
monkeypatch.setitem(globals(), "COMMON", tmp_path)
|
||||
found = {name: sorted(hits) for name, hits in _violations().items()}
|
||||
assert found == {"bad.py": ["line 1: src.plugin_system",
|
||||
"line 2: src.plugin_system",
|
||||
"line 2: src.plugin_system.x"]}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user