Files
wled-screen-controller-mixed/server/src/wled_controller/api/routes/devices.py
alexei.dolgolyov 9392741f08 Batch API endpoints, reduce frontend polling by ~75%, fix resource leaks
Backend: add batch endpoints for target states, metrics, and device
health to replace O(N) individual API calls per poll cycle.
Frontend: use batch endpoints in dashboard/targets/profiles tabs,
fix Chart.js instance leaks, debounce server event reloads, add
i18n active-tab guards, clean up ResizeObserver on pattern editor
close, cache uptime timer DOM refs, increase KC auto-refresh to 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:55:09 +03:00

483 lines
17 KiB
Python

"""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}")