10 Commits

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

View File

@@ -22,6 +22,18 @@ pip install -e ".[dev]"
uvicorn app.main:app --reload uvicorn app.main:app --reload
``` ```
## Home Assistant Add-on
Add this repository in Home Assistant:
```text
http://192.168.6.31:3000/Otto/sillyhome-future
```
Then install **SillyHome Future**. The add-on is intentionally independent from
`sillyhome-next` and starts its own v2 event-core API on port `8099` behind
Ingress.
## Test ## Test
```bash ```bash
@@ -29,4 +41,3 @@ pytest
ruff check . ruff check .
mypy app tests mypy app tests
``` ```

13
addon/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.13-slim
WORKDIR /app
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.11
RUN python -m pip install --no-cache-dir \
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"
COPY run.sh /run.sh
RUN chmod 0755 /run.sh \
&& useradd --create-home --uid 10001 sillyhome
ENTRYPOINT ["/run.sh"]

28
addon/config.yaml Normal file
View File

@@ -0,0 +1,28 @@
name: SillyHome Future
version: "2.0.0-alpha.11"
slug: sillyhome_future
description: Event-first SillyHome v2 test controller
url: http://192.168.6.31:3000/Otto/sillyhome-future
arch:
- amd64
startup: application
boot: auto
watchdog: http://[HOST]:[PORT:8099]/health
init: false
ingress: true
ingress_port: 8099
panel_title: SillyHome Future
panel_icon: mdi:home-lightning-bolt
panel_admin: true
homeassistant_api: true
hassio_api: false
auth_api: false
map:
- type: addon_config
read_only: false
options:
store_path: /data/future_store
log_level: info
schema:
store_path: str
log_level: list(debug|info|warning|error)

31
addon/run.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
set -eu
export PYTHONUNBUFFERED=1
STORE_PATH="/data/future_store"
LOG_LEVEL="info"
if [ -f /data/options.json ]; then
eval "$(python - <<'PY'
import json
import shlex
with open("/data/options.json", encoding="utf-8") as stream:
options = json.load(stream)
for name, env_name in {
"store_path": "STORE_PATH",
"log_level": "LOG_LEVEL",
}.items():
if name in options:
print(f"{env_name}={shlex.quote(str(options[name]))}")
PY
)"
fi
export SILLYHOME_FUTURE_STORE="$STORE_PATH"
mkdir -p "$SILLYHOME_FUTURE_STORE"
chown -R sillyhome:sillyhome "$SILLYHOME_FUTURE_STORE"
exec su -s /bin/sh sillyhome -c \
"uvicorn app.main:app --host 0.0.0.0 --port 8099 --log-level ${LOG_LEVEL}"

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,
}
)

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

@@ -0,0 +1,163 @@
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()
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,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)

File diff suppressed because it is too large Load Diff

View File

@@ -27,3 +27,9 @@ state, but switching decisions must remain inspectable and testable.
6. Dashboard v2 6. Dashboard v2
7. Add-on hardening 7. Add-on hardening
## Add-on Contract
The Home Assistant add-on is owned by this repository and does not copy code
from `sillyhome-next`. It starts the v2 API with `SILLYHOME_FUTURE_STORE`
pointing at `/data/future_store`, so runtime, learning and control stores are
kept inside the add-on data volume.

View File

@@ -4,13 +4,16 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-future" name = "sillyhome-future"
version = "2.0.0-alpha.1" 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 = [
"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",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -32,4 +35,3 @@ target-version = "py311"
python_version = "3.11" python_version = "3.11"
strict = true strict = true
plugins = [] plugins = []

4
repository.yaml Normal file
View File

@@ -0,0 +1,4 @@
name: SillyHome Future Add-ons
url: http://192.168.6.31:3000/Otto/sillyhome-future
maintainer: Otto

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from pathlib import Path
import yaml # type: ignore[import-untyped]
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.11"
assert config["ingress"] is True
assert config["ingress_port"] == 8099
assert config["homeassistant_api"] is True
assert config["boot"] == "auto"
assert config["watchdog"] == "http://[HOST]:[PORT:8099]/health"
def test_repository_points_to_gitea_repo() -> None:
repository = yaml.safe_load(Path("repository.yaml").read_text(encoding="utf-8"))
assert repository["url"] == "http://192.168.6.31:3000/Otto/sillyhome-future"

View File

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

View File

@@ -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