13 Commits

Author SHA1 Message Date
bf832b49f4 Filter autopilot actuator candidates 2026-06-18 18:43:53 +02:00
8057751c11 Filter autopilot trigger candidates 2026-06-18 17:30:48 +02:00
2f750e3e41 Add autopilot light workflow 2026-06-18 17:27:25 +02:00
6e6031cfda Add HA history and model evaluation 2026-06-18 17:08:23 +02:00
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
b77992606a Add native HA integration and dashboard 2026-06-18 12:46:57 +02:00
9d01af98b2 Fix addon build context 2026-06-18 12:26:54 +02:00
13 changed files with 2084 additions and 28 deletions

View File

@@ -2,13 +2,12 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.15
COPY app ./app 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"
COPY addon/run.sh /run.sh COPY run.sh /run.sh
RUN chmod 0755 /run.sh \ RUN chmod 0755 /run.sh \
&& useradd --create-home --uid 10001 sillyhome && useradd --create-home --uid 10001 sillyhome
ENTRYPOINT ["/run.sh"] ENTRYPOINT ["/run.sh"]

View File

@@ -1,12 +1,12 @@
name: SillyHome Future name: SillyHome Future
version: "2.0.0-alpha.2" version: "2.0.0-alpha.15"
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
@@ -26,4 +26,3 @@ options:
schema: schema:
store_path: str store_path: str
log_level: list(debug|info|warning|error) log_level: list(debug|info|warning|error)

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

