Bootstrap SillyHome Future v2 core
This commit is contained in:
2
app/core/__init__.py
Normal file
2
app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core v2 modules."""
|
||||
|
||||
66
app/core/decision.py
Normal file
66
app/core/decision.py
Normal 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
89
app/core/event_core.py
Normal 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
32
app/core/handoff.py
Normal 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
130
app/core/models.py
Normal 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
74
app/core/stores.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user