from __future__ import annotations import json import os from datetime import datetime, timezone from pathlib import Path 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.config import Settings from app.dependencies import get_ha_reader from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities 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 class ActuatorSummary(BaseModel): actuator_entity_id: str domain: str friendly_name: str | None = None area_name: str | None = None device_name: str | None = None enabled: bool behavior_mode: str behavior_status: str lifecycle_status: str activation_ready: bool activation_reason: str sample_count: int prediction_target_state: str | None = None prediction_confidence: float | None = None updated_at: str class EntityCacheStatus(BaseModel): available: bool updated_at: str | None = None entity_count: int = 0 class DashboardSystemStatus(BaseModel): api_status: str = "ok" websocket_status: str = "unavailable" websocket_error: str | None = None reconciliation_last_completed_at: str | None = None configured_actuators: int = 0 trained_models: int = 0 review_required: int = 0 class DashboardDiscoveryGroup(BaseModel): category: str role: str count: int class DashboardOverview(BaseModel): system: DashboardSystemStatus cache: EntityCacheStatus actuators: list[ActuatorSummary] discovery_groups: list[DashboardDiscoveryGroup] @router.get("/discovery", response_model=list[HaEntitySummary]) def discover_actuators( request: Request, refresh: bool = Query(default=False), ha_reader: HaReader = Depends(get_ha_reader), ) -> list[HaEntitySummary]: cached_entities = [] if refresh else _load_cached_entities(request) if cached_entities: entities = {entity.entity_id: entity for entity in cached_entities} else: fresh_entities = list(ha_reader.read_entities()) _save_cached_entities(request, fresh_entities) entities = {entity.entity_id: entity for entity in fresh_entities} discovered = discover_entities(list(entities.values())) 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 discover_entities(list(entities.values()))} 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("/summary", response_model=list[ActuatorSummary]) def list_configured_summary(request: Request) -> list[ActuatorSummary]: records = _service(request).list_configured() entity_map = _load_cached_entity_map( request, {record.actuator_entity_id for record in records}, ) return [ ActuatorSummary( actuator_entity_id=record.actuator_entity_id, domain=record.actuator_entity_id.split(".", 1)[0], friendly_name=( entity_map[record.actuator_entity_id].friendly_name if record.actuator_entity_id in entity_map else None ), area_name=( entity_map[record.actuator_entity_id].area_name if record.actuator_entity_id in entity_map else None ), device_name=( entity_map[record.actuator_entity_id].device_name if record.actuator_entity_id in entity_map else None ), enabled=record.enabled, behavior_mode=record.behavior.mode.value, behavior_status=record.behavior.status.value, lifecycle_status=record.lifecycle.status.value, activation_ready=record.behavior.activation_ready, activation_reason=record.behavior.activation_reason, sample_count=record.behavior.sample_count, prediction_target_state=( record.behavior.prediction.target_state if record.behavior.prediction is not None else None ), prediction_confidence=( record.behavior.prediction.confidence if record.behavior.prediction is not None else None ), updated_at=record.updated_at.isoformat(), ) for record in records ] @router.get("/dashboard", response_model=DashboardOverview) def dashboard_overview(request: Request) -> DashboardOverview: cache_payload = _load_entity_cache_payload(request) raw_entities = cache_payload.get("entities", []) if not isinstance(raw_entities, list): raw_entities = [] raw_updated_at = cache_payload.get("updated_at") updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None raw_groups = cache_payload.get("discovery_groups", []) cached_groups = [ DashboardDiscoveryGroup.model_validate(group) for group in raw_groups if isinstance(group, dict) ] if isinstance(raw_groups, list) else [] reconciliation = _reconciliation_state_or_default(request) ws_status = getattr(request.app.state, "ws_status", None) actuators = list_configured_summary(request) return DashboardOverview( system=DashboardSystemStatus( websocket_status=getattr(ws_status, "status", "unavailable"), websocket_error=getattr(ws_status, "error", None), reconciliation_last_completed_at=( reconciliation.last_completed_at.isoformat() if reconciliation.last_completed_at is not None else None ), configured_actuators=len(actuators), trained_models=reconciliation.trained_models, review_required=reconciliation.review_required, ), cache=EntityCacheStatus( available=bool(raw_entities), updated_at=updated_at, entity_count=len(raw_entities), ), actuators=actuators, discovery_groups=cached_groups, ) @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 _reconciliation_state_or_default(request: Request) -> ReconciliationState: store = getattr(request.app.state, "actuator_store", None) if not isinstance(store, ActuatorStore): return ReconciliationState() try: return store.load_reconciliation_state() except ValueError: return ReconciliationState(last_summary="Reconciliation-Status ist unlesbar.") def _entity_cache_path(request: Request) -> Path: store = getattr(request.app.state, "actuator_store", None) settings = getattr(request.app.state, "settings", None) if isinstance(store, ActuatorStore): base_dir = store._root elif isinstance(settings, Settings): base_dir = Path(settings.actuator_store).resolve().parent else: base_dir = Path(".").resolve() return Path(os.getenv("SILLYHOME_ENTITY_CACHE", base_dir / "ha_entity_cache.json")) def _load_cached_entities(request: Request) -> list[HaEntitySummary]: payload = _load_entity_cache_payload(request) raw_entities = payload.get("entities", []) if not isinstance(raw_entities, list): return [] try: return [HaEntitySummary.model_validate(entity) for entity in raw_entities] except ValueError: return [] def _load_cached_entity_map( request: Request, entity_ids: set[str], ) -> dict[str, HaEntitySummary]: if not entity_ids: return {} payload = _load_entity_cache_payload(request) raw_entities = payload.get("entities", []) if not isinstance(raw_entities, list): return {} result: dict[str, HaEntitySummary] = {} for raw_entity in raw_entities: if not isinstance(raw_entity, dict): continue entity_id = raw_entity.get("entity_id") if not isinstance(entity_id, str) or entity_id not in entity_ids: continue try: result[entity_id] = HaEntitySummary.model_validate(raw_entity) except ValueError: continue return result def _load_entity_cache_payload(request: Request) -> dict[str, object]: path = _entity_cache_path(request) if not path.exists(): return {} try: payload = json.loads(path.read_text(encoding="utf-8")) return payload if isinstance(payload, dict) else {} except (OSError, TypeError, ValueError): return {} def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None: path = _entity_cache_path(request) path.parent.mkdir(parents=True, exist_ok=True) group_counts: dict[tuple[str, str], int] = {} for entity in discover_entities(entities): key = (entity.category, entity.role.value) group_counts[key] = group_counts.get(key, 0) + 1 payload = { "updated_at": datetime.now(timezone.utc).isoformat(), "discovery_groups": [ {"category": category, "role": role, "count": count} for (category, role), count in sorted(group_counts.items()) ], "entities": [entity.model_dump(mode="json") for entity in entities], } temporary = path.with_suffix(".json.tmp") temporary.write_text( json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n", encoding="utf-8", ) os.replace(temporary, path) 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