@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from collections.abc import Callable
from app.core.decision import DecisionEngineV2 from app.core.decision import DecisionEngineV2
from app.core.handoff import HandoffMatrix from app.core.handoff import HandoffMatrix
from app.core.models import ( from app.core.models import (
AuditEvent, AuditEvent,
ControlProfile, ControlProfile,
Decision,
EntityState, EntityState,
LearningProfile, LearningProfile,
StateEvent, StateEvent,
@@ -31,7 +33,12 @@ class EventCore:
self._decision = DecisionEngineV2() self._decision = DecisionEngineV2()
self._handoff = HandoffMatrix() self._handoff = HandoffMatrix()
def process_state_event(self, event: StateEvent) -> list[AuditEvent]: def process_state_event(
self,
event: StateEvent,
*,
execute: Callable[[Decision], bool] | None = None,
) -> list[AuditEvent]:
runtime = self._stores.runtime() runtime = self._stores.runtime()
learning = self._stores.learning() learning = self._stores.learning()
control = self._stores.control() control = self._stores.control()
@@ -69,6 +76,19 @@ 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:
decision = _execute_decision(decision, execute)
audit.append( audit.append(
AuditEvent( AuditEvent(
event_id=_event_id("decision"), event_id=_event_id("decision"),
@@ -87,3 +107,39 @@ class EventCore:
def _event_id(prefix: str) -> str: def _event_id(prefix: str) -> str:
return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}" return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}"
def _execute_decision(decision: Decision, execute: Callable[[Decision], bool]) -> Decision:
try:
result = execute(decision)
except Exception as exc:
return decision.model_copy(
update={
"executed": False,
"allowed": False,
"reason": f"Ausfuehrung fehlgeschlagen: {exc}",
"blockers": [*decision.blockers, str(exc)],
}
)
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,
}
)

202
app/core/ha_client.py Normal file
View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timezone
import httpx
import websockets
from app.core.models import EntityState, StateEvent
@dataclass(frozen=True)
class HaClientConfig:
core_url: str
token: str
websocket_url: str | None = None
timeout_seconds: float = 15.0
class FutureHaClient:
def __init__(self, config: HaClientConfig) -> None:
self._config = config
self._core_url = config.core_url.rstrip("/")
def read_states(self) -> list[EntityState]:
with httpx.Client(timeout=self._config.timeout_seconds) as client:
response = client.get(
f"{self._core_url}/api/states",
headers=self._headers,
)
response.raise_for_status()
payload = response.json()
result: list[EntityState] = []
for item in payload if isinstance(payload, list) else []:
if not isinstance(item, dict):
continue
entity_id = item.get("entity_id")
if not isinstance(entity_id, str) or "." not in entity_id:
continue
attributes = item.get("attributes")
attr = attributes if isinstance(attributes, dict) else {}
result.append(
EntityState(
entity_id=entity_id,
domain=entity_id.split(".", 1)[0],
state=item.get("state") if isinstance(item.get("state"), str) else None,
changed_at=_parse_datetime(item.get("last_changed")),
area_name=attr.get("area_name") if isinstance(attr.get("area_name"), str) else None,
device_id=attr.get("device_id") if isinstance(attr.get("device_id"), str) else None,
friendly_name=(
attr.get("friendly_name")
if isinstance(attr.get("friendly_name"), str)
else None
),
)
)
return result
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> None:
with httpx.Client(timeout=self._config.timeout_seconds) as client:
response = client.post(
f"{self._core_url}/api/services/{domain}/{service}",
headers=self._headers,
json=service_data,
)
response.raise_for_status()
def read_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> dict[str, list[StateEvent]]:
params = {
"filter_entity_id": ",".join(entity_ids),
"end_time": end_time.isoformat(),
"minimal_response": "1",
}
with httpx.Client(timeout=self._config.timeout_seconds) as client:
response = client.get(
f"{self._core_url}/api/history/period/{start_time.isoformat()}",
headers=self._headers,
params=params,
)
response.raise_for_status()
payload = response.json()
result: dict[str, list[StateEvent]] = {entity_id: [] for entity_id in entity_ids}
for series in payload if isinstance(payload, list) else []:
if not isinstance(series, list):
continue
for item in series:
if not isinstance(item, dict):
continue
entity_id = item.get("entity_id")
if not isinstance(entity_id, str):
continue
result.setdefault(entity_id, []).append(
StateEvent(
entity_id=entity_id,
new_state=item.get("state") if isinstance(item.get("state"), str) else None,
changed_at=_parse_datetime(
item.get("last_changed") or item.get("last_updated")
),
)
)
return result
async def listen_state_events(self) -> AsyncIterator[StateEvent]:
websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url)
async with websockets.connect(websocket_url, ping_interval=None) as websocket:
auth_required = json.loads(await websocket.recv())
if auth_required.get("type") != "auth_required":
raise RuntimeError("Home Assistant websocket did not request authentication")
await websocket.send(json.dumps({"type": "auth", "access_token": self._config.token}))
auth_result = json.loads(await websocket.recv())
if auth_result.get("type") != "auth_ok":
raise RuntimeError("Home Assistant websocket authentication failed")
await websocket.send(
json.dumps({"id": 1, "type": "subscribe_events", "event_type": "state_changed"})
)
async for raw_message in websocket:
data = json.loads(raw_message)
if data.get("type") != "event":
continue
event = data.get("event")
if not isinstance(event, dict) or event.get("event_type") != "state_changed":
continue
event_data = event.get("data")
if not isinstance(event_data, dict):
continue
entity_id = event_data.get("entity_id")
new_state = event_data.get("new_state")
if not isinstance(entity_id, str) or not isinstance(new_state, dict):
continue
state = new_state.get("state")
attributes = new_state.get("attributes")
attr = attributes if isinstance(attributes, dict) else {}
yield StateEvent(
entity_id=entity_id,
new_state=state if isinstance(state, str) else None,
changed_at=_parse_datetime(
new_state.get("last_changed") or new_state.get("last_updated")
),
attributes={
key: value
for key, value in {
"area_name": attr.get("area_name"),
"device_id": attr.get("device_id"),
"friendly_name": attr.get("friendly_name"),
}.items()
if isinstance(value, str)
},
)
@property
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._config.token}",
"Content-Type": "application/json",
}
def service_for_state(domain: str, target_state: str | None) -> tuple[str, str] | None:
if target_state is None:
return None
if domain in {"light", "switch", "fan", "humidifier"}:
if target_state == "on":
return domain, "turn_on"
if target_state == "off":
return domain, "turn_off"
if domain == "cover":
if target_state in {"open", "opening"}:
return domain, "open_cover"
if target_state in {"closed", "closing"}:
return domain, "close_cover"
return None
def _default_websocket_url(core_url: str) -> str:
stripped = core_url.rstrip("/")
if stripped.endswith("/core"):
return stripped.replace("http://", "ws://").replace("https://", "wss://") + "/websocket"
return stripped.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
def _parse_datetime(value: object) -> datetime:
if not isinstance(value, str):
return datetime.now(timezone.utc)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return datetime.now(timezone.utc)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed

View File

@@ -21,6 +21,25 @@ 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 CandidateStatus(StrEnum):
PROPOSED = "proposed"
ACCEPTED = "accepted"
DISMISSED = "dismissed"
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 +86,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 +108,88 @@ 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 ModelEvaluation(BaseModel):
evaluation_id: str
model_id: str
actuator_entity_id: str
score: float = Field(default=0.0, ge=0.0, le=1.0)
coverage: float = Field(default=0.0, ge=0.0, le=1.0)
dry_run_success_rate: float = Field(default=0.0, ge=0.0, le=1.0)
feedback_score: float = Field(default=0.0, ge=0.0, le=1.0)
verdict: str
reasons: list[str] = Field(default_factory=list)
evaluated_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 AutopilotSettings(BaseModel):
enabled: bool = True
interval_seconds: int = Field(default=3600, ge=300)
min_candidate_confidence: float = Field(default=0.65, ge=0.0, le=1.0)
auto_train: bool = True
auto_evaluate: bool = True
auto_activate: bool = False
last_run_at: datetime | None = None
class CandidateRecommendation(BaseModel):
candidate_id: str
actuator_entity_id: str
trigger_entity_id: str
trigger_state: str | None = None
target_state: str
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
reason: str
status: CandidateStatus = CandidateStatus.PROPOSED
created_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
unique_states: int = 0
transitions: int = 0
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 +222,18 @@ 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)
model_evaluations: dict[str, ModelEvaluation] = Field(default_factory=dict)
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
candidates: dict[str, CandidateRecommendation] = 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
autopilot: AutopilotSettings = Field(default_factory=AutopilotSettings)
class BackupBundle(BaseModel): class BackupBundle(BaseModel):
@@ -127,4 +241,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)

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-future" name = "sillyhome-future"
version = "2.0.0-alpha.2" version = "2.0.0-alpha.15"
description = "SillyHome v2 event-core prototype" description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115", "fastapi>=0.115",
"httpx>=0.27",
"pydantic>=2.8", "pydantic>=2.8",
"uvicorn[standard]>=0.30", "uvicorn[standard]>=0.30",
"websockets>=12.0",
"pyyaml>=6.0", "pyyaml>=6.0",
] ]

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.2" assert config["version"] == "2.0.0-alpha.15"
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

