Compare commits
5 Commits
feature/co
...
v0.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
| d87d3abc00 | |||
| 2ae5576b8f | |||
| 51d23e0a9a | |||
| c8f491ba1a | |||
| f9c7c27e00 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.2 - 2026-06-14
|
||||||
|
- Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA-
|
||||||
|
Installationen nicht mehr am Template-Ausgabe-Limit scheitern
|
||||||
|
- Nicht über die HA-Config-API exponierte Automationen werden leise übersprungen,
|
||||||
|
statt wiederholt Warnungen in die Logs zu schreiben
|
||||||
|
- Dashboard für mobile Nutzung optimiert: Sticky-Schnellnavigation, Karten statt
|
||||||
|
breiter Tabelle, größere Touch-Ziele und bessere Detail-/Menüführung
|
||||||
|
- WebSocket-Status ist direkt im Dashboard-Systemstatus sichtbar
|
||||||
|
|
||||||
|
## 0.7.1 - 2026-06-14
|
||||||
|
- Event-basierter Home-Assistant-WebSocket-Listener authentifiziert sich jetzt
|
||||||
|
mit dem echten HA-WebSocket-Protokoll (`auth_required` -> `auth` -> `auth_ok`)
|
||||||
|
- Kompatibilität mit aktuellen `websockets`-Versionen wiederhergestellt
|
||||||
|
- WebSocket-Healthcheck und Event-Listener-Tests laufen ohne zusätzliches
|
||||||
|
Async-Pytest-Plugin
|
||||||
|
- Add-on-Version angehoben, damit Home Assistant das aktualisierte Image baut
|
||||||
|
|
||||||
## 0.7.0 - 2026-06-14
|
## 0.7.0 - 2026-06-14
|
||||||
- Freie Eingabe von Home-Assistant-Entitätsnamen mit Vorschlagsliste
|
- Freie Eingabe von Home-Assistant-Entitätsnamen mit Vorschlagsliste
|
||||||
- Freigabestatus und Blockadegrund sind in Übersicht und Details immer sichtbar
|
- Freigabestatus und Blockadegrund sind in Übersicht und Details immer sichtbar
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "0.7.0"
|
version: "0.7.2"
|
||||||
slug: sillyhome_next
|
slug: sillyhome_next
|
||||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||||
|
|||||||
@@ -608,6 +608,35 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
return self._store.upsert(updated)
|
return self._store.upsert(updated)
|
||||||
|
|
||||||
|
def handle_state_change(self, entity_id: str, new_state: dict[str, object] | None) -> None:
|
||||||
|
"""Wird bei jedem HA-State-Change aufgerufen und löst sofortige Vorhersage aus.
|
||||||
|
|
||||||
|
- Wenn entity_id ein Aktor ist: evaluate() direkt.
|
||||||
|
- Wenn entity_id ein Kontext-Entity ist: alle betroffenen Aktoren evaluieren.
|
||||||
|
"""
|
||||||
|
# Aktor direkt evaluieren
|
||||||
|
for record in self._store.list():
|
||||||
|
if record.actuator_entity_id == entity_id:
|
||||||
|
try:
|
||||||
|
self.evaluate(record.actuator_entity_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
|
||||||
|
return
|
||||||
|
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
|
||||||
|
affected_actuators = [
|
||||||
|
record.actuator_entity_id
|
||||||
|
for record in self._store.list()
|
||||||
|
if (
|
||||||
|
record.assignment.selected_numeric_entity_id == entity_id
|
||||||
|
or entity_id in record.assignment.selected_context_entity_ids
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for actuator_entity_id in affected_actuators:
|
||||||
|
try:
|
||||||
|
self.evaluate(actuator_entity_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
|
||||||
|
|
||||||
|
|
||||||
def predict_behavior(
|
def predict_behavior(
|
||||||
patterns: list[BehaviorPattern],
|
patterns: list[BehaviorPattern],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
|
|||||||
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||||
_SERVICE_PART_PATTERN = re.compile(r"^[a-z0-9_]+$")
|
_SERVICE_PART_PATTERN = re.compile(r"^[a-z0-9_]+$")
|
||||||
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
|
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
|
||||||
|
_METADATA_BATCH_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -141,6 +142,17 @@ class HaClient:
|
|||||||
return {}
|
return {}
|
||||||
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
|
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
|
||||||
raise ValueError("entity_id enthält ein ungültiges Format.")
|
raise ValueError("entity_id enthält ein ungültiges Format.")
|
||||||
|
result: dict[str, dict[str, str | None]] = {}
|
||||||
|
for start in range(0, len(entity_ids), _METADATA_BATCH_SIZE):
|
||||||
|
result.update(
|
||||||
|
self._list_entity_metadata_batch(entity_ids[start:start + _METADATA_BATCH_SIZE])
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _list_entity_metadata_batch(
|
||||||
|
self,
|
||||||
|
entity_ids: list[str],
|
||||||
|
) -> dict[str, dict[str, str | None]]:
|
||||||
template = _metadata_template(entity_ids)
|
template = _metadata_template(entity_ids)
|
||||||
rendered = self._post_text("/api/template", {"template": template})
|
rendered = self._post_text("/api/template", {"template": template})
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from threading import RLock
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.ha.exceptions import HaClientError
|
from app.ha.exceptions import HaClientError, HaHttpError
|
||||||
|
|
||||||
from app.ha.client import HaClient
|
from app.ha.client import HaClient
|
||||||
from app.ha.discovery import DiscoveredEntity, discover_entities
|
from app.ha.discovery import DiscoveredEntity, discover_entities
|
||||||
@@ -167,6 +167,19 @@ class HaReader:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
config = self._client.get_automation_config(config_id)
|
config = self._client.get_automation_config(config_id)
|
||||||
|
except HaHttpError as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
logger.info(
|
||||||
|
"Automation config not exposed by Home Assistant for %s.",
|
||||||
|
raw_entity_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
logger.warning(
|
||||||
|
"Automation config unavailable for %s: %s",
|
||||||
|
raw_entity_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except (HaClientError, ValueError) as exc:
|
except (HaClientError, ValueError) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Automation config unavailable for %s: %s",
|
"Automation config unavailable for %s: %s",
|
||||||
|
|||||||
151
app/main.py
151
app/main.py
@@ -1,9 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
import websockets
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -20,13 +23,27 @@ from app.ha.reader import HaReader
|
|||||||
from app.ml.registry.model_registry import ModelRegistry
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
from backend.routes.ml import init_ml_routes
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
settings = app.state.settings
|
settings = app.state.settings
|
||||||
client: HaClient | None = None
|
client: HaClient | None = None
|
||||||
reconcile_task: asyncio.Task[None] | None = None
|
reconcile_task: asyncio.Task[None] | None = None
|
||||||
prediction_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.registry = ModelRegistry(settings.model_store)
|
||||||
app.state.actuator_store = ActuatorStore(settings.actuator_store)
|
app.state.actuator_store = ActuatorStore(settings.actuator_store)
|
||||||
if hasattr(app.state, "ha_reader"):
|
if hasattr(app.state, "ha_reader"):
|
||||||
@@ -54,11 +71,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
store=app.state.actuator_store,
|
store=app.state.actuator_store,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
app.state.ws_status = _WsStatus()
|
||||||
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
|
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.train_all)
|
||||||
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
|
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
|
||||||
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
||||||
prediction_task = asyncio.create_task(_periodic_prediction(app))
|
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
|
||||||
|
fallback_task = asyncio.create_task(_fallback_prediction(app))
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -66,10 +85,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
reconcile_task.cancel()
|
reconcile_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await reconcile_task
|
await reconcile_task
|
||||||
if prediction_task is not None:
|
if event_listener_task is not None:
|
||||||
prediction_task.cancel()
|
event_listener_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await prediction_task
|
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:
|
if client is not None:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
@@ -77,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SillyHome Next API",
|
title="SillyHome Next API",
|
||||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||||
version="0.7.0",
|
version="0.7.2",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
@@ -94,6 +117,19 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
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("/")
|
@app.get("/")
|
||||||
def root() -> FileResponse:
|
def root() -> FileResponse:
|
||||||
@@ -115,10 +151,107 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
|
|||||||
await asyncio.to_thread(engine.train_all)
|
await asyncio.to_thread(engine.train_all)
|
||||||
|
|
||||||
|
|
||||||
async def _periodic_prediction(app: FastAPI) -> None:
|
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:
|
while True:
|
||||||
await asyncio.sleep(app.state.settings.prediction_interval_seconds)
|
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)
|
engine = getattr(app.state, "behavior_engine", None)
|
||||||
if not isinstance(engine, BehaviorEngine):
|
if isinstance(engine, BehaviorEngine):
|
||||||
continue
|
logger.debug(
|
||||||
|
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
|
||||||
|
ws_status.status if ws_status else "unavailable",
|
||||||
|
)
|
||||||
await asyncio.to_thread(engine.evaluate_all)
|
await asyncio.to_thread(engine.evaluate_all)
|
||||||
|
|||||||
@@ -5,14 +5,18 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>SillyHome Next</title>
|
<title>SillyHome Next</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
|
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
|
||||||
body { margin: 0; }
|
body { margin: 0; font-size:16px; }
|
||||||
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
||||||
h1,h2,h3 { margin: 0 0 12px; }
|
h1,h2,h3 { margin: 0 0 12px; }
|
||||||
header p { margin: 5px 0; color: #c3d1dc; }
|
header p { margin: 5px 0; color: #c3d1dc; }
|
||||||
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
||||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
||||||
|
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
|
||||||
.wide { grid-column: 1 / -1; }
|
.wide { grid-column: 1 / -1; }
|
||||||
|
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
|
||||||
|
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
|
||||||
|
.quick-nav a.primary { background:#23715b; }
|
||||||
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
||||||
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
||||||
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
||||||
@@ -21,10 +25,11 @@
|
|||||||
.warn { color: #f3c969; }
|
.warn { color: #f3c969; }
|
||||||
.bad { color: #ff8f8f; }
|
.bad { color: #ff8f8f; }
|
||||||
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
||||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; }
|
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
|
||||||
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
||||||
button.secondary { background: #37495c; }
|
button.secondary { background: #37495c; }
|
||||||
button.danger { background: #7b3434; }
|
button.danger { background: #7b3434; }
|
||||||
|
button.compact { width:auto; min-width:120px; margin-right:8px; }
|
||||||
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
|
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
|
||||||
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
|
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
|
||||||
ul { margin: 8px 0; padding-left: 18px; }
|
ul { margin: 8px 0; padding-left: 18px; }
|
||||||
@@ -33,6 +38,35 @@
|
|||||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||||
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
||||||
.muted { color:#9fb0be; }
|
.muted { color:#9fb0be; }
|
||||||
|
.card-list { display:grid; gap:12px; }
|
||||||
|
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||||
|
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
|
||||||
|
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
|
||||||
|
.entity-id { overflow-wrap:anywhere; font-weight:800; }
|
||||||
|
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; }
|
||||||
|
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
|
||||||
|
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
||||||
|
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||||
|
.actions button { flex:1 1 180px; margin-top:0; }
|
||||||
|
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
||||||
|
code { color:#cfe0ec; overflow-wrap:anywhere; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
header { padding:18px 14px; }
|
||||||
|
header h1 { font-size:1.55rem; }
|
||||||
|
main { display:block; padding:10px; }
|
||||||
|
section { margin-bottom:12px; padding:14px; border-radius:14px; }
|
||||||
|
.steps { grid-template-columns:1fr; }
|
||||||
|
.grid-two { grid-template-columns:1fr; }
|
||||||
|
.metric-grid { grid-template-columns:1fr 1fr; }
|
||||||
|
.actions { display:grid; grid-template-columns:1fr; }
|
||||||
|
.actions button, button.compact { width:100%; min-width:0; margin-right:0; }
|
||||||
|
.quick-nav { padding:8px 10px; }
|
||||||
|
.quick-nav a { padding:10px 11px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
.metric-grid { grid-template-columns:1fr; }
|
||||||
|
body { font-size:15px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -41,8 +75,15 @@
|
|||||||
<p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
|
<p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
|
||||||
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
||||||
</header>
|
</header>
|
||||||
|
<nav class="quick-nav" aria-label="Schnellnavigation">
|
||||||
|
<a class="primary" href="#choose">Gerät wählen</a>
|
||||||
|
<a href="#observed">Beobachtet</a>
|
||||||
|
<a href="#detail">Details</a>
|
||||||
|
<a href="#status-section">Status</a>
|
||||||
|
<a href="#guide">Ablauf</a>
|
||||||
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<section class="wide">
|
<section class="wide" id="guide">
|
||||||
<h2>So gehst du vor</h2>
|
<h2>So gehst du vor</h2>
|
||||||
<div class="steps">
|
<div class="steps">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
@@ -66,7 +107,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="status-section">
|
||||||
<h2>Systemstatus</h2>
|
<h2>Systemstatus</h2>
|
||||||
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
|
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
|
||||||
<div id="status">Prüfung läuft ...</div>
|
<div id="status">Prüfung läuft ...</div>
|
||||||
@@ -74,7 +115,7 @@
|
|||||||
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
|
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="choose">
|
||||||
<h2>1. Gerät zum Lernen auswählen</h2>
|
<h2>1. Gerät zum Lernen auswählen</h2>
|
||||||
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
|
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
|
||||||
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
||||||
@@ -84,13 +125,13 @@
|
|||||||
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
|
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="wide">
|
<section class="wide" id="observed">
|
||||||
<h2>2. Beobachtete Geräte</h2>
|
<h2>2. Beobachtete Geräte</h2>
|
||||||
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
||||||
<div id="configured-actuators">Noch nicht geladen.</div>
|
<div id="configured-actuators">Noch nicht geladen.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="wide">
|
<section class="wide" id="detail">
|
||||||
<h2>3. Lernfortschritt und Freigabe</h2>
|
<h2>3. Lernfortschritt und Freigabe</h2>
|
||||||
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
|
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
|
||||||
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
||||||
@@ -139,12 +180,19 @@ function behaviorLabel(record) {
|
|||||||
return "sammelt Handlungen";
|
return "sammelt Handlungen";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function predictionLabel(record) {
|
||||||
|
return record.behavior.prediction
|
||||||
|
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
||||||
|
: "Keine fällige Aktion";
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
const status = document.getElementById("status");
|
const status = document.getElementById("status");
|
||||||
const chips = document.getElementById("status-chips");
|
const chips = document.getElementById("status-chips");
|
||||||
try {
|
try {
|
||||||
const [health, ml, reconciliation, actuators] = await Promise.all([
|
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
|
||||||
api("health"),
|
api("health"),
|
||||||
|
api("health/websocket"),
|
||||||
api("ml/health"),
|
api("ml/health"),
|
||||||
api("v1/actuators/reconciliation/state"),
|
api("v1/actuators/reconciliation/state"),
|
||||||
api("v1/actuators"),
|
api("v1/actuators"),
|
||||||
@@ -152,6 +200,7 @@ async function loadOverview() {
|
|||||||
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
||||||
chips.innerHTML = [
|
chips.innerHTML = [
|
||||||
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
||||||
|
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
|
||||||
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
||||||
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
||||||
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
||||||
@@ -194,6 +243,7 @@ async function configureActuator() {
|
|||||||
result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
|
result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
|
||||||
await loadOverview();
|
await loadOverview();
|
||||||
await showActuator(record.actuator_entity_id);
|
await showActuator(record.actuator_entity_id);
|
||||||
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.textContent = error.message;
|
result.textContent = error.message;
|
||||||
}
|
}
|
||||||
@@ -204,29 +254,33 @@ async function loadConfiguredActuators() {
|
|||||||
try {
|
try {
|
||||||
const rows = await api("v1/actuators");
|
const rows = await api("v1/actuators");
|
||||||
box.innerHTML = rows.length ? `
|
box.innerHTML = rows.length ? `
|
||||||
<table>
|
<div class="card-list">
|
||||||
<tr><th>Gerät</th><th>Lernstatus</th><th>Freigabe</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr>
|
|
||||||
${rows.map(record => `
|
${rows.map(record => `
|
||||||
<tr>
|
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
||||||
<td>${escapeHtml(record.actuator_entity_id)}</td>
|
<div class="card-title">
|
||||||
<td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td>
|
<div>
|
||||||
<td class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</td>
|
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
||||||
<td>${record.behavior.sample_count}</td>
|
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
|
||||||
<td>${record.behavior.prediction
|
</div>
|
||||||
? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
|
||||||
: "-"}</td>
|
</div>
|
||||||
<td>
|
<div class="metric-grid">
|
||||||
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details</button>
|
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div>
|
||||||
|
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div>
|
||||||
|
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
|
||||||
${record.behavior.mode === "active"
|
${record.behavior.mode === "active"
|
||||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
||||||
: record.behavior.activation_ready
|
: record.behavior.activation_ready
|
||||||
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
|
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
|
||||||
: ""}
|
: ""}
|
||||||
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</article>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</table>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
</div>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
box.textContent = error.message;
|
box.textContent = error.message;
|
||||||
}
|
}
|
||||||
@@ -268,13 +322,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<strong>${escapeHtml(automation.friendly_name)}</strong>
|
<strong>${escapeHtml(automation.friendly_name)}</strong>
|
||||||
<code>${escapeHtml(automation.entity_id)}</code>:
|
<code>${escapeHtml(automation.entity_id)}</code>:
|
||||||
<span class="${automation.enabled ? "ok" : "warn"}">${automation.enabled ? "aktiv" : "pausiert"}</span>
|
<span class="${automation.enabled ? "ok" : "warn"}">${automation.enabled ? "aktiv" : "pausiert"}</span>
|
||||||
<button class="secondary" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
<button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
||||||
</li>`).join("")}</ul>`
|
</li>`).join("")}</ul>`
|
||||||
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
|
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<div class="grid-two">
|
<div class="detail-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
||||||
|
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
|
||||||
|
</div>
|
||||||
|
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid-two">
|
||||||
|
<div>
|
||||||
|
<h3>Zuordnung</h3>
|
||||||
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
||||||
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
||||||
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
|
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
|
||||||
@@ -290,7 +351,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
|
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
|
||||||
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
||||||
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
|
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
|
||||||
${activationButton}
|
<div class="actions">${activationButton}</div>
|
||||||
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
|
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
|
||||||
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
|
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
|
||||||
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
|
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
|
||||||
@@ -306,6 +367,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
||||||
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
||||||
`;
|
`;
|
||||||
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
box.textContent = error.message;
|
box.textContent = error.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"uvicorn[standard]>=0.29.0",
|
"uvicorn[standard]>=0.29.0",
|
||||||
"pydantic>=2.6.0",
|
"pydantic>=2.6.0",
|
||||||
"requests>=2.31.0",
|
"requests>=2.31.0",
|
||||||
|
"websockets>=12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -108,6 +108,27 @@ def test_list_entity_metadata_calls_template_api() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entity_metadata_batches_template_calls() -> None:
|
||||||
|
responses = []
|
||||||
|
for index in range(3):
|
||||||
|
response = _response()
|
||||||
|
response.text = (
|
||||||
|
f'[{{"entity_id":"sensor.test_{index}",'
|
||||||
|
f'"area_name":"Area {index}","device_name":"Device {index}"}}]'
|
||||||
|
)
|
||||||
|
responses.append(response)
|
||||||
|
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||||
|
client._session.post = Mock(side_effect=responses) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
entity_ids = [f"sensor.test_{index}" for index in range(401)]
|
||||||
|
metadata = client.list_entity_metadata(entity_ids)
|
||||||
|
|
||||||
|
assert client._session.post.call_count == 3
|
||||||
|
assert metadata["sensor.test_0"]["area_name"] == "Area 0"
|
||||||
|
assert metadata["sensor.test_1"]["device_name"] == "Device 1"
|
||||||
|
assert metadata["sensor.test_2"]["device_name"] == "Device 2"
|
||||||
|
|
||||||
|
|
||||||
def test_get_logbook_filters_entity_and_period() -> None:
|
def test_get_logbook_filters_entity_and_period() -> None:
|
||||||
response = _response(payload=[{"entity_id": "light.office"}])
|
response = _response(payload=[{"entity_id": "light.office"}])
|
||||||
client = _client_with_response(response)
|
client = _client_with_response(response)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.ha.client import HaClient, HaClientSettings
|
from app.ha.client import HaClient, HaClientSettings
|
||||||
|
from app.ha.exceptions import HaHttpError
|
||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
|
|
||||||
|
|
||||||
@@ -150,3 +151,23 @@ def test_ha_reader_finds_automation_that_targets_entity() -> None:
|
|||||||
assert len(matches) == 1
|
assert len(matches) == 1
|
||||||
assert matches[0].entity_id == "automation.storage_light"
|
assert matches[0].entity_id == "automation.storage_light"
|
||||||
assert matches[0].enabled is True
|
assert matches[0].enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_reader_ignores_automation_configs_not_exposed_by_ha() -> None:
|
||||||
|
client = FakeHaClient()
|
||||||
|
client.list_entities = lambda: [ # type: ignore[method-assign]
|
||||||
|
{
|
||||||
|
"entity_id": "automation.storage_light",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": {
|
||||||
|
"id": "123",
|
||||||
|
"friendly_name": "Storage light",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
client.get_automation_config = lambda automation_id: (_ for _ in ()).throw( # type: ignore[method-assign]
|
||||||
|
HaHttpError(404, "Resource not found")
|
||||||
|
)
|
||||||
|
reader = HaReader(client)
|
||||||
|
|
||||||
|
assert reader.find_automations_for_entity("light.storage") == []
|
||||||
|
|||||||
112
tests/test_main.py
Normal file
112
tests/test_main.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.actuators.store import ActuatorStore
|
||||||
|
from app.behavior.engine import BehaviorEngine
|
||||||
|
from app.main import _ha_event_listener, app as fastapi_app, lifespan
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWebSocket:
|
||||||
|
def __init__(self, messages: list[str | BaseException]) -> None:
|
||||||
|
self._messages = messages
|
||||||
|
self.sent: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "_FakeWebSocket":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def recv(self) -> str:
|
||||||
|
message = self._messages.pop(0)
|
||||||
|
if isinstance(message, BaseException):
|
||||||
|
raise message
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def send(self, message: str) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
self.sent.append(json.loads(message))
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingBehaviorEngine(BehaviorEngine):
|
||||||
|
def __init__(self, tmp_path: Path) -> None:
|
||||||
|
super().__init__(
|
||||||
|
ha_reader=MagicMock(),
|
||||||
|
store=ActuatorStore(tmp_path / "actuators"),
|
||||||
|
settings=MagicMock(),
|
||||||
|
)
|
||||||
|
self.state_changes: list[tuple[str, dict[str, object] | None]] = []
|
||||||
|
|
||||||
|
def handle_state_change(self, entity_id: str, new_state: dict[str, object] | None) -> None:
|
||||||
|
self.state_changes.append((entity_id, new_state))
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
||||||
|
async def run_test() -> None:
|
||||||
|
fake_ws = _FakeWebSocket(
|
||||||
|
[
|
||||||
|
'{"type":"auth_required"}',
|
||||||
|
'{"type":"auth_ok"}',
|
||||||
|
(
|
||||||
|
'{"type":"event","event":{"event_type":"state_changed",'
|
||||||
|
'"entity_id":"light.test","new_state":{"state":"on"}}}'
|
||||||
|
),
|
||||||
|
asyncio.CancelledError(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("websockets.connect", return_value=fake_ws):
|
||||||
|
try:
|
||||||
|
await _ha_event_listener(mock_app, mock_client)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert fake_ws.sent == [
|
||||||
|
{"type": "auth", "access_token": "test-token"},
|
||||||
|
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.state.settings = MagicMock()
|
||||||
|
mock_app.state.settings.ha_url = "http://homeassistant:8123"
|
||||||
|
mock_app.state.settings.ha_token = "test-token"
|
||||||
|
mock_app.state.ws_status = MagicMock()
|
||||||
|
mock_engine = _RecordingBehaviorEngine(tmp_path)
|
||||||
|
mock_app.state.behavior_engine = mock_engine
|
||||||
|
mock_store = ActuatorStore(tmp_path / "store")
|
||||||
|
mock_app.state.actuator_store = mock_store
|
||||||
|
mock_client = MagicMock()
|
||||||
|
|
||||||
|
anyio.run(run_test)
|
||||||
|
assert mock_engine.state_changes == [("light.test", {"state": "on"})]
|
||||||
|
assert mock_app.state.ws_status.status == "connected"
|
||||||
|
assert mock_app.state.ws_status.error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.settings = MagicMock()
|
||||||
|
app.state.settings.ha_configured = False
|
||||||
|
|
||||||
|
async def run_test() -> None:
|
||||||
|
async with lifespan(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
anyio.run(run_test)
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_health_returns_unavailable_without_listener() -> None:
|
||||||
|
with TestClient(fastapi_app) as client:
|
||||||
|
response = client.get("/health/websocket")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"status": "unavailable",
|
||||||
|
"error": "WebSocket-Listener nicht initialisiert",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user