mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-25 21:48:37 +00:00
feat(display): double-sided mode — mirror one screen across the panel chain (#375)
* feat(display): add double-sided mode to mirror one screen across the panel chain
Renders a plugin once at a logical (per-screen) size, then tiles the
rendered frame across the full physical chain so two (or more) panels show
identical content. A 128x32 chain configured with 2 copies drives two 64x32
screens; vertical axis splits parallel outputs instead of the chain.
Plugins size themselves from matrix.width/height, so a thin _LogicalMatrix
proxy reports the logical size while delegating every real operation
(CreateFrameCanvas, SwapOnVSync, brightness, Clear) to the physical matrix —
no plugin changes required. Duplication is a single PIL paste per copy in
update_display(), so render cost is unchanged.
Config: display.double_sided { enabled, copies, axis }. Invalid config
(non-divisible dimension, bad axis/copies) logs a warning and falls back to
single-screen rather than failing to light up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(web): expose double-sided display config in the settings UI
Adds a Double-Sided Display section to the Display settings page (enabled
checkbox, copies, horizontal/vertical axis) and wires the save handler to
persist it under display.double_sided. Validates copies (2-8) and axis,
returning 400 on bad input; an omitted checkbox is saved as disabled.
Like the other hardware fields, changes take effect after a display restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(display): add type hints and docstrings to double-sided proxy
Addresses CodeRabbit nits: sort _LogicalMatrix.__slots__ (Ruff RUF023),
annotate the proxy's __init__/properties/dunders and _resolve_double_sided's
return type, and add docstrings to the property/dunder methods.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -109,11 +109,114 @@ class TestDisplayManagerDrawing:
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user