From 3c6fe24b03d781ed57183e488ee838a81e3b7fc8 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 18 Jun 2026 13:24:17 +0200 Subject: [PATCH] Add production safety controls --- addon/Dockerfile | 2 +- addon/config.yaml | 2 +- app/core/decision.py | 3 +- app/core/event_core.py | 33 +++++++ app/core/models.py | 7 +- app/core/stores.py | 13 ++- app/main.py | 188 ++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/test_addon_config.py | 2 +- tests/test_future_api.py | 21 ++++- tests/test_future_core.py | 56 +++++++++++ 11 files changed, 316 insertions(+), 13 deletions(-) diff --git a/addon/Dockerfile b/addon/Dockerfile index 18a1a7a..76a1106 100644 --- a/addon/Dockerfile +++ b/addon/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim WORKDIR /app -ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.8 +ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.9 RUN python -m pip install --no-cache-dir \ "http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz" diff --git a/addon/config.yaml b/addon/config.yaml index fa3ae02..ff7bd19 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Future -version: "2.0.0-alpha.8" +version: "2.0.0-alpha.9" slug: sillyhome_future description: Event-first SillyHome v2 test controller url: http://192.168.6.31:3000/Otto/sillyhome-future diff --git a/app/core/decision.py b/app/core/decision.py index 6955f4e..4378a82 100644 --- a/app/core/decision.py +++ b/app/core/decision.py @@ -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, ) - diff --git a/app/core/event_core.py b/app/core/event_core.py index 324c767..193b483 100644 --- a/app/core/event_core.py +++ b/app/core/event_core.py @@ -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, + } + ) diff --git a/app/core/models.py b/app/core/models.py index 433316a..c5ab32f 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -67,6 +67,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): @@ -120,6 +125,7 @@ class LearningState(BaseModel): class ControlState(BaseModel): profiles: dict[str, ControlProfile] = Field(default_factory=dict) + global_enabled: bool = True class BackupBundle(BaseModel): @@ -127,4 +133,3 @@ class BackupBundle(BaseModel): runtime: RuntimeState learning: LearningState control: ControlState - diff --git a/app/core/stores.py b/app/core/stores.py index 3958489..b5b741a 100644 --- a/app/core/stores.py +++ b/app/core/stores.py @@ -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) - diff --git a/app/main.py b/app/main.py index 2bcb290..a348244 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ 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 @@ -50,6 +51,25 @@ class PatternCreateRequest(BaseModel): 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 + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: global ha_client @@ -70,7 +90,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Future API", description="SillyHome v2 event-core side project.", - version="2.0.0-alpha.8", + version="2.0.0-alpha.9", lifespan=lifespan, ) @@ -85,6 +105,21 @@ def health() -> dict[str, str]: } +@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() @@ -105,6 +140,7 @@ def dashboard_data() -> dict[str, object]: "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), "rooms": list(learning.rooms.values()), @@ -140,6 +176,18 @@ def list_entities(domain: str | None = None, q: str | None = None) -> list[Entit 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() @@ -155,6 +203,18 @@ def get_control() -> ControlState: return stores.control() +@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() @@ -173,9 +233,12 @@ def update_control_stage( 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": update.stage, + "stage": requested_stage, "min_confidence": update.min_confidence, "manual_block": update.manual_block, "cooldown_seconds": update.cooldown_seconds, @@ -184,9 +247,32 @@ def update_control_stage( ) 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, @@ -219,6 +305,53 @@ def create_learning_pattern( return updated +@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() @@ -358,6 +491,18 @@ def _merge_unrouted_events(events: list[StateEvent]) -> None: 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 _execute_ha_decision(decision: object) -> bool: if ha_client is None or not hasattr(decision, "actuator_entity_id"): return False @@ -427,6 +572,8 @@ def _dashboard_html() -> str:
+ +
Ready-Status wird geladen...
@@ -452,6 +599,7 @@ def _dashboard_html() -> str:
+

Aktuelles Profil

Lade...
@@ -528,6 +676,7 @@ def _dashboard_html() -> str: ['WebSocket', data.websocket_status], ['Entities', data.entity_count], ['Actuators', data.actuator_count], + ['Global', data.global_enabled ? 'on' : 'off'], ['Learning', data.learning_profiles], ['Control', data.control_profiles], ['Rooms', data.rooms.length], @@ -537,11 +686,21 @@ def _dashboard_html() -> str: `
${label}
${value}
` ).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 ? 'ready for active' : 'nicht active-ready'} - ${data.reason}`; + } + document.getElementById('entitySearch').oninput = renderEntities; - document.getElementById('actuator').onchange = renderProfile; + 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`, { @@ -557,6 +716,7 @@ def _dashboard_html() -> str: 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; @@ -574,6 +734,28 @@ def _dashboard_html() -> str: 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); diff --git a/pyproject.toml b/pyproject.toml index 1b5bb77..1208749 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-future" -version = "2.0.0-alpha.8" +version = "2.0.0-alpha.9" description = "SillyHome v2 event-core prototype" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_addon_config.py b/tests/test_addon_config.py index 064979f..f76027b 100644 --- a/tests/test_addon_config.py +++ b/tests/test_addon_config.py @@ -9,7 +9,7 @@ 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.8" + assert config["version"] == "2.0.0-alpha.9" assert config["ingress"] is True assert config["ingress_port"] == 8099 assert config["homeassistant_api"] is True diff --git a/tests/test_future_api.py b/tests/test_future_api.py index cfd3c89..733a270 100644 --- a/tests/test_future_api.py +++ b/tests/test_future_api.py @@ -19,7 +19,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig restore = client.post("/v2/backup/restore", json=backup.json()) assert health.status_code == 200 - assert health.json()["version"] == "2.0.0-alpha.8" + assert health.json()["version"] == "2.0.0-alpha.9" assert backup.status_code == 200 assert restore.status_code == 200 assert restore.json() == {"status": "restored"} @@ -66,6 +66,17 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None "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") dashboard = client.get("/v2/dashboard") assert entities.status_code == 200 @@ -77,5 +88,13 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None 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 dashboard.status_code == 200 assert dashboard.json()["actuator_count"] == 1 diff --git a/tests/test_future_core.py b/tests/test_future_core.py index ef22cf7..194d0f3 100644 --- a/tests/test_future_core.py +++ b/tests/test_future_core.py @@ -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]