Files
wled-screen-controller-mixed/server/src/wled_controller/api/routes/value_sources.py
alexei.dolgolyov cdba98813b Backend performance and code quality improvements
Performance (hot path):
- Fix double brightness: removed duplicate scaling from 9 device clients
  (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid,
  espnow) — processor loop is now the single source of brightness
- Bounded send_timestamps deque with maxlen, removed 3 cleanup loops
- Running FPS sum O(1) instead of sum()/len() O(n) per frame
- datetime.now(timezone.utc) → time.monotonic() with lazy conversion
- Device info refresh interval 30 → 300 iterations
- Composite: gate layer_snapshots copy on preview client flag
- Composite: versioned sub_streams snapshot (copy only on change)
- Composite: pre-resolved blend methods (dict lookup vs getattr)
- ApiInput: np.copyto in-place instead of astype allocation

Code quality:
- BaseJsonStore: RLock on get/delete/get_all/count (was created but unused)
- EntityNotFoundError → proper 404 responses across 15 route files
- Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models
- Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores
- Log 4 silenced exceptions (automation engine, metrics, system)
- ValueStream.get_value() now @abstractmethod
- Config.from_yaml: add encoding="utf-8"
- OutputTargetStore: remove 25-line _load override, use _legacy_json_keys
- BaseJsonStore: add _legacy_json_keys for migration support
- Remove unnecessary except Exception→500 from postprocessing list endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:06:29 +03:00

254 lines
8.7 KiB
Python

"""Value source routes: CRUD for value sources."""
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_value_source_store,
)
from wled_controller.api.schemas.value_sources import (
ValueSourceCreate,
ValueSourceListResponse,
ValueSourceResponse,
ValueSourceUpdate,
)
from wled_controller.storage.value_source import ValueSource
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _to_response(source: ValueSource) -> ValueSourceResponse:
"""Convert a ValueSource to a ValueSourceResponse."""
d = source.to_dict()
return ValueSourceResponse(
id=d["id"],
name=d["name"],
source_type=d["source_type"],
value=d.get("value"),
waveform=d.get("waveform"),
speed=d.get("speed"),
min_value=d.get("min_value"),
max_value=d.get("max_value"),
audio_source_id=d.get("audio_source_id"),
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"),
use_real_time=d.get("use_real_time"),
latitude=d.get("latitude"),
description=d.get("description"),
tags=d.get("tags", []),
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get("/api/v1/value-sources", response_model=ValueSourceListResponse, tags=["Value Sources"])
async def list_value_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: static, animated, audio, adaptive_time, or adaptive_scene"),
store: ValueSourceStore = Depends(get_value_source_store),
):
"""List all value sources, optionally filtered by type."""
sources = store.get_all_sources()
if source_type:
sources = [s for s in sources if s.source_type == source_type]
return ValueSourceListResponse(
sources=[_to_response(s) for s in sources],
count=len(sources),
)
@router.post("/api/v1/value-sources", response_model=ValueSourceResponse, status_code=201, tags=["Value Sources"])
async def create_value_source(
data: ValueSourceCreate,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Create a new value source."""
try:
source = store.create_source(
name=data.name,
source_type=data.source_type,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
fire_entity_event("value_source", "created", source.id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
async def get_value_source(
source_id: str,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
):
"""Get a value source by ID."""
try:
source = store.get_source(source_id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/value-sources/{source_id}", response_model=ValueSourceResponse, tags=["Value Sources"])
async def update_value_source(
source_id: str,
data: ValueSourceUpdate,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
pm: ProcessorManager = Depends(get_processor_manager),
):
"""Update an existing value source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
value=data.value,
waveform=data.waveform,
speed=data.speed,
min_value=data.min_value,
max_value=data.max_value,
audio_source_id=data.audio_source_id,
mode=data.mode,
sensitivity=data.sensitivity,
smoothing=data.smoothing,
description=data.description,
schedule=data.schedule,
picture_source_id=data.picture_source_id,
scene_behavior=data.scene_behavior,
auto_gain=data.auto_gain,
use_real_time=data.use_real_time,
latitude=data.latitude,
tags=data.tags,
)
# Hot-reload running value streams
pm.update_value_source(source_id)
fire_entity_event("value_source", "updated", source_id)
return _to_response(source)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/api/v1/value-sources/{source_id}", status_code=204, tags=["Value Sources"])
async def delete_value_source(
source_id: str,
_auth: AuthRequired,
store: ValueSourceStore = Depends(get_value_source_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a value source."""
try:
# Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget):
if getattr(target, "brightness_value_source_id", "") == source_id:
raise ValueError(
f"Cannot delete: referenced by target '{target.name}'"
)
store.delete_source(source_id)
fire_entity_event("value_source", "deleted", source_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
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.
"""
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
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}")