"""Automation management API routes.""" import secrets from fastapi import APIRouter, Depends, HTTPException, Request from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( fire_entity_event, get_automation_engine, get_automation_store, get_scene_preset_store, ) from wled_controller.api.schemas.automations import ( AutomationCreate, AutomationListResponse, AutomationResponse, AutomationUpdate, ConditionSchema, ) from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.storage.automation import ( AlwaysCondition, ApplicationCondition, Condition, DisplayStateCondition, MQTTCondition, StartupCondition, SystemIdleCondition, TimeOfDayCondition, WebhookCondition, ) from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.utils import get_logger logger = get_logger(__name__) router = APIRouter() # ===== Helpers ===== def _condition_from_schema(s: ConditionSchema) -> Condition: if s.condition_type == "always": return AlwaysCondition() if s.condition_type == "application": return ApplicationCondition( apps=s.apps or [], match_type=s.match_type or "running", ) if s.condition_type == "time_of_day": return TimeOfDayCondition( start_time=s.start_time or "00:00", end_time=s.end_time or "23:59", ) if s.condition_type == "system_idle": return SystemIdleCondition( idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5, when_idle=s.when_idle if s.when_idle is not None else True, ) if s.condition_type == "display_state": return DisplayStateCondition( state=s.state or "on", ) if s.condition_type == "mqtt": return MQTTCondition( topic=s.topic or "", payload=s.payload or "", match_mode=s.match_mode or "exact", ) if s.condition_type == "webhook": return WebhookCondition( token=s.token or secrets.token_hex(16), ) if s.condition_type == "startup": return StartupCondition() raise ValueError(f"Unknown condition type: {s.condition_type}") def _condition_to_schema(c: Condition) -> ConditionSchema: d = c.to_dict() return ConditionSchema(**d) def _automation_to_response(automation, engine: AutomationEngine, request: Request = None) -> AutomationResponse: state = engine.get_automation_state(automation.id) # Build webhook URL from the first webhook condition (if any) webhook_url = None for c in automation.conditions: if isinstance(c, WebhookCondition) and c.token: # Prefer configured external URL, fall back to request base URL from wled_controller.api.routes.system import load_external_url ext = load_external_url() if ext: webhook_url = ext + f"/api/v1/webhooks/{c.token}" elif request: webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}" else: webhook_url = f"/api/v1/webhooks/{c.token}" break return AutomationResponse( id=automation.id, name=automation.name, enabled=automation.enabled, condition_logic=automation.condition_logic, conditions=[_condition_to_schema(c) for c in automation.conditions], scene_preset_id=automation.scene_preset_id, deactivation_mode=automation.deactivation_mode, deactivation_scene_preset_id=automation.deactivation_scene_preset_id, webhook_url=webhook_url, is_active=state["is_active"], last_activated_at=state.get("last_activated_at"), last_deactivated_at=state.get("last_deactivated_at"), tags=getattr(automation, 'tags', []), created_at=automation.created_at, updated_at=automation.updated_at, ) def _validate_condition_logic(logic: str) -> None: if logic not in ("or", "and"): raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.") def _validate_scene_refs( scene_preset_id: str | None, deactivation_scene_preset_id: str | None, scene_store: ScenePresetStore, ) -> None: """Validate that referenced scene preset IDs exist.""" for sid, label in [ (scene_preset_id, "scene_preset_id"), (deactivation_scene_preset_id, "deactivation_scene_preset_id"), ]: if sid is not None: try: scene_store.get_preset(sid) except ValueError: raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})") # ===== CRUD Endpoints ===== @router.post( "/api/v1/automations", response_model=AutomationResponse, tags=["Automations"], status_code=201, ) async def create_automation( request: Request, data: AutomationCreate, _auth: AuthRequired, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), scene_store: ScenePresetStore = Depends(get_scene_preset_store), ): """Create a new automation.""" _validate_condition_logic(data.condition_logic) _validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store) try: conditions = [_condition_from_schema(c) for c in data.conditions] except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) automation = store.create_automation( name=data.name, enabled=data.enabled, condition_logic=data.condition_logic, conditions=conditions, scene_preset_id=data.scene_preset_id, deactivation_mode=data.deactivation_mode, deactivation_scene_preset_id=data.deactivation_scene_preset_id, tags=data.tags, ) if automation.enabled: await engine.trigger_evaluate() fire_entity_event("automation", "created", automation.id) return _automation_to_response(automation, engine, request) @router.get( "/api/v1/automations", response_model=AutomationListResponse, tags=["Automations"], ) async def list_automations( request: Request, _auth: AuthRequired, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), ): """List all automations.""" automations = store.get_all_automations() return AutomationListResponse( automations=[_automation_to_response(a, engine, request) for a in automations], count=len(automations), ) @router.get( "/api/v1/automations/{automation_id}", response_model=AutomationResponse, tags=["Automations"], ) async def get_automation( request: Request, automation_id: str, _auth: AuthRequired, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), ): """Get a single automation.""" try: automation = store.get_automation(automation_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return _automation_to_response(automation, engine, request) @router.put( "/api/v1/automations/{automation_id}", response_model=AutomationResponse, tags=["Automations"], ) async def update_automation( request: Request, automation_id: str, data: AutomationUpdate, _auth: AuthRequired, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), scene_store: ScenePresetStore = Depends(get_scene_preset_store), ): """Update an automation.""" if data.condition_logic is not None: _validate_condition_logic(data.condition_logic) # Validate scene refs (only the ones being updated) _validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store) conditions = None if data.conditions is not None: try: conditions = [_condition_from_schema(c) for c in data.conditions] except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) try: # If disabling, deactivate first if data.enabled is False: await engine.deactivate_if_active(automation_id) # Build update kwargs — use sentinel for Optional[str] fields update_kwargs = dict( automation_id=automation_id, name=data.name, enabled=data.enabled, condition_logic=data.condition_logic, conditions=conditions, deactivation_mode=data.deactivation_mode, tags=data.tags, ) if data.scene_preset_id is not None: update_kwargs["scene_preset_id"] = data.scene_preset_id if data.deactivation_scene_preset_id is not None: update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id automation = store.update_automation(**update_kwargs) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # Re-evaluate immediately if automation is enabled (may have new conditions/scene) if automation.enabled: await engine.trigger_evaluate() fire_entity_event("automation", "updated", automation_id) return _automation_to_response(automation, engine, request) @router.delete( "/api/v1/automations/{automation_id}", status_code=204, tags=["Automations"], ) async def delete_automation( automation_id: str, _auth: AuthRequired, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), ): """Delete an automation.""" # Deactivate first await engine.deactivate_if_active(automation_id) try: store.delete_automation(automation_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) fire_entity_event("automation", "deleted", automation_id) # ===== Enable/Disable ===== @router.post( "/api/v1/automations/{automation_id}/enable", response_model=AutomationResponse, tags=["Automations"], ) async def enable_automation( request: Request, automation_id: str, _auth: AuthRequired, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), ): """Enable an automation.""" try: automation = store.update_automation(automation_id=automation_id, enabled=True) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # Evaluate immediately so scene activates without waiting for the next poll cycle await engine.trigger_evaluate() return _automation_to_response(automation, engine, request) @router.post( "/api/v1/automations/{automation_id}/disable", response_model=AutomationResponse, tags=["Automations"], ) async def disable_automation( request: Request, automation_id: str, _auth: AuthRequired, store: AutomationStore = Depends(get_automation_store), engine: AutomationEngine = Depends(get_automation_engine), ): """Disable an automation and deactivate it.""" await engine.deactivate_if_active(automation_id) try: automation = store.update_automation(automation_id=automation_id, enabled=False) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return _automation_to_response(automation, engine, request)