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,
|
DeviceUpdate,
|
||||||
DiscoveredDeviceResponse,
|
DiscoveredDeviceResponse,
|
||||||
DiscoverDevicesResponse,
|
DiscoverDevicesResponse,
|
||||||
|
OpenRGBZoneResponse,
|
||||||
|
OpenRGBZonesResponse,
|
||||||
)
|
)
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
@@ -48,6 +50,7 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
auto_shutdown=device.auto_shutdown,
|
auto_shutdown=device.auto_shutdown,
|
||||||
send_latency_ms=device.send_latency_ms,
|
send_latency_ms=device.send_latency_ms,
|
||||||
rgbw=device.rgbw,
|
rgbw=device.rgbw,
|
||||||
|
zone_mode=device.zone_mode,
|
||||||
capabilities=sorted(get_device_capabilities(device.device_type)),
|
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
@@ -122,6 +125,7 @@ async def create_device(
|
|||||||
auto_shutdown=auto_shutdown,
|
auto_shutdown=auto_shutdown,
|
||||||
send_latency_ms=device_data.send_latency_ms or 0,
|
send_latency_ms=device_data.send_latency_ms or 0,
|
||||||
rgbw=device_data.rgbw or False,
|
rgbw=device_data.rgbw or False,
|
||||||
|
zone_mode=device_data.zone_mode or "combined",
|
||||||
)
|
)
|
||||||
|
|
||||||
# WS devices: auto-set URL to ws://{device_id}
|
# WS devices: auto-set URL to ws://{device_id}
|
||||||
@@ -137,6 +141,7 @@ async def create_device(
|
|||||||
device_type=device.device_type,
|
device_type=device.device_type,
|
||||||
baud_rate=device.baud_rate,
|
baud_rate=device.baud_rate,
|
||||||
auto_shutdown=device.auto_shutdown,
|
auto_shutdown=device.auto_shutdown,
|
||||||
|
zone_mode=device.zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _device_to_response(device)
|
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"])
|
@router.get("/api/v1/devices/batch/states", tags=["Devices"])
|
||||||
async def batch_device_states(
|
async def batch_device_states(
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
@@ -255,6 +307,7 @@ async def update_device(
|
|||||||
auto_shutdown=update_data.auto_shutdown,
|
auto_shutdown=update_data.auto_shutdown,
|
||||||
send_latency_ms=update_data.send_latency_ms,
|
send_latency_ms=update_data.send_latency_ms,
|
||||||
rgbw=update_data.rgbw,
|
rgbw=update_data.rgbw,
|
||||||
|
zone_mode=update_data.zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync connection info in processor manager
|
# Sync connection info in processor manager
|
||||||
@@ -268,9 +321,12 @@ async def update_device(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Sync auto_shutdown in runtime state
|
# Sync auto_shutdown and zone_mode in runtime state
|
||||||
if update_data.auto_shutdown is not None and device_id in manager._devices:
|
if device_id in manager._devices:
|
||||||
manager._devices[device_id].auto_shutdown = update_data.auto_shutdown
|
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)
|
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)")
|
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)")
|
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)")
|
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):
|
class DeviceUpdate(BaseModel):
|
||||||
@@ -30,6 +31,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
|
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)")
|
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)")
|
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):
|
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")
|
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)")
|
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)")
|
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")
|
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
@@ -149,3 +152,18 @@ class DiscoverDevicesResponse(BaseModel):
|
|||||||
devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices")
|
devices: List[DiscoveredDeviceResponse] = Field(description="Discovered devices")
|
||||||
count: int = Field(description="Total devices found")
|
count: int = Field(description="Total devices found")
|
||||||
scan_duration_ms: float = Field(description="How long the scan took in milliseconds")
|
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")
|
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]:
|
async def snapshot_device_state(self) -> Optional[dict]:
|
||||||
"""Snapshot device state before streaming starts.
|
"""Snapshot device state before streaming starts.
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,31 @@ from wled_controller.utils import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_openrgb_url(url: str) -> Tuple[str, int, int]:
|
def parse_openrgb_url(url: str) -> Tuple[str, int, int, List[str]]:
|
||||||
"""Parse an openrgb:// URL into (host, port, device_index).
|
"""Parse an openrgb:// URL into (host, port, device_index, zone_names).
|
||||||
|
|
||||||
Format: openrgb://host:port/device_index
|
Format: openrgb://host:port/device_index[/zone1+zone2+...]
|
||||||
Defaults: host=localhost, port=6742, device_index=0
|
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://"):
|
if url.startswith("openrgb://"):
|
||||||
url = url[len("openrgb://"):]
|
url = url[len("openrgb://"):]
|
||||||
else:
|
else:
|
||||||
return ("localhost", 6742, 0)
|
return ("localhost", 6742, 0, [])
|
||||||
|
|
||||||
# Split path from host:port
|
# Split path from host:port
|
||||||
if "/" in url:
|
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:
|
try:
|
||||||
device_index = int(index_str)
|
device_index = int(index_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -46,7 +57,11 @@ def parse_openrgb_url(url: str) -> Tuple[str, int, int]:
|
|||||||
host = host_port if host_port else "localhost"
|
host = host_port if host_port else "localhost"
|
||||||
port = 6742
|
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):
|
class OpenRGBLEDClient(LEDClient):
|
||||||
@@ -61,10 +76,12 @@ class OpenRGBLEDClient(LEDClient):
|
|||||||
|
|
||||||
def __init__(self, url: str, **kwargs):
|
def __init__(self, url: str, **kwargs):
|
||||||
self._url = url
|
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._host = host
|
||||||
self._port = port
|
self._port = port
|
||||||
self._device_index = device_index
|
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._client: Any = None # openrgb.OpenRGBClient
|
||||||
self._device: Any = None # openrgb.Device
|
self._device: Any = None # openrgb.Device
|
||||||
self._connected = False
|
self._connected = False
|
||||||
@@ -77,10 +94,38 @@ class OpenRGBLEDClient(LEDClient):
|
|||||||
self._client, self._device = await asyncio.to_thread(self._connect_sync)
|
self._client, self._device = await asyncio.to_thread(self._connect_sync)
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._device_name = self._device.name
|
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(
|
logger.info(
|
||||||
f"Connected to OpenRGB device '{self._device_name}' "
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -131,6 +176,10 @@ class OpenRGBLEDClient(LEDClient):
|
|||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
return self._connected and self._client is not None
|
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(
|
async def send_pixels(
|
||||||
self,
|
self,
|
||||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||||
@@ -158,8 +207,13 @@ class OpenRGBLEDClient(LEDClient):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Synchronous fire-and-forget send for the processing hot loop.
|
"""Synchronous fire-and-forget send for the processing hot loop.
|
||||||
|
|
||||||
Converts numpy (N,3) array to List[RGBColor] and calls
|
Converts numpy (N,3) array to List[RGBColor] and distributes colors
|
||||||
device.set_colors(colors, fast=True) to skip the re-fetch round trip.
|
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:
|
if not self.is_connected or self._device is None:
|
||||||
return
|
return
|
||||||
@@ -176,19 +230,45 @@ class OpenRGBLEDClient(LEDClient):
|
|||||||
if brightness < 255:
|
if brightness < 255:
|
||||||
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||||
|
|
||||||
# Truncate or pad to match device LED count
|
# Truncate or pad to match target LED count
|
||||||
n_device = len(self._device.leds)
|
n_target = self._device_led_count
|
||||||
n_pixels = len(pixel_array)
|
n_pixels = len(pixel_array)
|
||||||
if n_pixels > n_device:
|
if n_pixels > n_target:
|
||||||
pixel_array = pixel_array[:n_device]
|
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]
|
colors = [RGBColor(int(r), int(g), int(b)) for r, g, b in pixel_array]
|
||||||
|
|
||||||
# Pad with black if fewer pixels than device LEDs
|
# Pad with black if fewer pixels than target LEDs
|
||||||
if len(colors) < n_device:
|
if len(colors) < n_target:
|
||||||
colors.extend([RGBColor(0, 0, 0)] * (n_device - len(colors)))
|
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:
|
except Exception as e:
|
||||||
logger.error(f"OpenRGB send_pixels_fast failed: {e}")
|
logger.error(f"OpenRGB send_pixels_fast failed: {e}")
|
||||||
self._connected = False
|
self._connected = False
|
||||||
@@ -227,7 +307,7 @@ class OpenRGBLEDClient(LEDClient):
|
|||||||
Uses a lightweight socket probe instead of full library init to avoid
|
Uses a lightweight socket probe instead of full library init to avoid
|
||||||
re-downloading all device data every health check cycle (~30s).
|
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()
|
start = asyncio.get_event_loop().time()
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
|||||||
return {"health_check", "auto_restore", "static_color"}
|
return {"health_check", "auto_restore", "static_color"}
|
||||||
|
|
||||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
|
zone_mode = kwargs.pop("zone_mode", "combined")
|
||||||
kwargs.pop("led_count", None)
|
kwargs.pop("led_count", None)
|
||||||
kwargs.pop("baud_rate", None)
|
kwargs.pop("baud_rate", None)
|
||||||
kwargs.pop("send_latency_ms", None)
|
kwargs.pop("send_latency_ms", None)
|
||||||
kwargs.pop("rgbw", 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:
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
return await OpenRGBLEDClient.check_health(url, http_client, prev_health)
|
return await OpenRGBLEDClient.check_health(url, http_client, prev_health)
|
||||||
@@ -48,7 +49,7 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
|||||||
Raises:
|
Raises:
|
||||||
Exception on validation failure.
|
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():
|
def _validate_sync():
|
||||||
from openrgb import OpenRGBClient
|
from openrgb import OpenRGBClient
|
||||||
@@ -62,10 +63,26 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
|||||||
f"(server has {len(devices)} device(s))"
|
f"(server has {len(devices)} device(s))"
|
||||||
)
|
)
|
||||||
device = devices[device_index]
|
device = devices[device_index]
|
||||||
led_count = len(device.leds)
|
|
||||||
logger.info(
|
if zone_names:
|
||||||
f"OpenRGB device validated: '{device.name}' ({led_count} LEDs)"
|
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}
|
return {"led_count": led_count}
|
||||||
finally:
|
finally:
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
@@ -111,8 +128,8 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
|||||||
return await asyncio.to_thread(_discover_sync)
|
return await asyncio.to_thread(_discover_sync)
|
||||||
|
|
||||||
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
|
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."""
|
"""Set all LEDs on the OpenRGB device (or target zone) to a solid color."""
|
||||||
host, port, device_index = parse_openrgb_url(url)
|
host, port, device_index, zone_names = parse_openrgb_url(url)
|
||||||
|
|
||||||
def _set_color_sync():
|
def _set_color_sync():
|
||||||
from openrgb import OpenRGBClient
|
from openrgb import OpenRGBClient
|
||||||
@@ -125,7 +142,15 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
|
|||||||
raise ValueError(f"Device index {device_index} out of range")
|
raise ValueError(f"Device index {device_index} out of range")
|
||||||
device = devices[device_index]
|
device = devices[device_index]
|
||||||
color_obj = RGBColor(color[0], color[1], color[2])
|
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:
|
finally:
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class DeviceState:
|
|||||||
test_calibration: Optional[CalibrationConfig] = None
|
test_calibration: Optional[CalibrationConfig] = None
|
||||||
# Tracked power state for serial devices (no hardware query)
|
# Tracked power state for serial devices (no hardware query)
|
||||||
power_on: bool = True
|
power_on: bool = True
|
||||||
|
# OpenRGB zone mode: "combined" or "separate"
|
||||||
|
zone_mode: str = "combined"
|
||||||
|
|
||||||
|
|
||||||
class ProcessorManager:
|
class ProcessorManager:
|
||||||
@@ -160,6 +162,7 @@ class ProcessorManager:
|
|||||||
test_mode_active=ds.test_mode_active,
|
test_mode_active=ds.test_mode_active,
|
||||||
send_latency_ms=send_latency_ms,
|
send_latency_ms=send_latency_ms,
|
||||||
rgbw=rgbw,
|
rgbw=rgbw,
|
||||||
|
zone_mode=ds.zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== EVENT SYSTEM (state change notifications) =====
|
# ===== EVENT SYSTEM (state change notifications) =====
|
||||||
@@ -200,6 +203,7 @@ class ProcessorManager:
|
|||||||
baud_rate: Optional[int] = None,
|
baud_rate: Optional[int] = None,
|
||||||
software_brightness: int = 255,
|
software_brightness: int = 255,
|
||||||
auto_shutdown: bool = False,
|
auto_shutdown: bool = False,
|
||||||
|
zone_mode: str = "combined",
|
||||||
):
|
):
|
||||||
"""Register a device for health monitoring."""
|
"""Register a device for health monitoring."""
|
||||||
if device_id in self._devices:
|
if device_id in self._devices:
|
||||||
@@ -213,6 +217,7 @@ class ProcessorManager:
|
|||||||
baud_rate=baud_rate,
|
baud_rate=baud_rate,
|
||||||
software_brightness=software_brightness,
|
software_brightness=software_brightness,
|
||||||
auto_shutdown=auto_shutdown,
|
auto_shutdown=auto_shutdown,
|
||||||
|
zone_mode=zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._devices[device_id] = state
|
self._devices[device_id] = state
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class DeviceInfo:
|
|||||||
test_mode_active: bool = False
|
test_mode_active: bool = False
|
||||||
send_latency_ms: int = 0
|
send_latency_ms: int = 0
|
||||||
rgbw: bool = False
|
rgbw: bool = False
|
||||||
|
zone_mode: str = "combined"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._overlay_active = False
|
self._overlay_active = False
|
||||||
self._needs_keepalive = True
|
self._needs_keepalive = True
|
||||||
|
|
||||||
|
self._effective_led_count: int = 0
|
||||||
self._resolved_display_index: Optional[int] = None
|
self._resolved_display_index: Optional[int] = None
|
||||||
|
|
||||||
# Fit-to-device linspace cache (per-instance to avoid cross-target thrash)
|
# 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,
|
baud_rate=device_info.baud_rate,
|
||||||
send_latency_ms=device_info.send_latency_ms,
|
send_latency_ms=device_info.send_latency_ms,
|
||||||
rgbw=device_info.rgbw,
|
rgbw=device_info.rgbw,
|
||||||
|
zone_mode=device_info.zone_mode,
|
||||||
)
|
)
|
||||||
await self._led_client.connect()
|
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(
|
logger.info(
|
||||||
f"Target {self._target_id} connected to {device_info.device_type} "
|
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._device_state_before = await self._led_client.snapshot_device_state()
|
||||||
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type)
|
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type)
|
||||||
@@ -132,8 +146,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id)
|
stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id)
|
||||||
if hasattr(stream, "configure") and device_info.led_count > 0:
|
if hasattr(stream, "configure") and self._effective_led_count > 0:
|
||||||
stream.configure(device_info.led_count)
|
stream.configure(self._effective_led_count)
|
||||||
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
|
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
|
||||||
|
|
||||||
self._resolved_display_index = getattr(stream, "display_index", None)
|
self._resolved_display_index = getattr(stream, "display_index", None)
|
||||||
@@ -254,7 +268,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
return
|
return
|
||||||
|
|
||||||
device_info = self._ctx.get_device_info(self._device_id)
|
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
|
# Release old stream
|
||||||
if self._css_stream is not None and old_css_id:
|
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()
|
prev_frame_time_stamp = time.perf_counter()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
_init_device_info = self._ctx.get_device_info(self._device_id)
|
_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 reference — re-read each tick to detect hot-swaps
|
||||||
stream = self._css_stream
|
stream = self._css_stream
|
||||||
|
|||||||
@@ -443,6 +443,62 @@ body.cs-drag-active .card-drag-handle {
|
|||||||
animation: spin 0.8s linear infinite;
|
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 {
|
.channel-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -792,3 +848,42 @@ ul.section-tip li {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.8;
|
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,
|
baudRate: document.getElementById('device-baud-rate').value,
|
||||||
ledType: document.getElementById('device-led-type')?.value || 'rgb',
|
ledType: document.getElementById('device-led-type')?.value || 'rgb',
|
||||||
sendLatency: document.getElementById('device-send-latency')?.value || '0',
|
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 urlLabel = document.getElementById('device-url-label');
|
||||||
const urlHint = document.getElementById('device-url-hint');
|
const urlHint = document.getElementById('device-url-hint');
|
||||||
|
|
||||||
|
const zoneGroup = document.getElementById('device-zone-group');
|
||||||
const scanBtn = document.getElementById('scan-network-btn');
|
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)) {
|
if (isMqttDevice(deviceType)) {
|
||||||
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
|
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
|
||||||
urlGroup.style.display = '';
|
urlGroup.style.display = '';
|
||||||
@@ -121,6 +129,7 @@ export function onDeviceTypeChanged() {
|
|||||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||||
if (scanBtn) scanBtn.style.display = '';
|
if (scanBtn) scanBtn.style.display = '';
|
||||||
|
if (zoneGroup) zoneGroup.style.display = '';
|
||||||
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
|
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
|
||||||
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
|
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
|
||||||
urlInput.placeholder = 'openrgb://localhost:6742/0';
|
urlInput.placeholder = 'openrgb://localhost:6742/0';
|
||||||
@@ -354,6 +363,10 @@ export function selectDiscoveredDevice(device) {
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('device-url').value = device.url;
|
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');
|
showToast(t('device.scan.selected'), 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,12 +393,20 @@ export async function handleAddDevice(event) {
|
|||||||
url = 'mqtt://' + url;
|
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)) {
|
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) {
|
||||||
error.textContent = t('device_discovery.error.fill_all_fields');
|
error.textContent = t('device_discovery.error.fill_all_fields');
|
||||||
error.style.display = 'block';
|
error.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = { name, url, device_type: deviceType };
|
const body = { name, url, device_type: deviceType };
|
||||||
const ledCountInput = document.getElementById('device-led-count');
|
const ledCountInput = document.getElementById('device-led-count');
|
||||||
@@ -402,10 +423,10 @@ export async function handleAddDevice(event) {
|
|||||||
const ledType = document.getElementById('device-led-type')?.value;
|
const ledType = document.getElementById('device-led-type')?.value;
|
||||||
body.rgbw = ledType === 'rgbw';
|
body.rgbw = ledType === 'rgbw';
|
||||||
}
|
}
|
||||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
if (isOpenrgbDevice(deviceType) && checkedZones.length >= 2) {
|
||||||
if (lastTemplateId) {
|
body.zone_mode = _getZoneMode();
|
||||||
body.capture_template_id = lastTemplateId;
|
|
||||||
}
|
}
|
||||||
|
if (lastTemplateId) body.capture_template_id = lastTemplateId;
|
||||||
|
|
||||||
const response = await fetchWithAuth('/devices', {
|
const response = await fetchWithAuth('/devices', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -417,9 +438,7 @@ export async function handleAddDevice(event) {
|
|||||||
console.log('Device added successfully:', result);
|
console.log('Device added successfully:', result);
|
||||||
showToast(t('device_discovery.added'), 'success');
|
showToast(t('device_discovery.added'), 'success');
|
||||||
addDeviceModal.forceClose();
|
addDeviceModal.forceClose();
|
||||||
// Use window.* to avoid circular imports
|
|
||||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||||
// Auto-start device tutorial on first device add
|
|
||||||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||||
localStorage.setItem('deviceTutorialSeen', '1');
|
localStorage.setItem('deviceTutorialSeen', '1');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -438,3 +457,112 @@ export async function handleAddDevice(event) {
|
|||||||
showToast(t('device_discovery.error.add_failed'), 'error');
|
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,
|
_deviceBrightnessCache, updateDeviceBrightness,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.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 { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
@@ -27,6 +28,8 @@ class DeviceSettingsModal extends Modal {
|
|||||||
led_count: this.$('settings-led-count').value,
|
led_count: this.$('settings-led-count').value,
|
||||||
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
|
led_type: document.getElementById('settings-led-type')?.value || 'rgb',
|
||||||
send_latency: document.getElementById('settings-send-latency')?.value || '0',
|
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)) {
|
if (isSerialDevice(this.deviceType)) {
|
||||||
return this.$('settings-serial-port').value;
|
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;
|
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({
|
return wrapCard({
|
||||||
dataAttr: 'data-device-id',
|
dataAttr: 'data-device-id',
|
||||||
id: device.id,
|
id: device.id,
|
||||||
@@ -89,13 +105,15 @@ export function createDeviceCard(device) {
|
|||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||||
${device.name || device.id}
|
${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}
|
${healthLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-subtitle">
|
<div class="card-subtitle">
|
||||||
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
|
<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>` : ''}
|
${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>
|
<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>
|
</div>
|
||||||
@@ -207,6 +225,9 @@ export async function showSettings(deviceId) {
|
|||||||
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
|
if (urlLabel) urlLabel.textContent = t('device.openrgb.url');
|
||||||
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
|
if (urlHint) urlHint.textContent = t('device.openrgb.url.hint');
|
||||||
urlInput.placeholder = 'openrgb://localhost:6742/0';
|
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 {
|
} else {
|
||||||
if (urlLabel) urlLabel.textContent = t('device.url');
|
if (urlLabel) urlLabel.textContent = t('device.url');
|
||||||
if (urlHint) urlHint.textContent = t('settings.url.hint');
|
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';
|
autoShutdownGroup.style.display = caps.includes('auto_restore') ? '' : 'none';
|
||||||
}
|
}
|
||||||
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
|
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.snapshot();
|
||||||
settingsModal.open();
|
settingsModal.open();
|
||||||
|
|
||||||
@@ -327,6 +371,9 @@ export async function saveDeviceSettings() {
|
|||||||
const ledType = document.getElementById('settings-led-type')?.value;
|
const ledType = document.getElementById('settings-led-type')?.value;
|
||||||
body.rgbw = ledType === 'rgbw';
|
body.rgbw = ledType === 'rgbw';
|
||||||
}
|
}
|
||||||
|
if (isOpenrgbDevice(settingsModal.deviceType)) {
|
||||||
|
body.zone_mode = _getZoneMode('settings-zone-mode');
|
||||||
|
}
|
||||||
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
@@ -487,3 +534,62 @@ export function copyWsUrl() {
|
|||||||
export async function loadDevices() {
|
export async function loadDevices() {
|
||||||
await window.loadTargetsTab();
|
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,
|
ledPreviewWebSockets,
|
||||||
_cachedValueSources, valueSourcesCache,
|
_cachedValueSources, valueSourcesCache,
|
||||||
} from '../core/state.js';
|
} 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 { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
|
import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.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 { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
||||||
import { createColorStripCard } from './color-strips.js';
|
import { createColorStripCard } from './color-strips.js';
|
||||||
import {
|
import {
|
||||||
@@ -655,6 +656,10 @@ export async function loadTargetsTab() {
|
|||||||
fetchDeviceBrightness(device.id);
|
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
|
// Manage KC WebSockets: connect for processing, disconnect for stopped
|
||||||
@@ -915,10 +920,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div id="led-preview-panel-${target.id}" class="led-preview-panel" style="display:${ledPreviewWebSockets[target.id] ? '' : 'none'}">
|
${_buildLedPreviewHtml(target.id, device, bvsId)}`,
|
||||||
<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>`,
|
|
||||||
actions: `
|
actions: `
|
||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
<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 = {};
|
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) {
|
function _renderLedStrip(canvas, rgbBytes) {
|
||||||
const ledCount = rgbBytes.length / 3;
|
const ledCount = rgbBytes.length / 3;
|
||||||
if (ledCount <= 0) return;
|
if (ledCount <= 0) return;
|
||||||
@@ -1150,8 +1235,17 @@ function connectLedPreviewWS(targetId) {
|
|||||||
const brightness = raw[0];
|
const brightness = raw[0];
|
||||||
const frame = raw.subarray(1);
|
const frame = raw.subarray(1);
|
||||||
_ledPreviewLastFrame[targetId] = frame;
|
_ledPreviewLastFrame[targetId] = frame;
|
||||||
const canvas = document.getElementById(`led-preview-canvas-${targetId}`);
|
|
||||||
if (canvas) _renderLedStrip(canvas, frame);
|
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%
|
// Show brightness label: always when a brightness source is set, otherwise only below 100%
|
||||||
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
|
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
|
||||||
if (bLabel) {
|
if (bLabel) {
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
|
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
|
||||||
"device.openrgb.url": "OpenRGB URL:",
|
"device.openrgb.url": "OpenRGB URL:",
|
||||||
"device.openrgb.url.hint": "OpenRGB server address (e.g. openrgb://localhost:6742/0)",
|
"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.type.openrgb": "OpenRGB",
|
||||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||||
"device.name": "Device Name:",
|
"device.name": "Device Name:",
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
|
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
|
||||||
"device.openrgb.url": "OpenRGB URL:",
|
"device.openrgb.url": "OpenRGB URL:",
|
||||||
"device.openrgb.url.hint": "Адрес сервера OpenRGB (напр. openrgb://localhost:6742/0)",
|
"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.type.openrgb": "OpenRGB",
|
||||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||||
"device.name": "Имя Устройства:",
|
"device.name": "Имя Устройства:",
|
||||||
|
|||||||
@@ -137,6 +137,15 @@
|
|||||||
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
|
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
|
||||||
"device.openrgb.url": "OpenRGB URL:",
|
"device.openrgb.url": "OpenRGB URL:",
|
||||||
"device.openrgb.url.hint": "OpenRGB 服务器地址(例如 openrgb://localhost:6742/0)",
|
"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.type.openrgb": "OpenRGB",
|
||||||
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
||||||
"device.name": "设备名称:",
|
"device.name": "设备名称:",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - Navigation: network-first with offline fallback
|
* - Navigation: network-first with offline fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = 'ledgrab-v2';
|
const CACHE_NAME = 'ledgrab-v6';
|
||||||
|
|
||||||
// Only pre-cache static assets (no auth required).
|
// Only pre-cache static assets (no auth required).
|
||||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
// 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,
|
auto_shutdown: bool = False,
|
||||||
send_latency_ms: int = 0,
|
send_latency_ms: int = 0,
|
||||||
rgbw: bool = False,
|
rgbw: bool = False,
|
||||||
|
zone_mode: str = "combined",
|
||||||
created_at: Optional[datetime] = None,
|
created_at: Optional[datetime] = None,
|
||||||
updated_at: Optional[datetime] = None,
|
updated_at: Optional[datetime] = None,
|
||||||
):
|
):
|
||||||
@@ -46,6 +47,7 @@ class Device:
|
|||||||
self.auto_shutdown = auto_shutdown
|
self.auto_shutdown = auto_shutdown
|
||||||
self.send_latency_ms = send_latency_ms
|
self.send_latency_ms = send_latency_ms
|
||||||
self.rgbw = rgbw
|
self.rgbw = rgbw
|
||||||
|
self.zone_mode = zone_mode
|
||||||
self.created_at = created_at or datetime.utcnow()
|
self.created_at = created_at or datetime.utcnow()
|
||||||
self.updated_at = updated_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
|
d["send_latency_ms"] = self.send_latency_ms
|
||||||
if self.rgbw:
|
if self.rgbw:
|
||||||
d["rgbw"] = True
|
d["rgbw"] = True
|
||||||
|
if self.zone_mode != "combined":
|
||||||
|
d["zone_mode"] = self.zone_mode
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -88,6 +92,7 @@ class Device:
|
|||||||
auto_shutdown=data.get("auto_shutdown", False),
|
auto_shutdown=data.get("auto_shutdown", False),
|
||||||
send_latency_ms=data.get("send_latency_ms", 0),
|
send_latency_ms=data.get("send_latency_ms", 0),
|
||||||
rgbw=data.get("rgbw", False),
|
rgbw=data.get("rgbw", False),
|
||||||
|
zone_mode=data.get("zone_mode", "combined"),
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_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,
|
auto_shutdown: bool = False,
|
||||||
send_latency_ms: int = 0,
|
send_latency_ms: int = 0,
|
||||||
rgbw: bool = False,
|
rgbw: bool = False,
|
||||||
|
zone_mode: str = "combined",
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Create a new device."""
|
"""Create a new device."""
|
||||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||||
@@ -193,6 +199,7 @@ class DeviceStore:
|
|||||||
auto_shutdown=auto_shutdown,
|
auto_shutdown=auto_shutdown,
|
||||||
send_latency_ms=send_latency_ms,
|
send_latency_ms=send_latency_ms,
|
||||||
rgbw=rgbw,
|
rgbw=rgbw,
|
||||||
|
zone_mode=zone_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._devices[device_id] = device
|
self._devices[device_id] = device
|
||||||
@@ -220,6 +227,7 @@ class DeviceStore:
|
|||||||
auto_shutdown: Optional[bool] = None,
|
auto_shutdown: Optional[bool] = None,
|
||||||
send_latency_ms: Optional[int] = None,
|
send_latency_ms: Optional[int] = None,
|
||||||
rgbw: Optional[bool] = None,
|
rgbw: Optional[bool] = None,
|
||||||
|
zone_mode: Optional[str] = None,
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Update device."""
|
"""Update device."""
|
||||||
device = self._devices.get(device_id)
|
device = self._devices.get(device_id)
|
||||||
@@ -242,6 +250,8 @@ class DeviceStore:
|
|||||||
device.send_latency_ms = send_latency_ms
|
device.send_latency_ms = send_latency_ms
|
||||||
if rgbw is not None:
|
if rgbw is not None:
|
||||||
device.rgbw = rgbw
|
device.rgbw = rgbw
|
||||||
|
if zone_mode is not None:
|
||||||
|
device.zone_mode = zone_mode
|
||||||
|
|
||||||
device.updated_at = datetime.utcnow()
|
device.updated_at = datetime.utcnow()
|
||||||
self.save()
|
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>
|
<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>
|
<select id="device-serial-port" onfocus="onSerialPortFocus()"></select>
|
||||||
</div>
|
</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="form-group" id="device-led-count-group" style="display: none;">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
|
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
|
||||||
|
|||||||
@@ -31,6 +31,31 @@
|
|||||||
<select id="settings-serial-port"></select>
|
<select id="settings-serial-port"></select>
|
||||||
</div>
|
</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="form-group" id="settings-led-count-group" style="display: none;">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
|
<label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user