Add OpenRGB per-zone LED control with separate/combined modes and zone preview

- Zone picker UI in device add/settings modals with per-zone checkbox selection
- Combined mode: pixels distributed sequentially across zones
- Separate mode: full effect resampled independently to each zone via linear interpolation
- Per-zone LED preview in target cards: one canvas strip per zone with hover overlay labels
- Zone badges on device cards enriched with actual LED counts from OpenRGB API
- Fix stale led_count by using device_led_count discovered at connect time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 20:35:51 +03:00
parent aafcf83896
commit 52ee4bdeb6
19 changed files with 769 additions and 55 deletions

View File

@@ -58,6 +58,8 @@ class DeviceState:
test_calibration: Optional[CalibrationConfig] = None
# Tracked power state for serial devices (no hardware query)
power_on: bool = True
# OpenRGB zone mode: "combined" or "separate"
zone_mode: str = "combined"
class ProcessorManager:
@@ -160,6 +162,7 @@ class ProcessorManager:
test_mode_active=ds.test_mode_active,
send_latency_ms=send_latency_ms,
rgbw=rgbw,
zone_mode=ds.zone_mode,
)
# ===== EVENT SYSTEM (state change notifications) =====
@@ -200,6 +203,7 @@ class ProcessorManager:
baud_rate: Optional[int] = None,
software_brightness: int = 255,
auto_shutdown: bool = False,
zone_mode: str = "combined",
):
"""Register a device for health monitoring."""
if device_id in self._devices:
@@ -213,6 +217,7 @@ class ProcessorManager:
baud_rate=baud_rate,
software_brightness=software_brightness,
auto_shutdown=auto_shutdown,
zone_mode=zone_mode,
)
self._devices[device_id] = state

View File

@@ -74,6 +74,7 @@ class DeviceInfo:
test_mode_active: bool = False
send_latency_ms: int = 0
rgbw: bool = False
zone_mode: str = "combined"
@dataclass

View File

@@ -65,6 +65,7 @@ class WledTargetProcessor(TargetProcessor):
self._overlay_active = False
self._needs_keepalive = True
self._effective_led_count: int = 0
self._resolved_display_index: Optional[int] = None
# Fit-to-device linspace cache (per-instance to avoid cross-target thrash)
@@ -106,11 +107,24 @@ class WledTargetProcessor(TargetProcessor):
baud_rate=device_info.baud_rate,
send_latency_ms=device_info.send_latency_ms,
rgbw=device_info.rgbw,
zone_mode=device_info.zone_mode,
)
await self._led_client.connect()
# Use client-reported LED count if available (more accurate than stored)
client_led_count = self._led_client.device_led_count
effective_led_count = client_led_count if client_led_count and client_led_count > 0 else device_info.led_count
self._effective_led_count = effective_led_count
if effective_led_count != device_info.led_count:
logger.info(
f"Target {self._target_id}: device reports {effective_led_count} LEDs "
f"(stored: {device_info.led_count}), using actual count"
)
logger.info(
f"Target {self._target_id} connected to {device_info.device_type} "
f"device ({device_info.led_count} LEDs)"
f"device ({effective_led_count} LEDs)"
)
self._device_state_before = await self._led_client.snapshot_device_state()
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type)
@@ -132,8 +146,8 @@ class WledTargetProcessor(TargetProcessor):
try:
stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id)
if hasattr(stream, "configure") and device_info.led_count > 0:
stream.configure(device_info.led_count)
if hasattr(stream, "configure") and self._effective_led_count > 0:
stream.configure(self._effective_led_count)
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
self._resolved_display_index = getattr(stream, "display_index", None)
@@ -254,7 +268,7 @@ class WledTargetProcessor(TargetProcessor):
return
device_info = self._ctx.get_device_info(self._device_id)
device_leds = device_info.led_count if device_info else 0
device_leds = getattr(self, '_effective_led_count', None) or (device_info.led_count if device_info else 0)
# Release old stream
if self._css_stream is not None and old_css_id:
@@ -533,7 +547,7 @@ class WledTargetProcessor(TargetProcessor):
prev_frame_time_stamp = time.perf_counter()
loop = asyncio.get_running_loop()
_init_device_info = self._ctx.get_device_info(self._device_id)
_total_leds = _init_device_info.led_count if _init_device_info else 0
_total_leds = getattr(self, '_effective_led_count', None) or (_init_device_info.led_count if _init_device_info else 0)
# Stream reference — re-read each tick to detect hot-swaps
stream = self._css_stream