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__) @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 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, ) 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)) 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 client is not None: client.close() app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", version="0.7.0", 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("/") 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") 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) while True: try: async with websockets.connect(ws_url, extra_headers={ "Authorization": f"Bearer {auth_token}" }) as websocket: logger.info("WebSocket-Verbindung zu Home Assistant hergestellt") # Authentifizierung durchführen auth_msg = await websocket.recv() auth_data = json.loads(auth_msg) if auth_data.get("type") != "auth_ok": logger.error("WebSocket-Authentifizierung fehlgeschlagen") await asyncio.sleep(5) continue # Auf alle State Changes subscriben subscribe_msg = { "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) await asyncio.sleep(5) except Exception as exc: logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc) await asyncio.sleep(5) # Fallback: periodische Vorhersage falls Event-Stream ausfällt async def _fallback_prediction(app: FastAPI) -> None: while True: await asyncio.sleep(app.state.settings.prediction_interval_seconds) engine = getattr(app.state, "behavior_engine", None) if isinstance(engine, BehaviorEngine): await asyncio.to_thread(engine.evaluate_all)