Bootstrap SillyHome Future v2 core

This commit is contained in:
2026-06-18 12:16:54 +02:00
commit 549f87523a
15 changed files with 729 additions and 0 deletions

2
app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""SillyHome Future package."""

2
app/api/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""API modules."""

2
app/core/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Core v2 modules."""

66
app/core/decision.py Normal file
View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from app.core.models import ControlProfile, Decision, LearningProfile, RuntimeState, SafetyStage
SAFE_DOMAINS = frozenset({"light", "switch", "fan", "cover", "humidifier"})
class DecisionEngineV2:
def decide(
self,
*,
actuator_entity_id: str,
trigger_entity_id: str,
runtime: RuntimeState,
learning: LearningProfile,
control: ControlProfile,
) -> Decision:
blockers: list[str] = []
actuator = runtime.entities.get(actuator_entity_id)
trigger = runtime.entities.get(trigger_entity_id)
if actuator is None:
blockers.append("Aktor ist im Runtime-State nicht bekannt.")
if trigger is None:
blockers.append("Trigger ist im Runtime-State nicht bekannt.")
domain = actuator_entity_id.split(".", 1)[0]
if domain not in SAFE_DOMAINS:
blockers.append(f"Domain {domain} ist nicht fuer Active-Control freigegeben.")
if control.manual_block:
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
best = None
for pattern in learning.patterns:
if pattern.trigger_entity_id != trigger_entity_id:
continue
if trigger is not None and pattern.trigger_state != trigger.state:
continue
if best is None or pattern.confidence > best.confidence:
best = pattern
if best is None:
blockers.append("Kein passendes kausales Muster gefunden.")
return Decision(
actuator_entity_id=actuator_entity_id,
allowed=False,
reason="Keine Aktion faellig.",
blockers=blockers,
trigger_entity_id=trigger_entity_id,
)
if best.confidence < control.min_confidence:
blockers.append(
f"Confidence {best.confidence:.0%} liegt unter {control.min_confidence:.0%}."
)
if actuator is not None and actuator.state == best.target_state:
blockers.append("Zielzustand ist bereits erreicht.")
dry_run = control.stage is SafetyStage.DRY_RUN
allowed = not blockers and control.stage in {SafetyStage.ACTIVE, SafetyStage.DRY_RUN}
return Decision(
actuator_entity_id=actuator_entity_id,
target_state=best.target_state,
confidence=best.confidence,
allowed=allowed,
executed=False,
dry_run=dry_run,
reason="Ausfuehrung freigegeben." if allowed and not dry_run else "Dry-run oder blockiert.",
blockers=blockers,
trigger_entity_id=trigger_entity_id,
)

89
app/core/event_core.py Normal file
View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from datetime import datetime, timezone
from app.core.decision import DecisionEngineV2
from app.core.handoff import HandoffMatrix
from app.core.models import (
AuditEvent,
ControlProfile,
EntityState,
LearningProfile,
StateEvent,
)
from app.core.stores import FutureStores
class RoutingIndex:
def affected_actuators(self, trigger_entity_id: str, learning: dict[str, LearningProfile]) -> list[str]:
result = [
actuator_id
for actuator_id, profile in learning.items()
if any(pattern.trigger_entity_id == trigger_entity_id for pattern in profile.patterns)
]
return sorted(set(result))
class EventCore:
def __init__(self, stores: FutureStores) -> None:
self._stores = stores
self._router = RoutingIndex()
self._decision = DecisionEngineV2()
self._handoff = HandoffMatrix()
def process_state_event(self, event: StateEvent) -> list[AuditEvent]:
runtime = self._stores.runtime()
learning = self._stores.learning()
control = self._stores.control()
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"),
)
audit: list[AuditEvent] = [
AuditEvent(
event_id=_event_id("state"),
kind="state",
entity_id=event.entity_id,
message=f"{event.entity_id} -> {event.new_state}",
)
]
for actuator_id in self._router.affected_actuators(event.entity_id, learning.profiles):
learning_profile = learning.profiles[actuator_id]
control_profile = control.profiles.get(
actuator_id,
ControlProfile(actuator_entity_id=actuator_id),
)
control_profile = control_profile.model_copy(
update={"handoff_mode": self._handoff.classify(control_profile)}
)
control.profiles[actuator_id] = control_profile
decision = self._decision.decide(
actuator_entity_id=actuator_id,
trigger_entity_id=event.entity_id,
runtime=runtime,
learning=learning_profile,
control=control_profile,
)
audit.append(
AuditEvent(
event_id=_event_id("decision"),
kind="decision",
entity_id=actuator_id,
message=decision.reason,
decision=decision,
)
)
runtime.audit = [*runtime.audit, *audit][-200:]
self._stores.save_runtime(runtime)
self._stores.save_control(control)
return audit
def _event_id(prefix: str) -> str:
return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}"

32
app/core/handoff.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from app.core.models import ControlProfile, HandoffMode
class HandoffMatrix:
def classify(self, profile: ControlProfile) -> HandoffMode:
active_related = set(profile.related_automation_ids) - set(profile.paused_automation_ids)
if profile.handoff_mode is HandoffMode.CONTROLLED and active_related:
return HandoffMode.CONFLICT
if profile.paused_automation_ids:
return HandoffMode.CONTROLLED
if profile.related_automation_ids:
return HandoffMode.CANDIDATE
return profile.handoff_mode
def assume_control(self, profile: ControlProfile) -> ControlProfile:
return profile.model_copy(
update={
"handoff_mode": HandoffMode.CONTROLLED,
"paused_automation_ids": list(profile.related_automation_ids),
}
)
def rollback(self, profile: ControlProfile) -> ControlProfile:
return profile.model_copy(
update={
"handoff_mode": HandoffMode.ROLLBACK,
"paused_automation_ids": [],
}
)

130
app/core/models.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import StrEnum
from pydantic import BaseModel, Field
class HandoffMode(StrEnum):
SHADOW = "shadow"
CANDIDATE = "candidate"
CONTROLLED = "controlled"
CONFLICT = "conflict"
ROLLBACK = "rollback"
class SafetyStage(StrEnum):
OBSERVE = "observe"
DRY_RUN = "dry_run"
ACTIVE = "active"
BLOCKED = "blocked"
class EntityState(BaseModel):
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
domain: str
state: str | None = None
changed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
area_name: str | None = None
device_id: str | None = None
friendly_name: str | None = None
class StateEvent(BaseModel):
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
new_state: str | None = None
changed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
attributes: dict[str, str] = Field(default_factory=dict)
class BehaviorPatternV2(BaseModel):
actuator_entity_id: str
target_state: str
trigger_entity_id: str | None = None
trigger_state: str | None = None
context: dict[str, str] = Field(default_factory=dict)
support: int = Field(default=1, ge=1)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
source: str = Field(default="observed", max_length=40)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class LearningProfile(BaseModel):
actuator_entity_id: str
patterns: list[BehaviorPatternV2] = Field(default_factory=list)
feedback_positive: int = Field(default=0, ge=0)
feedback_negative: int = Field(default=0, ge=0)
model_version: str = "empty"
class ControlProfile(BaseModel):
actuator_entity_id: str
stage: SafetyStage = SafetyStage.OBSERVE
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)
handoff_mode: HandoffMode = HandoffMode.SHADOW
related_automation_ids: list[str] = Field(default_factory=list)
paused_automation_ids: list[str] = Field(default_factory=list)
class RoomProfile(BaseModel):
room_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str
actuator_entity_ids: list[str] = Field(default_factory=list)
context_entity_ids: list[str] = Field(default_factory=list)
class SceneProfile(BaseModel):
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str
actuator_entity_ids: list[str] = Field(default_factory=list)
trigger_entity_id: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class Decision(BaseModel):
actuator_entity_id: str
target_state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
allowed: bool = False
executed: bool = False
dry_run: bool = False
reason: str
blockers: list[str] = Field(default_factory=list)
trigger_entity_id: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class AuditEvent(BaseModel):
event_id: str
kind: str = Field(max_length=40)
entity_id: str | None = None
message: str = Field(max_length=700)
decision: Decision | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class RuntimeState(BaseModel):
entities: dict[str, EntityState] = Field(default_factory=dict)
audit: list[AuditEvent] = Field(default_factory=list)
websocket_status: str = "unavailable"
class LearningState(BaseModel):
profiles: dict[str, LearningProfile] = Field(default_factory=dict)
rooms: dict[str, RoomProfile] = Field(default_factory=dict)
scenes: dict[str, SceneProfile] = Field(default_factory=dict)
class ControlState(BaseModel):
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
class BackupBundle(BaseModel):
exported_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
runtime: RuntimeState
learning: LearningState
control: ControlState

74
app/core/stores.py Normal file
View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from threading import RLock
from typing import TypeVar
from pydantic import BaseModel
from app.core.models import BackupBundle, ControlState, LearningState, RuntimeState
T = TypeVar("T", bound=BaseModel)
class JsonDocumentStore:
def __init__(self, root: str | Path) -> None:
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
def load(self, name: str, model: type[T], default: T) -> T:
path = self.root / name
with self._lock:
if not path.exists():
return default
return model.model_validate_json(path.read_text(encoding="utf-8"))
def save(self, name: str, value: T) -> T:
path = self.root / name
temporary = path.with_suffix(path.suffix + ".tmp")
with self._lock:
temporary.write_text(
json.dumps(value.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
return value
class FutureStores:
def __init__(self, root: str | Path = ".future_store") -> None:
self._docs = JsonDocumentStore(root)
def runtime(self) -> RuntimeState:
return self._docs.load("runtime.json", RuntimeState, RuntimeState())
def save_runtime(self, state: RuntimeState) -> RuntimeState:
return self._docs.save("runtime.json", state)
def learning(self) -> LearningState:
return self._docs.load("learning.json", LearningState, LearningState())
def save_learning(self, state: LearningState) -> LearningState:
return self._docs.save("learning.json", state)
def control(self) -> ControlState:
return self._docs.load("control.json", ControlState, ControlState())
def save_control(self, state: ControlState) -> ControlState:
return self._docs.save("control.json", state)
def export_backup(self) -> BackupBundle:
return BackupBundle(
runtime=self.runtime(),
learning=self.learning(),
control=self.control(),
)
def restore_backup(self, bundle: BackupBundle) -> None:
self.save_runtime(bundle.runtime)
self.save_learning(bundle.learning)
self.save_control(bundle.control)

113
app/main.py Normal file
View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import os
from fastapi import FastAPI
from app.core.event_core import EventCore
from app.core.handoff import HandoffMatrix
from app.core.models import (
AuditEvent,
BackupBundle,
ControlProfile,
ControlState,
HandoffMode,
LearningState,
RuntimeState,
StateEvent,
)
from app.core.stores import FutureStores
app = FastAPI(
title="SillyHome Future API",
description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.1",
)
stores = FutureStores(os.getenv("SILLYHOME_FUTURE_STORE", ".future_store"))
event_core = EventCore(stores)
handoff = HandoffMatrix()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "version": app.version}
@app.post("/v2/events/state", response_model=list[AuditEvent])
def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
return event_core.process_state_event(event)
@app.get("/v2/runtime", response_model=RuntimeState)
def get_runtime() -> RuntimeState:
return stores.runtime()
@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.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/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"}