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 DiscoveredEntity, 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) class ActuatorSuggestion(BaseModel): entity_id: str domain: str friendly_name: str | None = None area_name: str | None = None device_name: str | None = None confidence: float reason: str related_automation_count: int = 0 likely_context_count: int = 0 @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("/suggestions", response_model=list[ActuatorSuggestion]) def suggest_actuators( request: Request, ha_reader: HaReader = Depends(get_ha_reader), ) -> list[ActuatorSuggestion]: entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} discovered = {entity.entity_id: entity for entity in ha_reader.discover()} configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()} actuator_ids = _deduplicate_actuator_ids( [ (entity.entity_id, entity.category) for entity in discovered.values() if entity.role is EntityRole.ACTUATOR ], entities, ) suggestions: list[ActuatorSuggestion] = [] for entity_id in actuator_ids: if entity_id in configured_ids: continue entity = entities.get(entity_id) if entity is None: continue try: automations = ha_reader.find_automations_for_entity(entity_id) except Exception: automations = [] context_count = _likely_context_count(entity, entities, discovered) if not automations and context_count == 0: continue confidence = 1.0 if automations else min(0.85, 0.35 + context_count * 0.1) reason_parts = [] if automations: reason_parts.append(f"{len(automations)} passende HA-Automation(en)") if context_count: reason_parts.append(f"{context_count} naheliegende Kontext-Entity(s)") suggestions.append( ActuatorSuggestion( entity_id=entity.entity_id, domain=entity.domain, friendly_name=entity.friendly_name, area_name=entity.area_name, device_name=entity.device_name, confidence=round(confidence, 4), reason=", ".join(reason_parts), related_automation_count=len(automations), likely_context_count=context_count, ) ) return sorted( suggestions, key=lambda item: ( -item.related_automation_count, -item.confidence, item.area_name or "", item.friendly_name or item.entity_id, ), )[:30] @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}" def _likely_context_count( actuator: HaEntitySummary, entities: dict[str, HaEntitySummary], discovered: dict[str, DiscoveredEntity], ) -> int: actuator_tokens = _tokens(actuator) count = 0 for entity in entities.values(): if entity.entity_id == actuator.entity_id: continue descriptor = discovered.get(entity.entity_id) role = descriptor.role if descriptor is not None else None if role not in { EntityRole.MEASUREMENT, EntityRole.BINARY_CONTEXT, EntityRole.CONTEXT, }: continue if entity.device_class not in { "door", "energy", "garage_door", "humidity", "illuminance", "motion", "occupancy", "opening", "power", "presence", "temperature", "window", }: continue same_area = bool( actuator.area_name and entity.area_name and actuator.area_name == entity.area_name ) same_device = bool( actuator.device_id and entity.device_id and actuator.device_id == entity.device_id ) token_match = bool(actuator_tokens.intersection(_tokens(entity))) if same_area or same_device or token_match: count += 1 return count def _tokens(entity: HaEntitySummary) -> set[str]: values = [ entity.entity_id, entity.friendly_name, entity.area_name, entity.device_name, ] tokens: set[str] = set() for value in values: if not value: continue tokens.update(token for token in value.lower().replace("_", " ").split() if len(token) > 2) return tokens