Compare commits

..
8 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5 7ae614aa35 fix(sports): recover from ESPN rejecting scoreboard date ranges (#591)
* fix(sports): recover from ESPN rejecting scoreboard date ranges

Since 2026-09-15 ESPN's site API answers `dates=YYYYMMDD-YYYYMMDD` with
400 "Failed to get events endpoint." for every sport. Single days, months
(`YYYYMM`) and season years still work. Every season and weeks-window fetch
in core failed, including the background service the scoreboards submit
their season schedules to.

src/common/espn_dates.py re-asks a rejected range as whole-month chunks
plus the leftover edge days, which tile the window exactly (a season is
8 requests, not 213). A month that comes back with exactly 500 events is
truncated (college baseball's March) and is re-asked day by day.

It also clamps `limit` to 500: above that ESPN truncates silently, e.g.
college football returns 25 of 68 games for one Saturday at limit=1000.

BackgroundDataService recovers rejected ranges on the worker thread and
advertises `handles_espn_date_ranges` so plugins can tell whether to hand
it a range. SportsCore, sports_shared, ESPNDataSource and APIHelper route
through the helper or the clamped limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): ESPN date-range fallback and limit clamp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(sports): stop re-sending ESPN date ranges once one is rejected

Live scoreboards refresh every 30 seconds, and each refresh sent the range
first, got the 400, then fetched the chunks: three requests where one used
to do. After a rejection, ranges now go straight to chunks for six hours,
then the range is tried again so the workaround retires itself if ESPN
reverts. A single-day 400 does not set the memo, and when every chunk fails
the range request supplies the error without the chunks being fetched a
second time. Per-fetch chunk logging drops to debug; the rejection itself
stays a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(sports): clamp limit only on ESPN scoreboard submissions

The background service is generic, and limit above 500 only truncates
scoreboards. /teams needs limit=1000 (college football has 762 teams and
limit=500 returns 500), so a teams submission must keep its limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:12:28 -04:00
ChuckandClaude Opus 5 2082665252 fix(config): load config_secrets.json on hosts without os.geteuid (#590)
ensure_shared_group_ownership() - the chgrp self-heal ConfigManager runs
before reading config_secrets.json (#416) - looked up os.geteuid
unguarded. That name does not exist on Windows, and the AttributeError
is not an OSError, so it escaped the helper's best-effort handling and
every except clause in load_config(). Any Windows checkout with a
config/config_secrets.json got a ConfigError from every config load and
could not import web_interface.app.

That is what made test_update_all_plugins.py error at setup: its client
fixture imports web_interface.app. It was not state leaked between test
files - the trigger is whether the checkout has a secrets file.

Return early when os.geteuid or os.chown is missing. No change on POSIX.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:12:18 -04:00
ChuckandClaude Opus 5 f9b3d6ae52 fix(web): accept every panel size and row address type the rgbmatrix library does (#586)
* fix(web): accept every panel size and row address type the rgbmatrix library does

The Display form capped columns at 128 and chain length at 24, and its
submit handler (fixInvalidNumberInputs) rewrote anything larger to the cap,
so wide panels and long chains silently saved as the wrong size. The config
API checked none of the hardware numbers, so values the library rejects (odd
rows, parallel 4, PWM dither bits 3) saved and the matrix then refused to
start.

- Form limits now match the pinned library: rows even 8-64, cols >= 16 and
  chain_length >= 1 with no upper bound, parallel 1-3, PWM dither bits 0-2,
  PWM LSB nanoseconds 50-3000.
- save_main_config rejects out-of-range rows, cols, chain_length, parallel,
  brightness, scan_mode, pwm_bits, pwm_dither_bits, pwm_lsb_nanoseconds and
  gpio_slowdown with a 400.
- A stored gpio_slowdown or pwm_dither_bits of 0 renders as 0 instead of the
  default, so saving the tab no longer overwrites it.
- Row Address Type offers 5 (SM5368 / B707 row shift register). Verified on a
  Waveshare 96x48 V2 (24S-A1) on a Pi 4 with the Adafruit Triple LED Matrix
  Bonnet: rows 48, cols 96, row address type 5, BGR, GPIO slowdown 8.
- Help text and docs: FM6124-family panels use Panel Type Standard; on a Pi 5
  the library supports only row address types 0 and 2.

No change to the rpi-rgb-led-matrix submodule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): drop the rows cap and document every display setting accurately

Rows: no upper limit in the form or the API. Still even and at least 8. The
current rgbmatrix library rejects more than 64 per panel, so a larger value
saves but the matrix won't start; the help tip, README, config reference and
troubleshooting section all say so, and nothing here needs changing if the
library lifts the limit.

limit_refresh_rate_hz: the form accepts 0 (the library's "no cap"), a stored
0 no longer renders and re-saves as 120, and the API rejects negatives.

pwm_dither_bits stays 0-2: the library rejects 3 and 4, so the old form's
0-4 only ever let users save a config the display couldn't start with.

Docs and help tips, checked against the pinned library and its README:
- panel_type and rp1_rio get README entries
- show_refresh_rate prints to stdout; it never drew on the panel
- dither bits raise the refresh rate; the tip said they lowered it
- scan_mode is about interlacing at low refresh, not wrong colours
- disable_hardware_pulsing: hardware pulsing needs OE on GPIO 18 and the
  onboard sound driver off; software timing makes rows flash brighter
- gpio_slowdown guidance agrees between the README and the UI
- all 22 multiplexing values listed; every numeric setting states its range
- troubleshooting for a blank panel after a settings change, jumping rows
  and brightness flashes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): reject true and 5.5 for row_address_type and multiplexing

Both still went straight through int(), so a JSON true saved as 1 and 5.5
as 5. They now use the shared hardware range check like the other panel
fields. Review feedback on #586.

Also: the RP1 Backend tooltip said it is ignored on Pi 3/4 (it is ignored
on every model but the Pi 5), and the README gave the dynamic-duration
default cap as 90s; the code default is 180s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: refuse matrix settings a Raspberry Pi 5 can't drive

On a Pi 5 the pinned rgbmatrix library drives the panel through the RP1
chip, and that path supports only row address types 0 and 2, parallel 1-3
and the regular / regular-pi1 / classic / adafruit-hat(-pwm) mappings
(Rp1PioConfigSupported in lib/rp1/rp1_pio_backend.cc). For anything else
CreateFromOptions returns NULL; the Python binding doesn't check, so the
display process crashed on its first call into the matrix and systemd
restarted it into the same crash every 10 seconds.

- src/pi5_matrix_support.py: the rule and Pi 5 detection, matching the
  library's /proc/device-tree/model check
- DisplayManager raises before creating the matrix, so it is a logged init
  failure (reported by /api/v3/hardware/status) and fallback mode
- the config API rejects those settings on a Pi 5 when a request sets
  row_address_type, parallel or hardware_mapping
- the Display form offers only row address types 0 and 2 on a Pi 5, and
  warns when a stored value can't be used
- CLAUDE.md: re-check the rule whenever the submodule is bumped

Review feedback on #586.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:17:48 -04:00
ChuckandClaude Opus 5 9616a5a054 fix(web): auto_update and other core settings are not orphaned plugins (#589)
v3.4.0 shows "Plugin Config Warning - In config but not installed:
auto_update. Reinstall via the Plugin Store, or remove these entries from
config.json." auto_update is the core weekly-update setting from #581.
Reconciliation treated every top-level dict not in its private
_SYSTEM_CONFIG_KEYS list as a plugin id, and #581 could not know to extend
that list.

- Move core top-level keys into src/core_config_keys.py (CORE_CONFIG_KEYS)
  and use it in reconciliation. Tests fail if a config.template.json key or
  a key written by the general-settings save is missing from it.
- A secrets-file key only counts as a non-plugin when no installed plugin
  has that id. Plugin secrets are namespaced by id, so installed plugins
  with secrets were reported as missing from config on every run.
- still_unresolved() drops "not on disk" findings whose id is no longer a
  plugin entry in config, so a stored verdict clears without a restart.
- A plugin whose id is a core key is skipped with a warning, and the fix
  never writes a plugin stub over or in place of a core setting.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:19:39 -04:00
ChuckandClaude Opus 5 c200b5837d fix(plugins): normalize legacy boolean settings before schema validation (#588)
The news plugin's schema turned global.dynamic_duration from a boolean
into an {enabled, min_duration_seconds, ...} object. Installs that have
not saved the news settings since still hold `true`, so every start
logged "Plugin news config does not match its schema (loading anyway):
Field 'global.dynamic_duration': Expected type object, got bool" and
flagged news degraded.

The settings form already reads such a boolean as {"enabled": <bool>}
(render_nested_section in plugin_config.html) and the next save writes
the object. The loader did not. It now applies the same rule before
merging schema defaults and validating, so the defaults fill in the rest
of the object and the plugin receives it in the new shape.

The rule lives in schema_manager.legacy_bool_as_object /
normalize_legacy_booleans. It applies at any depth of nested objects
but not inside arrays, matching the form, and only to a real bool under
an object-typed property with an `enabled` child. Every other mismatch
still warns. A parity test renders the template macro against the helper
so the two cannot drift.

Nothing is written to config.json at load: the normalization is in
memory, and the next save of the plugin's settings persists the object.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:09:23 -04:00
ChuckandClaude Opus 5 9f2743471c fix(web): update-all skips Starlark apps and no longer misses plugins (#587)
* fix(web): update-all skips Starlark apps and no longer misses plugins

Check & Update All posted every entry from /plugins/installed to
POST /plugins/update, including the virtual starlark:<app_id> entries
that list installed Starlark apps. The store manager cannot find those,
so each answered 500 "plugin not found". Update-all now sends only
plugin ids (install_manager.js, and the older app-shell.js copy), and the
route answers a starlark: id with a 400 saying it is a Starlark app.

A request that got no HTTP answer was recorded as failed and never sent
again. On a device, a web-service restart mid-run killed the in-flight
request and refused the next one, stock-news, which was left on 2.6.2
with 2.8.0 available. Such requests are now re-sent with backoff
(about 30s) before being reported as failed. HTTP error answers are not
retried.

Tests: test/js/unit/test_update_all.js (run from pytest via
test/web_interface/test_update_all_plugins.py so CI covers it) and the
route contract for starlark: ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(web): walk update-all retry delays without indexed lookup

Codacy's ESLint security/detect-object-injection rule flagged
retryDelays[attempt] as a High issue. The index was a bounded loop
counter over a fixed array, but shifting a per-plugin copy of the
schedule gives the same backoff without the pattern. No behaviour
change: test/js/unit/test_update_all.js (21) and
test/web_interface/test_update_all_plugins.py (7) pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:09:11 -04:00
ChuckandClaude Opus 5 fddb0e06db feat(web): honour x-display: hidden in plugin settings (#585)
* feat(web): honour x-display: hidden in plugin settings

Plugins keep deprecated and internal keys declared so stored configs keep
validating (weather api_key/radar_zoom, countdown's auto-generated row id),
but the settings form drew them as live controls.

A property marked "x-display": "hidden" -- or an object whose children are
all hidden -- now gets no control at any depth: top level, nested sections,
Advanced Settings (not counted either), array-table columns and the row
editor. A hidden top-level key is not reported in __rendered_section.

Saving never changes a hidden value. Plain and nested fields aren't posted,
so the save's deep merge keeps them; _set_missing_booleans_to_false skips
hidden booleans at every depth. A posted array row replaces the stored item,
so hidden row properties are carried as JSON-encoded hidden inputs and
decoded exactly on save (an id "1" stays a string). New rows get none.
JSON API saves are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(web): read hidden row keys without dynamic property access

Build the set of x-display: hidden item properties once and look values up
through Object.entries, instead of indexing objects by a variable key on
the lines this branch added (Codacy: object injection sink, 6 warnings).
Behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:47:06 -04:00
ChuckandClaude Opus 5 8360220809 feat(common): sports_helpers — the helpers all nine scoreboards carry identical copies of (#583)
* feat(common): sports_helpers, the helpers all nine scoreboards copy verbatim

Add src/common/sports_helpers.py: the helpers the scoreboard plugins'
sports.py carry byte-identical copies of (docstring-stripped AST, checked at
ledmatrix-plugins f09bff2), so a later plugins PR can delete its copies once
it floors on the core release that ships this.

- Free functions: clamp_window, clamp_seconds, logo_needs_refresh (lazy
  src.logo_downloader import, as in the plugins), spread_weighted_order,
  MIN_WINDOW_DAYS / MAX_WINDOW_DAYS. All nine plugins.
- SportsHelpersMixin (no __init__, stateless): _mode_customization,
  _setting_int, _reset_dwell_on_reentry, _next_switch_index,
  _spread_weighted_order (all nine), _odds_color and
  _upcoming_date_and_time_text (all but ufc), plus the _favorite_key seam
  from base_classes core.py for later phases.

A new module rather than more methods on sports_shared: a plugin that
deletes a copy and relies on an existing module having grown the method
fails at runtime with AttributeError on an older core, which neither the
loader nor check_min_core_version.py can see; a missing module fails at load.

Tests: behaviour for every helper, a derived host contract, and a parity
test that AST-compares every body against every plugin copy when
LEDMATRIX_PLUGINS points at a checkout (skipped otherwise).
test_common_is_hardware_free.py imports src.common and every sports_* module
with rgbmatrix blocked and scans src/common for module-level imports of
src.base_classes, src.display_manager and src.plugin_system (no existing
violations).

Nothing in core imports the new module; no behaviour change. CHANGELOG
Unreleased entry and a converging note in docs/SPORTS_UNIFICATION.md.
__version__ is not bumped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(common): address review on sports_helpers and the hardware-free test

- SportsHelpersMixin docstring and CHANGELOG: constructor-free, but it keeps
  lazy state on its host (_reset_dwell_on_reentry, _next_switch_index).
- test_common_is_hardware_free: the runtime check now filters every
  FORBIDDEN package, src.plugin_system included; the AST scan resolves
  relative imports against src.common, so `from .. import plugin_system`
  and `from ..plugin_system import x` are caught. Guard tests for both.
- Parity skip reason names the CI guard that runs the same comparison:
  ledmatrix-plugins scripts/check_sports_helpers_parity.py (#495).
- _odds_color: line-level pylint disable for a not-callable false positive
  (getter is None-checked); the AST is unchanged, parity still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:32:21 -04:00
51 changed files with 5089 additions and 222 deletions
+116
View File
@@ -19,6 +19,122 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
New module a plugin may import via `src.*` (floor on the release that ships
this):
- `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.
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.
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. Rows, columns and
chain length now have no upper limit (the current rgbmatrix library still
rejects more than 64 rows per panel); rows must be even and at least 8,
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.
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.
## 3.4.0 ## 3.4.0
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down: Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
+1
View File
@@ -63,3 +63,4 @@
`self.display_manager.image.paste(img, (x, y))` then `update_display()` `self.display_manager.image.paste(img, (x, y))` then `update_display()`
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`) (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 - 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
+94 -48
View File
@@ -494,15 +494,20 @@ These settings control the physical hardware configuration and how the matrix is
- **`rows`** (integer, default: 32) - **`rows`** (integer, default: 32)
- Number of LED rows (vertical pixels) in each panel - Number of LED rows (vertical pixels) in each panel
- Common values: 16, 32, 48, 64 - Common values: 16, 32, 48, 64
- Must be an even number, at least 8. LEDMatrix sets no upper limit, but the
current rgbmatrix library rejects more than 64 rows per panel — the display
then won't start (see Troubleshooting Display Settings below)
- Must match your physical panel configuration - Must match your physical panel configuration
- **`cols`** (integer, default: 64) - **`cols`** (integer, default: 64)
- Number of LED columns (horizontal pixels) in each panel - Number of LED columns (horizontal pixels) in each panel
- Common values: 32, 64, 96, 128 - Common values: 32, 64, 96, 128
- At least 16, with no upper limit
- Must match your physical panel configuration - Must match your physical panel configuration
- **`chain_length`** (integer, default: 2) - **`chain_length`** (integer, default: 2)
- Number of LED panels chained together horizontally - Number of LED panels chained together horizontally
- At least 1, with no upper limit; longer chains lower the refresh rate
- If you have 2 panels side-by-side, set to 2 - If you have 2 panels side-by-side, set to 2
- If you have 4 panels in a row, set to 4 - If you have 4 panels in a row, set to 4
- Total display width = `cols × chain_length` - Total display width = `cols × chain_length`
@@ -511,13 +516,14 @@ These settings control the physical hardware configuration and how the matrix is
- Number of parallel chains (panels stacked vertically) - Number of parallel chains (panels stacked vertically)
- Use 1 for a single row of panels - Use 1 for a single row of panels
- Use 2 if you have panels stacked in two rows - Use 2 if you have panels stacked in two rows
- 1–3 on a Raspberry Pi, and the HAT needs that many outputs
- Total display height = `rows × parallel` - Total display height = `rows × parallel`
#### Brightness and Visual Settings #### Brightness and Visual Settings
- **`brightness`** (integer, 0-100, default: 90) - **`brightness`** (integer, 1-100, default: 90)
- Display brightness level - 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 - Recommended: 70-90 for indoor use, 90-100 for bright environments
- Very high brightness may cause distortion or require more power - Very high brightness may cause distortion or require more power
@@ -527,52 +533,52 @@ These settings control the physical hardware configuration and how the matrix is
- Specifies which GPIO pin mapping to use for your hardware - 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-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. - **`"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) - **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
- Choose the option that matches your specific hardware setup, if aren't sure try them all. - 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 #### PWM (Pulse Width Modulation) Settings
These settings affect color fidelity and smoothness of color transitions: These settings affect color fidelity and smoothness of color transitions:
- **`pwm_bits`** (integer, default: 9) - **`pwm_bits`** (integer, 1-11, default: 9)
- Number of bits used for PWM (affects color depth) - Color depth per channel: how many brightness levels each LED gets
- Higher values (9-11) = more color levels, smoother gradients - Higher values (9-11) = more color levels, smoother gradients, lower refresh rate
- Lower values (7-8) = fewer color levels, but may improve stability on some hardware - Lower values (7-8) = the subtlest shades are dropped for a higher refresh rate; `1` gives 8 colors
- Range: 1-11, recommended: 9-10 - Recommended: 9-10
- **`pwm_dither_bits`** (integer, default: 1) - **`pwm_dither_bits`** (integer, 0-2, default: 1)
- Additional dithering bits for smoother color transitions - Time-dithers the lowest color bits: their brightness comes from showing them on only some frames
- Helps reduce color banding in gradients - Raises the refresh rate; the cost is that dark shades can shimmer slightly
- Higher values (1-2) = smoother gradients but may impact performance - `0` = steadiest dim colors, `2` = fastest
- Range: 0-2, recommended: 1 - The rgbmatrix library accepts only 0-2; a higher value stops the display starting
- **`pwm_lsb_nanoseconds`** (integer, default: 130) - **`pwm_lsb_nanoseconds`** (integer, 50-3000, default: 130)
- Least significant bit timing in nanoseconds - On-time of the least significant color bit; each higher bit doubles it
- Controls the base timing for PWM signals - Lower values = higher refresh rate, but can cost color accuracy or add ghosting on some panels
- Lower values = faster PWM, higher values = slower PWM - Higher values = less ghosting (faint trails behind bright text on black), lower refresh rate
- Typical range: 100-300 nanoseconds - Typical range: 100-300 nanoseconds
- May need adjustment if you see flickering or color issues
#### Advanced Hardware Settings #### Advanced Hardware Settings
- **`scan_mode`** (integer, default: 0) - **`scan_mode`** (integer, 0-1, default: 0)
- Panel scan mode (how rows are addressed) - Order the rows are refreshed in: `0` = progressive, `1` = interlaced
- Common values: 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
- Most panels use 0, but some require 1 - Leave at `0` unless you are tuning a slow setup
- Check your panel datasheet if colors appear incorrect
- **`limit_refresh_rate_hz`** (integer, default: 100) - **`limit_refresh_rate_hz`** (integer, default: 100)
- Maximum refresh rate in Hz (frames per second) - Caps the panel refresh rate in Hz; `0` = no cap
- Caps the refresh rate for better stability - A steady cap reduces flicker caused by other activity on the Pi, and in camera recordings
- Lower values (60-80) = more stable, less CPU usage - 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
- Higher values (100-120) = smoother animations, more CPU usage - If the key is missing from the config, `DisplayManager` uses 90
- Recommended: 80-100 for most setups - Recommended: 80-120. `sudo python3 scripts/scroll_speeds.py --measure` reports the rate your panel really achieves
- **`disable_hardware_pulsing`** (boolean, default: false) - **`disable_hardware_pulsing`** (boolean, default: false)
- Disables hardware pulsing (usually leave as false) - `false` = the Pi's hardware PWM times each brightness pulse; `true` = software timing
- Set to `true` only if you experience timing issues - Leave `false` where possible. Software timing is less exact, so a row, or the whole panel, can briefly flash brighter
- Most users should leave this as `false` - 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) - **`inverse_colors`** (boolean, default: false)
- Inverts all colors (red becomes cyan, etc.) - Inverts all colors (red becomes cyan, etc.)
@@ -580,9 +586,9 @@ These settings affect color fidelity and smoothness of color transitions:
- Set to `true` only if colors appear inverted - Set to `true` only if colors appear inverted
- **`show_refresh_rate`** (boolean, default: false) - **`show_refresh_rate`** (boolean, default: false)
- Displays the current refresh rate on the matrix (for debugging) - Prints the live refresh rate to the console; nothing is drawn on the panel
- Set to `true` to see FPS on the display - Readable when you stop the service and run `sudo python3 run.py` in a terminal; under the service the output is buffered
- Useful for troubleshooting performance issues - `sudo python3 scripts/scroll_speeds.py --measure` is an easier way to see the real refresh rate
#### Advanced Panel Configuration (Advanced Users Only) #### Advanced Panel Configuration (Advanced Users Only)
@@ -592,6 +598,7 @@ These settings are typically only needed for non-standard panels or custom confi
- Color channel order for your LED panel - Color channel order for your LED panel
- Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR" - Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR"
- Most panels use "RGB", but some use "GRB" or other orders - 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 - Check your panel datasheet if colors appear wrong
- **`pixel_mapper_config`** (string, default: "") - **`pixel_mapper_config`** (string, default: "")
@@ -612,29 +619,60 @@ These settings are typically only needed for non-standard panels or custom confi
- **`row_address_type`** (integer, default: 0) - **`row_address_type`** (integer, default: 0)
- How rows are addressed on the panel - How rows are addressed on the panel
- Most panels use 0 (direct addressing) - 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 - Check your panel datasheet if display appears corrupted
- **`multiplexing`** (integer, default: 0) - **`multiplexing`** (integer, 0-22, default: 0)
- Panel multiplexing type - 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 = no multiplexing (standard panels) - `0` = direct (standard indoor panels)
- Higher values for panels with different multiplexing schemes - `1` Stripe, `2` Checkered, `3` Spiral, `4` ZStripe, `5` ZnMirrorZStripe,
- Check your panel datasheet for the correct value `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`) ### Runtime Configuration (`display.runtime`)
These settings control runtime behavior and GPIO timing: These settings control runtime behavior and GPIO timing:
- **`gpio_slowdown`** (integer, default: 3) - **`gpio_slowdown`** (integer, default: 3)
- GPIO timing slowdown factor - 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**: Must match your Raspberry Pi model for stability - **Critical setting**: depends on your Raspberry Pi model and your panel
- **Raspberry Pi 3**: Use 3 - **Raspberry Pi Zero/1**: 0-1
- **Raspberry Pi 4**: Use 4 - **Raspberry Pi 2/3**: 1-3
- **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 4**: 2-4 (the config template ships 3)
- **Raspberry Pi Zero/1**: Use 1-2 - **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
- Incorrect values can cause display corruption, flickering, or system instability - 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 - 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`) ### 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. Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
@@ -667,7 +705,7 @@ Controls how long each installed plugin stays visible in seconds before switchin
- Some plugins can automatically adjust their display time based on content - Some plugins can automatically adjust their display time based on content
- This setting limits how long they can extend (prevents one display from dominating) - 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 - 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 ### Example Configuration
@@ -714,6 +752,14 @@ Controls how long each installed plugin stays visible in seconds before switchin
- Verify `hardware_mapping` matches your HAT/connection type - Verify `hardware_mapping` matches your HAT/connection type
- Try adjusting `gpio_slowdown` - Try adjusting `gpio_slowdown`
- Ensure your display doesn't need the E-Addressable line - Ensure your display doesn't need the E-Addressable line
- If it went blank right after a settings change, check `sudo journalctl -u ledmatrix` for `Failed to initialize RGB Matrix`: the rgbmatrix library refused a value (for example more than 64 `rows`, or `pwm_dither_bits` above 2) and the display fell back to no output. The library's own message nearby names the setting; on a Raspberry Pi 5, LEDMatrix's message names any unsupported `row_address_type`, `parallel` or `hardware_mapping`
- 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:** **Colors are wrong or inverted:**
- Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work) - Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work)
+17 -17
View File
@@ -51,25 +51,25 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
| Key | Type / default | | Key | Type / default |
|---|---| |---|---|
| `rows` / `cols` | int, `32` / `64` | | `rows` / `cols` | int, `32` / `64` — rows: even, at least 8, no upper limit here (the current rgbmatrix library rejects more than 64); cols: at least 16, no upper limit |
| `chain_length` | int, `2` | | `chain_length` | int, `2` — at least 1, no upper limit |
| `parallel` | int, `1` | | `parallel` | int, `1` — 1–3 |
| `brightness` | int, `90` | | `brightness` | int, `90` — 1–100 |
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) | | `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
| `scan_mode` | int, `0` | | `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
| `pwm_bits` | int, `9` (code default 10) | | `pwm_bits` | int, `9` (code default 10) — 1–11 |
| `pwm_dither_bits` | int, `1` | | `pwm_dither_bits` | int, `1` — 0–2 |
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) | | `pwm_lsb_nanoseconds` | int, `130` (code default 150) — 50–3000 |
| `disable_hardware_pulsing` | bool, `false` | | `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` | | `inverse_colors` | bool, `false` |
| `show_refresh_rate` | bool, `false` | | `show_refresh_rate` | bool, `false` — prints the refresh rate to stdout; draws nothing on the panel |
| `led_rgb_sequence` | string, `"RGB"` | | `led_rgb_sequence` | string, `"RGB"` — `"RGB"`, `"RBG"`, `"GRB"`, `"GBR"`, `"BRG"` or `"BGR"` |
| `limit_refresh_rate_hz` | int, `100` (code default 90) | | `limit_refresh_rate_hz` | int, `100` (code default 90) — `0` = no cap; scroll timing assumes 100 Hz when `0` |
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate: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 | | `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 | | `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` — panel multiplexing scheme | | `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 | | `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
Where "code default" differs from the template value, the code default only Where "code default" differs from the template value, the code default only
applies if the key is missing entirely from your config. applies if the key is missing entirely from your config.
@@ -78,8 +78,8 @@ applies if the key is missing entirely from your config.
| Key | Type / default | Meaning | | Key | Type / default | Meaning |
|---|---|---| |---|---|---|
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis | | `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). 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` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) | | `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` ## `display.double_sided`
+4 -4
View File
@@ -83,10 +83,10 @@ You should see:
1. Open the **Display** tab 1. Open the **Display** tab
2. Set your matrix configuration: 2. Set your matrix configuration:
- **Rows**: 32 or 64 (match your hardware) - **Rows**: match your panel — commonly 32 or 64; any even number
- **Columns**: commonly 64 or 96; the web UI accepts any integer from 8 to 64
in the 1–128 range, but 64 and 96 are the values the bundled - **Columns**: match your panel — commonly 64 or 96; at least 16,
panel hardware ships with with no upper limit
- **Chain Length**: Number of panels chained horizontally - **Chain Length**: Number of panels chained horizontally
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper - **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
mod) or `adafruit-hat` (without). See the root README for the full list. mod) or `adafruit-hat` (without). See the root README for the full list.
+19
View File
@@ -80,11 +80,30 @@ src/base_classes/sports/
src/common/ src/common/
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
(content building stays in the plugins) (content building stays in the plugins)
sports_helpers.py clamp/logo/rotation free functions + SportsHelpersMixin
(unreleased) — 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 `from src.base_classes.sports import SportsCore` keeps working — the package
`__init__` re-exports, so the conversion is invisible to every existing importer. `__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) ## Override points (the plugin-facing seam)
The base class calls these; plugins implement or override them. This table is the The base class calls these; plugins implement or override them. This table is the
+14 -6
View File
@@ -132,18 +132,26 @@ require a display service restart from **Overview**.
Configure your LED matrix hardware: Configure your LED matrix hardware:
**Matrix configuration:** **Matrix configuration:**
- `rows` — LED rows (typically 32 or 64) - `rows` — LED rows per panel (typically 32 or 64; even, at least 8 — the
- `cols` — LED columns (typically 64 or 96) 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 - `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), - `hardware_mapping` — `adafruit-hat-pwm` (with PWM jumper mod),
`adafruit-hat` (without), `regular`, or `regular-pi1` `adafruit-hat` (without), `regular` (direct wiring, and the Adafruit Triple
- `gpio_slowdown` — must match your Pi model (3 for Pi 3, 4 for Pi 4, etc.) LED Matrix Bonnet), or `regular-pi1`
- `brightness` — 0–100% - `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 - `pwm_bits`, `pwm_lsb_nanoseconds`, `pwm_dither_bits` — PWM tuning
- Dynamic Duration — global cap for plugins that extend their display - Dynamic Duration — global cap for plugins that extend their display
time based on content 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 **Vegas Scroll Mode:** the Display tab also has a full Vegas Scroll
Mode section — enable toggle, scroll speed, separator width, dynamic Mode section — enable toggle, scroll speed, separator width, dynamic
duration, and related settings — so you can configure Vegas mode duration, and related settings — so you can configure Vegas mode
+36
View File
@@ -281,6 +281,42 @@ Guidelines:
their own collapsible sections) and is safely ignored by older cores, so their own collapsible sections) and is safely ignored by older cores, so
adding it never breaks compatibility. 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 ## Creating Custom Widgets
### Step 1: Create Widget File ### Step 1: Create Widget File
+39 -3
View File
@@ -25,6 +25,7 @@ from enum import Enum
import queue import queue
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
from src.common.espn_dates import clamp_espn_limit, fetch_espn_date_chunks
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,6 +100,11 @@ class BackgroundDataService:
with intelligent caching, retry logic, and progress tracking. 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): def __init__(self, cache_manager: CacheManager, max_workers: int = 3, request_timeout: int = 30):
""" """
Initialize the background data service. Initialize the background data service.
@@ -247,6 +253,12 @@ class BackgroundDataService:
logger.debug(f"Cache hit for {sport} {year} data") logger.debug(f"Cache hit for {sport} {year} data")
return request_id 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 # Create fetch request
request = FetchRequest( request = FetchRequest(
id=request_id, id=request_id,
@@ -254,7 +266,7 @@ class BackgroundDataService:
year=year, year=year,
cache_key=cache_key, cache_key=cache_key,
url=url, url=url,
params=params or {}, params=dict(params or {}),
headers={**self.default_headers, **(headers or {})}, headers={**self.default_headers, **(headers or {})},
timeout=timeout or self.request_timeout, timeout=timeout or self.request_timeout,
max_retries=max_retries, max_retries=max_retries,
@@ -340,9 +352,16 @@ class BackgroundDataService:
# Perform HTTP request with retry logic # Perform HTTP request with retry logic
response = self._make_request_with_retry(request) response = self._make_request_with_retry(request)
response.raise_for_status()
# Parse response # 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.
if response.status_code == 400:
data = self._fetch_in_date_chunks(request)
if data is None:
response.raise_for_status()
else:
response.raise_for_status()
data = response.json() data = response.json()
# Validate data structure # Validate data structure
@@ -519,6 +538,23 @@ class BackgroundDataService:
""" """
result.data = None 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: def _make_request_with_retry(self, request: FetchRequest) -> requests.Response:
""" """
Make HTTP request with retry logic and exponential backoff. Make HTTP request with retry logic and exponential backoff.
+9 -8
View File
@@ -10,6 +10,7 @@ from typing import Dict, List
import requests import requests
import logging import logging
from datetime import datetime from datetime import datetime
from src.common.espn_dates import fetch_espn_scoreboard
class DataSource(ABC): class DataSource(ABC):
"""Abstract base class for data sources.""" """Abstract base class for data sources."""
@@ -71,10 +72,10 @@ class ESPNDataSource(DataSource):
now = datetime.now() now = datetime.now()
formatted_date = now.strftime("%Y%m%d") formatted_date = now.strftime("%Y%m%d")
url = f"{self.base_url}/{sport}/{league}/scoreboard" 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) data = fetch_espn_scoreboard(
response.raise_for_status() self.session, url, params={"dates": formatted_date, "limit": 1000},
headers=self.get_headers(), timeout=15, logger=self.logger,
data = response.json() )
events = data.get('events', []) events = data.get('events', [])
# Filter for live games # Filter for live games
@@ -99,10 +100,10 @@ class ESPNDataSource(DataSource):
"limit": 1000 "limit": 1000
} }
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) data = fetch_espn_scoreboard(
response.raise_for_status() self.session, url, params=params,
headers=self.get_headers(), timeout=15, logger=self.logger,
data = response.json() )
events = data.get('events', []) events = data.get('events', [])
self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}")
+10 -6
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import pytz import pytz
from src.common.espn_dates import fetch_espn_scoreboard
import requests import requests
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype from src.common.font_layout import load_truetype
@@ -844,9 +845,11 @@ class SportsCore(ABC):
formatted_date_yesterday = yesterday.strftime("%Y%m%d") formatted_date_yesterday = yesterday.strftime("%Y%m%d")
# Fetch todays games only # Fetch todays games only
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" 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) data = fetch_espn_scoreboard(
response.raise_for_status() self.session, url,
data = response.json() params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000},
headers=self.headers, timeout=10, logger=self.logger,
)
events = data.get('events', []) events = data.get('events', [])
self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}") 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) end_date = now + timedelta(weeks=1)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" 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) data = fetch_espn_scoreboard(
response.raise_for_status() self.session, url, params={"dates": date_str, "limit": 1000},
data = response.json() headers=self.headers, timeout=10, logger=self.logger,
)
immediate_events = data.get('events', []) immediate_events = data.get('events', [])
if immediate_events: if immediate_events:
+4 -1
View File
@@ -8,6 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
import logging import logging
import time import time
from datetime import datetime from datetime import datetime
from src.common.espn_dates import ESPN_MAX_LIMIT
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import requests import requests
@@ -153,9 +154,11 @@ class APIHelper:
cache_key = f"espn_{sport}_{league}_{date}" cache_key = f"espn_{sport}_{league}_{date}"
# Set parameters # 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 = { params = {
'dates': date, 'dates': date,
'limit': 1000 'limit': ESPN_MAX_LIMIT
} }
return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl) return self.get(url, params=params, cache_key=cache_key, cache_ttl=cache_ttl)
+294
View File
@@ -0,0 +1,294 @@
"""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 datetime import date, timedelta
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
_range_lock = threading.Lock()
_ranges_rejected_until = 0.0
__all__ = [
"ESPN_MAX_LIMIT",
"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_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.
"""
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),
)
payloads: List[Dict[str, Any]] = []
attempted = 0
pending = list(chunks)
while pending:
chunk = pending.pop(0)
attempted += 1
try:
response = session.get(
url,
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
headers=headers,
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
except Exception as exc: # noqa: BLE001 - one bad chunk must not sink the rest
if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
continue
events = payload.get("events") if isinstance(payload, dict) else None
if len(chunk) == 6 and 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,
)
pending[:0] = _days_of_month(chunk)
continue
payloads.append(payload)
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()
+10
View File
@@ -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 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 — user's. Silently does nothing if not running as root or on any error —
this is a hardening step, not a required one. 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: if os.geteuid() != 0:
return return
gid = get_shared_group_gid() gid = get_shared_group_gid()
+408
View File
@@ -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
+4 -3
View File
@@ -84,6 +84,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, ClassVar, Dict, List, Optional, Tuple from typing import Any, ClassVar, Dict, List, Optional, Tuple
import pytz import pytz
from src.common.espn_dates import fetch_espn_scoreboard
import requests import requests
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype from src.common.font_layout import load_truetype
@@ -947,14 +948,14 @@ class SportsCoreSharedMixin:
end_date = now + timedelta(days=self.schedule_lookahead_days) end_date = now + timedelta(days=self.schedule_lookahead_days)
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" 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, url,
params={"dates": date_str, "limit": 1000}, params={"dates": date_str, "limit": 1000},
headers=self.headers, headers=self.headers,
timeout=10, timeout=10,
logger=self.logger,
) )
response.raise_for_status()
data = response.json()
immediate_events = data.get("events", []) immediate_events = data.get("events", [])
if immediate_events: if immediate_events:
+43
View File
@@ -0,0 +1,43 @@
"""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',
})
+10
View File
@@ -39,6 +39,7 @@ from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
physical_size, resolve_double_sided, physical_size, resolve_double_sided,
) )
from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings
import threading import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
@@ -325,6 +326,15 @@ class DisplayManager:
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}") 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}")
# On a Pi 5 the library hands back no matrix for settings its RP1
# path can't drive, and the binding doesn't check -- the process
# would crash on its next call instead of reaching the fallback
# below. Raise first so it is a logged, reported init failure.
if os.getenv("EMULATOR", "false") != "true" and is_raspberry_pi_5():
unsupported = pi5_unsupported_settings(hardware_config)
if unsupported:
raise RuntimeError(unsupported)
# Initialize the matrix # Initialize the matrix
self.matrix = RGBMatrix(options=options) self.matrix = RGBMatrix(options=options)
logger.info("RGB Matrix initialized successfully") logger.info("RGB Matrix initialized successfully")
+70
View File
@@ -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) + ".")
+20 -1
View File
@@ -22,7 +22,7 @@ from src.logging_config import get_logger
from src.plugin_system.plugin_loader import PluginLoader from src.plugin_system.plugin_loader import PluginLoader
from src.plugin_system.plugin_executor import PluginExecutor from src.plugin_system.plugin_executor import PluginExecutor
from src.plugin_system.plugin_state import PluginStateManager, PluginState from src.plugin_system.plugin_state import PluginStateManager, PluginState
from src.plugin_system.schema_manager import SchemaManager from src.plugin_system.schema_manager import SchemaManager, normalize_legacy_booleans
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
get_plugin_dir_mode get_plugin_dir_mode
@@ -351,6 +351,7 @@ class PluginManager:
config = {} config = {}
# Check if plugin has a config schema # Check if plugin has a config schema
schema = None
schema_path = self.schema_manager.get_schema_path(plugin_id) schema_path = self.schema_manager.get_schema_path(plugin_id)
if schema_path is None: if schema_path is None:
# Schema file doesn't exist # Schema file doesn't exist
@@ -368,6 +369,24 @@ class PluginManager:
f"The schema may be invalid. Please verify the schema file at: {schema_path}" f"The schema may be invalid. Please verify the schema file at: {schema_path}"
) )
# A plugin that turned an on/off boolean into an {enabled, ...}
# object still finds the boolean in config.json until the user saves
# its settings form, which carries it over (plugin_config.html).
# Read it the same way here, before the defaults fill in the rest
# of the object and before schema validation, so the plugin doesn't
# start with a schema warning and a degraded flag. In memory only:
# config.json is written by saves, never by loading a plugin.
if schema:
upgraded: List[str] = []
config = normalize_legacy_booleans(config, schema, upgraded)
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),
)
# Merge config with schema defaults to ensure all defaults are applied # Merge config with schema defaults to ensure all defaults are applied
try: try:
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True) defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
+79
View File
@@ -14,6 +14,85 @@ import jsonschema
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
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
class SchemaManager: class SchemaManager:
""" """
Manages plugin configuration schemas with caching and validation. Manages plugin configuration schemas with caching and validation.
+48 -31
View File
@@ -14,6 +14,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS
from src.plugin_system.state_manager import PluginStateManager from src.plugin_system.state_manager import PluginStateManager
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -72,9 +73,16 @@ def secrets_top_level_keys(config_manager) -> Set[str]:
return set(secrets) if isinstance(secrets, dict) else set() return set(secrets) if isinstance(secrets, dict) else set()
def ignored_config_keys(config_manager) -> Set[str]: def ignored_config_keys(config_manager, installed_ids=frozenset()) -> Set[str]:
"""Config keys that are not plugin ids: system keys plus secrets keys.""" """Config keys that are not plugin ids: core keys plus secrets keys.
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
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]: 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 plugins were all present in config kept being told, for hours, that four of
them were missing and should be removed from config.json. 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 Entry kinds this cannot re-check are kept, so filtering only ever removes
findings that are provably stale. findings that are provably stale.
""" """
@@ -143,7 +158,7 @@ def still_unresolved(entries: List[Dict[str, Any]],
if plugin_id not in config_keys: if plugin_id not in config_keys:
live.append(entry) live.append(entry)
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value: 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) live.append(entry)
else: else:
live.append(entry) live.append(entry)
@@ -280,39 +295,22 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}" message=f"Reconciliation failed: {str(e)}"
) )
# Top-level config keys that are NOT plugins. # Top-level config keys that are NOT plugins. The core keys come from the
# Includes both config.json structural keys and config_secrets.json top-level # shared list in src/core_config_keys.py -- a private copy here missed
# keys (load_config() deep-merges secrets in, so secrets keys appear here too). # #581's 'auto_update' and reported it as a plugin missing from disk.
_SYSTEM_CONFIG_KEYS = frozenset({ # 'github'/'youtube' are the historical secrets-file keys. The secrets file
'web_display_autostart', 'timezone', 'location', 'display', # itself is read at run time too (ignored_config_keys): load_config() merges
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width', # it in, and naming its keys one by one let a 'data' key become a phantom
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', # plugin permanently reported as "in config but not on disk".
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging', _SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
'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)
def _get_config_state(self) -> Dict[str, Dict[str, Any]]: def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file.""" """Get plugin state from config file."""
state = {} state = {}
try: try:
config = self.config_manager.load_config() 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): for plugin_id in config_plugin_ids(config, ignored):
plugin_config = config[plugin_id] plugin_config = config[plugin_id]
state[plugin_id] = { state[plugin_id] = {
@@ -390,6 +388,19 @@ class StateReconciliation:
"""Check consistency for a single plugin.""" """Check consistency for a single plugin."""
inconsistencies = [] 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, {}) config = config_state.get(plugin_id, {})
disk = disk_state.get(plugin_id, {}) disk = disk_state.get(plugin_id, {})
state_mgr = state_manager_state.get(plugin_id, {}) state_mgr = state_manager_state.get(plugin_id, {})
@@ -468,6 +479,12 @@ class StateReconciliation:
"""Attempt to fix an inconsistency.""" """Attempt to fix an inconsistency."""
try: try:
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG: 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() config = self.config_manager.load_config()
if inconsistency.plugin_id in config: if inconsistency.plugin_id in config:
# Detection said "not in config" but it is there -- the # Detection said "not in config" but it is there -- the
+1
View File
@@ -35,6 +35,7 @@ nothing is listening, so it stays useful in a bare checkout.
| Suite | Needs a server | Covers | | 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_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), and re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin. 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_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_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_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 |
+2 -1
View File
@@ -17,7 +17,8 @@ const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js', 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_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.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'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js']; 'dom/test_tools_sections.js'];
+151
View File
@@ -0,0 +1,151 @@
// "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(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+3 -2
View File
@@ -17,6 +17,7 @@ import requests
from freezegun import freeze_time from freezegun import freeze_time
import src.common.api_helper as api_helper_module 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 from src.common.api_helper import APIHelper
@@ -152,7 +153,7 @@ class TestEspnHelpers:
assert result == {'ok': 1} assert result == {'ok': 1}
helper.get.assert_called_once_with( helper.get.assert_called_once_with(
'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard', '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_key='espn_football_nfl_20260807',
cache_ttl=300, cache_ttl=300,
) )
@@ -163,7 +164,7 @@ class TestEspnHelpers:
helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115') helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')
kwargs = helper.get.call_args.kwargs 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' assert kwargs['cache_key'] == 'espn_basketball_nba_20250115'
def test_fetch_espn_standings_url_and_cache_key(self, helper): def test_fetch_espn_standings_url_and_cache_key(self, helper):
+308
View File
@@ -0,0 +1,308 @@
"""Display hardware settings accept what the rgbmatrix library accepts.
Held to the ranges in the pinned library (RGBMatrix::Options::Validate in
lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc), with
one deliberate exception: rows has no upper bound here, although the library
currently rejects more than 64 per panel. Two 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.
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), ('rows', 96), ('rows', 128),
('cols', 16), ('cols', 192), ('cols', 512),
('chain_length', 1), ('chain_length', 32),
('parallel', 3),
('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', 97), ('rows', '48.5'),
('cols', 15), ('cols', 96.5), ('cols', True), ('cols', 'wide'),
('chain_length', 0),
('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.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('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': 'compute-module'},
])
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_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,163 @@
"""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.espn_dates import ESPN_MAX_LIMIT, 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
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")
assert [call["dates"] for call in session.calls] == [
"20260801-20270301",
"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()
+163
View File
@@ -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"]}
+24
View File
@@ -333,3 +333,27 @@ class TestArraySecretStripAndMerge:
manager._deep_merge(stripped, secrets) manager._deep_merge(stripped, secrets)
assert stripped == full # round trip restores the items assert stripped == full # round trip restores the items
class TestLoadWithoutPosixOwnershipApis:
"""A secrets file must load on platforms that have no uid/gid at all.
load_config() chgrp's the secrets file to the shared group before reading
it, to self-heal a root-written file the non-root web user can't read.
That helper is documented as best-effort, but it looked up os.geteuid
unguarded -- absent on Windows -- and the resulting AttributeError is not
an OSError, so it escaped every except clause on the way out. The symptom
was a ConfigError from load_config on any Windows checkout carrying a
config/config_secrets.json, which took `import web_interface.app` with it.
"""
def test_secrets_still_merge_without_geteuid(self, tmp_path, monkeypatch):
monkeypatch.delattr(os, "geteuid", raising=False)
monkeypatch.delattr(os, "chown", raising=False)
manager = make_manager(
tmp_path,
config={"weather": {"city": "Austin"}},
secrets={"weather": {"api_key": "s3cret"}},
)
assert manager.load_config()["weather"] == {"city": "Austin", "api_key": "s3cret"}
+270
View File
@@ -0,0 +1,270 @@
"""Core settings in config.json must never be reported as orphaned plugins.
Observed on a v3.4.0 device: the web UI showed "Plugin Config Warning -- In
config but not installed: auto_update. Reinstall via the Plugin Store, or
remove these entries from config.json." ``auto_update`` is the top-level core
section #581 added for weekly automatic updates. Reconciliation kept its own
private list of non-plugin keys, which #581 did not know to extend, so any
top-level dict it did not list was treated as a plugin id. Following the
warning's advice deletes a real core setting.
These tests pin the shared list in src/core_config_keys.py to the sources that
actually define top-level keys, so the next new core setting cannot regress it.
"""
import json
import re
import sys
from pathlib import Path
from unittest.mock import MagicMock, Mock
import pytest
from flask import Flask
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from src.core_config_keys import CORE_CONFIG_KEYS # noqa: E402
from src.plugin_system.state_reconciliation import ( # noqa: E402
InconsistencyType,
StateReconciliation,
)
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
TEMPLATE = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
BUNDLED_PLUGINS = {p.parent.name for p in (REPO / "plugin-repos").glob("*/manifest.json")}
# Top-level keys of the hdpi device's config.json (v3.4.0 fddb0e06), keys only.
DEVICE_CONFIG_KEYS = [
'web_display_autostart', 'schedule', 'dim_schedule', 'timezone', 'target_fps',
'location', 'display', 'sync', 'plugin_system', 'web-ui-info', 'starlark-apps',
'football-scoreboard', 'hockey-scoreboard', 'lacrosse-scoreboard', 'afl-scoreboard',
'nrl-scoreboard', 'soccer-scoreboard', 'baseball-scoreboard', 'ledmatrix-flights',
'birdnet-go', 'geochron', 'incoming-packages', 'jellyfin-now-playing', 'tide-display',
'ledmatrix-leaderboard', 'ledmatrix-music', 'odds-ticker', 'stock-news',
'basketball-scoreboard', 'ledmatrix-stocks', 'ledmatrix-weather', 'news',
'clock-simple', 'christmas-countdown', '7-segment-clock', 'calendar', 'on-air',
'static-image', 'ledmatrix-dresden-departures', 'olympics', 'ledmatrix-elections',
'f1-scoreboard', 'hello-world', 'tidbyt-baseball-scoreboard', 'youtube-stats',
'mqtt-notifications', 'march-madness', 'pga-tour-leaderboard', 'nfl-draft',
'pomodoro-timer', 'countdown', 'gif-player', 'of-the-day', 'text-display',
'masters-tournament', 'cricket-scoreboard', 'ufc-scoreboard', 'auto_update',
]
DEVICE_SECRETS_KEYS = ['github', 'incoming-packages', 'jellyfin-now-playing']
DEVICE_INSTALLED = [k for k in DEVICE_CONFIG_KEYS if k not in CORE_CONFIG_KEYS] + [
'ledmatrix-flights.standalone-backup-migrating']
def _device_config():
"""Device-shaped config: real key set, placeholder values."""
cfg = {}
for key in DEVICE_CONFIG_KEYS:
template_value = TEMPLATE.get(key)
cfg[key] = template_value if template_value is not None else {"enabled": True}
return cfg
class _ConfigManager:
def __init__(self, config, secrets_path=None):
self._config = config
self._secrets_path = secrets_path
self.saved = []
def load_config(self):
return dict(self._config)
def save_config(self, config):
self.saved.append(dict(config))
self._config = dict(config)
def get_secrets_path(self):
return self._secrets_path
def _install(plugins_dir, plugin_id):
d = plugins_dir / plugin_id
d.mkdir(parents=True, exist_ok=True)
(d / "manifest.json").write_text(json.dumps({"id": plugin_id, "version": "1.0.0"}),
encoding="utf-8")
def _reconcile(tmp_path, config, installed=(), secrets=None):
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir(exist_ok=True)
for pid in installed:
_install(plugins_dir, pid)
secrets_path = tmp_path / "config_secrets.json"
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
state_manager = Mock()
state_manager.get_all_states.return_value = {}
plugin_manager = Mock()
plugin_manager.plugin_manifests = {}
reconciler = StateReconciliation(
state_manager=state_manager,
config_manager=_ConfigManager(config, str(secrets_path)),
plugin_manager=plugin_manager,
plugins_dir=plugins_dir,
)
return reconciler, reconciler.reconcile_state()
def _ids(result, kind):
return sorted(i.plugin_id for i in result.inconsistencies_found
if i.inconsistency_type == kind)
class TestTheListCoversEveryCoreKey:
def test_every_template_top_level_key_is_core_or_a_bundled_plugin(self):
unaccounted = [k for k in TEMPLATE
if k not in CORE_CONFIG_KEYS and k not in BUNDLED_PLUGINS]
assert unaccounted == [], (
"config.template.json has top-level keys that are neither in "
"src/core_config_keys.py nor a plugin under plugin-repos/; "
"reconciliation will report them as plugins missing from disk")
def test_the_template_keys_this_bug_was_about_are_covered(self):
for key in ("auto_update", "sync", "schedule", "dim_schedule", "location",
"display", "plugin_system", "timezone", "target_fps",
"web_display_autostart"):
assert key in CORE_CONFIG_KEYS, key
def test_keys_written_by_the_general_settings_save_are_core(self):
"""A new setting can be added to the save endpoint without touching
the template; catch that too."""
source = (REPO / "web_interface" / "blueprints" / "api_v3" / "config.py").read_text(
encoding="utf-8")
written = set(re.findall(r"current_config\[['\"]([A-Za-z0-9_-]+)['\"]\]\s*=", source))
assert "auto_update" in written, "pattern no longer finds the writes; update the test"
assert written - CORE_CONFIG_KEYS == set()
def test_no_core_key_is_also_a_bundled_plugin(self):
assert CORE_CONFIG_KEYS & BUNDLED_PLUGINS == set()
class TestReconciliationIgnoresCoreKeys:
def test_auto_update_is_not_reported(self, tmp_path):
_, result = _reconcile(tmp_path, {"auto_update": {"enabled": False}})
assert "auto_update" not in [i.plugin_id for i in result.inconsistencies_found]
def test_the_whole_template_yields_only_its_bundled_plugins(self, tmp_path):
template_plugins = {k for k in TEMPLATE if k in BUNDLED_PLUGINS}
reconciler, result = _reconcile(tmp_path, dict(TEMPLATE), installed=template_plugins)
assert result.inconsistencies_found == []
assert set(reconciler._get_config_state()) == template_plugins
@pytest.mark.parametrize("key", sorted(CORE_CONFIG_KEYS))
def test_no_core_key_is_ever_a_plugin_missing_from_disk(self, tmp_path, key):
_, result = _reconcile(tmp_path, {key: {"enabled": True}})
assert _ids(result, ON_DISK) == []
def test_a_genuinely_orphaned_plugin_is_still_reported(self, tmp_path):
config = {"auto_update": {"enabled": False}, "display": {"hardware": {}},
"ghost-plugin": {"enabled": True}, "odds-ticker": {"enabled": True}}
_, result = _reconcile(tmp_path, config, installed=["odds-ticker"])
assert _ids(result, ON_DISK) == ["ghost-plugin"]
assert [i.plugin_id for i in result.inconsistencies_manual] == ["ghost-plugin"]
def test_the_device_config_reports_nothing(self, tmp_path):
_, result = _reconcile(
tmp_path, _device_config(), installed=DEVICE_INSTALLED,
secrets={k: {"api_key": "x"} for k in DEVICE_SECRETS_KEYS})
assert result.inconsistencies_found == []
assert result.reconciliation_successful
class TestPluginSecretsDoNotHideThePlugin:
"""Plugin secrets are namespaced by plugin id in config_secrets.json. The
device had 'incoming-packages' and 'jellyfin-now-playing' there; ignoring
every secrets key reported both installed plugins as missing from config on
every run (auto-"fixed" as a no-op, so it never reached the banner)."""
def test_installed_plugin_with_secrets_is_a_plugin(self, tmp_path):
config = {"incoming-packages": {"enabled": True, "api_key": "x"}}
reconciler, result = _reconcile(tmp_path, config, installed=["incoming-packages"],
secrets={"incoming-packages": {"api_key": "x"}})
assert result.inconsistencies_found == []
assert "incoming-packages" in reconciler._get_config_state()
def test_a_secrets_only_key_is_still_not_a_plugin(self, tmp_path):
# The #557 phantom: a non-plugin 'data' key in the secrets file.
_, result = _reconcile(tmp_path, {"data": {"mode": "nfl_recent"}},
secrets={"data": {"mode": "nfl_recent"}})
assert result.inconsistencies_found == []
class TestAPluginNamedLikeACoreKey:
"""A plugin whose id collides with a core key has no config section of its
own. Reconciliation must neither nag about it nor write a stub over (or
in place of) the core setting."""
def test_installed_plugin_named_like_a_core_key_is_not_reported(self, tmp_path):
config = {"sync": {"role": "standalone"}}
reconciler, result = _reconcile(tmp_path, config, installed=["sync"])
assert result.inconsistencies_found == []
assert reconciler.config_manager.saved == []
assert reconciler.config_manager.load_config() == config
def test_core_key_absent_from_config_is_not_stubbed(self, tmp_path):
reconciler, result = _reconcile(tmp_path, {}, installed=["display"])
assert result.inconsistencies_found == []
assert reconciler.config_manager.saved == []
def test_fix_refuses_to_write_a_core_key(self, tmp_path):
reconciler, _ = _reconcile(tmp_path, {})
inc = Mock(inconsistency_type=IN_CONFIG, plugin_id="auto_update")
assert reconciler._fix_inconsistency(inc) is False
assert reconciler.config_manager.saved == []
class TestTheStatusEndpoint:
@pytest.fixture
def get_status(self, tmp_path, monkeypatch):
import tempfile
from web_interface.blueprints.api_v3 import api_v3
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
def _get(verdict, config, installed=(), secrets=None):
(tmp_path / "ledmatrix_reconciliation.json").write_text(
json.dumps(verdict), encoding="utf-8")
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir(exist_ok=True)
for pid in installed:
_install(plugins_dir, pid)
secrets_path = tmp_path / "config_secrets.json"
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
cm = MagicMock()
cm.load_config.return_value = config
cm.get_secrets_path.return_value = str(secrets_path)
pm = MagicMock()
pm.plugins_dir = str(plugins_dir)
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
resp = app.test_client().get("/api/v3/plugins/reconciliation-status")
return resp.get_json()["data"]
return _get
def test_the_verdict_the_device_served_now_clears(self, get_status):
"""The exact stored verdict from the device, written by the old build,
is dropped against the device's config -- no restart needed."""
verdict = {"done": True, "fixed_count": 3, "successful": False, "unresolved": [
{"description": "Plugin auto_update in config but not on disk",
"plugin_id": "auto_update", "type": ON_DISK.value}]}
data = get_status(verdict, _device_config(), installed=DEVICE_INSTALLED,
secrets={k: {} for k in DEVICE_SECRETS_KEYS})
assert data["unresolved"] == []
def test_a_real_orphan_still_reaches_the_banner(self, get_status):
verdict = {"done": True, "unresolved": [
{"plugin_id": "auto_update", "type": ON_DISK.value},
{"plugin_id": "ghost-plugin", "type": ON_DISK.value}]}
config = _device_config()
config["ghost-plugin"] = {"enabled": True}
data = get_status(verdict, config, installed=DEVICE_INSTALLED)
assert [e["plugin_id"] for e in data["unresolved"]] == ["ghost-plugin"]
+55
View File
@@ -279,3 +279,58 @@ class TestDisplayManagerOrientation:
suppress_test_pattern=True) suppress_test_pattern=True)
options = mock_rgb_matrix['options_class'].return_value options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == 'U-mapper;Rotate:180' assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
class TestDisplayManagerPi5Guard:
"""On a Pi 5 the library returns no matrix for settings its RP1 path can't
drive, and the binding doesn't check, so the process would crash on its next
call. DisplayManager has to refuse before creating the matrix and fall back."""
def _config(self, **hardware_overrides):
config = {
'display': {
'hardware': {
'rows': 48, 'cols': 96, 'chain_length': 1, 'parallel': 1,
'hardware_mapping': 'regular', 'brightness': 90,
},
'runtime': {'gpio_slowdown': 2},
},
'timezone': 'UTC',
'plugin_system': {'plugins_directory': 'plugins'},
}
config['display']['hardware'].update(hardware_overrides)
return config
@pytest.fixture
def board(self, tmp_path, monkeypatch):
from src import pi5_matrix_support
def set_model(model):
path = tmp_path / 'model'
path.write_bytes(model.encode() + b'\x00')
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
return set_model
def test_unsupported_setting_on_pi5_never_creates_the_matrix(self, mock_rgb_matrix, board):
board('Raspberry Pi 5 Model B Rev 1.0')
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True)
mock_rgb_matrix['matrix_class'].assert_not_called()
assert dm.matrix is None
def test_supported_setting_on_pi5_creates_the_matrix(self, mock_rgb_matrix, board):
board('Raspberry Pi 5 Model B Rev 1.0')
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(row_address_type=2), suppress_test_pattern=True)
mock_rgb_matrix['matrix_class'].assert_called_once()
assert dm.matrix is not None
def test_other_boards_are_left_to_the_library(self, mock_rgb_matrix, board):
board('Raspberry Pi 4 Model B Rev 1.5')
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True)
mock_rgb_matrix['matrix_class'].assert_called_once()
assert dm.matrix is not None
+372
View File
@@ -0,0 +1,372 @@
"""src.common.espn_dates: the ESPN site-API workarounds.
Two real upstream behaviours are pinned here, both observed on 2026-09-15 and
re-verified with desktop curl (see the module docstring):
* ``dates=YYYYMMDD-YYYYMMDD`` answers 400 for every sport, so a range has to be
re-asked in months and days.
* ``limit`` over 500 truncates instead of erroring -- college-football returned
25 of 68 games for a single Saturday at ``limit=1000``.
Nothing here touches the network. The fake session records what a caller would
have sent, which is the part that regressed.
"""
from datetime import date, timedelta
import pytest
import src.common.espn_dates as espn_dates
from src.common.espn_dates import (
ESPN_MAX_LIMIT,
RANGE_RETRY_SECONDS,
clamp_espn_limit,
espn_date_chunks,
fetch_espn_date_chunks,
fetch_espn_scoreboard,
merge_scoreboard_payloads,
parse_espn_date_range,
)
URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
@pytest.fixture(autouse=True)
def forget_rejected_ranges(monkeypatch):
"""The rejected-range memo is process-wide; no test may inherit it."""
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
class FakeResponse:
def __init__(self, status_code=200, 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 FakeSession:
"""Answers 400 to day ranges, like ESPN does, and records every call."""
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 = 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, [])})
def days_covered_by(chunks):
"""Expand chunks back into the days they stand for, in order."""
covered = []
for chunk in chunks:
if len(chunk) == 6:
day = date(int(chunk[:4]), int(chunk[4:]), 1)
month = day.month
while day.month == month:
covered.append(day)
day += timedelta(days=1)
else:
covered.append(date(int(chunk[:4]), int(chunk[4:6]), int(chunk[6:])))
return covered
class TestClampLimit:
"""limit over 500 silently truncates upstream, so it must never be sent."""
def test_the_limit_every_caller_used_is_pulled_back(self):
assert clamp_espn_limit({"limit": 1000})["limit"] == ESPN_MAX_LIMIT
def test_a_safe_limit_is_left_alone(self):
assert clamp_espn_limit({"limit": 100})["limit"] == 100
def test_the_boundary_value_is_kept(self):
assert clamp_espn_limit({"limit": 500})["limit"] == 500
@pytest.mark.parametrize("params", [{}, {"limit": None}, {"limit": "many"}])
def test_absent_or_unparseable_limits_pass_through(self, params):
assert clamp_espn_limit(params) == params
def test_the_callers_dict_is_not_mutated(self):
original = {"limit": 1000}
clamp_espn_limit(original)
assert original == {"limit": 1000}
class TestParseRange:
def test_a_day_range_parses(self):
assert parse_espn_date_range("20260801-20270301") == (
date(2026, 8, 1),
date(2027, 3, 1),
)
@pytest.mark.parametrize(
"value",
[
"20260914", # single day still works upstream
"202609", # month still works upstream
"2026", # season year still works upstream
"20260801-",
"not-a-date",
"20270301-20260801", # backwards
"2026080-20270301", # short half
None,
1234,
],
)
def test_everything_that_is_not_a_day_range_is_left_alone(self, value):
assert parse_espn_date_range(value) is None
class TestChunks:
"""Chunks must tile the window exactly -- never reaching outside it."""
def test_a_full_season_collapses_to_months_plus_one_day(self):
assert espn_date_chunks(date(2026, 8, 1), date(2027, 3, 1)) == [
"202608",
"202609",
"202610",
"202611",
"202612",
"202701",
"202702",
"20270301",
]
def test_a_two_day_window_stays_two_days(self):
assert espn_date_chunks(date(2026, 9, 14), date(2026, 9, 15)) == [
"20260914",
"20260915",
]
def test_an_exact_calendar_month_is_one_request(self):
assert espn_date_chunks(date(2026, 9, 1), date(2026, 9, 30)) == ["202609"]
def test_partial_edges_are_spelled_out_day_by_day(self):
assert espn_date_chunks(date(2026, 8, 30), date(2026, 10, 2)) == [
"20260830",
"20260831",
"202609",
"20261001",
"20261002",
]
def test_a_single_day_window_is_one_day(self):
assert espn_date_chunks(date(2026, 9, 14), date(2026, 9, 14)) == ["20260914"]
def test_february_in_a_leap_year_is_still_one_month(self):
assert espn_date_chunks(date(2028, 2, 1), date(2028, 2, 29)) == ["202802"]
def test_the_29th_of_a_leap_february_is_not_swallowed(self):
# A month chunk may only be used when it ends inside the window.
assert espn_date_chunks(date(2028, 2, 1), date(2028, 2, 28)) == [
"202802{:02d}".format(day) for day in range(1, 29)
]
def test_a_year_boundary_is_crossed_cleanly(self):
assert espn_date_chunks(date(2026, 12, 31), date(2027, 1, 31)) == [
"20261231",
"202701",
]
@pytest.mark.parametrize(
"start,end",
[
(date(2026, 8, 1), date(2027, 3, 1)),
(date(2026, 8, 30), date(2026, 10, 2)),
(date(2025, 9, 1), date(2026, 8, 1)),
(date(2026, 9, 14), date(2026, 9, 15)),
],
)
def test_chunks_cover_every_day_exactly_once(self, start, end):
expected = []
day = start
while day <= end:
expected.append(day)
day += timedelta(days=1)
assert days_covered_by(espn_date_chunks(start, end)) == expected
class TestMerge:
def test_events_are_deduplicated_by_id(self):
merged = merge_scoreboard_payloads(
[
{"events": [{"id": "1"}, {"id": "2"}]},
{"events": [{"id": "2"}, {"id": "3"}]},
]
)
assert [e["id"] for e in merged["events"]] == ["1", "2", "3"]
def test_non_event_keys_come_from_the_first_payload_that_has_them(self):
merged = merge_scoreboard_payloads(
[
{"events": [], "leagues": ["first"]},
{"events": [], "leagues": ["second"], "season": 2026},
]
)
assert merged["leagues"] == ["first"]
assert merged["season"] == 2026
def test_an_empty_merge_still_has_an_events_list(self):
assert merge_scoreboard_payloads([]) == {"events": []}
class TestFetch:
def test_a_working_request_is_not_chunked(self):
session = FakeSession({"20260913": [{"id": "1"}]})
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260913"})
assert data["events"] == [{"id": "1"}]
assert len(session.calls) == 1
def test_limit_is_clamped_even_on_the_happy_path(self):
session = FakeSession()
fetch_espn_scoreboard(session, URL, params={"dates": "20260913", "limit": 1000})
assert session.calls[0]["limit"] == ESPN_MAX_LIMIT
def test_a_rejected_range_is_refetched_in_chunks(self):
session = FakeSession(
{"202609": [{"id": "a"}, {"id": "b"}], "20261001": [{"id": "c"}]}
)
data = fetch_espn_scoreboard(
session, URL, params={"dates": "20260901-20261001", "limit": 1000}
)
assert [e["id"] for e in data["events"]] == ["a", "b", "c"]
sent = [call["dates"] for call in session.calls]
assert sent == ["20260901-20261001", "202609", "20261001"]
def test_chunk_requests_keep_the_clamped_limit(self):
session = FakeSession({"202609": []})
fetch_espn_scoreboard(
session, URL, params={"dates": "20260901-20260930", "limit": 1000}
)
assert all(call["limit"] == ESPN_MAX_LIMIT for call in session.calls)
def test_other_params_survive_chunking(self):
session = FakeSession({"202609": []})
fetch_espn_scoreboard(
session, URL, params={"dates": "20260901-20260930", "groups": "80"}
)
assert session.calls[-1]["groups"] == "80"
def test_one_bad_chunk_does_not_sink_the_season(self):
session = FakeSession(
{"202609": [{"id": "a"}], "20261001": [{"id": "c"}]},
fail_chunks={"20261001"},
)
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20261001"})
assert [e["id"] for e in data["events"]] == ["a"]
def test_a_total_failure_raises_rather_than_looking_like_no_games(self):
session = FakeSession({}, fail_chunks={"202609"})
with pytest.raises(RuntimeError):
fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20260930"})
def test_a_400_on_a_non_range_request_is_still_an_error(self):
class AlwaysBad(FakeSession):
def get(self, url, params=None, headers=None, timeout=None):
self.calls.append(params or {})
return FakeResponse(400)
session = AlwaysBad()
with pytest.raises(RuntimeError):
fetch_espn_scoreboard(session, URL, params={"dates": "20260913"})
assert len(session.calls) == 1
class TestMonthCap:
"""A month holding more than 500 events comes back cut at exactly 500.
College baseball's March 2026 does this. Nothing in the response says more
exist, so a full month chunk has to be re-asked day by day.
"""
def test_a_full_month_is_re_asked_day_by_day(self):
full = [{"id": "m%d" % i} for i in range(ESPN_MAX_LIMIT)]
by_chunk = {"202603": full}
by_chunk.update({"202603%02d" % day: [{"id": "d%d" % day}] for day in range(1, 32)})
session = FakeSession(by_chunk)
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260301-20260331"})
assert [call["dates"] for call in session.calls] == ["202603"] + [
"202603%02d" % day for day in range(1, 32)
]
# The truncated month payload is dropped, not merged with the days.
assert [event["id"] for event in data["events"]] == [
"d%d" % day for day in range(1, 32)
]
def test_a_month_under_the_cap_is_trusted(self):
session = FakeSession({"202609": [{"id": "a"}] * 10})
fetch_espn_date_chunks(session, URL, params={"dates": "20260901-20260930"})
assert [call["dates"] for call in session.calls] == ["202609"]
def test_chunks_ask_for_the_cap_even_when_the_caller_sent_no_limit(self):
# ESPN's default page is 100 for NFL and 300 for college football --
# smaller than a busy month.
session = FakeSession({"202609": []})
fetch_espn_date_chunks(session, URL, params={"dates": "20260901-20260930"})
assert session.calls[0]["limit"] == ESPN_MAX_LIMIT
def test_a_non_range_is_not_chunked(self):
session = FakeSession()
assert fetch_espn_date_chunks(session, URL, params={"dates": "202609"}) is None
assert session.calls == []
class TestRejectedRangeMemo:
"""Live boards ask every 30s; a known-rejected range must not be re-sent."""
def test_after_one_rejection_the_next_range_skips_straight_to_chunks(self):
session = FakeSession({"20260914": [{"id": "a"}], "20260915": []})
fetch_espn_scoreboard(session, URL, params={"dates": "20260914-20260915"})
session.calls.clear()
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260914-20260915"})
assert [call["dates"] for call in session.calls] == ["20260914", "20260915"]
assert [event["id"] for event in data["events"]] == ["a"]
def test_the_range_is_tried_again_once_the_memo_expires(self, monkeypatch):
clock = [1000.0]
monkeypatch.setattr(espn_dates.time, "monotonic", lambda: clock[0])
session = FakeSession()
fetch_espn_scoreboard(session, URL, params={"dates": "20260914-20260915"})
session.calls.clear()
clock[0] += RANGE_RETRY_SECONDS + 1
fetch_espn_scoreboard(session, URL, params={"dates": "20260914-20260915"})
assert session.calls[0]["dates"] == "20260914-20260915"
def test_a_400_on_a_single_day_does_not_mark_ranges_rejected(self):
class AlwaysBad(FakeSession):
def get(self, url, params=None, headers=None, timeout=None):
self.calls.append(params or {})
return FakeResponse(400)
with pytest.raises(RuntimeError):
fetch_espn_scoreboard(AlwaysBad(), URL, params={"dates": "20260913"})
assert not espn_dates._ranges_known_rejected()
def test_when_every_chunk_fails_the_range_itself_supplies_the_error(self):
session = FakeSession(fail_chunks={"20260914", "20260915"})
fetch_espn_scoreboard(session, URL, params={"dates": "20260801-20260801"})
session.calls.clear()
with pytest.raises(RuntimeError):
fetch_espn_scoreboard(session, URL, params={"dates": "20260914-20260915"})
assert session.calls[-1]["dates"] == "20260914-20260915"
+394
View File
@@ -0,0 +1,394 @@
"""A boolean the schema has since turned into an ``{enabled, ...}`` object.
The news plugin's ``global.dynamic_duration`` went from ``true`` to an object
with ``enabled``, ``min_duration_seconds``, ... An install that has not saved
the news settings since still holds ``true``, and on every start the display
service logged::
Plugin news config does not match its schema (loading anyway):
Field 'global.dynamic_duration': Expected type object, got bool
and flagged news degraded. The settings form already reads such a boolean as
``{"enabled": <bool>}`` (``render_nested_section`` in plugin_config.html); the
loader did not. Both now follow ``legacy_bool_as_object``, and the parity tests
below render the real macro against it so the two cannot drift.
"""
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
PROJECT_ROOT = Path(__file__).parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.schema_manager import (
legacy_bool_as_object,
normalize_legacy_booleans,
)
DYNAMIC_DURATION = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"min_duration_seconds": {"type": "integer", "default": 30,
"minimum": 10, "maximum": 300},
"max_duration_seconds": {"type": "integer", "default": 300,
"minimum": 30, "maximum": 600},
"buffer_ratio": {"type": "number", "default": 0.1,
"minimum": 0.01, "maximum": 1.0},
},
"additionalProperties": False,
}
# The shape of news' schema, cut down to what the tests need.
NEWS_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {
"type": "object",
"properties": {
"font_size": {"type": "integer", "default": 12},
"dynamic_duration": DYNAMIC_DURATION,
"display": {
"type": "object",
"properties": {
"scroll_speed": {"type": "number", "default": 1.0},
},
"additionalProperties": False,
},
"headline_paging": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"page_hold_seconds": {"type": "number", "default": 2.0},
},
"additionalProperties": False,
},
},
"additionalProperties": False,
},
"feeds": {
"type": "object",
"properties": {
"custom_feeds": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"schedule": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
},
},
},
},
},
},
},
"additionalProperties": False,
}
# --------------------------------------------------------------------------
# The helper
# --------------------------------------------------------------------------
class TestLegacyBoolAsObject:
def test_true_under_an_enabled_object_becomes_its_switch(self):
assert legacy_bool_as_object(True, DYNAMIC_DURATION) == {"enabled": True}
def test_false_stays_off(self):
"""Upgrading must never turn a feature the user switched off back on."""
assert legacy_bool_as_object(False, DYNAMIC_DURATION) == {"enabled": False}
def test_nullable_object_type_list_counts_as_object(self):
prop = dict(DYNAMIC_DURATION, type=["object", "null"])
assert legacy_bool_as_object(True, prop) == {"enabled": True}
@pytest.mark.parametrize("value", [1, 0, "true", "false", None, [], 2.5])
def test_a_value_that_is_not_a_real_bool_is_untouched(self, value):
assert legacy_bool_as_object(value, DYNAMIC_DURATION) is value
def test_an_object_without_enabled_is_untouched(self):
prop = {"type": "object", "properties": {"speed": {"type": "number"}}}
assert legacy_bool_as_object(True, prop) is True
def test_a_boolean_or_object_union_is_untouched(self):
"""The form draws ``["boolean", "object"]`` as a checkbox, and the
schema accepts the boolean, so there is nothing to upgrade."""
prop = dict(DYNAMIC_DURATION, type=["boolean", "object"])
assert legacy_bool_as_object(True, prop) is True
def test_a_non_object_schema_is_untouched(self):
assert legacy_bool_as_object(True, {"type": "boolean"}) is True
assert legacy_bool_as_object(True, None) is True
class TestNormalizeLegacyBooleans:
def test_top_level(self):
schema = {"type": "object",
"properties": {"dynamic_duration": DYNAMIC_DURATION}}
out = normalize_legacy_booleans({"dynamic_duration": True}, schema)
assert out == {"dynamic_duration": {"enabled": True}}
def test_nested(self):
cfg = {"global": {"font_size": 12, "dynamic_duration": True}}
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
assert out == {"global": {"font_size": 12,
"dynamic_duration": {"enabled": True}}}
def test_several_depths_report_their_paths(self):
schema = {"type": "object", "properties": {
"dynamic_duration": DYNAMIC_DURATION,
"outer": {"type": "object", "properties": {
"inner": {"type": "object", "properties": {
"mode": DYNAMIC_DURATION}}}},
}}
cfg = {"dynamic_duration": False, "outer": {"inner": {"mode": True}}}
changed = []
out = normalize_legacy_booleans(cfg, schema, changed)
assert out == {"dynamic_duration": {"enabled": False},
"outer": {"inner": {"mode": {"enabled": True}}}}
assert changed == ["dynamic_duration", "outer.inner.mode"]
def test_array_items_are_left_alone_as_the_form_does(self):
"""The form hands arrays to widgets and never upgrades inside them."""
cfg = {"feeds": {"custom_feeds": [{"name": "a", "schedule": True}]}}
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
assert out is cfg
def test_already_an_object_is_untouched(self):
cfg = {"global": {"dynamic_duration": {"enabled": False,
"min_duration_seconds": 45}}}
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA) is cfg
def test_other_mismatches_are_untouched(self):
cfg = {"global": {"font_size": True, "display": True,
"dynamic_duration": "yes"}}
changed = []
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA, changed) is cfg
assert changed == []
def test_keys_the_schema_does_not_declare_are_untouched(self):
cfg = {"global": {"mystery": True}, "vegas_width_pct": 50}
assert normalize_legacy_booleans(cfg, NEWS_SCHEMA) is cfg
def test_the_stored_config_is_not_mutated(self):
"""load_config() may hand back a cached dict; it must stay as stored."""
cfg = {"enabled": True, "global": {"dynamic_duration": True}}
before = json.loads(json.dumps(cfg))
out = normalize_legacy_booleans(cfg, NEWS_SCHEMA)
assert cfg == before
assert out is not cfg and out["global"] is not cfg["global"]
@pytest.mark.parametrize("config,schema", [
(None, NEWS_SCHEMA), ([], NEWS_SCHEMA), ({"a": True}, None),
({"a": True}, {"type": "object"}),
])
def test_tolerates_odd_inputs(self, config, schema):
assert normalize_legacy_booleans(config, schema) is config
# --------------------------------------------------------------------------
# The load path
# --------------------------------------------------------------------------
PLUGIN_ID = "news-shaped"
@pytest.fixture
def load(tmp_path):
"""Run the real ``PluginManager.load_plugin`` for a plugin with
``NEWS_SCHEMA`` and the given stored section; the plugin class is stubbed.
Returns ``(loaded, config the plugin received, logger, health_tracker)``.
"""
plugins_dir = tmp_path / "plugins"
plugin_dir = plugins_dir / PLUGIN_ID
plugin_dir.mkdir(parents=True)
manifest = {"id": PLUGIN_ID, "name": "News-shaped", "version": "1.0.0",
"entry_point": "manager.py", "class_name": "Plugin"}
(plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(plugin_dir / "config_schema.json").write_text(json.dumps(NEWS_SCHEMA),
encoding="utf-8")
def _load(stored_section):
config_manager = MagicMock()
config_manager.load_config.return_value = {
PLUGIN_ID: json.loads(json.dumps(stored_section))}
with patch('src.common.permission_utils.ensure_directory_permissions'):
manager = PluginManager(plugins_dir=str(plugins_dir),
config_manager=config_manager,
display_manager=MagicMock(),
cache_manager=MagicMock())
manager.logger = MagicMock()
manager.health_tracker = MagicMock()
manager.plugin_manifests[PLUGIN_ID] = manifest
manager.plugin_loader.find_plugin_directory = MagicMock(return_value=plugin_dir)
received = {}
def fake_load_plugin(**kwargs):
received.update(kwargs["config"])
instance = MagicMock()
instance.validate_config.return_value = True
return instance, MagicMock()
manager.plugin_loader.load_plugin = MagicMock(side_effect=fake_load_plugin)
loaded = manager.load_plugin(PLUGIN_ID)
return loaded, received, manager.logger, manager.health_tracker
return _load
def _schema_warnings(logger):
return [c for c in logger.warning.call_args_list
if "does not match its schema" in str(c.args[0])]
def _degraded_reason(health_tracker):
calls = [c.args for c in health_tracker.set_degraded.call_args_list
if c.args[0] == PLUGIN_ID]
assert calls, "schema validation never ran"
return calls[-1][1]
class TestLoadPath:
def test_news_shaped_legacy_config_loads_without_warning_or_degraded(self, load):
stored = {"enabled": True,
"global": {"font_size": 12, "dynamic_duration": True,
"headline_paging": {"enabled": False}}}
loaded, received, logger, tracker = load(stored)
assert loaded is True
assert _schema_warnings(logger) == []
assert _degraded_reason(tracker) is None
# The plugin gets the object, with the schema defaults filled in.
assert received["global"]["dynamic_duration"] == {
"enabled": True, "min_duration_seconds": 30,
"max_duration_seconds": 300, "buffer_ratio": 0.1}
# And the upgrade is logged once, below warning level.
assert any("global.dynamic_duration" in str(c.args)
for c in logger.info.call_args_list)
def test_a_legacy_false_loads_switched_off(self, load):
loaded, received, logger, tracker = load(
{"enabled": True, "global": {"dynamic_duration": False}})
assert loaded is True
assert received["global"]["dynamic_duration"]["enabled"] is False
assert _schema_warnings(logger) == []
assert _degraded_reason(tracker) is None
def test_a_real_mismatch_still_warns_and_degrades(self, load):
loaded, received, logger, tracker = load(
{"enabled": True, "global": {"dynamic_duration": True,
"font_size": "big"}})
assert loaded is True # warn-only, as before
warnings = _schema_warnings(logger)
assert len(warnings) == 1
assert "font_size" in str(warnings[0].args)
assert "dynamic_duration" not in str(warnings[0].args)
reason = _degraded_reason(tracker)
assert reason and "font_size" in reason
def test_a_boolean_under_an_object_without_enabled_still_warns(self, load):
loaded, received, logger, tracker = load(
{"enabled": True, "global": {"display": True}})
assert loaded is True
assert received["global"]["display"] is True
warnings = _schema_warnings(logger)
assert len(warnings) == 1 and "global.display" in str(warnings[0].args)
assert _degraded_reason(tracker)
# --------------------------------------------------------------------------
# Parity with the settings form
# --------------------------------------------------------------------------
PARITY_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"global": {"type": "object", "properties": {
"dynamic_duration": DYNAMIC_DURATION,
"outer": {"type": "object", "properties": {
"inner": {"type": "object", "properties": {
"enabled": {"type": "boolean", "default": True},
"level": {"type": "integer", "default": 1},
}},
}},
"union": {"type": ["boolean", "object"], "properties": {
"enabled": {"type": "boolean"}}},
}},
},
}
class _Checkboxes(HTMLParser):
def __init__(self):
super().__init__()
self.checked = {}
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "input" and a.get("type") == "checkbox" and a.get("name"):
self.checked[a["name"]] = "checked" in a
def _form_checkboxes(stored):
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": "demo", "name": "Demo", "description": "", "enabled": True,
"author": "me", "version": "1.0.0"}
html = env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=PARITY_SCHEMA, config=json.loads(json.dumps(stored)))
parser = _Checkboxes()
parser.feed(html)
return parser.checked
def _loader_enabled(normalized, *path):
node = normalized
for key in path:
node = node.get(key) if isinstance(node, dict) else None
return isinstance(node, dict) and node.get("enabled") is True
@pytest.mark.parametrize("value", [True, False, 1, "true", None])
def test_form_and_loader_agree_on_what_a_stored_value_means(value):
"""Where the loader reads a stored value as ``{"enabled": true}``, the form
draws the object's ``enabled`` checkbox ticked, and nowhere else."""
stored = {"enabled": True,
"global": {"dynamic_duration": value, "outer": {"inner": value}}}
normalized = normalize_legacy_booleans(stored, PARITY_SCHEMA)
boxes = _form_checkboxes(stored)
for path in (("global", "dynamic_duration"), ("global", "outer", "inner")):
name = ".".join(path) + ".enabled"
assert name in boxes, f"form drew no {name} checkbox"
assert boxes[name] is _loader_enabled(normalized, *path), name
@pytest.mark.parametrize("value", [True, False])
def test_form_and_loader_agree_a_boolean_union_stays_a_boolean(value):
stored = {"enabled": True, "global": {"union": value}}
normalized = normalize_legacy_booleans(stored, PARITY_SCHEMA)
boxes = _form_checkboxes(stored)
assert normalized is stored
assert boxes.get("global.union") is value
assert "global.union.enabled" not in boxes
+59 -6
View File
@@ -1,16 +1,25 @@
""" """
Tests for src.common.permission_utils's URL-credential redaction. Tests for src.common.permission_utils.
Covers the fix for a CodeQL clear-text-logging-of-secrets alert: Covers two things:
install_requirements_file() must never let a private index URL's embedded
user:pass@ credentials reach logs or its returned CompletedProcess, since * URL-credential redaction -- the fix for a CodeQL clear-text-logging-of-secrets
pip can echo that URL back verbatim in its own stderr/stdout on failure. alert: install_requirements_file() must never let a private index URL's
embedded user:pass@ credentials reach logs or its returned CompletedProcess,
since pip can echo that URL back verbatim in its own stderr/stdout on failure.
* ensure_shared_group_ownership() staying a silent no-op on platforms without
the POSIX ownership APIs.
""" """
import os
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from src.common.permission_utils import _redact_url_credentials, install_requirements_file from src.common.permission_utils import (
_redact_url_credentials,
ensure_shared_group_ownership,
install_requirements_file,
)
class TestRedactUrlCredentials: class TestRedactUrlCredentials:
@@ -81,3 +90,47 @@ class TestInstallRequirementsFileRedaction:
assert "swordfish" not in result.stderr assert "swordfish" not in result.stderr
assert "https://***:***@pypi.internal" in result.stdout assert "https://***:***@pypi.internal" in result.stdout
assert "https://***:***@pypi.internal" in result.stderr assert "https://***:***@pypi.internal" in result.stderr
class TestEnsureSharedGroupOwnership:
"""The chgrp self-heal must stay a no-op wherever it cannot apply.
``ConfigManager.load_config()`` calls this on every load that finds a
secrets file, and its callers only ever catch ``OSError``. An
``AttributeError`` from looking up a POSIX-only name on Windows therefore
escaped all the way out of ``load_config``, and took the import of
``web_interface.app`` with it on any Windows checkout that had a
``config/config_secrets.json``.
"""
def test_no_geteuid_is_a_silent_no_op(self, monkeypatch, tmp_path):
secrets = tmp_path / "config_secrets.json"
secrets.write_text("{}", encoding='utf-8')
monkeypatch.delattr(os, "geteuid", raising=False)
monkeypatch.delattr(os, "chown", raising=False)
ensure_shared_group_ownership(secrets) # must not raise
def test_non_root_never_chowns(self, monkeypatch, tmp_path):
chown = MagicMock()
monkeypatch.setattr(os, "geteuid", lambda: 1000, raising=False)
monkeypatch.setattr(os, "chown", chown, raising=False)
ensure_shared_group_ownership(tmp_path / "config_secrets.json")
chown.assert_not_called()
def test_root_chowns_a_file_whose_group_is_wrong(self, monkeypatch, tmp_path):
secrets = tmp_path / "config_secrets.json"
secrets.write_text("{}", encoding='utf-8')
# Any gid the file does not already have, so the chgrp is due.
wanted = secrets.stat().st_gid + 1
chown = MagicMock()
monkeypatch.setattr(os, "geteuid", lambda: 0, raising=False)
monkeypatch.setattr(os, "chown", chown, raising=False)
monkeypatch.setattr('src.common.permission_utils.get_shared_group_gid',
lambda: wanted)
ensure_shared_group_ownership(secrets)
chown.assert_called_once_with(secrets, -1, wanted)
+79
View File
@@ -0,0 +1,79 @@
"""The Pi 5 rule in src/pi5_matrix_support.py mirrors the pinned library.
Rp1PioPlatformDetected() and Rp1PioConfigSupported() in
rpi-rgb-led-matrix-master/lib/rp1/rp1_pio_backend.cc (commit 1ee4f76). When the
submodule is bumped and those change, these are the cases to revisit.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src import pi5_matrix_support as pi5 # noqa: E402
@pytest.fixture
def model_file(tmp_path, monkeypatch):
def write(model):
path = tmp_path / 'model'
# The device tree string is NUL-terminated.
path.write_bytes(model.encode() + b'\x00')
monkeypatch.setattr(pi5, 'MODEL_PATH', str(path))
return write
@pytest.mark.parametrize('model', [
'Raspberry Pi 5 Model B Rev 1.0',
'Raspberry Pi 500 Rev 1.0',
'Raspberry Pi Compute Module 5 Rev 1.0',
])
def test_pi5_family_is_detected(model_file, model):
model_file(model)
assert pi5.is_raspberry_pi_5()
@pytest.mark.parametrize('model', [
'Raspberry Pi 4 Model B Rev 1.5',
'Raspberry Pi Zero 2 W Rev 1.0',
'Raspberry Pi Compute Module 4 Rev 1.0',
])
def test_other_boards_are_not(model_file, model):
model_file(model)
assert not pi5.is_raspberry_pi_5()
def test_no_device_tree_is_not_a_pi5(tmp_path, monkeypatch):
monkeypatch.setattr(pi5, 'MODEL_PATH', str(tmp_path / 'missing'))
assert not pi5.is_raspberry_pi_5()
@pytest.mark.parametrize('hardware', [
{},
{'row_address_type': 0}, {'row_address_type': 2}, {'row_address_type': '2'},
{'parallel': 1}, {'parallel': 3},
{'hardware_mapping': 'regular'}, {'hardware_mapping': 'regular-pi1'},
{'hardware_mapping': 'classic'}, {'hardware_mapping': 'adafruit-hat'},
{'hardware_mapping': 'adafruit-hat-pwm'}, {'hardware_mapping': ''},
])
def test_what_the_pi5_path_supports(hardware):
assert pi5.pi5_unsupported_settings(hardware) is None
@pytest.mark.parametrize('hardware,named', [
({'row_address_type': 1}, 'row address type 1'),
({'row_address_type': 3}, 'row address type 3'),
({'row_address_type': 4}, 'row address type 4'),
({'row_address_type': '5'}, 'row address type 5'),
({'parallel': 4}, 'parallel 4'),
({'hardware_mapping': 'compute-module'}, 'hardware mapping "compute-module"'),
])
def test_what_it_does_not(hardware, named):
message = pi5.pi5_unsupported_settings(hardware)
assert message is not None and named in message
def test_every_problem_is_named():
message = pi5.pi5_unsupported_settings({'row_address_type': 5, 'parallel': 4})
assert 'row address type 5' in message and 'parallel 4' in message
+11 -6
View File
@@ -142,8 +142,14 @@ class TestStaleFindingsAreDropped:
assert still_unresolved(entries, set(), {"odds-ticker"}) == [] assert still_unresolved(entries, set(), {"odds-ticker"}) == []
def test_genuinely_missing_on_disk_is_kept(self): def test_genuinely_missing_on_disk_is_kept(self):
entries = [{"plugin_id": "data", "type": self.ON_DISK}] entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
assert still_unresolved(entries, set(), {"odds-ticker"}) == entries assert still_unresolved(entries, {"ghost-plugin"}, {"odds-ticker"}) == entries
def test_missing_on_disk_no_longer_in_config_is_dropped(self):
# Removed from config.json (or never a plugin entry at all, like a
# core key an older build misread): nothing left to reinstall.
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
def test_unrecheckable_kinds_are_kept(self): def test_unrecheckable_kinds_are_kept(self):
# Filtering must only ever remove what it can prove stale. # Filtering must only ever remove what it can prove stale.
@@ -164,7 +170,6 @@ class TestStaleFindingsAreDropped:
live = still_unresolved(entries, installed, installed) live = still_unresolved(entries, installed, installed)
# All four "installed but not in config" findings were false and clear. # All four "installed but not in config" findings were false and clear.
# 'data' legitimately is not installed, so this filter keeps it; it stops # 'data' is not a plugin entry in config (it came from the secrets
# being reported because _get_config_state() no longer invents it from the # file), so its "not on disk" finding is provably stale and clears too.
# secrets file, which takes effect on the next reconciliation run. assert live == []
assert [e["plugin_id"] for e in live] == ["data"]
+10 -1
View File
@@ -94,9 +94,16 @@ class TestStaleFindingsAreDroppedByTheEndpoint:
def test_a_finding_that_still_holds_is_kept(self, client): def test_a_finding_that_still_holds_is_kept(self, client):
c = client({"done": True, "unresolved": [ c = client({"done": True, "unresolved": [
{"plugin_id": "ghost-plugin", "type": ON_DISK}]}) {"plugin_id": "ghost-plugin", "type": ON_DISK}]},
config={"ghost-plugin": {"enabled": True}})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"] assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
def test_a_plugin_removed_from_config_is_no_longer_reported(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "ghost-plugin", "type": ON_DISK}]},
config={"other-plugin": {"enabled": True}})
assert _get(c)["unresolved"] == []
def test_the_reported_device_verdict_clears(self, client): def test_the_reported_device_verdict_clears(self, client):
"""The five findings the live device served, against its real state.""" """The five findings the live device served, against its real state."""
installed = ["football-scoreboard", "ledmatrix-weather", installed = ["football-scoreboard", "ledmatrix-weather",
@@ -157,11 +164,13 @@ class TestTheFilterUsesTheReconcilersOwnRules:
# finding on the strength of something nothing can read. # finding on the strength of something nothing can read.
c = client({"done": True, "unresolved": [ c = client({"done": True, "unresolved": [
{"plugin_id": "broken-plugin", "type": ON_DISK}]}, {"plugin_id": "broken-plugin", "type": ON_DISK}]},
config={"broken-plugin": {"enabled": True}},
corrupt=["broken-plugin"]) corrupt=["broken-plugin"])
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"] assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client): def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
c = client({"done": True, "unresolved": [ c = client({"done": True, "unresolved": [
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]}, {"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
config={"weather.standalone-backup-20260101": {"enabled": True}},
installed=["weather.standalone-backup-20260101"]) installed=["weather.standalone-backup-20260101"])
assert len(_get(c)["unresolved"]) == 1 assert len(_get(c)["unresolved"]) == 1
+603
View File
@@ -0,0 +1,603 @@
"""src.common.sports_helpers: behaviour, host contract, and parity with the plugins.
The bodies were copied from the scoreboard plugins' sports.py, so the plugins'
own tests already cover them there. They are ported here for two reasons: core
now owns a copy that can drift on its own, and a plugin that deletes its copy
loses those tests with it.
The parity class is what keeps "byte-identical" true after this lands. Point
LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every promoted body is
compared, as a docstring-stripped AST, against every plugin copy that carries
it. Without the variable it skips rather than fails, since core CI has no
plugins checkout; ledmatrix-plugins CI runs the same comparison against core
(scripts/check_sports_helpers_parity.py, ledmatrix-plugins#495).
"""
import ast
import logging
import math
import os
import time
from pathlib import Path
import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from src.common import sports_helpers
from src.common.sports_helpers import (
MAX_WINDOW_DAYS,
MIN_WINDOW_DAYS,
SportsHelpersMixin,
clamp_seconds,
clamp_window,
logo_needs_refresh,
spread_weighted_order,
)
from src.common.sports_shared import SportsCoreSharedMixin
# ---------------------------------------------------------------------------
# Free functions
# ---------------------------------------------------------------------------
class TestClampWindow:
def test_within_range_is_kept(self):
assert clamp_window(14, 7) == 14
assert clamp_window("30", 7) == 30
def test_clamped_to_bounds(self):
assert (MIN_WINDOW_DAYS, MAX_WINDOW_DAYS) == (1, 60)
assert clamp_window(0, 7) == 1
assert clamp_window(-5, 7) == 1
assert clamp_window(365, 7) == 60
@pytest.mark.parametrize("bad", [None, "abc", [], {}, float("nan")])
def test_unusable_falls_back(self, bad):
assert clamp_window(bad, 7) == 7
@pytest.mark.parametrize("inf", [float("inf"), float("-inf")])
def test_infinity_falls_back_instead_of_raising(self, inf):
# json.loads accepts a bare Infinity; int(inf) raises OverflowError.
with pytest.raises(OverflowError):
int(inf)
assert clamp_window(inf, 7) == 7
class TestClampSeconds:
def test_defaults_bound_five_seconds_to_a_day(self):
assert clamp_seconds(1, 60) == 5
assert clamp_seconds(10 ** 9, 60) == 86400
assert clamp_seconds(300, 60) == 300
def test_custom_bounds(self):
assert clamp_seconds(1, 60, low=30, high=900) == 30
assert clamp_seconds(5000, 60, low=30, high=900) == 900
@pytest.mark.parametrize("bad", [None, "x", float("nan"), float("inf")])
def test_unusable_falls_back(self, bad):
assert clamp_seconds(bad, 60) == 60
def _png(path, size=(64, 64), color=(100, 100, 100, 255), marker=None):
img = Image.new("RGBA", size, color)
info = None
if marker is not None:
info = PngInfo()
info.add_text("ledmatrix_placeholder", marker)
img.save(path, "PNG", pnginfo=info)
return path
class TestLogoNeedsRefresh:
def test_stale_placeholder_is_retried(self, tmp_path):
from src.logo_downloader import PLACEHOLDER_RETRY_SECONDS
stamp = str(time.time() - PLACEHOLDER_RETRY_SECONDS - 60)
assert logo_needs_refresh(_png(tmp_path / "A.png", marker=stamp)) is True
def test_fresh_placeholder_is_trusted(self, tmp_path):
assert logo_needs_refresh(_png(tmp_path / "A.png", marker=str(time.time()))) is False
def test_real_logo_is_never_refreshed(self, tmp_path):
assert logo_needs_refresh(_png(tmp_path / "A.png", size=(32, 32),
color=(200, 0, 0, 255))) is False
def test_missing_file_is_not_a_placeholder(self, tmp_path):
assert logo_needs_refresh(tmp_path / "nope.png") is False
def test_core_without_placeholder_marking_keeps_old_behaviour(self, monkeypatch, tmp_path):
# A None entry in sys.modules makes the import raise ImportError, the
# same thing an older core without these names does.
monkeypatch.setitem(__import__("sys").modules, "src.logo_downloader", None)
stamp = str(time.time() - 10 ** 7)
assert logo_needs_refresh(_png(tmp_path / "A.png", marker=stamp)) is False
def test_the_import_is_deferred(self):
tree = ast.parse(Path(sports_helpers.__file__).read_text(encoding="utf-8"))
top = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))]
assert not any(isinstance(n, ast.ImportFrom) and n.module == "src.logo_downloader"
for n in top)
def _back_to_back(cycle):
return any(cycle[i] == cycle[(i + 1) % len(cycle)] for i in range(len(cycle)))
class TestSpreadWeightedOrder:
def test_equal_weights_are_plain_order(self):
assert spread_weighted_order([1, 1, 1]) == [0, 1, 2]
assert spread_weighted_order([]) == []
def test_a_favourite_at_the_end_wraps_its_extra_turn(self):
order = spread_weighted_order([1, 1, 1, 2])
assert sorted(order) == [0, 1, 2, 3, 3]
assert not _back_to_back(order)
@pytest.mark.parametrize("weights,expected", [
([3, 1, 1], [0, 1, 0, 2, 0]),
([1, 3, 1], [0, 1, 1, 2, 1]),
([5, 1], None),
([2, 1], None),
])
def test_ratio_wins_over_spacing(self, weights, expected):
order = spread_weighted_order(weights)
assert [order.count(i) for i in range(len(weights))] == weights
if expected is not None:
assert order == expected
def test_mixin_exposes_it_as_a_staticmethod(self):
assert SportsHelpersMixin._spread_weighted_order([1, 2]) == spread_weighted_order([1, 2])
assert isinstance(SportsHelpersMixin.__dict__["_spread_weighted_order"], staticmethod)
# ---------------------------------------------------------------------------
# Mixin methods, against stand-in hosts carrying only what each reads
# ---------------------------------------------------------------------------
class _Dwell(SportsHelpersMixin):
def __init__(self, last_game_switch):
self._last_display_call_monotonic = 0.0
self.last_game_switch = last_game_switch
class TestResetDwellOnReentry:
def test_first_frame_resets(self):
mgr = _Dwell(time.time() - 6.0)
assert mgr._reset_dwell_on_reentry() is True
assert time.time() - mgr.last_game_switch < 1.0
def test_frames_inside_one_stint_do_not(self):
mgr = _Dwell(time.time() - 6.0)
mgr._reset_dwell_on_reentry()
stamp = mgr.last_game_switch
assert [mgr._reset_dwell_on_reentry() for _ in range(3)] == [False] * 3
assert mgr.last_game_switch == stamp
def test_returning_after_a_long_gap_resets(self):
mgr = _Dwell(time.time() - 150.0)
mgr._last_display_call_monotonic = time.monotonic() - 150.0
assert mgr._reset_dwell_on_reentry() is True
def test_just_under_the_threshold_is_the_same_stint(self):
mgr = _Dwell(time.time() - 150.0)
mgr._last_display_call_monotonic = time.monotonic() - (
SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS - 1.0)
stamp = mgr.last_game_switch
assert mgr._reset_dwell_on_reentry() is False
assert mgr.last_game_switch == stamp
def test_live_zero_sentinel_survives(self):
mgr = _Dwell(0)
assert mgr._reset_dwell_on_reentry() is False
assert mgr.last_game_switch == 0
def test_host_without_any_state(self):
mgr = SportsHelpersMixin()
assert mgr._reset_dwell_on_reentry() is False # no last_game_switch
assert mgr._last_display_call_monotonic > 0
def _game(gid, home="X", away="Y"):
return {"id": gid, "home_abbr": home, "away_abbr": away}
class _Switch(SportsHelpersMixin):
def __init__(self, games, boost=None, favorites=("UF",)):
self.games_list = games
self.favorite_teams = list(favorites)
if boost is not None:
self.favorite_rotation_boost = boost
self.current_game_index = 0
def _is_favorite_game(self, g):
return g["home_abbr"] in self.favorite_teams or g["away_abbr"] in self.favorite_teams
def walk(self, steps):
shown = []
for _ in range(steps):
self.current_game_index = self._next_switch_index()
shown.append(self.games_list[self.current_game_index]["id"])
return shown
class TestNextSwitchIndex:
def test_no_boost_attribute_is_the_plain_rotation(self):
s = _Switch([_game("a"), _game("b", "UF"), _game("c")])
assert s.walk(6) == ["b", "c", "a", "b", "c", "a"]
def test_boost_without_a_favourite_changes_nothing(self):
assert _Switch([_game("a"), _game("b"), _game("c")], boost=3).walk(3) == ["b", "c", "a"]
def test_boost_two_spreads_the_favourite(self):
s = _Switch([_game("a"), _game("uf", "UF"), _game("c"), _game("d")], boost=2)
cycle = s.walk(5)
assert sorted(cycle) == ["a", "c", "d", "uf", "uf"]
assert not _back_to_back(cycle)
assert s.walk(5) == cycle
def test_boost_three_two_favourites(self):
s = _Switch([_game("a", "UF"), _game("b"), _game("c", "FSU"), _game("d"), _game("e")],
boost=3, favorites=("UF", "FSU"))
cycle = s.walk(9)
assert cycle.count("a") == 3 and cycle.count("c") == 3
def test_index_reset_by_update_is_followed(self):
s = _Switch([_game("a"), _game("uf", "UF"), _game("c"), _game("d")], boost=2)
s.walk(3)
s.current_game_index = 0
assert s.walk(1)[0] == "uf"
def test_single_card_stays_put(self):
assert _Switch([_game("uf", "UF")], boost=5).walk(3) == ["uf", "uf", "uf"]
class _Settings(SportsHelpersMixin):
def __init__(self, mode_config):
self.mode_config = mode_config
self.logger = logging.getLogger("test.sports_helpers")
self.league = "nfl"
class TestSettingInt:
def test_reads_and_clamps(self):
assert _Settings({"n": 3})._setting_int("n", 1, 1, 5) == 3
assert _Settings({"n": "4"})._setting_int("n", 1, 1, 5) == 4
assert _Settings({"n": 99})._setting_int("n", 1, 1, 5) == 5
assert _Settings({"n": -2})._setting_int("n", 1, 1, 5) == 1
assert _Settings({})._setting_int("n", 2, 1, 5) == 2
@pytest.mark.parametrize("bad", ["abc", None, [], float("inf"), float("nan")])
def test_unusable_falls_back_with_a_warning(self, bad, caplog):
with caplog.at_level(logging.WARNING, logger="test.sports_helpers"):
assert _Settings({"n": bad})._setting_int("n", 2, 1, 5) == 2
assert "ignoring unusable n" in caplog.text
class _Custom(SportsHelpersMixin):
def __init__(self, config, mode=None):
self.config = config
if mode is not None:
self.SKIN_MODE = mode
class TestModeCustomization:
def test_no_mode_returns_block_as_is(self):
cust = {"score": {"font_size": 10}}
assert _Custom({"customization": cust})._mode_customization() is cust
def test_non_dict_customization(self):
assert _Custom({"customization": "junk"}, "live")._mode_customization() == {}
assert _Custom({}, "live")._mode_customization() == {}
def test_mode_overrides_merge_and_none_inherits(self):
cfg = {"customization": {
"score": {"font_size": 10, "text_color": [1, 1, 1]},
"layout": {"score": {"x_offset": 2, "y_offset": 3}},
"modes": {"live": {
"score": {"font_size": 12, "text_color": None},
"status": "not-a-dict",
"layout": {"score": {"y_offset": 0, "x_offset": None}, "bad": 5},
}},
}}
merged = _Custom(cfg, "live")._mode_customization()
assert merged["score"] == {"font_size": 12, "text_color": [1, 1, 1]}
assert merged["layout"]["score"] == {"x_offset": 2, "y_offset": 0}
assert "bad" not in merged["layout"]
# The stored config is not mutated.
assert cfg["customization"]["score"]["font_size"] == 10
assert cfg["customization"]["layout"]["score"]["y_offset"] == 3
def test_other_mode_is_untouched(self):
cust = {"modes": {"live": {"score": {"font_size": 12}}}}
assert _Custom({"customization": cust}, "recent")._mode_customization() is cust
class TestOddsColor:
def test_without_element_color_is_green(self):
assert SportsHelpersMixin()._odds_color() == (0, 255, 0)
def test_raising_getter_is_green(self):
class Host(SportsHelpersMixin):
def _element_color(self, element, default):
raise RuntimeError("boom")
assert Host()._odds_color() == (0, 255, 0)
def test_asks_for_odds_text_with_green_default(self):
seen = []
class Host(SportsHelpersMixin):
def _element_color(self, element, default):
seen.append((element, default))
return (9, 9, 9)
assert Host()._odds_color() == (9, 9, 9)
assert seen == [("odds_text", (0, 255, 0))]
class _Scorebug(SportsHelpersMixin):
def __init__(self, scroll_card):
self.config = {"scroll_card": scroll_card}
def _card_option(self, key, default=None):
return SportsCoreSharedMixin._card_option(self, key, default)
def _format_game_date(self, game_date, game=None):
return "DATE"
def _format_game_time(self, game_time):
return "TIME"
class TestUpcomingDateAndTimeText:
def _text(self, block):
return _Scorebug(block)._upcoming_date_and_time_text("2026-10-07", "19:00")
def test_defaults_draw_both(self):
assert self._text({}) == ("DATE", "TIME")
def test_scroll_card_toggles_do_not_reach_the_scorebug(self):
assert self._text({"show_date": False, "show_time": False}) == ("DATE", "TIME")
def test_switch_toggles(self):
assert self._text({"switch_show_date": False}) == ("", "TIME")
assert self._text({"switch_show_time": False}) == ("DATE", "")
class TestFavoriteKey:
def test_defaults_to_abbreviation(self):
assert SportsHelpersMixin()._favorite_key({"home_abbr": "UF"}, "home") == "UF"
def test_missing_abbreviation_never_matches(self):
assert SportsHelpersMixin()._favorite_key({}, "away") is None
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads():
"""Every ``self.X`` / ``getattr(self, "X")`` the mixin reads, by parsing it."""
tree = ast.parse(Path(sports_helpers.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body
if isinstance(n, ast.ClassDef) and n.name == "SportsHelpersMixin")
names = set()
for node in ast.walk(cls):
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"):
names.add(node.attr)
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "getattr" and len(node.args) >= 2
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
and isinstance(node.args[1], ast.Constant)):
names.add(node.args[1].value)
return names
class _Host(SportsCoreSharedMixin, SportsHelpersMixin):
"""The smallest thing a scoreboard's SportsCore has to be for these to run."""
def __init__(self, config, mode_config=None):
self.config = config
self.mode_config = mode_config or {}
self.logger = logging.getLogger("test.sports_helpers.host")
self.games_list = []
self.current_game_index = 0
self.last_game_switch = 0
def _is_favorite_game(self, game):
return game.get("home_abbr") == "UF"
class TestHostContract:
def test_every_host_read_is_documented(self):
defined = set(dir(SportsHelpersMixin))
# Written by the mixin itself before (or instead of) being read.
own_state = {"_last_display_call_monotonic", "_switch_order",
"_switch_order_key", "_switch_position"}
needed = _self_reads() - defined - own_state
doc = sports_helpers.__doc__
undocumented = sorted(n for n in needed | own_state if f"``{n}``" not in doc)
assert undocumented == [], (
f"self attributes read but not in the module docstring's host "
f"contract: {undocumented}")
def test_every_constant_read_has_a_default(self):
# SKIN_MODE is the one exception: the host's mode name, read with
# getattr(self, 'SKIN_MODE', None) where absence means "no mode". A
# default here would say nothing the getattr does not already.
missing = sorted(n for n in _self_reads() - {"SKIN_MODE"}
if n.upper() == n and not hasattr(SportsHelpersMixin, n))
assert missing == []
def test_defaults_match_what_the_plugins_ship(self):
assert SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS == 5.0
assert SportsHelpersMixin.favorite_rotation_boost == 1
def test_stateless_and_constructor_free(self):
assert "__init__" not in SportsHelpersMixin.__dict__
assert SportsHelpersMixin().__dict__ == {}
def test_base_order_does_not_matter(self):
class Reversed(SportsHelpersMixin, SportsCoreSharedMixin):
__init__ = _Host.__init__
_is_favorite_game = _Host._is_favorite_game
for cls in (_Host, Reversed):
h = cls({"scroll_card": {"switch_show_time": False, "time_format": "24h"}})
assert h._upcoming_date_and_time_text("10/7", "7:00 PM") == ("10/7", "")
def test_shared_mixin_supplies_the_card_helpers(self):
h = _Host({"scroll_card": {"switch_date_format": "abbrev", "time_format": "24h"}})
assert h._upcoming_date_and_time_text("10/7", "7:05 PM") == ("Oct 7", "19:05")
def test_odds_color_resolves_through_element_style(self):
h = _Host({"customization": {"odds_text": {"text_color": [1, 2, 3]}}})
assert tuple(h._odds_color()) == (1, 2, 3)
assert tuple(_Host({})._odds_color()) == (0, 255, 0)
def test_rotation_settings_and_dwell_on_one_host(self):
h = _Host({}, mode_config={"favorite_rotation_boost": "2"})
h.favorite_rotation_boost = h._setting_int("favorite_rotation_boost", 1, 1, 5)
h.games_list = [_game("a"), _game("uf", "UF"), _game("c")]
seen = []
for _ in range(4):
h.current_game_index = h._next_switch_index()
seen.append(h.games_list[h.current_game_index]["id"])
assert sorted(seen) == ["a", "c", "uf", "uf"]
h.last_game_switch = time.time() - 100
assert h._reset_dwell_on_reentry() is True
# ---------------------------------------------------------------------------
# Parity with the plugin copies
# ---------------------------------------------------------------------------
SCOREBOARDS = ("afl", "baseball", "basketball", "football", "hockey",
"lacrosse", "nrl", "soccer", "ufc")
#: core name -> (where it lives in the plugins' sports.py, plugin name,
#: plugins expected to carry it).
_ALL = SCOREBOARDS
_NOT_UFC = tuple(p for p in SCOREBOARDS if p != "ufc")
PROMOTED = {
"clamp_window": ("module", "_clamp_window", _ALL),
"clamp_seconds": ("module", "_clamp_seconds", _ALL),
"logo_needs_refresh": ("module", "_logo_needs_refresh", _ALL),
"spread_weighted_order": ("SportsCore", "_spread_weighted_order", _ALL),
"_mode_customization": ("SportsCore", "_mode_customization", _ALL),
"_setting_int": ("SportsCore", "_setting_int", _ALL),
"_reset_dwell_on_reentry": ("SportsCore", "_reset_dwell_on_reentry", _ALL),
"_next_switch_index": ("SportsCore", "_next_switch_index", _ALL),
"_odds_color": ("SportsCore", "_odds_color", _NOT_UFC),
"_upcoming_date_and_time_text": ("SportsCore", "_upcoming_date_and_time_text", _NOT_UFC),
}
#: Public names here that are private in the plugins.
_RENAMES = {
"clamp_window": "_clamp_window",
"clamp_seconds": "_clamp_seconds",
"logo_needs_refresh": "_logo_needs_refresh",
"spread_weighted_order": "_spread_weighted_order",
"MIN_WINDOW_DAYS": "_MIN_WINDOW_DAYS",
"MAX_WINDOW_DAYS": "_MAX_WINDOW_DAYS",
}
def _plugins_root():
raw = os.environ.get("LEDMATRIX_PLUGINS")
if not raw:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare sports_helpers against the plugin copies; in CI "
"this parity check runs in ledmatrix-plugins "
"scripts/check_sports_helpers_parity.py (#495)")
root = Path(raw)
if (root / "plugins").is_dir():
root = root / "plugins"
if not (root / "football-scoreboard" / "sports.py").is_file():
pytest.skip(f"LEDMATRIX_PLUGINS={raw} has no football-scoreboard/sports.py")
return root
class _Normalise(ast.NodeTransformer):
def visit_Name(self, node):
node.id = _RENAMES.get(node.id, node.id)
return node
def _func(self, node):
node.name = _RENAMES.get(node.name, node.name)
node.decorator_list = []
body = node.body
if (body and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and isinstance(body[0].value.value, str)):
node.body = body[1:] or [ast.Pass()]
self.generic_visit(node)
return node
visit_FunctionDef = _func
def _dump(node):
node = ast.parse(ast.unparse(node)).body[0] # detach and copy
return ast.dump(_Normalise().visit(node))
def _definitions(tree):
module, core = {}, {}
for node in tree.body:
if isinstance(node, ast.FunctionDef):
module[node.name] = node
elif isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name):
module[node.targets[0].id] = node
elif isinstance(node, ast.ClassDef) and node.name == "SportsCore":
for item in node.body:
if isinstance(item, ast.FunctionDef):
core[item.name] = item
elif isinstance(item, (ast.Assign, ast.AnnAssign)):
target = item.targets[0] if isinstance(item, ast.Assign) else item.target
if isinstance(target, ast.Name):
core[target.id] = item
return {"module": module, "SportsCore": core}
def _core_definitions():
tree = ast.parse(Path(sports_helpers.__file__).read_text(encoding="utf-8"))
out = {}
for node in tree.body:
if isinstance(node, ast.FunctionDef):
out[node.name] = node
elif isinstance(node, ast.ClassDef) and node.name == "SportsHelpersMixin":
for item in node.body:
if isinstance(item, ast.FunctionDef):
out[item.name] = item
return out
class TestParityWithPlugins:
@pytest.mark.parametrize("name", sorted(PROMOTED))
def test_body_matches_every_plugin_copy(self, name):
root = _plugins_root()
where, plugin_name, carriers = PROMOTED[name]
ours = _dump(_core_definitions()[name])
drifted, missing = [], []
for sport in carriers:
defs = _definitions(ast.parse(
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")))
theirs = defs[where].get(plugin_name)
if theirs is None:
missing.append(sport)
elif _dump(theirs) != ours:
drifted.append(sport)
assert missing == [], f"{plugin_name} no longer in: {missing}"
assert drifted == [], (
f"{plugin_name} differs from src/common/sports_helpers.py in: {drifted}. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", SCOREBOARDS)
def test_constants_match(self, sport):
root = _plugins_root()
defs = _definitions(ast.parse(
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")))
assert ast.literal_eval(defs["module"]["_MIN_WINDOW_DAYS"].value) == MIN_WINDOW_DAYS
assert ast.literal_eval(defs["module"]["_MAX_WINDOW_DAYS"].value) == MAX_WINDOW_DAYS
gap = defs["SportsCore"]["_DWELL_REENTRY_GAP_SECONDS"].value
assert math.isclose(ast.literal_eval(gap), SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS)
@@ -0,0 +1,104 @@
"""'Check & Update All' must only send ids POST /plugins/update handles.
Seen on a device running core 3.4.0: update-all posted every entry from
/plugins/installed, including the virtual `starlark:<app_id>` entries that
list installed Starlark apps, and the route answered each with a 500
"Plugin update failed: plugin not found". A web-service restart during the
same run also cost stock-news its update: its request was refused while the
service came back, and update-all never sent it again.
The id selection and retry live in install_manager.js and are covered by
test/js/unit/test_update_all.js, which this module runs so CI sees it. The
route contract is tested here directly.
"""
import shutil
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
REPO = Path(__file__).resolve().parents[2]
JS_SUITE = REPO / 'test' / 'js' / 'unit' / 'test_update_all.js'
@pytest.fixture
def client():
from web_interface.app import app
app.config['TESTING'] = True
with app.test_client() as c:
yield c
@pytest.fixture
def store(tmp_path):
"""A store manager that records calls; no git, no network."""
from web_interface.blueprints.api_v3 import api_v3
sm = MagicMock()
sm.plugins_dir = str(tmp_path)
sm._get_local_git_info.return_value = None
sm.get_plugin_info.return_value = None
sm.update_plugin.return_value = True
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
patch.object(api_v3, 'plugin_manager', None, create=True), \
patch.object(api_v3, 'schema_manager', None, create=True), \
patch.object(api_v3, 'plugin_state_manager', None, create=True), \
patch.object(api_v3, 'operation_history', None, create=True):
yield sm
class TestUpdateRouteRejectsStarlarkIds:
@pytest.mark.parametrize('app_id', ['starlark:analogtime', 'starlark:analogclock'])
def test_a_starlark_id_is_a_400_not_a_500(self, client, store, app_id):
resp = client.post('/api/v3/plugins/update', json={'plugin_id': app_id})
assert resp.status_code == 400, resp.get_json()
body = resp.get_json()
assert body['status'] == 'error'
assert body['error_code'] == 'INVALID_INPUT'
assert 'Starlark app' in body['message'], body
assert 'not found' not in body['message'], \
"the app is installed; 'not found' is the misleading message this replaces"
def test_the_store_manager_is_never_asked(self, client, store):
client.post('/api/v3/plugins/update', json={'plugin_id': 'starlark:analogtime'})
store.update_plugin.assert_not_called()
def test_form_encoded_starlark_id_is_rejected_the_same_way(self, client, store):
resp = client.post('/api/v3/plugins/update', data={'plugin_id': 'starlark:analogtime'})
assert resp.status_code == 400
def test_a_plugin_id_still_reaches_the_updater(self, client, store, tmp_path):
# The guard is on the 'starlark:' prefix only: the starlark-apps plugin
# and a disabled plugin with an update are still updated.
for pid in ('stock-news', 'starlark-apps'):
(tmp_path / pid).mkdir()
(tmp_path / pid / 'manifest.json').write_text('{"id": "%s"}' % pid, encoding='utf-8')
resp = client.post('/api/v3/plugins/update', json={'plugin_id': pid})
assert resp.status_code == 200, (pid, resp.get_json())
assert [c.args[0] for c in store.update_plugin.call_args_list] == ['stock-news', 'starlark-apps']
class TestInstalledListContract:
"""What update-all filters on is what /plugins/installed publishes."""
def test_starlark_entries_are_flagged_and_prefixed(self, client):
apps = {'apps': {'analogtime': {'name': 'Analog Time', 'enabled': False}}}
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=apps):
resp = client.get('/api/v3/plugins/installed')
plugins = resp.get_json()['data']['plugins']
entry = next(p for p in plugins if p['id'] == 'starlark:analogtime')
assert entry['is_starlark_app'] is True
assert not any(p.get('is_starlark_app') for p in plugins
if not p['id'].startswith('starlark:')), \
"a real plugin carries the Starlark flag and would be dropped from update-all"
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
def test_update_all_js_selection_and_retry():
node = shutil.which('node')
result = subprocess.run([node, str(JS_SUITE)], capture_output=True, text=True,
timeout=120, cwd=str(JS_SUITE.parent))
assert result.returncode == 0, result.stdout + result.stderr
+577
View File
@@ -0,0 +1,577 @@
"""``"x-display": "hidden"`` keeps a setting declared but off the form.
Plugins keep deprecated keys in their schema so configs that still carry them
keep validating (weather's ``api_key`` and ``radar_zoom``, olympics'
notification keys), and some keys are internal (countdown's auto-generated
row ``id``). Drawn as live controls they invite edits that do nothing, or
worse, change an internal id.
The contract pinned here:
* nothing hidden is drawn -- top level, nested, in Advanced Settings, or as an
array-table column -- and visible siblings still are;
* an object of nothing but hidden children draws no empty section;
* a hidden top-level key is not reported in ``__rendered_section`` and does
not count toward the Advanced Settings total;
* saving the rendered form never changes a hidden value: plain and nested ones
are simply not posted (the save deep-merges over the stored section), and a
hidden array-row property is carried through JSON-encoded, because a posted
row replaces the stored item wholesale;
* an unchecked *visible* checkbox beside them is still cleared;
* a JSON API save is unaffected.
"""
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
HIDDEN = {"x-display": "hidden"}
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"city": {"type": "string", "title": "City", "default": "Dallas"},
"legacy_key": {"type": "string", "title": "Legacy Key", **HIDDEN},
"legacy_flag": {"type": "boolean", "title": "Legacy Flag", **HIDDEN},
"timeout": {"type": "integer", "title": "Timeout", "default": 10,
"x-advanced": True},
"old_zoom": {"type": "integer", "title": "Old Zoom", "default": 6,
"minimum": 4, "maximum": 8, "x-advanced": True, **HIDDEN},
"display": {
"type": "object",
"title": "Display",
"properties": {
"show_clock": {"type": "boolean", "title": "Show Clock",
"default": True},
"old_flag": {"type": "boolean", "title": "Old Flag", **HIDDEN},
"old_mode": {"type": "string", "title": "Old Mode", **HIDDEN},
},
},
# Every child hidden: no empty section.
"internal": {
"type": "object",
"title": "Internal Stuff",
"properties": {
"token_seen": {"type": "boolean", **HIDDEN},
"revision": {"type": "integer", **HIDDEN},
},
},
"entries": {
"type": "array",
"title": "Entries",
"x-widget": "array-table",
# x-columns naming a hidden property must not draw it either.
"x-columns": ["enabled", "name", "id"],
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "title": "Row ID", **HIDDEN},
"enabled": {"type": "boolean", "default": True},
"name": {"type": "string"},
"pinned": {"type": "boolean", "title": "Pinned", **HIDDEN},
"weight": {"type": "number", "title": "Weight", **HIDDEN},
"layout": {
"type": "object",
"properties": {
"x": {"type": "integer", "default": 0},
"legacy_x": {"type": "integer", **HIDDEN},
},
},
},
"required": ["name"],
},
},
},
}
STORED = {
"enabled": True,
"city": "Dallas",
"legacy_key": "keep-me",
"legacy_flag": True,
"timeout": 10,
"old_zoom": 5,
"display": {"show_clock": True, "old_flag": True, "old_mode": "classic"},
"internal": {"token_seen": True, "revision": 7},
"entries": [
# An id that looks like a number must come back a string.
{"id": "1", "enabled": True, "name": "First", "pinned": True,
"weight": 2.5, "layout": {"x": 3, "legacy_x": 9}},
{"id": "cd_ab12cd34ef56", "enabled": False, "name": "Second",
"pinned": False},
],
}
HIDDEN_LABELS = ("Legacy Key", "Legacy Flag", "Old Zoom", "Old Flag",
"Old Mode", "Internal Stuff", "Row ID", "Pinned", "Weight")
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
self._select = None
self._first = None
self._chosen = None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
name = a.get("name")
if tag == "input" and name and "disabled" not in a:
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((name, a.get("value", "")))
elif tag == "select" and name:
self._select, self._first, self._chosen = name, None, None
elif tag == "option" and self._select:
if self._first is None:
self._first = a.get("value", "")
if "selected" in a:
self._chosen = a.get("value", "")
def handle_endtag(self, tag):
if tag == "select" and self._select:
chosen = self._chosen if self._chosen is not None else self._first
self.pairs.append((self._select, chosen or ""))
self._select = None
def _render(schema, config, plugin_id="demo"):
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": plugin_id, "name": "Demo", "description": "",
"enabled": True, "author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=schema, config=json.loads(json.dumps(config)))
def _form_pairs(html):
parser = _FormFields()
parser.feed(html)
return parser.pairs
def _names(html):
return {name for name, _ in _form_pairs(html)}
def _visible_names(html):
"""Names of controls a user can see (anything but type=hidden)."""
names = set()
for tag in re.findall(r"<(?:input|select|textarea)\b[^>]*>", html):
m = re.search(r'\bname="([^"]+)"', tag)
if m and 'type="hidden"' not in tag:
names.add(m.group(1))
return names
def _make_post(tmp_path, schema, stored, plugin_id="demo"):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / plugin_id
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(schema), encoding="utf-8")
store = {plugin_id: json.loads(json.dumps(stored))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post(f"/api/v3/plugins/config?plugin_id={plugin_id}",
data=MultiDict(pairs))
return resp, store.get(plugin_id, {})
def _post_json(config):
resp = client.post("/api/v3/plugins/config",
json={"plugin_id": plugin_id, "config": config})
return resp, store.get(plugin_id, {})
def _restore():
for k, v in originals.items():
setattr(a.api_v3, k, v)
return _post, _post_json, _restore
@pytest.fixture
def post(tmp_path):
_post, _post_json, restore = _make_post(tmp_path, SCHEMA, STORED)
_post.json = _post_json
try:
yield _post
finally:
restore()
# --------------------------------------------------------------------------
# Rendering
# --------------------------------------------------------------------------
class TestRendering:
html = None
@classmethod
def setup_class(cls):
cls.html = _render(SCHEMA, STORED)
def test_no_hidden_field_gets_a_visible_control(self):
visible = _visible_names(self.html)
for name in ("legacy_key", "legacy_flag", "old_zoom",
"display.old_flag", "display.old_mode",
"internal.token_seen", "internal.revision"):
assert name not in visible, name
assert not [n for n in visible if re.fullmatch(
r"entries\.\d+\.(id|pinned|weight|layout\.legacy_x)", n)], visible
def test_plain_and_nested_hidden_fields_are_not_posted_at_all(self):
names = _names(self.html)
for name in ("legacy_key", "legacy_flag", "old_zoom",
"display.old_flag", "display.old_mode",
"internal.token_seen", "internal.revision"):
assert name not in names, name
def test_no_hidden_label_is_drawn(self):
for label in HIDDEN_LABELS:
assert f">{label}<" not in self.html, label
assert f"{label}\n" not in self.html, label
def test_visible_siblings_still_render(self):
visible = _visible_names(self.html)
assert {"city", "timeout", "display.show_clock",
"entries.0.name", "entries.0.enabled"} <= visible
def test_rendered_sections_exclude_hidden_top_level_keys(self):
sections = re.findall(r'name="__rendered_section" value="([^"]+)"',
self.html)
assert set(sections) == {"city", "timeout", "display", "entries"}
def test_an_object_of_only_hidden_children_draws_no_section(self):
assert "Internal Stuff" not in self.html
assert "section-internal" not in self.html
def test_the_advanced_count_excludes_hidden_fields(self):
assert re.findall(r"Advanced Settings \((\d+)\)", self.html) == ["1"]
def test_advanced_settings_disappear_when_all_advanced_are_hidden(self):
schema = json.loads(json.dumps(SCHEMA))
del schema["properties"]["timeout"]
html = _render(schema, STORED)
assert "Advanced Settings" not in html
def test_hidden_array_properties_are_not_columns(self):
headers = re.findall(r"<th[^>]*>([^<]+)</th>", self.html)
assert "Row ID" not in headers and "Pinned" not in headers
cols = re.search(r"data-display-columns='([^']*)'", self.html)
assert json.loads(cols.group(1)) == ["enabled", "name"]
def test_hidden_array_properties_stay_out_of_the_row_editor(self):
"""The editor draws a field for every data-nested-prop and every key
of the advanced cell's data-prop-schema."""
assert 'data-nested-prop="pinned"' not in self.html
assert 'data-nested-prop="layout.legacy_x"' not in self.html
assert 'data-nested-prop="layout.x"' in self.html
for m in re.finditer(r"class=\"array-table-advanced-data\"\s+"
r"data-prop-schema='([^']*)'", self.html):
import html as _h
adv = json.loads(_h.unescape(m.group(1)))
assert "pinned" not in adv and "weight" not in adv and "id" not in adv
def test_stored_hidden_row_values_are_carried_json_encoded(self):
pairs = dict(_form_pairs(self.html))
assert pairs["entries.0.id"] == '"1"'
assert pairs["entries.0.pinned"] == "true"
assert pairs["entries.0.weight"] == "2.5"
assert pairs["entries.0.layout.legacy_x"] == "9"
assert pairs["entries.1.id"] == '"cd_ab12cd34ef56"'
# Absent stored values are not invented.
assert "entries.1.weight" not in pairs
assert "entries.1.layout.legacy_x" not in pairs
def test_a_schema_without_the_flag_renders_as_before():
"""Regression guard: the flag's absence changes nothing."""
schema = json.loads(json.dumps(SCHEMA))
def strip(node):
if isinstance(node, dict):
node.pop("x-display", None)
for v in node.values():
strip(v)
strip(schema)
html = _render(schema, STORED)
visible = _visible_names(html)
assert {"legacy_key", "legacy_flag", "old_zoom", "display.old_flag",
"internal.token_seen", "entries.0.id"} <= visible
# Not a column, so it lives in the row editor's advanced cell as before.
assert 'data-nested-prop="pinned"' in html
assert re.findall(r"Advanced Settings \((\d+)\)", html) == ["2"]
# --------------------------------------------------------------------------
# Saving
# --------------------------------------------------------------------------
class TestSavingTheRenderedForm:
def test_the_untouched_form_changes_no_hidden_value(self, post):
resp, cfg = post(_form_pairs(_render(SCHEMA, STORED)))
assert resp.status_code == 200, resp.get_json()
assert cfg["legacy_key"] == "keep-me"
assert cfg["legacy_flag"] is True
assert cfg["old_zoom"] == 5
assert cfg["display"]["old_flag"] is True
assert cfg["display"]["old_mode"] == "classic"
assert cfg["internal"] == {"token_seen": True, "revision": 7}
first, second = cfg["entries"]
assert first["id"] == "1" and isinstance(first["id"], str)
assert first["pinned"] is True
assert first["weight"] == 2.5
assert first["layout"]["legacy_x"] == 9
assert second["id"] == "cd_ab12cd34ef56"
assert second["pinned"] is False
def test_visible_edits_apply_and_an_unchecked_visible_box_is_cleared(self, post):
pairs = [(k, v) for k, v in _form_pairs(_render(SCHEMA, STORED))
if k != "display.show_clock"]
pairs = [(k, "Austin" if k == "city" else v) for k, v in pairs]
resp, cfg = post(pairs)
assert resp.status_code == 200, resp.get_json()
assert cfg["city"] == "Austin"
# The existing unchecked-checkbox behaviour, in the same section as
# hidden booleans that must not be touched.
assert cfg["display"]["show_clock"] is False
assert cfg["display"]["old_flag"] is True
assert cfg["legacy_flag"] is True
assert cfg["entries"][0]["pinned"] is True
def test_a_deleted_row_takes_its_hidden_values_with_it(self, post):
"""Row 0 removed; the browser renumbers row 1 to index 0."""
pairs = []
for k, v in _form_pairs(_render(SCHEMA, STORED)):
m = re.match(r"entries\.(\d+)\.(.*)", k)
if m:
if m.group(1) == "0":
continue
k = f"entries.0.{m.group(2)}"
pairs.append((k, v))
resp, cfg = post(pairs)
assert resp.status_code == 200, resp.get_json()
assert [e["id"] for e in cfg["entries"]] == ["cd_ab12cd34ef56"]
def test_a_new_row_gets_no_invented_hidden_values(self, post):
pairs = _form_pairs(_render(SCHEMA, STORED)) + [
("entries.2.enabled", "true"), ("entries.2.name", "Third")]
resp, cfg = post(pairs)
assert resp.status_code == 200, resp.get_json()
assert cfg["entries"][2]["name"] == "Third"
assert "id" not in cfg["entries"][2]
assert "pinned" not in cfg["entries"][2]
def test_a_json_api_save_still_writes_hidden_fields(self, post):
"""Hidden is a form concern: API callers can still set these."""
config = json.loads(json.dumps(STORED))
config["legacy_flag"] = False
config["display"]["old_mode"] = "modern"
config["entries"][0]["id"] = "renamed"
resp, cfg = post.json(config)
assert resp.status_code == 200, resp.get_json()
assert cfg["legacy_flag"] is False
assert cfg["display"]["old_mode"] == "modern"
assert cfg["entries"][0]["id"] == "renamed"
class TestMissingBooleans:
"""_set_missing_booleans_to_false, directly, at every depth."""
def test_hidden_booleans_are_out_of_scope_everywhere(self):
from web_interface.blueprints.api_v3 import _set_missing_booleans_to_false
cfg = json.loads(json.dumps(STORED))
form_keys = {"city", "entries.0.name", "entries.1.name"}
_set_missing_booleans_to_false(
cfg, SCHEMA["properties"], form_keys,
sections={"city", "display", "entries", "internal", "legacy_flag"})
assert cfg["legacy_flag"] is True
assert cfg["display"]["old_flag"] is True
assert cfg["internal"]["token_seen"] is True
assert cfg["entries"][0]["pinned"] is True
# Visible booleans in the same places are still cleared.
assert cfg["display"]["show_clock"] is False
assert cfg["entries"][0]["enabled"] is False
def test_hidden_array_item_property_lookup(self):
from web_interface.blueprints.api_v3 import _hidden_array_item_property
assert _hidden_array_item_property(SCHEMA, "entries.0.id")
assert _hidden_array_item_property(SCHEMA, "entries.12.layout.legacy_x")
assert _hidden_array_item_property(SCHEMA, "entries.0.name") is None
assert _hidden_array_item_property(SCHEMA, "entries.0.layout.x") is None
# Only array rows qualify: plain hidden keys are never posted.
assert _hidden_array_item_property(SCHEMA, "legacy_key") is None
assert _hidden_array_item_property(SCHEMA, "display.old_flag") is None
# --------------------------------------------------------------------------
# Real plugin schemas (snippets copied from ledmatrix-plugins)
# --------------------------------------------------------------------------
# plugins/countdown/config_schema.json, trimmed to the countdowns table.
COUNTDOWN_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"countdowns": {
"type": "array",
"x-widget": "array-table",
"x-columns": ["enabled", "name", "target_date", "target_time", "mode"],
"default": [],
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "title": "ID",
"description": "Unique identifier (auto-generated)",
"x-display": "hidden"},
"enabled": {"type": "boolean", "title": "Enabled", "default": True},
"name": {"type": "string", "title": "Name",
"minLength": 1, "maxLength": 30},
"target_date": {"type": "string", "title": "Target Date",
"x-widget": "date-picker",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"},
"target_time": {"type": "string", "title": "Target Time",
"x-widget": "time-picker", "default": "00:00"},
"mode": {"type": "string", "title": "Mode",
"enum": ["until", "since"], "default": "until"},
"display_order": {"x-advanced": True, "type": "integer",
"title": "Order", "default": 0, "minimum": 0},
},
"required": ["name", "target_date"],
},
},
"show_expired": {"x-advanced": True, "type": "boolean",
"title": "Show Expired", "default": False},
},
"required": ["enabled"],
}
COUNTDOWN_STORED = {
"enabled": True,
"show_expired": True,
"countdowns": [
{"id": "cd_0123456789ab", "enabled": True, "name": "Vacation",
"target_date": "2026-12-20", "target_time": "00:00", "mode": "until",
"display_order": 0},
{"id": "42", "enabled": True, "name": "Anniversary",
"target_date": "2020-06-01", "target_time": "00:00", "mode": "since",
"display_order": 1},
],
}
# plugins/ledmatrix-weather/config_schema.json, trimmed.
WEATHER_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"location_city": {"type": "string", "default": "Dallas", "title": "City"},
"update_interval": {"x-advanced": True, "type": "integer", "default": 1800,
"minimum": 300},
"api_key": {"x-advanced": True, "x-display": "hidden", "type": "string",
"title": "API Key (deprecated)"},
"radar_range_miles": {"type": "integer", "default": 75, "minimum": 10,
"maximum": 500, "title": "Radar Range (miles)"},
"radar_zoom": {"x-advanced": True, "x-display": "hidden", "type": "integer",
"default": 6, "minimum": 4, "maximum": 8,
"title": "Radar Zoom Level (deprecated)"},
"radar_show_nowcast": {"type": "boolean", "default": True,
"title": "Show Forecast Frames"},
},
}
WEATHER_STORED = {"enabled": True, "location_city": "Tampa", "update_interval": 900,
"api_key": "legacy-owm-key", "radar_range_miles": 75,
"radar_zoom": 7, "radar_show_nowcast": True}
def test_countdown_ids_survive_a_form_save(tmp_path):
html = _render(COUNTDOWN_SCHEMA, COUNTDOWN_STORED, "countdown")
assert ">ID<" not in html
assert "countdowns.0.id" not in _visible_names(html)
_post, _json, restore = _make_post(tmp_path, COUNTDOWN_SCHEMA,
COUNTDOWN_STORED, "countdown")
try:
# Edit a visible cell too.
pairs = [(k, "Beach" if k == "countdowns.0.name" else v)
for k, v in _form_pairs(html)]
resp, cfg = _post(pairs)
finally:
restore()
assert resp.status_code == 200, resp.get_json()
assert [c["id"] for c in cfg["countdowns"]] == ["cd_0123456789ab", "42"]
assert cfg["countdowns"][0]["name"] == "Beach"
def test_weather_deprecated_keys_are_hidden_and_kept(tmp_path):
html = _render(WEATHER_SCHEMA, WEATHER_STORED, "weather")
assert "api_key" not in html and "radar_zoom" not in html
assert "Radar Zoom Level" not in html and "API Key" not in html
assert re.findall(r"Advanced Settings \((\d+)\)", html) == ["1"]
assert "radar_range_miles" in _visible_names(html)
_post, _json, restore = _make_post(tmp_path, WEATHER_SCHEMA,
WEATHER_STORED, "weather")
try:
pairs = [(k, v) for k, v in _form_pairs(html)
if k != "radar_show_nowcast"]
resp, cfg = _post(pairs)
finally:
restore()
assert resp.status_code == 200, resp.get_json()
assert cfg["api_key"] == "legacy-owm-key"
assert cfg["radar_zoom"] == 7
assert cfg["radar_show_nowcast"] is False
@@ -1039,6 +1039,51 @@ def _set_nested_value(config, key_path, value):
_RENDERED_SECTION_FIELD = '__rendered_section' _RENDERED_SECTION_FIELD = '__rendered_section'
def _is_hidden_prop(prop_schema):
"""Whether a schema property is declared ``"x-display": "hidden"``.
Mirrors ``prop_is_hidden`` in ``plugin_config.html``: an object whose every
child is hidden counts as hidden too. A hidden property stays declared (so
stored configs carrying it keep validating) but the form draws no control
for it, which puts it outside everything the save infers from the form.
"""
if not isinstance(prop_schema, dict):
return False
if prop_schema.get('x-display') == 'hidden':
return True
children = prop_schema.get('properties')
if isinstance(children, dict) and children:
return all(_is_hidden_prop(child) for child in children.values())
return False
def _hidden_array_item_property(schema, key_path):
"""The hidden schema property a posted array-row key names, else None.
Only paths through an array index (``countdowns.0.id``) qualify: that is
the one place the rendered form posts a hidden value, because a posted row
replaces the stored item wholesale. The value is JSON-encoded there so its
exact type survives the round trip.
"""
parts = key_path.split('.')
digit_at = next((i for i, p in enumerate(parts) if p.isdigit()), None)
if digit_at is None or digit_at == 0 or digit_at == len(parts) - 1:
return None
array_prop = _get_schema_property(schema, '.'.join(parts[:digit_at]))
if not _schema_type_is(array_prop, 'array'):
return None
items = array_prop.get('items')
if not isinstance(items, dict) or not isinstance(items.get('properties'), dict):
return None
rest = '.'.join(parts[digit_at + 1:])
item_schema = {'properties': items['properties']}
prop = _get_schema_property(item_schema, rest)
if prop is None:
# Deeper array inside the item (rare): keep walking.
return _hidden_array_item_property(item_schema, rest)
return prop if _is_hidden_prop(prop) else None
def _boolean_is_in_scope(full_path, prefix, sections, submitted_parents): def _boolean_is_in_scope(full_path, prefix, sections, submitted_parents):
"""Whether a missing checkbox at ``full_path`` may be forced to False. """Whether a missing checkbox at ``full_path`` may be forced to False.
@@ -1112,6 +1157,12 @@ def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefi
if not isinstance(prop_schema, dict): if not isinstance(prop_schema, dict):
continue continue
# A hidden property (or an object of nothing but hidden ones) was never
# drawn, so its absence from the form is not an unchecked box. Leave it
# -- and everything under it -- exactly as stored.
if _is_hidden_prop(prop_schema):
continue
full_path = f"{prefix}.{prop_name}" if prefix else prop_name full_path = f"{prefix}.{prop_name}" if prefix else prop_name
prop_type = prop_schema.get('type') prop_type = prop_schema.get('type')
+52 -15
View File
@@ -12,6 +12,7 @@ from web_interface.blueprints.api_v3 import (
success_response, success_response,
) )
from src.common.path_safety import resolve_under from src.common.path_safety import resolve_under
from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings
import web_interface.blueprints.api_v3 as _pkg import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch. # as module attributes, and a value binding would not see the patch.
@@ -555,15 +556,6 @@ def save_main_config():
if 'panel_type' in data and data['panel_type'] not in PANEL_TYPE_ALLOWED: if 'panel_type' in data and data['panel_type'] not in PANEL_TYPE_ALLOWED:
return jsonify({'status': 'error', 'message': f"Invalid panel type '{data['panel_type']}'. Allowed values: Standard (empty), FM6126A, FM6127"}), 400 return jsonify({'status': 'error', 'message': f"Invalid panel type '{data['panel_type']}'. Allowed values: Standard (empty), FM6126A, FM6127"}), 400
# Validate multiplexing
if 'multiplexing' in data:
try:
mux_val = int(data['multiplexing'])
if mux_val < 0 or mux_val > 22:
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': f"Invalid multiplexing value '{data['multiplexing']}'. Must be an integer from 0 to 22."}), 400
# Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90") # Validate pixel_mapper_config (free-form mapper string, e.g. "U-mapper;Rotate:90")
if 'pixel_mapper_config' in data and not isinstance(data['pixel_mapper_config'], str): if 'pixel_mapper_config' in data and not isinstance(data['pixel_mapper_config'], str):
return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400 return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400
@@ -573,14 +565,59 @@ def save_main_config():
if 'orientation' in data and data['orientation'] not in ORIENTATION_ALLOWED: if 'orientation' in data and data['orientation'] not in ORIENTATION_ALLOWED:
return jsonify({'status': 'error', 'message': f"Invalid orientation '{data['orientation']}'. Allowed values: {', '.join(sorted(ORIENTATION_ALLOWED))}"}), 400 return jsonify({'status': 'error', 'message': f"Invalid orientation '{data['orientation']}'. Allowed values: {', '.join(sorted(ORIENTATION_ALLOWED))}"}), 400
# Validate row_address_type # Panel geometry, PWM and GPIO timing, held to what the rgbmatrix library
if 'row_address_type' in data: # accepts (RGBMatrix::Options::Validate in lib/options-initialize.cc,
# the gpio_slowdown check in lib/led-matrix.cc). Outside those ranges
# the config used to save, then the matrix refused to start and the
# display dropped to fallback mode. cols, chain_length and
# limit_refresh_rate_hz (0 = no cap) have no upper bound in the
# library. rows has none here by choice: the library currently
# rejects more than 64 per panel, and that limit is left to it so a
# library that lifts it needs no change here.
def _hardware_int_error(field, low, high=None, even=False):
"""A 400 response if data[field] is not an allowed integer, else None."""
raw = data[field]
kind = "an even integer" if even else "an integer"
if high is None:
allowed = f"{kind} of at least {low}"
else:
allowed = f"{kind} from {low} to {high}"
rejection = (jsonify({'status': 'error', 'message': f"Invalid {field} '{raw}'. Must be {allowed}."}), 400)
# int() would quietly turn true into 1 and 48.5 into 48.
if isinstance(raw, bool) or (isinstance(raw, float) and not raw.is_integer()):
return rejection
try: try:
rat_val = int(data['row_address_type']) value = int(raw)
if rat_val < 0 or rat_val > 4:
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400
except (ValueError, TypeError, OverflowError): except (ValueError, TypeError, OverflowError):
return jsonify({'status': 'error', 'message': f"Invalid row_address_type '{data['row_address_type']}'. Must be an integer from 0 to 4."}), 400 return rejection
if value < low or (high is not None and value > high) or (even and value % 2):
return rejection
return None
for field, low, high, even in (('rows', 8, None, True), ('cols', 16, None, False),
('chain_length', 1, None, False), ('parallel', 1, 3, False),
('brightness', 1, 100, False), ('scan_mode', 0, 1, False),
('pwm_bits', 1, 11, False), ('pwm_dither_bits', 0, 2, False),
('pwm_lsb_nanoseconds', 50, 3000, False),
('limit_refresh_rate_hz', 0, None, False),
('row_address_type', 0, 5, False), ('multiplexing', 0, 22, False),
('gpio_slowdown', 0, 10, False)):
if field in data:
error = _hardware_int_error(field, low, high, even)
if error:
return error
# A Pi 5 can't drive every combination (src/pi5_matrix_support.py),
# and one it can't crashes the display service instead of falling
# back. Checked only when this request sets one of those fields, so
# a combination already stored doesn't block unrelated saves.
pi5_fields = ('row_address_type', 'parallel', 'hardware_mapping')
if any(k in data for k in pi5_fields) and is_raspberry_pi_5():
effective = dict(current_config['display']['hardware'])
effective.update({k: data[k] for k in pi5_fields if k in data})
unsupported = pi5_unsupported_settings(effective)
if unsupported:
return jsonify({'status': 'error', 'message': unsupported}), 400
# Handle hardware settings # Handle hardware settings
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode', for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
+30 -3
View File
@@ -8,7 +8,7 @@ from web_interface.blueprints.api_v3 import (
_CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool, _CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool,
_do_transactional_uninstall, _enhance_schema_with_core_properties, _do_transactional_uninstall, _enhance_schema_with_core_properties,
_filter_config_by_schema, _get_plugin_version, _get_schema_property, _filter_config_by_schema, _get_plugin_version, _get_schema_property,
_installed_plugin_ids, _is_plugin_update_available, _hidden_array_item_property, _installed_plugin_ids, _is_plugin_update_available,
_parse_form_value_with_schema, _prune_credential_backups, _parse_form_value_with_schema, _prune_credential_backups,
_run_calendar_registration, _schema_allows_null, _schema_type_is, _run_calendar_registration, _schema_allows_null, _schema_type_is,
_set_missing_booleans_to_false, _set_missing_booleans_to_false,
@@ -735,10 +735,10 @@ def _drop_stale_reconciliation_findings(unresolved):
) )
cm = api_v3.config_manager cm = api_v3.config_manager
config_keys = config_plugin_ids(cm.load_config() or {},
ignored_config_keys(cm))
plugins_dir = getattr(api_v3.plugin_manager, 'plugins_dir', None) plugins_dir = getattr(api_v3.plugin_manager, 'plugins_dir', None)
installed = disk_plugin_ids(plugins_dir) if plugins_dir else set() installed = disk_plugin_ids(plugins_dir) if plugins_dir else set()
config_keys = config_plugin_ids(cm.load_config() or {},
ignored_config_keys(cm, installed))
return still_unresolved(unresolved, config_keys, installed) return still_unresolved(unresolved, config_keys, installed)
except Exception: except Exception:
logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True) logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True)
@@ -943,6 +943,19 @@ def update_plugin():
) )
data = {'plugin_id': plugin_id} data = {'plugin_id': plugin_id}
# /plugins/installed lists installed Starlark apps as virtual
# 'starlark:<app_id>' entries. They are not plugin directories, so the
# store manager can only fail to find them -- which used to surface as
# a 500 "plugin not found" for an app that is installed and working.
raw_id = data.get('plugin_id')
if isinstance(raw_id, str) and raw_id.startswith('starlark:'):
return error_response(
ErrorCode.INVALID_INPUT,
f'{raw_id} is a Starlark app, not a plugin; Starlark apps are '
'not updated through the plugin updater',
status_code=400
)
if not api_v3.plugin_store_manager: if not api_v3.plugin_store_manager:
return error_response( return error_response(
ErrorCode.SYSTEM_ERROR, ErrorCode.SYSTEM_ERROR,
@@ -1848,6 +1861,20 @@ def save_plugin_config():
# (to avoid overwriting the combined array with a single value) # (to avoid overwriting the combined array with a single value)
if key not in indexed_base_paths: if key not in indexed_base_paths:
# Parse value using schema to determine correct type # Parse value using schema to determine correct type
parsed_value = _SKIP_FIELD
decoded = False
# A hidden property inside an array row is carried
# through the form JSON-encoded (a posted row replaces
# the stored item, so it must be posted at all). Decode
# it exactly: the generic parse would turn an id "1"
# into the integer 1 and fail validation.
if _hidden_array_item_property(schema, key) is not None:
try:
parsed_value = json.loads(value)
decoded = True
except (TypeError, ValueError):
pass
if not decoded:
parsed_value = _parse_form_value_with_schema(value, key, schema) parsed_value = _parse_form_value_with_schema(value, key, schema)
# Debug logging for array fields # Debug logging for array fields
if schema: if schema:
+3 -1
View File
@@ -14,6 +14,7 @@ _SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$') _SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
from src.web_interface.secret_helpers import mask_secret_fields from src.web_interface.secret_helpers import mask_secret_fields
from src.common.path_safety import resolve_under, safe_path_component from src.common.path_safety import resolve_under, safe_path_component
from src.pi5_matrix_support import is_raspberry_pi_5
from web_interface import widget_bundle from web_interface import widget_bundle
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -544,7 +545,8 @@ def _load_display_partial():
if pages_v3.config_manager: if pages_v3.config_manager:
main_config = pages_v3.config_manager.load_config() main_config = pages_v3.config_manager.load_config()
return render_template('v3/partials/display.html', return render_template('v3/partials/display.html',
main_config=main_config) main_config=main_config,
is_pi5=is_raspberry_pi_5())
except Exception as e: except Exception as e:
logger.error("Error loading partial", exc_info=True) logger.error("Error loading partial", exc_info=True)
return "Error loading partial", 500 return "Error loading partial", 500
+4 -1
View File
@@ -2421,7 +2421,10 @@
async function updateAllPlugins() { async function updateAllPlugins() {
try { try {
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : []; // Starlark apps are listed as virtual 'starlark:<id>' entries;
// /plugins/update does not handle them (see install_manager.js).
const plugins = (Array.isArray(window.installedPlugins) ? window.installedPlugins : [])
.filter(p => p && typeof p.id === 'string' && !p.is_starlark_app && !p.id.startsWith('starlark:'));
if (!plugins.length) { if (!plugins.length) {
showNotification('No installed plugins to update.', 'warning'); showNotification('No installed plugins to update.', 'warning');
@@ -78,33 +78,87 @@ const PluginInstallManager = {
} }
}, },
/**
* Whether POST /plugins/update can update this installed-list entry.
*
* /plugins/installed also lists installed Starlark apps as virtual
* `starlark:<app_id>` entries (flagged `is_starlark_app`) so they can be
* seen and toggled with everything else. They are not plugin directories:
* the plugin updater has nothing to update for them, and there is no
* Starlark update route (reinstalling from the repository would reset the
* app's saved settings), so they are left out of update-all.
*
* @param {Object} plugin - Entry from /plugins/installed
* @returns {boolean}
*/
isUpdatablePlugin(plugin) {
if (!plugin || typeof plugin.id !== 'string' || !plugin.id) return false;
return !plugin.is_starlark_app && !plugin.id.startsWith('starlark:');
},
/**
* The entries update-all sends to POST /plugins/update, in list order.
*
* @param {Array} plugins - Entries from /plugins/installed
* @returns {Array}
*/
updatablePlugins(plugins) {
return (Array.isArray(plugins) ? plugins : []).filter(p => this.isUpdatablePlugin(p));
},
/**
* Backoff (ms) before re-sending an update whose request never got an
* answer. Covers a web-service restart (~3s on a Pi) with room to spare.
*/
NETWORK_RETRY_DELAYS_MS: [1000, 2000, 4000, 8000, 15000],
/** /**
* Update all plugins. * Update all plugins.
* *
* @param {Function} onProgress - Optional callback(index, total, pluginId) for progress updates * @param {Function} onProgress - Optional callback(index, total, pluginId) for progress updates
* @returns {Promise<Array>} Update results * @param {Object} options - Optional { sleep(ms), retryDelaysMs } (tests inject these)
* @returns {Promise<Array>} Update results, one per plugin sent
*/ */
async updateAll(onProgress) { async updateAll(onProgress, options = {}) {
// Prefer PluginStateManager if populated, fall back to window.installedPlugins // Prefer PluginStateManager if populated, fall back to window.installedPlugins
// (plugins_manager.js populates window.installedPlugins independently) // (plugins_manager.js populates window.installedPlugins independently)
const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins; const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins;
const plugins = (stateManagerPlugins && stateManagerPlugins.length > 0) const listed = (stateManagerPlugins && stateManagerPlugins.length > 0)
? stateManagerPlugins ? stateManagerPlugins
: (window.installedPlugins || []); : (window.installedPlugins || []);
// Snapshot: the list can be replaced while this loop is awaiting.
const plugins = this.updatablePlugins(listed);
if (!plugins.length) { if (!plugins.length) {
return []; return [];
} }
const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
const retryDelays = options.retryDelaysMs || this.NETWORK_RETRY_DELAYS_MS;
const results = []; const results = [];
for (let i = 0; i < plugins.length; i++) { for (let i = 0; i < plugins.length; i++) {
const plugin = plugins[i]; const plugin = plugins[i];
if (onProgress) onProgress(i + 1, plugins.length, plugin.id); if (onProgress) onProgress(i + 1, plugins.length, plugin.id);
// Each plugin gets its own pass over the backoff schedule.
const pendingDelays = retryDelays.slice();
for (;;) {
try { try {
const result = await window.PluginAPI.updatePlugin(plugin.id); const result = await window.PluginAPI.updatePlugin(plugin.id);
results.push({ pluginId: plugin.id, success: true, result }); results.push({ pluginId: plugin.id, success: true, result });
break;
} catch (error) { } catch (error) {
// No HTTP answer at all (connection refused/reset, e.g. the
// web service restarting mid-run): the server never saw or
// never finished this plugin, so send it again once it is
// back rather than skipping it. An HTTP error response is
// the server's answer and is not retried.
if (error && error.error_code === 'NETWORK_ERROR' && pendingDelays.length > 0) {
await sleep(pendingDelays.shift());
continue;
}
results.push({ pluginId: plugin.id, success: false, error }); results.push({ pluginId: plugin.id, success: false, error });
break;
}
} }
} }
@@ -111,6 +111,37 @@
// ─── Helpers ──────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────
/**
* "x-display": "hidden" — the property stays in the schema but gets no
* control. An object whose every child is hidden counts as hidden too.
* Mirrors prop_is_hidden in plugin_config.html and _is_hidden_prop on the
* server.
*/
function isHiddenProp(schema) {
if (!schema || typeof schema !== 'object') return false;
if (schema['x-display'] === 'hidden') return true;
const children = schema.properties;
if (children && typeof children === 'object') {
const values = Object.values(children);
return values.length > 0 && values.every(isHiddenProp);
}
return false;
}
/**
* A hidden input carrying a hidden property's stored value, JSON-encoded so
* the save restores its exact type. A posted row replaces the stored item,
* so a value that isn't posted is lost.
*/
function hiddenValueInput(name, dataKey, value) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = JSON.stringify(value);
input.dataset.hiddenProp = dataKey;
return input;
}
function safeSetHTML(target, html) { function safeSetHTML(target, html) {
target.textContent = ''; target.textContent = '';
// createContextualFragment parses html relative to the document context // createContextualFragment parses html relative to the document context
@@ -343,6 +374,16 @@
if (propType === 'object' && propSchema.properties) { if (propType === 'object' && propSchema.properties) {
const nestedVal = (item && item[propName]) || {}; const nestedVal = (item && item[propName]) || {};
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => { Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
if (isHiddenProp(subSchema)) {
// No data-nested-prop, so the row editor never shows it.
const stored = Object.entries(nestedVal).find(([name]) => name === subName);
if (stored && stored[1] !== undefined && stored[1] !== null) {
cell.appendChild(hiddenValueInput(
`${fullKey}.${index}.${propName}.${subName}`,
`${propName}.${subName}`, stored[1]));
}
return;
}
const subType = Array.isArray(subSchema.type) const subType = Array.isArray(subSchema.type)
? subSchema.type.find(t => t !== 'null') || 'string' ? subSchema.type.find(t => t !== 'null') || 'string'
: (subSchema.type || 'string'); : (subSchema.type || 'string');
@@ -386,7 +427,16 @@
row.className = 'array-table-row'; row.className = 'array-table-row';
row.setAttribute('data-index', index); row.setAttribute('data-index', index);
// "x-display": "hidden" item properties: never a column (even if
// x-columns names one), never in the advanced cell or row editor.
const hiddenKeys = new Set(
Object.entries(fullItemProperties)
.filter(([, propSchema]) => isHiddenProp(propSchema))
.map(([propName]) => propName)
);
// Visible column cells // Visible column cells
displayColumns = displayColumns.filter(colName => !hiddenKeys.has(colName));
displayColumns.forEach(colName => { displayColumns.forEach(colName => {
const colDef = itemProperties[colName] || {}; const colDef = itemProperties[colName] || {};
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string'); const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
@@ -396,10 +446,11 @@
row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId)); row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId));
}); });
// Determine non-displayed properties (these go into the advanced cell + edit modal) // Determine non-displayed properties (these go into the advanced cell + edit modal).
// Hidden ones ("x-display": "hidden") get no column and no editor field.
const nonDisplayed = {}; const nonDisplayed = {};
Object.keys(fullItemProperties).forEach(k => { Object.keys(fullItemProperties).forEach(k => {
if (!displayColumns.includes(k) && k !== 'id') { if (!displayColumns.includes(k) && k !== 'id' && !hiddenKeys.has(k)) {
nonDisplayed[k] = fullItemProperties[k]; nonDisplayed[k] = fullItemProperties[k];
} }
}); });
@@ -430,6 +481,14 @@
actionsCell.appendChild(editBtn); actionsCell.appendChild(editBtn);
} }
// Hidden properties: carry stored values only. A new row has none, so
// nothing is invented (countdown fills in its own id).
Object.entries(item).forEach(([propName, stored]) => {
if (hiddenKeys.has(propName) && stored !== undefined && stored !== null) {
actionsCell.appendChild(hiddenValueInput(`${fullKey}.${index}.${propName}`, propName, stored));
}
});
row.appendChild(actionsCell); row.appendChild(actionsCell);
// Hidden advanced data cell // Hidden advanced data cell
@@ -494,7 +553,8 @@
grid.className = 'grid grid-cols-2 gap-3'; grid.className = 'grid grid-cols-2 gap-3';
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => { Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
const subType = Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string'); if (isHiddenProp(subSchema)) return;
const subType =Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string');
const subLabel = subSchema.title || subName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const subLabel = subSchema.title || subName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const subDesc = subSchema.description || ''; const subDesc = subSchema.description || '';
const nestedPath = `${propName}.${subName}`; const nestedPath = `${propName}.${subName}`;
@@ -2409,6 +2409,19 @@ function getSchemaProperty(schema, path) {
return null; return null;
} }
// "x-display": "hidden" (or an object of nothing but hidden children).
// Mirrors prop_is_hidden in plugin_config.html.
function isHiddenSchemaProp(schema) {
if (!schema || typeof schema !== 'object') return false;
if (schema['x-display'] === 'hidden') return true;
const children = schema.properties;
if (children && typeof children === 'object') {
const values = Object.values(children);
return values.length > 0 && values.every(isHiddenSchemaProp);
}
return false;
}
// Helper function to render a single item in an array of objects // Helper function to render a single item in an array of objects
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) { function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
const item = itemValue || {}; const item = itemValue || {};
@@ -2422,6 +2435,9 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties); const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
propertyOrder.forEach(propKey => { propertyOrder.forEach(propKey => {
if (!itemProperties[propKey]) return; if (!itemProperties[propKey]) return;
// "x-display": "hidden": no control. The stored value survives through
// data-item-data, which updateArrayObjectData overlays edits onto.
if (isHiddenSchemaProp(itemProperties[propKey])) return;
const propSchema = itemProperties[propKey]; const propSchema = itemProperties[propKey];
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default; const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
@@ -5478,6 +5494,7 @@ if (typeof window !== 'undefined') {
itemHtml = `<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${newIndex}">`; itemHtml = `<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${newIndex}">`;
Object.keys(itemsSchema.properties || {}).forEach(propKey => { Object.keys(itemsSchema.properties || {}).forEach(propKey => {
const propSchema = itemsSchema.properties[propKey]; const propSchema = itemsSchema.properties[propKey];
if (propSchema && propSchema['x-display'] === 'hidden') return;
const propValue = newItem[propKey] !== undefined ? newItem[propKey] : propSchema.default; const propValue = newItem[propKey] !== undefined ? newItem[propKey] : propSchema.default;
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
itemHtml += `<div class="mb-3"><label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`; itemHtml += `<div class="mb-3"><label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
@@ -41,46 +41,44 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4">
<div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows"> <div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows">
<label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, or 64. Default: 32. Must match your panel.', 'Rows') }}</label> <label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, 48 or 64. Default: 32. Must match your panel and be an even number, at least 8. There is no upper limit here, but the current rgbmatrix library rejects more than 64 rows per panel, and the display will not start.', 'Rows') }}</label>
<input type="number" <input type="number"
id="rows" id="rows"
name="rows" name="rows"
value="{{ main_config.display.hardware.rows or 32 }}" value="{{ main_config.display.hardware.rows or 32 }}"
min="1" min="8"
max="128" step="2"
class="form-control"> class="form-control">
</div> </div>
<div class="form-group" id="setting-display-cols" data-setting-key="display.hardware.cols"> <div class="form-group" id="setting-display-cols" data-setting-key="display.hardware.cols">
<label for="cols" class="block text-sm font-medium text-gray-700">Columns{{ ui.help_tip('Number of LED columns on a single panel.\nCommon: 32 or 64. Default: 64. Must match your panel.', 'Columns') }}</label> <label for="cols" class="block text-sm font-medium text-gray-700">Columns{{ ui.help_tip('Number of LED columns on a single panel.\nCommon: 32, 64, 96 or 128. Default: 64. Must match your panel. At least 16, with no upper limit.', 'Columns') }}</label>
<input type="number" <input type="number"
id="cols" id="cols"
name="cols" name="cols"
value="{{ main_config.display.hardware.cols or 64 }}" value="{{ main_config.display.hardware.cols or 64 }}"
min="1" min="16"
max="128"
class="form-control"> class="form-control">
</div> </div>
<div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length"> <div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length">
<label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display.', 'Chain Length') }}</label> <label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display. No upper limit, but longer chains lower the refresh rate.', 'Chain Length') }}</label>
<input type="number" <input type="number"
id="chain_length" id="chain_length"
name="chain_length" name="chain_length"
value="{{ main_config.display.hardware.chain_length or 2 }}" value="{{ main_config.display.hardware.chain_length or 2 }}"
min="1" min="1"
max="24"
class="form-control"> class="form-control">
</div> </div>
<div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel"> <div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel">
<label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. The Raspberry Pi supports up to 3 (some HATs allow more).', 'Parallel') }}</label> <label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. The Raspberry Pi supports up to 3, and the HAT needs that many outputs (e.g. the Adafruit Triple LED Matrix Bonnet).', 'Parallel') }}</label>
<input type="number" <input type="number"
id="parallel" id="parallel"
name="parallel" name="parallel"
value="{{ main_config.display.hardware.parallel or 1 }}" value="{{ main_config.display.hardware.parallel or 1 }}"
min="1" min="1"
max="4" max="3"
class="form-control"> class="form-control">
</div> </div>
</div> </div>
@@ -108,7 +106,7 @@
</div> </div>
<div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping"> <div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping">
<label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it; "Regular" for direct GPIO wiring.', 'Hardware Mapping') }}</label> <label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit RGB Matrix HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it (no hardware pulsing, so expect a little more flicker); "Regular" for direct GPIO wiring and for the Adafruit Triple LED Matrix Bonnet.', 'Hardware Mapping') }}</label>
<select id="hardware_mapping" name="hardware_mapping" class="form-control"> <select id="hardware_mapping" name="hardware_mapping" class="form-control">
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option> <option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option> <option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
@@ -126,7 +124,7 @@
</div> </div>
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence"> <div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label> <label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped (red shows as blue: try BGR). Default: RGB. The Waveshare 96x48 V2 needs BGR.', 'LED RGB Sequence') }}</label>
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control"> <select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
<option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option> <option value="RGB" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RGB" %}selected{% endif %}>RGB</option>
<option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option> <option value="RBG" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "RBG" %}selected{% endif %}>RBG</option>
@@ -160,7 +158,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing"> <div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', 'Multiplexing') }}</label> <label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('How the pixels are wired on outdoor/specialty panels (P10, P8, P4 and P3 outdoor modules and similar), whose LEDs are not laid out in straight rows.\nLeave at 0 (Direct) for most indoor panels. If the image is scrambled in a repeating pattern, try the value named after your panel first, then the others.', 'Multiplexing') }}</label>
<select id="multiplexing" name="multiplexing" class="form-control"> <select id="multiplexing" name="multiplexing" class="form-control">
<option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option> <option value="0" {% if main_config.display.hardware.get('multiplexing', 0)|int == 0 %}selected{% endif %}>0 - Direct</option>
<option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option> <option value="1" {% if main_config.display.hardware.get('multiplexing', 0)|int == 1 %}selected{% endif %}>1 - Stripe</option>
@@ -189,7 +187,7 @@
</div> </div>
<div class="form-group" id="setting-display-panel_type" data-setting-key="display.hardware.panel_type"> <div class="form-group" id="setting-display-panel_type" data-setting-key="display.hardware.panel_type">
<label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type{{ ui.help_tip('Special initialization for panels with a specific driver chip (e.g. FM6126A, FM6127).\nLeave on Standard unless your panel stays blank or shows only the first pixel.', 'Panel Type') }}</label> <label for="panel_type" class="block text-sm font-medium text-gray-700">Panel Type{{ ui.help_tip('Special initialization for panels with a specific driver chip (e.g. FM6126A, FM6127).\nLeave on Standard unless your panel stays blank or shows only the first pixel. FM6124 / FM6124D / FM6124DJ panels need no initialization: use Standard.', 'Panel Type') }}</label>
<select id="panel_type" name="panel_type" class="form-control"> <select id="panel_type" name="panel_type" class="form-control">
<option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option> <option value="" {% if not main_config.display.hardware.get('panel_type', '') %}selected{% endif %}>Standard</option>
<option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option> <option value="FM6126A" {% if main_config.display.hardware.get('panel_type', '') == "FM6126A" %}selected{% endif %}>FM6126A</option>
@@ -198,24 +196,33 @@
</div> </div>
<div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type"> <div class="form-group" id="setting-display-row_address_type" data-setting-key="display.hardware.row_address_type">
<label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.', 'Row Address Type') }}</label> <label for="row_address_type" class="block text-sm font-medium text-gray-700">Row Address Type{{ ui.help_tip('Row addressing scheme used by the panel.\nLeave at 0 (Default) unless your panel needs AB/ABC addressing — a wrong value shows a garbled or shifted image.\nABC panels (no E line, common on 128x64 FM6124 boards): try 3. Panels with SM5368 row drivers, such as the Waveshare 96x48 V2 (back silkscreen 24S-A1), need 5 with RGB sequence BGR. If rows then jump up and down, or the bottom row shows a copy of other rows, raise GPIO Slowdown (6–8 on a Pi 4). On a Raspberry Pi 5 the rgbmatrix library supports only 0 and 2, so only those are offered there.', 'Row Address Type') }}</label>
{% set stored_row_address_type = main_config.display.hardware.get('row_address_type', 0)|int %}
<select id="row_address_type" name="row_address_type" class="form-control"> <select id="row_address_type" name="row_address_type" class="form-control">
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 0 %}selected{% endif %}>0 - Default</option> <option value="0" {% if stored_row_address_type == 0 %}selected{% endif %}>0 - Default</option>
<option value="1" {% if main_config.display.hardware.get('row_address_type', 0)|int == 1 %}selected{% endif %}>1 - AB-addressed panels</option> {% if not is_pi5 %}
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option> <option value="1" {% if stored_row_address_type == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option> {% endif %}
<option value="4" {% if main_config.display.hardware.get('row_address_type', 0)|int == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option> <option value="2" {% if stored_row_address_type == 2 %}selected{% endif %}>2 - Row direct</option>
{% if not is_pi5 %}
<option value="3" {% if stored_row_address_type == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
<option value="4" {% if stored_row_address_type == 4 %}selected{% endif %}>4 - ABC Shift + DE direct</option>
<option value="5" {% if stored_row_address_type == 5 %}selected{% endif %}>5 - SM5368 / B707 row shift register</option>
{% endif %}
</select> </select>
{% if is_pi5 and stored_row_address_type not in (0, 2) %}
<p class="text-sm text-red-600 mt-1">Your saved row address type ({{ stored_row_address_type }}) can't be used on this Raspberry Pi 5, so the display won't start with it. Saving this form sets it to 0.</p>
{% endif %}
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown"> <div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown">
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows the GPIO signal so the panel keeps up.\nGuide: Pi 3 → 1–2, Pi 4 → 2–4, Pi 5 (PIO) → 1–3. Increase if the display shows garbage or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label> <label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows GPIO writes so the panel electronics keep up (0–10); higher is more reliable but lowers the refresh rate.\nStarting points: Pi Zero / Pi 1 → 0–1, Pi 2 / Pi 3 → 1–3, Pi 4 → 2–4, Pi 5 (PIO) → 1–3. Panels on Row Address Type 5 (SM5368 row drivers) can need 6–8 on a Pi 4. Raise it if the display shows garbage, jumping rows or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label>
<input type="number" <input type="number"
id="gpio_slowdown" id="gpio_slowdown"
name="gpio_slowdown" name="gpio_slowdown"
value="{{ main_config.display.runtime.gpio_slowdown or 3 }}" value="{{ main_config.display.get('runtime', {}).get('gpio_slowdown', 3) }}"
min="0" min="0"
max="10" max="10"
class="form-control"> class="form-control">
@@ -223,7 +230,7 @@
<div class="form-group" id="setting-display-rp1_rio" data-setting-key="display.runtime.rp1_rio"> <div class="form-group" id="setting-display-rp1_rio" data-setting-key="display.runtime.rp1_rio">
<label for="rp1_rio" class="block text-sm font-medium text-gray-700"> <label for="rp1_rio" class="block text-sm font-medium text-gray-700">
RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>{{ ui.help_tip('Pi 5 RP1 coprocessor driver mode.\nPIO (0) is the default and uses less CPU. RIO (1) can push a higher refresh rate but inverts the GPIO Slowdown behavior. Ignored on Pi 3/4.', 'RP1 Backend') }} RP1 Backend <span class="text-xs text-gray-400 font-normal">(Pi 5 only)</span>{{ ui.help_tip('Pi 5 RP1 coprocessor driver mode.\nPIO (0) is the default and uses less CPU. RIO (1) can push a higher refresh rate but inverts the GPIO Slowdown behavior. Ignored on every other Pi model.', 'RP1 Backend') }}
</label> </label>
<select id="rp1_rio" name="rp1_rio" class="form-control"> <select id="rp1_rio" name="rp1_rio" class="form-control">
<option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option> <option value="0" {% if main_config.display.get('runtime', {}).get('rp1_rio', 0)|int == 0 %}selected{% endif %}>0 &mdash; PIO (default, low CPU)</option>
@@ -232,7 +239,7 @@
</div> </div>
<div class="form-group" id="setting-display-scan_mode" data-setting-key="display.hardware.scan_mode"> <div class="form-group" id="setting-display-scan_mode" data-setting-key="display.hardware.scan_mode">
<label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode{{ ui.help_tip('Order rows are refreshed in.\n0 = progressive (default), 1 = interlaced. Change only if you see banding or flicker on certain panels.', 'Scan Mode') }}</label> <label for="scan_mode" class="block text-sm font-medium text-gray-700">Scan Mode{{ ui.help_tip('Order rows are refreshed in.\n0 = progressive (default), 1 = interlaced. Interlaced can look a little smoother when the refresh rate is very low, but usually shows a comb effect on anything moving. Leave at 0 unless you are tuning a slow setup.', 'Scan Mode') }}</label>
<input type="number" <input type="number"
id="scan_mode" id="scan_mode"
name="scan_mode" name="scan_mode"
@@ -245,7 +252,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-pwm_bits" data-setting-key="display.hardware.pwm_bits"> <div class="form-group" id="setting-display-pwm_bits" data-setting-key="display.hardware.pwm_bits">
<label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits{{ ui.help_tip('Color depth per channel (1–11).\nHigher means smoother color but a lower refresh rate; lower means faster refresh with more banding. Default: 11 (this build defaults to 9).', 'PWM Bits') }}</label> <label for="pwm_bits" class="block text-sm font-medium text-gray-700">PWM Bits{{ ui.help_tip('Color depth per channel (1–11).\nHigher means smoother color but a lower refresh rate; lower drops the subtlest shades for a faster refresh (1 = 8 colors). Default: 9.', 'PWM Bits') }}</label>
<input type="number" <input type="number"
id="pwm_bits" id="pwm_bits"
name="pwm_bits" name="pwm_bits"
@@ -256,36 +263,36 @@
</div> </div>
<div class="form-group" id="setting-display-pwm_dither_bits" data-setting-key="display.hardware.pwm_dither_bits"> <div class="form-group" id="setting-display-pwm_dither_bits" data-setting-key="display.hardware.pwm_dither_bits">
<label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits{{ ui.help_tip('Time-dithering to gain apparent color depth (0–4).\nDefault: 0. Raising it can smooth gradients at the cost of a slightly lower refresh rate.', 'PWM Dither Bits') }}</label> <label for="pwm_dither_bits" class="block text-sm font-medium text-gray-700">PWM Dither Bits{{ ui.help_tip('Time-dithers the lowest color bits (0–2): their brightness comes from showing them on only some frames, which raises the refresh rate.\nDefault: 1. 0 gives the steadiest dim colors; 2 is fastest but dark shades can shimmer. The rgbmatrix library accepts only 0–2.', 'PWM Dither Bits') }}</label>
<input type="number" <input type="number"
id="pwm_dither_bits" id="pwm_dither_bits"
name="pwm_dither_bits" name="pwm_dither_bits"
value="{{ main_config.display.hardware.pwm_dither_bits or 1 }}" value="{{ main_config.display.hardware.get('pwm_dither_bits', 1) }}"
min="0" min="0"
max="4" max="2"
class="form-control"> class="form-control">
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-pwm_lsb_nanoseconds" data-setting-key="display.hardware.pwm_lsb_nanoseconds"> <div class="form-group" id="setting-display-pwm_lsb_nanoseconds" data-setting-key="display.hardware.pwm_lsb_nanoseconds">
<label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds{{ ui.help_tip('Base time for the least-significant color bit (50–500 ns).\nDefault: 130. Raising it can reduce flicker on some panels but lowers the maximum refresh rate.', 'PWM LSB Nanoseconds') }}</label> <label for="pwm_lsb_nanoseconds" class="block text-sm font-medium text-gray-700">PWM LSB Nanoseconds{{ ui.help_tip('On-time of the least-significant color bit (50–3000 ns); each higher bit doubles it.\nDefault: 130. Lower allows a higher refresh rate but can cost color accuracy or add ghosting; raise it if bright text on black leaves faint trails.', 'PWM LSB Nanoseconds') }}</label>
<input type="number" <input type="number"
id="pwm_lsb_nanoseconds" id="pwm_lsb_nanoseconds"
name="pwm_lsb_nanoseconds" name="pwm_lsb_nanoseconds"
value="{{ main_config.display.hardware.pwm_lsb_nanoseconds or 130 }}" value="{{ main_config.display.hardware.pwm_lsb_nanoseconds or 130 }}"
min="50" min="50"
max="500" max="3000"
class="form-control"> class="form-control">
</div> </div>
<div class="form-group" id="setting-display-limit_refresh_rate_hz" data-setting-key="display.hardware.limit_refresh_rate_hz"> <div class="form-group" id="setting-display-limit_refresh_rate_hz" data-setting-key="display.hardware.limit_refresh_rate_hz">
<label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz){{ ui.help_tip('Caps the panel refresh rate (1–1000 Hz).\nDefault: 120. A steady cap reduces flicker in camera recordings and keeps timing consistent. Set higher or to the max your panel supports for the smoothest motion.', 'Limit Refresh Rate') }}</label> <label for="limit_refresh_rate_hz" class="block text-sm font-medium text-gray-700">Limit Refresh Rate (Hz){{ ui.help_tip('Caps the panel refresh rate (0–1000 Hz; 0 = no cap).\nDefault: 100. A steady cap reduces flicker from 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.', 'Limit Refresh Rate') }}</label>
<input type="number" <input type="number"
id="limit_refresh_rate_hz" id="limit_refresh_rate_hz"
name="limit_refresh_rate_hz" name="limit_refresh_rate_hz"
value="{{ main_config.display.hardware.limit_refresh_rate_hz or 120 }}" value="{{ main_config.display.hardware.get('limit_refresh_rate_hz', 100) }}"
min="1" min="0"
max="1000" max="1000"
class="form-control"> class="form-control">
</div> </div>
@@ -304,7 +311,7 @@
{% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %} {% if main_config.display.hardware.disable_hardware_pulsing %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span> <span class="ml-2 text-sm font-medium text-gray-900">Disable Hardware Pulsing</span>
{{ ui.help_tip('Turn off hardware PWM pulsing.\nEnable this if the Pi audio is in use or you hear buzzing / see instability. Slightly increases CPU usage.', 'Disable Hardware Pulsing') }} {{ ui.help_tip('Time the brightness pulses in software instead of with the Pi hardware PWM.\nLeave unchecked where possible: software timing is less exact, so a row or the whole panel can briefly flash brighter. Hardware pulsing needs the panel OE line on GPIO 18 (Adafruit HAT PWM, Regular, Triple Bonnet) and the Pi onboard sound driver disabled; check this if you need Pi audio. Without GPIO 18 the library uses software timing anyway.', 'Disable Hardware Pulsing') }}
</label> </label>
</div> </div>
@@ -328,7 +335,7 @@
{% if main_config.display.hardware.show_refresh_rate %}checked{% endif %} {% if main_config.display.hardware.show_refresh_rate %}checked{% endif %}
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span> <span class="ml-2 text-sm font-medium text-gray-900">Show Refresh Rate</span>
{{ ui.help_tip('Overlay the live panel refresh rate on the display.\nUseful for tuning GPIO Slowdown and PWM settings; turn off for normal use.', 'Show Refresh Rate') }} {{ ui.help_tip('Print the live panel refresh rate to the console; nothing is drawn on the panel.\nReadable when you stop the service and run the display in a terminal; under the service the output is buffered. Turn off for normal use.', 'Show Refresh Rate') }}
</label> </label>
</div> </div>
@@ -6,7 +6,29 @@
{# ===== MACROS FOR FORM FIELD GENERATION ===== #} {# ===== MACROS FOR FORM FIELD GENERATION ===== #}
{# Render a single form field based on schema type #} {# Render a single form field based on schema type #}
{# "x-display": "hidden" keeps a property declared -- so stored configs that
still carry it keep validating -- without drawing a control for it
(deprecated keys, internal ids). An object whose every child is hidden
counts as hidden too, so it doesn't render as an empty section. Outputs
"1" when hidden, nothing otherwise; test it with `|trim`.
Nothing hidden is posted for a plain or nested field: the save deep-merges
the form over the stored section, so the stored value stays. Array rows are
the exception (a posted list replaces the stored one), see array-table. #}
{% macro prop_is_hidden(prop) -%}
{%- if prop is mapping -%}
{%- if prop.get('x-display') == 'hidden' -%}1
{%- elif prop.get('properties') is mapping and prop.get('properties') -%}
{%- set _hid = namespace(all=true) -%}
{%- for _child in prop.get('properties').values() -%}
{%- if not prop_is_hidden(_child)|trim -%}{%- set _hid.all = false -%}{%- endif -%}
{%- endfor -%}
{%- if _hid.all -%}1{%- endif -%}
{%- endif -%}
{%- endif -%}
{%- endmacro %}
{% macro render_field(key, prop, value, prefix='', plugin_id='') %} {% macro render_field(key, prop, value, prefix='', plugin_id='') %}
{% if not prop_is_hidden(prop)|trim %}
{% set full_key = (prefix ~ '.' ~ key) if prefix else key %} {% set full_key = (prefix ~ '.' ~ key) if prefix else key %}
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %} {% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %} {% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
@@ -660,14 +682,20 @@
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %} {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
{# Use x-columns if specified, otherwise auto-detect first 4 simple properties #} {# Use x-columns if specified, otherwise auto-detect first 4 simple properties #}
{# Hidden item properties never become columns (see prop_is_hidden). #}
{% set x_columns = prop.get('x-columns') %} {% set x_columns = prop.get('x-columns') %}
{% if x_columns %} {% if x_columns %}
{% set display_columns = x_columns %} {% set display_columns = [] %}
{% for col_name in x_columns %}
{% if not prop_is_hidden(item_properties.get(col_name))|trim %}
{% set _ = display_columns.append(col_name) %}
{% endif %}
{% endfor %}
{% else %} {% else %}
{% set display_columns = [] %} {% set display_columns = [] %}
{% for col_name in item_properties.keys() %} {% for col_name in item_properties.keys() %}
{% set col_def = item_properties[col_name] %} {% set col_def = item_properties[col_name] %}
{% if col_def.get('type') not in ['object', 'array'] and display_columns|length < 4 %} {% if col_def.get('type') not in ['object', 'array'] and display_columns|length < 4 and not prop_is_hidden(col_def)|trim %}
{% set _ = display_columns.append(col_name) %} {% set _ = display_columns.append(col_name) %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -814,8 +842,16 @@
{# Actions cell: delete + optional edit button for advanced props #} {# Actions cell: delete + optional edit button for advanced props #}
{% set has_advanced = namespace(value=false) %} {% set has_advanced = namespace(value=false) %}
{% for k in item_properties.keys() %}{% if k not in display_columns and k != 'id' %}{% set has_advanced.value = true %}{% endif %}{% endfor %} {% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' and not prop_is_hidden(v)|trim %}{% set has_advanced.value = true %}{% endif %}{% endfor %}
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle"> <td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
{# Hidden item properties (e.g. countdown's auto-generated id) get
no control, but a posted row replaces the stored item wholesale,
so carry the stored value through. JSON-encoded so the save can
restore its exact type (an id "1" must stay a string); absent
values are not invented. #}
{% for k, v in item_properties.items() %}{% if prop_is_hidden(v)|trim and k in item and item[k] is not none %}
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ k }}" data-hidden-prop="{{ k }}" value='{{ item[k]|tojson }}'>
{% endif %}{% endfor %}
<button type="button" <button type="button"
onclick="removeArrayTableRow(this)" onclick="removeArrayTableRow(this)"
aria-label="Remove row {{ item_index + 1 }}" aria-label="Remove row {{ item_index + 1 }}"
@@ -836,13 +872,23 @@
{# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #} {# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #}
{% if has_advanced.value %} {% if has_advanced.value %}
{% set adv_schema = namespace(d={}) %} {% set adv_schema = namespace(d={}) %}
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %} {% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' and not prop_is_hidden(v)|trim %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %}
<td style="display:none" class="array-table-advanced-data" <td style="display:none" class="array-table-advanced-data"
data-prop-schema='{{ adv_schema.d|tojson }}'> data-prop-schema='{{ adv_schema.d|tojson }}'>
{% for prop_name, prop_schema in adv_schema.d.items() %} {% for prop_name, prop_schema in adv_schema.d.items() %}
{% set prop_type = prop_schema.get('type', 'string') %} {% set prop_type = prop_schema.get('type', 'string') %}
{% if prop_type == 'object' and prop_schema.get('properties') %} {% if prop_type == 'object' and prop_schema.get('properties') %}
{% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %} {% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %}
{% set _sub_obj = item.get(prop_name) if item.get(prop_name) is mapping else {} %}
{% if prop_is_hidden(sub_schema)|trim %}
{# Kept out of the row editor (no data-nested-prop); stored value only. #}
{% if sub_name in _sub_obj and _sub_obj[sub_name] is not none %}
<input type="hidden"
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}.{{ sub_name }}"
data-hidden-prop="{{ prop_name }}.{{ sub_name }}"
value='{{ _sub_obj[sub_name]|tojson }}'>
{% endif %}
{% else %}
{% set sub_val = item.get(prop_name, {}).get(sub_name) %} {% set sub_val = item.get(prop_name, {}).get(sub_name) %}
{% set sub_default = sub_schema.get('default') %} {% set sub_default = sub_schema.get('default') %}
{% set final_val = sub_val if sub_val is not none else sub_default %} {% set final_val = sub_val if sub_val is not none else sub_default %}
@@ -852,6 +898,7 @@
data-prop-type="{{ sub_schema.get('type', 'string') }}" data-prop-type="{{ sub_schema.get('type', 'string') }}"
data-prop-schema='{{ sub_schema|tojson }}' data-prop-schema='{{ sub_schema|tojson }}'
value="{{ final_val if final_val is not none else '' }}"> value="{{ final_val if final_val is not none else '' }}">
{% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
{% set prop_val = item.get(prop_name) %} {% set prop_val = item.get(prop_name) %}
@@ -1104,6 +1151,7 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %}{# not prop_is_hidden #}
{% endmacro %} {% endmacro %}
{# Render a nested/collapsible section for object types #} {# Render a nested/collapsible section for object types #}
@@ -1118,7 +1166,11 @@
TypeError that takes down the whole config page. A legacy boolean was TypeError that takes down the whole config page. A legacy boolean was
the on/off switch the object's `enabled` now holds, so carry it over -- the on/off switch the object's `enabled` now holds, so carry it over --
rendering {} would draw that checkbox unchecked and the next save would rendering {} would draw that checkbox unchecked and the next save would
quietly turn the feature off. The save then writes the object. #} quietly turn the feature off. The save then writes the object.
The plugin loader applies the same rule before validating
(legacy_bool_as_object in src/plugin_system/schema_manager.py), and
test/test_legacy_boolean_config.py renders this macro against it:
change the two together. #}
{% if value is mapping %} {% if value is mapping %}
{% set nested_value = value %} {% set nested_value = value %}
{% elif value is boolean and 'enabled' in (prop.properties or {}) %} {% elif value is boolean and 'enabled' in (prop.properties or {}) %}
@@ -1142,7 +1194,7 @@
<div id="{{ section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;"> <div id="{{ section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
{% set property_order = prop['x-propertyOrder'] if 'x-propertyOrder' in prop else prop.properties.keys()|list %} {% set property_order = prop['x-propertyOrder'] if 'x-propertyOrder' in prop else prop.properties.keys()|list %}
{% for nested_key in property_order %} {% for nested_key in property_order %}
{% if nested_key in prop.properties %} {% if nested_key in prop.properties and not prop_is_hidden(prop.properties[nested_key])|trim %}
{% set nested_prop = prop.properties[nested_key] %} {% set nested_prop = prop.properties[nested_key] %}
{% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %} {% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %}
{# Tagged so a widget that takes over part of a block can {# Tagged so a widget that takes over part of a block can
@@ -1295,7 +1347,10 @@
them. Schemas without the flag render exactly as before. #} them. Schemas without the flag render exactly as before. #}
{% set tiers = namespace(basic=[], advanced=[]) %} {% set tiers = namespace(basic=[], advanced=[]) %}
{% for key in property_order %} {% for key in property_order %}
{% if key in schema.properties and key != 'enabled' %} {# A hidden key is neither drawn nor reported in
__rendered_section, so the save never reads its
absence as an unchecked box. #}
{% if key in schema.properties and key != 'enabled' and not prop_is_hidden(schema.properties[key])|trim %}
{% set prop = schema.properties[key] %} {% set prop = schema.properties[key] %}
{% set is_object = prop.type is defined and 'object' in prop.type %} {% set is_object = prop.type is defined and 'object' in prop.type %}
{% if prop.get('x-advanced') and not is_object %} {% if prop.get('x-advanced') and not is_object %}