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:
|
||||
# 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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 +63,22 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
||||
f"(server has {len(devices)} device(s))"
|
||||
)
|
||||
device = devices[device_index]
|
||||
|
||||
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)"
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +74,7 @@ class DeviceInfo:
|
||||
test_mode_active: bool = False
|
||||
send_latency_ms: int = 0
|
||||
rgbw: bool = False
|
||||
zone_mode: str = "combined"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
container.innerHTML = `<span class="zone-error">${err.detail || t('device.openrgb.zone.error')}</span>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
_renderZoneCheckboxes(container, data.zones, preChecked);
|
||||
} catch (err) {
|
||||
if (err.isAuth) return;
|
||||
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<div class="card-title">
|
||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||
${device.name || device.id}
|
||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||
${healthLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-subtitle">
|
||||
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
|
||||
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : ''}
|
||||
${openrgbZones.length
|
||||
? openrgbZones.map(z => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
|
||||
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
|
||||
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
</div>
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div id="led-preview-panel-${target.id}" class="led-preview-panel" style="display:${ledPreviewWebSockets[target.id] ? '' : 'none'}">
|
||||
<canvas id="led-preview-canvas-${target.id}" class="led-preview-canvas"></canvas>
|
||||
<span id="led-preview-brightness-${target.id}" class="led-preview-brightness" style="display:none"${bvsId ? ' data-has-bvs="1"' : ''}></span>
|
||||
</div>`,
|
||||
${_buildLedPreviewHtml(target.id, device, bvsId)}`,
|
||||
actions: `
|
||||
${isProcessing ? `
|
||||
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
||||
@@ -1106,6 +1108,89 @@ export async function deleteTarget(targetId) {
|
||||
|
||||
const _ledPreviewLastFrame = {};
|
||||
|
||||
/**
|
||||
* Build the LED preview panel HTML for a target card.
|
||||
* For OpenRGB devices in "separate" zone mode with 2+ zones, renders
|
||||
* one canvas per zone with labels. Otherwise, a single canvas.
|
||||
*/
|
||||
function _buildLedPreviewHtml(targetId, device, bvsId) {
|
||||
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
|
||||
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
||||
|
||||
// Check for per-zone preview
|
||||
if (device && isOpenrgbDevice(device.device_type) && device.zone_mode === 'separate') {
|
||||
const { baseUrl, zones } = _splitOpenrgbZone(device.url);
|
||||
if (zones.length > 1) {
|
||||
const zoneCanvases = zones.map(z =>
|
||||
`<div class="led-preview-zone">` +
|
||||
`<canvas class="led-preview-canvas led-preview-zone-canvas" data-zone-name="${escapeHtml(z)}"></canvas>` +
|
||||
`<span class="led-preview-zone-label">${escapeHtml(z)}</span>` +
|
||||
`</div>`
|
||||
).join('');
|
||||
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-zones" data-zone-mode="separate" data-zone-base-url="${escapeHtml(baseUrl)}" style="display:${visible}">` +
|
||||
zoneCanvases +
|
||||
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
|
||||
`</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: single canvas
|
||||
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel" style="display:${visible}">` +
|
||||
`<canvas id="led-preview-canvas-${targetId}" class="led-preview-canvas"></canvas>` +
|
||||
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resample an RGB byte array from srcCount pixels to dstCount pixels
|
||||
* using linear interpolation (matches backend np.interp behavior).
|
||||
*/
|
||||
function _resampleStrip(srcBytes, srcCount, dstCount) {
|
||||
if (dstCount === srcCount) return srcBytes;
|
||||
const dst = new Uint8Array(dstCount * 3);
|
||||
for (let i = 0; i < dstCount; i++) {
|
||||
const t = dstCount > 1 ? i / (dstCount - 1) : 0;
|
||||
const srcPos = t * (srcCount - 1);
|
||||
const lo = Math.floor(srcPos);
|
||||
const hi = Math.min(lo + 1, srcCount - 1);
|
||||
const frac = srcPos - lo;
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
dst[i * 3 + ch] = Math.round(
|
||||
srcBytes[lo * 3 + ch] * (1 - frac) + srcBytes[hi * 3 + ch] * frac
|
||||
);
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render per-zone LED previews: resample the full frame independently
|
||||
* for each zone canvas (matching the backend's separate-mode behavior).
|
||||
*/
|
||||
function _renderLedStripZones(panel, rgbBytes) {
|
||||
const baseUrl = panel.dataset.zoneBaseUrl;
|
||||
const cache = baseUrl ? getZoneCountCache(baseUrl) : null;
|
||||
const srcCount = Math.floor(rgbBytes.length / 3);
|
||||
if (srcCount < 1) return;
|
||||
|
||||
const zoneCanvases = panel.querySelectorAll('.led-preview-zone-canvas');
|
||||
if (!cache) {
|
||||
// Zone sizes unknown — render full frame to all canvases
|
||||
for (const canvas of zoneCanvases) {
|
||||
_renderLedStrip(canvas, rgbBytes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const canvas of zoneCanvases) {
|
||||
const zoneName = canvas.dataset.zoneName;
|
||||
const zoneSize = cache[zoneName.toLowerCase()];
|
||||
if (!zoneSize || zoneSize < 1) continue;
|
||||
const resampled = _resampleStrip(rgbBytes, srcCount, zoneSize);
|
||||
_renderLedStrip(canvas, resampled);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderLedStrip(canvas, rgbBytes) {
|
||||
const ledCount = rgbBytes.length / 3;
|
||||
if (ledCount <= 0) return;
|
||||
@@ -1150,8 +1235,17 @@ function connectLedPreviewWS(targetId) {
|
||||
const brightness = raw[0];
|
||||
const frame = raw.subarray(1);
|
||||
_ledPreviewLastFrame[targetId] = frame;
|
||||
const canvas = document.getElementById(`led-preview-canvas-${targetId}`);
|
||||
|
||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||
if (panel) {
|
||||
if (panel.dataset.zoneMode === 'separate') {
|
||||
_renderLedStripZones(panel, frame);
|
||||
} else {
|
||||
const canvas = panel.querySelector('.led-preview-canvas');
|
||||
if (canvas) _renderLedStrip(canvas, frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Show brightness label: always when a brightness source is set, otherwise only below 100%
|
||||
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
|
||||
if (bLabel) {
|
||||
|
||||
@@ -137,6 +137,15 @@
|
||||
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
|
||||
"device.openrgb.url": "OpenRGB URL:",
|
||||
"device.openrgb.url.hint": "OpenRGB server address (e.g. openrgb://localhost:6742/0)",
|
||||
"device.openrgb.zone": "Zones:",
|
||||
"device.openrgb.zone.hint": "Select which LED zones to control (leave all unchecked for all zones)",
|
||||
"device.openrgb.zone.loading": "Loading zones…",
|
||||
"device.openrgb.zone.error": "Failed to load zones",
|
||||
"device.openrgb.mode": "Zone mode:",
|
||||
"device.openrgb.mode.hint": "Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.",
|
||||
"device.openrgb.mode.combined": "Combined strip",
|
||||
"device.openrgb.mode.separate": "Independent zones",
|
||||
"device.openrgb.added_multiple": "Added {count} devices",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||
"device.name": "Device Name:",
|
||||
|
||||
@@ -137,6 +137,15 @@
|
||||
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
|
||||
"device.openrgb.url": "OpenRGB URL:",
|
||||
"device.openrgb.url.hint": "Адрес сервера OpenRGB (напр. openrgb://localhost:6742/0)",
|
||||
"device.openrgb.zone": "Зоны:",
|
||||
"device.openrgb.zone.hint": "Выберите зоны LED для управления (оставьте все неотмеченными для всех зон)",
|
||||
"device.openrgb.zone.loading": "Загрузка зон…",
|
||||
"device.openrgb.zone.error": "Не удалось загрузить зоны",
|
||||
"device.openrgb.mode": "Режим зон:",
|
||||
"device.openrgb.mode.hint": "Объединённый — все зоны как одна непрерывная LED-лента. Раздельный — каждая зона независимо отображает полный эффект.",
|
||||
"device.openrgb.mode.combined": "Объединённая лента",
|
||||
"device.openrgb.mode.separate": "Независимые зоны",
|
||||
"device.openrgb.added_multiple": "Добавлено {count} устройств",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||
"device.name": "Имя Устройства:",
|
||||
|
||||
@@ -137,6 +137,15 @@
|
||||
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
|
||||
"device.openrgb.url": "OpenRGB URL:",
|
||||
"device.openrgb.url.hint": "OpenRGB 服务器地址(例如 openrgb://localhost:6742/0)",
|
||||
"device.openrgb.zone": "区域:",
|
||||
"device.openrgb.zone.hint": "选择要控制的 LED 区域(全部不选则控制所有区域)",
|
||||
"device.openrgb.zone.loading": "加载区域中…",
|
||||
"device.openrgb.zone.error": "加载区域失败",
|
||||
"device.openrgb.mode": "区域模式:",
|
||||
"device.openrgb.mode.hint": "合并模式将所有区域作为一条连续 LED 灯带。独立模式让每个区域独立渲染完整效果。",
|
||||
"device.openrgb.mode.combined": "合并灯带",
|
||||
"device.openrgb.mode.separate": "独立区域",
|
||||
"device.openrgb.added_multiple": "已添加 {count} 个设备",
|
||||
"device.type.openrgb": "OpenRGB",
|
||||
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
||||
"device.name": "设备名称:",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Navigation: network-first with offline fallback
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'ledgrab-v2';
|
||||
const CACHE_NAME = 'ledgrab-v6';
|
||||
|
||||
// Only pre-cache static assets (no auth required).
|
||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||
|
||||
@@ -32,6 +32,7 @@ class Device:
|
||||
auto_shutdown: bool = False,
|
||||
send_latency_ms: int = 0,
|
||||
rgbw: bool = False,
|
||||
zone_mode: str = "combined",
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
@@ -46,6 +47,7 @@ class Device:
|
||||
self.auto_shutdown = auto_shutdown
|
||||
self.send_latency_ms = send_latency_ms
|
||||
self.rgbw = rgbw
|
||||
self.zone_mode = zone_mode
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
|
||||
@@ -71,6 +73,8 @@ class Device:
|
||||
d["send_latency_ms"] = self.send_latency_ms
|
||||
if self.rgbw:
|
||||
d["rgbw"] = True
|
||||
if self.zone_mode != "combined":
|
||||
d["zone_mode"] = self.zone_mode
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -88,6 +92,7 @@ class Device:
|
||||
auto_shutdown=data.get("auto_shutdown", False),
|
||||
send_latency_ms=data.get("send_latency_ms", 0),
|
||||
rgbw=data.get("rgbw", False),
|
||||
zone_mode=data.get("zone_mode", "combined"),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
)
|
||||
@@ -175,6 +180,7 @@ class DeviceStore:
|
||||
auto_shutdown: bool = False,
|
||||
send_latency_ms: int = 0,
|
||||
rgbw: bool = False,
|
||||
zone_mode: str = "combined",
|
||||
) -> Device:
|
||||
"""Create a new device."""
|
||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||
@@ -193,6 +199,7 @@ class DeviceStore:
|
||||
auto_shutdown=auto_shutdown,
|
||||
send_latency_ms=send_latency_ms,
|
||||
rgbw=rgbw,
|
||||
zone_mode=zone_mode,
|
||||
)
|
||||
|
||||
self._devices[device_id] = device
|
||||
@@ -220,6 +227,7 @@ class DeviceStore:
|
||||
auto_shutdown: Optional[bool] = None,
|
||||
send_latency_ms: Optional[int] = None,
|
||||
rgbw: Optional[bool] = None,
|
||||
zone_mode: Optional[str] = None,
|
||||
) -> Device:
|
||||
"""Update device."""
|
||||
device = self._devices.get(device_id)
|
||||
@@ -242,6 +250,8 @@ class DeviceStore:
|
||||
device.send_latency_ms = send_latency_ms
|
||||
if rgbw is not None:
|
||||
device.rgbw = rgbw
|
||||
if zone_mode is not None:
|
||||
device.zone_mode = zone_mode
|
||||
|
||||
device.updated_at = datetime.utcnow()
|
||||
self.save()
|
||||
|
||||
@@ -56,6 +56,31 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
|
||||
<select id="device-serial-port" onfocus="onSerialPortFocus()"></select>
|
||||
</div>
|
||||
<div class="form-group" id="device-zone-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label data-i18n="device.openrgb.zone">Zones:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.openrgb.zone.hint">Select which LED zones to control (leave all unchecked for all zones)</small>
|
||||
<div id="device-zone-list" class="zone-checkbox-list"></div>
|
||||
</div>
|
||||
<div class="form-group" id="device-zone-mode-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label data-i18n="device.openrgb.mode">Zone mode:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.openrgb.mode.hint">Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.</small>
|
||||
<div class="zone-mode-radios">
|
||||
<label class="zone-mode-option">
|
||||
<input type="radio" name="device-zone-mode" value="combined" checked>
|
||||
<span data-i18n="device.openrgb.mode.combined">Combined strip</span>
|
||||
</label>
|
||||
<label class="zone-mode-option">
|
||||
<input type="radio" name="device-zone-mode" value="separate">
|
||||
<span data-i18n="device.openrgb.mode.separate">Independent zones</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="device-led-count-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
|
||||
|
||||
@@ -31,6 +31,31 @@
|
||||
<select id="settings-serial-port"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="settings-zone-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label data-i18n="device.openrgb.zone">Zones:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.openrgb.zone.hint">Select which LED zones to control (leave all unchecked for all zones)</small>
|
||||
<div id="settings-zone-list" class="zone-checkbox-list"></div>
|
||||
</div>
|
||||
<div class="form-group" id="settings-zone-mode-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label data-i18n="device.openrgb.mode">Zone mode:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.openrgb.mode.hint">Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.</small>
|
||||
<div class="zone-mode-radios">
|
||||
<label class="zone-mode-option">
|
||||
<input type="radio" name="settings-zone-mode" value="combined" checked>
|
||||
<span data-i18n="device.openrgb.mode.combined">Combined strip</span>
|
||||
</label>
|
||||
<label class="zone-mode-option">
|
||||
<input type="radio" name="settings-zone-mode" value="separate">
|
||||
<span data-i18n="device.openrgb.mode.separate">Independent zones</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="settings-led-count-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
|
||||
|
||||
Reference in New Issue
Block a user