Add panel orientation setting for upside-down mounting (#455)

Adds a display.hardware.orientation config field ("normal" / "180")
so panels mounted upside down (e.g. to put the Pi/wiring on a more
convenient side) render correctly without custom pixel_mapper_config
edits. Composes onto the existing pixel_mapper_config as a trailing
"Rotate:180" mapper, so it stays independent of any custom mapper
string (e.g. U-mapper chain layouts) already in use.

Exposed as a "Panel Orientation" dropdown in the web UI's Display
settings, validated server-side, and documented in README and
CONFIG_REFERENCE.


Claude-Session: https://claude.ai/code/session_01FakipqMDHQLpsFjTuBdSFQ

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-08-12 09:40:48 -04:00
committed by GitHub
co-authored by Claude
parent 7171e6c022
commit fce1fdac57
7 changed files with 88 additions and 3 deletions
+8
View File
@@ -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)
+1
View File
@@ -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": ""
+1
View File
@@ -66,6 +66,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270295).
| `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 |
+21 -1
View File
@@ -258,6 +258,26 @@ class DisplayManager:
# Initialize managers
# Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" 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:<deg>" 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', '')
+42
View File
@@ -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'
+7 -2
View File
@@ -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',
@@ -117,6 +117,14 @@
</select>
</div>
<div class="form-group" id="setting-display-orientation" data-setting-key="display.hardware.orientation">
<label for="orientation" class="block text-sm font-medium text-gray-700">Panel Orientation{{ ui.help_tip('Rotates the rendered image to match how the panel is physically mounted.\nUse "Upside Down" if you flipped the panel 180° to move the Raspberry Pi / wiring to a more convenient side.', 'Panel Orientation') }}</label>
<select id="orientation" name="orientation" class="form-control">
<option value="normal" {% if main_config.display.hardware.get('orientation', 'normal') == "normal" %}selected{% endif %}>Normal</option>
<option value="180" {% if main_config.display.hardware.get('orientation', 'normal') == "180" %}selected{% endif %}>Upside Down (180°)</option>
</select>
</div>
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">