Files
sillyhome-future/app/main.py
2026-06-18 17:27:25 +02:00

1438 lines
52 KiB
Python

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.13",
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 entity.domain in {"light", "switch", "fan", "cover", "humidifier"}
]
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()
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 entity.domain in {"light", "switch", "fan", "cover", "humidifier"}
][:150]
triggers = [
entity
for entity in runtime.entities.values()
if entity.domain in {"binary_sensor", "sensor"}
][: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 _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 """<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SillyHome Future</title>
<style>
:root { color-scheme: dark; --bg: #101418; --panel: #171d23; --line: #2c3640; --text: #eef3f6; --muted: #9fb0bd; --accent: #42d392; --warn: #f3c969; }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--text); }
main { max-width: 1180px; margin: 0 auto; padding: 20px; }
h1 { font-size: 28px; margin: 0 0 18px; }
h2 { font-size: 18px; margin: 24px 0 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
.split { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.2fr); gap: 14px; align-items: start; }
.card { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: var(--panel); }
.label { color: #9fb0bd; font-size: 13px; }
.value { font-size: 24px; margin-top: 4px; }
label { display: block; color: var(--muted); font-size: 13px; margin: 10px 0 5px; }
input, select, button { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #0c1014; color: var(--text); font: inherit; padding: 10px; }
button { cursor: pointer; background: #20303a; }
button:hover { border-color: var(--accent); }
.row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.stages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 8px; }
.stages button.active { border-color: var(--accent); color: var(--accent); }
.primary { background: #17402c; border-color: #256f4a; }
.status { min-height: 24px; color: var(--warn); margin-top: 10px; }
pre { max-height: 360px; overflow: auto; white-space: pre-wrap; word-break: break-word; background: #0c1014; padding: 14px; border-radius: 8px; }
@media (max-width: 800px) { .split, .row, .stages { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<h1>SillyHome Future</h1>
<section class="grid" id="metrics"></section>
<section class="split">
<div class="card">
<h2>Steuerung</h2>
<label for="entitySearch">Suche</label>
<input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off">
<label for="actuator">Aktor</label>
<select id="actuator"></select>
<div class="row">
<div>
<label for="minConfidence">Mindest-Sicherheit</label>
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
</div>
<div>
<label for="cooldown">Sperrzeit in Sekunden</label>
<input id="cooldown" type="number" min="0" step="30" value="900">
</div>
</div>
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
<div class="stages" id="stages"></div>
<button class="primary" id="saveControl">Steuerung speichern</button>
<button id="globalToggle">Globaler Not-Aus</button>
<div class="status" id="readiness">Ready-Status wird geladen...</div>
<div class="status" id="controlStatus"></div>
</div>
<div class="card">
<h2>Lernmuster</h2>
<div class="row">
<div>
<label for="trigger">Trigger</label>
<select id="trigger"></select>
</div>
<div>
<label for="triggerState">Trigger-Zustand</label>
<input id="triggerState" placeholder="on, off, open...">
</div>
</div>
<div class="row">
<div>
<label for="targetState">Zielzustand</label>
<input id="targetState" value="on">
</div>
<div>
<label for="patternConfidence">Sicherheit</label>
<input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9">
</div>
</div>
<button class="primary" id="addPattern">Lernmuster anlegen</button>
<button id="simulatePattern">Lernmuster simulieren</button>
<div class="status" id="patternStatus"></div>
<h2>Aktuelles Profil</h2>
<pre id="profile">Lade...</pre>
</div>
</section>
<h2>Prüfprotokoll</h2>
<pre id="audit">Lade...</pre>
</main>
<script>
const stages = ['observe', 'dry_run', 'active', 'blocked'];
const stageLabels = {
observe: 'Beobachten',
dry_run: 'Testlauf',
active: 'Aktiv',
blocked: 'Gesperrt',
};
const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' };
function optionText(entity) {
const name = entity.friendly_name ? ` - ${entity.friendly_name}` : '';
const current = entity.state == null ? '' : ` (${entity.state})`;
return `${entity.entity_id}${current}${name}`;
}
function setStatus(id, text) {
document.getElementById(id).textContent = text;
if (text) setTimeout(() => document.getElementById(id).textContent = '', 4000);
}
function renderStages() {
document.getElementById('stages').innerHTML = stages.map(stage =>
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stageLabels[stage]}</button>`
).join('');
document.querySelectorAll('[data-stage]').forEach(button => {
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
});
}
function renderEntities() {
const actuator = document.getElementById('actuator');
const trigger = document.getElementById('trigger');
const query = document.getElementById('entitySearch').value.toLowerCase();
const shown = state.entities.filter(entity =>
!query || optionText(entity).toLowerCase().includes(query)
);
const actuators = shown.filter(entity => ['light', 'switch', 'fan', 'cover', 'humidifier'].includes(entity.domain));
actuator.innerHTML = actuators.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
trigger.innerHTML = shown.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
renderProfile();
}
function renderProfile() {
const id = document.getElementById('actuator').value;
const profile = {
control: state.control.profiles?.[id] || null,
learning: state.learning.profiles?.[id] || null,
};
if (profile.control) {
state.selectedStage = profile.control.stage;
document.getElementById('minConfidence').value = profile.control.min_confidence;
document.getElementById('cooldown').value = profile.control.cooldown_seconds;
document.getElementById('manualBlock').checked = profile.control.manual_block;
renderStages();
}
document.getElementById('profile').textContent = JSON.stringify(profile, null, 2);
}
async function loadDashboard() {
const [dashResponse, entitiesResponse, controlResponse, learningResponse] = await Promise.all([
fetch('/v2/dashboard'),
fetch('/v2/entities?domain=light,switch,fan,cover,humidifier,binary_sensor,sensor'),
fetch('/v2/control'),
fetch('/v2/learning'),
]);
const data = await dashResponse.json();
state.entities = await entitiesResponse.json();
state.control = await controlResponse.json();
state.learning = await learningResponse.json();
const metrics = [
['WebSocket', data.websocket_status],
['Entitäten', data.entity_count],
['Aktoren', data.actuator_count],
['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
['Lernen', data.learning_profiles],
['Steuerung', data.control_profiles],
['Vorschlaege', data.automation_proposals],
['Modelle', data.models],
['Jobs', data.jobs],
['Kandidaten', data.candidates],
['Autopilot', data.autopilot_enabled ? 'aktiv' : 'aus'],
['Räume', data.rooms.length],
['Szenen', data.scenes.length],
];
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
).join('');
renderEntities();
await loadReadiness();
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2);
}
async function loadReadiness() {
const id = document.getElementById('actuator').value;
if (!id) return;
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
const data = await response.json();
document.getElementById('readiness').textContent =
`${data.ready ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
}
document.getElementById('entitySearch').oninput = renderEntities;
document.getElementById('actuator').onchange = () => { renderProfile(); loadReadiness(); };
document.getElementById('saveControl').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stage: state.selectedStage,
min_confidence: Number(document.getElementById('minConfidence').value),
manual_block: document.getElementById('manualBlock').checked,
cooldown_seconds: Number(document.getElementById('cooldown').value),
}),
});
if (!response.ok) throw new Error(await response.text());
setStatus('controlStatus', 'Gespeichert');
await loadDashboard();
await loadReadiness();
};
document.getElementById('addPattern').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch(`/v2/learning/${encodeURIComponent(id)}/patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trigger_entity_id: document.getElementById('trigger').value,
trigger_state: document.getElementById('triggerState').value || null,
target_state: document.getElementById('targetState').value,
confidence: Number(document.getElementById('patternConfidence').value),
}),
});
if (!response.ok) throw new Error(await response.text());
setStatus('patternStatus', 'Pattern angelegt');
await loadDashboard();
};
document.getElementById('simulatePattern').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch('/v2/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actuator_entity_id: id,
trigger_entity_id: document.getElementById('trigger').value,
trigger_state: document.getElementById('triggerState').value || null,
}),
});
document.getElementById('profile').textContent = JSON.stringify(await response.json(), null, 2);
};
document.getElementById('globalToggle').onclick = async () => {
const dash = await (await fetch('/v2/dashboard')).json();
await fetch('/v2/control/global', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !dash.global_enabled }),
});
await loadDashboard();
};
renderStages();
loadDashboard();
setInterval(loadDashboard, 5000);
</script>
</body>
</html>"""