from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.models import ActuatorRecord, ReconciliationState from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine from app.dependencies import get_ha_reader from app.ha.discovery import EntityRole from app.ha.exceptions import HaClientError from app.ha.models import HaEntitySummary from app.ha.reader import HaReader router = APIRouter(prefix="/v1/actuators", tags=["actuators"]) class ConfigureActuatorRequest(BaseModel): actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") enabled: bool = True class ActivationRequest(BaseModel): active: bool pause_matching_automations: bool = False restore_paused_automations: bool = False class AutomationControlRequest(BaseModel): automation_entity_id: str = Field(pattern=r"^automation\.[a-z0-9_]+$") enabled: bool class ManualAssignmentRequest(BaseModel): numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") context_entity_ids: list[str] = Field(default_factory=list) note: str | None = Field(default=None, max_length=500) class FeedbackRequest(BaseModel): correct: bool expected_state: str | None = Field(default=None, max_length=100) @router.get("/discovery", response_model=list[HaEntitySummary]) def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} discovered = ha_reader.discover() actuator_ids = _deduplicate_actuator_ids( [ (entity.entity_id, entity.category) for entity in discovered if entity.role is EntityRole.ACTUATOR ], entities, ) return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] @router.get("/context-options", response_model=list[HaEntitySummary]) def context_options( request: Request, actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"), ) -> list[HaEntitySummary]: if actuator_entity_id is None: return [] try: return _service(request).suggest_context_options(actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("", response_model=list[ActuatorRecord]) def list_configured(request: Request) -> list[ActuatorRecord]: return _service(request).list_configured() @router.post("", response_model=ActuatorRecord, status_code=201) def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord: try: record = _service(request).configure_actuator( payload.actuator_entity_id, enabled=payload.enabled, ) _behavior(request).train(record.actuator_entity_id) return _behavior(request).evaluate(record.actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/{actuator_entity_id}", response_model=ActuatorRecord) def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord: try: return _service(request).get_actuator(actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.delete("/{actuator_entity_id}", status_code=204) def delete_actuator(actuator_entity_id: str, request: Request) -> None: _service(request).delete_actuator(actuator_entity_id) @router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord) def reconcile_actuator( actuator_entity_id: str, request: Request, ) -> ActuatorRecord: try: _service(request).reconcile_actuator(actuator_entity_id, trigger="manual") _behavior(request).train(actuator_entity_id) return _behavior(request).evaluate(actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/{actuator_entity_id}/evaluate", response_model=ActuatorRecord) def evaluate_actuator( actuator_entity_id: str, request: Request, ) -> ActuatorRecord: try: return _behavior(request).evaluate(actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord) def record_feedback( actuator_entity_id: str, payload: FeedbackRequest, request: Request, ) -> ActuatorRecord: try: return _behavior(request).record_feedback( actuator_entity_id, correct=payload.correct, expected_state=payload.expected_state, ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord) def set_activation( actuator_entity_id: str, payload: ActivationRequest, request: Request, ) -> ActuatorRecord: try: return _behavior(request).set_active( actuator_entity_id, active=payload.active, pause_matching_automations=payload.pause_matching_automations, restore_paused_automations=payload.restore_paused_automations, ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @router.post("/{actuator_entity_id}/assignment", response_model=ActuatorRecord) def set_manual_assignment( actuator_entity_id: str, payload: ManualAssignmentRequest, request: Request, ) -> ActuatorRecord: try: record = _service(request).set_manual_assignment( actuator_entity_id, numeric_entity_id=payload.numeric_entity_id, context_entity_ids=payload.context_entity_ids, note=payload.note, ) _behavior(request).train(record.actuator_entity_id) return _behavior(request).evaluate(record.actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @router.post( "/{actuator_entity_id}/related-automations/refresh", response_model=ActuatorRecord, ) def refresh_related_automations( actuator_entity_id: str, request: Request, ) -> ActuatorRecord: try: return _behavior(request).refresh_related_automations(actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ValueError, HaClientError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @router.post( "/{actuator_entity_id}/related-automations/control", response_model=ActuatorRecord, ) def control_related_automation( actuator_entity_id: str, payload: AutomationControlRequest, request: Request, ) -> ActuatorRecord: try: return _behavior(request).set_automation_enabled( actuator_entity_id, payload.automation_entity_id, enabled=payload.enabled, ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ValueError, HaClientError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @router.get("/reconciliation/state", response_model=ReconciliationState) def get_reconciliation_state(request: Request) -> ReconciliationState: store = getattr(request.app.state, "actuator_store", None) if not isinstance(store, ActuatorStore): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Actuator Store nicht initialisiert.", ) return store.load_reconciliation_state() @router.post("/reconciliation/run", response_model=ReconciliationState) def run_reconciliation( request: Request, trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"), ) -> ReconciliationState: state = _service(request).reconcile_all(trigger=trigger) _behavior(request).train_all() _behavior(request).evaluate_all() return state def _service(request: Request) -> ActuatorReconciliationService: service = getattr(request.app.state, "actuator_service", None) if not isinstance(service, ActuatorReconciliationService): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Actuator-Reconciliation nicht initialisiert.", ) return service def _behavior(request: Request) -> BehaviorEngine: engine = getattr(request.app.state, "behavior_engine", None) if not isinstance(engine, BehaviorEngine): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Verhaltenslernen ist nicht initialisiert.", ) return engine def _deduplicate_actuator_ids( discovered: list[tuple[str, str]], entities: dict[str, HaEntitySummary], ) -> list[str]: priority = { "light": 0, "cover_shutter": 1, "heating": 2, "lock": 3, "fan": 4, "switch_socket": 5, "button": 6, "helper": 7, } selected: dict[str, tuple[int, str]] = {} for entity_id, category in discovered: entity = entities.get(entity_id) if entity is None: continue key = _actuator_duplicate_key(entity, category) rank = priority.get(category, 50) current = selected.get(key) if current is None or (rank, entity_id) < current: selected[key] = (rank, entity_id) return sorted(entity_id for _, entity_id in selected.values()) def _actuator_duplicate_key(entity: HaEntitySummary, category: str) -> str: if entity.device_id and category in {"light", "switch_socket", "button"}: return f"device:{entity.device_id}:control" if entity.device_name and category in {"light", "switch_socket", "button"}: return f"device-name:{entity.device_name.lower()}:control" return f"entity:{entity.entity_id}"