fix: complete websocket delivery for v0.7.1
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-14 22:30:29 +02:00
parent 51d23e0a9a
commit 2ae5576b8f
5 changed files with 129 additions and 49 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 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

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.7.0" version: "0.7.1"
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

View File

@@ -29,7 +29,7 @@ class _WsStatus:
"""Einfacher Status-Tracker für den WebSocket-Listener. """Einfacher Status-Tracker für den WebSocket-Listener.
Wird als Attribut an app.state gehängt und enthält: Wird als Attribut an app.state gehängt und enthält:
- status: "disconnected" | "connecting" | "connected" | "error" - status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
- error: str | None (Fehlermeldung bei status=error) - error: str | None (Fehlermeldung bei status=error)
""" """
def __init__(self) -> None: def __init__(self) -> None:
@@ -100,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.1",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -122,7 +122,7 @@ def websocket_health() -> dict[str, object]:
"""Gibt den aktuellen Status des WebSocket-Listeners zurück. """Gibt den aktuellen Status des WebSocket-Listeners zurück.
Antwort: Antwort:
- status: "disconnected" | "connecting" | "connected" | "error" - status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
- error: str | None (nur bei status=error) - error: str | None (nur bei status=error)
""" """
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
@@ -167,29 +167,39 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket" ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token) auth_token = cast(str, settings.ha_token)
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
if ws_status is not None:
ws_status.status = "connecting"
while True: while True:
if ws_status is not None:
ws_status.status = "connecting"
try: try:
async with websockets.connect(ws_url, extra_headers={ async with websockets.connect(ws_url) as websocket:
"Authorization": f"Bearer {auth_token}" auth_required_msg = await websocket.recv()
}) as websocket: auth_required_data = json.loads(auth_required_msg)
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt") if auth_required_data.get("type") != "auth_required":
if ws_status is not None: logger.error("Unerwartete WebSocket-Authentifizierungsaufforderung")
ws_status.status = "connected" if ws_status is not None:
ws_status.error = None ws_status.status = "error"
# Authentifizierung durchführen ws_status.error = "Unerwartete Authentifizierungsaufforderung"
auth_msg = await websocket.recv() await asyncio.sleep(5)
auth_data = json.loads(auth_msg) continue
if auth_data.get("type") != "auth_ok":
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") logger.error("WebSocket-Authentifizierung fehlgeschlagen")
if ws_status is not None: if ws_status is not None:
ws_status.status = "error" ws_status.status = "error"
ws_status.error = "Authentifizierung fehlgeschlagen" ws_status.error = "Authentifizierung fehlgeschlagen"
await asyncio.sleep(5) await asyncio.sleep(5)
continue 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 # Auf alle State Changes subscriben
subscribe_msg = { subscribe_msg = {
"id": 1,
"type": "subscribe_events", "type": "subscribe_events",
"event_type": "state_changed" "event_type": "state_changed"
} }

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.7.0" version = "0.7.1"
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 = [

View File

@@ -1,50 +1,112 @@
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest import anyio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.main import _ha_event_listener, lifespan from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.main import _ha_event_listener, app as fastapi_app, lifespan
@pytest.mark.asyncio class _FakeWebSocket:
async def test_ha_event_listener_processes_state_change() -> None: def __init__(self, messages: list[str | BaseException]) -> None:
# Mock settings, store, engine 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 = MagicMock()
mock_app.state.settings = MagicMock() mock_app.state.settings = MagicMock()
mock_app.state.settings.ha_url = "http://homeassistant:8123" mock_app.state.settings.ha_url = "http://homeassistant:8123"
mock_app.state.settings.ha_token = "test-token" mock_app.state.settings.ha_token = "test-token"
mock_engine = MagicMock() mock_app.state.ws_status = MagicMock()
mock_engine.handle_state_change = MagicMock() mock_engine = _RecordingBehaviorEngine(tmp_path)
mock_app.state.behavior_engine = mock_engine mock_app.state.behavior_engine = mock_engine
mock_store = MagicMock() mock_store = ActuatorStore(tmp_path / "store")
mock_app.state.actuator_store = mock_store mock_app.state.actuator_store = mock_store
mock_client = MagicMock() mock_client = MagicMock()
# Mock websockets connection and messages anyio.run(run_test)
mock_ws = AsyncMock() assert mock_engine.state_changes == [("light.test", {"state": "on"})]
mock_ws.recv.side_effect = [ assert mock_app.state.ws_status.status == "connected"
'{"type":"auth_ok"}', # auth response assert mock_app.state.ws_status.error is None
'{"type":"event","event":{"event_type":"state_changed","entity_id":"light.test","new_state":{"state":"on"}}}', # event
Exception("EOF"), # break loop
]
mock_ws.send.return_value = None
with patch("websockets.connect", return_value=mock_ws):
await _ha_event_listener(mock_app, mock_client)
mock_engine.handle_state_change.assert_called_once_with("light.test", {"state": "on"})
@pytest.mark.asyncio def test_lifespan_skips_event_listener_without_ha_config() -> None:
async def test_lifespan_starts_event_listener() -> None:
# This is a basic smoke test that lifespan creates the expected tasks
from fastapi import FastAPI
app = FastAPI() app = FastAPI()
app.state.settings = MagicMock() app.state.settings = MagicMock()
app.state.settings.ha_configured = False app.state.settings.ha_configured = False
# Should not raise async def run_test() -> None:
async with lifespan(app): async with lifespan(app):
pass 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",
}