diff --git a/CHANGELOG.md b/CHANGELOG.md index df1542c..ef16134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 - Freie Eingabe von Home-Assistant-Entitätsnamen mit Vorschlagsliste - Freigabestatus und Blockadegrund sind in Übersicht und Details immer sichtbar diff --git a/addon/config.yaml b/addon/config.yaml index a5050dd..8faa97f 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.0" +version: "0.7.1" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/main.py b/app/main.py index 4b7df19..70f593e 100644 --- a/app/main.py +++ b/app/main.py @@ -29,7 +29,7 @@ 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" | "error" + - status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error" - error: str | None (Fehlermeldung bei status=error) """ def __init__(self) -> None: @@ -100,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="0.7.0", + version="0.7.1", lifespan=lifespan, ) app.state.settings = load_settings() @@ -122,7 +122,7 @@ def websocket_health() -> dict[str, object]: """Gibt den aktuellen Status des WebSocket-Listeners zurück. Antwort: - - status: "disconnected" | "connecting" | "connected" | "error" + - status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error" - error: str | None (nur bei status=error) """ 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" auth_token = cast(str, settings.ha_token) ws_status = getattr(app.state, "ws_status", None) - if ws_status is not None: - ws_status.status = "connecting" while True: + if ws_status is not None: + ws_status.status = "connecting" try: - async with websockets.connect(ws_url, extra_headers={ - "Authorization": f"Bearer {auth_token}" - }) as websocket: - logger.info("WebSocket-Verbindung zu Home Assistant hergestellt") - if ws_status is not None: - ws_status.status = "connected" - ws_status.error = None - # Authentifizierung durchführen - auth_msg = await websocket.recv() - auth_data = json.loads(auth_msg) - if auth_data.get("type") != "auth_ok": + 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" } diff --git a/pyproject.toml b/pyproject.toml index ae00457..f33edb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.0" +version = "0.7.1" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_main.py b/tests/test_main.py index 94c4b39..8341ffb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,50 +1,112 @@ 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 -async def test_ha_event_listener_processes_state_change() -> None: - # Mock settings, store, engine +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_engine = MagicMock() - mock_engine.handle_state_change = MagicMock() + mock_app.state.ws_status = MagicMock() + mock_engine = _RecordingBehaviorEngine(tmp_path) mock_app.state.behavior_engine = mock_engine - mock_store = MagicMock() + mock_store = ActuatorStore(tmp_path / "store") mock_app.state.actuator_store = mock_store - mock_client = MagicMock() - # Mock websockets connection and messages - mock_ws = AsyncMock() - mock_ws.recv.side_effect = [ - '{"type":"auth_ok"}', # auth response - '{"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"}) + 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 -@pytest.mark.asyncio -async def test_lifespan_starts_event_listener() -> None: - # This is a basic smoke test that lifespan creates the expected tasks - from fastapi import FastAPI - +def test_lifespan_skips_event_listener_without_ha_config() -> None: app = FastAPI() app.state.settings = MagicMock() app.state.settings.ha_configured = False - # Should not raise - async with lifespan(app): - pass + 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", + }