diff --git a/config/config.template.json b/config/config.template.json index e2347dfb..07a72b60 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -115,6 +115,11 @@ "gpio_slowdown": 3, "rp1_rio": 0 }, + "double_sided": { + "enabled": false, + "copies": 2, + "axis": "horizontal" + }, "display_durations": {}, "use_short_date_format": true, "vegas_scroll": { diff --git a/src/display_manager.py b/src/display_manager.py index f6ce9c6f..019c05c6 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -33,7 +33,7 @@ else: from contextlib import contextmanager from PIL import Image, ImageDraw, ImageFont import time -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional import logging import math import freetype @@ -42,6 +42,106 @@ import freetype logger = logging.getLogger(__name__) 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: """ Singleton hardware abstraction layer for the RGB LED matrix. @@ -76,6 +176,10 @@ class DisplayManager: self._suppress_test_pattern = suppress_test_pattern # When True, update_display() and clear() skip hardware writes (used during off-screen content capture) 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 # Avoids re-measuring the same string+font on every display() call. # Cleared on _load_fonts() so stale entries don't survive a font reload. @@ -168,13 +272,26 @@ class DisplayManager: # Initialize the matrix self.matrix = RGBMatrix(options=options) 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.current_canvas = self.matrix.CreateFrameCanvas() 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.draw = ImageDraw.Draw(self.image) 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)) cols = int(hardware_config.get('cols', 64)) chain_length = int(hardware_config.get('chain_length', 2)) + parallel = int(hardware_config.get('parallel', 1)) 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: fallback_width, fallback_height = 128, 32 @@ -364,6 +489,25 @@ class DisplayManager: finally: 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): """Update the display using double buffering with proper sync.""" try: @@ -377,8 +521,12 @@ class DisplayManager: if self._capture_mode_active: return # Skip hardware write — content is being captured off-screen - # Copy the current image to the offscreen canvas - self.offscreen_canvas.SetImage(self.image) + # 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) # Swap buffers immediately self.matrix.SwapOnVSync(self.offscreen_canvas) diff --git a/test/test_display_manager.py b/test/test_display_manager.py index 9f4406cd..63c49114 100644 --- a/test/test_display_manager.py +++ b/test/test_display_manager.py @@ -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 diff --git a/test/test_web_api.py b/test/test_web_api.py index 26653499..103ab989 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -141,9 +141,62 @@ class TestConfigAPI: data=json.dumps(invalid_config), content_type='application/json' ) - + 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): """Test getting secrets configuration.""" response = client.get('/api/v3/config/secrets') diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index f4e180ea..cc459fe9 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -773,6 +773,33 @@ def save_main_config(): current_config['display']['dynamic_duration'] = {} 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 vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins'] diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 2c42560f..ccbf2abf 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -258,6 +258,48 @@ + +
+

Double-Sided Display

+

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.

+ +
+
+ +

Mirror one screen across all panels.

+
+ +
+ + +

Number of identical screens. Must divide the panel evenly.

+
+ +
+ + +

Horizontal splits the chain; vertical splits parallel outputs.

+
+
+
+

Display Options