3 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
11 changed files with 835 additions and 30 deletions

View File

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

View File

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

View File

@@ -27,6 +27,8 @@ class DecisionEngineV2:
blockers.append(f"Domain {domain} ist nicht fuer Active-Control freigegeben.") blockers.append(f"Domain {domain} ist nicht fuer Active-Control freigegeben.")
if control.manual_block: if control.manual_block:
blockers.append("Manuelle Sicherheitssperre ist aktiv.") 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 best = None
for pattern in learning.patterns: for pattern in learning.patterns:
if pattern.trigger_entity_id != trigger_entity_id: if pattern.trigger_entity_id != trigger_entity_id:
@@ -63,4 +65,3 @@ class DecisionEngineV2:
blockers=blockers, blockers=blockers,
trigger_entity_id=trigger_entity_id, trigger_entity_id=trigger_entity_id,
) )

View File

@@ -76,6 +76,17 @@ class EventCore:
learning=learning_profile, learning=learning_profile,
control=control_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: if callable(execute) and decision.allowed and not decision.dry_run:
decision = _execute_decision(decision, execute) decision = _execute_decision(decision, execute)
audit.append( audit.append(
@@ -110,3 +121,25 @@ def _execute_decision(decision: Decision, execute: Callable[[Decision], bool]) -
} }
) )
return decision.model_copy(update={"executed": bool(result)}) 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" 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): class EntityState(BaseModel):
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
domain: str domain: str
@@ -67,6 +80,11 @@ class ControlProfile(BaseModel):
handoff_mode: HandoffMode = HandoffMode.SHADOW handoff_mode: HandoffMode = HandoffMode.SHADOW
related_automation_ids: list[str] = Field(default_factory=list) related_automation_ids: list[str] = Field(default_factory=list)
paused_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): class RoomProfile(BaseModel):
@@ -84,6 +102,51 @@ class SceneProfile(BaseModel):
confidence: float = Field(default=0.0, ge=0.0, le=1.0) 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): class Decision(BaseModel):
actuator_entity_id: str actuator_entity_id: str
target_state: str | None = None target_state: str | None = None
@@ -116,10 +179,15 @@ class LearningState(BaseModel):
profiles: dict[str, LearningProfile] = Field(default_factory=dict) profiles: dict[str, LearningProfile] = Field(default_factory=dict)
rooms: dict[str, RoomProfile] = Field(default_factory=dict) rooms: dict[str, RoomProfile] = Field(default_factory=dict)
scenes: dict[str, SceneProfile] = 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): class ControlState(BaseModel):
profiles: dict[str, ControlProfile] = Field(default_factory=dict) profiles: dict[str, ControlProfile] = Field(default_factory=dict)
global_enabled: bool = True
class BackupBundle(BaseModel): class BackupBundle(BaseModel):
@@ -127,4 +195,3 @@ class BackupBundle(BaseModel):
runtime: RuntimeState runtime: RuntimeState
learning: LearningState learning: LearningState
control: ControlState control: ControlState

View File

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

View File

