From 4940007e548c6bfa10bef3a6eeb8089a2388660c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 11 Apr 2026 02:26:56 +0300 Subject: [PATCH] feat: add Group device type for combining multiple devices 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). --- TODO.md | 28 ++ .../src/wled_controller/api/routes/devices.py | 212 +++++++--- .../wled_controller/api/schemas/devices.py | 193 ++++++--- .../core/devices/group_client.py | 197 +++++++++ .../core/devices/group_provider.py | 48 +++ .../core/devices/led_client.py | 22 + .../core/processing/processor_manager.py | 2 + .../core/processing/target_processor.py | 7 +- .../core/processing/wled_target_processor.py | 5 + .../src/wled_controller/static/css/cards.css | 107 +++++ server/src/wled_controller/static/js/app.ts | 4 +- .../src/wled_controller/static/js/core/api.ts | 4 + .../static/js/core/icon-paths.ts | 8 + .../wled_controller/static/js/core/icons.ts | 6 + .../static/js/features/device-discovery.ts | 209 +++++++++- .../static/js/features/devices.ts | 48 ++- .../src/wled_controller/static/js/global.d.ts | 2 + server/src/wled_controller/static/js/types.ts | 4 +- .../wled_controller/static/locales/en.json | 15 + .../wled_controller/static/locales/ru.json | 15 + .../wled_controller/static/locales/zh.json | 15 + .../wled_controller/storage/device_store.py | 142 ++++++- .../templates/modals/add-device.html | 22 + .../templates/modals/device-settings.html | 22 + server/tests/test_group_device.py | 383 ++++++++++++++++++ 25 files changed, 1595 insertions(+), 125 deletions(-) create mode 100644 TODO.md create mode 100644 server/src/wled_controller/core/devices/group_client.py create mode 100644 server/src/wled_controller/core/devices/group_provider.py create mode 100644 server/tests/test_group_device.py 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 @@
+ + +