mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
feat: refuse matrix settings a Raspberry Pi 5 can't drive
On a Pi 5 the pinned rgbmatrix library drives the panel through the RP1 chip, and that path supports only row address types 0 and 2, parallel 1-3 and the regular / regular-pi1 / classic / adafruit-hat(-pwm) mappings (Rp1PioConfigSupported in lib/rp1/rp1_pio_backend.cc). For anything else CreateFromOptions returns NULL; the Python binding doesn't check, so the display process crashed on its first call into the matrix and systemd restarted it into the same crash every 10 seconds. - src/pi5_matrix_support.py: the rule and Pi 5 detection, matching the library's /proc/device-tree/model check - DisplayManager raises before creating the matrix, so it is a logged init failure (reported by /api/v3/hardware/status) and fallback mode - the config API rejects those settings on a Pi 5 when a request sets row_address_type, parallel or hardware_mapping - the Display form offers only row address types 0 and 2 on a Pi 5, and warns when a stored value can't be used - CLAUDE.md: re-check the rule whenever the submodule is bumped Review feedback on #586. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
from test.test_web_settings_ui import REALISTIC_CONFIG # noqa: E402
|
||||
from src import pi5_matrix_support # noqa: E402
|
||||
|
||||
#: What a Waveshare RGB-Matrix-P2.5-96x48 V2 (back silkscreen 24S-A1) needed on
|
||||
#: a Pi 4 with an Adafruit Triple LED Matrix Bonnet, checked on the panel.
|
||||
@@ -43,6 +44,24 @@ WAVESHARE_96X48_V2 = {
|
||||
#: Fields stored under display.runtime; the rest go under display.hardware.
|
||||
RUNTIME_FIELDS = {'gpio_slowdown'}
|
||||
|
||||
PI5_MODEL = 'Raspberry Pi 5 Model B Rev 1.0'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def board(tmp_path, monkeypatch):
|
||||
"""Not a Pi 5 unless a test says so, whatever machine runs the suite.
|
||||
|
||||
Returns a setter: board(PI5_MODEL) makes the API and the form see a Pi 5.
|
||||
"""
|
||||
path = tmp_path / 'device-tree-model'
|
||||
|
||||
def set_model(model):
|
||||
path.write_bytes(model.encode() + b'\x00')
|
||||
|
||||
set_model('Raspberry Pi 4 Model B Rev 1.5')
|
||||
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
|
||||
return set_model
|
||||
|
||||
|
||||
def _stored(config, field):
|
||||
section = 'runtime' if field in RUNTIME_FIELDS else 'hardware'
|
||||
@@ -220,3 +239,70 @@ def test_a_stored_zero_renders_as_zero(display_page, field, section):
|
||||
"""`value or default` showed a stored 0 as the default, and the next save wrote it back."""
|
||||
body = display_page(_config_with(**{section: {field: 0}}))
|
||||
assert _attr(_input_tag(body, field), 'value') == '0'
|
||||
|
||||
|
||||
# --- Raspberry Pi 5 -------------------------------------------------------
|
||||
# The pinned library's Pi 5 path drives only row address types 0 and 2,
|
||||
# parallel 1-3 and the standard mappings; anything else crashes the display
|
||||
# service (src/pi5_matrix_support.py), so the API and the form refuse it.
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
{'row_address_type': 5}, {'row_address_type': '1'},
|
||||
{'hardware_mapping': 'compute-module'},
|
||||
])
|
||||
def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body):
|
||||
board(PI5_MODEL)
|
||||
response = _post(api_v3_client, body)
|
||||
assert response.status_code == 400
|
||||
assert 'Raspberry Pi 5' in response.get_json()['message']
|
||||
assert 'config' not in saved
|
||||
|
||||
|
||||
def test_pi5_saves_what_it_can_drive(api_v3_client, saved, board):
|
||||
board(PI5_MODEL)
|
||||
response = _post(api_v3_client, dict(WAVESHARE_96X48_V2, row_address_type=2))
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
assert saved['config']['display']['hardware']['row_address_type'] == 2
|
||||
|
||||
|
||||
def test_pi5_check_uses_the_stored_value_for_fields_not_sent(api_v3_client, api_v3_module, saved, board):
|
||||
"""Changing only the mapping is still checked against the stored row address type."""
|
||||
board(PI5_MODEL)
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
'display': {'hardware': {'row_address_type': 5}}}
|
||||
response = _post(api_v3_client, {'hardware_mapping': 'regular'})
|
||||
assert response.status_code == 400
|
||||
assert 'config' not in saved
|
||||
|
||||
|
||||
def test_pi5_stored_combination_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved, board):
|
||||
board(PI5_MODEL)
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
'display': {'hardware': {'row_address_type': 5}}}
|
||||
response = _post(api_v3_client, {'brightness': 70})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:200]
|
||||
|
||||
|
||||
def _option_values(body, select_id):
|
||||
select = re.search(r'<select id="%s".*?</select>' % select_id, body, re.S)
|
||||
assert select, f'no <select id="{select_id}">'
|
||||
return re.findall(r'<option value="([^"]*)"', select.group(0))
|
||||
|
||||
|
||||
def test_pi5_form_offers_only_supported_row_address_types(display_page, board):
|
||||
board(PI5_MODEL)
|
||||
body = display_page(_config_with())
|
||||
assert _option_values(body, 'row_address_type') == ['0', '2']
|
||||
assert "can't be used on this Raspberry Pi 5" not 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']
|
||||
|
||||
|
||||
def test_pi5_form_warns_about_a_stored_unsupported_row_address_type(display_page, board):
|
||||
"""The unsupported option isn't offered, so the browser posts 0 -- say so."""
|
||||
board(PI5_MODEL)
|
||||
body = display_page(_config_with(hardware={'row_address_type': 5}))
|
||||
assert "Your saved row address type (5) can't be used on this Raspberry Pi 5" in body
|
||||
|
||||
@@ -279,3 +279,58 @@ class TestDisplayManagerOrientation:
|
||||
suppress_test_pattern=True)
|
||||
options = mock_rgb_matrix['options_class'].return_value
|
||||
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."""
|
||||
|
||||
def _config(self, **hardware_overrides):
|
||||
config = {
|
||||
'display': {
|
||||
'hardware': {
|
||||
'rows': 48, 'cols': 96, 'chain_length': 1, 'parallel': 1,
|
||||
'hardware_mapping': 'regular', 'brightness': 90,
|
||||
},
|
||||
'runtime': {'gpio_slowdown': 2},
|
||||
},
|
||||
'timezone': 'UTC',
|
||||
'plugin_system': {'plugins_directory': 'plugins'},
|
||||
}
|
||||
config['display']['hardware'].update(hardware_overrides)
|
||||
return config
|
||||
|
||||
@pytest.fixture
|
||||
def board(self, tmp_path, monkeypatch):
|
||||
from src import pi5_matrix_support
|
||||
|
||||
def set_model(model):
|
||||
path = tmp_path / 'model'
|
||||
path.write_bytes(model.encode() + b'\x00')
|
||||
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
|
||||
return set_model
|
||||
|
||||
def test_unsupported_setting_on_pi5_never_creates_the_matrix(self, mock_rgb_matrix, board):
|
||||
board('Raspberry Pi 5 Model B Rev 1.0')
|
||||
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_not_called()
|
||||
assert dm.matrix is None
|
||||
|
||||
def test_supported_setting_on_pi5_creates_the_matrix(self, mock_rgb_matrix, board):
|
||||
board('Raspberry Pi 5 Model B Rev 1.0')
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(self._config(row_address_type=2), suppress_test_pattern=True)
|
||||
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):
|
||||
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
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""The Pi 5 rule in src/pi5_matrix_support.py mirrors the pinned library.
|
||||
|
||||
Rp1PioPlatformDetected() and Rp1PioConfigSupported() in
|
||||
rpi-rgb-led-matrix-master/lib/rp1/rp1_pio_backend.cc (commit 1ee4f76). When the
|
||||
submodule is bumped and those change, these are the cases to revisit.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src import pi5_matrix_support as pi5 # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_file(tmp_path, monkeypatch):
|
||||
def write(model):
|
||||
path = tmp_path / 'model'
|
||||
# The device tree string is NUL-terminated.
|
||||
path.write_bytes(model.encode() + b'\x00')
|
||||
monkeypatch.setattr(pi5, 'MODEL_PATH', str(path))
|
||||
return write
|
||||
|
||||
|
||||
@pytest.mark.parametrize('model', [
|
||||
'Raspberry Pi 5 Model B Rev 1.0',
|
||||
'Raspberry Pi 500 Rev 1.0',
|
||||
'Raspberry Pi Compute Module 5 Rev 1.0',
|
||||
])
|
||||
def test_pi5_family_is_detected(model_file, model):
|
||||
model_file(model)
|
||||
assert pi5.is_raspberry_pi_5()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('model', [
|
||||
'Raspberry Pi 4 Model B Rev 1.5',
|
||||
'Raspberry Pi Zero 2 W Rev 1.0',
|
||||
'Raspberry Pi Compute Module 4 Rev 1.0',
|
||||
])
|
||||
def test_other_boards_are_not(model_file, model):
|
||||
model_file(model)
|
||||
assert not pi5.is_raspberry_pi_5()
|
||||
|
||||
|
||||
def test_no_device_tree_is_not_a_pi5(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(pi5, 'MODEL_PATH', str(tmp_path / 'missing'))
|
||||
assert not pi5.is_raspberry_pi_5()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hardware', [
|
||||
{},
|
||||
{'row_address_type': 0}, {'row_address_type': 2}, {'row_address_type': '2'},
|
||||
{'parallel': 1}, {'parallel': 3},
|
||||
{'hardware_mapping': 'regular'}, {'hardware_mapping': 'regular-pi1'},
|
||||
{'hardware_mapping': 'classic'}, {'hardware_mapping': 'adafruit-hat'},
|
||||
{'hardware_mapping': 'adafruit-hat-pwm'}, {'hardware_mapping': ''},
|
||||
])
|
||||
def test_what_the_pi5_path_supports(hardware):
|
||||
assert pi5.pi5_unsupported_settings(hardware) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hardware,named', [
|
||||
({'row_address_type': 1}, 'row address type 1'),
|
||||
({'row_address_type': 3}, 'row address type 3'),
|
||||
({'row_address_type': 4}, 'row address type 4'),
|
||||
({'row_address_type': '5'}, 'row address type 5'),
|
||||
({'parallel': 4}, 'parallel 4'),
|
||||
({'hardware_mapping': 'compute-module'}, 'hardware mapping "compute-module"'),
|
||||
])
|
||||
def test_what_it_does_not(hardware, named):
|
||||
message = pi5.pi5_unsupported_settings(hardware)
|
||||
assert message is not None and named in message
|
||||
|
||||
|
||||
def test_every_problem_is_named():
|
||||
message = pi5.pi5_unsupported_settings({'row_address_type': 5, 'parallel': 4})
|
||||
assert 'row address type 5' in message and 'parallel 4' in message
|
||||
Reference in New Issue
Block a user