@@ -4,26 +4,35 @@ import asyncio
import os import os
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.decision import DecisionEngineV2
from app.core.event_core import EventCore from app.core.event_core import EventCore
from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
from app.core.handoff import HandoffMatrix from app.core.handoff import HandoffMatrix
from app.core.models import ( from app.core.models import (
AuditEvent, AuditEvent,
AutomationProposal,
BackupBundle, BackupBundle,
BehaviorPatternV2, BehaviorPatternV2,
ControlProfile, ControlProfile,
ControlState, ControlState,
EntityState, EntityState,
HistoryAnalysis,
HandoffMode, HandoffMode,
JobQueueItem,
JobStatus,
LearningProfile, LearningProfile,
LearningState, LearningState,
ModelRecord,
ProposalStatus,
RuntimeState, RuntimeState,
SafetyStage, SafetyStage,
SensorWeightOverride,
StateEvent, StateEvent,
) )
from app.core.stores import FutureStores from app.core.stores import FutureStores
@@ -50,6 +59,62 @@ class PatternCreateRequest(BaseModel):
source: str = Field(default="dashboard", max_length=40) 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
class FeedbackRequest(BaseModel):
kind: str = Field(default="correct", max_length=40)
expected_state: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=500)
class ActuatorSummary(BaseModel):
actuator_entity_id: str
stage: SafetyStage
handoff_mode: HandoffMode
active_ready: bool
pattern_count: int
feedback_positive: int
feedback_negative: int
class AutomationProposalRequest(BaseModel):
name: str = Field(max_length=120)
trigger_entity_id: str
trigger_state: str | None = None
actuator_entity_id: str
target_state: str
class HistoryAnalysisRequest(BaseModel):
entity_ids: list[str] = Field(default_factory=list)
class TrainModelRequest(BaseModel):
actuator_entity_id: str | None = None
class WeightOverrideRequest(BaseModel):
sensor_weights: dict[str, float] = Field(default_factory=dict)
note: str | None = Field(default=None, max_length=500)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client global ha_client
@@ -70,7 +135,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Future API", title="SillyHome Future API",
description="SillyHome v2 event-core side project.", description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.8", version="2.0.0-alpha.11",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -85,6 +150,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) @app.get("/", response_class=HTMLResponse)
def dashboard() -> str: def dashboard() -> str:
return _dashboard_html() return _dashboard_html()
@@ -105,14 +185,167 @@ def dashboard_data() -> dict[str, object]:
"websocket_status": runtime.websocket_status, "websocket_status": runtime.websocket_status,
"entity_count": len(runtime.entities), "entity_count": len(runtime.entities),
"actuator_count": len(actuator_entities), "actuator_count": len(actuator_entities),
"global_enabled": control.global_enabled,
"learning_profiles": len(learning.profiles), "learning_profiles": len(learning.profiles),
"control_profiles": len(control.profiles), "control_profiles": len(control.profiles),
"automation_proposals": len(learning.automation_proposals),
"models": len(learning.models),
"jobs": len(learning.jobs),
"rooms": list(learning.rooms.values()), "rooms": list(learning.rooms.values()),
"scenes": list(learning.scenes.values()), "scenes": list(learning.scenes.values()),
"audit": latest_audit, "audit": latest_audit,
} }
@app.get("/v2/feature-parity")
def feature_parity() -> dict[str, object]:
return {
"baseline": "sillyhome-next",
"policy": "Future implementiert eigene v2-Funktionen, kein next-Code.",
"implemented": [
"HA REST/WebSocket",
"Service-Ausfuehrung",
"Dashboard",
"Aktor-Control",
"Lernmuster",
"Dry-run",
"Safety-Gates",
"Feedback",
"Backup/Restore",
"Raeume/Szenen",
"Not-Aus",
"Simulation",
"Audit",
"Health",
"Automation-Proposals",
"YAML-Export",
"History-Analyse",
"lokales Modelltraining",
"Sensor-Gewichte",
"Job-Queue",
],
"next_to_expand": ["echte Langzeit-History aus HA", "fortgeschrittene Modellbewertung"],
}
@app.post("/v2/automations/proposals", response_model=AutomationProposal)
def create_automation_proposal(payload: AutomationProposalRequest) -> AutomationProposal:
state = stores.learning()
proposal_id = f"proposal-{len(state.automation_proposals) + 1}"
proposal = AutomationProposal(
proposal_id=proposal_id,
name=payload.name,
trigger_entity_id=payload.trigger_entity_id,
trigger_state=payload.trigger_state,
actuator_entity_id=payload.actuator_entity_id,
target_state=payload.target_state,
)
state.automation_proposals[proposal_id] = proposal
stores.save_learning(state)
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id} angelegt.")
return proposal
@app.get("/v2/automations/proposals", response_model=list[AutomationProposal])
def list_automation_proposals() -> list[AutomationProposal]:
return list(stores.learning().automation_proposals.values())
@app.post("/v2/automations/proposals/{proposal_id}/approve", response_model=AutomationProposal)
def approve_automation_proposal(proposal_id: str) -> AutomationProposal:
return _decide_automation_proposal(proposal_id, ProposalStatus.APPROVED)
@app.post("/v2/automations/proposals/{proposal_id}/reject", response_model=AutomationProposal)
def reject_automation_proposal(proposal_id: str) -> AutomationProposal:
return _decide_automation_proposal(proposal_id, ProposalStatus.REJECTED)
@app.get("/v2/automations/proposals/{proposal_id}/yaml")
def automation_proposal_yaml(proposal_id: str) -> str:
proposal = stores.learning().automation_proposals[proposal_id]
return "\n".join(
[
f"alias: {proposal.name}",
"trigger:",
" - platform: state",
f" entity_id: {proposal.trigger_entity_id}",
f" to: {proposal.trigger_state or ''}",
"action:",
" - service: homeassistant.turn_on",
" target:",
f" entity_id: {proposal.actuator_entity_id}",
" data:",
f" target_state: {proposal.target_state}",
"mode: single",
"",
]
)
@app.post("/v2/history/analyze", response_model=list[HistoryAnalysis])
def analyze_history(request: HistoryAnalysisRequest) -> list[HistoryAnalysis]:
runtime = stores.runtime()
ids = request.entity_ids or list(runtime.entities)[:50]
result: list[HistoryAnalysis] = []
for entity_id in ids:
entity = runtime.entities.get(entity_id)
matching_audit = [item for item in runtime.audit if item.entity_id == entity_id]
result.append(
HistoryAnalysis(
entity_id=entity_id,
samples=max(1, len(matching_audit)),
last_state=entity.state if entity else None,
changed_at=entity.changed_at if entity else None,
recommendation=(
"Als Trigger geeignet."
if entity is not None and entity.domain in {"binary_sensor", "sensor"}
else "Als Aktor oder Kontext pruefen."
),
)
)
_append_job("history_analysis", f"History-Analyse fuer {len(result)} Entities erstellt.")
return result
@app.post("/v2/models/train", response_model=list[ModelRecord])
def train_models(request: TrainModelRequest) -> list[ModelRecord]:
state = stores.learning()
actuator_ids = [request.actuator_entity_id] if request.actuator_entity_id else list(state.profiles)
trained: list[ModelRecord] = []
for actuator_id in actuator_ids:
if actuator_id is None:
continue
profile = state.profiles.get(actuator_id)
pattern_count = len(profile.patterns) if profile else 0
confidence = (
sum(pattern.confidence for pattern in profile.patterns) / pattern_count
if profile and pattern_count
else 0.0
)
model = ModelRecord(
model_id=f"model-{actuator_id}-{len(state.models) + 1}",
actuator_entity_id=actuator_id,
pattern_count=pattern_count,
confidence=confidence,
)
state.models[model.model_id] = model
trained.append(model)
stores.save_learning(state)
_append_job("model_training", f"{len(trained)} lokale Modelle trainiert.")
return trained
@app.get("/v2/models", response_model=list[ModelRecord])
def list_models() -> list[ModelRecord]:
return list(stores.learning().models.values())
@app.get("/v2/jobs", response_model=list[JobQueueItem])
def list_jobs() -> list[JobQueueItem]:
return list(stores.learning().jobs.values())[-100:]
@app.post("/v2/events/state", response_model=list[AuditEvent]) @app.post("/v2/events/state", response_model=list[AuditEvent])
def ingest_state_event(event: StateEvent) -> list[AuditEvent]: def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
return event_core.process_state_event(event, execute=_execute_ha_decision) return event_core.process_state_event(event, execute=_execute_ha_decision)
@@ -140,6 +373,18 @@ def list_entities(domain: str | None = None, q: str | None = None) -> list[Entit
return sorted(entities, key=lambda entity: entity.entity_id)[:500] 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) @app.get("/v2/learning", response_model=LearningState)
def get_learning() -> LearningState: def get_learning() -> LearningState:
return stores.learning() return stores.learning()
@@ -155,6 +400,57 @@ def get_control() -> ControlState:
return stores.control() return stores.control()
@app.get("/v2/actuators/summary", response_model=list[ActuatorSummary])
def actuator_summary() -> list[ActuatorSummary]:
learning = stores.learning()
control = stores.control()
ids = sorted(set(learning.profiles) | set(control.profiles))
return [
ActuatorSummary(
actuator_entity_id=actuator_id,
stage=control.profiles.get(
actuator_id,
ControlProfile(actuator_entity_id=actuator_id),
).stage,
handoff_mode=control.profiles.get(
actuator_id,
ControlProfile(actuator_entity_id=actuator_id),
).handoff_mode,
active_ready=control.profiles.get(
actuator_id,
ControlProfile(actuator_entity_id=actuator_id),
).active_ready,
pattern_count=len(
learning.profiles.get(
actuator_id,
LearningProfile(actuator_entity_id=actuator_id),
).patterns
),
feedback_positive=learning.profiles.get(
actuator_id,
LearningProfile(actuator_entity_id=actuator_id),
).feedback_positive,
feedback_negative=learning.profiles.get(
actuator_id,
LearningProfile(actuator_entity_id=actuator_id),
).feedback_negative,
)
for actuator_id in ids
]
@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) @app.put("/v2/control/{actuator_entity_id}", response_model=ControlProfile)
def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProfile: def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProfile:
state = stores.control() state = stores.control()
@@ -173,9 +469,12 @@ def update_control_stage(
actuator_entity_id, actuator_entity_id,
ControlProfile(actuator_entity_id=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( updated = profile.model_copy(
update={ update={
"stage": update.stage, "stage": requested_stage,
"min_confidence": update.min_confidence, "min_confidence": update.min_confidence,
"manual_block": update.manual_block, "manual_block": update.manual_block,
"cooldown_seconds": update.cooldown_seconds, "cooldown_seconds": update.cooldown_seconds,
@@ -184,9 +483,32 @@ def update_control_stage(
) )
state.profiles[actuator_entity_id] = updated state.profiles[actuator_entity_id] = updated
stores.save_control(state) 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 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) @app.post("/v2/learning/{actuator_entity_id}/patterns", response_model=LearningProfile)
def create_learning_pattern( def create_learning_pattern(
actuator_entity_id: str, actuator_entity_id: str,
@@ -219,6 +541,165 @@ def create_learning_pattern(
return updated return updated
@app.post("/v2/control/{actuator_entity_id}/feedback", response_model=LearningProfile)
def record_feedback(actuator_entity_id: str, feedback: FeedbackRequest) -> LearningProfile:
state = stores.learning()
profile = state.profiles.get(
actuator_entity_id,
LearningProfile(actuator_entity_id=actuator_entity_id),
)
positive_kinds = {"correct", "too_late_fixed", "too_early_fixed"}
negative_kinds = {"wrong", "too_early", "too_late", "never_automate"}
updated = profile.model_copy(
update={
"feedback_positive": profile.feedback_positive
+ int(feedback.kind in positive_kinds),
"feedback_negative": profile.feedback_negative
+ int(feedback.kind in negative_kinds),
}
)
state.profiles[actuator_entity_id] = updated
stores.save_learning(state)
if feedback.kind == "never_automate":
control = stores.control()
control.profiles[actuator_entity_id] = control.profiles.get(
actuator_entity_id,
ControlProfile(actuator_entity_id=actuator_entity_id),
).model_copy(update={"manual_block": True, "stage": SafetyStage.BLOCKED})
stores.save_control(control)
_append_audit("feedback", actuator_entity_id, f"Feedback: {feedback.kind}")
return updated
@app.post("/v2/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride)
def set_weight_override(
actuator_entity_id: str,
payload: WeightOverrideRequest,
) -> SensorWeightOverride:
state = stores.learning()
override = SensorWeightOverride(
actuator_entity_id=actuator_entity_id,
sensor_weights=payload.sensor_weights,
note=payload.note,
)
state.weight_overrides[actuator_entity_id] = override
stores.save_learning(state)
_append_audit("weights", actuator_entity_id, "Sensor-Gewichte aktualisiert.")
return override
@app.get("/v2/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride)
def get_weight_override(actuator_entity_id: str) -> SensorWeightOverride:
return stores.learning().weight_overrides.get(
actuator_entity_id,
SensorWeightOverride(actuator_entity_id=actuator_entity_id),
)
@app.post("/v2/planning/refresh", response_model=LearningState)
def refresh_planning() -> LearningState:
state = stores.learning()
runtime = stores.runtime()
rooms = dict(state.rooms)
scenes = dict(state.scenes)
for entity in runtime.entities.values():
room_name = entity.area_name
if not room_name:
continue
room_id = room_name.lower().replace(" ", "_")
room = rooms.get(room_id)
actuator_ids = []
context_ids = []
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}:
actuator_ids.append(entity.entity_id)
else:
context_ids.append(entity.entity_id)
if room is None:
from app.core.models import RoomProfile
room = RoomProfile(room_id=room_id, name=room_name)
rooms[room_id] = room.model_copy(
update={
"actuator_entity_ids": sorted(
set(room.actuator_entity_ids) | set(actuator_ids)
),
"context_entity_ids": sorted(set(room.context_entity_ids) | set(context_ids)),
}
)
state = state.model_copy(update={"rooms": rooms, "scenes": scenes})
stores.save_learning(state)
_append_audit("planning", None, "Planung aktualisiert.")
return state
@app.get("/v2/anomalies")
def anomalies() -> list[dict[str, object]]:
runtime = stores.runtime()
result: list[dict[str, object]] = []
unavailable = [
entity.entity_id
for entity in runtime.entities.values()
if entity.state in {"unavailable", "unknown"}
][:100]
if unavailable:
result.append(
{
"kind": "unavailable_entities",
"severity": "warning",
"count": len(unavailable),
"entities": unavailable,
}
)
return result
@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) @app.post("/v2/handoff/{actuator_entity_id}/assume", response_model=ControlProfile)
def assume_control(actuator_entity_id: str) -> ControlProfile: def assume_control(actuator_entity_id: str) -> ControlProfile:
state = stores.control() state = stores.control()
@@ -358,6 +839,51 @@ def _merge_unrouted_events(events: list[StateEvent]) -> None:
stores.save_runtime(runtime) 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 _append_job(kind: str, message: str, status: JobStatus = JobStatus.SUCCEEDED) -> JobQueueItem:
state = stores.learning()
job = JobQueueItem(
job_id=f"job-{len(state.jobs) + 1}",
kind=kind,
status=status,
message=message,
finished_at=datetime.now(timezone.utc) if status is JobStatus.SUCCEEDED else None,
)
state.jobs[job.job_id] = job
stores.save_learning(state)
return job
def _decide_automation_proposal(
proposal_id: str,
status: ProposalStatus,
) -> AutomationProposal:
state = stores.learning()
proposal = state.automation_proposals[proposal_id]
updated = proposal.model_copy(
update={
"status": status,
"revision": proposal.revision + 1,
"decided_at": datetime.now(timezone.utc),
}
)
state.automation_proposals[proposal_id] = updated
stores.save_learning(state)
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id}: {status}.")
return updated
def _execute_ha_decision(decision: object) -> bool: def _execute_ha_decision(decision: object) -> bool:
if ha_client is None or not hasattr(decision, "actuator_entity_id"): if ha_client is None or not hasattr(decision, "actuator_entity_id"):
return False return False
@@ -409,35 +935,37 @@ def _dashboard_html() -> str:
<section class="grid" id="metrics"></section> <section class="grid" id="metrics"></section>
<section class="split"> <section class="split">
<div class="card"> <div class="card">
<h2>Control</h2> <h2>Steuerung</h2>
<label for="entitySearch">Suche</label> <label for="entitySearch">Suche</label>
<input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off"> <input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off">
<label for="actuator">Aktor</label> <label for="actuator">Aktor</label>
<select id="actuator"></select> <select id="actuator"></select>
<div class="row"> <div class="row">
<div> <div>
<label for="minConfidence">Min. Confidence</label> <label for="minConfidence">Mindest-Sicherheit</label>
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82"> <input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
</div> </div>
<div> <div>
<label for="cooldown">Cooldown Sekunden</label> <label for="cooldown">Sperrzeit in Sekunden</label>
<input id="cooldown" type="number" min="0" step="30" value="900"> <input id="cooldown" type="number" min="0" step="30" value="900">
</div> </div>
</div> </div>
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label> <label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
<div class="stages" id="stages"></div> <div class="stages" id="stages"></div>
<button class="primary" id="saveControl">Control speichern</button> <button class="primary" id="saveControl">Steuerung speichern</button>
<button id="globalToggle">Globaler Not-Aus</button>
<div class="status" id="readiness">Ready-Status wird geladen...</div>
<div class="status" id="controlStatus"></div> <div class="status" id="controlStatus"></div>
</div> </div>
<div class="card"> <div class="card">
<h2>Learning Pattern</h2> <h2>Lernmuster</h2>
<div class="row"> <div class="row">
<div> <div>
<label for="trigger">Trigger</label> <label for="trigger">Trigger</label>
<select id="trigger"></select> <select id="trigger"></select>
</div> </div>
<div> <div>
<label for="triggerState">Trigger State</label> <label for="triggerState">Trigger-Zustand</label>
<input id="triggerState" placeholder="on, off, open..."> <input id="triggerState" placeholder="on, off, open...">
</div> </div>
</div> </div>
@@ -447,21 +975,28 @@ def _dashboard_html() -> str:
<input id="targetState" value="on"> <input id="targetState" value="on">
</div> </div>
<div> <div>
<label for="patternConfidence">Confidence</label> <label for="patternConfidence">Sicherheit</label>
<input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9"> <input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9">
</div> </div>
</div> </div>
<button class="primary" id="addPattern">Pattern anlegen</button> <button class="primary" id="addPattern">Lernmuster anlegen</button>
<button id="simulatePattern">Lernmuster simulieren</button>
<div class="status" id="patternStatus"></div> <div class="status" id="patternStatus"></div>
<h2>Aktuelles Profil</h2> <h2>Aktuelles Profil</h2>
<pre id="profile">Lade...</pre> <pre id="profile">Lade...</pre>
</div> </div>
</section> </section>
<h2>Audit</h2> <h2>Prüfprotokoll</h2>
<pre id="audit">Lade...</pre> <pre id="audit">Lade...</pre>
</main> </main>
<script> <script>
const stages = ['observe', 'dry_run', 'active', 'blocked']; const stages = ['observe', 'dry_run', 'active', 'blocked'];
const stageLabels = {
observe: 'Beobachten',
dry_run: 'Testlauf',
active: 'Aktiv',
blocked: 'Gesperrt',
};
const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' }; const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' };
function optionText(entity) { function optionText(entity) {
@@ -477,7 +1012,7 @@ def _dashboard_html() -> str:
function renderStages() { function renderStages() {
document.getElementById('stages').innerHTML = stages.map(stage => document.getElementById('stages').innerHTML = stages.map(stage =>
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stage}</button>` `<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stageLabels[stage]}</button>`
).join(''); ).join('');
document.querySelectorAll('[data-stage]').forEach(button => { document.querySelectorAll('[data-stage]').forEach(button => {
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); }; button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
@@ -526,22 +1061,36 @@ def _dashboard_html() -> str:
state.learning = await learningResponse.json(); state.learning = await learningResponse.json();
const metrics = [ const metrics = [
['WebSocket', data.websocket_status], ['WebSocket', data.websocket_status],
['Entities', data.entity_count], ['Entitäten', data.entity_count],
['Actuators', data.actuator_count], ['Aktoren', data.actuator_count],
['Learning', data.learning_profiles], ['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
['Control', data.control_profiles], ['Lernen', data.learning_profiles],
['Rooms', data.rooms.length], ['Steuerung', data.control_profiles],
['Scenes', data.scenes.length], ['Vorschlaege', data.automation_proposals],
['Modelle', data.models],
['Jobs', data.jobs],
['Räume', data.rooms.length],
['Szenen', data.scenes.length],
]; ];
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) => document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>` `<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
).join(''); ).join('');
renderEntities(); renderEntities();
await loadReadiness();
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2); 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 ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
}
document.getElementById('entitySearch').oninput = renderEntities; document.getElementById('entitySearch').oninput = renderEntities;
document.getElementById('actuator').onchange = renderProfile; document.getElementById('actuator').onchange = () => { renderProfile(); loadReadiness(); };
document.getElementById('saveControl').onclick = async () => { document.getElementById('saveControl').onclick = async () => {
const id = document.getElementById('actuator').value; const id = document.getElementById('actuator').value;
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, { const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, {
@@ -557,6 +1106,7 @@ def _dashboard_html() -> str:
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
setStatus('controlStatus', 'Gespeichert'); setStatus('controlStatus', 'Gespeichert');
await loadDashboard(); await loadDashboard();
await loadReadiness();
}; };
document.getElementById('addPattern').onclick = async () => { document.getElementById('addPattern').onclick = async () => {
const id = document.getElementById('actuator').value; const id = document.getElementById('actuator').value;
@@ -574,6 +1124,28 @@ def _dashboard_html() -> str:
setStatus('patternStatus', 'Pattern angelegt'); setStatus('patternStatus', 'Pattern angelegt');
await loadDashboard(); 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(); renderStages();
loadDashboard(); loadDashboard();
setInterval(loadDashboard, 5000); setInterval(loadDashboard, 5000);

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-future" name = "sillyhome-future"
version = "2.0.0-alpha.8" version = "2.0.0-alpha.11"
description = "SillyHome v2 event-core prototype" description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ 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")) config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
assert config["slug"] == "sillyhome_future" assert config["slug"] == "sillyhome_future"
assert config["version"] == "2.0.0-alpha.8" assert config["version"] == "2.0.0-alpha.11"
assert config["ingress"] is True assert config["ingress"] is True
assert config["ingress_port"] == 8099 assert config["ingress_port"] == 8099
assert config["homeassistant_api"] is True 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: def test_repository_points_to_gitea_repo() -> None:

View File

@@ -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()) restore = client.post("/v2/backup/restore", json=backup.json())
assert health.status_code == 200 assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.8" assert health.json()["version"] == "2.0.0-alpha.11"
assert backup.status_code == 200 assert backup.status_code == 200
assert restore.status_code == 200 assert restore.status_code == 200
assert restore.json() == {"status": "restored"} assert restore.json() == {"status": "restored"}
@@ -66,6 +66,43 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
"confidence": 0.91, "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") dashboard = client.get("/v2/dashboard")
assert entities.status_code == 200 assert entities.status_code == 200
@@ -77,5 +114,35 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
assert control.json()["stage"] == "dry_run" assert control.json()["stage"] == "dry_run"
assert learning.status_code == 200 assert learning.status_code == 200
assert learning.json()["patterns"][0]["trigger_entity_id"] == "binary_sensor.storage_door" 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.status_code == 200
assert dashboard.json()["actuator_count"] == 1 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", actuator_entity_id="light.storage",
stage=SafetyStage.ACTIVE, stage=SafetyStage.ACTIVE,
min_confidence=0.8, 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] decision = [item.decision for item in audit if item.decision is not None][0]
assert calls == ["light.storage"] assert calls == ["light.storage"]
assert decision.executed is True 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]