From 52ee4bdeb6d35766fcfc8b0a231e31312bc2cf19 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 20:35:51 +0300 Subject: [PATCH] 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 --- .../src/wled_controller/api/routes/devices.py | 62 +++++++- .../wled_controller/api/schemas/devices.py | 18 +++ .../core/devices/led_client.py | 5 + .../core/devices/openrgb_client.py | 122 +++++++++++++--- .../core/devices/openrgb_provider.py | 43 ++++-- .../core/processing/processor_manager.py | 5 + .../core/processing/target_processor.py | 1 + .../core/processing/wled_target_processor.py | 24 ++- .../src/wled_controller/static/css/cards.css | 95 ++++++++++++ .../static/js/features/device-discovery.js | 138 +++++++++++++++++- .../static/js/features/devices.js | 112 +++++++++++++- .../static/js/features/targets.js | 110 +++++++++++++- .../wled_controller/static/locales/en.json | 9 ++ .../wled_controller/static/locales/ru.json | 9 ++ .../wled_controller/static/locales/zh.json | 9 ++ server/src/wled_controller/static/sw.js | 2 +- .../wled_controller/storage/device_store.py | 10 ++ .../templates/modals/add-device.html | 25 ++++ .../templates/modals/device-settings.html | 25 ++++ 19 files changed, 769 insertions(+), 55 deletions(-) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 67196b0..e107660 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -24,6 +24,8 @@ from wled_controller.api.schemas.devices import ( DeviceUpdate, DiscoveredDeviceResponse, DiscoverDevicesResponse, + OpenRGBZoneResponse, + OpenRGBZonesResponse, ) from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore @@ -48,6 +50,7 @@ def _device_to_response(device) -> DeviceResponse: auto_shutdown=device.auto_shutdown, send_latency_ms=device.send_latency_ms, rgbw=device.rgbw, + zone_mode=device.zone_mode, capabilities=sorted(get_device_capabilities(device.device_type)), created_at=device.created_at, updated_at=device.updated_at, @@ -122,6 +125,7 @@ async def create_device( auto_shutdown=auto_shutdown, send_latency_ms=device_data.send_latency_ms or 0, rgbw=device_data.rgbw or False, + zone_mode=device_data.zone_mode or "combined", ) # WS devices: auto-set URL to ws://{device_id} @@ -137,6 +141,7 @@ async def create_device( device_type=device.device_type, baud_rate=device.baud_rate, auto_shutdown=device.auto_shutdown, + zone_mode=device.zone_mode, ) return _device_to_response(device) @@ -213,6 +218,53 @@ async def discover_devices( ) +@router.get("/api/v1/devices/openrgb-zones", response_model=OpenRGBZonesResponse, tags=["Devices"]) +async def get_openrgb_zones( + _auth: AuthRequired, + url: str = Query(..., description="Base OpenRGB URL (e.g. openrgb://localhost:6742/0)"), +): + """List available zones on an OpenRGB device.""" + import asyncio + + from wled_controller.core.devices.openrgb_client import parse_openrgb_url + + host, port, device_index, _zones = parse_openrgb_url(url) + + def _fetch_zones(): + from openrgb import OpenRGBClient + + client = OpenRGBClient(host, port, name="WLED Controller (zones)") + try: + devices = client.devices + if device_index >= len(devices): + raise ValueError( + f"Device index {device_index} out of range " + f"(server has {len(devices)} device(s))" + ) + device = devices[device_index] + zone_type_map = {0: "single", 1: "linear", 2: "matrix"} + zones = [] + for z in device.zones: + zt = zone_type_map.get(getattr(z, "type", -1), "unknown") + zones.append(OpenRGBZoneResponse( + name=z.name, + led_count=len(z.leds), + zone_type=zt, + )) + return device.name, zones + finally: + client.disconnect() + + try: + device_name, zones = await asyncio.to_thread(_fetch_zones) + return OpenRGBZonesResponse(device_name=device_name, zones=zones) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + except Exception as e: + logger.error(f"Failed to list OpenRGB zones: {e}") + raise HTTPException(status_code=502, detail=f"Cannot reach OpenRGB server: {e}") + + @router.get("/api/v1/devices/batch/states", tags=["Devices"]) async def batch_device_states( _auth: AuthRequired, @@ -255,6 +307,7 @@ async def update_device( auto_shutdown=update_data.auto_shutdown, send_latency_ms=update_data.send_latency_ms, rgbw=update_data.rgbw, + zone_mode=update_data.zone_mode, ) # Sync connection info in processor manager @@ -268,9 +321,12 @@ async def update_device( except ValueError: pass - # Sync auto_shutdown in runtime state - if update_data.auto_shutdown is not None and device_id in manager._devices: - manager._devices[device_id].auto_shutdown = update_data.auto_shutdown + # Sync auto_shutdown and zone_mode in runtime state + if device_id in manager._devices: + if update_data.auto_shutdown is not None: + manager._devices[device_id].auto_shutdown = update_data.auto_shutdown + if update_data.zone_mode is not None: + manager._devices[device_id].zone_mode = update_data.zone_mode return _device_to_response(device) diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 6bdb4ea..73d373a 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -17,6 +17,7 @@ class DeviceCreate(BaseModel): auto_shutdown: Optional[bool] = Field(default=None, description="Turn off device when server stops (defaults to true for adalight)") send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)") rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") + zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") class DeviceUpdate(BaseModel): @@ -30,6 +31,7 @@ class DeviceUpdate(BaseModel): auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops") send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)") rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") + zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") class Calibration(BaseModel): @@ -99,6 +101,7 @@ class DeviceResponse(BaseModel): auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop") send_latency_ms: int = Field(default=0, description="Simulated send latency in ms (mock devices)") rgbw: bool = Field(default=False, description="RGBW mode (mock devices)") + zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate") capabilities: List[str] = Field(default_factory=list, description="Device type capabilities") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") @@ -149,3 +152,18 @@ class DiscoverDevicesResponse(BaseModel): devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices") count: int = Field(description="Total devices found") scan_duration_ms: float = Field(description="How long the scan took in milliseconds") + + +class OpenRGBZoneResponse(BaseModel): + """A single zone on an OpenRGB device.""" + + name: str = Field(description="Zone name (e.g. JRAINBOW2)") + led_count: int = Field(description="Number of LEDs in this zone") + zone_type: str = Field(description="Zone type (linear, single, matrix)") + + +class OpenRGBZonesResponse(BaseModel): + """Response from OpenRGB zone listing.""" + + device_name: str = Field(description="OpenRGB device name") + zones: List[OpenRGBZoneResponse] = Field(description="Available zones") diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index 10cf290..eba010a 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -101,6 +101,11 @@ class LEDClient(ABC): """ raise NotImplementedError("send_pixels_fast not supported for this device type") + @property + def device_led_count(self) -> Optional[int]: + """Actual LED count discovered after connect(). None if not available.""" + return None + async def snapshot_device_state(self) -> Optional[dict]: """Snapshot device state before streaming starts. diff --git a/server/src/wled_controller/core/devices/openrgb_client.py b/server/src/wled_controller/core/devices/openrgb_client.py index 3f1f00f..a5f760e 100644 --- a/server/src/wled_controller/core/devices/openrgb_client.py +++ b/server/src/wled_controller/core/devices/openrgb_client.py @@ -13,20 +13,31 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) -def parse_openrgb_url(url: str) -> Tuple[str, int, int]: - """Parse an openrgb:// URL into (host, port, device_index). +def parse_openrgb_url(url: str) -> Tuple[str, int, int, List[str]]: + """Parse an openrgb:// URL into (host, port, device_index, zone_names). - Format: openrgb://host:port/device_index - Defaults: host=localhost, port=6742, device_index=0 + Format: openrgb://host:port/device_index[/zone1+zone2+...] + Defaults: host=localhost, port=6742, device_index=0, zone_names=[] + + When *zone_names* is non-empty, only LEDs in those zones are addressed. + Multiple zones are separated by ``+``. """ + zones_str: Optional[str] = None + if url.startswith("openrgb://"): url = url[len("openrgb://"):] else: - return ("localhost", 6742, 0) + return ("localhost", 6742, 0, []) # Split path from host:port if "/" in url: - host_port, index_str = url.split("/", 1) + host_port, path = url.split("/", 1) + # path may be "0" or "0/JRAINBOW2" or "0/JRAINBOW1+JRAINBOW2" + if "/" in path: + index_str, zones_str = path.split("/", 1) + zones_str = zones_str.strip() or None + else: + index_str = path try: device_index = int(index_str) except ValueError: @@ -46,7 +57,11 @@ def parse_openrgb_url(url: str) -> Tuple[str, int, int]: host = host_port if host_port else "localhost" port = 6742 - return (host, port, device_index) + zone_names: List[str] = [] + if zones_str: + zone_names = [z.strip() for z in zones_str.split("+") if z.strip()] + + return (host, port, device_index, zone_names) class OpenRGBLEDClient(LEDClient): @@ -61,10 +76,12 @@ class OpenRGBLEDClient(LEDClient): def __init__(self, url: str, **kwargs): self._url = url - host, port, device_index = parse_openrgb_url(url) + host, port, device_index, zone_names = parse_openrgb_url(url) self._host = host self._port = port self._device_index = device_index + self._zone_filters: List[str] = zone_names # e.g. ["JRAINBOW2"] + self._zone_mode: str = kwargs.pop("zone_mode", "combined") self._client: Any = None # openrgb.OpenRGBClient self._device: Any = None # openrgb.Device self._connected = False @@ -77,10 +94,38 @@ class OpenRGBLEDClient(LEDClient): self._client, self._device = await asyncio.to_thread(self._connect_sync) self._connected = True self._device_name = self._device.name - self._device_led_count = len(self._device.leds) + + # Resolve zone filter → target zones list + all_zone_info = ", ".join( + f"{z.name}({len(z.leds)})" for z in self._device.zones + ) + if self._zone_filters: + # Case-insensitive match by name(s) + filt_set = {n.lower() for n in self._zone_filters} + self._target_zones = [ + z for z in self._device.zones + if z.name.lower() in filt_set + ] + if not self._target_zones: + avail = [z.name for z in self._device.zones] + raise ValueError( + f"Zone(s) {self._zone_filters} not found on device " + f"'{self._device.name}'. Available zones: {avail}" + ) + else: + self._target_zones = list(self._device.zones) + + self._zone_sizes: List[int] = [len(z.leds) for z in self._target_zones] + self._device_led_count = sum(self._zone_sizes) + + target_info = ", ".join( + f"{z.name}({len(z.leds)})" for z in self._target_zones + ) logger.info( f"Connected to OpenRGB device '{self._device_name}' " - f"({self._device_led_count} LEDs) at {self._host}:{self._port}/{self._device_index}" + f"(all zones: {all_zone_info}) " + f"targeting {target_info} = {self._device_led_count} LEDs " + f"at {self._host}:{self._port}/{self._device_index}" ) return True except Exception as e: @@ -131,6 +176,10 @@ class OpenRGBLEDClient(LEDClient): def is_connected(self) -> bool: return self._connected and self._client is not None + @property + def device_led_count(self) -> Optional[int]: + return self._device_led_count + async def send_pixels( self, pixels: Union[List[Tuple[int, int, int]], np.ndarray], @@ -158,8 +207,13 @@ class OpenRGBLEDClient(LEDClient): ) -> None: """Synchronous fire-and-forget send for the processing hot loop. - Converts numpy (N,3) array to List[RGBColor] and calls - device.set_colors(colors, fast=True) to skip the re-fetch round trip. + Converts numpy (N,3) array to List[RGBColor] and distributes colors + across target zones using zone-level updateZoneLeds packets. This is + more compatible than device-level updateLeds — some motherboards (e.g. + MSI) only respond to zone-level commands. + + When a zone filter is configured (e.g. openrgb://…/0/JRAINBOW2), + only that zone receives colors; other zones are left untouched. """ if not self.is_connected or self._device is None: return @@ -176,19 +230,45 @@ class OpenRGBLEDClient(LEDClient): if brightness < 255: pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8) - # Truncate or pad to match device LED count - n_device = len(self._device.leds) + # Truncate or pad to match target LED count + n_target = self._device_led_count n_pixels = len(pixel_array) - if n_pixels > n_device: - pixel_array = pixel_array[:n_device] + if n_pixels > n_target: + pixel_array = pixel_array[:n_target] + # Separate mode: resample full pixel array independently per zone + if self._zone_mode == "separate" and len(self._target_zones) > 1: + n_src = len(pixel_array) + if n_src < 2: + # Single pixel — replicate to all zones + c = pixel_array[0] if n_src == 1 else np.array([0, 0, 0], dtype=np.uint8) + color_obj = RGBColor(int(c[0]), int(c[1]), int(c[2])) + for zone, zone_size in zip(self._target_zones, self._zone_sizes): + zone.set_colors([color_obj] * zone_size, fast=True) + else: + src_indices = np.linspace(0, 1, n_src) + for zone, zone_size in zip(self._target_zones, self._zone_sizes): + dst_indices = np.linspace(0, 1, zone_size) + resampled = np.column_stack([ + np.interp(dst_indices, src_indices, pixel_array[:, ch]) + for ch in range(3) + ]).astype(np.uint8) + colors = [RGBColor(int(r), int(g), int(b)) for r, g, b in resampled] + zone.set_colors(colors, fast=True) + return + + # Combined mode: distribute pixels sequentially across zones colors = [RGBColor(int(r), int(g), int(b)) for r, g, b in pixel_array] - # Pad with black if fewer pixels than device LEDs - if len(colors) < n_device: - colors.extend([RGBColor(0, 0, 0)] * (n_device - len(colors))) + # Pad with black if fewer pixels than target LEDs + if len(colors) < n_target: + colors.extend([RGBColor(0, 0, 0)] * (n_target - len(colors))) - self._device.set_colors(colors, fast=True) + offset = 0 + for zone, zone_size in zip(self._target_zones, self._zone_sizes): + zone_colors = colors[offset:offset + zone_size] + zone.set_colors(zone_colors, fast=True) + offset += zone_size except Exception as e: logger.error(f"OpenRGB send_pixels_fast failed: {e}") self._connected = False @@ -227,7 +307,7 @@ class OpenRGBLEDClient(LEDClient): Uses a lightweight socket probe instead of full library init to avoid re-downloading all device data every health check cycle (~30s). """ - host, port, device_index = parse_openrgb_url(url) + host, port, device_index, _zones = parse_openrgb_url(url) start = asyncio.get_event_loop().time() try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/server/src/wled_controller/core/devices/openrgb_provider.py b/server/src/wled_controller/core/devices/openrgb_provider.py index b02b37b..ece2d28 100644 --- a/server/src/wled_controller/core/devices/openrgb_provider.py +++ b/server/src/wled_controller/core/devices/openrgb_provider.py @@ -30,11 +30,12 @@ class OpenRGBDeviceProvider(LEDDeviceProvider): return {"health_check", "auto_restore", "static_color"} def create_client(self, url: str, **kwargs) -> LEDClient: + zone_mode = kwargs.pop("zone_mode", "combined") kwargs.pop("led_count", None) kwargs.pop("baud_rate", None) kwargs.pop("send_latency_ms", None) kwargs.pop("rgbw", None) - return OpenRGBLEDClient(url, **kwargs) + return OpenRGBLEDClient(url, zone_mode=zone_mode, **kwargs) async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: return await OpenRGBLEDClient.check_health(url, http_client, prev_health) @@ -48,7 +49,7 @@ class OpenRGBDeviceProvider(LEDDeviceProvider): Raises: Exception on validation failure. """ - host, port, device_index = parse_openrgb_url(url) + host, port, device_index, zone_names = parse_openrgb_url(url) def _validate_sync(): from openrgb import OpenRGBClient @@ -62,10 +63,26 @@ class OpenRGBDeviceProvider(LEDDeviceProvider): f"(server has {len(devices)} device(s))" ) device = devices[device_index] - led_count = len(device.leds) - logger.info( - f"OpenRGB device validated: '{device.name}' ({led_count} LEDs)" - ) + + if zone_names: + filt_set = {n.lower() for n in zone_names} + matching = [z for z in device.zones if z.name.lower() in filt_set] + if not matching: + avail = [z.name for z in device.zones] + raise ValueError( + f"Zone(s) {zone_names} not found on '{device.name}'. " + f"Available zones: {avail}" + ) + led_count = sum(len(z.leds) for z in matching) + logger.info( + f"OpenRGB device validated: '{device.name}' " + f"zones {zone_names} ({led_count} LEDs)" + ) + else: + led_count = len(device.leds) + logger.info( + f"OpenRGB device validated: '{device.name}' ({led_count} LEDs)" + ) return {"led_count": led_count} finally: client.disconnect() @@ -111,8 +128,8 @@ class OpenRGBDeviceProvider(LEDDeviceProvider): return await asyncio.to_thread(_discover_sync) async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: - """Set all LEDs on the OpenRGB device to a solid color.""" - host, port, device_index = parse_openrgb_url(url) + """Set all LEDs on the OpenRGB device (or target zone) to a solid color.""" + host, port, device_index, zone_names = parse_openrgb_url(url) def _set_color_sync(): from openrgb import OpenRGBClient @@ -125,7 +142,15 @@ class OpenRGBDeviceProvider(LEDDeviceProvider): raise ValueError(f"Device index {device_index} out of range") device = devices[device_index] color_obj = RGBColor(color[0], color[1], color[2]) - device.set_color(color_obj) + # Use zone-level calls for better motherboard compatibility + if zone_names: + filt_set = {n.lower() for n in zone_names} + for zone in device.zones: + if zone.name.lower() in filt_set: + zone.set_color(color_obj) + else: + for zone in device.zones: + zone.set_color(color_obj) finally: client.disconnect() diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index d9dd2c9..20c794a 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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 diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 5604677..9bfbee7 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -74,6 +74,7 @@ class DeviceInfo: test_mode_active: bool = False send_latency_ms: int = 0 rgbw: bool = False + zone_mode: str = "combined" @dataclass diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 1392ebb..e3cddcf 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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 diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 92e02b2..a2584e5 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -443,6 +443,62 @@ body.cs-drag-active .card-drag-handle { animation: spin 0.8s linear infinite; } +/* OpenRGB zone checkboxes */ +.zone-checkbox-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow-y: auto; + padding: 4px 0; +} +.zone-checkbox-list .zone-loading, +.zone-checkbox-list .zone-error { + font-size: 12px; + color: var(--text-secondary); + padding: 4px 0; +} +.zone-checkbox-list .zone-error { color: var(--danger-color, #e53935); } +.zone-checkbox-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: background 0.15s; +} +.zone-checkbox-item:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); } +.zone-checkbox-item input[type="checkbox"] { margin: 0; flex-shrink: 0; } +.zone-checkbox-item .zone-led-count { + margin-left: auto; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +.zone-mode-radios { + display: flex; + gap: 16px; +} +.zone-mode-option { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 13px; +} +.zone-mode-option input[type="radio"] { margin: 0; } + +.zone-badge { + font-size: 10px; + padding: 1px 5px; + border-radius: 3px; + background: var(--border-color); + font-weight: 600; +} + .channel-indicator { display: inline-flex; gap: 2px; @@ -792,3 +848,42 @@ ul.section-tip li { pointer-events: none; opacity: 0.8; } + +/* Per-zone LED preview (OpenRGB separate mode) */ +.led-preview-zones { + display: flex; + flex-direction: column; + gap: 2px; +} + +.led-preview-zone { + position: relative; +} + +.led-preview-zone-canvas { + display: block; + width: 100%; + height: 18px; + border-radius: 2px; + image-rendering: pixelated; + background: #111; +} + +.led-preview-zone-label { + position: absolute; + left: 4px; + top: 50%; + transform: translateY(-50%); + font-size: 0.65rem; + font-family: var(--font-mono, monospace); + color: #fff; + text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + white-space: nowrap; +} + +.led-preview-zones:hover .led-preview-zone-label { + opacity: 1; +} diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index d0f5351..0634318 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -25,6 +25,8 @@ class AddDeviceModal extends Modal { baudRate: document.getElementById('device-baud-rate').value, ledType: document.getElementById('device-led-type')?.value || 'rgb', sendLatency: document.getElementById('device-send-latency')?.value || '0', + zones: _getCheckedZones('device-zone-list'), + zoneMode: _getZoneMode(), }; } } @@ -47,8 +49,14 @@ export function onDeviceTypeChanged() { const urlLabel = document.getElementById('device-url-label'); const urlHint = document.getElementById('device-url-hint'); + const zoneGroup = document.getElementById('device-zone-group'); const scanBtn = document.getElementById('scan-network-btn'); + // Hide zone group + mode group by default (shown only for openrgb) + if (zoneGroup) zoneGroup.style.display = 'none'; + const zoneModeGroup = document.getElementById('device-zone-mode-group'); + if (zoneModeGroup) zoneModeGroup.style.display = 'none'; + if (isMqttDevice(deviceType)) { // MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery urlGroup.style.display = ''; @@ -121,6 +129,7 @@ export function onDeviceTypeChanged() { if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; if (scanBtn) scanBtn.style.display = ''; + if (zoneGroup) zoneGroup.style.display = ''; if (urlLabel) urlLabel.textContent = t('device.openrgb.url'); if (urlHint) urlHint.textContent = t('device.openrgb.url.hint'); urlInput.placeholder = 'openrgb://localhost:6742/0'; @@ -354,6 +363,10 @@ export function selectDiscoveredDevice(device) { } else { document.getElementById('device-url').value = device.url; } + // Fetch zones for OpenRGB devices + if (isOpenrgbDevice(device.device_type)) { + _fetchOpenrgbZones(device.url, 'device-zone-list'); + } showToast(t('device.scan.selected'), 'info'); } @@ -380,12 +393,20 @@ export async function handleAddDevice(event) { url = 'mqtt://' + url; } + // OpenRGB: append selected zones to URL + const checkedZones = isOpenrgbDevice(deviceType) ? _getCheckedZones('device-zone-list') : []; + if (isOpenrgbDevice(deviceType) && checkedZones.length > 0) { + url = _appendZonesToUrl(url, checkedZones); + } + if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) { error.textContent = t('device_discovery.error.fill_all_fields'); error.style.display = 'block'; return; } + const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); + try { const body = { name, url, device_type: deviceType }; const ledCountInput = document.getElementById('device-led-count'); @@ -402,10 +423,10 @@ export async function handleAddDevice(event) { const ledType = document.getElementById('device-led-type')?.value; body.rgbw = ledType === 'rgbw'; } - const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); - if (lastTemplateId) { - body.capture_template_id = lastTemplateId; + if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) { + body.zone_mode = _getZoneMode(); } + if (lastTemplateId) body.capture_template_id = lastTemplateId; const response = await fetchWithAuth('/devices', { method: 'POST', @@ -417,9 +438,7 @@ export async function handleAddDevice(event) { console.log('Device added successfully:', result); showToast(t('device_discovery.added'), 'success'); addDeviceModal.forceClose(); - // Use window.* to avoid circular imports if (typeof window.loadDevices === 'function') await window.loadDevices(); - // Auto-start device tutorial on first device add if (!localStorage.getItem('deviceTutorialSeen')) { localStorage.setItem('deviceTutorialSeen', '1'); setTimeout(() => { @@ -438,3 +457,112 @@ export async function handleAddDevice(event) { showToast(t('device_discovery.error.add_failed'), 'error'); } } + +// ===== OpenRGB zone helpers ===== + +/** + * Fetch zones for an OpenRGB device URL and render checkboxes in the given container. + * @param {string} baseUrl - Base OpenRGB URL (e.g. openrgb://localhost:6742/0) + * @param {string} containerId - ID of the zone checkbox list container + * @param {string[]} [preChecked=[]] - Zone names to pre-check + */ +export async function _fetchOpenrgbZones(baseUrl, containerId, preChecked = []) { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = `${t('device.openrgb.zone.loading')}`; + + try { + const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + container.innerHTML = `${err.detail || t('device.openrgb.zone.error')}`; + return; + } + const data = await resp.json(); + _renderZoneCheckboxes(container, data.zones, preChecked); + } catch (err) { + if (err.isAuth) return; + container.innerHTML = `${t('device.openrgb.zone.error')}`; + } +} + +function _renderZoneCheckboxes(container, zones, preChecked = []) { + container.innerHTML = ''; + container._zonesData = zones; + const preSet = new Set(preChecked.map(n => n.toLowerCase())); + + zones.forEach(zone => { + const label = document.createElement('label'); + label.className = 'zone-checkbox-item'; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = zone.name; + if (preSet.has(zone.name.toLowerCase())) cb.checked = true; + cb.addEventListener('change', () => _updateZoneModeVisibility(container.id)); + + const nameSpan = document.createElement('span'); + nameSpan.textContent = zone.name; + + const countSpan = document.createElement('span'); + countSpan.className = 'zone-led-count'; + countSpan.textContent = `${zone.led_count} LEDs`; + + label.appendChild(cb); + label.appendChild(nameSpan); + label.appendChild(countSpan); + container.appendChild(label); + }); + + _updateZoneModeVisibility(container.id); +} + +export function _getCheckedZones(containerId) { + const container = document.getElementById(containerId); + if (!container) return []; + return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) + .map(cb => cb.value); +} + +/** + * Split an OpenRGB URL into base URL (without zones) and zone names. + * E.g. "openrgb://localhost:6742/0/JRAINBOW1+JRAINBOW2" → { baseUrl: "openrgb://localhost:6742/0", zones: ["JRAINBOW1","JRAINBOW2"] } + */ +export function _splitOpenrgbZone(url) { + if (!url || !url.startsWith('openrgb://')) return { baseUrl: url, zones: [] }; + const stripped = url.slice('openrgb://'.length); + const parts = stripped.split('/'); + // parts: [host:port, device_index, ...zone_str] + if (parts.length >= 3) { + const zoneStr = parts.slice(2).join('/'); + const zones = zoneStr.split('+').map(z => z.trim()).filter(Boolean); + const baseUrl = 'openrgb://' + parts[0] + '/' + parts[1]; + return { baseUrl, zones }; + } + return { baseUrl: url, zones: [] }; +} + +function _appendZonesToUrl(baseUrl, zones) { + // Strip any existing zone suffix + const { baseUrl: clean } = _splitOpenrgbZone(baseUrl); + return clean + '/' + zones.join('+'); +} + +/** Show/hide zone mode toggle based on how many zones are checked. */ +export function _updateZoneModeVisibility(containerId) { + const modeGroupId = containerId === 'device-zone-list' ? 'device-zone-mode-group' + : containerId === 'settings-zone-list' ? 'settings-zone-mode-group' + : null; + if (!modeGroupId) return; + const modeGroup = document.getElementById(modeGroupId); + if (!modeGroup) return; + const checkedCount = _getCheckedZones(containerId).length; + modeGroup.style.display = checkedCount >= 2 ? '' : 'none'; +} + +/** Get the selected zone mode radio value ('combined' or 'separate'). */ +export function _getZoneMode(radioName = 'device-zone-mode') { + const radio = document.querySelector(`input[name="${radioName}"]:checked`); + return radio ? radio.value : 'combined'; +} diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index d43d285..de7abc8 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -6,6 +6,7 @@ import { _deviceBrightnessCache, updateDeviceBrightness, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js'; +import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -27,6 +28,8 @@ class DeviceSettingsModal extends Modal { led_count: this.$('settings-led-count').value, led_type: document.getElementById('settings-led-type')?.value || 'rgb', send_latency: document.getElementById('settings-send-latency')?.value || '0', + zones: _getCheckedZones('settings-zone-list'), + zoneMode: _getZoneMode('settings-zone-mode'), }; } @@ -42,7 +45,16 @@ class DeviceSettingsModal extends Modal { if (isSerialDevice(this.deviceType)) { return this.$('settings-serial-port').value; } - return this.$('settings-device-url').value.trim(); + let url = this.$('settings-device-url').value.trim(); + // Append selected zones for OpenRGB + if (isOpenrgbDevice(this.deviceType)) { + const zones = _getCheckedZones('settings-zone-list'); + if (zones.length > 0) { + const { baseUrl } = _splitOpenrgbZone(url); + url = baseUrl + '/' + zones.join('+'); + } + } + return url; } } @@ -78,6 +90,10 @@ export function createDeviceCard(device) { const ledCount = state.device_led_count || device.led_count; + // Parse zone names from OpenRGB URL for badge display + const openrgbZones = isOpenrgbDevice(device.device_type) + ? _splitOpenrgbZone(device.url).zones : []; + return wrapCard({ dataAttr: 'data-device-id', id: device.id, @@ -89,13 +105,15 @@ export function createDeviceCard(device) {
${device.name || device.id} - ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} + ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} ${healthLabel}
${(device.device_type || 'wled').toUpperCase()} - ${ledCount ? `${ICON_LED} ${ledCount}` : ''} + ${openrgbZones.length + ? openrgbZones.map(z => `${ICON_LED} ${escapeHtml(z)}`).join('') + : (ledCount ? `${ICON_LED} ${ledCount}` : '')} ${state.device_led_type ? `${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}` : ''} ${state.device_rgbw ? '' : ''}
@@ -207,6 +225,9 @@ export async function showSettings(deviceId) { if (urlLabel) urlLabel.textContent = t('device.openrgb.url'); if (urlHint) urlHint.textContent = t('device.openrgb.url.hint'); urlInput.placeholder = 'openrgb://localhost:6742/0'; + // Parse zone from URL and show base URL only + const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url); + urlInput.value = baseUrl; } else { if (urlLabel) urlLabel.textContent = t('device.url'); if (urlHint) urlHint.textContent = t('settings.url.hint'); @@ -279,6 +300,29 @@ export async function showSettings(deviceId) { autoShutdownGroup.style.display = caps.includes('auto_restore') ? '' : 'none'; } document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown; + + // OpenRGB zone picker + mode toggle + const settingsZoneGroup = document.getElementById('settings-zone-group'); + const settingsZoneModeGroup = document.getElementById('settings-zone-mode-group'); + if (settingsZoneModeGroup) settingsZoneModeGroup.style.display = 'none'; + if (settingsZoneGroup) { + if (isOpenrgbDevice(device.device_type)) { + settingsZoneGroup.style.display = ''; + const { baseUrl, zones: currentZones } = _splitOpenrgbZone(device.url); + // Set zone mode radio from device + const savedMode = device.zone_mode || 'combined'; + const modeRadio = document.querySelector(`input[name="settings-zone-mode"][value="${savedMode}"]`); + if (modeRadio) modeRadio.checked = true; + _fetchOpenrgbZones(baseUrl, 'settings-zone-list', currentZones).then(() => { + // Re-snapshot after zones are loaded so dirty-check baseline includes them + settingsModal.snapshot(); + }); + } else { + settingsZoneGroup.style.display = 'none'; + document.getElementById('settings-zone-list').innerHTML = ''; + } + } + settingsModal.snapshot(); settingsModal.open(); @@ -327,6 +371,9 @@ export async function saveDeviceSettings() { const ledType = document.getElementById('settings-led-type')?.value; body.rgbw = ledType === 'rgbw'; } + if (isOpenrgbDevice(settingsModal.deviceType)) { + body.zone_mode = _getZoneMode('settings-zone-mode'); + } const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, { method: 'PUT', body: JSON.stringify(body) @@ -487,3 +534,62 @@ export function copyWsUrl() { export async function loadDevices() { await window.loadTargetsTab(); } + +// ===== OpenRGB zone count enrichment ===== + +// Cache: baseUrl → { zoneName: ledCount, ... } +const _zoneCountCache = {}; + +/** Return cached zone LED counts for a base URL, or null if not cached. */ +export function getZoneCountCache(baseUrl) { + return _zoneCountCache[baseUrl] || null; +} +const _zoneCountInFlight = new Set(); + +/** + * Fetch zone LED counts for an OpenRGB device and update zone badges on the card. + * Called after cards are rendered (same pattern as fetchDeviceBrightness). + */ +export async function enrichOpenrgbZoneBadges(deviceId, deviceUrl) { + const { baseUrl, zones } = _splitOpenrgbZone(deviceUrl); + if (!zones.length) return; + + // Use cache if available + if (_zoneCountCache[baseUrl]) { + _applyZoneCounts(deviceId, zones, _zoneCountCache[baseUrl]); + return; + } + + // Deduplicate in-flight requests per base URL + if (_zoneCountInFlight.has(baseUrl)) return; + _zoneCountInFlight.add(baseUrl); + + try { + const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); + if (!resp.ok) return; + const data = await resp.json(); + const counts = {}; + for (const z of data.zones) { + counts[z.name.toLowerCase()] = z.led_count; + } + _zoneCountCache[baseUrl] = counts; + _applyZoneCounts(deviceId, zones, counts); + } catch { + // Silently fail — device may be offline + } finally { + _zoneCountInFlight.delete(baseUrl); + } +} + +function _applyZoneCounts(deviceId, zones, counts) { + const card = document.querySelector(`[data-device-id="${deviceId}"]`); + if (!card) return; + for (const zoneName of zones) { + const badge = card.querySelector(`[data-zone-name="${zoneName}"]`); + if (!badge) continue; + const ledCount = counts[zoneName.toLowerCase()]; + if (ledCount != null) { + badge.innerHTML = `${ICON_LED} ${escapeHtml(zoneName)} · ${ledCount}`; + } + } +} diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 4dddb80..a8164c2 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -10,11 +10,12 @@ import { ledPreviewWebSockets, _cachedValueSources, valueSourcesCache, } from '../core/state.js'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js'; import { Modal } from '../core/modal.js'; -import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js'; +import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache } from './devices.js'; +import { _splitOpenrgbZone } from './device-discovery.js'; import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; import { createColorStripCard } from './color-strips.js'; import { @@ -655,6 +656,10 @@ export async function loadTargetsTab() { fetchDeviceBrightness(device.id); } } + // Enrich OpenRGB zone badges with per-zone LED counts + if (device.device_type === 'openrgb') { + enrichOpenrgbZoneBadges(device.id, device.url); + } }); // Manage KC WebSockets: connect for processing, disconnect for stopped @@ -915,10 +920,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo ` : ''} -
- - -
`, + ${_buildLedPreviewHtml(target.id, device, bvsId)}`, actions: ` ${isProcessing ? ` + + +
+ +