feat: add Group device type for combining multiple devices
Lint & Test / test (push) Successful in 2m19s
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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user