diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..f202f61 --- /dev/null +++ b/TODO.md @@ -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 diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 79bb786..6723f6d 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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=. """ 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) - - diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index a09be11..e1d460c 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -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): diff --git a/server/src/wled_controller/core/devices/group_client.py b/server/src/wled_controller/core/devices/group_client.py new file mode 100644 index 0000000..5fc700b --- /dev/null +++ b/server/src/wled_controller/core/devices/group_client.py @@ -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) diff --git a/server/src/wled_controller/core/devices/group_provider.py b/server/src/wled_controller/core/devices/group_provider.py new file mode 100644 index 0000000..cd49bda --- /dev/null +++ b/server/src/wled_controller/core/devices/group_provider.py @@ -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 [] diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index 71671f1..1f58b80 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -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() diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 1bf7e5c..dc09641 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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]: diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index f39739b..6ce1697 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -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 diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index be16c30..3a1899e 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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() diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index e2c70b8..87428ec 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -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; diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index 273abae..9127c58 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -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, diff --git a/server/src/wled_controller/static/js/core/api.ts b/server/src/wled_controller/static/js/core/api.ts index 8346058..2a8c04d 100644 --- a/server/src/wled_controller/static/js/core/api.ts +++ b/server/src/wled_controller/static/js/core/api.ts @@ -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 diff --git a/server/src/wled_controller/static/js/core/icon-paths.ts b/server/src/wled_controller/static/js/core/icon-paths.ts index 4efdaba..7734209 100644 --- a/server/src/wled_controller/static/js/core/icon-paths.ts +++ b/server/src/wled_controller/static/js/core/icon-paths.ts @@ -113,3 +113,11 @@ export const pickaxe = ''; // Lucide: circle-dot (status indicator) export const circleDot = ''; +export const layers = ''; +// Lucide: chevron-up / chevron-down (reorder arrows) +export const chevronUp = ''; +export const chevronDown = ''; +// Lucide: plus (add button) +export const plus = ''; +// Lucide: git-merge (sequence mode icon) +export const gitMerge = ''; diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index 75a318e..79d9171 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -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 ───────────────────────────────── diff --git a/server/src/wled_controller/static/js/features/device-discovery.ts b/server/src/wled_controller/static/js/features/device-discovery.ts index b73dfed..8954865 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.ts +++ b/server/src/wled_controller/static/js/features/device-discovery.ts @@ -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 = {}; + +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 = + `#${list.children.length + 1}` + + `
` + + `${ICON_PLUS}` + + `${t('device.group.select_device')}` + + `` + + `
` + + `` + + `` + + `` + + `` + + ``; + 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; + 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 => { diff --git a/server/src/wled_controller/static/js/features/devices.ts b/server/src/wled_controller/static/js/features/devices.ts index 28c0f63..ecf16c2 100644 --- a/server/src/wled_controller/static/js/features/devices.ts +++ b/server/src/wled_controller/static/js/features/devices.ts @@ -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; + 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}`, { diff --git a/server/src/wled_controller/static/js/global.d.ts b/server/src/wled_controller/static/js/global.d.ts index 9c5d666..dc12201 100644 --- a/server/src/wled_controller/static/js/global.d.ts +++ b/server/src/wled_controller/static/js/global.d.ts @@ -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; diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 5125be5..f75b009 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -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; } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index ac7c205..6127e79 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 79ae1ad..7c1e021 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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:", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index ca16cb5..63a5d83 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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:", diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index 65cd4bb..22d0199 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -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() diff --git a/server/src/wled_controller/templates/modals/add-device.html b/server/src/wled_controller/templates/modals/add-device.html index 727c9b5..2e89d00 100644 --- a/server/src/wled_controller/templates/modals/add-device.html +++ b/server/src/wled_controller/templates/modals/add-device.html @@ -41,6 +41,7 @@ +
@@ -241,6 +242,27 @@
+ + +