mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 14:18:06 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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": ""
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+21
-1
@@ -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', '')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user