467 lines
19 KiB
Python
467 lines
19 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager, suppress
|
|
from collections.abc import AsyncIterator
|
|
from datetime import datetime, timezone
|
|
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.cache_db import DashboardCache
|
|
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.discovery import discover_entities
|
|
from app.ha.models import HaEntitySummary
|
|
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
|
|
startup_task: asyncio.Task[None] | None = None
|
|
reconcile_task: asyncio.Task[None] | None = None
|
|
event_listener_task: asyncio.Task[None] | None = None
|
|
fallback_task: asyncio.Task[None] | None = None
|
|
cache_refresh_task: asyncio.Task[None] | None = None
|
|
app.state.registry = ModelRegistry(settings.model_store)
|
|
app.state.actuator_store = ActuatorStore(settings.actuator_store)
|
|
app.state.dashboard_cache = DashboardCache(
|
|
Path(settings.actuator_store).resolve() / "dashboard_cache.sqlite3"
|
|
)
|
|
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),
|
|
timeout_seconds=settings.ha_timeout_seconds,
|
|
)
|
|
)
|
|
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()
|
|
startup_task = asyncio.create_task(_startup_reconciliation(app))
|
|
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))
|
|
cache_refresh_task = asyncio.create_task(_periodic_dashboard_cache_refresh(app))
|
|
try:
|
|
yield
|
|
finally:
|
|
if startup_task is not None:
|
|
startup_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await startup_task
|
|
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 cache_refresh_task is not None:
|
|
cache_refresh_task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await cache_refresh_task
|
|
if client is not None:
|
|
client.close()
|
|
|
|
|
|
app = FastAPI(
|
|
title="SillyHome Next API",
|
|
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
|
version="1.7.7",
|
|
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
|
|
try:
|
|
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)
|
|
await asyncio.to_thread(engine.evaluate_all)
|
|
await asyncio.to_thread(engine.refresh_planning_insights)
|
|
except Exception:
|
|
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
|
|
|
|
|
|
async def _periodic_dashboard_cache_refresh(app: FastAPI) -> None:
|
|
await asyncio.sleep(2)
|
|
while True:
|
|
await _refresh_dashboard_cache(app, trigger="scheduled")
|
|
await asyncio.sleep(app.state.settings.dashboard_cache_refresh_seconds)
|
|
|
|
|
|
async def _refresh_dashboard_cache(app: FastAPI, *, trigger: str) -> None:
|
|
ha_reader = getattr(app.state, "ha_reader", None)
|
|
cache = getattr(app.state, "dashboard_cache", None)
|
|
if not isinstance(ha_reader, HaReader) or not isinstance(cache, DashboardCache):
|
|
return
|
|
try:
|
|
entities = await asyncio.to_thread(ha_reader.read_entities)
|
|
groups = _discovery_group_payload(list(entities))
|
|
await asyncio.to_thread(
|
|
cache.save_entities_payload,
|
|
entities=list(entities),
|
|
discovery_groups=groups,
|
|
)
|
|
logger.info("Dashboard-Cache aktualisiert (%s): %d Entities", trigger, len(entities))
|
|
except Exception as exc:
|
|
logger.warning("Dashboard-Cache konnte nicht aktualisiert werden (%s): %s", trigger, exc)
|
|
|
|
|
|
def _discovery_group_payload(entities: list[HaEntitySummary]) -> list[dict[str, object]]:
|
|
group_counts: dict[tuple[str, str], int] = {}
|
|
for entity in discover_entities(entities):
|
|
key = (entity.category, entity.role.value)
|
|
group_counts[key] = group_counts.get(key, 0) + 1
|
|
return [
|
|
{"category": category, "role": role, "count": count}
|
|
for (category, role), count in sorted(group_counts.items())
|
|
]
|
|
|
|
|
|
async def _startup_reconciliation(app: FastAPI) -> None:
|
|
delay_seconds = 5
|
|
while True:
|
|
service = getattr(app.state, "actuator_service", None)
|
|
engine = getattr(app.state, "behavior_engine", None)
|
|
if not isinstance(service, ActuatorReconciliationService) or not isinstance(
|
|
engine,
|
|
BehaviorEngine,
|
|
):
|
|
return
|
|
try:
|
|
await asyncio.to_thread(service.reconcile_all, "startup")
|
|
await asyncio.to_thread(engine.train_all)
|
|
await asyncio.to_thread(engine.evaluate_all)
|
|
await asyncio.to_thread(engine.refresh_planning_insights)
|
|
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
|
|
return
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Startup-Reconciliation verschoben: %s. Neuer Versuch in %ss.",
|
|
exc,
|
|
delay_seconds,
|
|
)
|
|
await asyncio.sleep(delay_seconds)
|
|
delay_seconds = min(delay_seconds * 2, 60)
|
|
|
|
|
|
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
|
|
ha_reader = getattr(app.state, "ha_reader", None)
|
|
store = app.state.actuator_store
|
|
if (
|
|
not isinstance(engine, BehaviorEngine)
|
|
or not isinstance(store, ActuatorStore)
|
|
or not isinstance(ha_reader, HaReader)
|
|
):
|
|
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
|
|
state_cache: dict[str, HaEntitySummary] = {}
|
|
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)
|
|
reconnect_delay = 1.0
|
|
relevant_entity_ids: set[str] = set()
|
|
relevant_loaded_at = 0.0
|
|
while True:
|
|
if ws_status is not None:
|
|
ws_status.status = "connecting"
|
|
try:
|
|
async with websockets.connect(ws_url, ping_interval=None) 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")
|
|
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
|
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
|
relevant_loaded_at = asyncio.get_running_loop().time()
|
|
reconnect_delay = 1.0
|
|
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
|
|
event_data = event.get("data", {})
|
|
if not isinstance(event_data, dict):
|
|
logger.warning("State-Changed-Event ohne gültige Daten empfangen")
|
|
continue
|
|
entity_id = event_data.get("entity_id")
|
|
if not entity_id:
|
|
continue
|
|
loop_time = asyncio.get_running_loop().time()
|
|
if loop_time - relevant_loaded_at >= 10:
|
|
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
|
relevant_loaded_at = loop_time
|
|
if entity_id not in relevant_entity_ids:
|
|
continue
|
|
new_state = event_data.get("new_state")
|
|
_update_ha_state_cache(state_cache, entity_id, new_state)
|
|
# 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,
|
|
new_state,
|
|
current_entities=list(state_cache.values()),
|
|
)
|
|
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,
|
|
websockets.exceptions.InvalidStatus,
|
|
OSError,
|
|
) as exc:
|
|
delay = reconnect_delay
|
|
logger.warning(
|
|
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
|
|
exc,
|
|
delay,
|
|
)
|
|
if ws_status is not None:
|
|
ws_status.status = "reconnecting"
|
|
ws_status.error = str(exc)
|
|
await asyncio.sleep(delay)
|
|
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
|
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(reconnect_delay)
|
|
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
|
|
|
|
|
# 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:
|
|
ws_status = getattr(app.state, "ws_status", None)
|
|
websocket_connected = ws_status is not None and ws_status.status == "connected"
|
|
await asyncio.sleep(
|
|
app.state.settings.prediction_interval_seconds
|
|
if websocket_connected
|
|
else max(30, 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",
|
|
)
|
|
try:
|
|
await asyncio.to_thread(engine.evaluate_all)
|
|
except Exception:
|
|
logger.exception("Fallback-Vorhersage fehlgeschlagen.")
|
|
|
|
|
|
def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]:
|
|
return {entity.entity_id: entity for entity in reader.read_entities()}
|
|
|
|
|
|
def _update_ha_state_cache(
|
|
state_cache: dict[str, HaEntitySummary],
|
|
entity_id: str,
|
|
new_state: object,
|
|
) -> None:
|
|
if not isinstance(new_state, dict):
|
|
state_cache.pop(entity_id, None)
|
|
return
|
|
state_cache[entity_id] = _ha_entity_from_event(
|
|
entity_id,
|
|
new_state,
|
|
state_cache.get(entity_id),
|
|
)
|
|
|
|
|
|
def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
|
|
result: set[str] = set()
|
|
for record in store.list():
|
|
result.add(record.actuator_entity_id)
|
|
if record.assignment.selected_numeric_entity_id:
|
|
result.add(record.assignment.selected_numeric_entity_id)
|
|
result.update(record.assignment.selected_context_entity_ids)
|
|
return result
|
|
|
|
|
|
def _ha_entity_from_event(
|
|
entity_id: str,
|
|
new_state: dict[str, object],
|
|
previous: HaEntitySummary | None,
|
|
) -> HaEntitySummary:
|
|
attributes = new_state.get("attributes")
|
|
attr = attributes if isinstance(attributes, dict) else {}
|
|
state_class = _optional_event_string(attr.get("state_class"))
|
|
device_class = _optional_event_string(attr.get("device_class"))
|
|
unit_of_measurement = _optional_event_string(attr.get("unit_of_measurement"))
|
|
friendly_name = _optional_event_string(attr.get("friendly_name"))
|
|
return HaEntitySummary(
|
|
entity_id=entity_id,
|
|
domain=entity_id.split(".", 1)[0],
|
|
state=_optional_event_string(new_state.get("state")),
|
|
last_changed=_event_datetime(new_state.get("last_changed"))
|
|
or _event_datetime(new_state.get("last_updated")),
|
|
state_class=state_class or (previous.state_class if previous else None),
|
|
device_class=device_class or (previous.device_class if previous else None),
|
|
unit_of_measurement=unit_of_measurement
|
|
or (previous.unit_of_measurement if previous else None),
|
|
friendly_name=friendly_name or (previous.friendly_name if previous else None),
|
|
area_id=previous.area_id if previous else None,
|
|
area_name=previous.area_name if previous else None,
|
|
device_id=previous.device_id if previous else None,
|
|
device_name=previous.device_name if previous else None,
|
|
)
|
|
|
|
|
|
def _optional_event_string(value: object) -> str | None:
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def _event_datetime(value: object) -> datetime | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
return parsed.replace(tzinfo=timezone.utc)
|
|
return parsed
|