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
## 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

View File

@@ -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

View File

@@ -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)
while True:
if ws_status is not None:
ws_status.status = "connecting"
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")
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 = "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":
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"
}

View File

@@ -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 = [

View File

@@ -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 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",
}