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>
272 lines
8.4 KiB
Python
272 lines
8.4 KiB
Python
"""Scene preset API routes — CRUD, capture, activate, recapture."""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from wled_controller.api.auth import AuthRequired
|
|
from wled_controller.api.dependencies import (
|
|
fire_entity_event,
|
|
get_output_target_store,
|
|
get_processor_manager,
|
|
get_scene_preset_store,
|
|
)
|
|
from wled_controller.api.schemas.scene_presets import (
|
|
ActivateResponse,
|
|
ScenePresetCreate,
|
|
ScenePresetListResponse,
|
|
ScenePresetResponse,
|
|
ScenePresetUpdate,
|
|
)
|
|
from wled_controller.core.processing.processor_manager import ProcessorManager
|
|
from wled_controller.core.scenes.scene_activator import (
|
|
apply_scene_state,
|
|
capture_current_snapshot,
|
|
)
|
|
from wled_controller.storage.output_target_store import OutputTargetStore
|
|
from wled_controller.storage.scene_preset import ScenePreset
|
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
|
from wled_controller.utils import get_logger
|
|
from wled_controller.storage.base_store import EntityNotFoundError
|
|
|
|
logger = get_logger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
|
|
return ScenePresetResponse(
|
|
id=preset.id,
|
|
name=preset.name,
|
|
description=preset.description,
|
|
targets=[{
|
|
"target_id": t.target_id,
|
|
"running": t.running,
|
|
"color_strip_source_id": t.color_strip_source_id,
|
|
"brightness_value_source_id": t.brightness_value_source_id,
|
|
"fps": t.fps,
|
|
} for t in preset.targets],
|
|
order=preset.order,
|
|
tags=preset.tags,
|
|
created_at=preset.created_at,
|
|
updated_at=preset.updated_at,
|
|
)
|
|
|
|
|
|
# ===== CRUD =====
|
|
|
|
@router.post(
|
|
"/api/v1/scene-presets",
|
|
response_model=ScenePresetResponse,
|
|
tags=["Scene Presets"],
|
|
status_code=201,
|
|
)
|
|
async def create_scene_preset(
|
|
data: ScenePresetCreate,
|
|
_auth: AuthRequired,
|
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
|
manager: ProcessorManager = Depends(get_processor_manager),
|
|
):
|
|
"""Capture current state as a new scene preset."""
|
|
target_ids = set(data.target_ids) if data.target_ids is not None else None
|
|
targets = capture_current_snapshot(target_store, manager, target_ids)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
preset = ScenePreset(
|
|
id=f"scene_{uuid.uuid4().hex[:8]}",
|
|
name=data.name,
|
|
description=data.description,
|
|
targets=targets,
|
|
order=store.count(),
|
|
tags=data.tags if data.tags is not None else [],
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
try:
|
|
preset = store.create_preset(preset)
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
fire_entity_event("scene_preset", "created", preset.id)
|
|
return _preset_to_response(preset)
|
|
|
|
|
|
@router.get(
|
|
"/api/v1/scene-presets",
|
|
response_model=ScenePresetListResponse,
|
|
tags=["Scene Presets"],
|
|
)
|
|
async def list_scene_presets(
|
|
_auth: AuthRequired,
|
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
|
):
|
|
"""List all scene presets."""
|
|
presets = store.get_all_presets()
|
|
return ScenePresetListResponse(
|
|
presets=[_preset_to_response(p) for p in presets],
|
|
count=len(presets),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/api/v1/scene-presets/{preset_id}",
|
|
response_model=ScenePresetResponse,
|
|
tags=["Scene Presets"],
|
|
)
|
|
async def get_scene_preset(
|
|
preset_id: str,
|
|
_auth: AuthRequired,
|
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
|
):
|
|
"""Get a single scene preset."""
|
|
try:
|
|
preset = store.get_preset(preset_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
return _preset_to_response(preset)
|
|
|
|
|
|
@router.put(
|
|
"/api/v1/scene-presets/{preset_id}",
|
|
response_model=ScenePresetResponse,
|
|
tags=["Scene Presets"],
|
|
)
|
|
async def update_scene_preset(
|
|
preset_id: str,
|
|
data: ScenePresetUpdate,
|
|
_auth: AuthRequired,
|
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
|
manager: ProcessorManager = Depends(get_processor_manager),
|
|
):
|
|
"""Update scene preset metadata and optionally change targets."""
|
|
# If target_ids changed, update the snapshot: keep state for existing targets,
|
|
# capture fresh state for newly added targets, drop removed ones.
|
|
new_targets = None
|
|
if data.target_ids is not None:
|
|
try:
|
|
existing = store.get_preset(preset_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
existing_map = {t.target_id: t for t in existing.targets}
|
|
new_target_ids = set(data.target_ids)
|
|
|
|
# Capture fresh state for newly added targets
|
|
added_ids = new_target_ids - set(existing_map.keys())
|
|
fresh = capture_current_snapshot(target_store, manager, added_ids) if added_ids else []
|
|
fresh_map = {t.target_id: t for t in fresh}
|
|
|
|
# Build new target list preserving order from target_ids
|
|
new_targets = []
|
|
for tid in data.target_ids:
|
|
if tid in existing_map:
|
|
new_targets.append(existing_map[tid])
|
|
elif tid in fresh_map:
|
|
new_targets.append(fresh_map[tid])
|
|
|
|
try:
|
|
preset = store.update_preset(
|
|
preset_id,
|
|
name=data.name,
|
|
description=data.description,
|
|
order=data.order,
|
|
targets=new_targets,
|
|
tags=data.tags,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
|
|
fire_entity_event("scene_preset", "updated", preset_id)
|
|
return _preset_to_response(preset)
|
|
|
|
|
|
@router.delete(
|
|
"/api/v1/scene-presets/{preset_id}",
|
|
status_code=204,
|
|
tags=["Scene Presets"],
|
|
)
|
|
async def delete_scene_preset(
|
|
preset_id: str,
|
|
_auth: AuthRequired,
|
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
|
):
|
|
"""Delete a scene preset."""
|
|
try:
|
|
store.delete_preset(preset_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
fire_entity_event("scene_preset", "deleted", preset_id)
|
|
|
|
|
|
# ===== Recapture =====
|
|
|
|
@router.post(
|
|
"/api/v1/scene-presets/{preset_id}/recapture",
|
|
response_model=ScenePresetResponse,
|
|
tags=["Scene Presets"],
|
|
)
|
|
async def recapture_scene_preset(
|
|
preset_id: str,
|
|
_auth: AuthRequired,
|
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
|
manager: ProcessorManager = Depends(get_processor_manager),
|
|
):
|
|
"""Re-capture current state into an existing preset (updates snapshot)."""
|
|
try:
|
|
existing = store.get_preset(preset_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
# Only recapture targets that are already in the preset
|
|
existing_ids = {t.target_id for t in existing.targets}
|
|
targets = capture_current_snapshot(target_store, manager, existing_ids)
|
|
|
|
new_snapshot = ScenePreset(
|
|
id=preset_id,
|
|
name="",
|
|
targets=targets,
|
|
)
|
|
|
|
try:
|
|
preset = store.recapture_preset(preset_id, new_snapshot)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
return _preset_to_response(preset)
|
|
|
|
|
|
# ===== Activate =====
|
|
|
|
@router.post(
|
|
"/api/v1/scene-presets/{preset_id}/activate",
|
|
response_model=ActivateResponse,
|
|
tags=["Scene Presets"],
|
|
)
|
|
async def activate_scene_preset(
|
|
preset_id: str,
|
|
_auth: AuthRequired,
|
|
store: ScenePresetStore = Depends(get_scene_preset_store),
|
|
target_store: OutputTargetStore = Depends(get_output_target_store),
|
|
manager: ProcessorManager = Depends(get_processor_manager),
|
|
):
|
|
"""Activate a scene preset — restore the captured state."""
|
|
try:
|
|
preset = store.get_preset(preset_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
status, errors = await apply_scene_state(preset, target_store, manager)
|
|
|
|
if not errors:
|
|
logger.info(f"Scene preset '{preset.name}' activated successfully")
|
|
|
|
fire_entity_event("scene_preset", "updated", preset_id)
|
|
return ActivateResponse(status=status, errors=errors)
|