8 Commits

Author SHA1 Message Date
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 1313 additions and 28 deletions

View File

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

View File

@@ -1,12 +1,12 @@
name: SillyHome Future
version: "2.0.0-alpha.2"
version: "2.0.0-alpha.10"
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: manual
boot: auto
watchdog: http://[HOST]:[PORT:8099]/health
init: false
ingress: true
@@ -26,4 +26,3 @@ options:
schema:
store_path: str
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.")
if control.manual_block:
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
if control.stage is SafetyStage.ACTIVE and not control.active_ready:
blockers.append("Active ist noch nicht freigegeben. Erst Dry-run-Reife erreichen.")
best = None
for pattern in learning.patterns:
if pattern.trigger_entity_id != trigger_entity_id:
@@ -63,4 +65,3 @@ class DecisionEngineV2:
blockers=blockers,
trigger_entity_id=trigger_entity_id,
)

View File

@@ -1,12 +1,14 @@
from __future__ import annotations
from datetime import datetime, timezone
from collections.abc import Callable
from app.core.decision import DecisionEngineV2
from app.core.handoff import HandoffMatrix
from app.core.models import (
AuditEvent,
ControlProfile,
Decision,
EntityState,
LearningProfile,
StateEvent,
@@ -31,7 +33,12 @@ class EventCore:
self._decision = DecisionEngineV2()
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()
learning = self._stores.learning()
control = self._stores.control()
@@ -69,6 +76,19 @@ class EventCore:
learning=learning_profile,
control=control_profile,
)
if not control.global_enabled and decision.allowed:
decision = decision.model_copy(
update={
"allowed": False,
"reason": "Globaler Not-Aus ist aktiv.",
"blockers": [*decision.blockers, "Globaler Not-Aus ist aktiv."],
}
)
if decision.dry_run:
control_profile = _record_dry_run(control_profile, decision)
control.profiles[actuator_id] = control_profile
if callable(execute) and decision.allowed and not decision.dry_run:
decision = _execute_decision(decision, execute)
audit.append(
AuditEvent(
event_id=_event_id("decision"),
@@ -87,3 +107,39 @@ class EventCore:
def _event_id(prefix: str) -> str:
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

@@ -67,6 +67,11 @@ class ControlProfile(BaseModel):
handoff_mode: HandoffMode = HandoffMode.SHADOW
related_automation_ids: list[str] = Field(default_factory=list)
paused_automation_ids: list[str] = Field(default_factory=list)
dry_run_events: int = Field(default=0, ge=0)
dry_run_successes: int = Field(default=0, ge=0)
dry_run_failures: int = Field(default=0, ge=0)
active_ready: bool = False
active_readiness_reason: str = "Noch nicht bewertet."
class RoomProfile(BaseModel):
@@ -120,6 +125,7 @@ class LearningState(BaseModel):
class ControlState(BaseModel):
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
global_enabled: bool = True
class BackupBundle(BaseModel):
@@ -127,4 +133,3 @@ class BackupBundle(BaseModel):
runtime: RuntimeState
learning: LearningState
control: ControlState

View File

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

View File

@@ -1,41 +1,204 @@
from __future__ import annotations
import asyncio
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from app.core.decision import DecisionEngineV2
from app.core.event_core import EventCore
from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
from app.core.handoff import HandoffMatrix
from app.core.models import (
AuditEvent,
BackupBundle,
BehaviorPatternV2,
ControlProfile,
ControlState,
EntityState,
HandoffMode,
LearningProfile,
LearningState,
RuntimeState,
SafetyStage,
StateEvent,
)
from app.core.stores import FutureStores
app = FastAPI(
title="SillyHome Future API",
description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.2",
)
stores = FutureStores(os.getenv("SILLYHOME_FUTURE_STORE", ".future_store"))
event_core = EventCore(stores)
handoff = HandoffMatrix()
ha_client: FutureHaClient | None = None
class ControlStageUpdate(BaseModel):
stage: SafetyStage
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
manual_block: bool = False
cooldown_seconds: int = Field(default=900, ge=0)
class PatternCreateRequest(BaseModel):
trigger_entity_id: str
trigger_state: str | None = None
target_state: str
confidence: float = Field(default=0.9, ge=0.0, le=1.0)
support: int = Field(default=3, ge=1)
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
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client
listener_task: asyncio.Task[None] | None = None
ha_client = _ha_client_from_env()
if ha_client is not None:
_load_initial_ha_states(ha_client)
listener_task = asyncio.create_task(_ha_listener_loop(ha_client))
try:
yield
finally:
if listener_task is not None:
listener_task.cancel()
with suppress(asyncio.CancelledError):
await listener_task
app = FastAPI(
title="SillyHome Future API",
description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.10",
lifespan=lifespan,
)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "version": app.version}
runtime = stores.runtime()
return {
"status": "ok",
"version": app.version,
"ha": runtime.websocket_status,
}
@app.get("/v2/health")
def detailed_health() -> dict[str, object]:
runtime = stores.runtime()
control = stores.control()
return {
"api": "ok",
"version": app.version,
"websocket": runtime.websocket_status,
"store": "ok",
"global_enabled": control.global_enabled,
"entities": len(runtime.entities),
"audit_events": len(runtime.audit),
}
@app.get("/", response_class=HTMLResponse)
def dashboard() -> str:
return _dashboard_html()
@app.get("/v2/dashboard")
def dashboard_data() -> dict[str, object]:
runtime = stores.runtime()
learning = stores.learning()
control = stores.control()
latest_audit = runtime.audit[-20:]
actuator_entities = [
entity
for entity in runtime.entities.values()
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}
]
return {
"websocket_status": runtime.websocket_status,
"entity_count": len(runtime.entities),
"actuator_count": len(actuator_entities),
"global_enabled": control.global_enabled,
"learning_profiles": len(learning.profiles),
"control_profiles": len(control.profiles),
"rooms": list(learning.rooms.values()),
"scenes": list(learning.scenes.values()),
"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",
],
"next_to_expand": [
"automations-proposals",
"history-analysis",
"model-training",
"weight-overrides",
"job-queue",
],
}
@app.post("/v2/events/state", response_model=list[AuditEvent])
def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
return event_core.process_state_event(event)
return event_core.process_state_event(event, execute=_execute_ha_decision)
@app.get("/v2/runtime", response_model=RuntimeState)
@@ -43,6 +206,35 @@ def get_runtime() -> RuntimeState:
return stores.runtime()
@app.get("/v2/entities", response_model=list[EntityState])
def list_entities(domain: str | None = None, q: str | None = None) -> list[EntityState]:
entities = list(stores.runtime().entities.values())
if domain:
wanted = {item.strip() for item in domain.split(",") if item.strip()}
entities = [entity for entity in entities if entity.domain in wanted]
if q:
needle = q.casefold()
entities = [
entity
for entity in entities
if needle in entity.entity_id.casefold()
or (entity.friendly_name is not None and needle in entity.friendly_name.casefold())
]
return sorted(entities, key=lambda entity: entity.entity_id)[:500]
@app.get("/v2/entities/groups")
def entity_groups() -> dict[str, list[EntityState]]:
grouped: dict[str, list[EntityState]] = {}
for entity in stores.runtime().entities.values():
key = entity.area_name or entity.domain
grouped.setdefault(key, []).append(entity)
return {
key: sorted(values, key=lambda entity: entity.entity_id)[:250]
for key, values in sorted(grouped.items())
}
@app.get("/v2/learning", response_model=LearningState)
def get_learning() -> LearningState:
return stores.learning()
@@ -58,6 +250,57 @@ def get_control() -> ControlState:
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)
def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProfile:
state = stores.control()
@@ -66,6 +309,222 @@ def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProf
return profile
@app.post("/v2/control/{actuator_entity_id}/stage", response_model=ControlProfile)
def update_control_stage(
actuator_entity_id: str,
update: ControlStageUpdate,
) -> ControlProfile:
state = stores.control()
profile = state.profiles.get(
actuator_entity_id,
ControlProfile(actuator_entity_id=actuator_entity_id),
)
requested_stage = update.stage
if requested_stage is SafetyStage.ACTIVE and not profile.active_ready:
requested_stage = SafetyStage.DRY_RUN
updated = profile.model_copy(
update={
"stage": requested_stage,
"min_confidence": update.min_confidence,
"manual_block": update.manual_block,
"cooldown_seconds": update.cooldown_seconds,
"handoff_mode": handoff.classify(profile),
}
)
state.profiles[actuator_entity_id] = updated
stores.save_control(state)
_append_audit(
"safety",
actuator_entity_id,
f"Stage gesetzt auf {updated.stage}."
if updated.stage == update.stage
else f"Active blockiert: {updated.active_readiness_reason}",
)
return updated
@app.get("/v2/control/{actuator_entity_id}/readiness", response_model=ActiveReadiness)
def active_readiness(actuator_entity_id: str) -> ActiveReadiness:
profile = stores.control().profiles.get(
actuator_entity_id,
ControlProfile(actuator_entity_id=actuator_entity_id),
)
return ActiveReadiness(
actuator_entity_id=actuator_entity_id,
ready=profile.active_ready,
reason=profile.active_readiness_reason,
dry_run_events=profile.dry_run_events,
dry_run_successes=profile.dry_run_successes,
dry_run_failures=profile.dry_run_failures,
)
@app.post("/v2/learning/{actuator_entity_id}/patterns", response_model=LearningProfile)
def create_learning_pattern(
actuator_entity_id: str,
pattern: PatternCreateRequest,
) -> LearningProfile:
state = stores.learning()
profile = state.profiles.get(
actuator_entity_id,
LearningProfile(actuator_entity_id=actuator_entity_id),
)
updated = profile.model_copy(
update={
"patterns": [
*profile.patterns,
BehaviorPatternV2(
actuator_entity_id=actuator_entity_id,
target_state=pattern.target_state,
trigger_entity_id=pattern.trigger_entity_id,
trigger_state=pattern.trigger_state,
support=pattern.support,
confidence=pattern.confidence,
source=pattern.source,
),
],
"model_version": "dashboard-v1",
}
)
state.profiles[actuator_entity_id] = updated
stores.save_learning(state)
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/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)
def assume_control(actuator_entity_id: str) -> ControlProfile:
state = stores.control()
@@ -110,3 +569,375 @@ def export_backup() -> BackupBundle:
def restore_backup(bundle: BackupBundle) -> dict[str, str]:
stores.restore_backup(bundle)
return {"status": "restored"}
def _ha_client_from_env() -> FutureHaClient | None:
token = os.getenv("SUPERVISOR_TOKEN") or os.getenv("SILLYHOME_FUTURE_HA_TOKEN")
if not token:
return None
core_url = os.getenv("SILLYHOME_FUTURE_HA_CORE_URL", "http://supervisor/core")
websocket_url = os.getenv("SILLYHOME_FUTURE_HA_WS_URL")
return FutureHaClient(
HaClientConfig(core_url=core_url, token=token, websocket_url=websocket_url)
)
def _load_initial_ha_states(client: FutureHaClient) -> None:
runtime = stores.runtime()
try:
for entity in client.read_states():
runtime.entities[entity.entity_id] = entity
runtime.websocket_status = "initial_state_loaded"
except Exception as exc:
runtime.websocket_status = f"initial_state_error: {exc}"
stores.save_runtime(runtime)
async def _ha_listener_loop(client: FutureHaClient) -> None:
routed_triggers: set[str] = set()
next_route_refresh = 0.0
pending_unrouted: list[StateEvent] = []
last_unrouted_flush = 0.0
while True:
await asyncio.to_thread(_set_websocket_status, "connecting")
try:
async for event in client.listen_state_events():
loop_time = asyncio.get_running_loop().time()
if loop_time >= next_route_refresh:
routed_triggers = await asyncio.to_thread(_routed_trigger_ids)
next_route_refresh = loop_time + 5
await asyncio.to_thread(_set_websocket_status, "connected")
if event.entity_id in routed_triggers:
if pending_unrouted:
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
pending_unrouted = []
await asyncio.to_thread(
event_core.process_state_event,
event,
execute=_execute_ha_decision,
)
else:
pending_unrouted.append(event)
if len(pending_unrouted) >= 100 or loop_time - last_unrouted_flush >= 2:
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
pending_unrouted = []
last_unrouted_flush = loop_time
await asyncio.sleep(0)
except asyncio.CancelledError:
raise
except Exception as exc:
await asyncio.to_thread(_set_websocket_status, f"reconnecting: {exc}")
await asyncio.sleep(2)
def _set_websocket_status(status: str) -> None:
runtime = stores.runtime()
if runtime.websocket_status == status:
return
runtime.websocket_status = status
stores.save_runtime(runtime)
def _routed_trigger_ids() -> set[str]:
result: set[str] = set()
for profile in stores.learning().profiles.values():
for pattern in profile.patterns:
if pattern.trigger_entity_id is not None:
result.add(pattern.trigger_entity_id)
return result
def _merge_unrouted_events(events: list[StateEvent]) -> None:
if not events:
return
runtime = stores.runtime()
for event in events:
runtime.entities[event.entity_id] = EntityState(
entity_id=event.entity_id,
domain=event.entity_id.split(".", 1)[0],
state=event.new_state,
changed_at=event.changed_at,
area_name=event.attributes.get("area_name"),
device_id=event.attributes.get("device_id"),
friendly_name=event.attributes.get("friendly_name"),
)
stores.save_runtime(runtime)
def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
runtime = stores.runtime()
event = AuditEvent(
event_id=f"{kind}-{len(runtime.audit) + 1}",
kind=kind,
entity_id=entity_id,
message=message,
)
runtime.audit = [*runtime.audit, event][-200:]
stores.save_runtime(runtime)
def _execute_ha_decision(decision: object) -> bool:
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
return False
actuator_entity_id = str(decision.actuator_entity_id)
target_state = getattr(decision, "target_state", None)
service = service_for_state(actuator_entity_id.split(".", 1)[0], target_state)
if service is None:
return False
domain, service_name = service
ha_client.call_service(domain, service_name, {"entity_id": actuator_entity_id})
return True
def _dashboard_html() -> str:
return """<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SillyHome Future</title>
<style>
:root { color-scheme: dark; --bg: #101418; --panel: #171d23; --line: #2c3640; --text: #eef3f6; --muted: #9fb0bd; --accent: #42d392; --warn: #f3c969; }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--text); }
main { max-width: 1180px; margin: 0 auto; padding: 20px; }
h1 { font-size: 28px; margin: 0 0 18px; }
h2 { font-size: 18px; margin: 24px 0 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
.split { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.2fr); gap: 14px; align-items: start; }
.card { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: var(--panel); }
.label { color: #9fb0bd; font-size: 13px; }
.value { font-size: 24px; margin-top: 4px; }
label { display: block; color: var(--muted); font-size: 13px; margin: 10px 0 5px; }
input, select, button { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #0c1014; color: var(--text); font: inherit; padding: 10px; }
button { cursor: pointer; background: #20303a; }
button:hover { border-color: var(--accent); }
.row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.stages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 8px; }
.stages button.active { border-color: var(--accent); color: var(--accent); }
.primary { background: #17402c; border-color: #256f4a; }
.status { min-height: 24px; color: var(--warn); margin-top: 10px; }
pre { max-height: 360px; overflow: auto; white-space: pre-wrap; word-break: break-word; background: #0c1014; padding: 14px; border-radius: 8px; }
@media (max-width: 800px) { .split, .row, .stages { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<h1>SillyHome Future</h1>
<section class="grid" id="metrics"></section>
<section class="split">
<div class="card">
<h2>Steuerung</h2>
<label for="entitySearch">Suche</label>
<input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off">
<label for="actuator">Aktor</label>
<select id="actuator"></select>
<div class="row">
<div>
<label for="minConfidence">Mindest-Sicherheit</label>
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
</div>
<div>
<label for="cooldown">Sperrzeit in Sekunden</label>
<input id="cooldown" type="number" min="0" step="30" value="900">
</div>
</div>
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
<div class="stages" id="stages"></div>
<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>
<div class="card">
<h2>Lernmuster</h2>
<div class="row">
<div>
<label for="trigger">Trigger</label>
<select id="trigger"></select>
</div>
<div>
<label for="triggerState">Trigger-Zustand</label>
<input id="triggerState" placeholder="on, off, open...">
</div>
</div>
<div class="row">
<div>
<label for="targetState">Zielzustand</label>
<input id="targetState" value="on">
</div>
<div>
<label for="patternConfidence">Sicherheit</label>
<input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9">
</div>
</div>
<button class="primary" id="addPattern">Lernmuster anlegen</button>
<button id="simulatePattern">Lernmuster simulieren</button>
<div class="status" id="patternStatus"></div>
<h2>Aktuelles Profil</h2>
<pre id="profile">Lade...</pre>
</div>
</section>
<h2>Prüfprotokoll</h2>
<pre id="audit">Lade...</pre>
</main>
<script>
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' };
function optionText(entity) {
const name = entity.friendly_name ? ` - ${entity.friendly_name}` : '';
const current = entity.state == null ? '' : ` (${entity.state})`;
return `${entity.entity_id}${current}${name}`;
}
function setStatus(id, text) {
document.getElementById(id).textContent = text;
if (text) setTimeout(() => document.getElementById(id).textContent = '', 4000);
}
function renderStages() {
document.getElementById('stages').innerHTML = stages.map(stage =>
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stageLabels[stage]}</button>`
).join('');
document.querySelectorAll('[data-stage]').forEach(button => {
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
});
}
function renderEntities() {
const actuator = document.getElementById('actuator');
const trigger = document.getElementById('trigger');
const query = document.getElementById('entitySearch').value.toLowerCase();
const shown = state.entities.filter(entity =>
!query || optionText(entity).toLowerCase().includes(query)
);
const actuators = shown.filter(entity => ['light', 'switch', 'fan', 'cover', 'humidifier'].includes(entity.domain));
actuator.innerHTML = actuators.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
trigger.innerHTML = shown.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
renderProfile();
}
function renderProfile() {
const id = document.getElementById('actuator').value;
const profile = {
control: state.control.profiles?.[id] || null,
learning: state.learning.profiles?.[id] || null,
};
if (profile.control) {
state.selectedStage = profile.control.stage;
document.getElementById('minConfidence').value = profile.control.min_confidence;
document.getElementById('cooldown').value = profile.control.cooldown_seconds;
document.getElementById('manualBlock').checked = profile.control.manual_block;
renderStages();
}
document.getElementById('profile').textContent = JSON.stringify(profile, null, 2);
}
async function loadDashboard() {
const [dashResponse, entitiesResponse, controlResponse, learningResponse] = await Promise.all([
fetch('/v2/dashboard'),
fetch('/v2/entities?domain=light,switch,fan,cover,humidifier,binary_sensor,sensor'),
fetch('/v2/control'),
fetch('/v2/learning'),
]);
const data = await dashResponse.json();
state.entities = await entitiesResponse.json();
state.control = await controlResponse.json();
state.learning = await learningResponse.json();
const metrics = [
['WebSocket', data.websocket_status],
['Entitäten', data.entity_count],
['Aktoren', data.actuator_count],
['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
['Lernen', data.learning_profiles],
['Steuerung', data.control_profiles],
['Räume', data.rooms.length],
['Szenen', data.scenes.length],
];
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
).join('');
renderEntities();
await loadReadiness();
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2);
}
async function loadReadiness() {
const id = document.getElementById('actuator').value;
if (!id) return;
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
const data = await response.json();
document.getElementById('readiness').textContent =
`${data.ready ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
}
document.getElementById('entitySearch').oninput = renderEntities;
document.getElementById('actuator').onchange = () => { renderProfile(); loadReadiness(); };
document.getElementById('saveControl').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stage: state.selectedStage,
min_confidence: Number(document.getElementById('minConfidence').value),
manual_block: document.getElementById('manualBlock').checked,
cooldown_seconds: Number(document.getElementById('cooldown').value),
}),
});
if (!response.ok) throw new Error(await response.text());
setStatus('controlStatus', 'Gespeichert');
await loadDashboard();
await loadReadiness();
};
document.getElementById('addPattern').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch(`/v2/learning/${encodeURIComponent(id)}/patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trigger_entity_id: document.getElementById('trigger').value,
trigger_state: document.getElementById('triggerState').value || null,
target_state: document.getElementById('targetState').value,
confidence: Number(document.getElementById('patternConfidence').value),
}),
});
if (!response.ok) throw new Error(await response.text());
setStatus('patternStatus', 'Pattern angelegt');
await loadDashboard();
};
document.getElementById('simulatePattern').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch('/v2/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actuator_entity_id: id,
trigger_entity_id: document.getElementById('trigger').value,
trigger_state: document.getElementById('triggerState').value || null,
}),
});
document.getElementById('profile').textContent = JSON.stringify(await response.json(), null, 2);
};
document.getElementById('globalToggle').onclick = async () => {
const dash = await (await fetch('/v2/dashboard')).json();
await fetch('/v2/control/global', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !dash.global_enabled }),
});
await loadDashboard();
};
renderStages();
loadDashboard();
setInterval(loadDashboard, 5000);
</script>
</body>
</html>"""

