Full-stack implementation of DMX output for stage lighting and LED controllers: - DMXClient with Art-Net and sACN packet builders, multi-universe splitting - DMXDeviceProvider with manual_led_count capability and URL parsing - Device store, API schemas, routes wired with dmx_protocol/start_universe/start_channel - Frontend: add/settings modals with DMX fields, IconSelect protocol picker - Fix add device modal dirty check on type change (re-snapshot after switch) - i18n keys for DMX in en/ru/zh locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
643 lines
23 KiB
Python
643 lines
23 KiB
Python
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
|
|
|
import secrets
|
|
|
|
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
|
|
|
|
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=getattr(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),
|
|
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,
|
|
)
|
|
|
|
# WS devices: auto-set URL to ws://{device_id}
|
|
if device_type == "ws":
|
|
store.update_device(device_id=device.id, url=f"ws://{device.id}")
|
|
device = store.get_device(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."""
|
|
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,
|
|
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,
|
|
)
|
|
|
|
# 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."""
|
|
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))
|
|
|
|
|
|
@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."""
|
|
device = store.get_device(device_id)
|
|
if not device:
|
|
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
|
|
|
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.
|
|
"""
|
|
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.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."""
|
|
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
|
|
from datetime import datetime, timezone
|
|
device.updated_at = datetime.now(timezone.utc)
|
|
store.save()
|
|
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."""
|
|
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.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."""
|
|
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.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=<api_key>.
|
|
"""
|
|
from wled_controller.config import get_config
|
|
|
|
authenticated = False
|
|
cfg = get_config()
|
|
if token and cfg.auth.api_keys:
|
|
for _label, api_key in cfg.auth.api_keys.items():
|
|
if secrets.compare_digest(token, api_key):
|
|
authenticated = True
|
|
break
|
|
|
|
if not authenticated:
|
|
await websocket.close(code=4001, reason="Unauthorized")
|
|
return
|
|
|
|
store = get_device_store()
|
|
device = store.get_device(device_id)
|
|
if not device:
|
|
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)
|
|
|
|
|