@@ -2,20 +2,180 @@ from __future__ import annotations
from fastapi.testclient import TestClient 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] def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("SILLYHOME_FUTURE_STORE", str(tmp_path)) test_stores = FutureStores(tmp_path)
client = TestClient(app) monkeypatch.setattr(main, "stores", test_stores)
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
client = TestClient(main.app)
health = client.get("/health") health = client.get("/health")
backup = client.get("/v2/backup/export") backup = client.get("/v2/backup/export")
restore = client.post("/v2/backup/restore", json=backup.json()) restore = client.post("/v2/backup/restore", json=backup.json())
assert stores is not None
assert health.status_code == 200 assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.2" assert health.json()["version"] == "2.0.0-alpha.15"
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"}
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",
area_name="Storage",
),
"light.storage_door": EntityState(
entity_id="light.storage_door",
domain="light",
state="off",
area_name="Storage",
),
"switch.storage_motion": EntityState(
entity_id="switch.storage_motion",
domain="switch",
state="off",
area_name="Storage",
),
}
)
)
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"})
evaluation = client.post("/v2/models/evaluate", json={"actuator_entity_id": "light.storage"})
evaluations = client.get("/v2/models/evaluations")
autopilot = client.post("/v2/autopilot/run")
candidates = client.get("/v2/autopilot/candidates")
accepted = client.post(
f"/v2/autopilot/candidates/{autopilot.json()['candidates'][0]['candidate_id']}/accept"
)
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",
"light.storage_door",
]
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 evaluation.status_code == 200
assert evaluation.json()[0]["verdict"] in {"bereit", "weiter testen"}
assert evaluations.status_code == 200
assert evaluations.json()
assert autopilot.status_code == 200
assert autopilot.json()["candidates"]
assert all(
candidate["actuator_entity_id"] != "switch.storage_motion"
for candidate in autopilot.json()["candidates"]
)
assert candidates.status_code == 200
assert accepted.status_code == 200
assert jobs.status_code == 200
assert jobs.json()
assert dashboard.status_code == 200
assert dashboard.json()["actuator_count"] == 2
assert dashboard.json()["automation_proposals"] == 1

View File

@@ -7,6 +7,7 @@ from app.core.handoff import HandoffMatrix
from app.core.models import ( from app.core.models import (
BehaviorPatternV2, BehaviorPatternV2,
ControlProfile, ControlProfile,
Decision,
EntityState, EntityState,
HandoffMode, HandoffMode,
LearningProfile, LearningProfile,
@@ -91,3 +92,120 @@ def test_handoff_matrix_detects_conflict_and_rollback() -> None:
rolled_back = matrix.rollback(controlled) rolled_back = matrix.rollback(controlled)
assert rolled_back.handoff_mode is HandoffMode.ROLLBACK assert rolled_back.handoff_mode is HandoffMode.ROLLBACK
assert rolled_back.paused_automation_ids == [] assert rolled_back.paused_automation_ids == []
def test_event_core_executes_allowed_active_decision(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,
active_ready=True,
)
}
}
)
)
calls: list[str] = []
def execute(decision: Decision) -> bool:
calls.append(decision.actuator_entity_id)
return True
audit = EventCore(stores).process_state_event(
StateEvent(entity_id="binary_sensor.storage_door", new_state="on"),
execute=execute,
)
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]

12
tests/test_ha_client.py Normal file
View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from app.core.ha_client import service_for_state
def test_service_for_state_maps_safe_domains() -> None:
assert service_for_state("light", "on") == ("light", "turn_on")
assert service_for_state("switch", "off") == ("switch", "turn_off")
assert service_for_state("cover", "open") == ("cover", "open_cover")
assert service_for_state("cover", "closed") == ("cover", "close_cover")
assert service_for_state("lock", "unlocked") is None