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.cache_db import DashboardCache from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile 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 WeightOverrideRequest(BaseModel): sensor_weights: dict[str, float] = Field(default_factory=dict) sensor_weight_groups: list[SensorWeightGroup] = 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 SafetyProfileRequest(BaseModel): safety: SafetyProfile class ModelRollbackRequest(BaseModel): version_id: str = Field(min_length=1, max_length=120) 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 anomaly_count: int = 0 critical_anomaly_count: int = 0 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 performance_budget_ms: int = 3000 job_p95_duration_ms: int | None = None slow_job_count: int = 0 performance_status: str = "unknown" anomaly_count: int = 0 critical_anomaly_count: 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] jobs: JobQueueState = Field(default_factory=JobQueueState) class AnomalyOverview(BaseModel): actuator_entity_id: str friendly_name: str | None = None anomalies: list[AnomalyEvent] = Field(default_factory=list) @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: job = _start_job( request, kind="discovery", trigger="manual" if refresh else "cache-miss", summary="Home-Assistant-Entities werden gelesen und klassifiziert.", ) try: fresh_entities = list(ha_reader.read_entities()) _save_cached_entities(request, fresh_entities) except Exception as exc: _finish_job(job, request, status=JobStatus.FAILED, summary="Discovery fehlgeschlagen.", error=str(exc)) raise _finish_job(job, request, status=JobStatus.COMPLETED, summary=f"{len(fresh_entities)} Entities klassifiziert.") 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, anomaly_count=len([item for item in record.behavior.anomalies if not item.resolved]), critical_anomaly_count=len( [ item for item in record.behavior.anomalies if not item.resolved and item.severity == "critical" ] ), 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: return _dashboard_overview(request, include_background=True, include_actuators=True) @router.get("/dashboard/start", response_model=DashboardOverview) def dashboard_start(request: Request) -> DashboardOverview: return _dashboard_overview(request, include_background=False, include_actuators=True) @router.get("/dashboard/system", response_model=DashboardOverview) def dashboard_system(request: Request) -> DashboardOverview: return _dashboard_overview(request, include_background=False, include_actuators=False) def _dashboard_overview( request: Request, *, include_background: bool, include_actuators: bool, ) -> DashboardOverview: cache_status = _load_entity_cache_status(request) raw_updated_at = cache_status.get("updated_at") updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None raw_groups = cache_status.get("discovery_groups", []) cached_groups = [ DashboardDiscoveryGroup.model_validate(group) for group in raw_groups if isinstance(group, dict) ] if include_background and 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) if include_actuators else [] store = getattr(request.app.state, "actuator_store", None) jobs = ( store.load_job_queue() if include_background and isinstance(store, ActuatorStore) else JobQueueState() ) job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs) anomaly_count = sum(record.anomaly_count for record in actuators) critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators) entity_count = cache_status.get("entity_count") if not isinstance(entity_count, int): entity_count = 0 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) if include_actuators else reconciliation.configured_actuators ), trained_models=reconciliation.trained_models, review_required=reconciliation.review_required, job_p95_duration_ms=job_p95_duration_ms, slow_job_count=slow_job_count, performance_status=performance_status, anomaly_count=anomaly_count, critical_anomaly_count=critical_anomaly_count, ), cache=EntityCacheStatus( available=bool(entity_count), updated_at=updated_at, entity_count=entity_count, ), actuators=actuators, discovery_groups=cached_groups, jobs=jobs, ) @router.get("/anomalies", response_model=list[AnomalyOverview]) def list_anomalies(request: Request) -> list[AnomalyOverview]: records = _service(request).list_configured() entity_map = _load_cached_entity_map( request, {record.actuator_entity_id for record in records}, ) overview: list[AnomalyOverview] = [] for record in records: active = [item for item in record.behavior.anomalies if not item.resolved] if not active: continue entity = entity_map.get(record.actuator_entity_id) overview.append( AnomalyOverview( actuator_entity_id=record.actuator_entity_id, friendly_name=entity.friendly_name if entity is not None else None, anomalies=active, ) ) return overview @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.get("/{actuator_entity_id}/detail", response_model=ActuatorRecord) def get_actuator_detail(actuator_entity_id: str, request: Request) -> ActuatorRecord: try: record = _service(request).get_actuator(actuator_entity_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc selected_ids = { entity_id for entity_id in [ record.assignment.selected_numeric_entity_id, *record.assignment.selected_context_entity_ids, ] if entity_id } compact_snapshots = [ snapshot.model_copy(update={"patterns": []}) for snapshot in record.behavior.model_snapshots[-3:] ] compact_behavior = record.behavior.model_copy( update={ "patterns": [], "model_snapshots": compact_snapshots, } ) return record.model_copy( update={ "behavior": compact_behavior, "numeric_candidates": [ candidate for candidate in record.numeric_candidates if candidate.entity_id in selected_ids ], "context_candidates": [ candidate for candidate in record.context_candidates if candidate.entity_id in selected_ids ], } ) @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}/safety", response_model=ActuatorRecord) def set_safety_profile( actuator_entity_id: str, payload: SafetyProfileRequest, request: Request, ) -> ActuatorRecord: try: return _behavior(request).set_safety_profile(actuator_entity_id, profile=payload.safety) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/{actuator_entity_id}/model/rollback", response_model=ActuatorRecord) def rollback_model( actuator_entity_id: str, payload: ModelRollbackRequest, request: Request, ) -> ActuatorRecord: try: return _behavior(request).rollback_model(actuator_entity_id, version_id=payload.version_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}/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}/weights", response_model=ActuatorRecord) def set_weight_overrides( actuator_entity_id: str, payload: WeightOverrideRequest, request: Request, ) -> ActuatorRecord: try: _validate_weight_payload(payload) record = _service(request).set_weight_overrides( actuator_entity_id, sensor_weights=payload.sensor_weights, sensor_weight_groups=payload.sensor_weight_groups, note=payload.note, ) return record 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: job = _start_job( request, kind="automation_refresh", trigger="manual", target=actuator_entity_id, summary="Passende HA-Automationen werden gesucht.", ) try: record = _behavior(request).refresh_related_automations(actuator_entity_id) _finish_job( job, request, status=JobStatus.COMPLETED, summary=f"{len(record.behavior.related_automations)} Automationen gefunden.", ) return record except KeyError as exc: _finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(exc)) raise HTTPException(status_code=404, detail=str(exc)) from exc except (ValueError, HaClientError) as exc: _finish_job(job, request, status=JobStatus.FAILED, summary="Automation-Refresh fehlgeschlagen.", error=str(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: reconciliation_job = _start_job( request, kind="reconciliation", trigger=trigger, summary="Kontext, Zuordnung und Modelle werden abgeglichen.", ) training_job: JobQueueItem | None = None evaluation_job: JobQueueItem | None = None try: state = _service(request).reconcile_all(trigger=trigger) _finish_job(reconciliation_job, request, status=JobStatus.COMPLETED, summary=state.last_summary) reconciliation_job = None training_job = _start_job( request, kind="training", trigger=trigger, summary="Gelernte Aktorhandlungen werden aktualisiert.", ) _behavior(request).train_all() _finish_job(training_job, request, status=JobStatus.COMPLETED, summary="Training abgeschlossen.") training_job = None evaluation_job = _start_job( request, kind="evaluation", trigger=trigger, summary="Aktuelle Vorhersagen werden neu berechnet.", ) _behavior(request).evaluate_all() _finish_job(evaluation_job, request, status=JobStatus.COMPLETED, summary="Evaluation abgeschlossen.") evaluation_job = None except Exception as exc: for job in [reconciliation_job, training_job, evaluation_job]: if isinstance(job, JobQueueItem) and job.status is JobStatus.RUNNING: _finish_job(job, request, status=JobStatus.FAILED, summary="Job fehlgeschlagen.", error=str(exc)) raise return state @router.get("/job-queue/state", response_model=JobQueueState) def get_job_queue(request: Request) -> JobQueueState: 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_job_queue() def _start_job( request: Request, *, kind: str, trigger: str, target: str | None = None, summary: str = "", ) -> JobQueueItem | None: store = getattr(request.app.state, "actuator_store", None) if not isinstance(store, ActuatorStore): return None return store.start_job(kind=kind, trigger=trigger, target=target, summary=summary) def _finish_job( job: JobQueueItem | None, request: Request, *, status: JobStatus, summary: str, error: str | None = None, ) -> None: if job is None: return store = getattr(request.app.state, "actuator_store", None) if not isinstance(store, ActuatorStore): return store.finish_job(job.job_id, status=status, summary=summary, error=error) def _performance_status(jobs: JobQueueState) -> tuple[int | None, int, str]: budget_ms = 3000 durations = sorted( job.duration_ms for job in jobs.jobs if job.status is JobStatus.COMPLETED and job.duration_ms is not None ) slow_count = sum(1 for duration in durations if duration >= budget_ms) if durations: index = min(len(durations) - 1, int(round((len(durations) - 1) * 0.95))) p95: int | None = durations[index] status_value = "slow" if slow_count else "ok" else: p95 = None status_value = "unknown" if any(job.status is JobStatus.RUNNING for job in jobs.jobs): status_value = "running" if status_value == "unknown" else status_value return p95, slow_count, status_value 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 _validate_weight_payload(payload: WeightOverrideRequest) -> None: for entity_id, weight in payload.sensor_weights.items(): if "." not in entity_id: raise ValueError(f"Ungültige Entity-ID: {entity_id}") if not 0.0 <= weight <= 1.0: raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}") for group in payload.sensor_weight_groups: if not group.entity_ids: raise ValueError(f"Gruppe {group.name} enthält keine Entities.") for entity_id in group.entity_ids: if "." not in entity_id: raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}") 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 {} cache = getattr(request.app.state, "dashboard_cache", None) if isinstance(cache, DashboardCache): cached_result = cache.load_entity_map(entity_ids) if cached_result: return cached_result 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_status(request: Request) -> dict[str, object]: cache = getattr(request.app.state, "dashboard_cache", None) if isinstance(cache, DashboardCache): status_payload = cache.load_status() if status_payload.get("entity_count"): return status_payload path = _entity_cache_path(request) if not path.exists(): return {} try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, TypeError, ValueError): return {} if not isinstance(payload, dict): return {} raw_entities = payload.get("entities", []) return { "updated_at": payload.get("updated_at"), "discovery_groups": payload.get("discovery_groups", []), "entity_count": len(raw_entities) if isinstance(raw_entities, list) else 0, } def _load_entity_cache_payload(request: Request) -> dict[str, object]: cache = getattr(request.app.state, "dashboard_cache", None) if isinstance(cache, DashboardCache): payload = cache.load_entities_payload() if payload.get("entities"): return payload 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: group_payload = _discovery_group_payload(entities) cache = getattr(request.app.state, "dashboard_cache", None) if isinstance(cache, DashboardCache): cache.save_entities_payload( entities=entities, discovery_groups=group_payload, ) path = _entity_cache_path(request) path.parent.mkdir(parents=True, exist_ok=True) payload = { "updated_at": datetime.now(timezone.utc).isoformat(), "discovery_groups": group_payload, "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 _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]: 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 return [ {"category": category, "role": role, "count": count} for (category, role), count in sorted(group_counts.items()) ] 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