638dc526f9
Scan the local network for WLED devices advertising _wled._tcp.local. and present them in the Add Device modal for one-click selection. - New discovery.py: async mDNS browse + parallel /json/info enrichment - GET /api/v1/devices/discover endpoint with already_added dedup - Header scan button (magnifying glass icon) in add-device modal - Discovered devices show name, IP, LED count, version; click to fill form - en/ru locale strings for discovery UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
456 lines
16 KiB
Python
456 lines
16 KiB
Python
"""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_device_capabilities
|
|
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,
|
|
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("/")
|
|
wled_led_count = 0
|
|
|
|
if device_type == "wled":
|
|
# Validate WLED device is reachable before adding
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
response = await client.get(f"{device_url}/json/info")
|
|
response.raise_for_status()
|
|
wled_info = response.json()
|
|
wled_led_count = wled_info.get("leds", {}).get("count")
|
|
if not wled_led_count or wled_led_count < 1:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}"
|
|
)
|
|
logger.info(
|
|
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
|
|
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
|
|
)
|
|
except httpx.ConnectError:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Cannot reach WLED 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 HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Failed to connect to WLED device at {device_url}: {e}"
|
|
)
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unsupported device type: {device_type}"
|
|
)
|
|
|
|
# Create device in storage
|
|
device = store.create_device(
|
|
name=device_data.name,
|
|
url=device_data.url,
|
|
led_count=wled_led_count,
|
|
device_type=device_type,
|
|
)
|
|
|
|
# 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,
|
|
)
|
|
|
|
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,
|
|
):
|
|
"""Scan the local network for WLED devices via mDNS."""
|
|
import time
|
|
from wled_controller.core.discovery import discover_wled_devices
|
|
|
|
start = time.time()
|
|
discovered = await discover_wled_devices(timeout=min(timeout, 10.0))
|
|
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,
|
|
)
|
|
|
|
# Sync connection info in processor manager
|
|
try:
|
|
manager.update_device_info(
|
|
device_id,
|
|
device_url=update_data.url,
|
|
led_count=None,
|
|
)
|
|
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:
|
|
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
|
resp = await http_client.get(f"{device.url}/json/state")
|
|
resp.raise_for_status()
|
|
state = resp.json()
|
|
bri = state.get("bri", 255)
|
|
return {"brightness": bri}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get WLED brightness for {device_id}: {e}")
|
|
raise HTTPException(status_code=502, detail=f"Failed to reach WLED 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:
|
|
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
|
resp = await http_client.post(
|
|
f"{device.url}/json/state",
|
|
json={"bri": bri},
|
|
)
|
|
resp.raise_for_status()
|
|
return {"brightness": bri}
|
|
except Exception as e:
|
|
logger.error(f"Failed to set WLED brightness for {device_id}: {e}")
|
|
raise HTTPException(status_code=502, detail=f"Failed to reach WLED 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))
|