Add value source test modal, auto-gain, brightness always-show, shared value streams
- Add real-time value source test: WebSocket endpoint streams get_value() at ~20Hz, frontend renders scrolling time-series chart with min/max/current stats - Add auto-gain for audio value sources: rolling peak normalization with slow decay, sensitivity range increased to 0.1-20.0 - Always show brightness overlay on LED preview when brightness source is set - Refactor ValueStreamManager to shared ref-counted streams (value streams produce scalars, not LED-count-dependent, so sharing is correct) - Simplify acquire/release API: remove consumer_id parameter since streams are no longer consumer-dependent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
@@ -10,6 +12,7 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.api.schemas.value_sources import (
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
@@ -43,6 +46,7 @@ def _to_response(source: ValueSource) -> ValueSourceResponse:
|
||||
mode=d.get("mode"),
|
||||
sensitivity=d.get("sensitivity"),
|
||||
smoothing=d.get("smoothing"),
|
||||
auto_gain=d.get("auto_gain"),
|
||||
schedule=d.get("schedule"),
|
||||
picture_source_id=d.get("picture_source_id"),
|
||||
scene_behavior=d.get("scene_behavior"),
|
||||
@@ -92,6 +96,7 @@ async def create_value_source(
|
||||
schedule=data.schedule,
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
)
|
||||
return _to_response(source)
|
||||
except ValueError as e:
|
||||
@@ -138,6 +143,7 @@ async def update_value_source(
|
||||
schedule=data.schedule,
|
||||
picture_source_id=data.picture_source_id,
|
||||
scene_behavior=data.scene_behavior,
|
||||
auto_gain=data.auto_gain,
|
||||
)
|
||||
# Hot-reload running value streams
|
||||
pm.update_value_source(source_id)
|
||||
@@ -168,3 +174,68 @@ async def delete_value_source(
|
||||
return {"status": "deleted", "id": source_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ===== REAL-TIME VALUE SOURCE TEST WEBSOCKET =====
|
||||
|
||||
|
||||
@router.websocket("/api/v1/value-sources/{source_id}/test/ws")
|
||||
async def test_value_source_ws(
|
||||
websocket: WebSocket,
|
||||
source_id: str,
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time value source output. Auth via ?token=<api_key>.
|
||||
|
||||
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
|
||||
and streams {value: float} JSON to the client.
|
||||
"""
|
||||
# Authenticate
|
||||
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
|
||||
|
||||
# Validate source exists
|
||||
store = get_value_source_store()
|
||||
try:
|
||||
store.get_source(source_id)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
# Acquire a value stream
|
||||
manager = get_processor_manager()
|
||||
vsm = manager.value_stream_manager
|
||||
if vsm is None:
|
||||
await websocket.close(code=4003, reason="Value stream manager not available")
|
||||
return
|
||||
|
||||
try:
|
||||
stream = vsm.acquire(source_id)
|
||||
except Exception as e:
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"Value source test WebSocket connected for {source_id}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
value = stream.get_value()
|
||||
await websocket.send_json({"value": round(value, 4)})
|
||||
await asyncio.sleep(0.05)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Value source test WebSocket error for {source_id}: {e}")
|
||||
finally:
|
||||
vsm.release(source_id)
|
||||
logger.info(f"Value source test WebSocket disconnected for {source_id}")
|
||||
|
||||
@@ -21,8 +21,9 @@ class ValueSourceCreate(BaseModel):
|
||||
# audio fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-5.0)", ge=0.1, le=5.0)
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
|
||||
# adaptive fields
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule: [{time: 'HH:MM', value: 0.0-1.0}]")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
@@ -44,8 +45,9 @@ class ValueSourceUpdate(BaseModel):
|
||||
# audio fields
|
||||
audio_source_id: Optional[str] = Field(None, description="Mono audio source ID")
|
||||
mode: Optional[str] = Field(None, description="Audio mode: rms|peak|beat")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-5.0)", ge=0.1, le=5.0)
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier (0.1-20.0)", ge=0.1, le=20.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
|
||||
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels to full range")
|
||||
# adaptive fields
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode")
|
||||
@@ -68,6 +70,7 @@ class ValueSourceResponse(BaseModel):
|
||||
mode: Optional[str] = Field(None, description="Audio mode")
|
||||
sensitivity: Optional[float] = Field(None, description="Gain multiplier")
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing")
|
||||
auto_gain: Optional[bool] = Field(None, description="Auto-normalize audio levels")
|
||||
schedule: Optional[list] = Field(None, description="Time-of-day schedule")
|
||||
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
|
||||
scene_behavior: Optional[str] = Field(None, description="Scene behavior")
|
||||
|
||||
Reference in New Issue
Block a user