mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web): accept every panel size and row address type the rgbmatrix library does The Display form capped columns at 128 and chain length at 24, and its submit handler (fixInvalidNumberInputs) rewrote anything larger to the cap, so wide panels and long chains silently saved as the wrong size. The config API checked none of the hardware numbers, so values the library rejects (odd rows, parallel 4, PWM dither bits 3) saved and the matrix then refused to start. - Form limits now match the pinned library: rows even 8-64, cols >= 16 and chain_length >= 1 with no upper bound, parallel 1-3, PWM dither bits 0-2, PWM LSB nanoseconds 50-3000. - save_main_config 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. - A stored gpio_slowdown or pwm_dither_bits of 0 renders as 0 instead of the default, so saving the tab no longer overwrites it. - Row Address Type offers 5 (SM5368 / B707 row shift register). Verified on a Waveshare 96x48 V2 (24S-A1) on a Pi 4 with the Adafruit Triple LED Matrix Bonnet: rows 48, cols 96, row address type 5, BGR, GPIO slowdown 8. - Help text and docs: FM6124-family panels use Panel Type Standard; on a Pi 5 the library supports only row address types 0 and 2. No change to the rpi-rgb-led-matrix submodule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): drop the rows cap and document every display setting accurately Rows: no upper limit in the form or the API. Still even and at least 8. The current rgbmatrix library rejects more than 64 per panel, so a larger value saves but the matrix won't start; the help tip, README, config reference and troubleshooting section all say so, and nothing here needs changing if the library lifts the limit. limit_refresh_rate_hz: the form accepts 0 (the library's "no cap"), a stored 0 no longer renders and re-saves as 120, and the API rejects negatives. pwm_dither_bits stays 0-2: the library rejects 3 and 4, so the old form's 0-4 only ever let users save a config the display couldn't start with. Docs and help tips, checked against the pinned library and its README: - panel_type and rp1_rio get README entries - show_refresh_rate prints to stdout; it never drew on the panel - dither bits raise the refresh rate; the tip said they lowered it - scan_mode is about interlacing at low refresh, not wrong colours - disable_hardware_pulsing: hardware pulsing needs OE on GPIO 18 and the onboard sound driver off; software timing makes rows flash brighter - gpio_slowdown guidance agrees between the README and the UI - all 22 multiplexing values listed; every numeric setting states its range - troubleshooting for a blank panel after a settings change, jumping rows and brightness flashes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): reject true and 5.5 for row_address_type and multiplexing Both still went straight through int(), so a JSON true saved as 1 and 5.5 as 5. They now use the shared hardware range check like the other panel fields. Review feedback on #586. Also: the RP1 Backend tooltip said it is ignored on Pi 3/4 (it is ignored on every model but the Pi 5), and the README gave the dynamic-duration default cap as 90s; the code default is 180s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: refuse matrix settings a Raspberry Pi 5 can't drive On a Pi 5 the pinned rgbmatrix library drives the panel through the RP1 chip, and that path supports only row address types 0 and 2, parallel 1-3 and the regular / regular-pi1 / classic / adafruit-hat(-pwm) mappings (Rp1PioConfigSupported in lib/rp1/rp1_pio_backend.cc). For anything else CreateFromOptions returns NULL; the Python binding doesn't check, so the display process crashed on its first call into the matrix and systemd restarted it into the same crash every 10 seconds. - src/pi5_matrix_support.py: the rule and Pi 5 detection, matching the library's /proc/device-tree/model check - DisplayManager raises before creating the matrix, so it is a logged init failure (reported by /api/v3/hardware/status) and fallback mode - the config API rejects those settings on a Pi 5 when a request sets row_address_type, parallel or hardware_mapping - the Display form offers only row address types 0 and 2 on a Pi 5, and warns when a stored value can't be used - CLAUDE.md: re-check the rule whenever the submodule is bumped Review feedback on #586. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
337 lines
15 KiB
Python
337 lines
15 KiB
Python
import os
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
from PIL import ImageDraw
|
|
|
|
# display_manager imports the hardware rgbmatrix module at import time unless
|
|
# EMULATOR=true. Use the emulator (same convention as
|
|
# test_display_dirty_tracking.py) so this file collects standalone instead of
|
|
# relying on collection order — the tests below patch RGBMatrix/
|
|
# RGBMatrixOptions explicitly, so the underlying binding doesn't matter here.
|
|
os.environ.setdefault("EMULATOR", "true")
|
|
|
|
from src.display_manager import DisplayManager
|
|
|
|
@pytest.fixture
|
|
def mock_rgb_matrix():
|
|
"""Mock the rgbmatrix library."""
|
|
with patch('src.display_manager.RGBMatrix') as mock_matrix, \
|
|
patch('src.display_manager.RGBMatrixOptions') as mock_options, \
|
|
patch('src.display_manager.freetype'):
|
|
|
|
# Setup matrix instance mock
|
|
matrix_instance = MagicMock()
|
|
matrix_instance.width = 128
|
|
matrix_instance.height = 32
|
|
matrix_instance.CreateFrameCanvas.return_value = MagicMock()
|
|
matrix_instance.Clear = MagicMock()
|
|
matrix_instance.SetImage = MagicMock()
|
|
mock_matrix.return_value = matrix_instance
|
|
|
|
yield {
|
|
'matrix_class': mock_matrix,
|
|
'options_class': mock_options,
|
|
'matrix_instance': matrix_instance
|
|
}
|
|
|
|
class TestDisplayManagerInitialization:
|
|
"""Test DisplayManager initialization."""
|
|
|
|
def test_init_hardware_mode(self, test_config, mock_rgb_matrix):
|
|
"""Test initialization in hardware mode."""
|
|
# Ensure EMULATOR env var is not set
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(test_config)
|
|
|
|
assert dm.width == 128
|
|
assert dm.height == 32
|
|
assert dm.matrix is not None
|
|
|
|
# Verify options were set correctly
|
|
mock_rgb_matrix['options_class'].assert_called()
|
|
options = mock_rgb_matrix['options_class'].return_value
|
|
assert options.rows == 32
|
|
assert options.cols == 64
|
|
assert options.chain_length == 2
|
|
|
|
def test_init_emulator_mode(self, test_config):
|
|
"""Test initialization in emulator mode."""
|
|
# Set EMULATOR env var and patch the import
|
|
with patch.dict('os.environ', {'EMULATOR': 'true'}), \
|
|
patch('src.display_manager.RGBMatrix') as mock_matrix, \
|
|
patch('src.display_manager.RGBMatrixOptions') as mock_options:
|
|
|
|
# Setup matrix instance
|
|
matrix_instance = MagicMock()
|
|
matrix_instance.width = 128
|
|
matrix_instance.height = 32
|
|
mock_matrix.return_value = matrix_instance
|
|
|
|
dm = DisplayManager(test_config)
|
|
|
|
assert dm.width == 128
|
|
assert dm.height == 32
|
|
mock_matrix.assert_called()
|
|
|
|
|
|
class TestDisplayManagerDrawing:
|
|
"""Test drawing operations."""
|
|
|
|
def test_clear(self, test_config, mock_rgb_matrix):
|
|
"""Test clear operation."""
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(test_config)
|
|
dm.clear()
|
|
# clear() calls Clear() multiple times (offscreen_canvas, current_canvas, matrix)
|
|
assert dm.matrix.Clear.called
|
|
|
|
def test_draw_text(self, test_config, mock_rgb_matrix):
|
|
"""Text drawn through draw_text must actually light pixels."""
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import src.display_manager as dm_mod
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
DisplayManager._instance = None
|
|
dm = DisplayManager(test_config, suppress_test_pattern=True)
|
|
# The fixture replaces the module's freetype with a MagicMock,
|
|
# which breaks draw_text's isinstance(font, freetype.Face) check
|
|
# (and silently swallows the draw). Give the mock a real class so
|
|
# isinstance works and the PIL path is taken.
|
|
dm_mod.freetype.Face = type("_FakeFace", (), {})
|
|
# Start from a known-black canvas so the assertion below can only
|
|
# pass if draw_text itself lit something.
|
|
dm.image = Image.new('RGB', (dm.width, dm.height))
|
|
dm.draw = ImageDraw.Draw(dm.image)
|
|
|
|
dm.draw_text("Test", 0, 0, font=ImageFont.load_default())
|
|
|
|
assert dm.image.convert("L").getbbox() is not None, \
|
|
"draw_text lit no pixels"
|
|
|
|
def test_draw_image(self, test_config, mock_rgb_matrix):
|
|
"""Test image drawing."""
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(test_config)
|
|
|
|
# DisplayManager doesn't have draw_image method
|
|
# It uses SetImage on canvas in update_display()
|
|
# Just verify DisplayManager can handle image operations
|
|
from PIL import Image
|
|
test_image = Image.new('RGB', (64, 32))
|
|
dm.image = test_image
|
|
dm.draw = ImageDraw.Draw(dm.image)
|
|
|
|
# Verify image was set
|
|
assert dm.image is not None
|
|
|
|
|
|
class TestDisplayManagerResourceManagement:
|
|
"""Test resource management."""
|
|
|
|
def test_cleanup(self, test_config, mock_rgb_matrix):
|
|
"""Test cleanup operation."""
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(test_config)
|
|
dm.cleanup()
|
|
|
|
dm.matrix.Clear.assert_called()
|
|
|
|
|
|
class TestDisplayManagerDoubleSided:
|
|
"""Double-sided mode: render once at logical size, tile across the chain."""
|
|
|
|
def _config(self, **double_sided):
|
|
"""Build a config (physical 128x32) with the given double_sided block."""
|
|
return {
|
|
'display': {
|
|
'hardware': {
|
|
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
|
|
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
|
|
},
|
|
'runtime': {'gpio_slowdown': 2},
|
|
'double_sided': double_sided,
|
|
},
|
|
'timezone': 'UTC',
|
|
'plugin_system': {'plugins_directory': 'plugins'},
|
|
}
|
|
|
|
def _captured_physical(self, mock_rgb_matrix):
|
|
"""Return the image handed to the canvas on the last update_display()."""
|
|
canvas = mock_rgb_matrix['matrix_instance'].CreateFrameCanvas.return_value
|
|
return canvas.SetImage.call_args[0][0]
|
|
|
|
def test_horizontal_reports_logical_dimensions(self, mock_rgb_matrix):
|
|
"""Plugins see the per-screen size, not the full physical chain."""
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
|
suppress_test_pattern=True)
|
|
# Physical chain is 128x32; two side-by-side copies -> logical 64x32.
|
|
assert dm.matrix.width == 64
|
|
assert dm.matrix.height == 32
|
|
assert (dm.width, dm.height) == (64, 32)
|
|
assert dm.image.size == (64, 32)
|
|
|
|
def test_horizontal_tiles_image_across_chain(self, mock_rgb_matrix):
|
|
"""The logical screen is duplicated left/right into a full-chain frame."""
|
|
from PIL import Image
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
|
suppress_test_pattern=True)
|
|
logical = Image.new('RGB', (64, 32), (0, 0, 0))
|
|
logical.putpixel((5, 5), (255, 0, 0))
|
|
dm.image = logical
|
|
dm.update_display()
|
|
|
|
physical = self._captured_physical(mock_rgb_matrix)
|
|
assert physical.size == (128, 32)
|
|
assert physical.getpixel((5, 5)) == (255, 0, 0)
|
|
assert physical.getpixel((69, 5)) == (255, 0, 0) # copy shifted +64
|
|
|
|
def test_vertical_axis_tiles_stacked(self, mock_rgb_matrix):
|
|
"""Vertical axis stacks copies (for panels on parallel outputs)."""
|
|
from PIL import Image
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='vertical'),
|
|
suppress_test_pattern=True)
|
|
# 128x32 split vertically -> logical 128x16.
|
|
assert (dm.matrix.width, dm.matrix.height) == (128, 16)
|
|
logical = Image.new('RGB', (128, 16), (0, 0, 0))
|
|
logical.putpixel((10, 3), (0, 255, 0))
|
|
dm.image = logical
|
|
dm.update_display()
|
|
|
|
physical = self._captured_physical(mock_rgb_matrix)
|
|
assert physical.size == (128, 32)
|
|
assert physical.getpixel((10, 3)) == (0, 255, 0)
|
|
assert physical.getpixel((10, 19)) == (0, 255, 0) # copy shifted +16
|
|
|
|
def test_indivisible_dimension_disables_mode(self, mock_rgb_matrix):
|
|
"""A physical size that doesn't divide evenly falls back to single."""
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(enabled=True, copies=3, axis='horizontal'),
|
|
suppress_test_pattern=True)
|
|
assert dm._double_sided is None # 128 % 3 != 0
|
|
assert dm.matrix.width == 128
|
|
assert dm.image.size == (128, 32)
|
|
|
|
def test_disabled_blits_logical_image_unchanged(self, mock_rgb_matrix):
|
|
"""With the feature off, the rendered image is sent through untouched."""
|
|
from PIL import Image
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(enabled=False), suppress_test_pattern=True)
|
|
assert dm._double_sided is None
|
|
img = Image.new('RGB', (128, 32))
|
|
dm.image = img
|
|
dm.update_display()
|
|
assert self._captured_physical(mock_rgb_matrix) is img
|
|
|
|
def test_brightness_write_forwards_through_proxy(self, mock_rgb_matrix):
|
|
"""Setting brightness via the proxy reaches the real matrix."""
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
|
suppress_test_pattern=True)
|
|
assert dm.set_brightness(70) is True
|
|
assert mock_rgb_matrix['matrix_instance'].brightness == 70
|
|
|
|
|
|
class TestDisplayManagerOrientation:
|
|
"""The orientation setting composes onto pixel_mapper_config for panels
|
|
mounted upside down, without disturbing a custom pixel_mapper_config."""
|
|
|
|
def _config(self, **hardware_overrides):
|
|
config = {
|
|
'display': {
|
|
'hardware': {
|
|
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
|
|
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
|
|
},
|
|
'runtime': {'gpio_slowdown': 2},
|
|
},
|
|
'timezone': 'UTC',
|
|
'plugin_system': {'plugins_directory': 'plugins'},
|
|
}
|
|
config['display']['hardware'].update(hardware_overrides)
|
|
return config
|
|
|
|
def test_default_orientation_leaves_pixel_mapper_config_untouched(self, mock_rgb_matrix):
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
DisplayManager(self._config(), suppress_test_pattern=True)
|
|
options = mock_rgb_matrix['options_class'].return_value
|
|
assert options.pixel_mapper_config == ''
|
|
|
|
def test_orientation_180_appends_rotate_mapper(self, mock_rgb_matrix):
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
DisplayManager(self._config(orientation='180'), suppress_test_pattern=True)
|
|
options = mock_rgb_matrix['options_class'].return_value
|
|
assert options.pixel_mapper_config == 'Rotate:180'
|
|
|
|
def test_orientation_180_composes_with_existing_pixel_mapper_config(self, mock_rgb_matrix):
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
DisplayManager(self._config(orientation='180', pixel_mapper_config='U-mapper'),
|
|
suppress_test_pattern=True)
|
|
options = mock_rgb_matrix['options_class'].return_value
|
|
assert options.pixel_mapper_config == 'U-mapper;Rotate:180'
|
|
|
|
|
|
class TestDisplayManagerPi5Guard:
|
|
"""On a Pi 5 the library returns no matrix for settings its RP1 path can't
|
|
drive, and the binding doesn't check, so the process would crash on its next
|
|
call. DisplayManager has to refuse before creating the matrix and fall back."""
|
|
|
|
def _config(self, **hardware_overrides):
|
|
config = {
|
|
'display': {
|
|
'hardware': {
|
|
'rows': 48, 'cols': 96, 'chain_length': 1, 'parallel': 1,
|
|
'hardware_mapping': 'regular', 'brightness': 90,
|
|
},
|
|
'runtime': {'gpio_slowdown': 2},
|
|
},
|
|
'timezone': 'UTC',
|
|
'plugin_system': {'plugins_directory': 'plugins'},
|
|
}
|
|
config['display']['hardware'].update(hardware_overrides)
|
|
return config
|
|
|
|
@pytest.fixture
|
|
def board(self, tmp_path, monkeypatch):
|
|
from src import pi5_matrix_support
|
|
|
|
def set_model(model):
|
|
path = tmp_path / 'model'
|
|
path.write_bytes(model.encode() + b'\x00')
|
|
monkeypatch.setattr(pi5_matrix_support, 'MODEL_PATH', str(path))
|
|
return set_model
|
|
|
|
def test_unsupported_setting_on_pi5_never_creates_the_matrix(self, mock_rgb_matrix, board):
|
|
board('Raspberry Pi 5 Model B Rev 1.0')
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True)
|
|
mock_rgb_matrix['matrix_class'].assert_not_called()
|
|
assert dm.matrix is None
|
|
|
|
def test_supported_setting_on_pi5_creates_the_matrix(self, mock_rgb_matrix, board):
|
|
board('Raspberry Pi 5 Model B Rev 1.0')
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(row_address_type=2), suppress_test_pattern=True)
|
|
mock_rgb_matrix['matrix_class'].assert_called_once()
|
|
assert dm.matrix is not None
|
|
|
|
def test_other_boards_are_left_to_the_library(self, mock_rgb_matrix, board):
|
|
board('Raspberry Pi 4 Model B Rev 1.5')
|
|
DisplayManager._instance = None
|
|
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
|
dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True)
|
|
mock_rgb_matrix['matrix_class'].assert_called_once()
|
|
assert dm.matrix is not None
|