From c693278d338eea421e502471800271bb55366730 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:59:47 -0400 Subject: [PATCH 1/3] fix(display): size the preview after orientation and pixel mappers display_geometry.physical_size claimed to give DisplayManager's answer but only computed cols*chain x rows*parallel. RGBMatrix.width/height are measured after the library's pixel mappers, so a Rotate:90 / orientation 90 chain previewed 128x32 for a 32x128 panel and a U-mapper chain of four 256x32 for 128x64. Model the built-in mappers' size effect as the pinned lib/pixel-mapper.cc does (Rotate, U-mapper, V-mapper, StackToRow, Remap; Mirror and unknown names leave it alone), and move the orientation composition here so DisplayManager and the preview share it. The module docstring no longer claims the sync handshake uses it; that imports only DEFAULT_CHAIN_LENGTH. Audit finding F18. Co-Authored-By: Claude Opus 5 --- src/display_geometry.py | 150 ++++++++++++++++++++++++++++++++-- test/test_display_geometry.py | 51 ++++++++++++ 2 files changed, 193 insertions(+), 8 deletions(-) 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/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'}) == '' From 94ec74caa551b3af2aaade18e9f0b51e17671956 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:59:47 -0400 Subject: [PATCH 2/3] fix(display): refuse settings the rgbmatrix library aborts on, on every board The library answers several settings with a NULL matrix or abort() rather than an error, so the display service crash-looped (Restart=on-failure) instead of reaching fallback mode: rows above 64, chain_length above 255 (uint8_t binding setter, documented as "no upper limit"), a misspelled hardware_mapping, and parallel 2-3 on a single-output mapping, reachable from the Display form on the default adafruit-hat(-pwm) mapping. #586 only guarded the Pi 5 subset. - src/matrix_support.py holds the rules for every board (Options::Validate ranges, binding integer types, mapping names and outputs from lib/hardware-mapping.c) plus the Pi 5 ones, and is the one source of the API's numeric ranges. - DisplayManager checks them before building options and raises MatrixSettingsRefused, so a hand-edited config falls back with a logged, reported reason. Emulator mode only warns. - The config API refuses them with a 400 naming the setting; combinations are checked against stored values but reported only when the request sets a field involved. - The hardware status file gains "cause" (settings/library/forced). The fallback log and Display banner give the Pi 5 rebuild hint only for a library failure instead of rebuild + gpio_slowdown advice for every failure; one Pi 5 slowdown recommendation (1-3, start at 1). - The Display form offers classic/classic-pi1 and orientation 90/270 and renders any other stored mapping selected with a warning, so an unrelated save no longer rewrites them; the API accepts 90/270. Audit findings F03, F16, F19, F21. Co-Authored-By: Claude Opus 5 --- src/display_manager.py | 90 +++++--- src/matrix_support.py | 205 ++++++++++++++++++ test/test_api_v3_display_hardware.py | 128 ++++++++++- test/test_display_manager.py | 104 ++++++++- test/test_matrix_support.py | 125 +++++++++++ web_interface/blueprints/api_v3/config.py | 67 +++--- .../templates/v3/partials/display.html | 64 ++++-- 7 files changed, 680 insertions(+), 103 deletions(-) create mode 100644 src/matrix_support.py create mode 100644 test/test_matrix_support.py 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_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(); })(); From b02b302823d104f9aac9f94cf11ce2084a30968e Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:59:47 -0400 Subject: [PATCH 3/3] docs(display): library limits, template defaults and Pi 5 slowdown - rows 8-64, chain_length 1-255, parallel limited by the mapping's outputs, classic/classic-pi1 mappings and orientation 90/270 documented. - Defaults are the config.template.json values: config migration adds missing keys from the template, so the listed "code defaults" never applied. - One Raspberry Pi 5 gpio_slowdown recommendation: 1-3 in PIO mode, starting at 1. - Troubleshooting describes the refused-settings fallback, and CHANGELOG corrects the Unreleased "no upper limit" entry. Audit findings F19, F20, F21. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 37 ++++++++++++++++++++++++++++++++++--- README.md | 27 ++++++++++++++++----------- docs/CONFIG_REFERENCE.md | 33 +++++++++++++++++++-------------- 3 files changed, 69 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea497352..6f5c1c28 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 @@ -112,6 +112,37 @@ Web interface: re-sent with backoff instead of being counted as failed and skipped — that is how a disabled plugin with an update waiting was silently left out. +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`