7 Commits

Author SHA1 Message Date
07e3e96c30 Add Future parity feature blocks 2026-06-18 16:20:30 +02:00
e4b860571a Enable watchdog and localize dashboard 2026-06-18 14:26:26 +02:00
3c6fe24b03 Add production safety controls 2026-06-18 13:24:17 +02:00
01e5464ec1 Add dashboard control and learning editor 2026-06-18 13:08:58 +02:00
937c79a19b Batch unrouted HA state events 2026-06-18 13:00:57 +02:00
149f11a18e Keep API responsive under HA events 2026-06-18 12:57:01 +02:00
38a85fce74 Pin addon image to release archive 2026-06-18 12:49:35 +02:00
11 changed files with 1217 additions and 39 deletions

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app
ARG SILLYHOME_FUTURE_REF=main
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.11
RUN python -m pip install --no-cache-dir \
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"

View File

@@ -1,12 +1,12 @@
name: SillyHome Future
version: "2.0.0-alpha.4"
version: "2.0.0-alpha.11"
slug: sillyhome_future
description: Event-first SillyHome v2 test controller
url: http://192.168.6.31:3000/Otto/sillyhome-future
arch:
- amd64
startup: application
boot: manual
boot: auto
watchdog: http://[HOST]:[PORT:8099]/health
init: false
ingress: true

View File

@@ -27,6 +27,8 @@ class DecisionEngineV2:
blockers.append(f"Domain {domain} ist nicht fuer Active-Control freigegeben.")
if control.manual_block:
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
if control.stage is SafetyStage.ACTIVE and not control.active_ready:
blockers.append("Active ist noch nicht freigegeben. Erst Dry-run-Reife erreichen.")
best = None
for pattern in learning.patterns:
if pattern.trigger_entity_id != trigger_entity_id:
@@ -63,4 +65,3 @@ class DecisionEngineV2:
blockers=blockers,
trigger_entity_id=trigger_entity_id,
)

View File

@@ -76,6 +76,17 @@ class EventCore:
learning=learning_profile,
control=control_profile,
)
if not control.global_enabled and decision.allowed:
decision = decision.model_copy(
update={
"allowed": False,
"reason": "Globaler Not-Aus ist aktiv.",
"blockers": [*decision.blockers, "Globaler Not-Aus ist aktiv."],
}
)
if decision.dry_run:
control_profile = _record_dry_run(control_profile, decision)
control.profiles[actuator_id] = control_profile
if callable(execute) and decision.allowed and not decision.dry_run:
decision = _execute_decision(decision, execute)
audit.append(
@@ -110,3 +121,25 @@ def _execute_decision(decision: Decision, execute: Callable[[Decision], bool]) -
}
)
return decision.model_copy(update={"executed": bool(result)})
def _record_dry_run(profile: ControlProfile, decision: Decision) -> ControlProfile:
events = profile.dry_run_events + 1
successes = profile.dry_run_successes + int(decision.allowed and not decision.blockers)
failures = profile.dry_run_failures + int(bool(decision.blockers))
success_rate = successes / events if events else 0.0
ready = events >= 5 and success_rate >= 0.8 and failures <= 1
reason = (
f"Dry-run {successes}/{events} erfolgreich."
if ready
else f"Dry-run braucht mindestens 5 Events und 80% Treffer; aktuell {successes}/{events}."
)
return profile.model_copy(
update={
"dry_run_events": events,
"dry_run_successes": successes,
"dry_run_failures": failures,
"active_ready": ready,
"active_readiness_reason": reason,
}
)

View File

@@ -21,6 +21,19 @@ class SafetyStage(StrEnum):
BLOCKED = "blocked"
class ProposalStatus(StrEnum):
DRAFT = "draft"
APPROVED = "approved"
REJECTED = "rejected"
class JobStatus(StrEnum):
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
class EntityState(BaseModel):
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
domain: str
@@ -67,6 +80,11 @@ class ControlProfile(BaseModel):
handoff_mode: HandoffMode = HandoffMode.SHADOW
related_automation_ids: list[str] = Field(default_factory=list)
paused_automation_ids: list[str] = Field(default_factory=list)
dry_run_events: int = Field(default=0, ge=0)
dry_run_successes: int = Field(default=0, ge=0)
dry_run_failures: int = Field(default=0, ge=0)
active_ready: bool = False
active_readiness_reason: str = "Noch nicht bewertet."
class RoomProfile(BaseModel):
@@ -84,6 +102,51 @@ class SceneProfile(BaseModel):
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class AutomationProposal(BaseModel):
proposal_id: str
name: str
trigger_entity_id: str
trigger_state: str | None = None
actuator_entity_id: str
target_state: str
status: ProposalStatus = ProposalStatus.DRAFT
revision: int = 1
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
decided_at: datetime | None = None
class ModelRecord(BaseModel):
model_id: str
actuator_entity_id: str
pattern_count: int
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
trained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class JobQueueItem(BaseModel):
job_id: str
kind: str
status: JobStatus = JobStatus.QUEUED
message: str = ""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: datetime | None = None
class SensorWeightOverride(BaseModel):
actuator_entity_id: str
sensor_weights: dict[str, float] = Field(default_factory=dict)
note: str | None = Field(default=None, max_length=500)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class HistoryAnalysis(BaseModel):
entity_id: str
samples: int
last_state: str | None = None
changed_at: datetime | None = None
recommendation: str
class Decision(BaseModel):
actuator_entity_id: str
target_state: str | None = None
@@ -116,10 +179,15 @@ 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)
automation_proposals: dict[str, AutomationProposal] = Field(default_factory=dict)
models: dict[str, ModelRecord] = Field(default_factory=dict)
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
class ControlState(BaseModel):
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
global_enabled: bool = True
class BackupBundle(BaseModel):
@@ -127,4 +195,3 @@ class BackupBundle(BaseModel):
runtime: RuntimeState
learning: LearningState
control: ControlState

