mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 10:36:35 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b3d6ae52 | ||
|
|
9616a5a054 | ||
|
|
c200b5837d | ||
|
|
9f2743471c |
@@ -47,6 +47,61 @@ Web interface:
|
||||
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.
|
||||
|
||||
## 3.4.0
|
||||
|
||||
|
||||
@@ -63,3 +63,4 @@
|
||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
|
||||
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
|
||||
- `src/pi5_matrix_support.py` hardcodes what the pinned `rpi-rgb-led-matrix-master` can drive on a Raspberry Pi 5 (`Rp1PioConfigSupported()` in `lib/rp1/rp1_pio_backend.cc`). Re-check it whenever the submodule is bumped: a stale rule blocks Pi 5 settings the new library supports, and a missing one lets the display service crash-loop
|
||||
|
||||
@@ -494,15 +494,20 @@ These settings control the physical hardware configuration and how the matrix is
|
||||
- **`rows`** (integer, default: 32)
|
||||
- Number of LED rows (vertical pixels) in each panel
|
||||
- Common values: 16, 32, 48, 64
|
||||
- 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
|
||||
|
||||
- **`cols`** (integer, default: 64)
|
||||
- Number of LED columns (horizontal pixels) in each panel
|
||||
- Common values: 32, 64, 96, 128
|
||||
- At least 16, with no upper limit
|
||||
- Must match your physical panel configuration
|
||||
|
||||
- **`chain_length`** (integer, default: 2)
|
||||
- Number of LED panels chained together horizontally
|
||||
- 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 4 panels in a row, set to 4
|
||||
- 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)
|
||||
- Use 1 for a single row of panels
|
||||
- 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`
|
||||
|
||||
#### Brightness and Visual Settings
|
||||
|
||||
- **`brightness`** (integer, 0-100, default: 90)
|
||||
- **`brightness`** (integer, 1-100, default: 90)
|
||||
- Display brightness level
|
||||
- Lower values (0-50) are dimmer, higher values (50-100) are brighter
|
||||
- Lower values (1-50) are dimmer, higher values (50-100) are brighter
|
||||
- Recommended: 70-90 for indoor use, 90-100 for bright environments
|
||||
- Very high brightness may cause distortion or require more power
|
||||
|
||||
@@ -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
|
||||
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
|
||||
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic)
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
|
||||
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
|
||||
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
|
||||
- Hardware pulsing (see `disable_hardware_pulsing`) needs the panel's OE line on GPIO 18, which `adafruit-hat-pwm` and `regular` provide and `adafruit-hat` does not
|
||||
|
||||
#### PWM (Pulse Width Modulation) Settings
|
||||
|
||||
These settings affect color fidelity and smoothness of color transitions:
|
||||
|
||||
- **`pwm_bits`** (integer, default: 9)
|
||||
- Number of bits used for PWM (affects color depth)
|
||||
- Higher values (9-11) = more color levels, smoother gradients
|
||||
- Lower values (7-8) = fewer color levels, but may improve stability on some hardware
|
||||
- Range: 1-11, recommended: 9-10
|
||||
- **`pwm_bits`** (integer, 1-11, default: 9)
|
||||
- Color depth per channel: how many brightness levels each LED gets
|
||||
- Higher values (9-11) = more color levels, smoother gradients, lower refresh rate
|
||||
- Lower values (7-8) = the subtlest shades are dropped for a higher refresh rate; `1` gives 8 colors
|
||||
- Recommended: 9-10
|
||||
|
||||
- **`pwm_dither_bits`** (integer, default: 1)
|
||||
- Additional dithering bits for smoother color transitions
|
||||
- Helps reduce color banding in gradients
|
||||
- Higher values (1-2) = smoother gradients but may impact performance
|
||||
- Range: 0-2, recommended: 1
|
||||
- **`pwm_dither_bits`** (integer, 0-2, default: 1)
|
||||
- Time-dithers the lowest color bits: their brightness comes from showing them on only some frames
|
||||
- Raises the refresh rate; the cost is that dark shades can shimmer slightly
|
||||
- `0` = steadiest dim colors, `2` = fastest
|
||||
- The rgbmatrix library accepts only 0-2; a higher value stops the display starting
|
||||
|
||||
- **`pwm_lsb_nanoseconds`** (integer, default: 130)
|
||||
- Least significant bit timing in nanoseconds
|
||||
- Controls the base timing for PWM signals
|
||||
- Lower values = faster PWM, higher values = slower PWM
|
||||
- **`pwm_lsb_nanoseconds`** (integer, 50-3000, default: 130)
|
||||
- On-time of the least significant color bit; each higher bit doubles it
|
||||
- Lower values = higher refresh rate, but can cost color accuracy or add ghosting on some panels
|
||||
- Higher values = less ghosting (faint trails behind bright text on black), lower refresh rate
|
||||
- Typical range: 100-300 nanoseconds
|
||||
- May need adjustment if you see flickering or color issues
|
||||
|
||||
#### Advanced Hardware Settings
|
||||
|
||||
- **`scan_mode`** (integer, default: 0)
|
||||
- Panel scan mode (how rows are addressed)
|
||||
- Common values: 0 (progressive), 1 (interlaced)
|
||||
- Most panels use 0, but some require 1
|
||||
- Check your panel datasheet if colors appear incorrect
|
||||
- **`scan_mode`** (integer, 0-1, default: 0)
|
||||
- Order the rows are refreshed in: `0` = progressive, `1` = interlaced
|
||||
- Interlaced can look a little smoother when the refresh rate is very low, but usually shows a comb effect on anything moving
|
||||
- Leave at `0` unless you are tuning a slow setup
|
||||
|
||||
- **`limit_refresh_rate_hz`** (integer, default: 100)
|
||||
- Maximum refresh rate in Hz (frames per second)
|
||||
- Caps the refresh rate for better stability
|
||||
- Lower values (60-80) = more stable, less CPU usage
|
||||
- Higher values (100-120) = smoother animations, more CPU usage
|
||||
- Recommended: 80-100 for most setups
|
||||
- Caps the panel refresh rate in Hz; `0` = no cap
|
||||
- A steady cap reduces flicker caused by other activity on the Pi, and in camera recordings
|
||||
- Scroll speeds are worked out against this value (against 100 Hz when it is `0`), so a cap the panel can actually hold keeps scrolling even
|
||||
- If the key is missing from the config, `DisplayManager` uses 90
|
||||
- Recommended: 80-120. `sudo python3 scripts/scroll_speeds.py --measure` reports the rate your panel really achieves
|
||||
|
||||
- **`disable_hardware_pulsing`** (boolean, default: false)
|
||||
- Disables hardware pulsing (usually leave as false)
|
||||
- Set to `true` only if you experience timing issues
|
||||
- Most users should leave this as `false`
|
||||
- `false` = the Pi's hardware PWM times each brightness pulse; `true` = software timing
|
||||
- Leave `false` where possible. Software timing is less exact, so a row, or the whole panel, can briefly flash brighter
|
||||
- Hardware pulsing needs the panel's OE line on GPIO 18 (`adafruit-hat-pwm`, `regular`, the Adafruit Triple LED Matrix Bonnet). With `adafruit-hat` the library uses software timing anyway
|
||||
- It also needs the Pi's onboard sound driver (`snd_bcm2835`) disabled, which `first_time_install.sh` does. Set `true` only if you need the Pi's own audio
|
||||
|
||||
- **`inverse_colors`** (boolean, default: false)
|
||||
- Inverts all colors (red becomes cyan, etc.)
|
||||
@@ -580,9 +586,9 @@ These settings affect color fidelity and smoothness of color transitions:
|
||||
- Set to `true` only if colors appear inverted
|
||||
|
||||
- **`show_refresh_rate`** (boolean, default: false)
|
||||
- Displays the current refresh rate on the matrix (for debugging)
|
||||
- Set to `true` to see FPS on the display
|
||||
- Useful for troubleshooting performance issues
|
||||
- Prints the live refresh rate to the console; nothing is drawn on the panel
|
||||
- Readable when you stop the service and run `sudo python3 run.py` in a terminal; under the service the output is buffered
|
||||
- `sudo python3 scripts/scroll_speeds.py --measure` is an easier way to see the real refresh rate
|
||||
|
||||
#### Advanced Panel Configuration (Advanced Users Only)
|
||||
|
||||
@@ -592,6 +598,7 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Color channel order for your LED panel
|
||||
- Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR"
|
||||
- Most panels use "RGB", but some use "GRB" or other orders
|
||||
- If red shows as blue, try "BGR" (the Waveshare 96x48 V2 needs it)
|
||||
- Check your panel datasheet if colors appear wrong
|
||||
|
||||
- **`pixel_mapper_config`** (string, default: "")
|
||||
@@ -612,29 +619,60 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- **`row_address_type`** (integer, default: 0)
|
||||
- How rows are addressed on the panel
|
||||
- Most panels use 0 (direct addressing)
|
||||
- Some panels require 1 (AB addressing) or 2 (ABC addressing)
|
||||
- 1 = AB-addressed, 2 = direct row select, 3 = ABC-addressed,
|
||||
4 = ABC shift + DE direct (SM5266), 5 = SM5368 / B707 row shift register
|
||||
- ABC panels (no E line, e.g. many 128x64 FM6124 panels) use 3
|
||||
- Panels with SM5368 row drivers use 5 with `led_rgb_sequence` `"BGR"` —
|
||||
e.g. the Waveshare 96x48 V2 (back silkscreen `24S-A1`; the V1, `24S-A2.1`,
|
||||
uses the defaults). This is what Waveshare's `96X48_1_24_SM5368` panel
|
||||
type sets in their library fork.
|
||||
- SM5368 row drivers are timing-sensitive: if rows jump up and down or the
|
||||
bottom row shows a copy of other rows, raise `gpio_slowdown`. On a Pi 4
|
||||
with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a
|
||||
stable image.
|
||||
- On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2
|
||||
(and `parallel` 1-3). Anything else would crash the display service, so on
|
||||
a Pi 5 the web UI offers only 0 and 2, the config API refuses the others,
|
||||
and if one is set in `config.json` anyway the display logs why and runs in
|
||||
fallback mode
|
||||
- Check your panel datasheet if display appears corrupted
|
||||
|
||||
- **`multiplexing`** (integer, default: 0)
|
||||
- Panel multiplexing type
|
||||
- 0 = no multiplexing (standard panels)
|
||||
- Higher values for panels with different multiplexing schemes
|
||||
- Check your panel datasheet for the correct value
|
||||
- **`multiplexing`** (integer, 0-22, default: 0)
|
||||
- How pixels are wired on outdoor/specialty panels (P10, P8, P4 and P3 outdoor modules and similar) whose LEDs aren't laid out in straight rows
|
||||
- `0` = direct (standard indoor panels)
|
||||
- `1` Stripe, `2` Checkered, `3` Spiral, `4` ZStripe, `5` ZnMirrorZStripe,
|
||||
`6` Coreman, `7` Kaler2Scan, `8` ZStripeUneven, `9` P10-128x4-Z,
|
||||
`10` QiangLiQ8, `11` InversedZStripe, `12`–`14` P10Outdoor1R1G1B v1–v3,
|
||||
`15` P10CoremanMapper, `16` P8Outdoor1R1G1B, `17` FlippedStripe,
|
||||
`18` P10-32x16-HalfScan, `19` P10-32x16-QuarterScan, `20` P3Outdoor-64x64,
|
||||
`21` DoubleZMultiplex, `22` P4Outdoor-80x40
|
||||
- If the image is scrambled in a repeating pattern, try the value named after your panel first
|
||||
|
||||
- **`panel_type`** (string, default: `""`)
|
||||
- Sends a start-up initialization sequence to driver chips that need one
|
||||
- `""` = Standard (no initialization) — right for most panels, including FM6124 / FM6124D / FM6124DJ
|
||||
- `"FM6126A"` or `"FM6127"` for panels with those chips; try `"FM6126A"` if the panel stays dark or lights only the first pixel on Standard
|
||||
|
||||
### Runtime Configuration (`display.runtime`)
|
||||
|
||||
These settings control runtime behavior and GPIO timing:
|
||||
|
||||
- **`gpio_slowdown`** (integer, default: 3)
|
||||
- GPIO timing slowdown factor
|
||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||
- **Raspberry Pi 3**: Use 3
|
||||
- **Raspberry Pi 4**: Use 4
|
||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- **Raspberry Pi Zero/1**: Use 1-2
|
||||
- Incorrect values can cause display corruption, flickering, or system instability
|
||||
- GPIO timing slowdown factor (0-10): slows GPIO writes so the panel electronics keep up. Higher is more reliable but lowers the refresh rate
|
||||
- **Critical setting**: depends on your Raspberry Pi model and your panel
|
||||
- **Raspberry Pi Zero/1**: 0-1
|
||||
- **Raspberry Pi 2/3**: 1-3
|
||||
- **Raspberry Pi 4**: 2-4 (the config template ships 3)
|
||||
- **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- Panels on `row_address_type` 5 (SM5368 row drivers) can need 6-8 on a Pi 4
|
||||
- Too low: garbage, flicker or rows jumping. Too high: a lower refresh rate
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
|
||||
- **`rp1_rio`** (integer, 0 or 1, default: 0) — Raspberry Pi 5 only
|
||||
- Which driver the Pi 5's RP1 chip uses: `0` = PIO (default, less CPU), `1` = RIO (registered I/O, can reach a higher refresh rate)
|
||||
- In RIO mode the effect of `gpio_slowdown` is inverted: higher values may be faster
|
||||
- Ignored on a Pi 0-4, and applied only if the installed rgbmatrix library supports it
|
||||
|
||||
### Display Durations (`display.display_durations`)
|
||||
|
||||
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
|
||||
@@ -667,7 +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
|
||||
- This setting limits how long they can extend (prevents one display from dominating)
|
||||
- Example: If set to 60, a plugin can extend up to 60 seconds even if it requests longer
|
||||
- Leave unset to use the default cap (typically 90 seconds)
|
||||
- Leave unset to use the default cap (180 seconds; the web UI accepts 30-1800)
|
||||
|
||||
### Example Configuration
|
||||
|
||||
@@ -714,6 +752,14 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
||||
- Verify `hardware_mapping` matches your HAT/connection type
|
||||
- Try adjusting `gpio_slowdown`
|
||||
- Ensure your display doesn't need the E-Addressable line
|
||||
- If it went blank right after a settings change, 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:**
|
||||
- Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work)
|
||||
|
||||
+17
-17
@@ -51,25 +51,25 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `rows` / `cols` | int, `32` / `64` |
|
||||
| `chain_length` | int, `2` |
|
||||
| `parallel` | int, `1` |
|
||||
| `brightness` | int, `90` |
|
||||
| `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` — at least 1, no upper limit |
|
||||
| `parallel` | int, `1` — 1–3 |
|
||||
| `brightness` | int, `90` — 1–100 |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
|
||||
| `scan_mode` | int, `0` |
|
||||
| `pwm_bits` | int, `9` (code default 10) |
|
||||
| `pwm_dither_bits` | int, `1` |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) |
|
||||
| `disable_hardware_pulsing` | bool, `false` |
|
||||
| `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
|
||||
| `pwm_bits` | int, `9` (code default 10) — 1–11 |
|
||||
| `pwm_dither_bits` | int, `1` — 0–2 |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) — 50–3000 |
|
||||
| `disable_hardware_pulsing` | bool, `false` — `true` times brightness pulses in software (less exact); hardware pulsing needs the OE line on GPIO 18 and the Pi's onboard sound driver off |
|
||||
| `inverse_colors` | bool, `false` |
|
||||
| `show_refresh_rate` | bool, `false` |
|
||||
| `led_rgb_sequence` | string, `"RGB"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||
| `show_refresh_rate` | bool, `false` — prints the refresh rate to stdout; draws nothing on the panel |
|
||||
| `led_rgb_sequence` | string, `"RGB"` — `"RGB"`, `"RBG"`, `"GRB"`, `"GBR"`, `"BRG"` or `"BGR"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
||||
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
|
||||
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
|
||||
|
||||
Where "code default" differs from the template value, the code default only
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis |
|
||||
| `rp1_rio` | int, `0` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) |
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). Panels on `row_address_type` `5` (SM5368 row drivers) can need 6–8 on a Pi 4 — lower values make rows jump |
|
||||
| `rp1_rio` | int, `0` | Pi 5 only: `0` = PIO (less CPU), `1` = RIO (higher refresh; `gpio_slowdown` effect inverted). Applied only if the installed matrix library supports it |
|
||||
|
||||
## `display.double_sided`
|
||||
|
||||
|
||||
@@ -83,10 +83,10 @@ You should see:
|
||||
|
||||
1. Open the **Display** tab
|
||||
2. Set your matrix configuration:
|
||||
- **Rows**: 32 or 64 (match your hardware)
|
||||
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
||||
in the 1–128 range, but 64 and 96 are the values the bundled
|
||||
panel hardware ships with
|
||||
- **Rows**: match your panel — commonly 32 or 64; any even number
|
||||
from 8 to 64
|
||||
- **Columns**: match your panel — commonly 64 or 96; at least 16,
|
||||
with no upper limit
|
||||
- **Chain Length**: Number of panels chained horizontally
|
||||
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||
mod) or `adafruit-hat` (without). See the root README for the full list.
|
||||
|
||||
@@ -132,18 +132,26 @@ require a display service restart from **Overview**.
|
||||
Configure your LED matrix hardware:
|
||||
|
||||
**Matrix configuration:**
|
||||
- `rows` — LED rows (typically 32 or 64)
|
||||
- `cols` — LED columns (typically 64 or 96)
|
||||
- `rows` — LED rows per panel (typically 32 or 64; even, at least 8 — the
|
||||
current rgbmatrix library rejects more than 64)
|
||||
- `cols` — LED columns per panel (typically 64 or 96; at least 16)
|
||||
- `chain_length` — number of horizontally chained panels
|
||||
- `parallel` — number of parallel chains
|
||||
- `parallel` — number of parallel chains (1–3)
|
||||
- `hardware_mapping` — `adafruit-hat-pwm` (with PWM jumper mod),
|
||||
`adafruit-hat` (without), `regular`, or `regular-pi1`
|
||||
- `gpio_slowdown` — must match your Pi model (3 for Pi 3, 4 for Pi 4, etc.)
|
||||
- `brightness` — 0–100%
|
||||
`adafruit-hat` (without), `regular` (direct wiring, and the Adafruit Triple
|
||||
LED Matrix Bonnet), or `regular-pi1`
|
||||
- `gpio_slowdown` — depends on your Pi and panel (roughly 1–3 on a Pi 3,
|
||||
2–4 on a Pi 4); raise it if rows jump or the image is garbage
|
||||
- `brightness` — 1–100%
|
||||
- `pwm_bits`, `pwm_lsb_nanoseconds`, `pwm_dither_bits` — PWM tuning
|
||||
- Dynamic Duration — global cap for plugins that extend their display
|
||||
time based on content
|
||||
|
||||
The collapsed **Advanced Hardware & Display Options** section holds
|
||||
multiplexing, panel type, row address type, scan mode, PWM tuning, the
|
||||
refresh-rate cap and hardware pulsing. Every field has a help tip, and the
|
||||
README's Display Settings section describes each one with its allowed range.
|
||||
|
||||
**Vegas Scroll Mode:** the Display tab also has a full Vegas Scroll
|
||||
Mode section — enable toggle, scroll speed, separator width, dynamic
|
||||
duration, and related settings — so you can configure Vegas mode
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -39,6 +39,7 @@ from src.display_geometry import (
|
||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||
physical_size, resolve_double_sided,
|
||||
)
|
||||
from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings
|
||||
import threading
|
||||
import time
|
||||
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}")
|
||||
|
||||
# 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
|
||||
self.matrix = RGBMatrix(options=options)
|
||||
logger.info("RGB Matrix initialized successfully")
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Which matrix settings the pinned rgbmatrix library can drive on a Raspberry Pi 5.
|
||||
|
||||
On a Pi 5 the library drives the panel through the RP1 chip, and at the pinned
|
||||
rpi-rgb-led-matrix-master commit (1ee4f76) that path supports only some
|
||||
settings -- ``Rp1PioConfigSupported()`` in ``lib/rp1/rp1_pio_backend.cc``. For
|
||||
anything else ``RGBMatrix::CreateFromOptions()`` returns NULL. The Python
|
||||
binding does not check for that, so instead of raising, the display process
|
||||
crashes on its first call into the matrix, and systemd restarts it into the
|
||||
same crash every 10 seconds.
|
||||
|
||||
``DisplayManager`` checks this rule before creating the matrix, turning that
|
||||
crash into a logged error and the usual fallback mode. The config API and the
|
||||
Display form use the same rule to refuse the settings up front.
|
||||
|
||||
**Re-check this when the submodule is bumped.** Upstream is extending Pi 5
|
||||
support (e.g. 4e326c1b, "Pi5 - Improvements, additional led-row-addr-type
|
||||
support"); a stale rule here would block settings the new library drives.
|
||||
"""
|
||||
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
#: Read the way the library's ``Rp1PioPlatformDetected()`` reads it.
|
||||
MODEL_PATH = "/proc/device-tree/model"
|
||||
PI5_MODEL_MARKERS = ("Raspberry Pi 5", "Compute Module 5")
|
||||
|
||||
PI5_ROW_ADDRESS_TYPES = (0, 2)
|
||||
PI5_MAX_PARALLEL = 3
|
||||
PI5_HARDWARE_MAPPINGS = ("regular", "regular-pi1", "classic", "adafruit-hat", "adafruit-hat-pwm")
|
||||
|
||||
|
||||
def is_raspberry_pi_5() -> bool:
|
||||
"""True on a Pi 5-family board: Pi 5, Pi 500 or Compute Module 5."""
|
||||
try:
|
||||
with open(MODEL_PATH, "rb") as f:
|
||||
model = f.read(256).decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return False
|
||||
return any(marker in model for marker in PI5_MODEL_MARKERS)
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> Optional[int]:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def pi5_unsupported_settings(hardware: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Why a Pi 5 can't drive this ``display.hardware`` config, or None if it can.
|
||||
|
||||
Missing keys take DisplayManager's defaults. A value that isn't a number is
|
||||
left to the caller's own validation rather than reported here.
|
||||
"""
|
||||
problems = []
|
||||
row_address_type = _as_int(hardware.get("row_address_type"), 0)
|
||||
if row_address_type is not None and row_address_type not in PI5_ROW_ADDRESS_TYPES:
|
||||
problems.append(f"row address type {row_address_type} (only 0 and 2 are supported)")
|
||||
parallel = _as_int(hardware.get("parallel"), 1)
|
||||
if parallel is not None and not 1 <= parallel <= PI5_MAX_PARALLEL:
|
||||
problems.append(f"parallel {parallel} (1 to {PI5_MAX_PARALLEL} are supported)")
|
||||
mapping = hardware.get("hardware_mapping", "adafruit-hat-pwm")
|
||||
# The library treats an empty mapping as "regular".
|
||||
if isinstance(mapping, str) and (mapping or "regular") not in PI5_HARDWARE_MAPPINGS:
|
||||
problems.append(f'hardware mapping "{mapping}"')
|
||||
if not problems:
|
||||
return None
|
||||
return ("Not supported on a Raspberry Pi 5 by the installed rgbmatrix library: "
|
||||
+ "; ".join(problems) + ".")
|
||||
@@ -22,7 +22,7 @@ from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from src.plugin_system.schema_manager import SchemaManager, normalize_legacy_booleans
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
@@ -351,6 +351,7 @@ class PluginManager:
|
||||
config = {}
|
||||
|
||||
# Check if plugin has a config schema
|
||||
schema = None
|
||||
schema_path = self.schema_manager.get_schema_path(plugin_id)
|
||||
if schema_path is None:
|
||||
# Schema file doesn't exist
|
||||
@@ -368,6 +369,24 @@ class PluginManager:
|
||||
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
|
||||
try:
|
||||
defaults = self.schema_manager.generate_default_config(plugin_id, use_cache=True)
|
||||
|
||||
@@ -14,6 +14,85 @@ import jsonschema
|
||||
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:
|
||||
"""
|
||||
Manages plugin configuration schemas with caching and validation.
|
||||
|
||||
@@ -14,6 +14,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.logging_config import get_logger
|
||||
|
||||
@@ -72,9 +73,16 @@ def secrets_top_level_keys(config_manager) -> Set[str]:
|
||||
return set(secrets) if isinstance(secrets, dict) else set()
|
||||
|
||||
|
||||
def ignored_config_keys(config_manager) -> Set[str]:
|
||||
"""Config keys that are not plugin ids: system keys plus secrets keys."""
|
||||
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
|
||||
def ignored_config_keys(config_manager, installed_ids=frozenset()) -> Set[str]:
|
||||
"""Config keys that are not plugin ids: core keys plus secrets keys.
|
||||
|
||||
A secrets key that is also an installed plugin's id is not ignored: plugin
|
||||
secrets are namespaced by plugin id, so that key is the plugin's own entry.
|
||||
Ignoring it made every installed plugin with secrets read as "installed but
|
||||
missing from config" on each run.
|
||||
"""
|
||||
secrets_only = secrets_top_level_keys(config_manager) - set(installed_ids)
|
||||
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_only
|
||||
|
||||
|
||||
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
|
||||
@@ -132,6 +140,13 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
||||
plugins were all present in config kept being told, for hours, that four of
|
||||
them were missing and should be removed from config.json.
|
||||
|
||||
An "in config but not on disk" finding is stale once the plugin is
|
||||
installed, and equally once the id is no longer a plugin entry in config --
|
||||
whether the user removed it or it was never a plugin (a core key such as
|
||||
``auto_update`` that an older build misread). Without the second check a
|
||||
verdict written by such a build kept telling users to delete a real core
|
||||
setting until the next full reconciliation run.
|
||||
|
||||
Entry kinds this cannot re-check are kept, so filtering only ever removes
|
||||
findings that are provably stale.
|
||||
"""
|
||||
@@ -143,7 +158,7 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
||||
if plugin_id not in config_keys:
|
||||
live.append(entry)
|
||||
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
|
||||
if plugin_id not in installed_ids:
|
||||
if plugin_id in config_keys and plugin_id not in installed_ids:
|
||||
live.append(entry)
|
||||
else:
|
||||
live.append(entry)
|
||||
@@ -280,39 +295,22 @@ class StateReconciliation:
|
||||
message=f"Reconciliation failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Top-level config keys that are NOT plugins.
|
||||
# Includes both config.json structural keys and config_secrets.json top-level
|
||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||
'web_display_autostart', 'timezone', 'location', 'display',
|
||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||
'dim_schedule', 'network', 'system', 'schedule',
|
||||
# Multi-display sync config (config.json structural key)
|
||||
'sync',
|
||||
# Secrets file top-level keys (merged in by load_config)
|
||||
'github', 'youtube',
|
||||
})
|
||||
|
||||
def _secrets_top_level_keys(self) -> Set[str]:
|
||||
"""Top-level keys that load_config() merges in from the secrets file.
|
||||
|
||||
load_config() merges config_secrets.json into the config it returns, so
|
||||
those keys sit alongside plugin ids. _SYSTEM_CONFIG_KEYS named them
|
||||
individually ('github', 'youtube'), which broke the moment anything else
|
||||
was written there: a 'data' key became a phantom plugin, permanently
|
||||
reported as "in config but not on disk". Reading the file keeps this
|
||||
correct no matter what it holds.
|
||||
"""
|
||||
return secrets_top_level_keys(self.config_manager)
|
||||
# Top-level config keys that are NOT plugins. The core keys come from the
|
||||
# shared list in src/core_config_keys.py -- a private copy here missed
|
||||
# #581's 'auto_update' and reported it as a plugin missing from disk.
|
||||
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
|
||||
# itself is read at run time too (ignored_config_keys): load_config() merges
|
||||
# it in, and naming its keys one by one let a 'data' key become a phantom
|
||||
# plugin permanently reported as "in config but not on disk".
|
||||
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from config file."""
|
||||
state = {}
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
ignored = self._SYSTEM_CONFIG_KEYS | self._secrets_top_level_keys()
|
||||
ignored = ignored_config_keys(self.config_manager,
|
||||
disk_plugin_ids(self.plugins_dir))
|
||||
for plugin_id in config_plugin_ids(config, ignored):
|
||||
plugin_config = config[plugin_id]
|
||||
state[plugin_id] = {
|
||||
@@ -389,7 +387,20 @@ class StateReconciliation:
|
||||
) -> List[Inconsistency]:
|
||||
"""Check consistency for a single plugin."""
|
||||
inconsistencies = []
|
||||
|
||||
|
||||
if plugin_id in CORE_CONFIG_KEYS:
|
||||
# A plugin whose id is a core setting's key ('display', 'sync',
|
||||
# ...) can never have a config section of its own: that section
|
||||
# is the core setting. Every check below would misfire -- "not in
|
||||
# config" forever, and an auto-fix that writes {'enabled': False}
|
||||
# where a core setting belongs -- so report it in the log instead.
|
||||
if disk_state.get(plugin_id, {}).get('exists_on_disk'):
|
||||
self.logger.warning(
|
||||
"Plugin id %r is reserved for a core config setting; "
|
||||
"the plugin cannot be configured and is skipped by "
|
||||
"reconciliation. Rename the plugin.", plugin_id)
|
||||
return inconsistencies
|
||||
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
state_mgr = state_manager_state.get(plugin_id, {})
|
||||
@@ -468,6 +479,12 @@ class StateReconciliation:
|
||||
"""Attempt to fix an inconsistency."""
|
||||
try:
|
||||
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
|
||||
if inconsistency.plugin_id in CORE_CONFIG_KEYS:
|
||||
# Never create or replace a core setting with a plugin stub.
|
||||
self.logger.warning(
|
||||
"Refusing to add plugin entry %r: that key is a core "
|
||||
"config setting", inconsistency.plugin_id)
|
||||
return False
|
||||
config = self.config_manager.load_config()
|
||||
if inconsistency.plugin_id in config:
|
||||
# Detection said "not in config" but it is there -- the
|
||||
|
||||
@@ -35,6 +35,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
||||
| Suite | Needs a server | Covers |
|
||||
|---|---|---|
|
||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), 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_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 |
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@ const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||
'unit/test_style_editor_layout_leaf_columns.js',
|
||||
'unit/test_style_editor_layout_leaf_collision.js'];
|
||||
'unit/test_style_editor_layout_leaf_collision.js',
|
||||
'unit/test_update_all.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js'];
|
||||
|
||||
|
||||
@@ -0,0 +1,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); });
|
||||
@@ -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,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"]
|
||||
@@ -279,3 +279,58 @@ class TestDisplayManagerOrientation:
|
||||
suppress_test_pattern=True)
|
||||
options = mock_rgb_matrix['options_class'].return_value
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -142,8 +142,14 @@ class TestStaleFindingsAreDropped:
|
||||
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
|
||||
|
||||
def test_genuinely_missing_on_disk_is_kept(self):
|
||||
entries = [{"plugin_id": "data", "type": self.ON_DISK}]
|
||||
assert still_unresolved(entries, set(), {"odds-ticker"}) == entries
|
||||
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
|
||||
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):
|
||||
# Filtering must only ever remove what it can prove stale.
|
||||
@@ -164,7 +170,6 @@ class TestStaleFindingsAreDropped:
|
||||
live = still_unresolved(entries, installed, installed)
|
||||
|
||||
# All four "installed but not in config" findings were false and clear.
|
||||
# 'data' legitimately is not installed, so this filter keeps it; it stops
|
||||
# being reported because _get_config_state() no longer invents it from the
|
||||
# secrets file, which takes effect on the next reconciliation run.
|
||||
assert [e["plugin_id"] for e in live] == ["data"]
|
||||
# 'data' is not a plugin entry in config (it came from the secrets
|
||||
# file), so its "not on disk" finding is provably stale and clears too.
|
||||
assert live == []
|
||||
|
||||
@@ -94,9 +94,16 @@ class TestStaleFindingsAreDroppedByTheEndpoint:
|
||||
|
||||
def test_a_finding_that_still_holds_is_kept(self, client):
|
||||
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"]
|
||||
|
||||
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):
|
||||
"""The five findings the live device served, against its real state."""
|
||||
installed = ["football-scoreboard", "ledmatrix-weather",
|
||||
@@ -157,11 +164,13 @@ class TestTheFilterUsesTheReconcilersOwnRules:
|
||||
# finding on the strength of something nothing can read.
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
|
||||
config={"broken-plugin": {"enabled": True}},
|
||||
corrupt=["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):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
|
||||
config={"weather.standalone-backup-20260101": {"enabled": True}},
|
||||
installed=["weather.standalone-backup-20260101"])
|
||||
assert len(_get(c)["unresolved"]) == 1
|
||||
|
||||
@@ -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
|
||||
@@ -12,6 +12,7 @@ from web_interface.blueprints.api_v3 import (
|
||||
success_response,
|
||||
)
|
||||
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
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# 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:
|
||||
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")
|
||||
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
|
||||
@@ -573,14 +565,59 @@ def save_main_config():
|
||||
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
|
||||
|
||||
# Validate row_address_type
|
||||
if 'row_address_type' in data:
|
||||
# Panel geometry, PWM and GPIO timing, held to what the rgbmatrix library
|
||||
# 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:
|
||||
rat_val = int(data['row_address_type'])
|
||||
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
|
||||
value = int(raw)
|
||||
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
|
||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||
|
||||
@@ -735,10 +735,10 @@ def _drop_stale_reconciliation_findings(unresolved):
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
except Exception:
|
||||
logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True)
|
||||
@@ -943,6 +943,19 @@ def update_plugin():
|
||||
)
|
||||
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:
|
||||
return error_response(
|
||||
ErrorCode.SYSTEM_ERROR,
|
||||
|
||||
@@ -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$')
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -544,7 +545,8 @@ def _load_display_partial():
|
||||
if pages_v3.config_manager:
|
||||
main_config = pages_v3.config_manager.load_config()
|
||||
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:
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
@@ -2421,7 +2421,10 @@
|
||||
|
||||
async function updateAllPlugins() {
|
||||
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) {
|
||||
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.
|
||||
*
|
||||
* @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
|
||||
// (plugins_manager.js populates window.installedPlugins independently)
|
||||
const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins;
|
||||
const plugins = (stateManagerPlugins && stateManagerPlugins.length > 0)
|
||||
const listed = (stateManagerPlugins && stateManagerPlugins.length > 0)
|
||||
? stateManagerPlugins
|
||||
: (window.installedPlugins || []);
|
||||
// Snapshot: the list can be replaced while this loop is awaiting.
|
||||
const plugins = this.updatablePlugins(listed);
|
||||
|
||||
if (!plugins.length) {
|
||||
return [];
|
||||
}
|
||||
const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
||||
const retryDelays = options.retryDelaysMs || this.NETWORK_RETRY_DELAYS_MS;
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const plugin = plugins[i];
|
||||
if (onProgress) onProgress(i + 1, plugins.length, plugin.id);
|
||||
try {
|
||||
const result = await window.PluginAPI.updatePlugin(plugin.id);
|
||||
results.push({ pluginId: plugin.id, success: true, result });
|
||||
} catch (error) {
|
||||
results.push({ pluginId: plugin.id, success: false, error });
|
||||
// Each plugin gets its own pass over the backoff schedule.
|
||||
const pendingDelays = retryDelays.slice();
|
||||
for (;;) {
|
||||
try {
|
||||
const result = await window.PluginAPI.updatePlugin(plugin.id);
|
||||
results.push({ pluginId: plugin.id, success: true, result });
|
||||
break;
|
||||
} 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 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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="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"
|
||||
id="rows"
|
||||
name="rows"
|
||||
value="{{ main_config.display.hardware.rows or 32 }}"
|
||||
min="1"
|
||||
max="128"
|
||||
min="8"
|
||||
step="2"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="cols"
|
||||
name="cols"
|
||||
value="{{ main_config.display.hardware.cols or 64 }}"
|
||||
min="1"
|
||||
max="128"
|
||||
min="16"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="chain_length"
|
||||
name="chain_length"
|
||||
value="{{ main_config.display.hardware.chain_length or 2 }}"
|
||||
min="1"
|
||||
max="24"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="parallel"
|
||||
name="parallel"
|
||||
value="{{ main_config.display.hardware.parallel or 1 }}"
|
||||
min="1"
|
||||
max="4"
|
||||
max="3"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +106,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -126,7 +124,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -160,7 +158,7 @@
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -189,7 +187,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -198,24 +196,33 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<option value="0" {% if main_config.display.hardware.get('row_address_type', 0)|int == 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>
|
||||
<option value="2" {% if main_config.display.hardware.get('row_address_type', 0)|int == 2 %}selected{% endif %}>2 - Row direct</option>
|
||||
<option value="3" {% if main_config.display.hardware.get('row_address_type', 0)|int == 3 %}selected{% endif %}>3 - ABC-addressed panels</option>
|
||||
<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="0" {% if stored_row_address_type == 0 %}selected{% endif %}>0 - Default</option>
|
||||
{% if not is_pi5 %}
|
||||
<option value="1" {% if stored_row_address_type == 1 %}selected{% endif %}>1 - AB-addressed panels</option>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% 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 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">
|
||||
<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"
|
||||
id="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"
|
||||
max="10"
|
||||
class="form-control">
|
||||
@@ -223,7 +230,7 @@
|
||||
|
||||
<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">
|
||||
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>
|
||||
<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 — PIO (default, low CPU)</option>
|
||||
@@ -232,7 +239,7 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="scan_mode"
|
||||
name="scan_mode"
|
||||
@@ -245,7 +252,7 @@
|
||||
|
||||
<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">
|
||||
<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"
|
||||
id="pwm_bits"
|
||||
name="pwm_bits"
|
||||
@@ -256,36 +263,36 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="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"
|
||||
max="4"
|
||||
max="2"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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"
|
||||
id="pwm_lsb_nanoseconds"
|
||||
name="pwm_lsb_nanoseconds"
|
||||
value="{{ main_config.display.hardware.pwm_lsb_nanoseconds or 130 }}"
|
||||
min="50"
|
||||
max="500"
|
||||
max="3000"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="limit_refresh_rate_hz"
|
||||
name="limit_refresh_rate_hz"
|
||||
value="{{ main_config.display.hardware.limit_refresh_rate_hz or 120 }}"
|
||||
min="1"
|
||||
value="{{ main_config.display.hardware.get('limit_refresh_rate_hz', 100) }}"
|
||||
min="0"
|
||||
max="1000"
|
||||
class="form-control">
|
||||
</div>
|
||||
@@ -304,7 +311,7 @@
|
||||
{% 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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +335,7 @@
|
||||
{% 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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1166,7 +1166,11 @@
|
||||
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 --
|
||||
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 %}
|
||||
{% set nested_value = value %}
|
||||
{% elif value is boolean and 'enabled' in (prop.properties or {}) %}
|
||||
|
||||
Reference in New Issue
Block a user