Files
sillyhome-next-dev/app/main.py

258 lines
11 KiB
Python

import asyncio
import json
import logging
from contextlib import asynccontextmanager, suppress
from collections.abc import AsyncIterator
from pathlib import Path
from typing import cast
import websockets
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router
from app.api.v1.entities import router as entities_router
from app.behavior.engine import BehaviorEngine
from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes
logger = logging.getLogger(__name__)
class _WsStatus:
"""Einfacher Status-Tracker für den WebSocket-Listener.
Wird als Attribut an app.state gehängt und enthält:
- status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
- error: str | None (Fehlermeldung bei status=error)
"""
def __init__(self) -> None:
self.status: str = "disconnected"
self.error: str | None = None
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings
client: HaClient | None = None
reconcile_task: asyncio.Task[None] | None = None
event_listener_task: asyncio.Task[None] | None = None
fallback_task: asyncio.Task[None] | None = None
app.state.registry = ModelRegistry(settings.model_store)
app.state.actuator_store = ActuatorStore(settings.actuator_store)
if hasattr(app.state, "ha_reader"):
del app.state.ha_reader
if hasattr(app.state, "actuator_service"):
del app.state.actuator_service
if hasattr(app.state, "behavior_engine"):
del app.state.behavior_engine
if settings.ha_configured:
client = HaClient(
settings=HaClientSettings(
url=cast(str, settings.ha_url),
token=cast(str, settings.ha_token),
)
)
app.state.ha_reader = HaReader(client=client)
app.state.actuator_service = ActuatorReconciliationService(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
registry=app.state.registry,
settings=settings,
)
app.state.behavior_engine = BehaviorEngine(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
settings=settings,
)
app.state.ws_status = _WsStatus()
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
await asyncio.to_thread(app.state.behavior_engine.train_all)
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
fallback_task = asyncio.create_task(_fallback_prediction(app))
try:
yield
finally:
if reconcile_task is not None:
reconcile_task.cancel()
with suppress(asyncio.CancelledError):
await reconcile_task
if event_listener_task is not None:
event_listener_task.cancel()
with suppress(asyncio.CancelledError):
await event_listener_task
if fallback_task is not None:
fallback_task.cancel()
with suppress(asyncio.CancelledError):
await fallback_task
if client is not None:
client.close()
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.5",
lifespan=lifespan,
)
app.state.settings = load_settings()
register_exception_handlers(app)
app.include_router(entities_router)
app.include_router(actuators_router)
init_ml_routes(app, model_store=app.state.settings.model_store)
STATIC_DIR = Path(__file__).with_name("static")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/health/websocket")
def websocket_health() -> dict[str, object]:
"""Gibt den aktuellen Status des WebSocket-Listeners zurück.
Antwort:
- status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
- error: str | None (nur bei status=error)
"""
ws_status = getattr(app.state, "ws_status", None)
if ws_status is None:
return {"status": "unavailable", "error": "WebSocket-Listener nicht initialisiert"}
return {"status": ws_status.status, "error": ws_status.error}
@app.get("/")
def root() -> FileResponse:
return FileResponse(
STATIC_DIR / "index.html",
headers={"Cache-Control": "no-store, max-age=0"},
)
async def _periodic_reconciliation(app: FastAPI) -> None:
while True:
await asyncio.sleep(app.state.settings.reconcile_interval_seconds)
service = getattr(app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
continue
await asyncio.to_thread(service.reconcile_all, "scheduled")
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all)
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
"""Hört auf Home-Assistant-Websocket-Events und löst sofortige Vorhersagen aus."""
settings = app.state.settings
engine = app.state.behavior_engine
store = app.state.actuator_store
if not isinstance(engine, BehaviorEngine) or not isinstance(store, ActuatorStore):
logger.error("BehaviorEngine oder ActuatorStore nicht initialisiert")
ws_status = getattr(app.state, "ws_status", None)
if ws_status is not None:
ws_status.status = "error"
ws_status.error = "BehaviorEngine oder ActuatorStore nicht initialisiert"
return
ha_url = str(settings.ha_url).rstrip("/")
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token)
ws_status = getattr(app.state, "ws_status", None)
while True:
if ws_status is not None:
ws_status.status = "connecting"
try:
async with websockets.connect(ws_url) as websocket:
auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required":
logger.error("Unerwartete WebSocket-Authentifizierungsaufforderung")
if ws_status is not None:
ws_status.status = "error"
ws_status.error = "Unerwartete Authentifizierungsaufforderung"
await asyncio.sleep(5)
continue
await websocket.send(json.dumps({"type": "auth", "access_token": auth_token}))
auth_result_msg = await websocket.recv()
auth_result_data = json.loads(auth_result_msg)
if auth_result_data.get("type") != "auth_ok":
logger.error("WebSocket-Authentifizierung fehlgeschlagen")
if ws_status is not None:
ws_status.status = "error"
ws_status.error = "Authentifizierung fehlgeschlagen"
await asyncio.sleep(5)
continue
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
if ws_status is not None:
ws_status.status = "connected"
ws_status.error = None
# Auf alle State Changes subscriben
subscribe_msg = {
"id": 1,
"type": "subscribe_events",
"event_type": "state_changed"
}
await websocket.send(json.dumps(subscribe_msg))
while True:
message = await websocket.recv()
try:
data = json.loads(message)
if data.get("type") != "event":
continue
event = data.get("event", {})
if event.get("event_type") != "state_changed":
continue
entity_id = event.get("entity_id")
if not entity_id:
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread(engine.handle_state_change, entity_id, event.get("new_state"))
except json.JSONDecodeError:
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
except Exception as exc:
logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
except (websockets.exceptions.ConnectionClosed, OSError) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
if ws_status is not None:
ws_status.status = "reconnecting"
ws_status.error = str(exc)
await asyncio.sleep(5)
except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None:
ws_status.status = "error"
ws_status.error = str(exc)
await asyncio.sleep(5)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
async def _fallback_prediction(app: FastAPI) -> None:
"""Periodische Vorhersage als Fallback, wenn WebSocket-Listener nicht verbunden ist.
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
"""
while True:
await asyncio.sleep(app.state.settings.prediction_interval_seconds)
# Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None)
if ws_status is None or ws_status.status != "connected":
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
logger.debug(
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
ws_status.status if ws_status else "unavailable",
)
await asyncio.to_thread(engine.evaluate_all)