"""Device routes: CRUD, health state, brightness, power, calibration.""" import httpx from fastapi import APIRouter, HTTPException, Depends 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 ( get_device_store, get_picture_target_store, get_processor_manager, ) from wled_controller.api.schemas.devices import ( DeviceCreate, DeviceListResponse, DeviceResponse, DeviceStateResponse, DeviceUpdate, DiscoveredDeviceResponse, DiscoverDevicesResponse, ) from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.utils import get_logger 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, capabilities=sorted(get_device_capabilities(device.device_type)), 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: True for adalight, False otherwise auto_shutdown = device_data.auto_shutdown if auto_shutdown is None: auto_shutdown = device_type == "adalight" # 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, ) # 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, ) 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/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.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") 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, ) # 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 in runtime state if update_data.auto_shutdown is not None and device_id in manager._devices: manager._devices[device_id].auto_shutdown = update_data.auto_shutdown 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: PictureTargetStore = Depends(get_picture_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) 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.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") 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)) # ===== 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. """ device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") 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._devices.get(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.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") 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 device.software_brightness = bri device.updated_at = __import__("datetime").datetime.utcnow() store.save() if device_id in manager._devices: manager._devices[device_id].software_brightness = bri # Update cached hardware brightness ds = manager._devices.get(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.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") 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._devices.get(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.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") 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._devices.get(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}")