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:
+ +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: `