258 lines
11 KiB
Python
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.2",
|
|
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)
|