"""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)