from __future__ import annotations import asyncio import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress from datetime import datetime, timezone from fastapi import FastAPI from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from app.core.decision import DecisionEngineV2 from app.core.event_core import EventCore from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state from app.core.handoff import HandoffMatrix from app.core.models import ( AuditEvent, AutopilotSettings, AutomationProposal, BackupBundle, BehaviorPatternV2, CandidateRecommendation, CandidateStatus, ControlProfile, ControlState, EntityState, HistoryAnalysis, HandoffMode, JobQueueItem, JobStatus, LearningProfile, LearningState, ModelEvaluation, ModelRecord, ProposalStatus, RuntimeState, SafetyStage, SensorWeightOverride, StateEvent, ) from app.core.stores import FutureStores stores = FutureStores(os.getenv("SILLYHOME_FUTURE_STORE", ".future_store")) event_core = EventCore(stores) handoff = HandoffMatrix() ha_client: FutureHaClient | None = None class ControlStageUpdate(BaseModel): stage: SafetyStage min_confidence: float = Field(default=0.82, ge=0.0, le=1.0) manual_block: bool = False cooldown_seconds: int = Field(default=900, ge=0) class PatternCreateRequest(BaseModel): trigger_entity_id: str trigger_state: str | None = None target_state: str confidence: float = Field(default=0.9, ge=0.0, le=1.0) support: int = Field(default=3, ge=1) source: str = Field(default="dashboard", max_length=40) class GlobalControlUpdate(BaseModel): enabled: bool class SimulationRequest(BaseModel): actuator_entity_id: str trigger_entity_id: str trigger_state: str | None = None class ActiveReadiness(BaseModel): actuator_entity_id: str ready: bool reason: str dry_run_events: int dry_run_successes: int dry_run_failures: int class FeedbackRequest(BaseModel): kind: str = Field(default="correct", max_length=40) expected_state: str | None = Field(default=None, max_length=100) note: str | None = Field(default=None, max_length=500) class ActuatorSummary(BaseModel): actuator_entity_id: str stage: SafetyStage handoff_mode: HandoffMode active_ready: bool pattern_count: int feedback_positive: int feedback_negative: int class AutomationProposalRequest(BaseModel): name: str = Field(max_length=120) trigger_entity_id: str trigger_state: str | None = None actuator_entity_id: str target_state: str class HistoryAnalysisRequest(BaseModel): entity_ids: list[str] = Field(default_factory=list) start_time: datetime | None = None end_time: datetime | None = None class TrainModelRequest(BaseModel): actuator_entity_id: str | None = None class EvaluateModelRequest(BaseModel): model_id: str | None = None actuator_entity_id: str | None = None class WeightOverrideRequest(BaseModel): sensor_weights: dict[str, float] = Field(default_factory=dict) note: str | None = Field(default=None, max_length=500) class AutopilotRunResult(BaseModel): candidates: list[CandidateRecommendation] trained_models: list[ModelRecord] evaluations: list[ModelEvaluation] @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: global ha_client listener_task: asyncio.Task[None] | None = None autopilot_task: asyncio.Task[None] | None = None ha_client = _ha_client_from_env() if ha_client is not None: _load_initial_ha_states(ha_client) listener_task = asyncio.create_task(_ha_listener_loop(ha_client)) autopilot_task = asyncio.create_task(_autopilot_loop()) try: yield finally: for task in (listener_task, autopilot_task): if task is not None: task.cancel() with suppress(asyncio.CancelledError): await task app = FastAPI( title="SillyHome Future API", description="SillyHome v2 event-core side project.", version="2.0.0-alpha.15", lifespan=lifespan, ) @app.get("/health") def health() -> dict[str, str]: runtime = stores.runtime() return { "status": "ok", "version": app.version, "ha": runtime.websocket_status, } @app.get("/v2/health") def detailed_health() -> dict[str, object]: runtime = stores.runtime() control = stores.control() return { "api": "ok", "version": app.version, "websocket": runtime.websocket_status, "store": "ok", "global_enabled": control.global_enabled, "entities": len(runtime.entities), "audit_events": len(runtime.audit), } @app.get("/", response_class=HTMLResponse) def dashboard() -> str: return _dashboard_html() @app.get("/v2/dashboard") def dashboard_data() -> dict[str, object]: runtime = stores.runtime() learning = stores.learning() control = stores.control() latest_audit = runtime.audit[-20:] actuator_entities = [ entity for entity in runtime.entities.values() if _is_good_actuator(entity.entity_id, entity.friendly_name) ] return { "websocket_status": runtime.websocket_status, "entity_count": len(runtime.entities), "actuator_count": len(actuator_entities), "global_enabled": control.global_enabled, "learning_profiles": len(learning.profiles), "control_profiles": len(control.profiles), "automation_proposals": len(learning.automation_proposals), "models": len(learning.models), "jobs": len(learning.jobs), "candidates": len(learning.candidates), "autopilot_enabled": control.autopilot.enabled, "autopilot_last_run_at": control.autopilot.last_run_at, "rooms": list(learning.rooms.values()), "scenes": list(learning.scenes.values()), "audit": latest_audit, } @app.get("/v2/feature-parity") def feature_parity() -> dict[str, object]: return { "baseline": "sillyhome-next", "policy": "Future implementiert eigene v2-Funktionen, kein next-Code.", "implemented": [ "HA REST/WebSocket", "Service-Ausfuehrung", "Dashboard", "Aktor-Control", "Lernmuster", "Dry-run", "Safety-Gates", "Feedback", "Backup/Restore", "Raeume/Szenen", "Not-Aus", "Simulation", "Audit", "Health", "Automation-Proposals", "YAML-Export", "History-Analyse", "lokales Modelltraining", "Sensor-Gewichte", "Job-Queue", "Autopilot light", "Kandidaten-Vorschlaege", "periodisches Training/Bewertung", ], "next_to_expand": ["Dashboard-Flaechen fuer Autopilot/History/Modelle"], } @app.get("/v2/autopilot/settings", response_model=AutopilotSettings) def get_autopilot_settings() -> AutopilotSettings: return stores.control().autopilot @app.put("/v2/autopilot/settings", response_model=AutopilotSettings) def put_autopilot_settings(settings: AutopilotSettings) -> AutopilotSettings: control = stores.control().model_copy(update={"autopilot": settings}) stores.save_control(control) _append_audit( "autopilot", None, "Autopilot light aktiviert." if settings.enabled else "Autopilot light deaktiviert.", ) return settings @app.post("/v2/autopilot/run", response_model=AutopilotRunResult) def run_autopilot() -> AutopilotRunResult: return _run_autopilot_once() @app.get("/v2/autopilot/candidates", response_model=list[CandidateRecommendation]) def list_candidates() -> list[CandidateRecommendation]: return list(stores.learning().candidates.values()) @app.post("/v2/autopilot/candidates/{candidate_id}/accept", response_model=LearningProfile) def accept_candidate(candidate_id: str) -> LearningProfile: state = stores.learning() candidate = state.candidates[candidate_id].model_copy( update={"status": CandidateStatus.ACCEPTED} ) state.candidates[candidate_id] = candidate profile = state.profiles.get( candidate.actuator_entity_id, LearningProfile(actuator_entity_id=candidate.actuator_entity_id), ) profile = profile.model_copy( update={ "patterns": [ *profile.patterns, BehaviorPatternV2( actuator_entity_id=candidate.actuator_entity_id, target_state=candidate.target_state, trigger_entity_id=candidate.trigger_entity_id, trigger_state=candidate.trigger_state, support=3, confidence=candidate.confidence, source="autopilot", ), ], "model_version": "autopilot-light", } ) state.profiles[candidate.actuator_entity_id] = profile stores.save_learning(state) _append_job("autopilot_candidate", f"Kandidat {candidate_id} akzeptiert.") return profile @app.post("/v2/autopilot/candidates/{candidate_id}/dismiss", response_model=CandidateRecommendation) def dismiss_candidate(candidate_id: str) -> CandidateRecommendation: state = stores.learning() candidate = state.candidates[candidate_id].model_copy( update={"status": CandidateStatus.DISMISSED} ) state.candidates[candidate_id] = candidate stores.save_learning(state) _append_job("autopilot_candidate", f"Kandidat {candidate_id} verworfen.") return candidate @app.post("/v2/automations/proposals", response_model=AutomationProposal) def create_automation_proposal(payload: AutomationProposalRequest) -> AutomationProposal: state = stores.learning() proposal_id = f"proposal-{len(state.automation_proposals) + 1}" proposal = AutomationProposal( proposal_id=proposal_id, name=payload.name, trigger_entity_id=payload.trigger_entity_id, trigger_state=payload.trigger_state, actuator_entity_id=payload.actuator_entity_id, target_state=payload.target_state, ) state.automation_proposals[proposal_id] = proposal stores.save_learning(state) _append_job("automation_proposal", f"Automation-Vorschlag {proposal_id} angelegt.") return proposal @app.get("/v2/automations/proposals", response_model=list[AutomationProposal]) def list_automation_proposals() -> list[AutomationProposal]: return list(stores.learning().automation_proposals.values()) @app.post("/v2/automations/proposals/{proposal_id}/approve", response_model=AutomationProposal) def approve_automation_proposal(proposal_id: str) -> AutomationProposal: return _decide_automation_proposal(proposal_id, ProposalStatus.APPROVED) @app.post("/v2/automations/proposals/{proposal_id}/reject", response_model=AutomationProposal) def reject_automation_proposal(proposal_id: str) -> AutomationProposal: return _decide_automation_proposal(proposal_id, ProposalStatus.REJECTED) @app.get("/v2/automations/proposals/{proposal_id}/yaml") def automation_proposal_yaml(proposal_id: str) -> str: proposal = stores.learning().automation_proposals[proposal_id] return "\n".join( [ f"alias: {proposal.name}", "trigger:", " - platform: state", f" entity_id: {proposal.trigger_entity_id}", f" to: {proposal.trigger_state or ''}", "action:", " - service: homeassistant.turn_on", " target:", f" entity_id: {proposal.actuator_entity_id}", " data:", f" target_state: {proposal.target_state}", "mode: single", "", ] ) @app.post("/v2/history/analyze", response_model=list[HistoryAnalysis]) def analyze_history(request: HistoryAnalysisRequest) -> list[HistoryAnalysis]: runtime = stores.runtime() ids = request.entity_ids or list(runtime.entities)[:50] history: dict[str, list[StateEvent]] = {} if ha_client is not None and request.start_time is not None and request.end_time is not None: history = ha_client.read_history(ids, request.start_time, request.end_time) result: list[HistoryAnalysis] = [] for entity_id in ids: entity = runtime.entities.get(entity_id) samples = history.get(entity_id, []) matching_audit = [item for item in runtime.audit if item.entity_id == entity_id] states = [sample.new_state for sample in samples if sample.new_state is not None] result.append( HistoryAnalysis( entity_id=entity_id, samples=max(1, len(samples) or len(matching_audit)), last_state=states[-1] if states else (entity.state if entity else None), changed_at=samples[-1].changed_at if samples else (entity.changed_at if entity else None), unique_states=len(set(states)) if states else int(entity is not None), transitions=_count_transitions(states), recommendation=( "Als Trigger geeignet." if entity is not None and entity.domain in {"binary_sensor", "sensor"} else "Als Aktor oder Kontext pruefen." ), ) ) _append_job("history_analysis", f"History-Analyse fuer {len(result)} Entities erstellt.") return result @app.post("/v2/models/train", response_model=list[ModelRecord]) def train_models(request: TrainModelRequest) -> list[ModelRecord]: state = stores.learning() actuator_ids = [request.actuator_entity_id] if request.actuator_entity_id else list(state.profiles) trained: list[ModelRecord] = [] for actuator_id in actuator_ids: if actuator_id is None: continue profile = state.profiles.get(actuator_id) pattern_count = len(profile.patterns) if profile else 0 confidence = ( sum(pattern.confidence for pattern in profile.patterns) / pattern_count if profile and pattern_count else 0.0 ) model = ModelRecord( model_id=f"model-{actuator_id}-{len(state.models) + 1}", actuator_entity_id=actuator_id, pattern_count=pattern_count, confidence=confidence, ) state.models[model.model_id] = model trained.append(model) stores.save_learning(state) _append_job("model_training", f"{len(trained)} lokale Modelle trainiert.") return trained @app.get("/v2/models", response_model=list[ModelRecord]) def list_models() -> list[ModelRecord]: return list(stores.learning().models.values()) @app.post("/v2/models/evaluate", response_model=list[ModelEvaluation]) def evaluate_models(request: EvaluateModelRequest) -> list[ModelEvaluation]: state = stores.learning() control = stores.control() models = list(state.models.values()) if request.model_id: models = [model for model in models if model.model_id == request.model_id] if request.actuator_entity_id: models = [model for model in models if model.actuator_entity_id == request.actuator_entity_id] evaluations = [ _evaluate_model(model, state, control) for model in models ] for evaluation in evaluations: state.model_evaluations[evaluation.evaluation_id] = evaluation stores.save_learning(state) _append_job("model_evaluation", f"{len(evaluations)} Modelle bewertet.") return evaluations @app.get("/v2/models/evaluations", response_model=list[ModelEvaluation]) def list_model_evaluations() -> list[ModelEvaluation]: return list(stores.learning().model_evaluations.values())[-100:] @app.get("/v2/jobs", response_model=list[JobQueueItem]) def list_jobs() -> list[JobQueueItem]: return list(stores.learning().jobs.values())[-100:] @app.post("/v2/events/state", response_model=list[AuditEvent]) def ingest_state_event(event: StateEvent) -> list[AuditEvent]: return event_core.process_state_event(event, execute=_execute_ha_decision) @app.get("/v2/runtime", response_model=RuntimeState) def get_runtime() -> RuntimeState: return stores.runtime() @app.get("/v2/entities", response_model=list[EntityState]) def list_entities(domain: str | None = None, q: str | None = None) -> list[EntityState]: entities = list(stores.runtime().entities.values()) if domain: wanted = {item.strip() for item in domain.split(",") if item.strip()} entities = [entity for entity in entities if entity.domain in wanted] if q: needle = q.casefold() entities = [ entity for entity in entities if needle in entity.entity_id.casefold() or (entity.friendly_name is not None and needle in entity.friendly_name.casefold()) ] return sorted(entities, key=lambda entity: entity.entity_id)[:500] @app.get("/v2/entities/groups") def entity_groups() -> dict[str, list[EntityState]]: grouped: dict[str, list[EntityState]] = {} for entity in stores.runtime().entities.values(): key = entity.area_name or entity.domain grouped.setdefault(key, []).append(entity) return { key: sorted(values, key=lambda entity: entity.entity_id)[:250] for key, values in sorted(grouped.items()) } @app.get("/v2/learning", response_model=LearningState) def get_learning() -> LearningState: return stores.learning() @app.put("/v2/learning", response_model=LearningState) def put_learning(state: LearningState) -> LearningState: return stores.save_learning(state) @app.get("/v2/control", response_model=ControlState) def get_control() -> ControlState: return stores.control() @app.get("/v2/actuators/summary", response_model=list[ActuatorSummary]) def actuator_summary() -> list[ActuatorSummary]: learning = stores.learning() control = stores.control() ids = sorted(set(learning.profiles) | set(control.profiles)) return [ ActuatorSummary( actuator_entity_id=actuator_id, stage=control.profiles.get( actuator_id, ControlProfile(actuator_entity_id=actuator_id), ).stage, handoff_mode=control.profiles.get( actuator_id, ControlProfile(actuator_entity_id=actuator_id), ).handoff_mode, active_ready=control.profiles.get( actuator_id, ControlProfile(actuator_entity_id=actuator_id), ).active_ready, pattern_count=len( learning.profiles.get( actuator_id, LearningProfile(actuator_entity_id=actuator_id), ).patterns ), feedback_positive=learning.profiles.get( actuator_id, LearningProfile(actuator_entity_id=actuator_id), ).feedback_positive, feedback_negative=learning.profiles.get( actuator_id, LearningProfile(actuator_entity_id=actuator_id), ).feedback_negative, ) for actuator_id in ids ] @app.post("/v2/control/global", response_model=ControlState) def set_global_control(update: GlobalControlUpdate) -> ControlState: state = stores.control().model_copy(update={"global_enabled": update.enabled}) stores.save_control(state) _append_audit( "safety", None, "Globaler Not-Aus deaktiviert." if update.enabled else "Globaler Not-Aus aktiviert.", ) return state @app.put("/v2/control/{actuator_entity_id}", response_model=ControlProfile) def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProfile: state = stores.control() state.profiles[actuator_entity_id] = profile stores.save_control(state) return profile @app.post("/v2/control/{actuator_entity_id}/stage", response_model=ControlProfile) def update_control_stage( actuator_entity_id: str, update: ControlStageUpdate, ) -> ControlProfile: state = stores.control() profile = state.profiles.get( actuator_entity_id, ControlProfile(actuator_entity_id=actuator_entity_id), ) requested_stage = update.stage if requested_stage is SafetyStage.ACTIVE and not profile.active_ready: requested_stage = SafetyStage.DRY_RUN updated = profile.model_copy( update={ "stage": requested_stage, "min_confidence": update.min_confidence, "manual_block": update.manual_block, "cooldown_seconds": update.cooldown_seconds, "handoff_mode": handoff.classify(profile), } ) state.profiles[actuator_entity_id] = updated stores.save_control(state) _append_audit( "safety", actuator_entity_id, f"Stage gesetzt auf {updated.stage}." if updated.stage == update.stage else f"Active blockiert: {updated.active_readiness_reason}", ) return updated @app.get("/v2/control/{actuator_entity_id}/readiness", response_model=ActiveReadiness) def active_readiness(actuator_entity_id: str) -> ActiveReadiness: profile = stores.control().profiles.get( actuator_entity_id, ControlProfile(actuator_entity_id=actuator_entity_id), ) return ActiveReadiness( actuator_entity_id=actuator_entity_id, ready=profile.active_ready, reason=profile.active_readiness_reason, dry_run_events=profile.dry_run_events, dry_run_successes=profile.dry_run_successes, dry_run_failures=profile.dry_run_failures, ) @app.post("/v2/learning/{actuator_entity_id}/patterns", response_model=LearningProfile) def create_learning_pattern( actuator_entity_id: str, pattern: PatternCreateRequest, ) -> LearningProfile: state = stores.learning() profile = state.profiles.get( actuator_entity_id, LearningProfile(actuator_entity_id=actuator_entity_id), ) updated = profile.model_copy( update={ "patterns": [ *profile.patterns, BehaviorPatternV2( actuator_entity_id=actuator_entity_id, target_state=pattern.target_state, trigger_entity_id=pattern.trigger_entity_id, trigger_state=pattern.trigger_state, support=pattern.support, confidence=pattern.confidence, source=pattern.source, ), ], "model_version": "dashboard-v1", } ) state.profiles[actuator_entity_id] = updated stores.save_learning(state) return updated @app.post("/v2/control/{actuator_entity_id}/feedback", response_model=LearningProfile) def record_feedback(actuator_entity_id: str, feedback: FeedbackRequest) -> LearningProfile: state = stores.learning() profile = state.profiles.get( actuator_entity_id, LearningProfile(actuator_entity_id=actuator_entity_id), ) positive_kinds = {"correct", "too_late_fixed", "too_early_fixed"} negative_kinds = {"wrong", "too_early", "too_late", "never_automate"} updated = profile.model_copy( update={ "feedback_positive": profile.feedback_positive + int(feedback.kind in positive_kinds), "feedback_negative": profile.feedback_negative + int(feedback.kind in negative_kinds), } ) state.profiles[actuator_entity_id] = updated stores.save_learning(state) if feedback.kind == "never_automate": control = stores.control() control.profiles[actuator_entity_id] = control.profiles.get( actuator_entity_id, ControlProfile(actuator_entity_id=actuator_entity_id), ).model_copy(update={"manual_block": True, "stage": SafetyStage.BLOCKED}) stores.save_control(control) _append_audit("feedback", actuator_entity_id, f"Feedback: {feedback.kind}") return updated @app.post("/v2/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride) def set_weight_override( actuator_entity_id: str, payload: WeightOverrideRequest, ) -> SensorWeightOverride: state = stores.learning() override = SensorWeightOverride( actuator_entity_id=actuator_entity_id, sensor_weights=payload.sensor_weights, note=payload.note, ) state.weight_overrides[actuator_entity_id] = override stores.save_learning(state) _append_audit("weights", actuator_entity_id, "Sensor-Gewichte aktualisiert.") return override @app.get("/v2/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride) def get_weight_override(actuator_entity_id: str) -> SensorWeightOverride: return stores.learning().weight_overrides.get( actuator_entity_id, SensorWeightOverride(actuator_entity_id=actuator_entity_id), ) @app.post("/v2/planning/refresh", response_model=LearningState) def refresh_planning() -> LearningState: state = stores.learning() runtime = stores.runtime() rooms = dict(state.rooms) scenes = dict(state.scenes) for entity in runtime.entities.values(): room_name = entity.area_name if not room_name: continue room_id = room_name.lower().replace(" ", "_") room = rooms.get(room_id) actuator_ids = [] context_ids = [] if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}: actuator_ids.append(entity.entity_id) else: context_ids.append(entity.entity_id) if room is None: from app.core.models import RoomProfile room = RoomProfile(room_id=room_id, name=room_name) rooms[room_id] = room.model_copy( update={ "actuator_entity_ids": sorted( set(room.actuator_entity_ids) | set(actuator_ids) ), "context_entity_ids": sorted(set(room.context_entity_ids) | set(context_ids)), } ) state = state.model_copy(update={"rooms": rooms, "scenes": scenes}) stores.save_learning(state) _append_audit("planning", None, "Planung aktualisiert.") return state @app.get("/v2/anomalies") def anomalies() -> list[dict[str, object]]: runtime = stores.runtime() result: list[dict[str, object]] = [] unavailable = [ entity.entity_id for entity in runtime.entities.values() if entity.state in {"unavailable", "unknown"} ][:100] if unavailable: result.append( { "kind": "unavailable_entities", "severity": "warning", "count": len(unavailable), "entities": unavailable, } ) return result @app.post("/v2/simulate") def simulate_decision(request: SimulationRequest) -> dict[str, object]: runtime = stores.runtime() learning = stores.learning() control = stores.control() runtime.entities[request.trigger_entity_id] = EntityState( entity_id=request.trigger_entity_id, domain=request.trigger_entity_id.split(".", 1)[0], state=request.trigger_state, ) profile = learning.profiles.get( request.actuator_entity_id, LearningProfile(actuator_entity_id=request.actuator_entity_id), ) control_profile = control.profiles.get( request.actuator_entity_id, ControlProfile(actuator_entity_id=request.actuator_entity_id), ) decision = DecisionEngineV2().decide( actuator_entity_id=request.actuator_entity_id, trigger_entity_id=request.trigger_entity_id, runtime=runtime, learning=profile, control=control_profile, ) service = service_for_state( request.actuator_entity_id.split(".", 1)[0], decision.target_state, ) return { "decision": decision, "would_call_service": service is not None and decision.allowed and not decision.dry_run, "service": service, } @app.get("/v2/audit/{actuator_entity_id}", response_model=list[AuditEvent]) def actuator_audit(actuator_entity_id: str) -> list[AuditEvent]: return [ item for item in stores.runtime().audit if item.entity_id == actuator_entity_id or ( item.decision is not None and item.decision.actuator_entity_id == actuator_entity_id ) ][-50:] @app.post("/v2/handoff/{actuator_entity_id}/assume", response_model=ControlProfile) def assume_control(actuator_entity_id: str) -> ControlProfile: state = stores.control() profile = state.profiles.get( actuator_entity_id, ControlProfile(actuator_entity_id=actuator_entity_id), ) updated = handoff.assume_control(profile) state.profiles[actuator_entity_id] = updated stores.save_control(state) return updated @app.post("/v2/handoff/{actuator_entity_id}/rollback", response_model=ControlProfile) def rollback_control(actuator_entity_id: str) -> ControlProfile: state = stores.control() profile = state.profiles.get( actuator_entity_id, ControlProfile(actuator_entity_id=actuator_entity_id), ) updated = handoff.rollback(profile) state.profiles[actuator_entity_id] = updated stores.save_control(state) return updated @app.get("/v2/handoff/{actuator_entity_id}", response_model=HandoffMode) def classify_handoff(actuator_entity_id: str) -> HandoffMode: profile = stores.control().profiles.get( actuator_entity_id, ControlProfile(actuator_entity_id=actuator_entity_id), ) return handoff.classify(profile) @app.get("/v2/backup/export", response_model=BackupBundle) def export_backup() -> BackupBundle: return stores.export_backup() @app.post("/v2/backup/restore") def restore_backup(bundle: BackupBundle) -> dict[str, str]: stores.restore_backup(bundle) return {"status": "restored"} def _ha_client_from_env() -> FutureHaClient | None: token = os.getenv("SUPERVISOR_TOKEN") or os.getenv("SILLYHOME_FUTURE_HA_TOKEN") if not token: return None core_url = os.getenv("SILLYHOME_FUTURE_HA_CORE_URL", "http://supervisor/core") websocket_url = os.getenv("SILLYHOME_FUTURE_HA_WS_URL") return FutureHaClient( HaClientConfig(core_url=core_url, token=token, websocket_url=websocket_url) ) def _load_initial_ha_states(client: FutureHaClient) -> None: runtime = stores.runtime() try: for entity in client.read_states(): runtime.entities[entity.entity_id] = entity runtime.websocket_status = "initial_state_loaded" except Exception as exc: runtime.websocket_status = f"initial_state_error: {exc}" stores.save_runtime(runtime) async def _ha_listener_loop(client: FutureHaClient) -> None: routed_triggers: set[str] = set() next_route_refresh = 0.0 pending_unrouted: list[StateEvent] = [] last_unrouted_flush = 0.0 while True: await asyncio.to_thread(_set_websocket_status, "connecting") try: async for event in client.listen_state_events(): loop_time = asyncio.get_running_loop().time() if loop_time >= next_route_refresh: routed_triggers = await asyncio.to_thread(_routed_trigger_ids) next_route_refresh = loop_time + 5 await asyncio.to_thread(_set_websocket_status, "connected") if event.entity_id in routed_triggers: if pending_unrouted: await asyncio.to_thread(_merge_unrouted_events, pending_unrouted) pending_unrouted = [] await asyncio.to_thread( event_core.process_state_event, event, execute=_execute_ha_decision, ) else: pending_unrouted.append(event) if len(pending_unrouted) >= 100 or loop_time - last_unrouted_flush >= 2: await asyncio.to_thread(_merge_unrouted_events, pending_unrouted) pending_unrouted = [] last_unrouted_flush = loop_time await asyncio.sleep(0) except asyncio.CancelledError: raise except Exception as exc: await asyncio.to_thread(_set_websocket_status, f"reconnecting: {exc}") await asyncio.sleep(2) async def _autopilot_loop() -> None: while True: control = stores.control() settings = control.autopilot if settings.enabled: await asyncio.to_thread(_run_autopilot_once) await asyncio.sleep(stores.control().autopilot.interval_seconds) def _run_autopilot_once() -> AutopilotRunResult: control = stores.control() settings = control.autopilot if not settings.enabled: return AutopilotRunResult(candidates=[], trained_models=[], evaluations=[]) learning = stores.learning() learning.candidates = { candidate_id: candidate for candidate_id, candidate in learning.candidates.items() if _is_good_actuator(candidate.actuator_entity_id, None) and _is_good_trigger_id(candidate.trigger_entity_id) } candidates = _generate_candidates(settings) for candidate in candidates: learning.candidates[candidate.candidate_id] = candidate stores.save_learning(learning) updated_settings = settings.model_copy(update={"last_run_at": datetime.now(timezone.utc)}) stores.save_control(control.model_copy(update={"autopilot": updated_settings})) trained = train_models(TrainModelRequest()) if settings.auto_train else [] evaluations = evaluate_models(EvaluateModelRequest()) if settings.auto_evaluate else [] _append_job("autopilot", f"Autopilot light: {len(candidates)} Kandidaten erzeugt.") return AutopilotRunResult(candidates=candidates, trained_models=trained, evaluations=evaluations) def _generate_candidates(settings: AutopilotSettings) -> list[CandidateRecommendation]: runtime = stores.runtime() existing = stores.learning().candidates actuators = [ entity for entity in runtime.entities.values() if _is_good_actuator(entity.entity_id, entity.friendly_name) ][:150] triggers = [ entity for entity in runtime.entities.values() if entity.domain == "binary_sensor" and _is_good_trigger_id(entity.entity_id) ][:500] result: list[CandidateRecommendation] = [] for actuator in actuators: best_trigger: EntityState | None = None best_score = 0.0 best_reason = "" for trigger in triggers: score, reason = _candidate_score(actuator, trigger) if score > best_score: best_score = score best_reason = reason best_trigger = trigger if best_trigger is None or best_score < settings.min_candidate_confidence: continue candidate_id = f"{actuator.entity_id}:{best_trigger.entity_id}:on" if candidate_id in existing: continue result.append( CandidateRecommendation( candidate_id=candidate_id, actuator_entity_id=actuator.entity_id, trigger_entity_id=best_trigger.entity_id, trigger_state="on" if best_trigger.domain == "binary_sensor" else None, target_state="open" if actuator.domain == "cover" else "on", confidence=round(best_score, 2), reason=best_reason, ) ) if len(result) >= 20: break return result def _candidate_score(actuator: EntityState, trigger: EntityState) -> tuple[float, str]: if actuator.area_name and actuator.area_name == trigger.area_name: return 0.82, f"Gleicher Raum: {actuator.area_name}" actuator_tokens = _entity_tokens(actuator) trigger_tokens = _entity_tokens(trigger) overlap = actuator_tokens & trigger_tokens if overlap: return min(0.78, 0.55 + (0.08 * len(overlap))), ( "Aehnliche Namen: " + ", ".join(sorted(overlap)[:4]) ) if trigger.domain == "binary_sensor" and actuator.domain in {"light", "switch"}: return 0.62, "Binary-Sensor passt grundsaetzlich zu Licht/Schalter." return 0.0, "" def _is_good_trigger_id(entity_id: str) -> bool: raw = entity_id.lower() bad = { "battery", "batterie", "low", "update", "problem", "connectivity", "linkquality", "tamper", } good = { "door", "tuer", "ture", "window", "fenster", "motion", "pir", "occupancy", "presence", "kontakt", "contact", } return not any(token in raw for token in bad) and any(token in raw for token in good) def _is_good_actuator(entity_id: str, friendly_name: str | None) -> bool: domain = entity_id.split(".", 1)[0] if domain not in {"light", "switch", "fan", "cover", "humidifier"}: return False if domain != "switch": return True raw = f"{entity_id} {friendly_name or ''}".lower() bad = { "alarm", "battery", "batterie", "detection", "linkquality", "low", "motion", "occupancy", "people", "presence", "problem", "tamper", "trigger", "update", } return not any(token in raw for token in bad) def _entity_tokens(entity: EntityState) -> set[str]: raw = f"{entity.entity_id} {entity.friendly_name or ''}".lower() return {part for part in raw.replace(".", "_").split("_") if len(part) >= 4} def _set_websocket_status(status: str) -> None: runtime = stores.runtime() if runtime.websocket_status == status: return runtime.websocket_status = status stores.save_runtime(runtime) def _routed_trigger_ids() -> set[str]: result: set[str] = set() for profile in stores.learning().profiles.values(): for pattern in profile.patterns: if pattern.trigger_entity_id is not None: result.add(pattern.trigger_entity_id) return result def _merge_unrouted_events(events: list[StateEvent]) -> None: if not events: return runtime = stores.runtime() for event in events: runtime.entities[event.entity_id] = EntityState( entity_id=event.entity_id, domain=event.entity_id.split(".", 1)[0], state=event.new_state, changed_at=event.changed_at, area_name=event.attributes.get("area_name"), device_id=event.attributes.get("device_id"), friendly_name=event.attributes.get("friendly_name"), ) stores.save_runtime(runtime) def _append_audit(kind: str, entity_id: str | None, message: str) -> None: runtime = stores.runtime() event = AuditEvent( event_id=f"{kind}-{len(runtime.audit) + 1}", kind=kind, entity_id=entity_id, message=message, ) runtime.audit = [*runtime.audit, event][-200:] stores.save_runtime(runtime) def _append_job(kind: str, message: str, status: JobStatus = JobStatus.SUCCEEDED) -> JobQueueItem: state = stores.learning() job = JobQueueItem( job_id=f"job-{len(state.jobs) + 1}", kind=kind, status=status, message=message, finished_at=datetime.now(timezone.utc) if status is JobStatus.SUCCEEDED else None, ) state.jobs[job.job_id] = job stores.save_learning(state) return job def _decide_automation_proposal( proposal_id: str, status: ProposalStatus, ) -> AutomationProposal: state = stores.learning() proposal = state.automation_proposals[proposal_id] updated = proposal.model_copy( update={ "status": status, "revision": proposal.revision + 1, "decided_at": datetime.now(timezone.utc), } ) state.automation_proposals[proposal_id] = updated stores.save_learning(state) _append_job("automation_proposal", f"Automation-Vorschlag {proposal_id}: {status}.") return updated def _count_transitions(states: list[str]) -> int: if not states: return 0 transitions = 0 previous = states[0] for state in states[1:]: transitions += int(state != previous) previous = state return transitions def _evaluate_model( model: ModelRecord, learning: LearningState, control: ControlState, ) -> ModelEvaluation: profile = learning.profiles.get( model.actuator_entity_id, LearningProfile(actuator_entity_id=model.actuator_entity_id), ) control_profile = control.profiles.get( model.actuator_entity_id, ControlProfile(actuator_entity_id=model.actuator_entity_id), ) coverage = min(1.0, model.pattern_count / 5) dry_run_events = max(1, control_profile.dry_run_events) dry_run_success_rate = control_profile.dry_run_successes / dry_run_events feedback_total = profile.feedback_positive + profile.feedback_negative feedback_score = ( profile.feedback_positive / feedback_total if feedback_total else 0.5 ) score = round( (model.confidence * 0.35) + (coverage * 0.2) + (dry_run_success_rate * 0.3) + (feedback_score * 0.15), 4, ) reasons = [ f"Modell-Confidence {model.confidence:.0%}", f"Pattern-Abdeckung {coverage:.0%}", f"Dry-run-Erfolg {dry_run_success_rate:.0%}", f"Feedback-Score {feedback_score:.0%}", ] verdict = "bereit" if score >= 0.82 and control_profile.active_ready else "weiter testen" return ModelEvaluation( evaluation_id=f"eval-{model.model_id}-{len(learning.model_evaluations) + 1}", model_id=model.model_id, actuator_entity_id=model.actuator_entity_id, score=score, coverage=coverage, dry_run_success_rate=dry_run_success_rate, feedback_score=feedback_score, verdict=verdict, reasons=reasons, ) def _execute_ha_decision(decision: object) -> bool: if ha_client is None or not hasattr(decision, "actuator_entity_id"): return False actuator_entity_id = str(decision.actuator_entity_id) target_state = getattr(decision, "target_state", None) service = service_for_state(actuator_entity_id.split(".", 1)[0], target_state) if service is None: return False domain, service_name = service ha_client.call_service(domain, service_name, {"entity_id": actuator_entity_id}) return True def _dashboard_html() -> str: return """ SillyHome Future

SillyHome Future

Steuerung

Ready-Status wird geladen...

Lernmuster

Aktuelles Profil

Lade...

Prüfprotokoll

Lade...
"""