View File

@@ -4,13 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-future"
version = "2.0.0-alpha.2"
version = "2.0.0-alpha.10"
description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"httpx>=0.27",
"pydantic>=2.8",
"uvicorn[standard]>=0.30",
"websockets>=12.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"))
assert config["slug"] == "sillyhome_future"
assert config["version"] == "2.0.0-alpha.2"
assert config["version"] == "2.0.0-alpha.10"
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:

View File

@@ -2,20 +2,110 @@ from __future__ import annotations
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]
monkeypatch.setenv("SILLYHOME_FUTURE_STORE", str(tmp_path))
client = TestClient(app)
test_stores = FutureStores(tmp_path)
monkeypatch.setattr(main, "stores", test_stores)
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
client = TestClient(main.app)
health = client.get("/health")
backup = client.get("/v2/backup/export")
restore = client.post("/v2/backup/restore", json=backup.json())
assert stores is not None
assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.2"
assert health.json()["version"] == "2.0.0-alpha.10"
assert backup.status_code == 200
assert restore.status_code == 200
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")
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 "Feedback" in parity.json()["implemented"]
assert anomalies.status_code == 200
assert dashboard.status_code == 200
assert dashboard.json()["actuator_count"] == 1

View File

@@ -7,6 +7,7 @@ from app.core.handoff import HandoffMatrix
from app.core.models import (
BehaviorPatternV2,
ControlProfile,
Decision,
EntityState,
HandoffMode,
LearningProfile,
@@ -91,3 +92,120 @@ def test_handoff_matrix_detects_conflict_and_rollback() -> None:
rolled_back = matrix.rollback(controlled)
assert rolled_back.handoff_mode is HandoffMode.ROLLBACK
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