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…') }} - @@ -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(); })();