diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43354296..30fd4a1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,9 +68,9 @@ Web interface:
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,
+ at 128, chain length at 24 and PWM LSB nanoseconds at 500. Columns have no
+ upper limit, chain length is 1–255 and rows must be even and 8–64 (see
+ "Display hardware settings the library refuses" below);
parallel is 1–3 and PWM dither bits 0–2, matching the library. A stored GPIO
slowdown, PWM dither bits or refresh-rate cap of 0 no longer shows (and
re-saves) as 3, 1 or 120, and the refresh cap accepts 0 (no cap). The config
@@ -132,6 +132,37 @@ Security (request paths and inline handlers, siblings of #561):
- The uploaded-images list escapes each file's original name, path and ids; a
name like `.png` was inserted as markup.
+Display hardware settings the library refuses:
+
+- The rgbmatrix library answers several settings with no matrix or `abort()`
+ rather than an error, on every board, so the display service crash-looped
+ instead of falling back: rows above 64, `chain_length` above 255 (the Python
+ binding stores it in one byte; this was documented as "no upper limit"), a
+ misspelled `hardware_mapping`, and `parallel` 2–3 on a mapping with one output
+ (`adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1`, `classic-pi1`) — the last
+ one reachable from the Display form on the default mapping. The config API
+ now refuses them with a 400 naming the setting, and `DisplayManager` refuses
+ a hand-edited one before creating the matrix: logged, fallback mode, reported
+ by `/api/v3/hardware/status`. The rules, including the Pi 5 ones, live in
+ `src/matrix_support.py` and must be re-checked when the submodule is bumped.
+- `/api/v3/hardware/status` adds `cause`: `"settings"` when LEDMatrix refused
+ the config, `"library"` when the library failed. The Display tab banner and
+ the fallback log line give the Pi 5 rebuild hint only for a library failure;
+ they used to follow every failure with it and with GPIO slowdown advice.
+- The Display form offers the `classic` and `classic-pi1` mappings and the
+ `90` / `270` orientations, and renders any other stored mapping selected with
+ a warning. With no option selected the browser posted the first one, so one
+ unrelated save rewrote those settings. The API accepts orientation `90` and
+ `270`, which `DisplayManager` already applied.
+- The display size the web preview, Starlark magnify default and
+ `scripts/dev/vegas_audit.py` compute (`src/display_geometry.py`) now applies
+ `orientation` and `pixel_mapper_config` as the library does: `Rotate:90`
+ swaps width and height, `U-mapper` folds the chain.
+- One Raspberry Pi 5 GPIO slowdown recommendation everywhere: 1–3 in PIO mode,
+ starting at 1. README and the config reference now describe the template
+ values as the defaults; the "code default" values they listed never apply,
+ because config migration fills missing keys from the template.
+
Plugin system:
- A plugin no longer starts with a schema warning and a degraded flag because
diff --git a/README.md b/README.md
index 0a77ce1f..162003b9 100644
--- a/README.md
+++ b/README.md
@@ -148,7 +148,7 @@ The system supports live, recent, and upcoming game information for multiple spo
```bash
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
```
- - Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
+ - Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and start `gpio_slowdown` at `1`, raising it a step at a time if the image flickers or shows garbage (see `gpio_slowdown` under Display Settings).
- **1GB models (Pi 3B / 3B+) and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`.
@@ -485,6 +485,10 @@ If you are copying my exact setup, you can likely leave the defaults alone. Howe
The display settings are located in `config/config.json` under the `"display"` key and are organized into three main sections: `hardware`, `runtime`, and `display_durations`.
+The defaults below are the values in `config/config.template.json`. They are what applies when you haven't set a key: on every load, LEDMatrix adds any key your `config.json` lacks from the template, so `DisplayManager`'s own fallbacks are never reached on a normal install.
+
+The web UI and the config API refuse values the rgbmatrix library can't start with. If one is written into `config.json` by hand anyway, the display logs which setting it is (`Failed to initialize RGB Matrix` in `sudo journalctl -u ledmatrix`), runs in fallback mode, and the Display tab shows the message.
+
### Hardware Configuration (`display.hardware`)
These settings control the physical hardware configuration and how the matrix is driven.
@@ -494,9 +498,7 @@ 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)
+ - An even number from 8 to 64, the most the rgbmatrix library drives per panel
- Must match your physical panel configuration
- **`cols`** (integer, default: 64)
@@ -507,7 +509,7 @@ These settings control the physical hardware configuration and how the matrix is
- **`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
+ - 1 to 255 (the library's Python binding stores it in one byte); longer chains lower the refresh rate
- If you have 2 panels side-by-side, set to 2
- If you have 4 panels in a row, set to 4
- Total display width = `cols × chain_length`
@@ -516,7 +518,7 @@ 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
+ - 1–3, and no more than your `hardware_mapping` has outputs: `regular` and `classic` have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); `adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1` and `classic-pi1` have 1. The library stops the display service outright on a mismatch, so it is refused
- Total display height = `rows × parallel`
#### Brightness and Visual Settings
@@ -529,12 +531,14 @@ These settings control the physical hardware configuration and how the matrix is
#### Hardware Mapping
-- **`hardware_mapping`** (string, default: "adafruit-hat-pwm")
+- **`hardware_mapping`** (string, default: "adafruit-hat")
- Specifies which GPIO pin mapping to use for your hardware
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
+ - **`"classic"`** / **`"classic-pi1"`**: the library's original pin-outs, for old adapter boards wired to them. Not used by current HATs
+ - Any other name is refused. `compute-module` is only compiled in when the library is built with `ENABLE_WIDE_GPIO_COMPUTE_MODULE`, which the installer doesn't do. On a Raspberry Pi 5, `classic-pi1` isn't supported
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
- Hardware pulsing (see `disable_hardware_pulsing`) needs the panel's OE line on GPIO 18, which `adafruit-hat-pwm` and `regular` provide and `adafruit-hat` does not
@@ -571,7 +575,6 @@ These settings affect color fidelity and smoothness of color transitions:
- 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)
@@ -613,8 +616,10 @@ These settings are typically only needed for non-standard panels or custom confi
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
settings) if the panel is mounted upside down — useful for optimizing
where the Raspberry Pi and wiring sit relative to the mounting location
+ - `"90"` and `"270"` are for a panel mounted on its side; they swap the
+ display's width and height
- Applied independently of `pixel_mapper_config` (appended as a trailing
- `Rotate:180` mapper), so custom mapper configs keep working alongside it
+ `Rotate:` mapper), so custom mapper configs keep working alongside it
- **`row_address_type`** (integer, default: 0)
- How rows are addressed on the panel
@@ -663,7 +668,7 @@ These settings control runtime behavior and GPIO timing:
- **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
+ - **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default). Start at `1` (the library treats `0` as `1` there) and raise it a step at a time if the image flickers or shows garbage — chained panels are the likeliest to need it
- Panels on `row_address_type` 5 (SM5368 row drivers) can need 6-8 on a Pi 4
- Too low: garbage, flicker or rows jumping. Too high: a lower refresh rate
- If you experience issues, try adjusting this value up or down by 1
@@ -752,7 +757,7 @@ 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`
+- If it went blank right after a settings change, the Display tab shows a "simulation mode" banner, and `sudo journalctl -u ledmatrix` shows `Failed to initialize RGB Matrix` followed by the reason. When LEDMatrix refused the settings (for example more than 64 `rows`, `parallel` 2 on an `adafruit-hat` mapping, a misspelled `hardware_mapping`, or on a Raspberry Pi 5 a `row_address_type` other than 0 or 2), the message names each one: change them, save, and restart the display service. Otherwise the library itself failed, and its own message just before names the problem
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
**Rows jump up and down, or the bottom row repeats other rows:**
diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md
index f4d991ca..834d1f4b 100644
--- a/docs/CONFIG_REFERENCE.md
+++ b/docs/CONFIG_REFERENCE.md
@@ -47,38 +47,43 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to
## `display.hardware` — matrix panel hardware
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
-in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
+in `DisplayManager._setup_matrix` (`src/display_manager.py`). Defaults are the
+`config/config.template.json` values: `ConfigManager` adds any key missing from
+`config.json` from the template on load, so `DisplayManager`'s own fallbacks
+don't apply on a normal install.
+
+The ranges are what the pinned rgbmatrix library and its Python binding accept
+(`src/matrix_support.py`). The config API refuses anything else; a value
+hand-edited into `config.json` makes the display log the setting and run in
+fallback mode instead of starting the matrix.
| Key | Type / default |
|---|---|
-| `rows` / `cols` | int, `32` / `64` — 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 |
+| `rows` / `cols` | int, `32` / `64` — rows: even, 8–64; cols: at least 16 |
+| `chain_length` | int, `2` — 1–255 (the Python binding stores it in one byte) |
+| `parallel` | int, `1` — 1–3, and no more than `hardware_mapping` has outputs (`regular`, `classic`: 3; the others: 1) |
| `brightness` | int, `90` — 1–100 |
-| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
+| `hardware_mapping` | string, `"adafruit-hat"` — `"adafruit-hat-pwm"`, `"adafruit-hat"`, `"regular"`, `"regular-pi1"`, `"classic"` or `"classic-pi1"` (case-insensitive; `compute-module` isn't in the installed build). A Pi 5 doesn't support `"classic-pi1"` |
| `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
-| `pwm_bits` | int, `9` (code default 10) — 1–11 |
+| `pwm_bits` | int, `9` — 1–11 |
| `pwm_dither_bits` | int, `1` — 0–2 |
-| `pwm_lsb_nanoseconds` | int, `130` (code default 150) — 50–3000 |
+| `pwm_lsb_nanoseconds` | int, `130` — 50–3000 |
| `disable_hardware_pulsing` | bool, `false` — `true` times brightness pulses in software (less exact); hardware pulsing needs the OE line on GPIO 18 and the Pi's onboard sound driver off |
| `inverse_colors` | bool, `false` |
| `show_refresh_rate` | bool, `false` — 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 |
+| `limit_refresh_rate_hz` | int, `100` — `0` = no cap; scroll timing assumes 100 Hz when `0` |
+| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"`; mappers that rotate or fold the chain change the display size plugins and the web preview see |
+| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); `"90"` / `"270"` for a panel on its side, swapping width and height; composed onto `pixel_mapper_config` as a trailing `Rotate:` mapper, so it stays independent of any custom `pixel_mapper_config` value |
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
-Where "code default" differs from the template value, the code default only
-applies if the key is missing entirely from your config.
-
## `display.runtime`
| Key | Type / default | Meaning |
|---|---|---|
-| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). Panels on `row_address_type` `5` (SM5368 row drivers) can need 6–8 on a Pi 4 — lower values make rows jump |
+| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). On a Pi 5 in PIO mode start at `1` (`0` acts as `1`) and raise it if the image flickers or shows garbage. Panels on `row_address_type` `5` (SM5368 row drivers) can need 6–8 on a Pi 4 — lower values make rows jump |
| `rp1_rio` | int, `0` | Pi 5 only: `0` = PIO (less CPU), `1` = RIO (higher refresh; `gpio_slowdown` effect inverted). Applied only if the installed matrix library supports it |
## `display.double_sided`
diff --git a/src/display_geometry.py b/src/display_geometry.py
index b74cb361..b5655c12 100644
--- a/src/display_geometry.py
+++ b/src/display_geometry.py
@@ -1,17 +1,25 @@
-"""Display size from config: the one computation every caller shares.
+"""Display size from config: the one computation the size readers share.
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
-``display.double_sided``. The web preview, the Starlark magnify default and
-the multi-display sync handshake used to re-derive that size themselves,
-each with its own defaults (``chain_length`` fell back to 2 in one place and
-1 in three others) and none of them applying double-sided mode. They now all
-call this module.
+``display.double_sided``. The web preview endpoints (``/display/current``, the
+SSE fallback), the Starlark magnify default and ``scripts/dev/vegas_audit.py``
+used to re-derive that size themselves, each with its own defaults
+(``chain_length`` fell back to 2 in one place and 1 in others) and none of
+them applying double-sided mode. They now call this module. (The multi-display
+sync handshake doesn't compute a size; it shares only
+``DEFAULT_CHAIN_LENGTH``.)
+
+The size includes what the rgbmatrix library's pixel mappers do to it --
+``orientation`` and ``pixel_mapper_config`` (``Rotate:90`` swaps the axes,
+``U-mapper`` folds the chain) -- because ``RGBMatrix.width``/``height``, which
+``DisplayManager`` reports on hardware, are measured after them.
Kept free of hardware imports on purpose: the web interface imports it, and
``display_manager`` pulls in ``rgbmatrix``.
"""
import logging
+import re
from typing import Any, Dict, Mapping, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -22,6 +30,10 @@ DEFAULT_COLS = 64
DEFAULT_CHAIN_LENGTH = 2
DEFAULT_PARALLEL = 1
+#: ``display.hardware.orientation`` -> degrees of the ``Rotate`` mapper
+#: DisplayManager appends to ``pixel_mapper_config`` (None: no mapper).
+ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
+
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
# A hand-edited config.json can hold anything here; treat a non-mapping
@@ -35,10 +47,128 @@ def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
return hw if isinstance(hw, Mapping) else {}
+def compose_pixel_mapper_config(hardware: Mapping[str, Any]) -> str:
+ """The ``pixel_mapper_config`` DisplayManager hands the library.
+
+ ``pixel_mapper_config`` stays a free-form advanced field (e.g. "U-mapper"
+ for chain layouts); ``orientation`` is the user-facing mounting rotation,
+ appended as a trailing ``Rotate:`` mapper rather than overwriting it.
+ """
+ base = hardware.get('pixel_mapper_config') or ''
+ base = base.strip() if isinstance(base, str) else ''
+ degrees = ORIENTATION_ROTATE_DEGREES.get(hardware.get('orientation', 'normal'))
+ if degrees is None:
+ return base
+ rotate = f'Rotate:{degrees}'
+ return f'{base};{rotate}' if base else rotate
+
+
+def _c_div(a: int, b: int) -> int:
+ """C integer division (truncates toward zero)."""
+ q = abs(a) // abs(b)
+ return q if (a >= 0) == (b >= 0) else -q
+
+
+def _c_strtol(text: str) -> Tuple[int, str]:
+ """strtol(text, &end, 10): the parsed value (0 if none) and the rest."""
+ match = re.match(r'\s*([+-]?\d+)', text)
+ if not match:
+ return 0, text
+ return int(match.group(1)), text[match.end():]
+
+
+def _remap_size(param: Optional[str], width: int, height: int,
+ chain: int, parallel: int) -> Optional[Tuple[int, int]]:
+ """RemapMapper::SetParameters and GetSizeMapping (lib/pixel-mapper.cc)."""
+ if not param:
+ return None
+ new_w, rest = _c_strtol(param)
+ if not rest.startswith(','):
+ return None
+ new_h, rest = _c_strtol(rest[1:])
+ if not rest.startswith('|'):
+ return None
+ rest = rest[1:]
+ tiles = []
+ while rest:
+ x, rest = _c_strtol(rest)
+ if not rest.startswith(','):
+ return None
+ y, rest = _c_strtol(rest[1:])
+ if not rest or rest[0].lower() not in 'neswx':
+ return None
+ tiles.append((x, y, rest[0].lower()))
+ rest = rest[1:]
+ if rest.startswith('|'):
+ rest = rest[1:]
+ elif rest:
+ return None
+ if len(tiles) != chain * parallel:
+ return None
+ panel_w, panel_h = _c_div(width, chain), _c_div(height, parallel)
+ for x, y, kind in tiles:
+ if kind == 'x':
+ continue
+ # MapTile::MapToVisible of the panel's (0, 0) and far corner.
+ x0, y0, x1, y1 = {
+ 'n': (x, y, x + panel_w - 1, y + panel_h - 1),
+ 'w': (x, y + panel_w - 1, x + panel_h - 1, y),
+ 's': (x + panel_w - 1, y + panel_h - 1, x, y),
+ 'e': (x + panel_h - 1, y, x, y + panel_w - 1),
+ }[kind]
+ if x1 < 0 or x0 >= new_w or y1 < 0 or y0 >= new_h:
+ return None
+ return new_w, new_h
+
+
+def apply_pixel_mappers(width: int, height: int, mapper_config: str,
+ chain: int, parallel: int) -> Tuple[int, int]:
+ """The canvas size after the library applies ``mapper_config``.
+
+ Mirrors ``RGBMatrix::Impl::ApplyNamedPixelMappers`` and each built-in
+ mapper's ``SetParameters``/``GetSizeMapping`` in the pinned
+ ``lib/pixel-mapper.cc``: mappers apply left to right, and one the library
+ doesn't know or can't configure is skipped and leaves the size alone.
+ ``multiplexing`` isn't modelled: its mappers give back the configured
+ size for the panel sizes they are made for.
+ """
+ for entry in (mapper_config or '').split(';'):
+ name, colon, param = entry.partition(':')
+ name = name.lower()
+ param = param if colon else None
+ if name == 'rotate':
+ if not param:
+ continue
+ angle, rest = _c_strtol(param)
+ if rest or angle % 90:
+ continue
+ if angle % 180:
+ width, height = height, width
+ elif name == 'u-mapper':
+ if chain < 2 or chain % 2 or height % parallel:
+ continue
+ width, height = _c_div(width, 64) * 32, 2 * height
+ elif name == 'v-mapper':
+ width, height = (_c_div(width * parallel, chain),
+ _c_div(height * chain, parallel))
+ elif name == 'stacktorow':
+ if param and any(c not in 'ZzFf, ' for c in param):
+ continue
+ width, height = width * parallel, _c_div(height, parallel)
+ elif name == 'remap':
+ size = _remap_size(param, width, height, chain, parallel)
+ if size is not None:
+ width, height = size
+ # "mirror" keeps the size; the library skips names it doesn't know.
+ return width, height
+
+
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
"""Width and height of the whole panel chain, in pixels.
- ``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
+ ``cols * chain_length`` by ``rows * parallel``, then through the pixel
+ mappers ``orientation`` and ``pixel_mapper_config`` set up -- what
+ ``RGBMatrix.width``/``height`` report. Raises ``ValueError`` or
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
decide their own fallback.
@@ -54,7 +184,11 @@ def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
except OverflowError as e:
raise ValueError(f"display.hardware size is not finite: {e}") from e
- return max(1, cols * chain_length), max(1, rows * parallel)
+ width, height = max(1, cols * chain_length), max(1, rows * parallel)
+ if chain_length >= 1 and parallel >= 1:
+ width, height = apply_pixel_mappers(
+ width, height, compose_pixel_mapper_config(hw), chain_length, parallel)
+ return max(1, width), max(1, height)
def resolve_double_sided(physical_width: int, physical_height: int,
diff --git a/src/display_manager.py b/src/display_manager.py
index a0b105fa..a9f5b785 100644
--- a/src/display_manager.py
+++ b/src/display_manager.py
@@ -37,9 +37,11 @@ from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
- physical_size, resolve_double_sided,
+ ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
+ resolve_double_sided,
)
-from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings
+from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
+from src.pi5_matrix_support import is_raspberry_pi_5
import threading
import time
from collections import OrderedDict
@@ -247,28 +249,41 @@ class DisplayManager:
# Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:" pixel-mapper suffix.
- # "normal" needs no suffix since 0 degrees is the identity transform.
- _ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
+ _ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
- """Compose the raw pixel_mapper_config string with the orientation setting.
+ """Compose pixel_mapper_config with the orientation setting.
- `pixel_mapper_config` stays available as a free-form advanced field (e.g.
- for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
- for physical mounting (e.g. panels mounted upside down) and is appended as
- a "Rotate:" mapper rather than overwriting any existing config.
+ See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
+ web preview shares so it sizes the canvas the same way.
"""
- base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
- orientation = hardware_config.get('orientation', 'normal')
- degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
- if degrees is None:
- return base_mapper
- rotate_mapper = f'Rotate:{degrees}'
- return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
+ return compose_pixel_mapper_config(hardware_config)
+
+ @staticmethod
+ def _fallback_advice(cause: str, error: Exception) -> str:
+ """What to do about a failed matrix init, for the log.
+
+ Only a library failure gets the rebuild hint: advice about the build
+ or GPIO timing sends someone whose settings were refused the wrong way.
+ """
+ if cause == "settings":
+ return (f"{error} Change these in the web interface's Display tab "
+ "(or display.hardware / display.runtime in config.json) "
+ "and restart the display service.")
+ if cause == "forced":
+ return f"Error: {error}."
+ advice = (f"Error: {error}. If the rgbmatrix library printed a message "
+ "just before this, it names the problem.")
+ if is_raspberry_pi_5():
+ advice += (" On a Raspberry Pi 5, an mmap error means the library was "
+ "built without Pi 5 support: sudo RPI_RGB_FORCE_REBUILD=1 "
+ "./first_time_install.sh")
+ return advice
def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings."""
_init_error_str = None
+ _init_cause = None
try:
# Allow callers (e.g., web UI) to force non-hardware fallback mode
if getattr(self, '_force_fallback', False):
@@ -278,6 +293,18 @@ class DisplayManager:
# Hardware configuration
hardware_config = self.config.get('display', {}).get('hardware', {})
runtime_config = self.config.get('display', {}).get('runtime', {})
+
+ # The library has no error path for many settings it can't use:
+ # it returns no matrix (which the binding doesn't check, so the
+ # process crashes on its next call) or calls abort(), and systemd
+ # restarts the service into the same crash. Refuse those first so
+ # they become a logged, reported fallback (src/matrix_support.py).
+ refused = refusal_message(library_refusals(
+ hardware_config, runtime_config, pi5=is_raspberry_pi_5()))
+ if refused:
+ if os.getenv("EMULATOR", "false") != "true":
+ raise MatrixSettingsRefused(refused)
+ logger.warning("Emulator mode: continuing, but on a real panel the display would not start. %s", refused)
# Basic hardware settings
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
@@ -326,15 +353,6 @@ 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")
@@ -379,7 +397,12 @@ class DisplayManager:
except Exception as e:
_init_error_str = str(e)
- logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
+ if isinstance(e, MatrixSettingsRefused):
+ _init_cause = "settings"
+ logger.error("Failed to initialize RGB Matrix: %s", e)
+ else:
+ _init_cause = "forced" if getattr(self, '_force_fallback', False) else "library"
+ logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
# Create a fallback image for web preview using configured dimensions when available
self.matrix = None
try:
@@ -406,15 +429,18 @@ class DisplayManager:
# Best-effort; ignore drawing errors in fallback
pass
logger.error(
- f"Matrix initialization failed — running in fallback/simulation mode "
- f"(size {fallback_width}x{fallback_height}). Error: {e}. "
- "On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
- "submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
- )
+ "Matrix initialization failed — running in fallback/simulation mode "
+ "(size %dx%d). %s",
+ fallback_width, fallback_height, self._fallback_advice(_init_cause, e))
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
# Write hardware status file so the web UI can surface init failures
- _hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
+ # cause: None when ok; "settings" when LEDMatrix refused the config
+ # (fix the named settings), "library" when the library itself failed,
+ # "forced" for a caller-requested fallback. The Display tab keys its
+ # advice on it.
+ _hw_status = {"ok": self.matrix is not None, "error": _init_error_str,
+ "cause": None if self.matrix is not None else _init_cause}
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try:
if os.path.islink(_status_path):
diff --git a/src/matrix_support.py b/src/matrix_support.py
new file mode 100644
index 00000000..b99d4e08
--- /dev/null
+++ b/src/matrix_support.py
@@ -0,0 +1,205 @@
+"""Display settings the pinned rgbmatrix library refuses, on every board.
+
+The library does not raise on a setting it can't use. For most it returns no
+matrix -- ``RGBMatrix::CreateFromOptions()`` gives back NULL when
+``Options::Validate()`` (``lib/options-initialize.cc``) or its GPIO slowdown
+check (``lib/led-matrix.cc``) fails -- and the Python binding stores that
+pointer without checking, so the display process crashes on its first call
+into the matrix. For two it calls ``abort()``: an unknown hardware mapping
+name, and more parallel chains than the mapping has outputs
+(``Framebuffer::InitHardwareMapping()`` and the ``Framebuffer`` constructor in
+``lib/framebuffer.cc``). Either way systemd restarts the service into the same
+crash, and no fallback or hardware status is ever reported.
+
+``DisplayManager`` runs :func:`library_refusals` before creating the matrix,
+turning those crashes into a logged error and the usual fallback mode. The
+config API uses the same rules to refuse the settings up front, and
+:data:`INT_SETTING_LIMITS` is the one place the numeric ranges live.
+
+Pi 5-only limits come from :mod:`src.pi5_matrix_support` and are added when
+``pi5=True``.
+
+**Re-check this when the submodule is bumped** (pinned at 1ee4f76): the
+ranges in ``Options::Validate()``, the mapping table in
+``lib/hardware-mapping.c`` and the setter types in
+``bindings/python/rgbmatrix/core.pyx``. A stale rule here blocks settings a
+new library accepts; a missing one lets the display service crash-loop.
+"""
+
+from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple
+
+from src.pi5_matrix_support import pi5_unsupported_settings
+
+#: The binding's setters for rows, chain_length, parallel and the other small
+#: integers are declared ``uint8_t`` (``core.pyx``), cols ``uint32_t``, and
+#: limit_refresh_rate_hz lands in a C ``int``; a larger value raises
+#: ``OverflowError`` before the library sees it.
+UINT8_MAX = 255
+UINT32_MAX = 2 ** 32 - 1
+INT32_MAX = 2 ** 31 - 1
+
+#: field -> (config section, lowest, highest, must be even). The library's own
+#: ranges where it has one, otherwise the binding's integer type.
+INT_SETTING_LIMITS: Dict[str, Tuple[str, int, int, bool]] = {
+ 'rows': ('hardware', 8, 64, True),
+ 'cols': ('hardware', 16, UINT32_MAX, False),
+ 'chain_length': ('hardware', 1, UINT8_MAX, False),
+ # 3 is the most any mapping in the default build has (see MAPPING_OUTPUTS).
+ 'parallel': ('hardware', 1, 3, False),
+ 'brightness': ('hardware', 1, 100, False),
+ 'scan_mode': ('hardware', 0, 1, False),
+ 'pwm_bits': ('hardware', 1, 11, False),
+ 'pwm_dither_bits': ('hardware', 0, 2, False),
+ 'pwm_lsb_nanoseconds': ('hardware', 50, 3000, False),
+ # 0 = no cap; the library has no upper bound.
+ 'limit_refresh_rate_hz': ('hardware', 0, INT32_MAX, False),
+ 'row_address_type': ('hardware', 0, 5, False),
+ # 22 registered multiplexers (lib/multiplex-mappers.cc).
+ 'multiplexing': ('hardware', 0, 22, False),
+ 'gpio_slowdown': ('runtime', 0, 10, False),
+ 'rp1_rio': ('runtime', 0, 1, False),
+}
+
+#: Hardware mapping name -> parallel chains it has outputs for, as
+#: ``lib/hardware-mapping.c`` defines them. ``compute-module`` exists only when
+#: the library is built with ENABLE_WIDE_GPIO_COMPUTE_MODULE, which the pinned
+#: Makefile leaves commented out and the installer does not set, so the library
+#: LEDMatrix installs aborts on it like any other unknown name.
+MAPPING_OUTPUTS: Dict[str, int] = {
+ 'regular': 3,
+ 'adafruit-hat': 1,
+ 'adafruit-hat-pwm': 1,
+ 'regular-pi1': 1,
+ 'classic': 3,
+ 'classic-pi1': 1,
+}
+
+#: What DisplayManager passes when a key is missing from display.hardware /
+#: display.runtime. Config migration normally fills these from
+#: config/config.template.json first, so they rarely apply.
+DISPLAY_MANAGER_DEFAULTS: Dict[str, Any] = {
+ 'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
+ 'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90, 'pwm_bits': 10,
+ 'pwm_lsb_nanoseconds': 150, 'led_rgb_sequence': 'RGB',
+ 'row_address_type': 0, 'multiplexing': 0, 'limit_refresh_rate_hz': 90,
+ 'gpio_slowdown': 3,
+}
+
+
+class Refusal(NamedTuple):
+ """One reason the library can't start with a config.
+
+ ``fields`` are the settings involved; the config API reports a refusal
+ only when the request sets one of them, so a problem already stored
+ doesn't block an unrelated save.
+ """
+ fields: Tuple[str, ...]
+ message: str
+ #: A Raspberry Pi 5-only limit, whose message is already a full sentence.
+ pi5: bool = False
+
+
+class MatrixSettingsRefused(RuntimeError):
+ """Raised by DisplayManager instead of handing the library such a config."""
+
+
+def _as_int(value: Any) -> Optional[int]:
+ """The integer the binding would receive, or None if it isn't one.
+
+ A value that isn't a number is left to the binding, which raises a
+ ``TypeError`` DisplayManager already turns into fallback mode.
+ """
+ try:
+ return int(value)
+ except (TypeError, ValueError, OverflowError):
+ return None
+
+
+def _setting(hardware: Mapping[str, Any], runtime: Mapping[str, Any], field: str) -> Any:
+ section = runtime if INT_SETTING_LIMITS.get(field, ('hardware',))[0] == 'runtime' else hardware
+ if field in section:
+ return section[field]
+ return DISPLAY_MANAGER_DEFAULTS.get(field)
+
+
+def describe_range(low: int, high: int, even: bool = False) -> str:
+ kind = "an even integer" if even else "an integer"
+ if high >= INT32_MAX:
+ return f"{kind} of at least {low}"
+ return f"{kind} from {low} to {high}"
+
+
+def library_refusals(hardware: Optional[Mapping[str, Any]],
+ runtime: Optional[Mapping[str, Any]] = None,
+ pi5: bool = False) -> List[Refusal]:
+ """Every reason the pinned library would refuse (NULL, abort or overflow)
+ to start with this ``display.hardware`` / ``display.runtime`` config.
+
+ Missing keys take DisplayManager's defaults. ``pi5`` adds the Raspberry
+ Pi 5 limits from :func:`pi5_unsupported_settings`.
+ """
+ hardware = hardware if isinstance(hardware, Mapping) else {}
+ runtime = runtime if isinstance(runtime, Mapping) else {}
+ refusals: List[Refusal] = []
+
+ for field, (section, low, high, even) in INT_SETTING_LIMITS.items():
+ # DisplayManager sets these only when present, and scan_mode /
+ # pwm_dither_bits / rp1_rio have no default of its own.
+ source = runtime if section == 'runtime' else hardware
+ if field not in source and field not in DISPLAY_MANAGER_DEFAULTS:
+ continue
+ value = _as_int(_setting(hardware, runtime, field))
+ if value is None:
+ continue
+ if not low <= value <= high or (even and value % 2):
+ refusals.append(Refusal(
+ (field,), f"{field} {value} (must be {describe_range(low, high, even)})"))
+
+ mapping = _setting(hardware, runtime, 'hardware_mapping')
+ outputs = None
+ if not isinstance(mapping, str):
+ refusals.append(Refusal(
+ ('hardware_mapping',), f"hardware mapping {mapping!r} (must be a mapping name)"))
+ else:
+ # The library matches names case-insensitively and reads an empty
+ # name as "regular".
+ outputs = MAPPING_OUTPUTS.get((mapping or 'regular').lower())
+ if outputs is None:
+ refusals.append(Refusal(
+ ('hardware_mapping',),
+ f'hardware mapping "{mapping}" (the installed library has '
+ + ", ".join(MAPPING_OUTPUTS) + ")"))
+
+ parallel = _as_int(_setting(hardware, runtime, 'parallel'))
+ if outputs is not None and parallel is not None and 1 <= parallel <= 3 and parallel > outputs:
+ refusals.append(Refusal(
+ ('parallel', 'hardware_mapping'),
+ f'parallel {parallel} with hardware mapping "{mapping}", which has '
+ f'{outputs} output{"s" if outputs != 1 else ""}'))
+
+ sequence = _setting(hardware, runtime, 'led_rgb_sequence')
+ if isinstance(sequence, str) and not (
+ len(sequence) == 3 and set(sequence.upper()) == {'R', 'G', 'B'}):
+ refusals.append(Refusal(
+ ('led_rgb_sequence',),
+ f'LED RGB sequence "{sequence}" (must be R, G and B in some order)'))
+
+ if pi5:
+ unsupported = pi5_unsupported_settings(hardware)
+ if unsupported:
+ refusals.append(Refusal(
+ ('row_address_type', 'parallel', 'hardware_mapping'), unsupported, pi5=True))
+
+ return refusals
+
+
+def refusal_message(refusals: List[Refusal]) -> Optional[str]:
+ """One sentence naming every refusal, or None when there are none."""
+ general = [r.message for r in refusals if not r.pi5]
+ pi5 = [r.message for r in refusals if r.pi5]
+ parts = []
+ if general:
+ parts.append("The installed rgbmatrix library can't start with these "
+ "display settings: " + "; ".join(general) + ".")
+ parts.extend(pi5)
+ return " ".join(parts) or None
diff --git a/test/test_api_v3_display_hardware.py b/test/test_api_v3_display_hardware.py
index c367f67b..6e31bc81 100644
--- a/test/test_api_v3_display_hardware.py
+++ b/test/test_api_v3_display_hardware.py
@@ -1,9 +1,10 @@
"""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:
+Held to what the pinned library and its Python binding accept
+(src/matrix_support.py: RGBMatrix::Options::Validate in
+lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc, the
+mapping table in lib/hardware-mapping.c and the binding's uint8_t setters).
+Ways this used to go wrong:
- The Display form capped cols at 128, chain_length at 24 and
pwm_lsb_nanoseconds at 500, and its submit handler (fixInvalidNumberInputs)
@@ -11,6 +12,10 @@ currently rejects more than 64 per panel. Two ways this used to go wrong:
a long chain silently saved as the wrong size.
- The API checked none of these, so a value the library rejects (odd rows,
parallel 4, pwm_dither_bits 3) saved, and the matrix then refused to start.
+- After that, rows above 64, chain_length above 255, a misspelled hardware
+ mapping and parallel 2-3 on a single-output HAT mapping still saved. The
+ library answers those with no matrix or abort(), not an error, so the
+ display service crash-looped instead of falling back.
Row address type 5 is the SM5368 / B707 row shift register the Waveshare 96x48
V2 needs (Waveshare's own "96X48_1_24_SM5368" panel type in their library fork
@@ -102,10 +107,9 @@ def test_waveshare_96x48_v2_settings_all_save(api_v3_client, saved, as_strings):
@pytest.mark.parametrize('field,value', [
- ('rows', 8), ('rows', 64), ('rows', 96), ('rows', 128),
+ ('rows', 8), ('rows', 64),
('cols', 16), ('cols', 192), ('cols', 512),
- ('chain_length', 1), ('chain_length', 32),
- ('parallel', 3),
+ ('chain_length', 1), ('chain_length', 32), ('chain_length', 255),
('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0),
('multiplexing', 0), ('multiplexing', 22),
('gpio_slowdown', 0), ('gpio_slowdown', 10),
@@ -123,9 +127,9 @@ def test_values_in_range_are_saved(api_v3_client, saved, field, value):
@pytest.mark.parametrize('field,value', [
- ('rows', 6), ('rows', 47), ('rows', 97), ('rows', '48.5'),
+ ('rows', 6), ('rows', 47), ('rows', 66), ('rows', 96), ('rows', 128), ('rows', '48.5'),
('cols', 15), ('cols', 96.5), ('cols', True), ('cols', 'wide'),
- ('chain_length', 0),
+ ('chain_length', 0), ('chain_length', 256), ('chain_length', 300),
('parallel', 0), ('parallel', 4),
('row_address_type', -1), ('row_address_type', 6),
('row_address_type', True), ('row_address_type', 5.5),
@@ -146,6 +150,69 @@ def test_values_out_of_range_are_refused(api_v3_client, saved, field, value):
assert 'config' not in saved
+@pytest.mark.parametrize('body', [
+ {'hardware_mapping': 'regular', 'parallel': 3},
+ {'hardware_mapping': 'classic', 'parallel': 2},
+ {'hardware_mapping': 'Regular', 'parallel': 3},
+ {'hardware_mapping': 'classic-pi1'},
+ {'hardware_mapping': 'adafruit-hat', 'parallel': 1},
+])
+def test_mappings_the_library_has_are_saved(api_v3_client, saved, body):
+ response = _post(api_v3_client, body)
+ assert response.status_code == 200, response.get_data(as_text=True)[:200]
+ for field, value in body.items():
+ assert saved['config']['display']['hardware'][field] == value
+
+
+@pytest.mark.parametrize('body,named', [
+ # Framebuffer() abort()s: the HAT mappings define one output.
+ ({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, 'parallel 2'),
+ ({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, 'parallel 3'),
+ ({'hardware_mapping': 'regular-pi1', 'parallel': 2}, 'parallel 2'),
+ # InitHardwareMapping() abort()s on a name it doesn't have; compute-module
+ # isn't compiled into the default build.
+ ({'hardware_mapping': 'adafruit-hat-pwn'}, 'adafruit-hat-pwn'),
+ ({'hardware_mapping': 'compute-module'}, 'compute-module'),
+ ({'hardware_mapping': 5}, 'hardware mapping'),
+])
+def test_combinations_the_library_aborts_on_are_refused(api_v3_client, saved, body, named):
+ response = _post(api_v3_client, body)
+ assert response.status_code == 400
+ assert named in response.get_json()['message']
+ assert 'config' not in saved
+
+
+def test_parallel_is_checked_against_the_stored_mapping(api_v3_client, api_v3_module, saved):
+ api_v3_module.api_v3.config_manager.load_config.return_value = {
+ 'display': {'hardware': {'hardware_mapping': 'adafruit-hat'}}}
+ response = _post(api_v3_client, {'parallel': 2})
+ assert response.status_code == 400
+ assert 'adafruit-hat' in response.get_json()['message']
+ assert 'config' not in saved
+
+
+def test_stored_refusal_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved):
+ api_v3_module.api_v3.config_manager.load_config.return_value = {
+ 'display': {'hardware': {'hardware_mapping': 'adafruit-hat', 'parallel': 2}}}
+ response = _post(api_v3_client, {'brightness': 70})
+ assert response.status_code == 200, response.get_data(as_text=True)[:200]
+
+
+def test_a_request_value_is_checked_not_the_stored_one(api_v3_client, api_v3_module, saved):
+ """Fixing a stored bad value in the same save as a mapping change must work."""
+ api_v3_module.api_v3.config_manager.load_config.return_value = {
+ 'display': {'hardware': {'rows': 128, 'parallel': 2}}}
+ response = _post(api_v3_client, {'rows': 64, 'hardware_mapping': 'regular'})
+ assert response.status_code == 200, response.get_data(as_text=True)[:200]
+
+
+@pytest.mark.parametrize('orientation', ['normal', '90', '180', '270'])
+def test_every_orientation_display_manager_applies_is_saved(api_v3_client, saved, orientation):
+ response = _post(api_v3_client, {'orientation': orientation})
+ assert response.status_code == 200, response.get_data(as_text=True)[:200]
+ assert saved['config']['display']['hardware']['orientation'] == orientation
+
+
@pytest.fixture
def display_page(monkeypatch):
"""Render the Display settings partial for a given config."""
@@ -231,6 +298,38 @@ def test_waveshare_96x48_v2_config_renders_back_unchanged(display_page):
assert _attr(_input_tag(body, input_id), 'value') == str(WAVESHARE_96X48_V2[input_id]), input_id
+@pytest.mark.parametrize('hardware,select_id,expected', [
+ ({'hardware_mapping': 'classic'}, 'hardware_mapping', 'classic'),
+ ({'hardware_mapping': 'classic-pi1'}, 'hardware_mapping', 'classic-pi1'),
+ ({'hardware_mapping': 'adafruit-hat'}, 'hardware_mapping', 'adafruit-hat'),
+ ({'hardware_mapping': 'Regular'}, 'hardware_mapping', 'regular'),
+ ({'hardware_mapping': ''}, 'hardware_mapping', 'regular'),
+ ({'orientation': '90'}, 'orientation', '90'),
+ ({'orientation': '270'}, 'orientation', '270'),
+])
+def test_stored_mapping_and_orientation_render_back_unchanged(display_page, hardware, select_id, expected):
+ """With nothing selected the browser posts the first option, so one unrelated
+ Display save turned classic into adafruit-hat-pwm and 90 degrees into normal."""
+ body = display_page(_config_with(hardware=hardware))
+ assert _selected_option(body, select_id) == [expected]
+ assert "saved hardware mapping" not in body
+
+
+def test_missing_mapping_renders_display_managers_default(display_page):
+ config = _config_with()
+ del config['display']['hardware']['hardware_mapping']
+ assert _selected_option(display_page(config), 'hardware_mapping') == ['adafruit-hat-pwm']
+
+
+@pytest.mark.parametrize('stored', ['compute-module', 'adafruit-hat-pwn'])
+def test_unusable_stored_mapping_renders_selected_with_a_warning(display_page, stored):
+ """Kept selected rather than silently swapped for the first option; the API
+ refuses it on save, and the warning says why."""
+ body = display_page(_config_with(hardware={'hardware_mapping': stored}))
+ assert _selected_option(body, 'hardware_mapping') == [stored]
+ assert f'Your saved hardware mapping ("{stored}")' in body
+
+
@pytest.mark.parametrize('field,section', [
('gpio_slowdown', 'runtime'), ('pwm_dither_bits', 'hardware'),
('limit_refresh_rate_hz', 'hardware'),
@@ -248,7 +347,7 @@ def test_a_stored_zero_renders_as_zero(display_page, field, section):
@pytest.mark.parametrize('body', [
{'row_address_type': 5}, {'row_address_type': '1'},
- {'hardware_mapping': 'compute-module'},
+ {'hardware_mapping': 'classic-pi1'},
])
def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body):
board(PI5_MODEL)
@@ -296,6 +395,15 @@ def test_pi5_form_offers_only_supported_row_address_types(display_page, board):
assert "can't be used on this Raspberry Pi 5" not in body
+def test_pi5_form_offers_only_mappings_it_can_drive(display_page, board):
+ board(PI5_MODEL)
+ body = display_page(_config_with())
+ assert 'classic-pi1' not in _option_values(body, 'hardware_mapping')
+ body = display_page(_config_with(hardware={'hardware_mapping': 'classic-pi1'}))
+ assert _selected_option(body, 'hardware_mapping') == ['classic-pi1']
+ assert 'can use on a Raspberry Pi 5' in body
+
+
def test_other_boards_offer_every_row_address_type(display_page):
body = display_page(_config_with())
assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5']
diff --git a/test/test_display_geometry.py b/test/test_display_geometry.py
index 781e2eda..30a5e35d 100644
--- a/test/test_display_geometry.py
+++ b/test/test_display_geometry.py
@@ -142,3 +142,54 @@ def test_preview_callers_do_not_rederive_the_size():
assert "get('chain_length', 1)" not in source, rel
assert 'get("chain_length", 1)' not in source, rel
assert 'cols * chain_length' not in source, rel
+
+
+# --- Pixel mappers ----------------------------------------------------------
+# RGBMatrix.width/height are measured after the library's pixel mappers
+# (lib/pixel-mapper.cc), so the preview has to apply them too. This used to
+# report 128x32 for a Rotate:90 chain the panel drew at 32x128.
+
+@pytest.mark.parametrize('hardware,expected', [
+ ({'pixel_mapper_config': 'Rotate:90'}, (32, 128)),
+ ({'pixel_mapper_config': 'Rotate:270'}, (32, 128)),
+ ({'pixel_mapper_config': 'Rotate:-90'}, (32, 128)),
+ ({'pixel_mapper_config': 'Rotate:180'}, (128, 32)),
+ ({'orientation': '90'}, (32, 128)),
+ ({'orientation': '270'}, (32, 128)),
+ ({'orientation': '180'}, (128, 32)),
+ # Two quarter turns cancel out.
+ ({'pixel_mapper_config': 'Rotate:90', 'orientation': '270'}, (128, 32)),
+ # U-mapper folds a chain of 4 into two rows: (256 / 64) * 32 by 2 * 32.
+ ({'chain_length': 4, 'pixel_mapper_config': 'U-mapper'}, (128, 64)),
+ ({'chain_length': 4, 'pixel_mapper_config': 'u-mapper;Rotate:90'}, (64, 128)),
+ # U-mapper needs an even chain of at least 2, or the library skips it.
+ ({'chain_length': 3, 'pixel_mapper_config': 'U-mapper'}, (192, 32)),
+ ({'cols': 32, 'chain_length': 4, 'pixel_mapper_config': 'V-mapper'}, (32, 128)),
+ ({'chain_length': 1, 'parallel': 2, 'pixel_mapper_config': 'StackToRow:Z'}, (128, 32)),
+ ({'pixel_mapper_config': 'Remap:64,64|0,0n|0,32n'}, (64, 64)),
+ # A Remap panel entirely outside the visible area: the library skips it.
+ ({'pixel_mapper_config': 'Remap:64,64|0,0n|0,99n'}, (128, 32)),
+ ({'pixel_mapper_config': 'Mirror:H'}, (128, 32)),
+ # Unknown or unusable mappers are skipped by the library.
+ ({'pixel_mapper_config': 'Bogus;Rotate:45;Rotate:ninety'}, (128, 32)),
+])
+def test_pixel_mappers_change_the_size_as_the_library_does(hardware, expected):
+ hw = {'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1}
+ hw.update(hardware)
+ assert physical_size(_config(hw)) == expected
+ assert logical_size(_config(hw)) == expected
+
+
+def test_double_sided_splits_the_mapped_size():
+ cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1, 'orientation': '90'},
+ {'enabled': True, 'copies': 2, 'axis': 'vertical'})
+ assert logical_size(cfg) == (32, 64)
+
+
+def test_display_manager_composes_the_mapper_config_it_sizes_from():
+ from src.display_geometry import compose_pixel_mapper_config
+ assert compose_pixel_mapper_config({}) == ''
+ assert compose_pixel_mapper_config({'orientation': '90'}) == 'Rotate:90'
+ assert compose_pixel_mapper_config(
+ {'pixel_mapper_config': ' U-mapper ', 'orientation': '180'}) == 'U-mapper;Rotate:180'
+ assert compose_pixel_mapper_config({'orientation': 'sideways'}) == ''
diff --git a/test/test_display_manager.py b/test/test_display_manager.py
index f583015c..d579ac85 100644
--- a/test/test_display_manager.py
+++ b/test/test_display_manager.py
@@ -265,6 +265,14 @@ class TestDisplayManagerOrientation:
options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == ''
+ @pytest.mark.parametrize('orientation', ['90', '270'])
+ def test_sideways_orientation_appends_rotate_mapper(self, mock_rgb_matrix, orientation):
+ DisplayManager._instance = None
+ with patch.dict('os.environ', {'EMULATOR': 'false'}):
+ DisplayManager(self._config(orientation=orientation), suppress_test_pattern=True)
+ options = mock_rgb_matrix['options_class'].return_value
+ assert options.pixel_mapper_config == f'Rotate:{orientation}'
+
def test_orientation_180_appends_rotate_mapper(self, mock_rgb_matrix):
DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}):
@@ -281,10 +289,11 @@ class TestDisplayManagerOrientation:
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."""
+class TestDisplayManagerLibraryGuard:
+ """For many settings it can't use the library returns no matrix (which the
+ binding doesn't check, so the process crashes on its next call) or calls
+ abort() -- on every board, with more on a Pi 5. DisplayManager has to refuse
+ before creating the matrix and fall back, reporting why."""
def _config(self, **hardware_overrides):
config = {
@@ -327,10 +336,95 @@ class TestDisplayManagerPi5Guard:
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):
+ def test_pi5_only_limits_do_not_apply_to_other_boards(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
+
+ @pytest.fixture
+ def hw_status(self, tmp_path, monkeypatch):
+ """What DisplayManager writes to /tmp/led_matrix_hw_status.json."""
+ import json as _json
+ import tempfile as _tempfile
+ from src import display_manager as dm_module
+ written = {}
+ real_replace = os.replace
+ real_mkstemp = _tempfile.mkstemp
+
+ def fake_mkstemp(dir=None, prefix=None):
+ return real_mkstemp(dir=str(tmp_path), prefix=prefix)
+
+ def fake_replace(src, dst):
+ if str(dst).endswith('led_matrix_hw_status.json'):
+ with open(src) as f:
+ written.update(_json.load(f))
+ os.remove(src)
+ else:
+ real_replace(src, dst)
+
+ monkeypatch.setattr(dm_module.tempfile, 'mkstemp', fake_mkstemp)
+ monkeypatch.setattr(dm_module.os, 'replace', fake_replace)
+ monkeypatch.setattr(dm_module.os.path, 'islink', lambda _p: False)
+ return written
+
+ @pytest.mark.parametrize('overrides,named', [
+ ({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, 'parallel 2'),
+ ({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, 'parallel 3'),
+ ({'hardware_mapping': 'adafruit-hat-pwn'}, 'adafruit-hat-pwn'),
+ ({'rows': 128}, 'rows 128'),
+ ({'chain_length': 300}, 'chain_length 300'),
+ ])
+ def test_hand_edited_setting_the_library_refuses_falls_back_on_any_board(
+ self, mock_rgb_matrix, board, hw_status, overrides, named):
+ board('Raspberry Pi 4 Model B Rev 1.5')
+ DisplayManager._instance = None
+ with patch.dict('os.environ', {'EMULATOR': 'false'}):
+ dm = DisplayManager(self._config(**overrides), suppress_test_pattern=True)
+ mock_rgb_matrix['matrix_class'].assert_not_called()
+ assert dm.matrix is None
+ assert hw_status['ok'] is False
+ assert hw_status['cause'] == 'settings'
+ assert named in hw_status['error']
+
+ def test_library_failure_is_reported_as_the_library(self, mock_rgb_matrix, board, hw_status):
+ board('Raspberry Pi 4 Model B Rev 1.5')
+ mock_rgb_matrix['matrix_class'].side_effect = RuntimeError('boom')
+ DisplayManager._instance = None
+ with patch.dict('os.environ', {'EMULATOR': 'false'}):
+ dm = DisplayManager(self._config(), suppress_test_pattern=True)
+ assert dm.matrix is None
+ assert hw_status == {'ok': False, 'error': 'boom', 'cause': 'library'}
+
+ def test_success_reports_no_cause(self, mock_rgb_matrix, board, hw_status):
+ board('Raspberry Pi 4 Model B Rev 1.5')
+ DisplayManager._instance = None
+ with patch.dict('os.environ', {'EMULATOR': 'false'}):
+ DisplayManager(self._config(), suppress_test_pattern=True)
+ assert hw_status == {'ok': True, 'error': None, 'cause': None}
+
+ def test_emulator_warns_but_still_starts(self, mock_rgb_matrix, board, caplog):
+ board('Raspberry Pi 4 Model B Rev 1.5')
+ DisplayManager._instance = None
+ with patch.dict('os.environ', {'EMULATOR': 'true'}):
+ dm = DisplayManager(self._config(rows=128), suppress_test_pattern=True)
+ mock_rgb_matrix['matrix_class'].assert_called_once()
+ assert dm.matrix is not None
+ assert 'rows 128' in caplog.text
+
+ def test_refused_settings_advice_does_not_send_users_to_rebuild(self, board):
+ """The Pi 5 rebuild / GPIO slowdown hint used to follow every failure,
+ including settings LEDMatrix itself refused."""
+ from src.matrix_support import MatrixSettingsRefused
+ board('Raspberry Pi 5 Model B Rev 1.0')
+ advice = DisplayManager._fallback_advice(
+ 'settings', MatrixSettingsRefused('row address type 5'))
+ assert 'Display tab' in advice
+ assert 'first_time_install' not in advice and 'slowdown' not in advice
+ library = DisplayManager._fallback_advice('library', RuntimeError('mmap failed'))
+ assert 'RPI_RGB_FORCE_REBUILD=1' in library
+ board('Raspberry Pi 4 Model B Rev 1.5')
+ assert 'RPI_RGB_FORCE_REBUILD' not in DisplayManager._fallback_advice(
+ 'library', RuntimeError('boom'))
diff --git a/test/test_matrix_support.py b/test/test_matrix_support.py
new file mode 100644
index 00000000..8f82a44f
--- /dev/null
+++ b/test/test_matrix_support.py
@@ -0,0 +1,125 @@
+"""The library-refusal rules in src/matrix_support.py mirror the pinned library.
+
+rpi-rgb-led-matrix-master at 1ee4f76: RGBMatrix::Options::Validate in
+lib/options-initialize.cc, the runtime checks in RGBMatrix::CreateFromOptions
+(lib/led-matrix.cc), the mapping table in lib/hardware-mapping.c with the
+abort()s in lib/framebuffer.cc, and the setter types in
+bindings/python/rgbmatrix/core.pyx. When the submodule is bumped and those
+change, these are the cases to revisit.
+"""
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from src import matrix_support as ms # noqa: E402
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+def _messages(hardware, runtime=None, pi5=False):
+ return [r.message for r in ms.library_refusals(hardware, runtime, pi5=pi5)]
+
+
+def test_the_config_template_starts():
+ template = json.loads((REPO_ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8'))
+ display = template['display']
+ assert ms.library_refusals(display['hardware'], display['runtime']) == []
+ assert ms.library_refusals(display['hardware'], display['runtime'], pi5=True) == []
+
+
+def test_display_manager_defaults_start():
+ assert ms.library_refusals({}) == []
+
+
+@pytest.mark.parametrize('hardware', [
+ {'rows': 8}, {'rows': 64}, {'cols': 16}, {'cols': 1024},
+ {'chain_length': 1}, {'chain_length': 255},
+ {'hardware_mapping': 'regular', 'parallel': 3},
+ {'hardware_mapping': 'classic', 'parallel': 3},
+ {'hardware_mapping': 'REGULAR', 'parallel': 2},
+ {'hardware_mapping': '', 'parallel': 3}, # empty reads as "regular"
+ {'hardware_mapping': 'classic-pi1'},
+ {'hardware_mapping': 'regular-pi1', 'parallel': 1},
+ {'pwm_bits': 11}, {'pwm_dither_bits': 2}, {'pwm_lsb_nanoseconds': 3000},
+ {'row_address_type': 5}, {'multiplexing': 22}, {'scan_mode': 1},
+ {'brightness': 1}, {'limit_refresh_rate_hz': 0},
+ {'led_rgb_sequence': 'bgr'}, {'led_rgb_sequence': 'GBR'},
+ # Not a number: left to the binding, which raises a TypeError.
+ {'rows': 'thirty-two'},
+])
+def test_what_the_library_starts_with(hardware):
+ assert ms.library_refusals(hardware) == []
+
+
+@pytest.mark.parametrize('hardware,runtime,named', [
+ ({'rows': 66}, None, 'rows 66'),
+ ({'rows': 128}, None, 'rows 128'),
+ ({'rows': 31}, None, 'rows 31'),
+ ({'cols': 8}, None, 'cols 8'),
+ ({'chain_length': 0}, None, 'chain_length 0'),
+ ({'chain_length': 256}, None, 'chain_length 256'), # uint8_t setter
+ ({'hardware_mapping': 'regular', 'parallel': 4}, None, 'parallel 4'),
+ ({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, None, 'which has 1 output'),
+ ({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, None, 'which has 1 output'),
+ ({'hardware_mapping': 'regular-pi1', 'parallel': 2}, None, 'which has 1 output'),
+ ({'hardware_mapping': 'classic-pi1', 'parallel': 2}, None, 'which has 1 output'),
+ ({'parallel': 2}, None, 'adafruit-hat-pwm'), # DisplayManager's default mapping
+ ({'hardware_mapping': 'adafruit-hat-pwn'}, None, '"adafruit-hat-pwn"'),
+ ({'hardware_mapping': 'compute-module'}, None, '"compute-module"'),
+ ({'hardware_mapping': None}, None, 'hardware mapping None'),
+ ({'brightness': 0}, None, 'brightness 0'),
+ ({'pwm_bits': 12}, None, 'pwm_bits 12'),
+ ({'pwm_dither_bits': 3}, None, 'pwm_dither_bits 3'),
+ ({'pwm_lsb_nanoseconds': 49}, None, 'pwm_lsb_nanoseconds 49'),
+ ({'row_address_type': 6}, None, 'row_address_type 6'),
+ ({'multiplexing': 23}, None, 'multiplexing 23'),
+ ({'scan_mode': 2}, None, 'scan_mode 2'),
+ ({'led_rgb_sequence': 'RGBW'}, None, 'LED RGB sequence'),
+ ({'led_rgb_sequence': 'RRB'}, None, 'LED RGB sequence'),
+ ({}, {'gpio_slowdown': 11}, 'gpio_slowdown 11'),
+ ({}, {'gpio_slowdown': -1}, 'gpio_slowdown -1'),
+ ({}, {'rp1_rio': 2}, 'rp1_rio 2'),
+])
+def test_what_it_refuses(hardware, runtime, named):
+ messages = _messages(hardware, runtime)
+ assert any(named in m for m in messages), messages
+
+
+def test_pi5_limits_apply_only_on_a_pi5():
+ assert ms.library_refusals({'row_address_type': 5}) == []
+ refusals = ms.library_refusals({'row_address_type': 5}, pi5=True)
+ assert len(refusals) == 1 and refusals[0].pi5
+ assert set(refusals[0].fields) == {'row_address_type', 'parallel', 'hardware_mapping'}
+
+
+def test_refusal_names_the_fields_involved():
+ (refusal,) = ms.library_refusals({'hardware_mapping': 'adafruit-hat', 'parallel': 2})
+ assert set(refusal.fields) == {'parallel', 'hardware_mapping'}
+ (refusal,) = ms.library_refusals({'rows': 128})
+ assert refusal.fields == ('rows',)
+
+
+def test_message_names_every_problem():
+ message = ms.refusal_message(ms.library_refusals(
+ {'rows': 128, 'row_address_type': 5}, {'gpio_slowdown': 11}, pi5=True))
+ assert message.startswith("The installed rgbmatrix library can't start")
+ assert 'rows 128' in message and 'gpio_slowdown 11' in message
+ assert 'Raspberry Pi 5' in message and 'row address type 5' in message
+ assert ms.refusal_message([]) is None
+
+
+def test_non_mapping_sections_are_treated_as_empty():
+ assert ms.library_refusals('oops', ['a']) == []
+
+
+def test_display_manager_defaults_match_display_manager():
+ """The guard fills missing keys the way DisplayManager._setup_matrix does."""
+ source = (REPO_ROOT / 'src' / 'display_manager.py').read_text(encoding='utf-8')
+ for field, value in ms.DISPLAY_MANAGER_DEFAULTS.items():
+ if field in ('rows', 'cols', 'chain_length', 'parallel'):
+ continue # read through display_geometry's DEFAULT_* constants
+ assert f"get('{field}', {value!r})" in source, field
diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py
index d1bd34d7..64ed15ef 100644
--- a/web_interface/blueprints/api_v3/config.py
+++ b/web_interface/blueprints/api_v3/config.py
@@ -12,7 +12,9 @@ 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
+from src.display_geometry import ORIENTATION_ROTATE_DEGREES
+from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message
+from src.pi5_matrix_support import is_raspberry_pi_5
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.
@@ -561,28 +563,20 @@ def save_main_config():
return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400
# Validate orientation (physical mounting rotation; composed onto pixel_mapper_config at runtime)
- ORIENTATION_ALLOWED = {'normal', '180'}
+ ORIENTATION_ALLOWED = set(ORIENTATION_ROTATE_DEGREES)
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
- # 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):
+ # Panel geometry, PWM and GPIO timing, held to what the rgbmatrix
+ # library and its Python binding accept (src/matrix_support.py:
+ # Options::Validate, the gpio_slowdown check, and the binding's
+ # uint8_t setters, which cap chain_length at 255). Outside those
+ # ranges the library returns no matrix and the display service
+ # crash-loops rather than falling back, so they never save.
+ def _hardware_int_error(field, low, high, 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)
+ rejection = (jsonify({'status': 'error', 'message': f"Invalid {field} '{raw}'. Must be {describe_range(low, high, even)}."}), 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
@@ -590,34 +584,31 @@ def save_main_config():
value = int(raw)
except (ValueError, TypeError, OverflowError):
return rejection
- if value < low or (high is not None and value > high) or (even and value % 2):
+ if value < low or 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:
+ # rp1_rio has its own check below.
+ for field, (_section, low, high, even) in INT_SETTING_LIMITS.items():
+ if field in data and field != 'rp1_rio':
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():
+ # Combinations the library can't start with: a hardware mapping it
+ # doesn't have, more parallel chains than the mapping has outputs,
+ # and on a Pi 5 its narrower RP1 support. Reported only when this
+ # request sets one of the settings involved, so a problem already
+ # stored doesn't block unrelated saves.
+ combination_fields = ('hardware_mapping', 'parallel', 'row_address_type')
+ if any(k in data for k in combination_fields):
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
+ effective.update({k: v for k, v in data.items()
+ if k in combination_fields or k in INT_SETTING_LIMITS})
+ refusals = [r for r in library_refusals(effective, pi5=is_raspberry_pi_5())
+ if any(f in data for f in r.fields)]
+ if refusals:
+ return jsonify({'status': 'error', 'message': refusal_message(refusals)}), 400
# Handle hardware settings
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html
index f80f93bf..0fa80a17 100644
--- a/web_interface/templates/v3/partials/display.html
+++ b/web_interface/templates/v3/partials/display.html
@@ -8,21 +8,26 @@
{{ ui.settings_filter('Filter display settings…') }}
-
LED matrix running in simulation mode
Hardware initialization failed:
-
- On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
- (first_time_install.sh)
- and try adjusting GPIO Slowdown (start at 3, reduce if the display looks dim or choppy).
- Check the Logs tab for the full error.
+
+ The rgbmatrix library can't start with the settings named above, so LEDMatrix didn't try.
+ Change them below, save, then restart the display service from the Overview tab.
+
+
+ {% if is_pi5 %}If the Logs tab shows an
+ mmap error, the library was built without Raspberry Pi 5 support: rebuild it with
+ sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh.
+ Otherwise the{% else %}The{% endif %}
+ Logs tab has the full error.
@@ -41,7 +46,7 @@
-
+
-
+
-
+
+ {#- Saving posts what is selected, so a stored value must render selected
+ even when it isn't one of the usual choices. The library matches
+ names case-insensitively and reads an empty name as "regular". -#}
+ {% set stored_mapping = main_config.display.hardware.get('hardware_mapping', 'adafruit-hat-pwm') %}
+ {% set mapping_key = (stored_mapping or 'regular')|lower if stored_mapping is string else stored_mapping|string %}
+ {% set mapping_labels = {'adafruit-hat-pwm': 'Adafruit HAT PWM', 'adafruit-hat': 'Adafruit HAT', 'regular': 'Regular', 'regular-pi1': 'Regular Pi1', 'classic': 'Classic (legacy wiring)', 'classic-pi1': 'Classic Pi1 (legacy wiring)'} %}
+ {% set mapping_values = ['adafruit-hat-pwm', 'adafruit-hat', 'regular', 'regular-pi1', 'classic'] if is_pi5 else ['adafruit-hat-pwm', 'adafruit-hat', 'regular', 'regular-pi1', 'classic', 'classic-pi1'] %}
+ {% if mapping_key not in mapping_values %}
+
Your saved hardware mapping ("{{ stored_mapping }}") isn't one the installed rgbmatrix library {% if is_pi5 and mapping_key in mapping_labels %}can use on a Raspberry Pi 5{% else %}has{% endif %}, so the display won't start with it. Choose your board's mapping before saving.
+ {% endif %}
-
+
@@ -218,7 +237,7 @@
-
+
// Live "Your display: W x H" readout - width = cols x chain_length,
- // height = rows x parallel (same math as the chain-length tooltip).
+ // height = rows x parallel (same math as the chain-length tooltip),
+ // swapped for a 90/270 orientation. A custom pixel mapper config can
+ // change it further; src/display_geometry.py models those.
(function () {
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
const out = document.getElementById('display-resolution-value');
@@ -389,12 +410,19 @@
out.textContent = '—';
return;
}
- out.textContent = (v.cols * v.chain_length) + ' × ' + (v.rows * v.parallel) + ' pixels';
+ let w = v.cols * v.chain_length, h = v.rows * v.parallel;
+ const orientation = document.getElementById('orientation');
+ if (orientation && (orientation.value === '90' || orientation.value === '270')) {
+ [w, h] = [h, w];
+ }
+ out.textContent = w + ' × ' + h + ' pixels';
}
for (const id of ids) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', recompute);
}
+ const orientationSelect = document.getElementById('orientation');
+ if (orientationSelect) orientationSelect.addEventListener('change', recompute);
recompute();
})();