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

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

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

View File

@@ -24,6 +24,8 @@ from wled_controller.api.schemas.devices import (
DeviceUpdate,
DiscoveredDeviceResponse,
DiscoverDevicesResponse,
OpenRGBZoneResponse,
OpenRGBZonesResponse,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
@@ -48,6 +50,7 @@ def _device_to_response(device) -> DeviceResponse:
auto_shutdown=device.auto_shutdown,
send_latency_ms=device.send_latency_ms,
rgbw=device.rgbw,
zone_mode=device.zone_mode,
capabilities=sorted(get_device_capabilities(device.device_type)),
created_at=device.created_at,
updated_at=device.updated_at,
@@ -122,6 +125,7 @@ async def create_device(
auto_shutdown=auto_shutdown,
send_latency_ms=device_data.send_latency_ms or 0,
rgbw=device_data.rgbw or False,
zone_mode=device_data.zone_mode or "combined",
)
# WS devices: auto-set URL to ws://{device_id}
@@ -137,6 +141,7 @@ async def create_device(
device_type=device.device_type,
baud_rate=device.baud_rate,
auto_shutdown=device.auto_shutdown,
zone_mode=device.zone_mode,
)
return _device_to_response(device)
@@ -213,6 +218,53 @@ async def discover_devices(
)
@router.get("/api/v1/devices/openrgb-zones", response_model=OpenRGBZonesResponse, tags=["Devices"])
async def get_openrgb_zones(
_auth: AuthRequired,
url: str = Query(..., description="Base OpenRGB URL (e.g. openrgb://localhost:6742/0)"),
):
"""List available zones on an OpenRGB device."""
import asyncio
from wled_controller.core.devices.openrgb_client import parse_openrgb_url
host, port, device_index, _zones = parse_openrgb_url(url)
def _fetch_zones():
from openrgb import OpenRGBClient
client = OpenRGBClient(host, port, name="WLED Controller (zones)")
try:
devices = client.devices
if device_index >= len(devices):
raise ValueError(
f"Device index {device_index} out of range "
f"(server has {len(devices)} device(s))"
)
device = devices[device_index]
zone_type_map = {0: "single", 1: "linear", 2: "matrix"}
zones = []
for z in device.zones:
zt = zone_type_map.get(getattr(z, "type", -1), "unknown")
zones.append(OpenRGBZoneResponse(
name=z.name,
led_count=len(z.leds),
zone_type=zt,
))
return device.name, zones
finally:
client.disconnect()
try:
device_name, zones = await asyncio.to_thread(_fetch_zones)
return OpenRGBZonesResponse(device_name=device_name, zones=zones)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception as e:
logger.error(f"Failed to list OpenRGB zones: {e}")
raise HTTPException(status_code=502, detail=f"Cannot reach OpenRGB server: {e}")
@router.get("/api/v1/devices/batch/states", tags=["Devices"])
async def batch_device_states(
_auth: AuthRequired,
@@ -255,6 +307,7 @@ async def update_device(
auto_shutdown=update_data.auto_shutdown,
send_latency_ms=update_data.send_latency_ms,
rgbw=update_data.rgbw,
zone_mode=update_data.zone_mode,
)
# Sync connection info in processor manager
@@ -268,9 +321,12 @@ async def update_device(
except ValueError:
pass
# Sync auto_shutdown in runtime state
if update_data.auto_shutdown is not None and device_id in manager._devices:
manager._devices[device_id].auto_shutdown = update_data.auto_shutdown
# Sync auto_shutdown and zone_mode in runtime state
if device_id in manager._devices:
if update_data.auto_shutdown is not None:
manager._devices[device_id].auto_shutdown = update_data.auto_shutdown
if update_data.zone_mode is not None:
manager._devices[device_id].zone_mode = update_data.zone_mode
return _device_to_response(device)

View File

@@ -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")

View File

@@ -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.

View File

@@ -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)

View File

@@ -30,11 +30,12 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
return {"health_check", "auto_restore", "static_color"}
def create_client(self, url: str, **kwargs) -> LEDClient:
zone_mode = kwargs.pop("zone_mode", "combined")
kwargs.pop("led_count", None)
kwargs.pop("baud_rate", None)
kwargs.pop("send_latency_ms", None)
kwargs.pop("rgbw", None)
return OpenRGBLEDClient(url, **kwargs)
return OpenRGBLEDClient(url, zone_mode=zone_mode, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return await OpenRGBLEDClient.check_health(url, http_client, prev_health)
@@ -48,7 +49,7 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
Raises:
Exception on validation failure.
"""
host, port, device_index = parse_openrgb_url(url)
host, port, device_index, zone_names = parse_openrgb_url(url)
def _validate_sync():
from openrgb import OpenRGBClient
@@ -62,10 +63,26 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
f"(server has {len(devices)} device(s))"
)
device = devices[device_index]
led_count = len(device.leds)
logger.info(
f"OpenRGB device validated: '{device.name}' ({led_count} LEDs)"
)
if zone_names:
filt_set = {n.lower() for n in zone_names}
matching = [z for z in device.zones if z.name.lower() in filt_set]
if not matching:
avail = [z.name for z in device.zones]
raise ValueError(
f"Zone(s) {zone_names} not found on '{device.name}'. "
f"Available zones: {avail}"
)
led_count = sum(len(z.leds) for z in matching)
logger.info(
f"OpenRGB device validated: '{device.name}' "
f"zones {zone_names} ({led_count} LEDs)"
)
else:
led_count = len(device.leds)
logger.info(
f"OpenRGB device validated: '{device.name}' ({led_count} LEDs)"
)
return {"led_count": led_count}
finally:
client.disconnect()
@@ -111,8 +128,8 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
return await asyncio.to_thread(_discover_sync)
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
"""Set all LEDs on the OpenRGB device to a solid color."""
host, port, device_index = parse_openrgb_url(url)
"""Set all LEDs on the OpenRGB device (or target zone) to a solid color."""
host, port, device_index, zone_names = parse_openrgb_url(url)
def _set_color_sync():
from openrgb import OpenRGBClient
@@ -125,7 +142,15 @@ class OpenRGBDeviceProvider(LEDDeviceProvider):
raise ValueError(f"Device index {device_index} out of range")
device = devices[device_index]
color_obj = RGBColor(color[0], color[1], color[2])
device.set_color(color_obj)
# Use zone-level calls for better motherboard compatibility
if zone_names:
filt_set = {n.lower() for n in zone_names}
for zone in device.zones:
if zone.name.lower() in filt_set:
zone.set_color(color_obj)
else:
for zone in device.zones:
zone.set_color(color_obj)
finally:
client.disconnect()

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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';
}

View File

@@ -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}`;
}
}
}

View File

@@ -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}`);
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%
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
if (bLabel) {

View File

@@ -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:",

View File

@@ -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": "Имя Устройства:",

View File

@@ -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": "设备名称:",

View File

@@ -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.

View File

@@ -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()

View File

@@ -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>

View File

@@ -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>