Add WebSocket device type, capability-driven settings, hide filter on collapse
- New WS device type: broadcaster singleton + LEDClient that sends binary
frames to connected WebSocket clients during processing
- FastAPI WS endpoint at /api/v1/devices/{device_id}/ws with token auth
- Frontend: add/edit WS devices, connection URL with copy button in settings
- Add health_check and auto_restore capabilities to WLED and Serial providers;
hide health interval and auto-restore toggle for devices without them
- Skip health check loop for virtual devices (Mock, MQTT, WS) — set always-online
- Copy buttons and labels for API CSS push endpoints (REST POST / WebSocket)
- Hide mock:// and ws:// URLs in target device dropdown
- Hide filter textbox when card section is collapsed (cs-collapsed CSS class)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"""Device routes: CRUD, health state, brightness, power, calibration."""
|
||||
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
||||
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.core.devices.led_client import (
|
||||
@@ -122,6 +124,11 @@ async def create_device(
|
||||
rgbw=device_data.rgbw or False,
|
||||
)
|
||||
|
||||
# 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,
|
||||
@@ -486,3 +493,55 @@ async def set_device_power(
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user