diff --git a/README.md b/README.md index a23b6720..681176ef 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,14 @@ These settings are typically only needed for non-standard panels or custom confi - Leave empty unless you need custom mapping - See rpi-rgb-led-matrix documentation for full options +- **`orientation`** (string, default: "normal") + - Rotates the rendered image to match how the panel is physically mounted + - 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 + - Applied independently of `pixel_mapper_config` (appended as a trailing + `Rotate:180` mapper), so custom mapper configs keep working alongside it + - **`row_address_type`** (integer, default: 0) - How rows are addressed on the panel - Most panels use 0 (direct addressing) diff --git a/config/config.template.json b/config/config.template.json index 9edbec18..59bfdb34 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -112,6 +112,7 @@ "led_rgb_sequence": "RGB", "limit_refresh_rate_hz": 100, "pixel_mapper_config": "", + "orientation": "normal", "row_address_type": 0, "multiplexing": 0, "panel_type": "" diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index ff623439..ff1202fb 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -66,6 +66,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295). | `led_rgb_sequence` | string, `"RGB"` | | `limit_refresh_rate_hz` | int, `100` (code default 90) | | `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 | | `row_address_type` | int, `0` — non-standard panel row addressing | | `multiplexing` | int, `0` — panel multiplexing scheme | | `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init | diff --git a/src/display_manager.py b/src/display_manager.py index d96fef35..c33e0861 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -258,6 +258,26 @@ class DisplayManager: # Initialize managers # Calendar manager is now initialized by DisplayController + # Orientation setting -> rpi-rgb-led-matrix "Rotate:" 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} + + def _build_pixel_mapper_config(self, hardware_config: dict) -> str: + """Compose the raw pixel_mapper_config string 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:" mapper rather than overwriting any existing config. + """ + 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 + def _setup_matrix(self): """Initialize the RGB matrix with configuration settings.""" _init_error_str = None @@ -283,7 +303,7 @@ class DisplayManager: options.pwm_bits = hardware_config.get('pwm_bits', 10) options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150) options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB') - options.pixel_mapper_config = hardware_config.get('pixel_mapper_config', '') + options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config) options.row_address_type = hardware_config.get('row_address_type', 0) options.multiplexing = hardware_config.get('multiplexing', 0) options.panel_type = hardware_config.get('panel_type', '') diff --git a/test/test_display_manager.py b/test/test_display_manager.py index 9c8b1468..9ccb7773 100644 --- a/test/test_display_manager.py +++ b/test/test_display_manager.py @@ -237,3 +237,45 @@ class TestDisplayManagerDoubleSided: 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' diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 6062b85d..06fef117 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -796,7 +796,7 @@ def save_main_config(): 'gpio_slowdown', 'rp1_rio', 'scan_mode', 'disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate', 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'use_short_date_format', 'max_dynamic_duration_seconds', 'led_rgb_sequence', 'multiplexing', 'panel_type', - 'row_address_type', 'pixel_mapper_config'] + 'row_address_type', 'pixel_mapper_config', 'orientation'] if any(k in data for k in display_fields): if 'display' not in current_config: @@ -831,6 +831,11 @@ def save_main_config(): if 'pixel_mapper_config' in data and not isinstance(data['pixel_mapper_config'], str): 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'} + 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 + # Validate row_address_type if 'row_address_type' in data: try: @@ -844,7 +849,7 @@ def save_main_config(): for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode', 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', 'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type', - 'pixel_mapper_config']: + 'pixel_mapper_config', 'orientation']: if field in data: if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode', 'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz', diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 1e2c423a..95e64d33 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -117,6 +117,14 @@ +
+ + +
+