View File

@@ -4,7 +4,7 @@ import json
import os
from pathlib import Path
from threading import RLock
from typing import TypeVar
from typing import cast, TypeVar
from pydantic import BaseModel
@@ -18,13 +18,20 @@ class JsonDocumentStore:
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._cache: dict[str, BaseModel] = {}
def load(self, name: str, model: type[T], default: T) -> T:
path = self.root / name
with self._lock:
cached = self._cache.get(name)
if cached is not None:
return cast(T, cached)
if not path.exists():
self._cache[name] = default
return default
return model.model_validate_json(path.read_text(encoding="utf-8"))
value = model.model_validate_json(path.read_text(encoding="utf-8"))
self._cache[name] = value
return value
def save(self, name: str, value: T) -> T:
path = self.root / name
@@ -35,6 +42,7 @@ class JsonDocumentStore:
encoding="utf-8",
)
os.replace(temporary, path)
self._cache[name] = value
return value
@@ -71,4 +79,3 @@ class FutureStores:
self.save_runtime(bundle.runtime)
self.save_learning(bundle.learning)
self.save_control(bundle.control)

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-future"
version = "2.0.0-alpha.4"
version = "2.0.0-alpha.11"
description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11"
dependencies = [

View File

@@ -9,10 +9,12 @@ def test_addon_config_declares_future_addon() -> None:
config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
assert config["slug"] == "sillyhome_future"
assert config["version"] == "2.0.0-alpha.4"
assert config["version"] == "2.0.0-alpha.11"
assert config["ingress"] is True
assert config["ingress_port"] == 8099
assert config["homeassistant_api"] is True
assert config["boot"] == "auto"
assert config["watchdog"] == "http://[HOST]:[PORT:8099]/health"
def test_repository_points_to_gitea_repo() -> None:

View File

@@ -2,20 +2,147 @@ from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app, stores
import app.main as main
from app.core.event_core import EventCore
from app.core.models import EntityState, RuntimeState
from app.core.stores import FutureStores
def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("SILLYHOME_FUTURE_STORE", str(tmp_path))
client = TestClient(app)
test_stores = FutureStores(tmp_path)
monkeypatch.setattr(main, "stores", test_stores)
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
client = TestClient(main.app)
health = client.get("/health")
backup = client.get("/v2/backup/export")
restore = client.post("/v2/backup/restore", json=backup.json())
assert stores is not None
assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.4"
assert health.json()["version"] == "2.0.0-alpha.11"
assert backup.status_code == 200
assert restore.status_code == 200
assert restore.json() == {"status": "restored"}
def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
test_stores = FutureStores(tmp_path)
test_stores.save_runtime(
RuntimeState(
entities={
"light.storage": EntityState(
entity_id="light.storage",
domain="light",
state="off",
),
"binary_sensor.storage_door": EntityState(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="off",
),
}
)
)
monkeypatch.setattr(main, "stores", test_stores)
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
client = TestClient(main.app)
entities = client.get("/v2/entities?domain=light,binary_sensor")
control = client.post(
"/v2/control/light.storage/stage",
json={
"stage": "dry_run",
"min_confidence": 0.75,
"manual_block": False,
"cooldown_seconds": 120,
},
)
learning = client.post(
"/v2/learning/light.storage/patterns",
json={
"trigger_entity_id": "binary_sensor.storage_door",
"trigger_state": "on",
"target_state": "on",
"confidence": 0.91,
},
)
simulation = client.post(
"/v2/simulate",
json={
"actuator_entity_id": "light.storage",
"trigger_entity_id": "binary_sensor.storage_door",
"trigger_state": "on",
},
)
readiness = client.get("/v2/control/light.storage/readiness")
global_control = client.post("/v2/control/global", json={"enabled": False})
detailed_health = client.get("/v2/health")
feedback = client.post("/v2/control/light.storage/feedback", json={"kind": "wrong"})
summary = client.get("/v2/actuators/summary")
parity = client.get("/v2/feature-parity")
anomalies = client.get("/v2/anomalies")
proposal = client.post(
"/v2/automations/proposals",
json={
"name": "Storage light",
"trigger_entity_id": "binary_sensor.storage_door",
"trigger_state": "on",
"actuator_entity_id": "light.storage",
"target_state": "on",
},
)
proposal_yaml = client.get("/v2/automations/proposals/proposal-1/yaml")
approved = client.post("/v2/automations/proposals/proposal-1/approve")
weights = client.post(
"/v2/control/light.storage/weights",
json={"sensor_weights": {"binary_sensor.storage_door": 1.0}, "note": "door"},
)
history = client.post(
"/v2/history/analyze",
json={"entity_ids": ["binary_sensor.storage_door"]},
)
models = client.post("/v2/models/train", json={"actuator_entity_id": "light.storage"})
jobs = client.get("/v2/jobs")
dashboard = client.get("/v2/dashboard")
assert entities.status_code == 200
assert [item["entity_id"] for item in entities.json()] == [
"binary_sensor.storage_door",
"light.storage",
]
assert control.status_code == 200
assert control.json()["stage"] == "dry_run"
assert learning.status_code == 200
assert learning.json()["patterns"][0]["trigger_entity_id"] == "binary_sensor.storage_door"
assert simulation.status_code == 200
assert simulation.json()["decision"]["target_state"] == "on"
assert readiness.status_code == 200
assert readiness.json()["ready"] is False
assert global_control.status_code == 200
assert global_control.json()["global_enabled"] is False
assert detailed_health.status_code == 200
assert detailed_health.json()["global_enabled"] is False
assert feedback.status_code == 200
assert feedback.json()["feedback_negative"] == 1
assert summary.status_code == 200
assert summary.json()[0]["actuator_entity_id"] == "light.storage"
assert parity.status_code == 200
assert "Job-Queue" in parity.json()["implemented"]
assert anomalies.status_code == 200
assert proposal.status_code == 200
assert proposal.json()["status"] == "draft"
assert proposal_yaml.status_code == 200
assert "alias: Storage light" in proposal_yaml.text
assert approved.status_code == 200
assert approved.json()["status"] == "approved"
assert weights.status_code == 200
assert weights.json()["sensor_weights"]["binary_sensor.storage_door"] == 1.0
assert history.status_code == 200
assert history.json()[0]["entity_id"] == "binary_sensor.storage_door"
assert models.status_code == 200
assert models.json()[0]["actuator_entity_id"] == "light.storage"
assert jobs.status_code == 200
assert jobs.json()
assert dashboard.status_code == 200
assert dashboard.json()["actuator_count"] == 1
assert dashboard.json()["automation_proposals"] == 1

View File

@@ -134,6 +134,7 @@ def test_event_core_executes_allowed_active_decision(tmp_path: Path) -> None:
actuator_entity_id="light.storage",
stage=SafetyStage.ACTIVE,
min_confidence=0.8,
active_ready=True,
)
}
}
@@ -153,3 +154,58 @@ def test_event_core_executes_allowed_active_decision(tmp_path: Path) -> None:
decision = [item.decision for item in audit if item.decision is not None][0]
assert calls == ["light.storage"]
assert decision.executed is True
def test_active_requires_dry_run_readiness(tmp_path: Path) -> None:
stores = FutureStores(tmp_path)
stores.save_runtime(
RuntimeState(
entities={
"light.storage": EntityState(
entity_id="light.storage",
domain="light",
state="off",
)
}
)
)
stores.save_learning(
LearningState(
profiles={
"light.storage": LearningProfile(
actuator_entity_id="light.storage",
patterns=[
BehaviorPatternV2(
actuator_entity_id="light.storage",
target_state="on",
trigger_entity_id="binary_sensor.storage_door",
trigger_state="on",
support=3,
confidence=0.95,
)
],
)
}
)
)
stores.save_control(
stores.control().model_copy(
update={
"profiles": {
"light.storage": ControlProfile(
actuator_entity_id="light.storage",
stage=SafetyStage.ACTIVE,
min_confidence=0.8,
)
}
}
)
)
audit = EventCore(stores).process_state_event(
StateEvent(entity_id="binary_sensor.storage_door", new_state="on")
)
decision = [item.decision for item in audit if item.decision is not None][0]
assert decision.allowed is False
assert "Active ist noch nicht freigegeben" in decision.blockers[0]