From f9b3d6ae5209337666e8dc2cc80ce765b650e65a Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:17:48 -0400 Subject: [PATCH] fix(web): accept every panel size and row address type the rgbmatrix library does (#586) * fix(web): accept every panel size and row address type the rgbmatrix library does The Display form capped columns at 128 and chain length at 24, and its submit handler (fixInvalidNumberInputs) rewrote anything larger to the cap, so wide panels and long chains silently saved as the wrong size. The config API checked none of the hardware numbers, so values the library rejects (odd rows, parallel 4, PWM dither bits 3) saved and the matrix then refused to start. - Form limits now match the pinned library: rows even 8-64, cols >= 16 and chain_length >= 1 with no upper bound, parallel 1-3, PWM dither bits 0-2, PWM LSB nanoseconds 50-3000. - save_main_config rejects out-of-range rows, cols, chain_length, parallel, brightness, scan_mode, pwm_bits, pwm_dither_bits, pwm_lsb_nanoseconds and gpio_slowdown with a 400. - A stored gpio_slowdown or pwm_dither_bits of 0 renders as 0 instead of the default, so saving the tab no longer overwrites it. - Row Address Type offers 5 (SM5368 / B707 row shift register). Verified on a Waveshare 96x48 V2 (24S-A1) on a Pi 4 with the Adafruit Triple LED Matrix Bonnet: rows 48, cols 96, row address type 5, BGR, GPIO slowdown 8. - Help text and docs: FM6124-family panels use Panel Type Standard; on a Pi 5 the library supports only row address types 0 and 2. No change to the rpi-rgb-led-matrix submodule. Co-Authored-By: Claude Opus 5 * fix(web): drop the rows cap and document every display setting accurately Rows: no upper limit in the form or the API. Still even and at least 8. The current rgbmatrix library rejects more than 64 per panel, so a larger value saves but the matrix won't start; the help tip, README, config reference and troubleshooting section all say so, and nothing here needs changing if the library lifts the limit. limit_refresh_rate_hz: the form accepts 0 (the library's "no cap"), a stored 0 no longer renders and re-saves as 120, and the API rejects negatives. pwm_dither_bits stays 0-2: the library rejects 3 and 4, so the old form's 0-4 only ever let users save a config the display couldn't start with. Docs and help tips, checked against the pinned library and its README: - panel_type and rp1_rio get README entries - show_refresh_rate prints to stdout; it never drew on the panel - dither bits raise the refresh rate; the tip said they lowered it - scan_mode is about interlacing at low refresh, not wrong colours - disable_hardware_pulsing: hardware pulsing needs OE on GPIO 18 and the onboard sound driver off; software timing makes rows flash brighter - gpio_slowdown guidance agrees between the README and the UI - all 22 multiplexing values listed; every numeric setting states its range - troubleshooting for a blank panel after a settings change, jumping rows and brightness flashes Co-Authored-By: Claude Opus 5 * fix(web): reject true and 5.5 for row_address_type and multiplexing Both still went straight through int(), so a JSON true saved as 1 and 5.5 as 5. They now use the shared hardware range check like the other panel fields. Review feedback on #586. Also: the RP1 Backend tooltip said it is ignored on Pi 3/4 (it is ignored on every model but the Pi 5), and the README gave the dynamic-duration default cap as 90s; the code default is 180s. Co-Authored-By: Claude Opus 5 * feat: refuse matrix settings a Raspberry Pi 5 can't drive On a Pi 5 the pinned rgbmatrix library drives the panel through the RP1 chip, and that path supports only row address types 0 and 2, parallel 1-3 and the regular / regular-pi1 / classic / adafruit-hat(-pwm) mappings (Rp1PioConfigSupported in lib/rp1/rp1_pio_backend.cc). For anything else CreateFromOptions returns NULL; the Python binding doesn't check, so the display process crashed on its first call into the matrix and systemd restarted it into the same crash every 10 seconds. - src/pi5_matrix_support.py: the rule and Pi 5 detection, matching the library's /proc/device-tree/model check - DisplayManager raises before creating the matrix, so it is a logged init failure (reported by /api/v3/hardware/status) and fallback mode - the config API rejects those settings on a Pi 5 when a request sets row_address_type, parallel or hardware_mapping - the Display form offers only row address types 0 and 2 on a Pi 5, and warns when a stored value can't be used - CLAUDE.md: re-check the rule whenever the submodule is bumped Review feedback on #586. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 30 ++ CLAUDE.md | 1 + README.md | 142 +++++--- docs/CONFIG_REFERENCE.md | 34 +- docs/GETTING_STARTED.md | 8 +- docs/WEB_INTERFACE_GUIDE.md | 20 +- src/display_manager.py | 10 + src/pi5_matrix_support.py | 70 ++++ test/test_api_v3_display_hardware.py | 308 ++++++++++++++++++ test/test_display_manager.py | 55 ++++ test/test_pi5_matrix_support.py | 79 +++++ web_interface/blueprints/api_v3/config.py | 67 +++- web_interface/blueprints/pages_v3.py | 4 +- .../templates/v3/partials/display.html | 77 +++-- 14 files changed, 779 insertions(+), 126 deletions(-) create mode 100644 src/pi5_matrix_support.py create mode 100644 test/test_api_v3_display_hardware.py create mode 100644 test/test_pi5_matrix_support.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c96eaba..f805ce10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,36 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 0bd1129b..764b637a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 3b90d871..0a77ce1f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 4af3c0fb..f4d991ca 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -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` diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d0102422..a7dceae0 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -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. diff --git a/docs/WEB_INTERFACE_GUIDE.md b/docs/WEB_INTERFACE_GUIDE.md index 77db0b10..10ef082b 100644 --- a/docs/WEB_INTERFACE_GUIDE.md +++ b/docs/WEB_INTERFACE_GUIDE.md @@ -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 diff --git a/src/display_manager.py b/src/display_manager.py index a11c49c4..a0b105fa 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -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") diff --git a/src/pi5_matrix_support.py b/src/pi5_matrix_support.py new file mode 100644 index 00000000..e864032a --- /dev/null +++ b/src/pi5_matrix_support.py @@ -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) + ".") diff --git a/test/test_api_v3_display_hardware.py b/test/test_api_v3_display_hardware.py new file mode 100644 index 00000000..c367f67b --- /dev/null +++ b/test/test_api_v3_display_hardware.py @@ -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']*\bid="%s"[^>]*>' % re.escape(input_id), body) + assert match, f'no ' + 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'' + return re.findall(r'