"""Device routes: CRUD, health state, brightness, power, calibration, WS stream.""" import httpx from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from wled_controller.api.auth import AuthRequired from wled_controller.core.devices.led_client import ( get_all_providers, get_device_capabilities, get_provider, ) from wled_controller.api.dependencies import ( fire_entity_event, get_device_store, get_output_target_store, get_processor_manager, ) from wled_controller.api.schemas.devices import ( DeviceCreate, DeviceListResponse, DeviceResponse, DeviceStateResponse, DeviceUpdate, DiscoveredDeviceResponse, DiscoverDevicesResponse, OpenRGBZoneResponse, OpenRGBZonesResponse, ) from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.utils import get_logger from wled_controller.storage.base_store import EntityNotFoundError logger = get_logger(__name__) router = APIRouter() def _device_to_response(device) -> DeviceResponse: """Convert a Device to DeviceResponse.""" return DeviceResponse( id=device.id, name=device.name, url=device.url, device_type=device.device_type, led_count=device.led_count, enabled=device.enabled, baud_rate=device.baud_rate, auto_shutdown=device.auto_shutdown, send_latency_ms=device.send_latency_ms, rgbw=device.rgbw, zone_mode=device.zone_mode, capabilities=sorted(get_device_capabilities(device.device_type)), tags=device.tags, dmx_protocol=getattr(device, 'dmx_protocol', 'artnet'), dmx_start_universe=getattr(device, 'dmx_start_universe', 0), dmx_start_channel=getattr(device, 'dmx_start_channel', 1), espnow_peer_mac=getattr(device, 'espnow_peer_mac', ''), espnow_channel=getattr(device, 'espnow_channel', 1), hue_username=getattr(device, 'hue_username', ''), hue_client_key=getattr(device, 'hue_client_key', ''), hue_entertainment_group_id=getattr(device, 'hue_entertainment_group_id', ''), spi_speed_hz=getattr(device, 'spi_speed_hz', 800000), spi_led_type=getattr(device, 'spi_led_type', 'WS2812B'), chroma_device_type=getattr(device, 'chroma_device_type', 'chromalink'), gamesense_device_type=getattr(device, 'gamesense_device_type', 'keyboard'), created_at=device.created_at, updated_at=device.updated_at, ) # ===== DEVICE MANAGEMENT ENDPOINTS ===== @router.post("/api/v1/devices", response_model=DeviceResponse, tags=["Devices"], status_code=201) async def create_device( device_data: DeviceCreate, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Create and attach a new LED device.""" try: 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 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}" ) # Resolve auto_shutdown default: False for all types auto_shutdown = device_data.auto_shutdown if auto_shutdown is None: auto_shutdown = False # Create device in storage device = store.create_device( name=device_data.name, url=device_data.url, led_count=led_count, device_type=device_type, baud_rate=device_data.baud_rate, auto_shutdown=auto_shutdown, send_latency_ms=device_data.send_latency_ms or 0, rgbw=device_data.rgbw or False, zone_mode=device_data.zone_mode or "combined", tags=device_data.tags, dmx_protocol=device_data.dmx_protocol or "artnet", dmx_start_universe=device_data.dmx_start_universe or 0, dmx_start_channel=device_data.dmx_start_channel or 1, espnow_peer_mac=device_data.espnow_peer_mac or "", espnow_channel=device_data.espnow_channel or 1, hue_username=device_data.hue_username or "", hue_client_key=device_data.hue_client_key or "", hue_entertainment_group_id=device_data.hue_entertainment_group_id or "", spi_speed_hz=device_data.spi_speed_hz or 800000, 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", ) # WS devices: auto-set URL to ws://{device_id} if device_type == "ws": device = store.update_device(device.id, url=f"ws://{device.id}") # Register in processor manager for health monitoring manager.add_device( device_id=device.id, device_url=device.url, led_count=device.led_count, device_type=device.device_type, baud_rate=device.baud_rate, auto_shutdown=device.auto_shutdown, zone_mode=device.zone_mode, ) fire_entity_event("device", "created", device.id) return _device_to_response(device) except HTTPException: raise except Exception as e: logger.error(f"Failed to create device: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"]) async def list_devices( _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), ): """List all attached WLED devices.""" devices = store.get_all_devices() responses = [_device_to_response(d) for d in devices] return DeviceListResponse(devices=responses, count=len(responses)) @router.get("/api/v1/devices/discover", response_model=DiscoverDevicesResponse, tags=["Devices"]) async def discover_devices( _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), timeout: float = 3.0, device_type: str | None = None, ): """Scan for LED devices. Optionally filter by device_type (e.g. wled, adalight).""" import asyncio import time start = time.time() capped_timeout = min(timeout, 10.0) if device_type: # Discover from a single provider try: provider = get_provider(device_type) except ValueError: raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}") discovered = await provider.discover(timeout=capped_timeout) else: # Discover from all providers in parallel providers = get_all_providers() discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()] all_results = await asyncio.gather(*discover_tasks) discovered = [d for batch in all_results for d in batch] elapsed_ms = (time.time() - start) * 1000 existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()} results = [] for d in discovered: already_added = d.url.rstrip("/").lower() in existing_urls results.append( DiscoveredDeviceResponse( name=d.name, url=d.url, device_type=d.device_type, ip=d.ip, mac=d.mac, led_count=d.led_count, version=d.version, already_added=already_added, ) ) return DiscoverDevicesResponse( devices=results, count=len(results), scan_duration_ms=round(elapsed_ms, 1), ) @router.get("/api/v1/devices/openrgb-zones", response_model=OpenRGBZonesResponse, tags=["Devices"]) async def get_openrgb_zones( _auth: AuthRequired, url: str = Query(..., description="Base OpenRGB URL (e.g. openrgb://localhost:6742/0)"), ): """List available zones on an OpenRGB device.""" import asyncio from wled_controller.core.devices.openrgb_client import parse_openrgb_url host, port, device_index, _zones = parse_openrgb_url(url) def _fetch_zones(): from openrgb import OpenRGBClient client = OpenRGBClient(host, port, name="WLED Controller (zones)") try: devices = client.devices if device_index >= len(devices): raise ValueError( f"Device index {device_index} out of range " f"(server has {len(devices)} device(s))" ) device = devices[device_index] zone_type_map = {0: "single", 1: "linear", 2: "matrix"} zones = [] for z in device.zones: zt = zone_type_map.get(getattr(z, "type", -1), "unknown") zones.append(OpenRGBZoneResponse( name=z.name, led_count=len(z.leds), zone_type=zt, )) return device.name, zones finally: client.disconnect() try: device_name, zones = await asyncio.to_thread(_fetch_zones) return OpenRGBZonesResponse(device_name=device_name, zones=zones) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) except Exception as e: logger.error(f"Failed to list OpenRGB zones: {e}") raise HTTPException(status_code=502, detail=f"Cannot reach OpenRGB server: {e}") @router.get("/api/v1/devices/batch/states", tags=["Devices"]) async def batch_device_states( _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get health/connection state for all devices in a single request.""" return {"states": manager.get_all_device_health_dicts()} @router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) async def get_device( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), ): """Get device details by ID.""" try: device = store.get_device(device_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return _device_to_response(device) @router.put("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) async def update_device( device_id: str, update_data: DeviceUpdate, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Update device information.""" try: device = store.update_device( device_id=device_id, name=update_data.name, url=update_data.url, enabled=update_data.enabled, led_count=update_data.led_count, baud_rate=update_data.baud_rate, auto_shutdown=update_data.auto_shutdown, send_latency_ms=update_data.send_latency_ms, rgbw=update_data.rgbw, zone_mode=update_data.zone_mode, tags=update_data.tags, dmx_protocol=update_data.dmx_protocol, dmx_start_universe=update_data.dmx_start_universe, dmx_start_channel=update_data.dmx_start_channel, espnow_peer_mac=update_data.espnow_peer_mac, espnow_channel=update_data.espnow_channel, hue_username=update_data.hue_username, hue_client_key=update_data.hue_client_key, hue_entertainment_group_id=update_data.hue_entertainment_group_id, spi_speed_hz=update_data.spi_speed_hz, spi_led_type=update_data.spi_led_type, chroma_device_type=update_data.chroma_device_type, gamesense_device_type=update_data.gamesense_device_type, ) # Sync connection info in processor manager try: manager.update_device_info( device_id, device_url=update_data.url, led_count=update_data.led_count, baud_rate=update_data.baud_rate, ) except ValueError: pass # Sync auto_shutdown and zone_mode in runtime state ds = manager.find_device_state(device_id) if ds: if update_data.auto_shutdown is not None: ds.auto_shutdown = update_data.auto_shutdown if update_data.zone_mode is not None: ds.zone_mode = update_data.zone_mode fire_entity_event("device", "updated", device_id) return _device_to_response(device) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to update device: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"]) async def delete_device( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), target_store: OutputTargetStore = Depends(get_output_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Delete/detach a device. Returns 409 if referenced by a target.""" try: # Check if any target references this device refs = target_store.get_targets_for_device(device_id) if refs: 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." ) # Remove from manager try: await manager.remove_device(device_id) except (ValueError, RuntimeError): pass # Delete from storage store.delete_device(device_id) fire_entity_event("device", "deleted", device_id) logger.info(f"Deleted device {device_id}") except HTTPException: raise except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to delete device: {e}") raise HTTPException(status_code=500, detail=str(e)) # ===== DEVICE STATE (health only) ===== @router.get("/api/v1/devices/{device_id}/state", response_model=DeviceStateResponse, tags=["Devices"]) async def get_device_state( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Get device health/connection state.""" try: device = store.get_device(device_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) try: state = manager.get_device_health_dict(device_id) state["device_type"] = device.device_type return DeviceStateResponse(**state) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @router.post("/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"]) async def ping_device( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Force an immediate health check on a device.""" try: device = store.get_device(device_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) try: state = await manager.force_device_health_check(device_id) state["device_type"] = device.device_type return DeviceStateResponse(**state) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # ===== WLED BRIGHTNESS ENDPOINTS ===== @router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) async def get_device_brightness( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Get current brightness from the device. Uses a server-side cache to avoid polling the physical device on every frontend request — hitting the ESP32 over WiFi in the async event loop causes ~150 ms jitter in the processing loop. """ try: device = store.get_device(device_id) 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") # Return cached hardware brightness if available (updated by SET endpoint) ds = manager.find_device_state(device_id) if ds and ds.hardware_brightness is not None: return {"brightness": ds.hardware_brightness} try: provider = get_provider(device.device_type) bri = await provider.get_brightness(device.url) # Cache the result so subsequent polls don't hit the device if ds: ds.hardware_brightness = bri return {"brightness": bri} except NotImplementedError: # Provider has no hardware brightness; use software brightness return {"brightness": device.software_brightness} except Exception as e: logger.error(f"Failed to get brightness for {device_id}: {e}") raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") @router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"]) async def set_device_brightness( device_id: str, body: dict, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Set brightness on the device.""" try: device = store.get_device(device_id) 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") bri = body.get("brightness") if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255: raise HTTPException(status_code=400, detail="brightness must be an integer 0-255") try: try: provider = get_provider(device.device_type) await provider.set_brightness(device.url, bri) except NotImplementedError: # Provider has no hardware brightness; use software brightness store.update_device(device_id=device_id, software_brightness=bri) ds = manager.find_device_state(device_id) if ds: ds.software_brightness = bri # Update cached hardware brightness ds = manager.find_device_state(device_id) if ds: ds.hardware_brightness = bri return {"brightness": bri} except Exception as e: logger.error(f"Failed to set brightness for {device_id}: {e}") raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") # ===== POWER ENDPOINTS ===== @router.get("/api/v1/devices/{device_id}/power", tags=["Settings"]) async def get_device_power( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Get current power state from the device.""" try: device = store.get_device(device_id) 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") try: # Serial devices: use tracked state (no hardware query available) ds = manager.find_device_state(device_id) if device.device_type in ("adalight", "ambiled") and ds: return {"on": ds.power_on} provider = get_provider(device.device_type) on = await provider.get_power(device.url) return {"on": on} except Exception as e: logger.error(f"Failed to get power for {device_id}: {e}") raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") @router.put("/api/v1/devices/{device_id}/power", tags=["Settings"]) async def set_device_power( device_id: str, body: dict, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Turn device on or off.""" try: device = store.get_device(device_id) 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") on = body.get("on") if on is None or not isinstance(on, bool): raise HTTPException(status_code=400, detail="'on' must be a boolean") try: # For serial devices, use the cached idle client to avoid port conflicts ds = manager.find_device_state(device_id) if device.device_type in ("adalight", "ambiled") and ds: if not on: await manager.send_clear_pixels(device_id) ds.power_on = on else: provider = get_provider(device.device_type) await provider.set_power( device.url, on, led_count=device.led_count, baud_rate=device.baud_rate, ) return {"on": on} except Exception as e: logger.error(f"Failed to set power for {device_id}: {e}") raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") # ===== WEBSOCKET DEVICE STREAM ===== @router.websocket("/api/v1/devices/{device_id}/ws") async def device_ws_stream( websocket: WebSocket, device_id: str, token: str = Query(""), ): """WebSocket stream of LED pixel data for WS device type. Wire format: [brightness_byte][R G B R G B ...] 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 store = get_device_store() try: device = store.get_device(device_id) except ValueError: await websocket.close(code=4004, reason="Device not found") return if device.device_type != "ws": await websocket.close(code=4003, reason="Device is not a WebSocket device") return await websocket.accept() from wled_controller.core.devices.ws_client import get_ws_broadcaster broadcaster = get_ws_broadcaster() broadcaster.add_client(device_id, websocket) try: while True: await websocket.receive_text() except WebSocketDisconnect: pass finally: broadcaster.remove_client(device_id, websocket)