feat: add Group device type for combining multiple devices
Lint & Test / test (push) Successful in 2m19s

Introduces a new "group" device type that aggregates multiple physical
(or nested group) devices into one virtual device. Supports two modes:
- Sequence: LEDs concatenated end-to-end (led_count = sum of children)
- Independent: full pixel array resampled to each child independently

Includes cycle detection (DFS) to prevent circular group references,
delete protection for devices referenced by groups, recursive LED count
resolution for nested groups, and reorder controls (move up/down) for
child devices in the UI.

Backend: Device model, API schemas, GroupLEDClient, GroupDeviceProvider,
route validation, processing pipeline integration.
Frontend: type picker, child device picker with reorder, mode selector,
i18n (en/ru/zh), layers icon, CSS for group child rows.
Tests: 20 unit tests for cycle detection, LED count resolution, and
GroupLEDClient (sequence slicing, independent resampling, cleanup).
This commit is contained in:
2026-04-11 02:26:56 +03:00
parent 92585e7c19
commit 4940007e54
25 changed files with 1595 additions and 125 deletions
+28
View File
@@ -0,0 +1,28 @@
# Group Device Type Implementation
## Phase 1: Storage Layer
- [x] Add `group_device_ids`, `group_mode` fields to Device model
- [x] Add cycle detection + led_count resolution + group reference helpers to DeviceStore
## Phase 2: API Schemas
- [x] Add group fields to DeviceCreate, DeviceUpdate, DeviceResponse
## Phase 3: GroupLEDClient + Provider
- [x] Create `group_client.py` — GroupLEDClient (sequence slice / independent resample)
- [x] Create `group_provider.py` — GroupDeviceProvider
- [x] Register group provider in `led_client.py`
## Phase 4: Routes + Processing Pipeline
- [x] Update device routes — group-specific create/update logic, delete protection, cycle validation
- [x] Add group fields to DeviceInfo + _DEVICE_FIELD_DEFAULTS
- [x] Pass group context (device_store, group fields) to create_led_client
## Phase 5: Tests
- [x] Unit tests for cycle detection, led_count resolution, GroupLEDClient (20 tests, all passing)
## Phase 6: Frontend
- [x] Group device UI (child picker, mode selector, hide URL for groups)
- [x] i18n keys (en, ru, zh)
- [x] TypeScript types + API helper
- [x] Icon (layers) for group device type
- [x] CSS for group child rows
+162 -50
View File
@@ -67,6 +67,8 @@ def _device_to_response(device) -> DeviceResponse:
chroma_device_type=device.chroma_device_type,
gamesense_device_type=device.gamesense_device_type,
default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids,
group_mode=device.group_mode,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -74,6 +76,7 @@ def _device_to_response(device) -> DeviceResponse:
# ===== DEVICE MANAGEMENT ENDPOINTS =====
@router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201)
async def create_device(
device_data: DeviceCreate,
@@ -86,44 +89,85 @@ async def create_device(
device_type = device_data.device_type
logger.info(f"Creating {device_type} device: {device_data.name}")
device_url = device_data.url.rstrip("/")
# Validate via provider
try:
provider = get_provider(device_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Unsupported device type: {device_type}"
)
try:
result = await provider.validate_device(device_url)
led_count = result.get("led_count") or device_data.led_count
# ── Group device: validate children + compute LED count ──
if device_type == "group":
group_device_ids = device_data.group_device_ids or []
group_mode = device_data.group_mode or "sequence"
if not group_device_ids:
raise HTTPException(
status_code=422,
detail="Group device requires at least one child device.",
)
if group_mode not in ("sequence", "independent"):
raise HTTPException(
status_code=422,
detail=f"Invalid group mode: {group_mode}. Must be 'sequence' or 'independent'.",
)
# Validate all children exist and no cycles
try:
store.validate_group_no_cycles(None, group_device_ids)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
# Compute LED count
if group_mode == "sequence":
led_count = store.resolve_group_led_count(group_device_ids)
else:
led_count = device_data.led_count or store.resolve_group_max_led_count(
group_device_ids
)
if not led_count or led_count < 1:
raise HTTPException(
status_code=422,
detail="LED count is required for this device type.",
detail="Could not determine LED count for group device.",
)
device_url = "group://virtual"
else:
group_device_ids = []
group_mode = "sequence"
if not device_data.url:
raise HTTPException(
status_code=422,
detail="URL is required for non-group device types.",
)
device_url = device_data.url.rstrip("/")
# ── Non-group: validate via provider ──
if device_type != "group":
# Validate via provider
try:
provider = get_provider(device_type)
except ValueError:
raise HTTPException(
status_code=400, detail=f"Unsupported device type: {device_type}"
)
try:
result = await provider.validate_device(device_url)
led_count = result.get("led_count") or device_data.led_count
if not led_count or led_count < 1:
raise HTTPException(
status_code=422,
detail="LED count is required for this device type.",
)
except httpx.ConnectError:
raise HTTPException(
status_code=422,
detail=f"Cannot reach {device_type} device at {device_url}. Check the URL and ensure the device is powered on.",
)
except httpx.TimeoutException:
raise HTTPException(
status_code=422,
detail=f"Connection to {device_url} timed out. Check network connectivity.",
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Failed to connect to {device_type} device at {device_url}: {e}",
)
except httpx.ConnectError:
raise HTTPException(
status_code=422,
detail=f"Cannot reach {device_type} device at {device_url}. Check the URL and ensure the device is powered on."
)
except httpx.TimeoutException:
raise HTTPException(
status_code=422,
detail=f"Connection to {device_url} timed out. Check network connectivity."
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
)
# Resolve auto_shutdown default: False for all types
auto_shutdown = device_data.auto_shutdown
@@ -154,6 +198,8 @@ async def create_device(
spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink",
gamesense_device_type=device_data.gamesense_device_type or "keyboard",
group_device_ids=group_device_ids,
group_mode=group_mode,
)
# WS devices: auto-set URL to ws://{device_id}
@@ -274,11 +320,13 @@ async def get_openrgb_zones(
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,
))
zones.append(
OpenRGBZoneResponse(
name=z.name,
led_count=len(z.leds),
zone_type=zt,
)
)
return device.name, zones
finally:
client.disconnect()
@@ -326,6 +374,38 @@ async def update_device(
):
"""Update device information."""
try:
# Group-specific validation before applying update
existing = store.get_device(device_id)
is_group = existing.device_type == "group"
if is_group:
new_children = update_data.group_device_ids
new_mode = update_data.group_mode or existing.group_mode
if new_children is not None:
if not new_children:
raise HTTPException(
status_code=422,
detail="Group device requires at least one child device.",
)
try:
store.validate_group_no_cycles(device_id, new_children)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
effective_children = (
new_children if new_children is not None else existing.group_device_ids
)
effective_mode = new_mode
# Auto-recompute led_count for sequence mode
if effective_mode == "sequence":
update_data.led_count = store.resolve_group_led_count(effective_children)
elif (
update_data.led_count is None
and new_mode == "independent"
and new_children is not None
):
update_data.led_count = store.resolve_group_max_led_count(effective_children)
device = store.update_device(
device_id=device_id,
name=update_data.name,
@@ -350,6 +430,8 @@ async def update_device(
spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type,
gamesense_device_type=update_data.gamesense_device_type,
group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode,
)
# Sync connection info in processor manager
@@ -398,7 +480,16 @@ async def delete_device(
names = ", ".join(t.name for t in refs)
raise HTTPException(
status_code=409,
detail=f"Device is referenced by target(s): {names}. Delete the target(s) first."
detail=f"Device is referenced by target(s): {names}. Delete the target(s) first.",
)
# Check if any group references this device
group_refs = store.get_groups_referencing(device_id)
if group_refs:
names = ", ".join(g.name for g in group_refs)
raise HTTPException(
status_code=409,
detail=f"Device is referenced by group(s): {names}. Remove from group(s) first.",
)
# Remove from manager
@@ -424,7 +515,10 @@ async def delete_device(
# ===== DEVICE STATE (health only) =====
@router.get("/api/v1/devices/{device_id}/state", response_model=DeviceStateResponse, tags=["Devices"])
@router.get(
"/api/v1/devices/{device_id}/state", response_model=DeviceStateResponse, tags=["Devices"]
)
async def get_device_state(
device_id: str,
_auth: AuthRequired,
@@ -445,7 +539,9 @@ async def get_device_state(
raise HTTPException(status_code=404, detail=str(e))
@router.post("/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"])
@router.post(
"/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"]
)
async def ping_device(
device_id: str,
_auth: AuthRequired,
@@ -468,6 +564,7 @@ async def ping_device(
# ===== WLED BRIGHTNESS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
async def get_device_brightness(
device_id: str,
@@ -486,7 +583,10 @@ async def get_device_brightness(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
raise HTTPException(
status_code=400,
detail=f"Brightness control is not supported for {device.device_type} devices",
)
# Return cached hardware brightness if available (updated by SET endpoint)
ds = manager.find_device_state(device_id)
@@ -522,7 +622,10 @@ async def set_device_brightness(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
raise HTTPException(
status_code=400,
detail=f"Brightness control is not supported for {device.device_type} devices",
)
bri = body.brightness
@@ -550,6 +653,7 @@ async def set_device_brightness(
# ===== POWER ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/power", tags=["Settings"])
async def get_device_power(
device_id: str,
@@ -563,7 +667,10 @@ async def get_device_power(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "power_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
raise HTTPException(
status_code=400,
detail=f"Power control is not supported for {device.device_type} devices",
)
try:
# Serial devices: use tracked state (no hardware query available)
@@ -593,7 +700,10 @@ async def set_device_power(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if "power_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices")
raise HTTPException(
status_code=400,
detail=f"Power control is not supported for {device.device_type} devices",
)
on = body.power
@@ -607,8 +717,10 @@ async def set_device_power(
else:
provider = get_provider(device.device_type)
await provider.set_power(
device.url, on,
led_count=device.led_count, baud_rate=device.baud_rate,
device.url,
on,
led_count=device.led_count,
baud_rate=device.baud_rate,
)
return {"on": on}
except Exception as e:
@@ -618,6 +730,7 @@ async def set_device_power(
# ===== WEBSOCKET DEVICE STREAM =====
@router.websocket("/api/v1/devices/{device_id}/ws")
async def device_ws_stream(
websocket: WebSocket,
@@ -630,6 +743,7 @@ async def device_ws_stream(
Auth via ?token=<api_key>.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -659,5 +773,3 @@ async def device_ws_stream(
pass
finally:
broadcaster.remove_client(device_id, websocket)
+145 -48
View File
@@ -10,34 +10,73 @@ class DeviceCreate(BaseModel):
"""Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100)
url: str = Field(description="Device URL (e.g., http://192.168.1.100 or COM3)")
url: Optional[str] = Field(
None,
description="Device URL (e.g., http://192.168.1.100 or COM3). Not required for group devices.",
)
device_type: str = Field(default="wled", description="LED device type (e.g., wled, adalight)")
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (required for adalight)")
led_count: Optional[int] = Field(
None, ge=1, le=10000, description="Number of LEDs (required for adalight)"
)
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
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)")
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")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# DMX (Art-Net / sACN) fields
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
# ESP-NOW fields
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)")
espnow_peer_mac: Optional[str] = Field(
None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)"
)
espnow_channel: Optional[int] = Field(
None, ge=1, le=14, description="ESP-NOW WiFi channel (1-14)"
)
# Philips Hue fields
hue_username: Optional[str] = Field(None, description="Hue bridge username (from pairing)")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key (hex)")
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group/zone ID")
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group/zone ID"
)
# SPI Direct fields
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed in Hz")
spi_led_type: Optional[str] = Field(None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW")
spi_speed_hz: Optional[int] = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
)
spi_led_type: Optional[str] = Field(
None, description="LED chipset: WS2812, WS2812B, WS2811, SK6812, SK6812_RGBW"
)
# Razer Chroma fields
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad")
chroma_device_type: Optional[str] = Field(
None,
description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad",
)
# SteelSeries GameSense fields
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator")
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
gamesense_device_type: Optional[str] = Field(
None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator"
)
default_css_processing_template_id: Optional[str] = Field(
None, description="Default color strip processing template ID"
)
# Group device fields
group_device_ids: Optional[List[str]] = Field(
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(
None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
)
class DeviceUpdate(BaseModel):
@@ -46,26 +85,46 @@ class DeviceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="Device URL or serial port")
enabled: Optional[bool] = Field(None, description="Whether device is enabled")
led_count: Optional[int] = Field(None, ge=1, le=10000, description="Number of LEDs (for devices with manual_led_count capability)")
led_count: Optional[int] = Field(
None,
ge=1,
le=10000,
description="Number of LEDs (for devices with manual_led_count capability)",
)
baud_rate: Optional[int] = Field(None, description="Serial baud rate (for adalight devices)")
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)")
zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate")
tags: Optional[List[str]] = None
dmx_protocol: Optional[str] = Field(None, description="DMX protocol: artnet or sacn")
dmx_start_universe: Optional[int] = Field(None, ge=0, le=32767, description="DMX start universe")
dmx_start_channel: Optional[int] = Field(None, ge=1, le=512, description="DMX start channel (1-512)")
dmx_start_universe: Optional[int] = Field(
None, ge=0, le=32767, description="DMX start universe"
)
dmx_start_channel: Optional[int] = Field(
None, ge=1, le=512, description="DMX start channel (1-512)"
)
espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address")
espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel")
hue_username: Optional[str] = Field(None, description="Hue bridge username")
hue_client_key: Optional[str] = Field(None, description="Hue entertainment client key")
hue_entertainment_group_id: Optional[str] = Field(None, description="Hue entertainment group ID")
hue_entertainment_group_id: Optional[str] = Field(
None, description="Hue entertainment group ID"
)
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
gamesense_device_type: Optional[str] = Field(None, description="GameSense device type")
default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID")
default_css_processing_template_id: Optional[str] = Field(
None, description="Default color strip processing template ID"
)
# Group device fields
group_device_ids: Optional[List[str]] = Field(
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
class CalibrationLineSchema(BaseModel):
@@ -85,44 +144,61 @@ class Calibration(BaseModel):
mode: Literal["simple", "advanced"] = Field(
default="simple",
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)"
description="Calibration mode: simple (4-edge) or advanced (multi-source lines)",
)
# Advanced mode: ordered list of lines
lines: Optional[List[CalibrationLineSchema]] = Field(
default=None,
description="Line list for advanced mode (ignored in simple mode)"
default=None, description="Line list for advanced mode (ignored in simple mode)"
)
# Simple mode fields
layout: Literal["clockwise", "counterclockwise"] = Field(
default="clockwise",
description="LED strip layout direction"
default="clockwise", description="LED strip layout direction"
)
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = Field(
default="bottom_left",
description="Starting corner of the LED strip"
default="bottom_left", description="Starting corner of the LED strip"
)
offset: int = Field(
default=0,
ge=0,
description="Number of LEDs from physical LED 0 to start corner (along strip direction)"
description="Number of LEDs from physical LED 0 to start corner (along strip direction)",
)
leds_top: int = Field(default=0, ge=0, description="Number of LEDs on the top edge")
leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge")
leds_bottom: int = Field(default=0, ge=0, description="Number of LEDs on the bottom edge")
leds_left: int = Field(default=0, ge=0, description="Number of LEDs on the left edge")
# Per-edge span: fraction of screen side covered by LEDs (0.0-1.0)
span_top_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of top edge coverage")
span_top_start: float = Field(
default=0.0, ge=0.0, le=1.0, description="Start of top edge coverage"
)
span_top_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of top edge coverage")
span_right_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of right edge coverage")
span_right_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of right edge coverage")
span_bottom_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of bottom edge coverage")
span_bottom_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of bottom edge coverage")
span_left_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of left edge coverage")
span_left_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of left edge coverage")
span_right_start: float = Field(
default=0.0, ge=0.0, le=1.0, description="Start of right edge coverage"
)
span_right_end: float = Field(
default=1.0, ge=0.0, le=1.0, description="End of right edge coverage"
)
span_bottom_start: float = Field(
default=0.0, ge=0.0, le=1.0, description="Start of bottom edge coverage"
)
span_bottom_end: float = Field(
default=1.0, ge=0.0, le=1.0, description="End of bottom edge coverage"
)
span_left_start: float = Field(
default=0.0, ge=0.0, le=1.0, description="Start of left edge coverage"
)
span_left_end: float = Field(
default=1.0, ge=0.0, le=1.0, description="End of left edge coverage"
)
# Skip LEDs at start/end of strip
skip_leds_start: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the start of the strip")
skip_leds_end: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the end of the strip")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for edge sampling")
skip_leds_start: int = Field(
default=0, ge=0, description="LEDs to skip (black out) at the start of the strip"
)
skip_leds_end: int = Field(
default=0, ge=0, description="LEDs to skip (black out) at the end of the strip"
)
border_width: int = Field(
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
)
class CalibrationTestModeRequest(BaseModel):
@@ -131,8 +207,8 @@ class CalibrationTestModeRequest(BaseModel):
edges: Dict[str, List[int]] = Field(
default_factory=dict,
description="Map of active edge names to RGB colors. "
"E.g. {'top': [255, 0, 0], 'left': [255, 255, 0]}. "
"Empty dict = exit test mode."
"E.g. {'top': [255, 0, 0], 'left': [255, 255, 0]}. "
"Empty dict = exit test mode.",
)
@@ -166,10 +242,16 @@ class DeviceResponse(BaseModel):
led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled")
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
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)")
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")
zone_mode: str = Field(
default="combined", description="OpenRGB zone mode: combined or separate"
)
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn")
@@ -184,7 +266,14 @@ class DeviceResponse(BaseModel):
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
gamesense_device_type: str = Field(default="keyboard", description="GameSense device type")
default_css_processing_template_id: str = Field(default="", description="Default color strip processing template ID")
default_css_processing_template_id: str = Field(
default="", description="Default color strip processing template ID"
)
# Group device fields
group_device_ids: List[str] = Field(
default_factory=list, description="Ordered list of child device IDs (for group device type)"
)
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -207,12 +296,18 @@ class DeviceStateResponse(BaseModel):
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
device_led_type: Optional[str] = Field(
None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)"
)
device_fps: Optional[int] = Field(
None, description="Device-reported FPS (WLED internal refresh rate)"
)
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")
test_mode_edges: List[str] = Field(
default_factory=list, description="Currently lit edges in test mode"
)
class DiscoveredDeviceResponse(BaseModel):
@@ -225,7 +320,9 @@ class DiscoveredDeviceResponse(BaseModel):
mac: str = Field(default="", description="MAC address")
led_count: Optional[int] = Field(None, description="LED count (if reachable)")
version: Optional[str] = Field(None, description="Firmware version")
already_added: bool = Field(default=False, description="Whether this device is already in the system")
already_added: bool = Field(
default=False, description="Whether this device is already in the system"
)
class DiscoverDevicesResponse(BaseModel):
@@ -0,0 +1,197 @@
"""Group LED client — distributes pixels to multiple child devices."""
from __future__ import annotations
import asyncio
from typing import List, Optional, Tuple, Union
import numpy as np
from wled_controller.core.devices.led_client import LEDClient, create_led_client
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class GroupLEDClient(LEDClient):
"""Virtual LED client that fans out pixels to multiple child devices.
Sequence mode: slices the pixel array by each child's LED count.
Independent mode: resamples the full pixel array to each child's LED count.
"""
def __init__(
self,
device_store,
device_id: str,
group_mode: str = "sequence",
group_device_ids: Optional[List[str]] = None,
**kwargs,
):
self._device_store = device_store
self._device_id = device_id
self._group_mode = group_mode
self._group_device_ids = group_device_ids or []
self._kwargs = kwargs
# Populated on connect()
self._children: List[Tuple[LEDClient, int]] = [] # (client, led_count)
self._connected = False
self._total_led_count = 0
async def connect(self) -> bool:
"""Recursively create and connect child clients."""
connected_clients: List[LEDClient] = []
try:
for child_id in self._group_device_ids:
device = self._device_store.get_device(child_id)
client = create_led_client(
device.device_type,
device.url,
led_count=device.led_count,
baud_rate=device.baud_rate,
send_latency_ms=device.send_latency_ms,
rgbw=device.rgbw,
zone_mode=device.zone_mode,
dmx_protocol=device.dmx_protocol,
dmx_start_universe=device.dmx_start_universe,
dmx_start_channel=device.dmx_start_channel,
espnow_peer_mac=device.espnow_peer_mac,
espnow_channel=device.espnow_channel,
hue_username=device.hue_username,
hue_client_key=device.hue_client_key,
hue_entertainment_group_id=device.hue_entertainment_group_id,
spi_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type,
gamesense_device_type=device.gamesense_device_type,
# Pass through for nested groups
device_store=self._device_store,
device_id=child_id,
group_mode=device.group_mode,
group_device_ids=device.group_device_ids,
)
await client.connect()
connected_clients.append(client)
# Use client-reported LED count if available
client_leds = client.device_led_count
led_count = client_leds if client_leds and client_leds > 0 else device.led_count
self._children.append((client, led_count))
self._total_led_count = sum(lc for _, lc in self._children)
self._connected = True
logger.info(
f"Group {self._device_id} connected: {len(self._children)} children, "
f"{self._total_led_count} total LEDs ({self._group_mode} mode)"
)
return True
except Exception:
# Clean up already-connected clients on failure
for c in connected_clients:
try:
await c.close()
except Exception:
pass
self._children.clear()
raise
async def close(self) -> None:
"""Close all child clients."""
for client, _ in self._children:
try:
await client.close()
except Exception as e:
logger.warning(f"Error closing child client in group {self._device_id}: {e}")
self._children.clear()
self._connected = False
@property
def is_connected(self) -> bool:
return self._connected and all(c.is_connected for c, _ in self._children)
@property
def device_led_count(self) -> Optional[int]:
if self._group_mode == "sequence":
return self._total_led_count
return None # independent mode uses user-specified led_count
async def send_pixels(
self,
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
if not self._children:
return False
pixel_array = np.asarray(pixels, dtype=np.uint8)
if self._group_mode == "sequence":
return await self._send_sequence(pixel_array, brightness)
else:
return await self._send_independent(pixel_array, brightness)
async def _send_sequence(self, pixels: np.ndarray, brightness: int) -> bool:
"""Slice pixel array and distribute sequentially to children."""
tasks = []
offset = 0
for client, led_count in self._children:
end = offset + led_count
chunk = (
pixels[offset:end]
if offset < len(pixels)
else np.zeros((led_count, 3), dtype=np.uint8)
)
# Pad with black if chunk is shorter than expected
if len(chunk) < led_count:
chunk = np.vstack(
[
chunk,
np.zeros((led_count - len(chunk), 3), dtype=np.uint8),
]
)
tasks.append(client.send_pixels(chunk, brightness))
offset = end
results = await asyncio.gather(*tasks, return_exceptions=True)
return all(r is True for r in results if not isinstance(r, Exception))
async def _send_independent(self, pixels: np.ndarray, brightness: int) -> bool:
"""Resample full pixel array independently to each child's LED count."""
n_src = len(pixels)
tasks = []
if n_src < 2:
# Single pixel or empty — broadcast same color to all
color = pixels[0] if n_src == 1 else np.array([0, 0, 0], dtype=np.uint8)
for client, led_count in self._children:
tiled = np.tile(color, (led_count, 1))
tasks.append(client.send_pixels(tiled, brightness))
else:
src_indices = np.linspace(0, 1, n_src)
for client, led_count in self._children:
dst_indices = np.linspace(0, 1, led_count)
resampled = np.column_stack(
[
np.interp(dst_indices, src_indices, pixels[:, ch])
for ch in range(pixels.shape[1])
]
).astype(np.uint8)
tasks.append(client.send_pixels(resampled, brightness))
results = await asyncio.gather(*tasks, return_exceptions=True)
return all(r is True for r in results if not isinstance(r, Exception))
async def snapshot_device_state(self) -> Optional[dict]:
"""Snapshot all children's states."""
states = {}
for i, (client, _) in enumerate(self._children):
state = await client.snapshot_device_state()
if state is not None:
states[i] = state
return states if states else None
async def restore_device_state(self, state: Optional[dict]) -> None:
"""Restore all children's states."""
if not state:
return
for i, (client, _) in enumerate(self._children):
child_state = state.get(i)
if child_state is not None:
await client.restore_device_state(child_state)
@@ -0,0 +1,48 @@
"""Group device provider — virtual device that aggregates multiple child devices."""
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.core.devices.group_client import GroupLEDClient
class GroupDeviceProvider(LEDDeviceProvider):
"""Provider for group devices that aggregate multiple child devices."""
@property
def device_type(self) -> str:
return "group"
@property
def capabilities(self) -> set:
return {"manual_led_count"}
def create_client(self, url: str, **kwargs) -> LEDClient:
device_store = kwargs.get("device_store")
device_id = kwargs.get("device_id", "")
group_mode = kwargs.get("group_mode", "sequence")
group_device_ids = kwargs.get("group_device_ids", [])
return GroupLEDClient(
device_store=device_store,
device_id=device_id,
group_mode=group_mode,
group_device_ids=group_device_ids,
)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
# Group health is aggregated by the processor manager from children's health.
# This fallback marks the group as online since it's a virtual device.
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
async def validate_device(self, url: str) -> dict:
# Group devices are virtual — no URL validation needed.
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
return []
@@ -151,6 +151,7 @@ class LEDClient(ABC):
# ===== LED DEVICE PROVIDER =====
class LEDDeviceProvider(ABC):
"""Encapsulates everything about a specific LED device type.
@@ -249,6 +250,7 @@ def get_all_providers() -> Dict[str, LEDDeviceProvider]:
# ===== FACTORY FUNCTIONS (delegate to providers) =====
def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient:
"""Factory: create the right LEDClient subclass for a device type."""
return get_provider(device_type).create_client(url, **kwargs)
@@ -274,51 +276,71 @@ def get_device_capabilities(device_type: str) -> set:
# ===== AUTO-REGISTER BUILT-IN PROVIDERS =====
def _register_builtin_providers():
from wled_controller.core.devices.wled_provider import WLEDDeviceProvider
register_provider(WLEDDeviceProvider())
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
register_provider(AdalightDeviceProvider())
from wled_controller.core.devices.ambiled_provider import AmbiLEDDeviceProvider
register_provider(AmbiLEDDeviceProvider())
from wled_controller.core.devices.mock_provider import MockDeviceProvider
register_provider(MockDeviceProvider())
from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider
register_provider(MQTTDeviceProvider())
from wled_controller.core.devices.ws_provider import WSDeviceProvider
register_provider(WSDeviceProvider())
from wled_controller.core.devices.openrgb_provider import OpenRGBDeviceProvider
register_provider(OpenRGBDeviceProvider())
from wled_controller.core.devices.dmx_provider import DMXDeviceProvider
register_provider(DMXDeviceProvider())
from wled_controller.core.devices.espnow_provider import ESPNowDeviceProvider
register_provider(ESPNowDeviceProvider())
from wled_controller.core.devices.hue_provider import HueDeviceProvider
register_provider(HueDeviceProvider())
from wled_controller.core.devices.usbhid_provider import USBHIDDeviceProvider
register_provider(USBHIDDeviceProvider())
from wled_controller.core.devices.spi_provider import SPIDeviceProvider
register_provider(SPIDeviceProvider())
from wled_controller.core.devices.chroma_provider import ChromaDeviceProvider
register_provider(ChromaDeviceProvider())
from wled_controller.core.devices.gamesense_provider import GameSenseDeviceProvider
register_provider(GameSenseDeviceProvider())
from wled_controller.core.devices.demo_provider import DemoDeviceProvider
register_provider(DemoDeviceProvider())
from wled_controller.core.devices.group_provider import GroupDeviceProvider
register_provider(GroupDeviceProvider())
_register_builtin_providers()
@@ -239,6 +239,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
"spi_led_type": "WS2812B",
"chroma_device_type": "chromalink",
"gamesense_device_type": "keyboard",
"group_device_ids": [],
"group_mode": "sequence",
}
def _get_device_info(self, device_id: str) -> Optional[DeviceInfo]:
@@ -12,9 +12,9 @@ from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
if TYPE_CHECKING:
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
@@ -100,6 +100,9 @@ class DeviceInfo:
chroma_device_type: str = "chromalink"
# SteelSeries GameSense fields
gamesense_device_type: str = "keyboard"
# Group device fields
group_device_ids: List[str] = field(default_factory=list)
group_mode: str = "sequence"
@dataclass
@@ -138,6 +138,11 @@ class WledTargetProcessor(TargetProcessor):
spi_led_type=device_info.spi_led_type,
chroma_device_type=device_info.chroma_device_type,
gamesense_device_type=device_info.gamesense_device_type,
# Group device fields
device_store=self._ctx.device_store,
device_id=device_info.device_id,
group_mode=device_info.group_mode,
group_device_ids=device_info.group_device_ids,
)
await self._led_client.connect()
@@ -746,6 +746,113 @@ body.cs-drag-active .card-drag-handle {
}
.zone-mode-option input[type="radio"] { margin: 0; }
/* Group device child rows */
.group-children-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
padding: 2px 0;
}
.group-child-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-color);
transition: border-color 0.2s, background 0.15s, box-shadow 0.2s;
}
.group-child-row:hover {
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.group-child-index {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-secondary);
min-width: 20px;
text-align: center;
opacity: 0.6;
}
.group-child-device {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
min-width: 0;
padding: 2px 0;
}
.group-child-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.group-child-icon .icon {
width: 18px;
height: 18px;
}
.group-child-name {
flex: 1;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.group-child-meta {
font-size: 0.75rem;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.group-child-empty .group-child-icon {
opacity: 0.4;
}
.group-child-empty .group-child-name {
color: var(--text-secondary);
font-style: italic;
}
.group-child-empty {
border-style: dashed;
}
.group-child-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.group-child-actions .btn-icon {
padding: 3px;
line-height: 1;
}
.group-child-actions .btn-icon .icon {
width: 14px;
height: 14px;
}
.group-child-up,
.group-child-down {
opacity: 0.5;
transition: opacity 0.15s;
}
.group-child-up:hover,
.group-child-down:hover {
opacity: 1;
}
.group-child-up:disabled,
.group-child-down:disabled {
opacity: 0.15;
cursor: default;
}
.group-child-remove {
opacity: 0.5;
transition: opacity 0.15s;
}
.group-child-remove:hover {
opacity: 1;
}
.zone-badge {
font-size: 10px;
padding: 1px 5px;
+3 -1
View File
@@ -105,7 +105,7 @@ import {
import {
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
cloneDevice,
cloneDevice, addGroupChild, addGroupChildSettings,
} from './features/device-discovery.ts';
import {
loadTargetsTab, switchTargetSubTab,
@@ -434,6 +434,8 @@ Object.assign(window, {
closeAddDeviceModal,
scanForDevices,
handleAddDevice,
addGroupChild,
addGroupChildSettings,
// targets
loadTargetsTab,
@@ -161,6 +161,10 @@ export function isGameSenseDevice(type: string) {
return type === 'gamesense';
}
export function isGroupDevice(type: string) {
return type === 'group';
}
export function handle401Error() {
if (!authRequired) return; // Auth disabled — ignore 401s
if (!apiKey) return; // Already handled or no session
@@ -113,3 +113,11 @@ export const pickaxe = '<path d="M14.531 12.469 6.619 20.38a1 1 0 1 1-3-3l7
export const rocketIcon = '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>';
// Lucide: circle-dot (status indicator)
export const circleDot = '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>';
export const layers = '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>';
// Lucide: chevron-up / chevron-down (reorder arrows)
export const chevronUp = '<path d="m18 15-6-6-6 6"/>';
export const chevronDown = '<path d="m6 9 6 6 6-6"/>';
// Lucide: plus (add button)
export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
// Lucide: git-merge (sequence mode icon)
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
@@ -50,6 +50,7 @@ const _deviceTypeIcons = {
dmx: _svg(P.radio), mock: _svg(P.wrench),
espnow: _svg(P.radio), hue: _svg(P.lightbulb), usbhid: _svg(P.usb),
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
group: _svg(P.layers),
};
const _engineTypeIcons = {
mss: _svg(P.monitor), dxcam: _svg(P.zap), bettercam: _svg(P.rocket),
@@ -335,6 +336,11 @@ export const ICON_FILE_AUDIO = _svg(P.fileAudio);
export const ICON_ASSET = _svg(P.packageIcon);
export const ICON_HEART = _svg(P.heart);
export const ICON_GITHUB = _svg(P.github);
export const ICON_CHEVRON_UP = _svg(P.chevronUp);
export const ICON_CHEVRON_DOWN = _svg(P.chevronDown);
export const ICON_PLUS = _svg(P.plus);
export const ICON_GIT_MERGE = _svg(P.gitMerge);
export const ICON_COPY = _svg(P.copy);
// ── Game integration icons ─────────────────────────────────
@@ -7,14 +7,14 @@ import {
_discoveryCache, set_discoveryCache,
csptCache,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.ts';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { _computeMaxFps, _renderFpsHint } from './devices.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY } from '../core/icons.ts';
import { EntitySelect, EntityPalette } from '../core/entity-palette.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
class AddDeviceModal extends Modal {
@@ -36,6 +36,8 @@ class AddDeviceModal extends Modal {
dmxProtocol: (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet',
dmxStartUniverse: (document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0',
dmxStartChannel: (document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1',
groupChildren: JSON.stringify(_getGroupChildIds('device')),
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
};
}
}
@@ -44,7 +46,7 @@ const addDeviceModal = new AddDeviceModal();
/* ── Icon-grid type selector ──────────────────────────────────── */
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'usbhid', 'spi', 'chroma', 'gamesense', 'mock'];
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
function _buildDeviceTypeItems() {
return DEVICE_TYPE_KEYS.map(key => ({
@@ -230,6 +232,7 @@ export function onDeviceTypeChanged() {
_showSpiFields(false);
_showChromaFields(false);
_showGameSenseFields(false);
_showGroupFields(false);
if (isMqttDevice(deviceType)) {
// MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery
@@ -455,6 +458,24 @@ export function onDeviceTypeChanged() {
} else {
scanForDevices();
}
} else if (isGroupDevice(deviceType)) {
// Group: hide URL/serial, show child device picker + mode + LED count (for independent)
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = 'none';
baudRateGroup.style.display = 'none';
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
if (discoverySection) discoverySection.style.display = 'none';
if (scanBtn) scanBtn.style.display = 'none';
_showGroupFields(true);
ensureGroupModeIconSelect('device-group-mode-select');
_updateGroupLedCountVisibility();
// Clear children list for fresh start
const childrenList = document.getElementById('device-group-children-list');
if (childrenList) childrenList.innerHTML = '';
} else {
urlGroup.style.display = '';
urlInput.setAttribute('required', '');
@@ -768,7 +789,9 @@ export async function handleAddDevice(event: any) {
const error = document.getElementById('add-device-error') as HTMLElement;
let url;
if (isMockDevice(deviceType)) {
if (isGroupDevice(deviceType)) {
url = 'group://virtual';
} else if (isMockDevice(deviceType)) {
url = 'mock://';
} else if (isWsDevice(deviceType)) {
url = 'ws://';
@@ -794,7 +817,7 @@ export async function handleAddDevice(event: any) {
url = _appendZonesToUrl(url, checkedZones);
}
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) {
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !isGroupDevice(deviceType) && !url)) {
error.textContent = t('device_discovery.error.fill_all_fields');
error.style.display = 'block';
return;
@@ -846,6 +869,15 @@ export async function handleAddDevice(event: any) {
if (isGameSenseDevice(deviceType)) {
body.gamesense_device_type = (document.getElementById('device-gamesense-device-type') as HTMLSelectElement)?.value || 'keyboard';
}
if (isGroupDevice(deviceType)) {
body.group_device_ids = _getGroupChildIds('device');
body.group_mode = (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence';
if (body.group_device_ids.length === 0) {
error.textContent = t('device.group.error.no_children');
error.style.display = 'block';
return;
}
}
const csptId = (document.getElementById('device-css-processing-template') as HTMLSelectElement)?.value;
if (csptId) body.default_css_processing_template_id = csptId;
if (lastTemplateId) body.capture_template_id = lastTemplateId;
@@ -994,6 +1026,171 @@ export function _getZoneMode(radioName = 'device-zone-mode') {
/* ── New device type field visibility helpers ──────────────────── */
function _showGroupFields(show: boolean, prefix = 'device') {
const ids = [`${prefix}-group-children-group`, `${prefix}-group-mode-group`];
ids.forEach(id => {
const el = document.getElementById(id) as HTMLElement;
if (el) el.style.display = show ? '' : 'none';
});
}
/* ── Icon-grid group mode selector ────────────────────────────── */
function _buildGroupModeItems() {
return [
{ value: 'sequence', icon: ICON_GIT_MERGE, label: t('device.group.mode.sequence'), desc: t('device.group.mode.sequence.desc') },
{ value: 'independent', icon: ICON_COPY, label: t('device.group.mode.independent'), desc: t('device.group.mode.independent.desc') },
];
}
const _groupModeIconSelects: Record<string, any> = {};
export function ensureGroupModeIconSelect(selectId: string) {
const sel = document.getElementById(selectId);
if (!sel) return;
if (_groupModeIconSelects[selectId]) {
_groupModeIconSelects[selectId].updateItems(_buildGroupModeItems());
return;
}
_groupModeIconSelects[selectId] = new IconSelect({
target: sel as HTMLSelectElement,
items: _buildGroupModeItems(),
columns: 2,
onChange: () => _updateGroupLedCountVisibility(selectId.startsWith('settings-') ? 'settings' : 'device'),
} as any);
}
export function destroyGroupModeIconSelect(selectId: string) {
if (_groupModeIconSelects[selectId]) {
_groupModeIconSelects[selectId].destroy();
delete _groupModeIconSelects[selectId];
}
}
function _updateGroupLedCountVisibility(prefix = 'device') {
const sel = document.getElementById(`${prefix}-group-mode-select`) as HTMLSelectElement;
const mode = sel ? sel.value : 'sequence';
const ledCountGroup = document.getElementById(`${prefix}-led-count-group`) as HTMLElement;
if (ledCountGroup) ledCountGroup.style.display = mode === 'independent' ? '' : 'none';
}
function _renderGroupChildrenList(prefix = 'device') {
const list = document.getElementById(`${prefix}-group-children-list`) as HTMLElement;
if (!list) return;
const items = list.querySelectorAll('.group-child-row');
const count = items.length;
items.forEach((row, idx) => {
const label = row.querySelector('.group-child-index') as HTMLElement;
if (label) label.textContent = `#${idx + 1}`;
const upBtn = row.querySelector('.group-child-up') as HTMLButtonElement;
const downBtn = row.querySelector('.group-child-down') as HTMLButtonElement;
if (upBtn) upBtn.disabled = idx === 0;
if (downBtn) downBtn.disabled = idx === count - 1;
});
}
function _moveGroupChild(row: HTMLElement, direction: 'up' | 'down') {
const list = row.parentElement;
if (!list) return;
if (direction === 'up' && row.previousElementSibling) {
list.insertBefore(row, row.previousElementSibling);
} else if (direction === 'down' && row.nextElementSibling) {
list.insertBefore(row.nextElementSibling, row);
}
const prefix = list.id.startsWith('settings-') ? 'settings' : 'device';
_renderGroupChildrenList(prefix);
}
function _getDeviceItems() {
return (devicesCache.data || []).map((d: any) => ({
value: d.id,
label: d.name,
icon: getDeviceTypeIcon(d.device_type),
desc: `${d.led_count} LEDs`,
}));
}
function _updateGroupChildDisplay(row: HTMLElement, deviceId: string) {
const iconEl = row.querySelector('.group-child-icon') as HTMLElement;
const nameEl = row.querySelector('.group-child-name') as HTMLElement;
const metaEl = row.querySelector('.group-child-meta') as HTMLElement;
const device = (devicesCache.data || []).find((d: any) => d.id === deviceId);
if (device) {
iconEl.innerHTML = getDeviceTypeIcon(device.device_type);
nameEl.textContent = device.name;
metaEl.textContent = `${device.led_count} LEDs`;
row.classList.remove('group-child-empty');
row.dataset.deviceId = device.id;
} else {
iconEl.innerHTML = ICON_PLUS;
nameEl.textContent = t('device.group.select_device');
metaEl.textContent = '';
row.classList.add('group-child-empty');
row.dataset.deviceId = '';
}
}
async function _pickDeviceForRow(row: HTMLElement, prefix: string) {
const currentId = row.dataset.deviceId || '';
const picked = await EntityPalette.pick({
items: _getDeviceItems(),
current: currentId,
placeholder: t('device.group.select_device'),
});
if (picked != null) {
_updateGroupChildDisplay(row, picked as string);
}
}
function _addGroupChildRow(prefix = 'device', selectedId = '') {
const list = document.getElementById(`${prefix}-group-children-list`) as HTMLElement;
if (!list) return;
const row = document.createElement('div');
row.className = 'group-child-row' + (selectedId ? '' : ' group-child-empty');
row.dataset.deviceId = selectedId;
row.innerHTML =
`<span class="group-child-index">#${list.children.length + 1}</span>` +
`<div class="group-child-device" title="${t('device.group.select_device')}">` +
`<span class="group-child-icon">${ICON_PLUS}</span>` +
`<span class="group-child-name">${t('device.group.select_device')}</span>` +
`<span class="group-child-meta"></span>` +
`</div>` +
`<span class="group-child-actions">` +
`<button type="button" class="btn btn-sm btn-icon group-child-up" title="${t('device.group.move_up')}">${ICON_CHEVRON_UP}</button>` +
`<button type="button" class="btn btn-sm btn-icon group-child-down" title="${t('device.group.move_down')}">${ICON_CHEVRON_DOWN}</button>` +
`<button type="button" class="btn btn-sm btn-icon btn-danger group-child-remove" title="${t('common.remove')}">${ICON_TRASH}</button>` +
`</span>`;
if (selectedId) _updateGroupChildDisplay(row, selectedId);
row.querySelector('.group-child-device')!.addEventListener('click', () => _pickDeviceForRow(row, prefix));
row.querySelector('.group-child-up')!.addEventListener('click', () => _moveGroupChild(row, 'up'));
row.querySelector('.group-child-down')!.addEventListener('click', () => _moveGroupChild(row, 'down'));
row.querySelector('.group-child-remove')!.addEventListener('click', () => {
row.remove();
_renderGroupChildrenList(prefix);
});
list.appendChild(row);
_renderGroupChildrenList(prefix);
}
function _getGroupChildIds(prefix = 'device'): string[] {
const list = document.getElementById(`${prefix}-group-children-list`) as HTMLElement;
if (!list) return [];
const rows = list.querySelectorAll('.group-child-row') as NodeListOf<HTMLElement>;
return Array.from(rows).map(r => r.dataset.deviceId || '').filter(v => v !== '');
}
export function addGroupChild() {
_addGroupChildRow('device');
}
export function addGroupChildSettings() {
_addGroupChildRow('settings');
}
export function addGroupChildSettingsWithId(deviceId: string) {
_addGroupChildRow('settings', deviceId);
}
function _showEspnowFields(show: boolean) {
const ids = ['device-espnow-peer-mac-group', 'device-espnow-channel-group'];
ids.forEach(id => {
@@ -6,9 +6,9 @@ import {
_deviceBrightnessCache, updateDeviceBrightness,
csptCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isGroupDevice } from '../core/api.ts';
import { devicesCache } from '../core/state.ts';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect } from './device-discovery.ts';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect } from './device-discovery.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -71,6 +71,9 @@ class DeviceSettingsModal extends Modal {
}
_getUrl() {
if (isGroupDevice(this.deviceType)) {
return 'group://virtual';
}
if (isMockDevice(this.deviceType)) {
const deviceId = (this.$('settings-device-id') as HTMLInputElement | null)?.value || '';
return `mock://${deviceId}`;
@@ -287,12 +290,13 @@ export async function showSettings(deviceId: any) {
const isMock = isMockDevice(device.device_type);
const isWs = isWsDevice(device.device_type);
const isMqtt = isMqttDevice(device.device_type);
const isGroup = isGroupDevice(device.device_type);
const urlGroup = document.getElementById('settings-url-group') as HTMLElement;
const serialGroup = document.getElementById('settings-serial-port-group') as HTMLElement;
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
const urlHint = urlGroup.querySelector('.input-hint') as HTMLElement | null;
const urlInput = document.getElementById('settings-device-url') as HTMLInputElement;
if (isMock || isWs) {
if (isMock || isWs || isGroup) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = 'none';
@@ -440,6 +444,36 @@ export async function showSettings(deviceId: any) {
if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = 'none';
}
// Group device fields
const groupChildrenGroup = document.getElementById('settings-group-children-group');
const groupModeGroup = document.getElementById('settings-group-mode-group');
if (isGroup) {
if (groupChildrenGroup) (groupChildrenGroup as HTMLElement).style.display = '';
if (groupModeGroup) (groupModeGroup as HTMLElement).style.display = '';
// Set mode via select + IconSelect
const savedMode = device.group_mode || 'sequence';
const modeSelect = document.getElementById('settings-group-mode-select') as HTMLSelectElement;
if (modeSelect) modeSelect.value = savedMode;
ensureGroupModeIconSelect('settings-group-mode-select');
// Show/hide LED count based on mode
const settingsLedCountGroup = document.getElementById('settings-led-count-group') as HTMLElement;
if (settingsLedCountGroup) settingsLedCountGroup.style.display = savedMode === 'independent' ? '' : 'none';
// Populate children list
await devicesCache.fetch();
const childrenList = document.getElementById('settings-group-children-list') as HTMLElement;
if (childrenList) {
childrenList.innerHTML = '';
const childIds = device.group_device_ids || [];
childIds.forEach((childId: string) => {
_addGroupChildSettingsWithId(childId);
});
}
} else {
if (groupChildrenGroup) (groupChildrenGroup as HTMLElement).style.display = 'none';
if (groupModeGroup) (groupModeGroup as HTMLElement).style.display = 'none';
destroyGroupModeIconSelect('settings-group-mode-select');
}
// Tags
if (_deviceTagsInput) _deviceTagsInput.destroy();
_deviceTagsInput = new TagInput(document.getElementById('device-tags-container'), {
@@ -474,7 +508,8 @@ export async function saveDeviceSettings() {
const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim();
const url = settingsModal._getUrl();
if (!name || !url) {
const isGroup = isGroupDevice(settingsModal.deviceType);
if (!name || (!isGroup && !url)) {
settingsModal.showError(t('device.error.required'));
return;
}
@@ -508,6 +543,11 @@ export async function saveDeviceSettings() {
body.dmx_start_universe = parseInt((document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', 10);
body.dmx_start_channel = parseInt((document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', 10);
}
if (isGroup) {
const childRows = document.querySelectorAll('#settings-group-children-list .group-child-row') as NodeListOf<HTMLElement>;
body.group_device_ids = Array.from(childRows).map(r => r.dataset.deviceId || '').filter(v => v !== '');
body.group_mode = (document.getElementById('settings-group-mode-select') as HTMLSelectElement)?.value || 'sequence';
}
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
body.default_css_processing_template_id = csptId;
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
+2
View File
@@ -217,6 +217,8 @@ interface Window {
closeAddDeviceModal: (...args: any[]) => any;
scanForDevices: (...args: any[]) => any;
handleAddDevice: (...args: any[]) => any;
addGroupChild: (...args: any[]) => any;
addGroupChildSettings: (...args: any[]) => any;
// ─── Targets ───
loadTargetsTab: (...args: any[]) => any;
@@ -48,7 +48,7 @@ export function bindableColorSourceId(b: BindableColor | undefined): string {
export type DeviceType =
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
| 'openrgb' | 'dmx' | 'espnow' | 'hue' | 'usbhid' | 'spi'
| 'chroma' | 'gamesense';
| 'chroma' | 'gamesense' | 'group';
export interface Device {
id: string;
@@ -77,6 +77,8 @@ export interface Device {
chroma_device_type: string;
gamesense_device_type: string;
default_css_processing_template_id: string;
group_device_ids: string[];
group_mode: string;
created_at: string;
updated_at: string;
}
@@ -173,6 +173,21 @@
"device.type.chroma.desc": "Razer peripherals via Chroma SDK",
"device.type.gamesense": "SteelSeries",
"device.type.gamesense.desc": "SteelSeries peripherals via GameSense",
"device.type.group": "Group",
"device.type.group.desc": "Combine multiple devices into one virtual device",
"device.group.children": "Child Devices:",
"device.group.children.hint": "Select devices to include in this group. Order matters for sequence mode.",
"device.group.add_child": "+ Add Device",
"device.group.select_device": "Select a device",
"device.group.mode": "Group Mode:",
"device.group.mode.hint": "Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.",
"device.group.mode.sequence": "Sequence",
"device.group.mode.sequence.desc": "Concatenate LEDs end-to-end into one long strip",
"device.group.mode.independent": "Independent",
"device.group.mode.independent.desc": "Mirror the full strip to each device independently",
"device.group.move_up": "Move up",
"device.group.move_down": "Move down",
"device.group.error.no_children": "Add at least one child device to the group.",
"device.chroma.device_type": "Peripheral Type:",
"device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK",
"device.gamesense.device_type": "Peripheral Type:",
@@ -177,6 +177,21 @@
"device.type.chroma.desc": "Razer peripherals via Chroma SDK",
"device.type.gamesense": "SteelSeries",
"device.type.gamesense.desc": "SteelSeries peripherals via GameSense",
"device.type.group": "Группа",
"device.type.group.desc": "Объединение нескольких устройств в одно виртуальное",
"device.group.children": "Дочерние устройства:",
"device.group.children.hint": "Выберите устройства для группы. Порядок важен в режиме последовательности.",
"device.group.add_child": "+ Добавить устройство",
"device.group.select_device": "Выберите устройство",
"device.group.mode": "Режим группы:",
"device.group.mode.hint": "Последовательный — LEDs соединяются друг за другом. Независимый — полная лента отражается на каждое устройство.",
"device.group.mode.sequence": "Последовательный",
"device.group.mode.sequence.desc": "Объединить светодиоды в одну длинную полосу",
"device.group.mode.independent": "Независимый",
"device.group.mode.independent.desc": "Дублировать полосу на каждое устройство отдельно",
"device.group.move_up": "Вверх",
"device.group.move_down": "Вниз",
"device.group.error.no_children": "Добавьте хотя бы одно устройство в группу.",
"device.chroma.device_type": "Peripheral Type:",
"device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK",
"device.gamesense.device_type": "Peripheral Type:",
@@ -177,6 +177,21 @@
"device.type.chroma.desc": "Razer peripherals via Chroma SDK",
"device.type.gamesense": "SteelSeries",
"device.type.gamesense.desc": "SteelSeries peripherals via GameSense",
"device.type.group": "设备组",
"device.type.group.desc": "将多个设备组合为一个虚拟设备",
"device.group.children": "子设备:",
"device.group.children.hint": "选择要包含在组中的设备。顺序在序列模式下很重要。",
"device.group.add_child": "+ 添加设备",
"device.group.select_device": "选择设备",
"device.group.mode": "组模式:",
"device.group.mode.hint": "序列模式将LED首尾相连。独立模式将完整灯带镜像到每个设备。",
"device.group.mode.sequence": "序列",
"device.group.mode.sequence.desc": "将LED首尾相连合并为一条长灯带",
"device.group.mode.independent": "独立",
"device.group.mode.independent.desc": "将完整灯带独立镜像到每个设备",
"device.group.move_up": "上移",
"device.group.move_down": "下移",
"device.group.error.no_children": "请至少添加一个子设备到组中。",
"device.chroma.device_type": "Peripheral Type:",
"device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK",
"device.gamesense.device_type": "Peripheral Type:",
@@ -54,6 +54,9 @@ class Device:
gamesense_device_type: str = "keyboard",
# Default color strip processing template
default_css_processing_template_id: str = "",
# Group device fields
group_device_ids: Optional[List[str]] = None,
group_mode: str = "sequence",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -83,6 +86,8 @@ class Device:
self.chroma_device_type = chroma_device_type
self.gamesense_device_type = gamesense_device_type
self.default_css_processing_template_id = default_css_processing_template_id
self.group_device_ids = group_device_ids or []
self.group_mode = group_mode
self.created_at = created_at or datetime.now(timezone.utc)
self.updated_at = updated_at or datetime.now(timezone.utc)
@@ -138,6 +143,10 @@ class Device:
d["gamesense_device_type"] = self.gamesense_device_type
if self.default_css_processing_template_id:
d["default_css_processing_template_id"] = self.default_css_processing_template_id
if self.group_device_ids:
d["group_device_ids"] = self.group_device_ids
if self.group_mode != "sequence":
d["group_mode"] = self.group_mode
return d
@classmethod
@@ -170,23 +179,49 @@ class Device:
chroma_device_type=data.get("chroma_device_type", "chromalink"),
gamesense_device_type=data.get("gamesense_device_type", "keyboard"),
default_css_processing_template_id=data.get("default_css_processing_template_id", ""),
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
group_device_ids=data.get("group_device_ids", []),
group_mode=data.get("group_mode", "sequence"),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
updated_at=datetime.fromisoformat(
data.get("updated_at", datetime.now(timezone.utc).isoformat())
),
)
# Fields that can be updated (all Device.__init__ params except identity/timestamps)
_UPDATABLE_FIELDS: frozenset[str] = frozenset({
"name", "url", "led_count", "enabled", "device_type",
"baud_rate", "software_brightness", "auto_shutdown",
"send_latency_ms", "rgbw", "zone_mode", "tags",
"dmx_protocol", "dmx_start_universe", "dmx_start_channel",
"espnow_peer_mac", "espnow_channel",
"hue_username", "hue_client_key", "hue_entertainment_group_id",
"spi_speed_hz", "spi_led_type",
"chroma_device_type", "gamesense_device_type",
"default_css_processing_template_id",
})
_UPDATABLE_FIELDS: frozenset[str] = frozenset(
{
"name",
"url",
"led_count",
"enabled",
"device_type",
"baud_rate",
"software_brightness",
"auto_shutdown",
"send_latency_ms",
"rgbw",
"zone_mode",
"tags",
"dmx_protocol",
"dmx_start_universe",
"dmx_start_channel",
"espnow_peer_mac",
"espnow_channel",
"hue_username",
"hue_client_key",
"hue_entertainment_group_id",
"spi_speed_hz",
"spi_led_type",
"chroma_device_type",
"gamesense_device_type",
"default_css_processing_template_id",
"group_device_ids",
"group_mode",
}
)
class DeviceStore(BaseSqliteStore[Device]):
@@ -239,6 +274,8 @@ class DeviceStore(BaseSqliteStore[Device]):
spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink",
gamesense_device_type: str = "keyboard",
group_device_ids: Optional[List[str]] = None,
group_mode: str = "sequence",
) -> Device:
"""Create a new device."""
with self._lock:
@@ -274,6 +311,8 @@ class DeviceStore(BaseSqliteStore[Device]):
spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type,
gamesense_device_type=gamesense_device_type,
group_device_ids=group_device_ids or [],
group_mode=group_mode,
)
self._items[device_id] = device
@@ -326,6 +365,83 @@ class DeviceStore(BaseSqliteStore[Device]):
"""Check if device exists."""
return device_id in self._items
# ── Group helpers ───────────────────────────────────────────
def validate_group_no_cycles(
self,
device_id: Optional[str],
group_device_ids: List[str],
) -> None:
"""Raise ValueError if adding these children would create a cycle.
Uses DFS with backtracking — diamond DAGs (A→B, A→C, B→D, C→D) are
allowed; only true cycles are rejected.
"""
ancestors = set()
if device_id:
ancestors.add(device_id)
def _dfs(child_ids: List[str]) -> None:
for cid in child_ids:
if cid in ancestors:
raise ValueError(f"Circular group reference detected: {cid}")
device = self._items.get(cid)
if device is None:
raise ValueError(f"Referenced device not found: {cid}")
if device.group_device_ids:
ancestors.add(cid)
_dfs(device.group_device_ids)
ancestors.discard(cid)
_dfs(group_device_ids)
def resolve_group_led_count(
self,
device_ids: List[str],
_seen: Optional[set] = None,
) -> int:
"""Sum led_counts of devices, recursively resolving nested sequence groups."""
if _seen is None:
_seen = set()
total = 0
for did in device_ids:
if did in _seen:
continue
_seen.add(did)
device = self._items.get(did)
if device is None:
continue
if (
device.device_type == "group"
and device.group_mode == "sequence"
and device.group_device_ids
):
total += self.resolve_group_led_count(device.group_device_ids, _seen)
else:
total += device.led_count
return total
def resolve_group_max_led_count(
self,
device_ids: List[str],
) -> int:
"""Return max led_count among children (for independent mode default)."""
max_count = 0
for did in device_ids:
device = self._items.get(did)
if device is None:
continue
max_count = max(max_count, device.led_count)
return max_count or 1
def get_groups_referencing(self, device_id: str) -> List["Device"]:
"""Return all group devices whose group_device_ids contains device_id."""
return [
d
for d in self._items.values()
if d.device_type == "group" and device_id in d.group_device_ids
]
def clear(self):
"""Clear all devices (for testing)."""
self._items.clear()
@@ -41,6 +41,7 @@
<option value="chroma">Razer Chroma</option>
<option value="gamesense">SteelSeries</option>
<option value="mock">Mock</option>
<option value="group">Group</option>
</select>
</div>
<div class="form-group">
@@ -241,6 +242,27 @@
<option value="keypad">Keypad (20 keys)</option>
</select>
</div>
<!-- Group device fields -->
<div class="form-group" id="device-group-children-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.children">Child Devices:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.group.children.hint">Select devices to include in this group (order matters for sequence mode)</small>
<div id="device-group-children-list" class="group-children-list"></div>
<button type="button" class="btn btn-sm btn-secondary" id="device-group-add-child-btn" onclick="addGroupChild()" data-i18n="device.group.add_child">+ Add Device</button>
</div>
<div class="form-group" id="device-group-mode-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.mode">Group Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.group.mode.hint">Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.</small>
<select id="device-group-mode-select">
<option value="sequence">Sequence</option>
<option value="independent">Independent</option>
</select>
</div>
<!-- SteelSeries GameSense fields -->
<div class="form-group" id="device-gamesense-device-type-group" style="display: none;">
<div class="label-row">
@@ -132,6 +132,28 @@
<input type="number" id="settings-dmx-start-channel" min="1" max="512" value="1">
</div>
<!-- Group device fields -->
<div class="form-group" id="settings-group-children-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.children">Child Devices:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.group.children.hint">Select devices to include in this group (order matters for sequence mode)</small>
<div id="settings-group-children-list" class="group-children-list"></div>
<button type="button" class="btn btn-sm btn-secondary" id="settings-group-add-child-btn" onclick="addGroupChildSettings()" data-i18n="device.group.add_child">+ Add Device</button>
</div>
<div class="form-group" id="settings-group-mode-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.mode">Group Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.group.mode.hint">Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.</small>
<select id="settings-group-mode-select">
<option value="sequence">Sequence</option>
<option value="independent">Independent</option>
</select>
</div>
<div class="form-group" id="settings-health-interval-group">
<div class="label-row">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
+383
View File
@@ -0,0 +1,383 @@
"""Tests for group device type: cycle detection, LED count resolution, GroupLEDClient."""
import numpy as np
import pytest
from wled_controller.storage.database import Database
from wled_controller.storage.device_store import Device, DeviceStore
# ── Fixtures ──────────────────────────────────────────────────────────
@pytest.fixture
def tmp_db(tmp_path):
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def store(tmp_db):
return DeviceStore(tmp_db)
def _create_device(store: DeviceStore, name: str, led_count: int = 30, **kwargs) -> Device:
"""Helper to create a simple mock device."""
return store.create_device(
name=name,
url=f"mock://{name}",
led_count=led_count,
device_type=kwargs.pop("device_type", "mock"),
**kwargs,
)
# ── Cycle Detection ──────────────────────────────────────────────────
class TestCycleDetection:
def test_valid_flat_group(self, store):
d1 = _create_device(store, "d1", 30)
d2 = _create_device(store, "d2", 60)
# No cycle — should not raise
store.validate_group_no_cycles(None, [d1.id, d2.id])
def test_self_reference(self, store):
d1 = _create_device(store, "d1", 30)
with pytest.raises(ValueError, match="Circular group reference"):
store.validate_group_no_cycles(d1.id, [d1.id])
def test_simple_cycle(self, store):
d1 = _create_device(store, "d1", 30)
g1 = _create_device(
store,
"g1",
30,
device_type="group",
group_device_ids=[d1.id],
group_mode="sequence",
)
# g2 wants to contain g1, but g1 is also going to be in g2 → cycle
with pytest.raises(ValueError, match="Circular group reference"):
store.validate_group_no_cycles(g1.id, [g1.id])
def test_deep_cycle(self, store):
d1 = _create_device(store, "d1", 30)
g1 = _create_device(
store,
"g1",
30,
device_type="group",
group_device_ids=[d1.id],
group_mode="sequence",
)
g2 = _create_device(
store,
"g2",
30,
device_type="group",
group_device_ids=[g1.id],
group_mode="sequence",
)
# g3 wants g2, and we're editing g1 to contain g3 → cycle: g1→g3→g2→g1
g3 = _create_device(
store,
"g3",
30,
device_type="group",
group_device_ids=[g2.id],
group_mode="sequence",
)
with pytest.raises(ValueError, match="Circular group reference"):
store.validate_group_no_cycles(g1.id, [g3.id])
def test_diamond_dag_allowed(self, store):
"""Diamond shape (A→B, A→C, B→D, C→D) is NOT a cycle."""
d = _create_device(store, "d", 30)
g_b = _create_device(
store,
"g_b",
30,
device_type="group",
group_device_ids=[d.id],
group_mode="sequence",
)
g_c = _create_device(
store,
"g_c",
30,
device_type="group",
group_device_ids=[d.id],
group_mode="sequence",
)
# g_a contains both g_b and g_c, which both contain d — diamond, not cycle
store.validate_group_no_cycles(None, [g_b.id, g_c.id])
def test_nonexistent_child_raises(self, store):
with pytest.raises(ValueError, match="Referenced device not found"):
store.validate_group_no_cycles(None, ["nonexistent_device"])
def test_valid_nested_groups(self, store):
"""Groups can contain other groups without cycles."""
d1 = _create_device(store, "d1", 30)
d2 = _create_device(store, "d2", 60)
g_inner = _create_device(
store,
"g_inner",
90,
device_type="group",
group_device_ids=[d1.id, d2.id],
group_mode="sequence",
)
# Outer group containing inner group + another device — valid
d3 = _create_device(store, "d3", 20)
store.validate_group_no_cycles(None, [g_inner.id, d3.id])
# ── LED Count Resolution ─────────────────────────────────────────────
class TestLedCountResolution:
def test_flat_sequence(self, store):
d1 = _create_device(store, "d1", 30)
d2 = _create_device(store, "d2", 60)
d3 = _create_device(store, "d3", 10)
total = store.resolve_group_led_count([d1.id, d2.id, d3.id])
assert total == 100
def test_nested_sequence_groups(self, store):
d1 = _create_device(store, "d1", 30)
d2 = _create_device(store, "d2", 60)
g_inner = _create_device(
store,
"g_inner",
90,
device_type="group",
group_device_ids=[d1.id, d2.id],
group_mode="sequence",
)
d3 = _create_device(store, "d3", 20)
total = store.resolve_group_led_count([g_inner.id, d3.id])
assert total == 110 # 30+60+20
def test_independent_child_uses_own_led_count(self, store):
"""Independent mode child group contributes its own led_count (not recursed)."""
d1 = _create_device(store, "d1", 30)
d2 = _create_device(store, "d2", 60)
g_independent = _create_device(
store,
"g_ind",
100,
device_type="group",
group_device_ids=[d1.id, d2.id],
group_mode="independent",
)
d3 = _create_device(store, "d3", 20)
# g_independent is in independent mode, so its led_count=100 is used directly
total = store.resolve_group_led_count([g_independent.id, d3.id])
assert total == 120 # 100+20
def test_max_led_count(self, store):
d1 = _create_device(store, "d1", 30)
d2 = _create_device(store, "d2", 60)
d3 = _create_device(store, "d3", 10)
max_count = store.resolve_group_max_led_count([d1.id, d2.id, d3.id])
assert max_count == 60
def test_max_led_count_empty(self, store):
assert store.resolve_group_max_led_count([]) == 1
def test_missing_device_skipped(self, store):
d1 = _create_device(store, "d1", 30)
total = store.resolve_group_led_count([d1.id, "nonexistent"])
assert total == 30
# ── Group References ──────────────────────────────────────────────────
class TestGroupReferences:
def test_get_groups_referencing(self, store):
d1 = _create_device(store, "d1", 30)
d2 = _create_device(store, "d2", 60)
g1 = _create_device(
store,
"g1",
90,
device_type="group",
group_device_ids=[d1.id, d2.id],
group_mode="sequence",
)
g2 = _create_device(
store,
"g2",
30,
device_type="group",
group_device_ids=[d1.id],
group_mode="sequence",
)
refs = store.get_groups_referencing(d1.id)
ref_ids = {r.id for r in refs}
assert ref_ids == {g1.id, g2.id}
def test_no_groups_referencing(self, store):
d1 = _create_device(store, "d1", 30)
assert store.get_groups_referencing(d1.id) == []
# ── GroupLEDClient ────────────────────────────────────────────────────
class TestGroupLEDClient:
@pytest.fixture
def mock_store(self, store):
"""Store with 3 mock devices for client tests."""
d1 = _create_device(store, "d1", 10)
d2 = _create_device(store, "d2", 20)
d3 = _create_device(store, "d3", 30)
return store, [d1, d2, d3]
@pytest.mark.asyncio
async def test_connect_creates_children(self, mock_store):
from wled_controller.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="sequence",
group_device_ids=[d.id for d in devices],
)
await client.connect()
assert client.is_connected
assert client.device_led_count == 60 # 10+20+30
assert len(client._children) == 3
await client.close()
@pytest.mark.asyncio
async def test_sequence_mode_slices(self, mock_store):
from wled_controller.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="sequence",
group_device_ids=[d.id for d in devices],
)
await client.connect()
# Capture what each child receives
sent_pixels = []
for child_client, _ in client._children:
original_send = child_client.send_pixels
async def capture_send(pixels, brightness, _orig=original_send, _idx=len(sent_pixels)):
sent_pixels.append(np.asarray(pixels))
return await _orig(pixels, brightness)
child_client.send_pixels = capture_send
# Create a 60-pixel gradient
pixels = np.arange(60 * 3, dtype=np.uint8).reshape(60, 3)
await client.send_pixels(pixels, 255)
assert len(sent_pixels) == 3
np.testing.assert_array_equal(sent_pixels[0], pixels[0:10])
np.testing.assert_array_equal(sent_pixels[1], pixels[10:30])
np.testing.assert_array_equal(sent_pixels[2], pixels[30:60])
await client.close()
@pytest.mark.asyncio
async def test_independent_mode_resamples(self, mock_store):
from wled_controller.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="independent",
group_device_ids=[d.id for d in devices],
)
await client.connect()
sent_pixels = []
for child_client, _ in client._children:
original_send = child_client.send_pixels
async def capture_send(pixels, brightness, _orig=original_send):
sent_pixels.append(np.asarray(pixels))
return await _orig(pixels, brightness)
child_client.send_pixels = capture_send
# Send 5 pixels — each child should get its own resampled version
pixels = np.array(
[[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255]], dtype=np.uint8
)
await client.send_pixels(pixels, 255)
assert len(sent_pixels) == 3
assert sent_pixels[0].shape == (10, 3) # resampled to 10 LEDs
assert sent_pixels[1].shape == (20, 3) # resampled to 20 LEDs
assert sent_pixels[2].shape == (30, 3) # resampled to 30 LEDs
await client.close()
@pytest.mark.asyncio
async def test_close_cleans_up(self, mock_store):
from wled_controller.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="sequence",
group_device_ids=[d.id for d in devices],
)
await client.connect()
assert client.is_connected
await client.close()
assert not client.is_connected
assert len(client._children) == 0
@pytest.mark.asyncio
async def test_sequence_pads_short_pixels(self, mock_store):
from wled_controller.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="sequence",
group_device_ids=[d.id for d in devices],
)
await client.connect()
sent_pixels = []
for child_client, _ in client._children:
original_send = child_client.send_pixels
async def capture_send(pixels, brightness, _orig=original_send):
sent_pixels.append(np.asarray(pixels))
return await _orig(pixels, brightness)
child_client.send_pixels = capture_send
# Send only 15 pixels (less than 60 total needed)
pixels = np.ones((15, 3), dtype=np.uint8) * 128
await client.send_pixels(pixels, 255)
assert len(sent_pixels) == 3
assert sent_pixels[0].shape == (10, 3)
assert sent_pixels[1].shape == (20, 3)
assert sent_pixels[2].shape == (30, 3)
# First child gets full 10 pixels
np.testing.assert_array_equal(sent_pixels[0], np.ones((10, 3), dtype=np.uint8) * 128)
# Second child gets 5 real + 15 black
np.testing.assert_array_equal(sent_pixels[1][:5], np.ones((5, 3), dtype=np.uint8) * 128)
np.testing.assert_array_equal(sent_pixels[1][5:], np.zeros((15, 3), dtype=np.uint8))
await client.close()