"""Output target routes: CRUD endpoints and batch state/metrics queries.""" import asyncio from fastapi import APIRouter, HTTPException, Depends from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( fire_entity_event, get_device_store, get_output_target_store, get_processor_manager, ) from wled_controller.api.schemas.output_targets import ( KeyColorsSettingsSchema, OutputTargetCreate, OutputTargetListResponse, OutputTargetResponse, OutputTargetUpdate, ) from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.key_colors_output_target import ( KeyColorsSettings, KeyColorsOutputTarget, ) from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.utils import get_logger from wled_controller.storage.base_store import EntityNotFoundError logger = get_logger(__name__) router = APIRouter() def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema: """Convert core KeyColorsSettings to schema.""" return KeyColorsSettingsSchema( fps=settings.fps, interpolation_mode=settings.interpolation_mode, smoothing=settings.smoothing, pattern_template_id=settings.pattern_template_id, brightness=settings.brightness, brightness_value_source_id=settings.brightness_value_source_id, ) def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings: """Convert schema KeyColorsSettings to core.""" return KeyColorsSettings( fps=schema.fps, interpolation_mode=schema.interpolation_mode, smoothing=schema.smoothing, pattern_template_id=schema.pattern_template_id, brightness=schema.brightness, brightness_value_source_id=schema.brightness_value_source_id, ) def _target_to_response(target) -> OutputTargetResponse: """Convert an OutputTarget to OutputTargetResponse.""" if isinstance(target, WledOutputTarget): return OutputTargetResponse( id=target.id, name=target.name, target_type=target.target_type, device_id=target.device_id, color_strip_source_id=target.color_strip_source_id, brightness_value_source_id=target.brightness_value_source_id or "", fps=target.fps, keepalive_interval=target.keepalive_interval, state_check_interval=target.state_check_interval, min_brightness_threshold=target.min_brightness_threshold, adaptive_fps=target.adaptive_fps, protocol=target.protocol, description=target.description, tags=target.tags, created_at=target.created_at, updated_at=target.updated_at, ) elif isinstance(target, KeyColorsOutputTarget): return OutputTargetResponse( id=target.id, name=target.name, target_type=target.target_type, picture_source_id=target.picture_source_id, key_colors_settings=_kc_settings_to_schema(target.settings), description=target.description, tags=target.tags, created_at=target.created_at, updated_at=target.updated_at, ) else: return OutputTargetResponse( id=target.id, name=target.name, target_type=target.target_type, description=target.description, tags=target.tags, created_at=target.created_at, updated_at=target.updated_at, ) # ===== CRUD ENDPOINTS ===== @router.post("/api/v1/output-targets", response_model=OutputTargetResponse, tags=["Targets"], status_code=201) async def create_target( data: OutputTargetCreate, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Create a new output target.""" try: # Validate device exists if provided if data.device_id: try: device_store.get_device(data.device_id) except ValueError: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None # Create in store target = target_store.create_target( name=data.name, target_type=data.target_type, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, brightness_value_source_id=data.brightness_value_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, tags=data.tags, ) # Register in processor manager try: target.register_with_manager(manager) except ValueError as e: logger.warning(f"Could not register target {target.id} in processor manager: {e}") fire_entity_event("output_target", "created", target.id) return _target_to_response(target) except HTTPException: raise except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Failed to create target: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"]) async def list_targets( _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), ): """List all output targets.""" targets = target_store.get_all_targets() responses = [_target_to_response(t) for t in targets] return OutputTargetListResponse(targets=responses, count=len(responses)) @router.get("/api/v1/output-targets/batch/states", tags=["Processing"]) async def batch_target_states( _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get processing state for all targets in a single request.""" return {"states": manager.get_all_target_states()} @router.get("/api/v1/output-targets/batch/metrics", tags=["Metrics"]) async def batch_target_metrics( _auth: AuthRequired, manager: ProcessorManager = Depends(get_processor_manager), ): """Get metrics for all targets in a single request.""" return {"metrics": manager.get_all_target_metrics()} @router.get("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]) async def get_target( target_id: str, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), ): """Get a output target by ID.""" try: target = target_store.get_target(target_id) return _target_to_response(target) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @router.put("/api/v1/output-targets/{target_id}", response_model=OutputTargetResponse, tags=["Targets"]) async def update_target( target_id: str, data: OutputTargetUpdate, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), device_store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Update a output target.""" try: # Validate device exists if changing if data.device_id is not None and data.device_id: try: device_store.get_device(data.device_id) except ValueError: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") # Build KC settings with partial-update support: only apply fields that were # explicitly provided in the request body, merging with the existing settings. kc_settings = None if data.key_colors_settings is not None: incoming = data.key_colors_settings.model_dump(exclude_unset=True) try: existing_target = target_store.get_target(target_id) except ValueError: existing_target = None if isinstance(existing_target, KeyColorsOutputTarget): ex = existing_target.settings merged = KeyColorsSettingsSchema( fps=incoming.get("fps", ex.fps), interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode), smoothing=incoming.get("smoothing", ex.smoothing), pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id), brightness=incoming.get("brightness", ex.brightness), brightness_value_source_id=incoming.get("brightness_value_source_id", ex.brightness_value_source_id), ) kc_settings = _kc_schema_to_settings(merged) else: kc_settings = _kc_schema_to_settings(data.key_colors_settings) # Update in store target = target_store.update_target( target_id=target_id, name=data.name, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, brightness_value_source_id=data.brightness_value_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, min_brightness_threshold=data.min_brightness_threshold, adaptive_fps=data.adaptive_fps, protocol=data.protocol, key_colors_settings=kc_settings, description=data.description, tags=data.tags, ) # Detect KC brightness VS change (inside key_colors_settings) kc_brightness_vs_changed = False if data.key_colors_settings is not None: kc_incoming = data.key_colors_settings.model_dump(exclude_unset=True) if "brightness_value_source_id" in kc_incoming: kc_brightness_vs_changed = True # Sync processor manager (run in thread — css release/acquire can block) try: await asyncio.to_thread( target.sync_with_manager, manager, settings_changed=(data.fps is not None or data.keepalive_interval is not None or data.state_check_interval is not None or data.min_brightness_threshold is not None or data.adaptive_fps is not None or data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed), ) except ValueError: pass # Device change requires async stop -> swap -> start cycle if data.device_id is not None: try: await manager.update_target_device(target_id, target.device_id) except ValueError: pass fire_entity_event("output_target", "updated", target_id) return _target_to_response(target) except HTTPException: raise except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to update target: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"]) async def delete_target( target_id: str, _auth: AuthRequired, target_store: OutputTargetStore = Depends(get_output_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Delete a output target. Stops processing first if active.""" try: # Stop processing if running try: await manager.stop_processing(target_id) except ValueError: pass # Remove from manager try: manager.remove_target(target_id) except (ValueError, RuntimeError): pass # Delete from store target_store.delete_target(target_id) fire_entity_event("output_target", "deleted", target_id) logger.info(f"Deleted target {target_id}") except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"Failed to delete target: {e}") raise HTTPException(status_code=500, detail=str(e))