"""Device routes: CRUD, health state, brightness, calibration.""" import httpx from fastapi import APIRouter, HTTPException, Depends from wled_controller.api.auth import AuthRequired from wled_controller.core.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 ( Calibration as CalibrationSchema, CalibrationTestModeRequest, CalibrationTestModeResponse, DeviceCreate, DeviceListResponse, DeviceResponse, DeviceStateResponse, DeviceUpdate, DiscoveredDeviceResponse, DiscoverDevicesResponse, ) from wled_controller.core.calibration import ( calibration_from_dict, calibration_to_dict, ) from wled_controller.core.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, capabilities=sorted(get_device_capabilities(device.device_type)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), 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}" ) # 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, ) # Register in processor manager for health monitoring manager.add_device( device_id=device.id, device_url=device.url, led_count=device.led_count, calibration=device.calibration, device_type=device.device_type, baud_rate=device.baud_rate, ) 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/{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, ) # 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 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: 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), ): """Get current brightness from the WLED 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") try: provider = get_provider(device.device_type) bri = await provider.get_brightness(device.url) return {"brightness": bri} 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), ): """Set brightness on the WLED device directly.""" 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: provider = get_provider(device.device_type) await provider.set_brightness(device.url, 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}") # ===== CALIBRATION ENDPOINTS ===== @router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) async def get_calibration( device_id: str, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), ): """Get calibration configuration for a device.""" device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") return CalibrationSchema(**calibration_to_dict(device.calibration)) @router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) async def update_calibration( device_id: str, calibration_data: CalibrationSchema, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Update calibration configuration for a device.""" try: # Convert schema to CalibrationConfig calibration_dict = calibration_data.model_dump() calibration = calibration_from_dict(calibration_dict) # Update in storage device = store.update_device(device_id, calibration=calibration) # Update in manager (also updates active target's cached calibration) try: manager.update_calibration(device_id, calibration) except ValueError: pass return CalibrationSchema(**calibration_to_dict(device.calibration)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to update calibration: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.put( "/api/v1/devices/{device_id}/calibration/test", response_model=CalibrationTestModeResponse, tags=["Calibration"], ) async def set_calibration_test_mode( device_id: str, body: CalibrationTestModeRequest, _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Toggle calibration test mode for specific edges.""" try: device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") # Validate edge names and colors valid_edges = {"top", "right", "bottom", "left"} for edge_name, color in body.edges.items(): if edge_name not in valid_edges: raise HTTPException( status_code=400, detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}" ) if len(color) != 3 or not all(0 <= c <= 255 for c in color): raise HTTPException( status_code=400, detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255." ) await manager.set_test_mode(device_id, body.edges) active_edges = list(body.edges.keys()) logger.info( f"Test mode {'activated' if active_edges else 'deactivated'} " f"for device {device_id}: {active_edges}" ) return CalibrationTestModeResponse( test_mode=len(active_edges) > 0, active_edges=active_edges, device_id=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 set test mode: {e}") raise HTTPException(status_code=500, detail=str(e))