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'