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 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-17 08:59:47 -04:00
co-authored by Claude Opus 5
parent c693278d33
commit 94ec74caa5
7 changed files with 680 additions and 103 deletions
+58 -32
View File
@@ -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.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src.display_geometry import ( from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, 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 threading
import time import time
from collections import OrderedDict from collections import OrderedDict
@@ -247,28 +249,41 @@ class DisplayManager:
# Calendar manager is now initialized by DisplayController # Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix. # Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
# "normal" needs no suffix since 0 degrees is the identity transform. _ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
_ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
def _build_pixel_mapper_config(self, hardware_config: dict) -> str: 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. See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown web preview shares so it sizes the canvas the same way.
for physical mounting (e.g. panels mounted upside down) and is appended as
a "Rotate:<deg>" mapper rather than overwriting any existing config.
""" """
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip() return compose_pixel_mapper_config(hardware_config)
orientation = hardware_config.get('orientation', 'normal')
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation) @staticmethod
if degrees is None: def _fallback_advice(cause: str, error: Exception) -> str:
return base_mapper """What to do about a failed matrix init, for the log.
rotate_mapper = f'Rotate:{degrees}'
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper 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): def _setup_matrix(self):
"""Initialize the RGB matrix with configuration settings.""" """Initialize the RGB matrix with configuration settings."""
_init_error_str = None _init_error_str = None
_init_cause = None
try: try:
# Allow callers (e.g., web UI) to force non-hardware fallback mode # Allow callers (e.g., web UI) to force non-hardware fallback mode
if getattr(self, '_force_fallback', False): if getattr(self, '_force_fallback', False):
@@ -278,6 +293,18 @@ class DisplayManager:
# Hardware configuration # Hardware configuration
hardware_config = self.config.get('display', {}).get('hardware', {}) hardware_config = self.config.get('display', {}).get('hardware', {})
runtime_config = self.config.get('display', {}).get('runtime', {}) 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 # Basic hardware settings
options.rows = hardware_config.get('rows', DEFAULT_ROWS) 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}") 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 # Initialize the matrix
self.matrix = RGBMatrix(options=options) self.matrix = RGBMatrix(options=options)
logger.info("RGB Matrix initialized successfully") logger.info("RGB Matrix initialized successfully")
@@ -379,7 +397,12 @@ class DisplayManager:
except Exception as e: except Exception as e:
_init_error_str = str(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 # Create a fallback image for web preview using configured dimensions when available
self.matrix = None self.matrix = None
try: try:
@@ -406,15 +429,18 @@ class DisplayManager:
# Best-effort; ignore drawing errors in fallback # Best-effort; ignore drawing errors in fallback
pass pass
logger.error( logger.error(
f"Matrix initialization failed — running in fallback/simulation mode " "Matrix initialization failed — running in fallback/simulation mode "
f"(size {fallback_width}x{fallback_height}). Error: {e}. " "(size %dx%d). %s",
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest " fallback_width, fallback_height, self._fallback_advice(_init_cause, e))
"submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
)
# Do not raise here; allow fallback mode so web preview and non-hardware environments work # 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 # 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 _status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
try: try:
if os.path.islink(_status_path): if os.path.islink(_status_path):
+205
View File
@@ -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
+118 -10
View File
@@ -1,9 +1,10 @@
"""Display hardware settings accept what the rgbmatrix library accepts. """Display hardware settings accept what the rgbmatrix library accepts.
Held to the ranges in the pinned library (RGBMatrix::Options::Validate in Held to what the pinned library and its Python binding accept
lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc), with (src/matrix_support.py: RGBMatrix::Options::Validate in
one deliberate exception: rows has no upper bound here, although the library lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc, the
currently rejects more than 64 per panel. Two ways this used to go wrong: 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 - The Display form capped cols at 128, chain_length at 24 and
pwm_lsb_nanoseconds at 500, and its submit handler (fixInvalidNumberInputs) 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. a long chain silently saved as the wrong size.
- The API checked none of these, so a value the library rejects (odd rows, - 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. 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 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 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', [ @pytest.mark.parametrize('field,value', [
('rows', 8), ('rows', 64), ('rows', 96), ('rows', 128), ('rows', 8), ('rows', 64),
('cols', 16), ('cols', 192), ('cols', 512), ('cols', 16), ('cols', 192), ('cols', 512),
('chain_length', 1), ('chain_length', 32), ('chain_length', 1), ('chain_length', 32), ('chain_length', 255),
('parallel', 3),
('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0), ('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0),
('multiplexing', 0), ('multiplexing', 22), ('multiplexing', 0), ('multiplexing', 22),
('gpio_slowdown', 0), ('gpio_slowdown', 10), ('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', [ @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'), ('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), ('parallel', 0), ('parallel', 4),
('row_address_type', -1), ('row_address_type', 6), ('row_address_type', -1), ('row_address_type', 6),
('row_address_type', True), ('row_address_type', 5.5), ('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 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 @pytest.fixture
def display_page(monkeypatch): def display_page(monkeypatch):
"""Render the Display settings partial for a given config.""" """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 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', [ @pytest.mark.parametrize('field,section', [
('gpio_slowdown', 'runtime'), ('pwm_dither_bits', 'hardware'), ('gpio_slowdown', 'runtime'), ('pwm_dither_bits', 'hardware'),
('limit_refresh_rate_hz', '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', [ @pytest.mark.parametrize('body', [
{'row_address_type': 5}, {'row_address_type': '1'}, {'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): def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body):
board(PI5_MODEL) 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 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): def test_other_boards_offer_every_row_address_type(display_page):
body = display_page(_config_with()) body = display_page(_config_with())
assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5'] assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5']
+99 -5
View File
@@ -265,6 +265,14 @@ class TestDisplayManagerOrientation:
options = mock_rgb_matrix['options_class'].return_value options = mock_rgb_matrix['options_class'].return_value
assert options.pixel_mapper_config == '' 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): def test_orientation_180_appends_rotate_mapper(self, mock_rgb_matrix):
DisplayManager._instance = None DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}): with patch.dict('os.environ', {'EMULATOR': 'false'}):
@@ -281,10 +289,11 @@ class TestDisplayManagerOrientation:
assert options.pixel_mapper_config == 'U-mapper;Rotate:180' assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
class TestDisplayManagerPi5Guard: class TestDisplayManagerLibraryGuard:
"""On a Pi 5 the library returns no matrix for settings its RP1 path can't """For many settings it can't use the library returns no matrix (which the
drive, and the binding doesn't check, so the process would crash on its next binding doesn't check, so the process crashes on its next call) or calls
call. DisplayManager has to refuse before creating the matrix and fall back.""" 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): def _config(self, **hardware_overrides):
config = { config = {
@@ -327,10 +336,95 @@ class TestDisplayManagerPi5Guard:
mock_rgb_matrix['matrix_class'].assert_called_once() mock_rgb_matrix['matrix_class'].assert_called_once()
assert dm.matrix is not None 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') board('Raspberry Pi 4 Model B Rev 1.5')
DisplayManager._instance = None DisplayManager._instance = None
with patch.dict('os.environ', {'EMULATOR': 'false'}): with patch.dict('os.environ', {'EMULATOR': 'false'}):
dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True) dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True)
mock_rgb_matrix['matrix_class'].assert_called_once() mock_rgb_matrix['matrix_class'].assert_called_once()
assert dm.matrix is not None 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'))
+125
View File
@@ -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
+29 -38
View File
@@ -12,7 +12,9 @@ from web_interface.blueprints.api_v3 import (
success_response, success_response,
) )
from src.common.path_safety import resolve_under 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 import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these # Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch. # 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 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) # 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: 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 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 # Panel geometry, PWM and GPIO timing, held to what the rgbmatrix
# accepts (RGBMatrix::Options::Validate in lib/options-initialize.cc, # library and its Python binding accept (src/matrix_support.py:
# the gpio_slowdown check in lib/led-matrix.cc). Outside those ranges # Options::Validate, the gpio_slowdown check, and the binding's
# the config used to save, then the matrix refused to start and the # uint8_t setters, which cap chain_length at 255). Outside those
# display dropped to fallback mode. cols, chain_length and # ranges the library returns no matrix and the display service
# limit_refresh_rate_hz (0 = no cap) have no upper bound in the # crash-loops rather than falling back, so they never save.
# library. rows has none here by choice: the library currently def _hardware_int_error(field, low, high, even=False):
# 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):
"""A 400 response if data[field] is not an allowed integer, else None.""" """A 400 response if data[field] is not an allowed integer, else None."""
raw = data[field] raw = data[field]
kind = "an even integer" if even else "an integer" rejection = (jsonify({'status': 'error', 'message': f"Invalid {field} '{raw}'. Must be {describe_range(low, high, even)}."}), 400)
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)
# int() would quietly turn true into 1 and 48.5 into 48. # 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()): if isinstance(raw, bool) or (isinstance(raw, float) and not raw.is_integer()):
return rejection return rejection
@@ -590,34 +584,31 @@ def save_main_config():
value = int(raw) value = int(raw)
except (ValueError, TypeError, OverflowError): except (ValueError, TypeError, OverflowError):
return rejection 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 rejection
return None return None
for field, low, high, even in (('rows', 8, None, True), ('cols', 16, None, False), # rp1_rio has its own check below.
('chain_length', 1, None, False), ('parallel', 1, 3, False), for field, (_section, low, high, even) in INT_SETTING_LIMITS.items():
('brightness', 1, 100, False), ('scan_mode', 0, 1, False), if field in data and field != 'rp1_rio':
('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:
error = _hardware_int_error(field, low, high, even) error = _hardware_int_error(field, low, high, even)
if error: if error:
return error return error
# A Pi 5 can't drive every combination (src/pi5_matrix_support.py), # Combinations the library can't start with: a hardware mapping it
# and one it can't crashes the display service instead of falling # doesn't have, more parallel chains than the mapping has outputs,
# back. Checked only when this request sets one of those fields, so # and on a Pi 5 its narrower RP1 support. Reported only when this
# a combination already stored doesn't block unrelated saves. # request sets one of the settings involved, so a problem already
pi5_fields = ('row_address_type', 'parallel', 'hardware_mapping') # stored doesn't block unrelated saves.
if any(k in data for k in pi5_fields) and is_raspberry_pi_5(): 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 = dict(current_config['display']['hardware'])
effective.update({k: data[k] for k in pi5_fields if k in data}) effective.update({k: v for k, v in data.items()
unsupported = pi5_unsupported_settings(effective) if k in combination_fields or k in INT_SETTING_LIMITS})
if unsupported: refusals = [r for r in library_refusals(effective, pi5=is_raspberry_pi_5())
return jsonify({'status': 'error', 'message': unsupported}), 400 if any(f in data for f in r.fields)]
if refusals:
return jsonify({'status': 'error', 'message': refusal_message(refusals)}), 400
# Handle hardware settings # Handle hardware settings
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode', for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
@@ -8,21 +8,26 @@
{{ ui.settings_filter('Filter display settings…') }} {{ ui.settings_filter('Filter display settings…') }}
<!-- Hardware status banner: shown when display service is in fallback/simulation mode --> <!-- Hardware status banner: shown when display service is in fallback/simulation mode -->
<div x-data="{ show: false, errorMsg: '' }" <div x-data="{ show: false, errorMsg: '', cause: null }"
x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => { x-init="fetch('/api/v3/hardware/status').then(r => r.json()).then(d => {
const hw = (d && d.data) || {}; const hw = (d && d.data) || {};
if (hw.ok === false) { show = true; errorMsg = hw.error || 'Unknown error'; } if (hw.ok === false) { show = true; errorMsg = hw.error || 'Unknown error'; cause = hw.cause || null; }
}).catch(() => {})" }).catch(() => {})"
x-show="show" x-show="show"
style="display:none" style="display:none"
class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-6"> class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-6">
<p class="font-semibold text-yellow-800"><i class="fas fa-exclamation-triangle mr-2"></i>LED matrix running in simulation mode</p> <p class="font-semibold text-yellow-800"><i class="fas fa-exclamation-triangle mr-2"></i>LED matrix running in simulation mode</p>
<p class="text-sm text-yellow-700 mt-1">Hardware initialization failed: <span x-text="errorMsg" class="font-mono text-xs break-all"></span></p> <p class="text-sm text-yellow-700 mt-1">Hardware initialization failed: <span x-text="errorMsg" class="font-mono text-xs break-all"></span></p>
<p class="text-sm text-yellow-700 mt-2"> <p class="text-sm text-yellow-700 mt-2" x-show="cause === 'settings'">
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule The rgbmatrix library can't start with the settings named above, so LEDMatrix didn't try.
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>) Change them below, save, then restart the display service from the Overview tab.
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy). </p>
Check the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> for the full error. <p class="text-sm text-yellow-700 mt-2" x-show="cause !== 'settings'">
{% if is_pi5 %}If the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> shows an
<code class="bg-yellow-100 px-1 rounded">mmap</code> error, the library was built without Raspberry Pi 5 support: rebuild it with
<code class="bg-yellow-100 px-1 rounded">sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh</code>.
Otherwise the{% else %}The{% endif %}
<a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> has the full error.
</p> </p>
</div> </div>
@@ -41,7 +46,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4">
<div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows"> <div class="form-group" id="setting-display-rows" data-setting-key="display.hardware.rows">
<label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, 48 or 64. Default: 32. Must match your panel and be an even number, at least 8. There is no upper limit here, but the current rgbmatrix library rejects more than 64 rows per panel, and the display will not start.', 'Rows') }}</label> <label for="rows" class="block text-sm font-medium text-gray-700">Rows{{ ui.help_tip('Number of LED rows on a single panel.\nCommon: 16, 32, 48 or 64. Default: 32. Must match your panel and be an even number from 8 to 64, the most the rgbmatrix library drives per panel.', 'Rows') }}</label>
<input type="number" <input type="number"
id="rows" id="rows"
name="rows" name="rows"
@@ -62,7 +67,7 @@
</div> </div>
<div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length"> <div class="form-group" id="setting-display-chain_length" data-setting-key="display.hardware.chain_length">
<label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display. No upper limit, but longer chains lower the refresh rate.', 'Chain Length') }}</label> <label for="chain_length" class="block text-sm font-medium text-gray-700">Chain Length{{ ui.help_tip('How many panels are wired end-to-end in one chain.\nDefault: 2. Example: two 64×32 panels chained make a 128×32 display. 1 to 255; longer chains lower the refresh rate.', 'Chain Length') }}</label>
<input type="number" <input type="number"
id="chain_length" id="chain_length"
name="chain_length" name="chain_length"
@@ -72,7 +77,7 @@
</div> </div>
<div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel"> <div class="form-group" id="setting-display-parallel" data-setting-key="display.hardware.parallel">
<label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. The Raspberry Pi supports up to 3, and the HAT needs that many outputs (e.g. the Adafruit Triple LED Matrix Bonnet).', 'Parallel') }}</label> <label for="parallel" class="block text-sm font-medium text-gray-700">Parallel{{ ui.help_tip('Number of separate chains driven in parallel from the HAT.\nDefault: 1. Up to 3, and the hardware mapping needs that many outputs: Regular and Classic have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); Adafruit HAT, Adafruit HAT PWM and the Pi1 mappings have 1.', 'Parallel') }}</label>
<input type="number" <input type="number"
id="parallel" id="parallel"
name="parallel" name="parallel"
@@ -107,19 +112,33 @@
<div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping"> <div class="form-group" id="setting-display-hardware_mapping" data-setting-key="display.hardware.hardware_mapping">
<label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit RGB Matrix HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it (no hardware pulsing, so expect a little more flicker); "Regular" for direct GPIO wiring and for the Adafruit Triple LED Matrix Bonnet.', 'Hardware Mapping') }}</label> <label for="hardware_mapping" class="block text-sm font-medium text-gray-700">Hardware Mapping{{ ui.help_tip('How the LED panel is wired to the Pi.\nUse "Adafruit HAT PWM" for an Adafruit RGB Matrix HAT/Bonnet with the PWM solder mod; "Adafruit HAT" without it (no hardware pulsing, so expect a little more flicker); "Regular" for direct GPIO wiring and for the Adafruit Triple LED Matrix Bonnet.', 'Hardware Mapping') }}</label>
{#- 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'] %}
<select id="hardware_mapping" name="hardware_mapping" class="form-control"> <select id="hardware_mapping" name="hardware_mapping" class="form-control">
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option> {% for value in mapping_values %}
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option> <option value="{{ value }}" {% if mapping_key == value %}selected{% endif %}>{{ mapping_labels[value] }}</option>
<option value="regular" {% if main_config.display.hardware.hardware_mapping == "regular" %}selected{% endif %}>Regular</option> {% endfor %}
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option> {% if mapping_key not in mapping_values %}
<option value="{{ stored_mapping }}" selected>{{ stored_mapping }} (saved, can't be used)</option>
{% endif %}
</select> </select>
{% if mapping_key not in mapping_values %}
<p class="text-sm text-red-600 mt-1">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.</p>
{% endif %}
</div> </div>
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation"> <div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side.', 'Panel Orientation') }}</label> <label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side. 90° and 270° are for a panel mounted on its side, and swap the display width and height.', 'Panel Orientation') }}</label>
<select id="orientation" name="orientation" class="form-control"> <select id="orientation" name="orientation" class="form-control">
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option> <option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
<option value="90" {% if main_config.display.hardware.get('orientation', 'normal') == "90" %}selected{% endif %}>Rotated 90°</option>
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option> <option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
<option value="270" {% if main_config.display.hardware.get('orientation', 'normal') == "270" %}selected{% endif %}>Rotated 270°</option>
</select> </select>
</div> </div>
@@ -218,7 +237,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown"> <div class="form-group" id="setting-display-gpio_slowdown" data-setting-key="display.runtime.gpio_slowdown">
<label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows GPIO writes so the panel electronics keep up (0–10); higher is more reliable but lowers the refresh rate.\nStarting points: Pi Zero / Pi 1 → 0–1, Pi 2 / Pi 3 → 1–3, Pi 4 → 2–4, Pi 5 (PIO) → 1–3. Panels on Row Address Type 5 (SM5368 row drivers) can need 6–8 on a Pi 4. Raise it if the display shows garbage, jumping rows or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label> <label for="gpio_slowdown" class="block text-sm font-medium text-gray-700">GPIO Slowdown{{ ui.help_tip('Slows GPIO writes so the panel electronics keep up (0–10); higher is more reliable but lowers the refresh rate.\nStarting points: Pi Zero / Pi 1 → 0–1, Pi 2 / Pi 3 → 1–3, Pi 4 → 2–4, Pi 5 (PIO) → 1–3, starting at 1 (0 acts as 1 there). Panels on Row Address Type 5 (SM5368 row drivers) can need 6–8 on a Pi 4. Raise it if the display shows garbage, jumping rows or flicker; in RIO mode higher values may improve performance.', 'GPIO Slowdown') }}</label>
<input type="number" <input type="number"
id="gpio_slowdown" id="gpio_slowdown"
name="gpio_slowdown" name="gpio_slowdown"
@@ -374,7 +393,9 @@
<script> <script>
// Live "Your display: W x H" readout - width = cols x chain_length, // 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 () { (function () {
const ids = ['rows', 'cols', 'chain_length', 'parallel']; const ids = ['rows', 'cols', 'chain_length', 'parallel'];
const out = document.getElementById('display-resolution-value'); const out = document.getElementById('display-resolution-value');
@@ -389,12 +410,19 @@
out.textContent = '—'; out.textContent = '—';
return; 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) { for (const id of ids) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.addEventListener('input', recompute); if (el) el.addEventListener('input', recompute);
} }
const orientationSelect = document.getElementById('orientation');
if (orientationSelect) orientationSelect.addEventListener('change', recompute);
recompute(); recompute();
})(); })();
</script> </script>