mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 22:28: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
|
- Leave empty unless you need custom mapping
|
||||||
- See rpi-rgb-led-matrix documentation for full options
|
- 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)
|
- **`row_address_type`** (integer, default: 0)
|
||||||
- How rows are addressed on the panel
|
- How rows are addressed on the panel
|
||||||
- Most panels use 0 (direct addressing)
|
- Most panels use 0 (direct addressing)
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
"led_rgb_sequence": "RGB",
|
"led_rgb_sequence": "RGB",
|
||||||
"limit_refresh_rate_hz": 100,
|
"limit_refresh_rate_hz": 100,
|
||||||
"pixel_mapper_config": "",
|
"pixel_mapper_config": "",
|
||||||
|
"orientation": "normal",
|
||||||
"row_address_type": 0,
|
"row_address_type": 0,
|
||||||
"multiplexing": 0,
|
"multiplexing": 0,
|
||||||
"panel_type": ""
|
"panel_type": ""
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
|||||||
| `led_rgb_sequence` | string, `"RGB"` |
|
| `led_rgb_sequence` | string, `"RGB"` |
|
||||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate: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 |
|
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||||
|
|||||||
+21
-1
@@ -258,6 +258,26 @@ class DisplayManager:
|
|||||||
# Initialize managers
|
# Initialize managers
|
||||||
# Calendar manager is now initialized by DisplayController
|
# 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):
|
def _setup_matrix(self):
|
||||||
"""Initialize the RGB matrix with configuration settings."""
|
"""Initialize the RGB matrix with configuration settings."""
|
||||||
_init_error_str = None
|
_init_error_str = None
|
||||||
@@ -283,7 +303,7 @@ class DisplayManager:
|
|||||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
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.row_address_type = hardware_config.get('row_address_type', 0)
|
||||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||||
options.panel_type = hardware_config.get('panel_type', '')
|
options.panel_type = hardware_config.get('panel_type', '')
|
||||||
|
|||||||
@@ -237,3 +237,45 @@ class TestDisplayManagerDoubleSided:
|
|||||||
suppress_test_pattern=True)
|
suppress_test_pattern=True)
|
||||||
assert dm.set_brightness(70) is True
|
assert dm.set_brightness(70) is True
|
||||||
assert mock_rgb_matrix['matrix_instance'].brightness == 70
|
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',
|
'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',
|
'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',
|
'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 any(k in data for k in display_fields):
|
||||||
if 'display' not in current_config:
|
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):
|
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
|
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
|
# Validate row_address_type
|
||||||
if 'row_address_type' in data:
|
if 'row_address_type' in data:
|
||||||
try:
|
try:
|
||||||
@@ -844,7 +849,7 @@ def save_main_config():
|
|||||||
for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode',
|
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',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||||
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type',
|
'led_rgb_sequence', 'multiplexing', 'panel_type', 'row_address_type',
|
||||||
'pixel_mapper_config']:
|
'pixel_mapper_config', 'orientation']:
|
||||||
if field in data:
|
if field in data:
|
||||||
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
if field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'scan_mode',
|
||||||
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
'pwm_bits', 'pwm_dither_bits', 'pwm_lsb_nanoseconds', 'limit_refresh_rate_hz',
|
||||||
|
|||||||
@@ -117,6 +117,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<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>
|
<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">
|
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
||||||
|
|||||||
Reference in New Issue
Block a user