fix: complete websocket delivery for v0.7.1
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
42
app/main.py
42
app/main.py
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user