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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user