diff --git a/CHANGELOG.md b/CHANGELOG.md index 209838e9..37e9cc15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,20 @@ Web interface: JSON API saves are unaffected. Lets plugins keep deprecated or internal keys declared, e.g. countdown's row `id` and weather's `api_key` / `radar_zoom`. See `docs/widget-guide.md`. +- Display settings accept every panel size and timing value the rgbmatrix + library does. Saving no longer silently cuts columns to 128, chain length to + 24 or PWM LSB nanoseconds to 500; rows are held to the library's even 8–64, + parallel to 1–3 and PWM dither bits to 0–2. A stored GPIO slowdown or PWM + dither bits of 0 no longer shows (and re-saves) as 3 or 1. The config API + rejects out-of-range `rows`, `cols`, `chain_length`, `parallel`, + `brightness`, `scan_mode`, `pwm_bits`, `pwm_dither_bits`, + `pwm_lsb_nanoseconds` and `gpio_slowdown` with a 400 instead of saving a + config the matrix refuses to start with. +- Row Address Type offers 5, the SM5368 / B707 row shift register. The + Waveshare 96x48 V2 panel (back silkscreen `24S-A1`) needs it with RGB + sequence BGR and, on a Pi 4, a GPIO slowdown of 6–8. Panels with FM6124 + column drivers need no Panel Type. On a Pi 5 the library supports only row + address types 0 and 2. ## 3.4.0 diff --git a/README.md b/README.md index 3b90d871..c6171490 100644 --- a/README.md +++ b/README.md @@ -494,15 +494,18 @@ 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 from 8 to 64 (the rgbmatrix library's limit) - Must match your physical panel configuration - **`cols`** (integer, default: 64) - Number of LED columns (horizontal pixels) in each panel - Common values: 32, 64, 96, 128 + - At least 16, with no upper limit - Must match your physical panel configuration - **`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 - 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` @@ -511,13 +514,14 @@ 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 - Total display height = `rows × parallel` #### Brightness and Visual Settings -- **`brightness`** (integer, 0-100, default: 90) +- **`brightness`** (integer, 1-100, default: 90) - Display brightness level - - Lower values (0-50) are dimmer, higher values (50-100) are brighter + - Lower values (1-50) are dimmer, higher values (50-100) are brighter - Recommended: 70-90 for indoor use, 90-100 for bright environments - Very high brightness may cause distortion or require more power @@ -612,7 +616,18 @@ These settings are typically only needed for non-standard panels or custom confi - **`row_address_type`** (integer, default: 0) - How rows are addressed on the panel - Most panels use 0 (direct addressing) - - Some panels require 1 (AB addressing) or 2 (ABC addressing) + - 1 = AB-addressed, 2 = direct row select, 3 = ABC-addressed, + 4 = ABC shift + DE direct (SM5266), 5 = SM5368 / B707 row shift register + - ABC panels (no E line, e.g. many 128x64 FM6124 panels) use 3 + - Panels with SM5368 row drivers use 5 with `led_rgb_sequence` `"BGR"` — + e.g. the Waveshare 96x48 V2 (back silkscreen `24S-A1`; the V1, `24S-A2.1`, + uses the defaults). This is what Waveshare's `96X48_1_24_SM5368` panel + type sets in their library fork. + - SM5368 row drivers are timing-sensitive: if rows jump up and down or the + bottom row shows a copy of other rows, raise `gpio_slowdown`. On a Pi 4 + with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a + stable image. + - On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2 - Check your panel datasheet if display appears corrupted - **`multiplexing`** (integer, default: 0) @@ -632,6 +647,8 @@ These settings control runtime behavior and GPIO timing: - **Raspberry Pi 4**: Use 4 - **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering - **Raspberry Pi Zero/1**: Use 1-2 + - Range: 0-10. Panels on `row_address_type` 5 (SM5368 row drivers) can need + 6-8 on a Pi 4 - Incorrect values can cause display corruption, flickering, or system instability - If you experience issues, try adjusting this value up or down by 1 diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 4af3c0fb..3f3c182f 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -51,15 +51,15 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295). | Key | Type / default | |---|---| -| `rows` / `cols` | int, `32` / `64` | -| `chain_length` | int, `2` | -| `parallel` | int, `1` | -| `brightness` | int, `90` | +| `rows` / `cols` | int, `32` / `64` — rows: even, 8–64; cols: at least 16, no upper limit | +| `chain_length` | int, `2` — at least 1, no upper limit | +| `parallel` | int, `1` — 1–3 | +| `brightness` | int, `90` — 1–100 | | `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) | -| `scan_mode` | int, `0` | -| `pwm_bits` | int, `9` (code default 10) | -| `pwm_dither_bits` | int, `1` | -| `pwm_lsb_nanoseconds` | int, `130` (code default 150) | +| `scan_mode` | int, `0` — `0` progressive, `1` interlaced | +| `pwm_bits` | int, `9` (code default 10) — 1–11 | +| `pwm_dither_bits` | int, `1` — 0–2 | +| `pwm_lsb_nanoseconds` | int, `130` (code default 150) — 50–3000 | | `disable_hardware_pulsing` | bool, `false` | | `inverse_colors` | bool, `false` | | `show_refresh_rate` | bool, `false` | @@ -67,9 +67,9 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295). | `limit_refresh_rate_hz` | int, `100` (code default 90) | | `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` | | `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value | -| `row_address_type` | int, `0` — non-standard panel row addressing | +| `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` | | `multiplexing` | int, `0` — panel multiplexing scheme | -| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init | +| `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. @@ -78,7 +78,7 @@ applies if the key is missing entirely from your config. | Key | Type / default | Meaning | |---|---|---| -| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis | +| `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 | | `rp1_rio` | int, `0` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) | ## `display.double_sided` diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d0102422..a7dceae0 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -83,10 +83,10 @@ You should see: 1. Open the **Display** tab 2. Set your matrix configuration: - - **Rows**: 32 or 64 (match your hardware) - - **Columns**: commonly 64 or 96; the web UI accepts any integer - in the 1–128 range, but 64 and 96 are the values the bundled - panel hardware ships with + - **Rows**: match your panel — commonly 32 or 64; any even number + from 8 to 64 + - **Columns**: match your panel — commonly 64 or 96; at least 16, + with no upper limit - **Chain Length**: Number of panels chained horizontally - **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper mod) or `adafruit-hat` (without). See the root README for the full list. diff --git a/test/test_api_v3_display_hardware.py b/test/test_api_v3_display_hardware.py new file mode 100644 index 00000000..67b6636a --- /dev/null +++ b/test/test_api_v3_display_hardware.py @@ -0,0 +1,214 @@ +"""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). 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 + +#: 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'} + + +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), + ('cols', 16), ('cols', 192), ('cols', 512), + ('chain_length', 1), ('chain_length', 32), + ('parallel', 3), + ('row_address_type', 0), ('row_address_type', 5), + ('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), +]) +def test_values_the_library_accepts_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', 66), ('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), + ('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), +]) +def test_values_the_library_rejects_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'