Add native HA integration and dashboard
This commit is contained in:
171
app/main.py
171
app/main.py
@@ -1,10 +1,15 @@
|
||||
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 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,
|
||||
@@ -18,24 +23,72 @@ from app.core.models import (
|
||||
)
|
||||
from app.core.stores import FutureStores
|
||||
|
||||
app = FastAPI(
|
||||
title="SillyHome Future API",
|
||||
description="SillyHome v2 event-core side project.",
|
||||
version="2.0.0-alpha.3",
|
||||
)
|
||||
stores = FutureStores(os.getenv("SILLYHOME_FUTURE_STORE", ".future_store"))
|
||||
event_core = EventCore(stores)
|
||||
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.4",
|
||||
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("/", 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])
|
||||
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)
|
||||
@@ -110,3 +163,107 @@ 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:
|
||||
while True:
|
||||
runtime = stores.runtime()
|
||||
runtime.websocket_status = "connecting"
|
||||
stores.save_runtime(runtime)
|
||||
try:
|
||||
async for event in client.listen_state_events():
|
||||
runtime = stores.runtime()
|
||||
runtime.websocket_status = "connected"
|
||||
stores.save_runtime(runtime)
|
||||
event_core.process_state_event(event, execute=_execute_ha_decision)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
runtime = stores.runtime()
|
||||
runtime.websocket_status = f"reconnecting: {exc}"
|
||||
stores.save_runtime(runtime)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
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>"""
|
||||
|
||||
Reference in New Issue
Block a user