4 Commits

Author SHA1 Message Date
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
10 changed files with 442 additions and 18 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.6
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,5 +1,5 @@
name: SillyHome Future name: SillyHome Future
version: "2.0.0-alpha.2" version: "2.0.0-alpha.6"
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
@@ -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

@@ -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,8 @@ class EventCore:
learning=learning_profile, learning=learning_profile,
control=control_profile, control=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 +96,17 @@ 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)})

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

@@ -1,10 +1,15 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from app.core.event_core import EventCore from app.core.event_core import EventCore
from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
from app.core.handoff import HandoffMatrix from app.core.handoff import HandoffMatrix
from app.core.models import ( from app.core.models import (
AuditEvent, AuditEvent,
@@ -18,24 +23,72 @@ from app.core.models import (
) )
from app.core.stores import FutureStores 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")) stores = FutureStores(os.getenv("SILLYHOME_FUTURE_STORE", ".future_store"))
event_core = EventCore(stores) event_core = EventCore(stores)
handoff = HandoffMatrix() handoff = HandoffMatrix()
ha_client: FutureHaClient | None = None
@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.6",
lifespan=lifespan,
)
@app.get("/health") @app.get("/health")
def health() -> dict[str, str]: 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("/", 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:]
return {
"websocket_status": runtime.websocket_status,
"entity_count": len(runtime.entities),
"learning_profiles": len(learning.profiles),
"control_profiles": len(control.profiles),
"rooms": list(learning.rooms.values()),
"scenes": list(learning.scenes.values()),
"audit": latest_audit,
}
@app.post("/v2/events/state", response_model=list[AuditEvent]) @app.post("/v2/events/state", response_model=list[AuditEvent])
def ingest_state_event(event: StateEvent) -> list[AuditEvent]: def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
return event_core.process_state_event(event) return event_core.process_state_event(event, execute=_execute_ha_decision)
@app.get("/v2/runtime", response_model=RuntimeState) @app.get("/v2/runtime", response_model=RuntimeState)
@@ -110,3 +163,114 @@ def export_backup() -> BackupBundle:
def restore_backup(bundle: BackupBundle) -> dict[str, str]: def restore_backup(bundle: BackupBundle) -> dict[str, str]:
stores.restore_backup(bundle) stores.restore_backup(bundle)
return {"status": "restored"} 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:
while True:
await asyncio.to_thread(_set_websocket_status, "connecting")
try:
async for event in client.listen_state_events():
await asyncio.to_thread(_set_websocket_status, "connected")
await asyncio.to_thread(
event_core.process_state_event,
event,
execute=_execute_ha_decision,
)
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 _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>
body { font-family: system-ui, sans-serif; margin: 0; background: #101418; color: #eef3f6; }
main { max-width: 1080px; margin: 0 auto; padding: 24px; }
h1 { font-size: 28px; margin: 0 0 18px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.card { border: 1px solid #2c3640; border-radius: 8px; padding: 14px; background: #171d23; }
.label { color: #9fb0bd; font-size: 13px; }
.value { font-size: 24px; margin-top: 4px; }
pre { white-space: pre-wrap; word-break: break-word; background: #0c1014; padding: 14px; border-radius: 8px; }
</style>
</head>
<body>
<main>
<h1>SillyHome Future</h1>
<section class="grid" id="metrics"></section>
<h2>Audit</h2>
<pre id="audit">Lade...</pre>
</main>
<script>
async function loadDashboard() {
const response = await fetch('/v2/dashboard');
const data = await response.json();
const metrics = [
['WebSocket', data.websocket_status],
['Entities', data.entity_count],
['Learning', data.learning_profiles],
['Control', data.control_profiles],
['Rooms', data.rooms.length],
['Scenes', 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('');
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2);
}
loadDashboard();
setInterval(loadDashboard, 5000);
</script>
</body>
</html>"""

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.6"
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,7 +9,7 @@ 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.6"
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

View File

@@ -15,7 +15,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig
assert stores is not None 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.6"
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"}

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

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