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:
@@ -115,6 +115,11 @@
|
|||||||
"gpio_slowdown": 3,
|
"gpio_slowdown": 3,
|
||||||
"rp1_rio": 0
|
"rp1_rio": 0
|
||||||
},
|
},
|
||||||
|
"double_sided": {
|
||||||
|
"enabled": false,
|
||||||
|
"copies": 2,
|
||||||
|
"axis": "horizontal"
|
||||||
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List, Optional
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import freetype
|
import freetype
|
||||||
@@ -42,6 +42,106 @@ import freetype
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
|
|
||||||
|
|
||||||
|
class _LogicalMatrix:
|
||||||
|
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
||||||
|
|
||||||
|
In double-sided mode the physical panel chain shows N identical copies of a
|
||||||
|
smaller logical screen. Plugins size themselves from ``matrix.width`` /
|
||||||
|
``matrix.height`` (the documented convention, used at 30+ call sites), so
|
||||||
|
this proxy reports the logical dimensions while delegating every real
|
||||||
|
operation — ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
|
||||||
|
``Clear`` and so on — to the underlying physical matrix. The duplication
|
||||||
|
itself happens once per frame in :meth:`DisplayManager.update_display`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_logical_height", "_logical_width", "_matrix")
|
||||||
|
|
||||||
|
def __init__(self, matrix: RGBMatrix, logical_width: int, logical_height: int) -> None:
|
||||||
|
object.__setattr__(self, "_matrix", matrix)
|
||||||
|
object.__setattr__(self, "_logical_width", logical_width)
|
||||||
|
object.__setattr__(self, "_logical_height", logical_height)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self) -> int:
|
||||||
|
"""Logical (per-screen) width reported to plugins."""
|
||||||
|
return self._logical_width
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
"""Logical (per-screen) height reported to plugins."""
|
||||||
|
return self._logical_height
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
"""Forward any non-overridden attribute access to the physical matrix.
|
||||||
|
|
||||||
|
Reached only when normal lookup fails (i.e. not width/height/_*).
|
||||||
|
"""
|
||||||
|
return getattr(object.__getattribute__(self, "_matrix"), name)
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: Any) -> None:
|
||||||
|
"""Forward attribute writes (e.g. ``matrix.brightness = 80``) to it."""
|
||||||
|
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_double_sided(physical_width: int, physical_height: int,
|
||||||
|
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Validate the ``display.double_sided`` config against the physical size.
|
||||||
|
|
||||||
|
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
||||||
|
feature is enabled and the physical panel divides evenly into ``copies``
|
||||||
|
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
||||||
|
config is logged and disabled rather than raised — a misconfigured panel
|
||||||
|
should still light up.
|
||||||
|
"""
|
||||||
|
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
copies = ds_config.get('copies', 2)
|
||||||
|
if not isinstance(copies, int) or copies < 2:
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
||||||
|
"disabling double-sided mode", copies)
|
||||||
|
return None
|
||||||
|
|
||||||
|
axis = ds_config.get('axis', 'horizontal')
|
||||||
|
if axis not in ('horizontal', 'vertical'):
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
||||||
|
"(got %r); defaulting to 'horizontal'", axis)
|
||||||
|
axis = 'horizontal'
|
||||||
|
|
||||||
|
# Horizontal splits the chain (panels side by side); vertical splits the
|
||||||
|
# parallel outputs (panels stacked). The split axis must divide evenly.
|
||||||
|
if axis == 'horizontal':
|
||||||
|
if physical_width % copies != 0:
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: physical width %d is not divisible by copies "
|
||||||
|
"%d; disabling double-sided mode", physical_width, copies)
|
||||||
|
return None
|
||||||
|
logical_width = physical_width // copies
|
||||||
|
logical_height = physical_height
|
||||||
|
else:
|
||||||
|
if physical_height % copies != 0:
|
||||||
|
logger.warning(
|
||||||
|
"double_sided: physical height %d is not divisible by copies "
|
||||||
|
"%d; disabling double-sided mode", physical_height, copies)
|
||||||
|
return None
|
||||||
|
logical_width = physical_width
|
||||||
|
logical_height = physical_height // copies
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
||||||
|
"tiled across physical %dx%d", copies, axis, logical_width,
|
||||||
|
logical_height, physical_width, physical_height)
|
||||||
|
return {
|
||||||
|
'copies': copies,
|
||||||
|
'axis': axis,
|
||||||
|
'logical_width': logical_width,
|
||||||
|
'logical_height': logical_height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
"""
|
"""
|
||||||
Singleton hardware abstraction layer for the RGB LED matrix.
|
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||||
@@ -76,6 +176,10 @@ class DisplayManager:
|
|||||||
self._suppress_test_pattern = suppress_test_pattern
|
self._suppress_test_pattern = suppress_test_pattern
|
||||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||||
|
# the logical image is blitted to the matrix unchanged.
|
||||||
|
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||||
|
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||||
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||||
# Avoids re-measuring the same string+font on every display() call.
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
@@ -169,12 +273,25 @@ class DisplayManager:
|
|||||||
self.matrix = RGBMatrix(options=options)
|
self.matrix = RGBMatrix(options=options)
|
||||||
logger.info("RGB Matrix initialized successfully")
|
logger.info("RGB Matrix initialized successfully")
|
||||||
|
|
||||||
# Create double buffer for smooth updates
|
# Create double buffer for smooth updates. The canvases are always
|
||||||
|
# full physical size — they back the real chain regardless of mode.
|
||||||
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
|
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
|
||||||
self.current_canvas = self.matrix.CreateFrameCanvas()
|
self.current_canvas = self.matrix.CreateFrameCanvas()
|
||||||
logger.info("Frame canvases created successfully")
|
logger.info("Frame canvases created successfully")
|
||||||
|
|
||||||
# Create image with full chain width
|
# Double-sided mode: wrap the physical matrix so plugins see the
|
||||||
|
# logical (per-screen) size, and keep a full-chain buffer to tile
|
||||||
|
# the rendered screen into once per frame.
|
||||||
|
ds_config = self.config.get('display', {}).get('double_sided', {})
|
||||||
|
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
|
||||||
|
self._double_sided = ds
|
||||||
|
if ds is not None:
|
||||||
|
self._physical_image = Image.new(
|
||||||
|
'RGB', (self.matrix.width, self.matrix.height))
|
||||||
|
self.matrix = _LogicalMatrix(
|
||||||
|
self.matrix, ds['logical_width'], ds['logical_height'])
|
||||||
|
|
||||||
|
# Create image with the (logical) display dimensions
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||||
@@ -201,8 +318,16 @@ class DisplayManager:
|
|||||||
rows = int(hardware_config.get('rows', 32))
|
rows = int(hardware_config.get('rows', 32))
|
||||||
cols = int(hardware_config.get('cols', 64))
|
cols = int(hardware_config.get('cols', 64))
|
||||||
chain_length = int(hardware_config.get('chain_length', 2))
|
chain_length = int(hardware_config.get('chain_length', 2))
|
||||||
|
parallel = int(hardware_config.get('parallel', 1))
|
||||||
fallback_width = max(1, cols * chain_length)
|
fallback_width = max(1, cols * chain_length)
|
||||||
fallback_height = max(1, rows)
|
fallback_height = max(1, rows * parallel)
|
||||||
|
# Mirror double-sided in fallback so the preview shows one screen.
|
||||||
|
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
||||||
|
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||||
|
self._double_sided = ds
|
||||||
|
if ds is not None:
|
||||||
|
fallback_width = ds['logical_width']
|
||||||
|
fallback_height = ds['logical_height']
|
||||||
except Exception:
|
except Exception:
|
||||||
fallback_width, fallback_height = 128, 32
|
fallback_width, fallback_height = 128, 32
|
||||||
|
|
||||||
@@ -364,6 +489,25 @@ class DisplayManager:
|
|||||||
finally:
|
finally:
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
|
||||||
|
def _composite_double_sided(self):
|
||||||
|
"""Tile the logical screen across the full physical chain.
|
||||||
|
|
||||||
|
Renders once into ``self._physical_image`` by pasting the rendered
|
||||||
|
logical image ``copies`` times along the configured axis. The paste is
|
||||||
|
a single memcpy per copy, so the per-frame cost is negligible and the
|
||||||
|
plugin render path is untouched.
|
||||||
|
"""
|
||||||
|
ds = self._double_sided
|
||||||
|
phys = self._physical_image
|
||||||
|
lw = ds['logical_width']
|
||||||
|
lh = ds['logical_height']
|
||||||
|
for i in range(ds['copies']):
|
||||||
|
if ds['axis'] == 'vertical':
|
||||||
|
phys.paste(self.image, (0, i * lh))
|
||||||
|
else:
|
||||||
|
phys.paste(self.image, (i * lw, 0))
|
||||||
|
return phys
|
||||||
|
|
||||||
def update_display(self):
|
def update_display(self):
|
||||||
"""Update the display using double buffering with proper sync."""
|
"""Update the display using double buffering with proper sync."""
|
||||||
try:
|
try:
|
||||||
@@ -377,7 +521,11 @@ class DisplayManager:
|
|||||||
if self._capture_mode_active:
|
if self._capture_mode_active:
|
||||||
return # Skip hardware write — content is being captured off-screen
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas
|
# Copy the current image to the offscreen canvas. In double-sided
|
||||||
|
# mode the logical screen is first tiled across the full chain.
|
||||||
|
if self._double_sided is not None:
|
||||||
|
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||||
|
else:
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
self.offscreen_canvas.SetImage(self.image)
|
||||||
|
|
||||||
# Swap buffers immediately
|
# Swap buffers immediately
|
||||||
|
|||||||
@@ -117,3 +117,106 @@ class TestDisplayManagerResourceManagement:
|
|||||||
dm.cleanup()
|
dm.cleanup()
|
||||||
|
|
||||||
dm.matrix.Clear.assert_called()
|
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
|
||||||
|
|||||||
@@ -144,6 +144,59 @@ class TestConfigAPI:
|
|||||||
|
|
||||||
assert response.status_code in [400, 500]
|
assert response.status_code in [400, 500]
|
||||||
|
|
||||||
|
def test_save_double_sided_settings(self, client, mock_config_manager):
|
||||||
|
"""Double-sided form fields are persisted under display.double_sided."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={
|
||||||
|
'double_sided_enabled': 'true',
|
||||||
|
'double_sided_copies': '2',
|
||||||
|
'double_sided_axis': 'vertical',
|
||||||
|
},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||||
|
assert saved['display']['double_sided'] == {
|
||||||
|
'enabled': True, 'copies': 2, 'axis': 'vertical',
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_save_double_sided_unchecked_disables(self, client, mock_config_manager):
|
||||||
|
"""An omitted 'enabled' checkbox is saved as disabled, not left stale."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'double_sided_copies': '4', 'double_sided_axis': 'horizontal'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
|
||||||
|
assert ds['enabled'] is False
|
||||||
|
assert ds['copies'] == 4
|
||||||
|
|
||||||
|
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
|
||||||
|
"""copies < 2 is rejected with a 400 before any save."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'double_sided_enabled': 'true', 'double_sided_copies': '1'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
mock_config_manager.save_config_atomic.assert_not_called()
|
||||||
|
|
||||||
|
def test_save_double_sided_invalid_axis_rejected(self, client, mock_config_manager):
|
||||||
|
"""An unknown axis is rejected with a 400 before any save."""
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/config/main',
|
||||||
|
data={'double_sided_enabled': 'true', 'double_sided_axis': 'diagonal'},
|
||||||
|
content_type='application/x-www-form-urlencoded',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
mock_config_manager.save_config_atomic.assert_not_called()
|
||||||
|
|
||||||
def test_get_secrets_config(self, client, mock_config_manager):
|
def test_get_secrets_config(self, client, mock_config_manager):
|
||||||
"""Test getting secrets configuration."""
|
"""Test getting secrets configuration."""
|
||||||
response = client.get('/api/v3/config/secrets')
|
response = client.get('/api/v3/config/secrets')
|
||||||
|
|||||||
@@ -773,6 +773,33 @@ def save_main_config():
|
|||||||
current_config['display']['dynamic_duration'] = {}
|
current_config['display']['dynamic_duration'] = {}
|
||||||
current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds'])
|
current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds'])
|
||||||
|
|
||||||
|
# Handle double-sided display settings
|
||||||
|
double_sided_fields = ['double_sided_enabled', 'double_sided_copies', 'double_sided_axis']
|
||||||
|
if any(k in data for k in double_sided_fields):
|
||||||
|
if 'display' not in current_config:
|
||||||
|
current_config['display'] = {}
|
||||||
|
if 'double_sided' not in current_config['display']:
|
||||||
|
current_config['display']['double_sided'] = {}
|
||||||
|
ds_config = current_config['display']['double_sided']
|
||||||
|
|
||||||
|
# Enabled checkbox: omitted from the form when unchecked.
|
||||||
|
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled'))
|
||||||
|
|
||||||
|
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
|
||||||
|
try:
|
||||||
|
copies = int(data['double_sided_copies'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
|
||||||
|
if not (2 <= copies <= 8):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
|
||||||
|
ds_config['copies'] = copies
|
||||||
|
|
||||||
|
if 'double_sided_axis' in data:
|
||||||
|
axis = data['double_sided_axis']
|
||||||
|
if axis not in ('horizontal', 'vertical'):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
|
||||||
|
ds_config['axis'] = axis
|
||||||
|
|
||||||
# Handle Vegas scroll mode settings
|
# Handle Vegas scroll mode settings
|
||||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
||||||
|
|||||||
@@ -258,6 +258,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Double-Sided Display -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="double_sided_enabled"
|
||||||
|
name="double_sided_enabled"
|
||||||
|
value="true"
|
||||||
|
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
||||||
|
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Mirror one screen across all panels.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies</label>
|
||||||
|
<input type="number"
|
||||||
|
id="double_sided_copies"
|
||||||
|
name="double_sided_copies"
|
||||||
|
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
|
||||||
|
min="2"
|
||||||
|
max="8"
|
||||||
|
class="form-control">
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Number of identical screens. Must divide the panel evenly.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis</label>
|
||||||
|
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
||||||
|
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
||||||
|
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Horizontal splits the chain; vertical splits parallel outputs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Display Options -->
|
<!-- Display Options -->
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-4">Display Options</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user