mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
Merge audit16-core/hardware into the September 16 core audit fixes
# Conflicts: # CHANGELOG.md
This commit is contained in:
+34
-3
@@ -68,9 +68,9 @@ Web interface:
|
||||
declared, e.g. countdown's row `id` and weather's `api_key` / `radar_zoom`.
|
||||
See `docs/widget-guide.md`.
|
||||
- Display settings no longer silently cut values on save: columns were capped
|
||||
at 128, chain length at 24 and PWM LSB nanoseconds at 500. Rows, columns and
|
||||
chain length now have no upper limit (the current rgbmatrix library still
|
||||
rejects more than 64 rows per panel); rows must be even and at least 8,
|
||||
at 128, chain length at 24 and PWM LSB nanoseconds at 500. Columns have no
|
||||
upper limit, chain length is 1–255 and rows must be even and 8–64 (see
|
||||
"Display hardware settings the library refuses" below);
|
||||
parallel is 1–3 and PWM dither bits 0–2, matching the library. A stored GPIO
|
||||
slowdown, PWM dither bits or refresh-rate cap of 0 no longer shows (and
|
||||
re-saves) as 3, 1 or 120, and the refresh cap accepts 0 (no cap). The config
|
||||
@@ -132,6 +132,37 @@ Security (request paths and inline handlers, siblings of #561):
|
||||
- The uploaded-images list escapes each file's original name, path and ids; a
|
||||
name like `<img src=x onerror=...>.png` was inserted as markup.
|
||||
|
||||
Display hardware settings the library refuses:
|
||||
|
||||
- The rgbmatrix library answers several settings with no matrix or `abort()`
|
||||
rather than an error, on every board, so the display service crash-looped
|
||||
instead of falling back: rows above 64, `chain_length` above 255 (the Python
|
||||
binding stores it in one byte; this was documented as "no upper limit"), a
|
||||
misspelled `hardware_mapping`, and `parallel` 2–3 on a mapping with one output
|
||||
(`adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1`, `classic-pi1`) — the last
|
||||
one reachable from the Display form on the default mapping. The config API
|
||||
now refuses them with a 400 naming the setting, and `DisplayManager` refuses
|
||||
a hand-edited one before creating the matrix: logged, fallback mode, reported
|
||||
by `/api/v3/hardware/status`. The rules, including the Pi 5 ones, live in
|
||||
`src/matrix_support.py` and must be re-checked when the submodule is bumped.
|
||||
- `/api/v3/hardware/status` adds `cause`: `"settings"` when LEDMatrix refused
|
||||
the config, `"library"` when the library failed. The Display tab banner and
|
||||
the fallback log line give the Pi 5 rebuild hint only for a library failure;
|
||||
they used to follow every failure with it and with GPIO slowdown advice.
|
||||
- The Display form offers the `classic` and `classic-pi1` mappings and the
|
||||
`90` / `270` orientations, and renders any other stored mapping selected with
|
||||
a warning. With no option selected the browser posted the first one, so one
|
||||
unrelated save rewrote those settings. The API accepts orientation `90` and
|
||||
`270`, which `DisplayManager` already applied.
|
||||
- The display size the web preview, Starlark magnify default and
|
||||
`scripts/dev/vegas_audit.py` compute (`src/display_geometry.py`) now applies
|
||||
`orientation` and `pixel_mapper_config` as the library does: `Rotate:90`
|
||||
swaps width and height, `U-mapper` folds the chain.
|
||||
- One Raspberry Pi 5 GPIO slowdown recommendation everywhere: 1–3 in PIO mode,
|
||||
starting at 1. README and the config reference now describe the template
|
||||
values as the defaults; the "code default" values they listed never apply,
|
||||
because config migration fills missing keys from the template.
|
||||
|
||||
Plugin system:
|
||||
|
||||
- A plugin no longer starts with a schema warning and a degraded flag because
|
||||
|
||||
@@ -148,7 +148,7 @@ The system supports live, recent, and upcoming game information for multiple spo
|
||||
```bash
|
||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
||||
```
|
||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and start `gpio_slowdown` at `1`, raising it a step at a time if the image flickers or shows garbage (see `gpio_slowdown` under Display Settings).
|
||||
- **1GB models (Pi 3B / 3B+) and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`.
|
||||
|
||||
|
||||
@@ -485,6 +485,10 @@ If you are copying my exact setup, you can likely leave the defaults alone. Howe
|
||||
|
||||
The display settings are located in `config/config.json` under the `"display"` key and are organized into three main sections: `hardware`, `runtime`, and `display_durations`.
|
||||
|
||||
The defaults below are the values in `config/config.template.json`. They are what applies when you haven't set a key: on every load, LEDMatrix adds any key your `config.json` lacks from the template, so `DisplayManager`'s own fallbacks are never reached on a normal install.
|
||||
|
||||
The web UI and the config API refuse values the rgbmatrix library can't start with. If one is written into `config.json` by hand anyway, the display logs which setting it is (`Failed to initialize RGB Matrix` in `sudo journalctl -u ledmatrix`), runs in fallback mode, and the Display tab shows the message.
|
||||
|
||||
### Hardware Configuration (`display.hardware`)
|
||||
|
||||
These settings control the physical hardware configuration and how the matrix is driven.
|
||||
@@ -494,9 +498,7 @@ 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, at least 8. LEDMatrix sets no upper limit, but the
|
||||
current rgbmatrix library rejects more than 64 rows per panel — the display
|
||||
then won't start (see Troubleshooting Display Settings below)
|
||||
- An even number from 8 to 64, the most the rgbmatrix library drives per panel
|
||||
- Must match your physical panel configuration
|
||||
|
||||
- **`cols`** (integer, default: 64)
|
||||
@@ -507,7 +509,7 @@ These settings control the physical hardware configuration and how the matrix is
|
||||
|
||||
- **`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
|
||||
- 1 to 255 (the library's Python binding stores it in one byte); 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`
|
||||
@@ -516,7 +518,7 @@ 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
|
||||
- 1–3, and no more than your `hardware_mapping` has outputs: `regular` and `classic` have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); `adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1` and `classic-pi1` have 1. The library stops the display service outright on a mismatch, so it is refused
|
||||
- Total display height = `rows × parallel`
|
||||
|
||||
#### Brightness and Visual Settings
|
||||
@@ -529,12 +531,14 @@ These settings control the physical hardware configuration and how the matrix is
|
||||
|
||||
#### Hardware Mapping
|
||||
|
||||
- **`hardware_mapping`** (string, default: "adafruit-hat-pwm")
|
||||
- **`hardware_mapping`** (string, default: "adafruit-hat")
|
||||
- Specifies which GPIO pin mapping to use for your hardware
|
||||
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
|
||||
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
|
||||
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
|
||||
- **`"classic"`** / **`"classic-pi1"`**: the library's original pin-outs, for old adapter boards wired to them. Not used by current HATs
|
||||
- Any other name is refused. `compute-module` is only compiled in when the library is built with `ENABLE_WIDE_GPIO_COMPUTE_MODULE`, which the installer doesn't do. On a Raspberry Pi 5, `classic-pi1` isn't supported
|
||||
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
|
||||
- Hardware pulsing (see `disable_hardware_pulsing`) needs the panel's OE line on GPIO 18, which `adafruit-hat-pwm` and `regular` provide and `adafruit-hat` does not
|
||||
|
||||
@@ -571,7 +575,6 @@ These settings affect color fidelity and smoothness of color transitions:
|
||||
- Caps the panel refresh rate in Hz; `0` = no cap
|
||||
- A steady cap reduces flicker caused by other activity on the Pi, and in camera recordings
|
||||
- Scroll speeds are worked out against this value (against 100 Hz when it is `0`), so a cap the panel can actually hold keeps scrolling even
|
||||
- If the key is missing from the config, `DisplayManager` uses 90
|
||||
- Recommended: 80-120. `sudo python3 scripts/scroll_speeds.py --measure` reports the rate your panel really achieves
|
||||
|
||||
- **`disable_hardware_pulsing`** (boolean, default: false)
|
||||
@@ -613,8 +616,10 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
|
||||
settings) if the panel is mounted upside down — useful for optimizing
|
||||
where the Raspberry Pi and wiring sit relative to the mounting location
|
||||
- `"90"` and `"270"` are for a panel mounted on its side; they swap the
|
||||
display's width and height
|
||||
- Applied independently of `pixel_mapper_config` (appended as a trailing
|
||||
`Rotate:180` mapper), so custom mapper configs keep working alongside it
|
||||
`Rotate:<degrees>` mapper), so custom mapper configs keep working alongside it
|
||||
|
||||
- **`row_address_type`** (integer, default: 0)
|
||||
- How rows are addressed on the panel
|
||||
@@ -663,7 +668,7 @@ These settings control runtime behavior and GPIO timing:
|
||||
- **Raspberry Pi Zero/1**: 0-1
|
||||
- **Raspberry Pi 2/3**: 1-3
|
||||
- **Raspberry Pi 4**: 2-4 (the config template ships 3)
|
||||
- **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default). Start at `1` (the library treats `0` as `1` there) and raise it a step at a time if the image flickers or shows garbage — chained panels are the likeliest to need it
|
||||
- Panels on `row_address_type` 5 (SM5368 row drivers) can need 6-8 on a Pi 4
|
||||
- Too low: garbage, flicker or rows jumping. Too high: a lower refresh rate
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
@@ -752,7 +757,7 @@ Controls how long each installed plugin stays visible in seconds before switchin
|
||||
- Verify `hardware_mapping` matches your HAT/connection type
|
||||
- Try adjusting `gpio_slowdown`
|
||||
- Ensure your display doesn't need the E-Addressable line
|
||||
- If it went blank right after a settings change, check `sudo journalctl -u ledmatrix` for `Failed to initialize RGB Matrix`: the rgbmatrix library refused a value (for example more than 64 `rows`, or `pwm_dither_bits` above 2) and the display fell back to no output. The library's own message nearby names the setting; on a Raspberry Pi 5, LEDMatrix's message names any unsupported `row_address_type`, `parallel` or `hardware_mapping`
|
||||
- If it went blank right after a settings change, the Display tab shows a "simulation mode" banner, and `sudo journalctl -u ledmatrix` shows `Failed to initialize RGB Matrix` followed by the reason. When LEDMatrix refused the settings (for example more than 64 `rows`, `parallel` 2 on an `adafruit-hat` mapping, a misspelled `hardware_mapping`, or on a Raspberry Pi 5 a `row_address_type` other than 0 or 2), the message names each one: change them, save, and restart the display service. Otherwise the library itself failed, and its own message just before names the problem
|
||||
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
|
||||
|
||||
**Rows jump up and down, or the bottom row repeats other rows:**
|
||||
|
||||
+19
-14
@@ -47,38 +47,43 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
||||
## `display.hardware` — matrix panel hardware
|
||||
|
||||
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
||||
in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
||||
in `DisplayManager._setup_matrix` (`src/display_manager.py`). Defaults are the
|
||||
`config/config.template.json` values: `ConfigManager` adds any key missing from
|
||||
`config.json` from the template on load, so `DisplayManager`'s own fallbacks
|
||||
don't apply on a normal install.
|
||||
|
||||
The ranges are what the pinned rgbmatrix library and its Python binding accept
|
||||
(`src/matrix_support.py`). The config API refuses anything else; a value
|
||||
hand-edited into `config.json` makes the display log the setting and run in
|
||||
fallback mode instead of starting the matrix.
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `rows` / `cols` | int, `32` / `64` — rows: even, at least 8, no upper limit here (the current rgbmatrix library rejects more than 64); cols: at least 16, no upper limit |
|
||||
| `chain_length` | int, `2` — at least 1, no upper limit |
|
||||
| `parallel` | int, `1` — 1–3 |
|
||||
| `rows` / `cols` | int, `32` / `64` — rows: even, 8–64; cols: at least 16 |
|
||||
| `chain_length` | int, `2` — 1–255 (the Python binding stores it in one byte) |
|
||||
| `parallel` | int, `1` — 1–3, and no more than `hardware_mapping` has outputs (`regular`, `classic`: 3; the others: 1) |
|
||||
| `brightness` | int, `90` — 1–100 |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` — `"adafruit-hat-pwm"`, `"adafruit-hat"`, `"regular"`, `"regular-pi1"`, `"classic"` or `"classic-pi1"` (case-insensitive; `compute-module` isn't in the installed build). A Pi 5 doesn't support `"classic-pi1"` |
|
||||
| `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
|
||||
| `pwm_bits` | int, `9` (code default 10) — 1–11 |
|
||||
| `pwm_bits` | int, `9` — 1–11 |
|
||||
| `pwm_dither_bits` | int, `1` — 0–2 |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) — 50–3000 |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` — 50–3000 |
|
||||
| `disable_hardware_pulsing` | bool, `false` — `true` times brightness pulses in software (less exact); hardware pulsing needs the OE line on GPIO 18 and the Pi's onboard sound driver off |
|
||||
| `inverse_colors` | bool, `false` |
|
||||
| `show_refresh_rate` | bool, `false` — prints the refresh rate to stdout; draws nothing on the panel |
|
||||
| `led_rgb_sequence` | string, `"RGB"` — `"RGB"`, `"RBG"`, `"GRB"`, `"GBR"`, `"BRG"` or `"BGR"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
||||
| `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 |
|
||||
| `limit_refresh_rate_hz` | int, `100` — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"`; mappers that rotate or fold the chain change the display size plugins and the web preview see |
|
||||
| `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); `"90"` / `"270"` for a panel on its side, swapping width and height; composed onto `pixel_mapper_config` as a trailing `Rotate:<degrees>` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `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`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
|
||||
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
|
||||
| `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.
|
||||
|
||||
## `display.runtime`
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). On a Pi 5 in PIO mode start at `1` (`0` acts as `1`) and raise it if the image flickers or shows garbage. 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` | Pi 5 only: `0` = PIO (less CPU), `1` = RIO (higher refresh; `gpio_slowdown` effect inverted). Applied only if the installed matrix library supports it |
|
||||
|
||||
## `display.double_sided`
|
||||
|
||||
+142
-8
@@ -1,17 +1,25 @@
|
||||
"""Display size from config: the one computation every caller shares.
|
||||
"""Display size from config: the one computation the size readers share.
|
||||
|
||||
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
|
||||
``display.double_sided``. The web preview, the Starlark magnify default and
|
||||
the multi-display sync handshake used to re-derive that size themselves,
|
||||
each with its own defaults (``chain_length`` fell back to 2 in one place and
|
||||
1 in three others) and none of them applying double-sided mode. They now all
|
||||
call this module.
|
||||
``display.double_sided``. The web preview endpoints (``/display/current``, the
|
||||
SSE fallback), the Starlark magnify default and ``scripts/dev/vegas_audit.py``
|
||||
used to re-derive that size themselves, each with its own defaults
|
||||
(``chain_length`` fell back to 2 in one place and 1 in others) and none of
|
||||
them applying double-sided mode. They now call this module. (The multi-display
|
||||
sync handshake doesn't compute a size; it shares only
|
||||
``DEFAULT_CHAIN_LENGTH``.)
|
||||
|
||||
The size includes what the rgbmatrix library's pixel mappers do to it --
|
||||
``orientation`` and ``pixel_mapper_config`` (``Rotate:90`` swaps the axes,
|
||||
``U-mapper`` folds the chain) -- because ``RGBMatrix.width``/``height``, which
|
||||
``DisplayManager`` reports on hardware, are measured after them.
|
||||
|
||||
Kept free of hardware imports on purpose: the web interface imports it, and
|
||||
``display_manager`` pulls in ``rgbmatrix``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,6 +30,10 @@ DEFAULT_COLS = 64
|
||||
DEFAULT_CHAIN_LENGTH = 2
|
||||
DEFAULT_PARALLEL = 1
|
||||
|
||||
#: ``display.hardware.orientation`` -> degrees of the ``Rotate`` mapper
|
||||
#: DisplayManager appends to ``pixel_mapper_config`` (None: no mapper).
|
||||
ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
|
||||
|
||||
|
||||
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||
# A hand-edited config.json can hold anything here; treat a non-mapping
|
||||
@@ -35,10 +47,128 @@ def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
||||
return hw if isinstance(hw, Mapping) else {}
|
||||
|
||||
|
||||
def compose_pixel_mapper_config(hardware: Mapping[str, Any]) -> str:
|
||||
"""The ``pixel_mapper_config`` DisplayManager hands the library.
|
||||
|
||||
``pixel_mapper_config`` stays a free-form advanced field (e.g. "U-mapper"
|
||||
for chain layouts); ``orientation`` is the user-facing mounting rotation,
|
||||
appended as a trailing ``Rotate:<deg>`` mapper rather than overwriting it.
|
||||
"""
|
||||
base = hardware.get('pixel_mapper_config') or ''
|
||||
base = base.strip() if isinstance(base, str) else ''
|
||||
degrees = ORIENTATION_ROTATE_DEGREES.get(hardware.get('orientation', 'normal'))
|
||||
if degrees is None:
|
||||
return base
|
||||
rotate = f'Rotate:{degrees}'
|
||||
return f'{base};{rotate}' if base else rotate
|
||||
|
||||
|
||||
def _c_div(a: int, b: int) -> int:
|
||||
"""C integer division (truncates toward zero)."""
|
||||
q = abs(a) // abs(b)
|
||||
return q if (a >= 0) == (b >= 0) else -q
|
||||
|
||||
|
||||
def _c_strtol(text: str) -> Tuple[int, str]:
|
||||
"""strtol(text, &end, 10): the parsed value (0 if none) and the rest."""
|
||||
match = re.match(r'\s*([+-]?\d+)', text)
|
||||
if not match:
|
||||
return 0, text
|
||||
return int(match.group(1)), text[match.end():]
|
||||
|
||||
|
||||
def _remap_size(param: Optional[str], width: int, height: int,
|
||||
chain: int, parallel: int) -> Optional[Tuple[int, int]]:
|
||||
"""RemapMapper::SetParameters and GetSizeMapping (lib/pixel-mapper.cc)."""
|
||||
if not param:
|
||||
return None
|
||||
new_w, rest = _c_strtol(param)
|
||||
if not rest.startswith(','):
|
||||
return None
|
||||
new_h, rest = _c_strtol(rest[1:])
|
||||
if not rest.startswith('|'):
|
||||
return None
|
||||
rest = rest[1:]
|
||||
tiles = []
|
||||
while rest:
|
||||
x, rest = _c_strtol(rest)
|
||||
if not rest.startswith(','):
|
||||
return None
|
||||
y, rest = _c_strtol(rest[1:])
|
||||
if not rest or rest[0].lower() not in 'neswx':
|
||||
return None
|
||||
tiles.append((x, y, rest[0].lower()))
|
||||
rest = rest[1:]
|
||||
if rest.startswith('|'):
|
||||
rest = rest[1:]
|
||||
elif rest:
|
||||
return None
|
||||
if len(tiles) != chain * parallel:
|
||||
return None
|
||||
panel_w, panel_h = _c_div(width, chain), _c_div(height, parallel)
|
||||
for x, y, kind in tiles:
|
||||
if kind == 'x':
|
||||
continue
|
||||
# MapTile::MapToVisible of the panel's (0, 0) and far corner.
|
||||
x0, y0, x1, y1 = {
|
||||
'n': (x, y, x + panel_w - 1, y + panel_h - 1),
|
||||
'w': (x, y + panel_w - 1, x + panel_h - 1, y),
|
||||
's': (x + panel_w - 1, y + panel_h - 1, x, y),
|
||||
'e': (x + panel_h - 1, y, x, y + panel_w - 1),
|
||||
}[kind]
|
||||
if x1 < 0 or x0 >= new_w or y1 < 0 or y0 >= new_h:
|
||||
return None
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
def apply_pixel_mappers(width: int, height: int, mapper_config: str,
|
||||
chain: int, parallel: int) -> Tuple[int, int]:
|
||||
"""The canvas size after the library applies ``mapper_config``.
|
||||
|
||||
Mirrors ``RGBMatrix::Impl::ApplyNamedPixelMappers`` and each built-in
|
||||
mapper's ``SetParameters``/``GetSizeMapping`` in the pinned
|
||||
``lib/pixel-mapper.cc``: mappers apply left to right, and one the library
|
||||
doesn't know or can't configure is skipped and leaves the size alone.
|
||||
``multiplexing`` isn't modelled: its mappers give back the configured
|
||||
size for the panel sizes they are made for.
|
||||
"""
|
||||
for entry in (mapper_config or '').split(';'):
|
||||
name, colon, param = entry.partition(':')
|
||||
name = name.lower()
|
||||
param = param if colon else None
|
||||
if name == 'rotate':
|
||||
if not param:
|
||||
continue
|
||||
angle, rest = _c_strtol(param)
|
||||
if rest or angle % 90:
|
||||
continue
|
||||
if angle % 180:
|
||||
width, height = height, width
|
||||
elif name == 'u-mapper':
|
||||
if chain < 2 or chain % 2 or height % parallel:
|
||||
continue
|
||||
width, height = _c_div(width, 64) * 32, 2 * height
|
||||
elif name == 'v-mapper':
|
||||
width, height = (_c_div(width * parallel, chain),
|
||||
_c_div(height * chain, parallel))
|
||||
elif name == 'stacktorow':
|
||||
if param and any(c not in 'ZzFf, ' for c in param):
|
||||
continue
|
||||
width, height = width * parallel, _c_div(height, parallel)
|
||||
elif name == 'remap':
|
||||
size = _remap_size(param, width, height, chain, parallel)
|
||||
if size is not None:
|
||||
width, height = size
|
||||
# "mirror" keeps the size; the library skips names it doesn't know.
|
||||
return width, height
|
||||
|
||||
|
||||
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
|
||||
"""Width and height of the whole panel chain, in pixels.
|
||||
|
||||
``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
|
||||
``cols * chain_length`` by ``rows * parallel``, then through the pixel
|
||||
mappers ``orientation`` and ``pixel_mapper_config`` set up -- what
|
||||
``RGBMatrix.width``/``height`` report. Raises ``ValueError`` or
|
||||
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
|
||||
decide their own fallback.
|
||||
|
||||
@@ -54,7 +184,11 @@ def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
|
||||
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
|
||||
except OverflowError as e:
|
||||
raise ValueError(f"display.hardware size is not finite: {e}") from e
|
||||
return max(1, cols * chain_length), max(1, rows * parallel)
|
||||
width, height = max(1, cols * chain_length), max(1, rows * parallel)
|
||||
if chain_length >= 1 and parallel >= 1:
|
||||
width, height = apply_pixel_mappers(
|
||||
width, height, compose_pixel_mapper_config(hw), chain_length, parallel)
|
||||
return max(1, width), max(1, height)
|
||||
|
||||
|
||||
def resolve_double_sided(physical_width: int, physical_height: int,
|
||||
|
||||
+58
-32
@@ -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.display_geometry import (
|
||||
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 time
|
||||
from collections import OrderedDict
|
||||
@@ -247,28 +249,41 @@ class DisplayManager:
|
||||
# Calendar manager is now initialized by DisplayController
|
||||
|
||||
# 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 = {'normal': None, '90': 90, '180': 180, '270': 270}
|
||||
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
|
||||
|
||||
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.
|
||||
for "U-mapper" chain layouts); `orientation` is the user-facing dropdown
|
||||
for physical mounting (e.g. panels mounted upside down) and is appended as
|
||||
a "Rotate:<deg>" mapper rather than overwriting any existing config.
|
||||
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
|
||||
web preview shares so it sizes the canvas the same way.
|
||||
"""
|
||||
base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip()
|
||||
orientation = hardware_config.get('orientation', 'normal')
|
||||
degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation)
|
||||
if degrees is None:
|
||||
return base_mapper
|
||||
rotate_mapper = f'Rotate:{degrees}'
|
||||
return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper
|
||||
return compose_pixel_mapper_config(hardware_config)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_advice(cause: str, error: Exception) -> str:
|
||||
"""What to do about a failed matrix init, for the log.
|
||||
|
||||
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):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
_init_error_str = None
|
||||
_init_cause = None
|
||||
try:
|
||||
# Allow callers (e.g., web UI) to force non-hardware fallback mode
|
||||
if getattr(self, '_force_fallback', False):
|
||||
@@ -278,6 +293,18 @@ class DisplayManager:
|
||||
# Hardware configuration
|
||||
hardware_config = self.config.get('display', {}).get('hardware', {})
|
||||
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
|
||||
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}")
|
||||
|
||||
# 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
|
||||
self.matrix = RGBMatrix(options=options)
|
||||
logger.info("RGB Matrix initialized successfully")
|
||||
@@ -379,7 +397,12 @@ class DisplayManager:
|
||||
|
||||
except Exception as 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
|
||||
self.matrix = None
|
||||
try:
|
||||
@@ -406,15 +429,18 @@ class DisplayManager:
|
||||
# Best-effort; ignore drawing errors in fallback
|
||||
pass
|
||||
logger.error(
|
||||
f"Matrix initialization failed — running in fallback/simulation mode "
|
||||
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
|
||||
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
|
||||
"submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
|
||||
)
|
||||
"Matrix initialization failed — running in fallback/simulation mode "
|
||||
"(size %dx%d). %s",
|
||||
fallback_width, fallback_height, self._fallback_advice(_init_cause, e))
|
||||
# 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
|
||||
_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
|
||||
try:
|
||||
if os.path.islink(_status_path):
|
||||
|
||||
@@ -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
|
||||
@@ -1,9 +1,10 @@
|
||||
"""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:
|
||||
Held to what the pinned library and its Python binding accept
|
||||
(src/matrix_support.py: RGBMatrix::Options::Validate in
|
||||
lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc, the
|
||||
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
|
||||
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.
|
||||
- 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.
|
||||
- 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
|
||||
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', [
|
||||
('rows', 8), ('rows', 64), ('rows', 96), ('rows', 128),
|
||||
('rows', 8), ('rows', 64),
|
||||
('cols', 16), ('cols', 192), ('cols', 512),
|
||||
('chain_length', 1), ('chain_length', 32),
|
||||
('parallel', 3),
|
||||
('chain_length', 1), ('chain_length', 32), ('chain_length', 255),
|
||||
('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0),
|
||||
('multiplexing', 0), ('multiplexing', 22),
|
||||
('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', [
|
||||
('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'),
|
||||
('chain_length', 0),
|
||||
('chain_length', 0), ('chain_length', 256), ('chain_length', 300),
|
||||
('parallel', 0), ('parallel', 4),
|
||||
('row_address_type', -1), ('row_address_type', 6),
|
||||
('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
|
||||
|
||||
|
||||
@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
|
||||
def display_page(monkeypatch):
|
||||
"""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
|
||||
|
||||
|
||||
@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', [
|
||||
('gpio_slowdown', 'runtime'), ('pwm_dither_bits', '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', [
|
||||
{'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):
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
body = display_page(_config_with())
|
||||
assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5']
|
||||
|
||||
@@ -142,3 +142,54 @@ def test_preview_callers_do_not_rederive_the_size():
|
||||
assert "get('chain_length', 1)" not in source, rel
|
||||
assert 'get("chain_length", 1)' not in source, rel
|
||||
assert 'cols * chain_length' not in source, rel
|
||||
|
||||
|
||||
# --- Pixel mappers ----------------------------------------------------------
|
||||
# RGBMatrix.width/height are measured after the library's pixel mappers
|
||||
# (lib/pixel-mapper.cc), so the preview has to apply them too. This used to
|
||||
# report 128x32 for a Rotate:90 chain the panel drew at 32x128.
|
||||
|
||||
@pytest.mark.parametrize('hardware,expected', [
|
||||
({'pixel_mapper_config': 'Rotate:90'}, (32, 128)),
|
||||
({'pixel_mapper_config': 'Rotate:270'}, (32, 128)),
|
||||
({'pixel_mapper_config': 'Rotate:-90'}, (32, 128)),
|
||||
({'pixel_mapper_config': 'Rotate:180'}, (128, 32)),
|
||||
({'orientation': '90'}, (32, 128)),
|
||||
({'orientation': '270'}, (32, 128)),
|
||||
({'orientation': '180'}, (128, 32)),
|
||||
# Two quarter turns cancel out.
|
||||
({'pixel_mapper_config': 'Rotate:90', 'orientation': '270'}, (128, 32)),
|
||||
# U-mapper folds a chain of 4 into two rows: (256 / 64) * 32 by 2 * 32.
|
||||
({'chain_length': 4, 'pixel_mapper_config': 'U-mapper'}, (128, 64)),
|
||||
({'chain_length': 4, 'pixel_mapper_config': 'u-mapper;Rotate:90'}, (64, 128)),
|
||||
# U-mapper needs an even chain of at least 2, or the library skips it.
|
||||
({'chain_length': 3, 'pixel_mapper_config': 'U-mapper'}, (192, 32)),
|
||||
({'cols': 32, 'chain_length': 4, 'pixel_mapper_config': 'V-mapper'}, (32, 128)),
|
||||
({'chain_length': 1, 'parallel': 2, 'pixel_mapper_config': 'StackToRow:Z'}, (128, 32)),
|
||||
({'pixel_mapper_config': 'Remap:64,64|0,0n|0,32n'}, (64, 64)),
|
||||
# A Remap panel entirely outside the visible area: the library skips it.
|
||||
({'pixel_mapper_config': 'Remap:64,64|0,0n|0,99n'}, (128, 32)),
|
||||
({'pixel_mapper_config': 'Mirror:H'}, (128, 32)),
|
||||
# Unknown or unusable mappers are skipped by the library.
|
||||
({'pixel_mapper_config': 'Bogus;Rotate:45;Rotate:ninety'}, (128, 32)),
|
||||
])
|
||||
def test_pixel_mappers_change_the_size_as_the_library_does(hardware, expected):
|
||||
hw = {'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1}
|
||||
hw.update(hardware)
|
||||
assert physical_size(_config(hw)) == expected
|
||||
assert logical_size(_config(hw)) == expected
|
||||
|
||||
|
||||
def test_double_sided_splits_the_mapped_size():
|
||||
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1, 'orientation': '90'},
|
||||
{'enabled': True, 'copies': 2, 'axis': 'vertical'})
|
||||
assert logical_size(cfg) == (32, 64)
|
||||
|
||||
|
||||
def test_display_manager_composes_the_mapper_config_it_sizes_from():
|
||||
from src.display_geometry import compose_pixel_mapper_config
|
||||
assert compose_pixel_mapper_config({}) == ''
|
||||
assert compose_pixel_mapper_config({'orientation': '90'}) == 'Rotate:90'
|
||||
assert compose_pixel_mapper_config(
|
||||
{'pixel_mapper_config': ' U-mapper ', 'orientation': '180'}) == 'U-mapper;Rotate:180'
|
||||
assert compose_pixel_mapper_config({'orientation': 'sideways'}) == ''
|
||||
|
||||
@@ -265,6 +265,14 @@ class TestDisplayManagerOrientation:
|
||||
options = mock_rgb_matrix['options_class'].return_value
|
||||
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):
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
@@ -281,10 +289,11 @@ class TestDisplayManagerOrientation:
|
||||
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."""
|
||||
class TestDisplayManagerLibraryGuard:
|
||||
"""For many settings it can't use the library returns no matrix (which the
|
||||
binding doesn't check, so the process crashes on its next call) or calls
|
||||
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):
|
||||
config = {
|
||||
@@ -327,10 +336,95 @@ class TestDisplayManagerPi5Guard:
|
||||
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):
|
||||
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')
|
||||
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
|
||||
|
||||
@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'))
|
||||
|
||||
@@ -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
|
||||
@@ -12,7 +12,9 @@ from web_interface.blueprints.api_v3 import (
|
||||
success_response,
|
||||
)
|
||||
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
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
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
|
||||
# accepts (RGBMatrix::Options::Validate in lib/options-initialize.cc,
|
||||
# the gpio_slowdown check in lib/led-matrix.cc). Outside those ranges
|
||||
# the config used to save, then the matrix refused to start and the
|
||||
# display dropped to fallback mode. cols, chain_length and
|
||||
# limit_refresh_rate_hz (0 = no cap) have no upper bound in the
|
||||
# library. rows has none here by choice: the library currently
|
||||
# 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):
|
||||
# Panel geometry, PWM and GPIO timing, held to what the rgbmatrix
|
||||
# library and its Python binding accept (src/matrix_support.py:
|
||||
# Options::Validate, the gpio_slowdown check, and the binding's
|
||||
# uint8_t setters, which cap chain_length at 255). Outside those
|
||||
# ranges the library returns no matrix and the display service
|
||||
# crash-loops rather than falling back, so they never save.
|
||||
def _hardware_int_error(field, low, high, even=False):
|
||||
"""A 400 response if data[field] is not an allowed integer, else None."""
|
||||
raw = data[field]
|
||||
kind = "an even integer" if even else "an integer"
|
||||
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)
|
||||
rejection = (jsonify({'status': 'error', 'message': f"Invalid {field} '{raw}'. Must be {describe_range(low, high, even)}."}), 400)
|
||||
# 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()):
|
||||
return rejection
|
||||
@@ -590,34 +584,31 @@ def save_main_config():
|
||||
value = int(raw)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
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 None
|
||||
|
||||
for field, low, high, even in (('rows', 8, None, True), ('cols', 16, None, False),
|
||||
('chain_length', 1, None, False), ('parallel', 1, 3, False),
|
||||
('brightness', 1, 100, False), ('scan_mode', 0, 1, False),
|
||||
('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:
|
||||
# rp1_rio has its own check below.
|
||||
for field, (_section, low, high, even) in INT_SETTING_LIMITS.items():
|
||||
if field in data and field != 'rp1_rio':
|
||||
error = _hardware_int_error(field, low, high, even)
|
||||
if error:
|
||||
return error
|
||||
|
||||
# A Pi 5 can't drive every combination (src/pi5_matrix_support.py),
|
||||
# and one it can't crashes the display service instead of falling
|
||||
# back. Checked only when this request sets one of those fields, so
|
||||
# a combination already stored doesn't block unrelated saves.
|
||||
pi5_fields = ('row_address_type', 'parallel', 'hardware_mapping')
|
||||
if any(k in data for k in pi5_fields) and is_raspberry_pi_5():
|
||||
# Combinations the library can't start with: a hardware mapping it
|
||||
# doesn't have, more parallel chains than the mapping has outputs,
|
||||
# and on a Pi 5 its narrower RP1 support. Reported only when this
|
||||
# request sets one of the settings involved, so a problem already
|
||||
# stored doesn't block unrelated saves.
|
||||
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.update({k: data[k] for k in pi5_fields if k in data})
|
||||
unsupported = pi5_unsupported_settings(effective)
|
||||
if unsupported:
|
||||
return jsonify({'status': 'error', 'message': unsupported}), 400
|
||||
effective.update({k: v for k, v in data.items()
|
||||
if k in combination_fields or k in INT_SETTING_LIMITS})
|
||||
refusals = [r for r in library_refusals(effective, pi5=is_raspberry_pi_5())
|
||||
if any(f in data for f in r.fields)]
|
||||
if refusals:
|
||||
return jsonify({'status': 'error', 'message': refusal_message(refusals)}), 400
|
||||
|
||||
# Handle hardware settings
|
||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
||||
|
||||
@@ -8,21 +8,26 @@
|
||||
{{ ui.settings_filter('Filter display settings…') }}
|
||||
|
||||
<!-- 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 => {
|
||||
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(() => {})"
|
||||
x-show="show"
|
||||
style="display:none"
|
||||
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="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">
|
||||
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
|
||||
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
|
||||
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
|
||||
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'">
|
||||
The rgbmatrix library can't start with the settings named above, so LEDMatrix didn't try.
|
||||
Change them below, save, then restart the display service from the Overview tab.
|
||||
</p>
|
||||
<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>
|
||||
</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="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"
|
||||
id="rows"
|
||||
name="rows"
|
||||
@@ -62,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="chain_length"
|
||||
name="chain_length"
|
||||
@@ -72,7 +77,7 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
id="parallel"
|
||||
name="parallel"
|
||||
@@ -107,19 +112,33 @@
|
||||
|
||||
<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>
|
||||
{#- 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">
|
||||
<option value="adafruit-hat-pwm" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
|
||||
<option value="adafruit-hat" {% if main_config.display.hardware.hardware_mapping == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
|
||||
<option value="regular" {% if main_config.display.hardware.hardware_mapping == "regular" %}selected{% endif %}>Regular</option>
|
||||
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
|
||||
{% for value in mapping_values %}
|
||||
<option value="{{ value }}" {% if mapping_key == value %}selected{% endif %}>{{ mapping_labels[value] }}</option>
|
||||
{% endfor %}
|
||||
{% if mapping_key not in mapping_values %}
|
||||
<option value="{{ stored_mapping }}" selected>{{ stored_mapping }} (saved, can't be used)</option>
|
||||
{% endif %}
|
||||
</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 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">
|
||||
<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="270" {% if main_config.display.hardware.get('orientation', 'normal') == "270" %}selected{% endif %}>Rotated 270°</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +237,7 @@
|
||||
|
||||
<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">
|
||||
<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"
|
||||
id="gpio_slowdown"
|
||||
name="gpio_slowdown"
|
||||
@@ -374,7 +393,9 @@
|
||||
|
||||
<script>
|
||||
// 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 () {
|
||||
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
|
||||
const out = document.getElementById('display-resolution-value');
|
||||
@@ -389,12 +410,19 @@
|
||||
out.textContent = '—';
|
||||
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) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', recompute);
|
||||
}
|
||||
const orientationSelect = document.getElementById('orientation');
|
||||
if (orientationSelect) orientationSelect.addEventListener('change', recompute);
|
||||
recompute();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user