diff --git a/CHANGELOG.md b/CHANGELOG.md index 42170491..a5854114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,8 +67,16 @@ Web interface: - Row Address Type offers 5, the SM5368 / B707 row shift register. The Waveshare 96x48 V2 panel (back silkscreen `24S-A1`) needs it with RGB sequence BGR and, on a Pi 4, a GPIO slowdown of 6–8. Panels with FM6124 - column drivers need no Panel Type. On a Pi 5 the library supports only row - address types 0 and 2. + column drivers need no Panel Type. +- On a Raspberry Pi 5 the pinned rgbmatrix library can drive only row address + types 0 and 2, parallel 1–3 and the standard mappings. For anything else it + returns no matrix, which the Python binding doesn't catch, so the display + service crashed and restarted every 10 seconds. `DisplayManager` now refuses + those settings before creating the matrix (logged, reported by + `/api/v3/hardware/status`, fallback mode), the config API rejects them, and + the Display form offers only row address types 0 and 2 on a Pi 5. The rule + lives in `src/pi5_matrix_support.py` and must be re-checked when the + submodule is bumped. ## 3.4.0 diff --git a/CLAUDE.md b/CLAUDE.md index 0bd1129b..764b637a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,3 +63,4 @@ `self.display_manager.image.paste(img, (x, y))` then `update_display()` (use a mask for transparency: `image.paste(rgba, (x, y), rgba)`) - When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update +- `src/pi5_matrix_support.py` hardcodes what the pinned `rpi-rgb-led-matrix-master` can drive on a Raspberry Pi 5 (`Rp1PioConfigSupported()` in `lib/rp1/rp1_pio_backend.cc`). Re-check it whenever the submodule is bumped: a stale rule blocks Pi 5 settings the new library supports, and a missing one lets the display service crash-loop diff --git a/README.md b/README.md index 30787068..0a77ce1f 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,10 @@ These settings are typically only needed for non-standard panels or custom confi with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a stable image. - On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2 + (and `parallel` 1-3). Anything else would crash the display service, so on + a Pi 5 the web UI offers only 0 and 2, the config API refuses the others, + and if one is set in `config.json` anyway the display logs why and runs in + fallback mode - Check your panel datasheet if display appears corrupted - **`multiplexing`** (integer, 0-22, default: 0) @@ -748,7 +752,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 +- If it went blank right after a settings change, check `sudo journalctl -u ledmatrix` for `Failed to initialize RGB Matrix`: the rgbmatrix library refused a value (for example more than 64 `rows`, or `pwm_dither_bits` above 2) and the display fell back to no output. The library's own message nearby names the setting; on a Raspberry Pi 5, LEDMatrix's message names any unsupported `row_address_type`, `parallel` or `hardware_mapping` - A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type` **Rows jump up and down, or the bottom row repeats other rows:** diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index b9d76b9a..f4d991ca 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -67,7 +67,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295). | `limit_refresh_rate_hz` | int, `100` (code default 90) — `0` = no cap; scroll timing assumes 100 Hz when `0` | | `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` | | `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value | -| `row_address_type` | int, `0` — non-standard panel row addressing: `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` | +| `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 `""` | diff --git a/src/display_manager.py b/src/display_manager.py index a11c49c4..a0b105fa 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -39,6 +39,7 @@ from src.display_geometry import ( DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, physical_size, resolve_double_sided, ) +from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings import threading import time from collections import OrderedDict @@ -325,6 +326,15 @@ class DisplayManager: logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}") + # On a Pi 5 the library hands back no matrix for settings its RP1 + # path can't drive, and the binding doesn't check -- the process + # would crash on its next call instead of reaching the fallback + # below. Raise first so it is a logged, reported init failure. + if os.getenv("EMULATOR", "false") != "true" and is_raspberry_pi_5(): + unsupported = pi5_unsupported_settings(hardware_config) + if unsupported: + raise RuntimeError(unsupported) + # Initialize the matrix self.matrix = RGBMatrix(options=options) logger.info("RGB Matrix initialized successfully") diff --git a/src/pi5_matrix_support.py b/src/pi5_matrix_support.py new file mode 100644 index 00000000..e864032a --- /dev/null +++ b/src/pi5_matrix_support.py @@ -0,0 +1,70 @@ +"""Which matrix settings the pinned rgbmatrix library can drive on a Raspberry Pi 5. + +On a Pi 5 the library drives the panel through the RP1 chip, and at the pinned +rpi-rgb-led-matrix-master commit (1ee4f76) that path supports only some +settings -- ``Rp1PioConfigSupported()`` in ``lib/rp1/rp1_pio_backend.cc``. For +anything else ``RGBMatrix::CreateFromOptions()`` returns NULL. The Python +binding does not check for that, so instead of raising, the display process +crashes on its first call into the matrix, and systemd restarts it into the +same crash every 10 seconds. + +``DisplayManager`` checks this rule before creating the matrix, turning that +crash into a logged error and the usual fallback mode. The config API and the +Display form use the same rule to refuse the settings up front. + +**Re-check this when the submodule is bumped.** Upstream is extending Pi 5 +support (e.g. 4e326c1b, "Pi5 - Improvements, additional led-row-addr-type +support"); a stale rule here would block settings the new library drives. +""" + +from typing import Any, Mapping, Optional + +#: Read the way the library's ``Rp1PioPlatformDetected()`` reads it. +MODEL_PATH = "/proc/device-tree/model" +PI5_MODEL_MARKERS = ("Raspberry Pi 5", "Compute Module 5") + +PI5_ROW_ADDRESS_TYPES = (0, 2) +PI5_MAX_PARALLEL = 3 +PI5_HARDWARE_MAPPINGS = ("regular", "regular-pi1", "classic", "adafruit-hat", "adafruit-hat-pwm") + + +def is_raspberry_pi_5() -> bool: + """True on a Pi 5-family board: Pi 5, Pi 500 or Compute Module 5.""" + try: + with open(MODEL_PATH, "rb") as f: + model = f.read(256).decode("utf-8", "replace") + except OSError: + return False + return any(marker in model for marker in PI5_MODEL_MARKERS) + + +def _as_int(value: Any, default: int) -> Optional[int]: + if value is None or value == "": + return default + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return None + + +def pi5_unsupported_settings(hardware: Mapping[str, Any]) -> Optional[str]: + """Why a Pi 5 can't drive this ``display.hardware`` config, or None if it can. + + Missing keys take DisplayManager's defaults. A value that isn't a number is + left to the caller's own validation rather than reported here. + """ + problems = [] + row_address_type = _as_int(hardware.get("row_address_type"), 0) + if row_address_type is not None and row_address_type not in PI5_ROW_ADDRESS_TYPES: + problems.append(f"row address type {row_address_type} (only 0 and 2 are supported)") + parallel = _as_int(hardware.get("parallel"), 1) + if parallel is not None and not 1 <= parallel <= PI5_MAX_PARALLEL: + problems.append(f"parallel {parallel} (1 to {PI5_MAX_PARALLEL} are supported)") + mapping = hardware.get("hardware_mapping", "adafruit-hat-pwm") + # The library treats an empty mapping as "regular". + if isinstance(mapping, str) and (mapping or "regular") not in PI5_HARDWARE_MAPPINGS: + problems.append(f'hardware mapping "{mapping}"') + if not problems: + return None + return ("Not supported on a Raspberry Pi 5 by the installed rgbmatrix library: " + + "; ".join(problems) + ".") diff --git a/test/test_api_v3_display_hardware.py b/test/test_api_v3_display_hardware.py index 04c507c7..c367f67b 100644 --- a/test/test_api_v3_display_hardware.py +++ b/test/test_api_v3_display_hardware.py @@ -31,6 +31,7 @@ sys.path.insert(0, str(PROJECT_ROOT)) from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 from test.test_web_settings_ui import REALISTIC_CONFIG # noqa: E402 +from src import pi5_matrix_support # noqa: E402 #: What a Waveshare RGB-Matrix-P2.5-96x48 V2 (back silkscreen 24S-A1) needed on #: a Pi 4 with an Adafruit Triple LED Matrix Bonnet, checked on the panel. @@ -43,6 +44,24 @@ WAVESHARE_96X48_V2 = { #: Fields stored under display.runtime; the rest go under display.hardware. RUNTIME_FIELDS = {'gpio_slowdown'} +PI5_MODEL = 'Raspberry Pi 5 Model B Rev 1.0' + + +@pytest.fixture(autouse=True) +def board(tmp_path, monkeypatch): + """Not a Pi 5 unless a test says so, whatever machine runs the suite. + + Returns a setter: board(PI5_MODEL) makes the API and the form see a Pi 5. + """ + path = tmp_path / 'device-tree-model' + + def set_model(model): + path.write_bytes(model.encode() + b'\x00') + + set_model('Raspberry Pi 4 Model B Rev 1.5') + monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path)) + return set_model + def _stored(config, field): section = 'runtime' if field in RUNTIME_FIELDS else 'hardware' @@ -220,3 +239,70 @@ def test_a_stored_zero_renders_as_zero(display_page, field, section): """`value or default` showed a stored 0 as the default, and the next save wrote it back.""" body = display_page(_config_with(**{section: {field: 0}})) assert _attr(_input_tag(body, field), 'value') == '0' + + +# --- Raspberry Pi 5 ------------------------------------------------------- +# The pinned library's Pi 5 path drives only row address types 0 and 2, +# parallel 1-3 and the standard mappings; anything else crashes the display +# service (src/pi5_matrix_support.py), so the API and the form refuse it. + +@pytest.mark.parametrize('body', [ + {'row_address_type': 5}, {'row_address_type': '1'}, + {'hardware_mapping': 'compute-module'}, +]) +def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body): + board(PI5_MODEL) + response = _post(api_v3_client, body) + assert response.status_code == 400 + assert 'Raspberry Pi 5' in response.get_json()['message'] + assert 'config' not in saved + + +def test_pi5_saves_what_it_can_drive(api_v3_client, saved, board): + board(PI5_MODEL) + response = _post(api_v3_client, dict(WAVESHARE_96X48_V2, row_address_type=2)) + assert response.status_code == 200, response.get_data(as_text=True)[:200] + assert saved['config']['display']['hardware']['row_address_type'] == 2 + + +def test_pi5_check_uses_the_stored_value_for_fields_not_sent(api_v3_client, api_v3_module, saved, board): + """Changing only the mapping is still checked against the stored row address type.""" + board(PI5_MODEL) + api_v3_module.api_v3.config_manager.load_config.return_value = { + 'display': {'hardware': {'row_address_type': 5}}} + response = _post(api_v3_client, {'hardware_mapping': 'regular'}) + assert response.status_code == 400 + assert 'config' not in saved + + +def test_pi5_stored_combination_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved, board): + board(PI5_MODEL) + api_v3_module.api_v3.config_manager.load_config.return_value = { + 'display': {'hardware': {'row_address_type': 5}}} + response = _post(api_v3_client, {'brightness': 70}) + assert response.status_code == 200, response.get_data(as_text=True)[:200] + + +def _option_values(body, select_id): + select = re.search(r'' + return re.findall(r'