"""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: - The Display form capped cols at 128, chain_length at 24 and pwm_lsb_nanoseconds at 500, and its submit handler (fixInvalidNumberInputs) rewrites anything past an input's min/max to that bound -- so a wide panel or 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. 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 just sets rows/cols, row_address_type=5 and BGR); the API used to stop at 4. """ import copy import json import re import sys from pathlib import Path from unittest.mock import MagicMock import pytest from flask import Flask PROJECT_ROOT = Path(__file__).parent.parent 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. WAVESHARE_96X48_V2 = { 'rows': 48, 'cols': 96, 'chain_length': 1, 'parallel': 1, 'hardware_mapping': 'regular', 'panel_type': '', 'row_address_type': 5, 'led_rgb_sequence': 'BGR', 'gpio_slowdown': 8, } #: 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' return config['display'][section][field] def _post(client, body): return client.post('/api/v3/config/main', data=json.dumps(body), content_type='application/json') @pytest.fixture def saved(api_v3_module, monkeypatch): """Capture what save_main_config would write. Asserting on the stored value, not just the status code, is what shows the value passed validation and landed where DisplayManager reads it. """ captured = {} api_v3_module.api_v3.config_manager.load_config.return_value = {} def fake_save(_manager, config, **_kwargs): captured['config'] = config return True, '' monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save) return captured @pytest.mark.parametrize('as_strings', [False, True], ids=['json-numbers', 'form-strings']) def test_waveshare_96x48_v2_settings_all_save(api_v3_client, saved, as_strings): """The Display form posts every value as a string (json-enc); API clients send numbers.""" body = {k: str(v) if as_strings else v for k, v in WAVESHARE_96X48_V2.items()} response = _post(api_v3_client, body) assert response.status_code == 200, response.get_data(as_text=True)[:200] for field, value in WAVESHARE_96X48_V2.items(): assert _stored(saved['config'], field) == value, field @pytest.mark.parametrize('field,value', [ ('rows', 8), ('rows', 64), ('rows', 96), ('rows', 128), ('cols', 16), ('cols', 192), ('cols', 512), ('chain_length', 1), ('chain_length', 32), ('parallel', 3), ('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0), ('multiplexing', 0), ('multiplexing', 22), ('gpio_slowdown', 0), ('gpio_slowdown', 10), ('pwm_bits', 1), ('pwm_bits', 11), ('pwm_dither_bits', 0), ('pwm_dither_bits', 2), ('pwm_lsb_nanoseconds', 50), ('pwm_lsb_nanoseconds', 3000), ('scan_mode', 1), ('brightness', 1), ('brightness', 100), ('limit_refresh_rate_hz', 0), ('limit_refresh_rate_hz', 1000), ]) def test_values_in_range_are_saved(api_v3_client, saved, field, value): response = _post(api_v3_client, {field: value}) assert response.status_code == 200, response.get_data(as_text=True)[:200] assert _stored(saved['config'], field) == value @pytest.mark.parametrize('field,value', [ ('rows', 6), ('rows', 47), ('rows', 97), ('rows', '48.5'), ('cols', 15), ('cols', 96.5), ('cols', True), ('cols', 'wide'), ('chain_length', 0), ('parallel', 0), ('parallel', 4), ('row_address_type', -1), ('row_address_type', 6), ('row_address_type', True), ('row_address_type', 5.5), ('multiplexing', -1), ('multiplexing', 23), ('multiplexing', True), ('gpio_slowdown', -1), ('gpio_slowdown', 11), ('pwm_bits', 0), ('pwm_bits', 12), ('pwm_dither_bits', 3), ('pwm_lsb_nanoseconds', 49), ('pwm_lsb_nanoseconds', 3001), ('scan_mode', 2), ('brightness', 0), ('brightness', 101), ('limit_refresh_rate_hz', -1), ]) def test_values_out_of_range_are_refused(api_v3_client, saved, field, value): """Refused with a message naming the field, and nothing written.""" response = _post(api_v3_client, {field: value}) assert response.status_code == 400 assert field in response.get_json()['message'] assert 'config' not in saved @pytest.fixture def display_page(monkeypatch): """Render the Display settings partial for a given config.""" from web_interface.blueprints import pages_v3 as pv def render(config): base = PROJECT_ROOT / 'web_interface' app = Flask(__name__, template_folder=str(base / 'templates'), static_folder=str(base / 'static')) app.config['TESTING'] = True config_manager = MagicMock() config_manager.load_config.return_value = config config_manager.get_raw_file_content.return_value = config config_manager.get_config_path.return_value = 'config/config.json' config_manager.get_secrets_path.return_value = 'config/config_secrets.json' monkeypatch.setattr(pv.pages_v3, 'config_manager', config_manager, raising=False) monkeypatch.setattr(pv.pages_v3, 'plugin_manager', MagicMock(plugins={}), raising=False) app.register_blueprint(pv.pages_v3, url_prefix='/v3') response = app.test_client().get('/v3/partials/display') assert response.status_code == 200 return response.get_data(as_text=True) return render def _config_with(hardware=None, runtime=None): config = copy.deepcopy(REALISTIC_CONFIG) config['display']['hardware'].update(hardware or {}) config['display']['runtime'].update(runtime or {}) return config def _input_tag(body, input_id): match = re.search(r']*\bid="%s"[^>]*>' % re.escape(input_id), body) assert match, f'no ' return match.group(0) def _attr(tag, name): match = re.search(r'\s%s="([^"]*)"' % name, tag) return match.group(1) if match else None def _selected_option(body, select_id): select = re.search(r'' return re.findall(r'