main: HA-Integration mit Exception-Handling und Testabdeckung

This commit is contained in:
2026-06-10 22:47:08 +02:00
parent 6b1e2ad0dc
commit d550030a1a
6 changed files with 164 additions and 23 deletions

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
from typing import List, Sequence
from typing import List
from fastapi import APIRouter
from fastapi import APIRouter, Request
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
router = APIRouter(prefix="/v1", tags=["entities"])
@@ -15,5 +17,9 @@ router = APIRouter(prefix="/v1", tags=["entities"])
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
response_model=List[HaEntitySummary],
)
def list_entities() -> Sequence[HaEntitySummary]:
raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.")
def list_entities(request: Request) -> List[HaEntitySummary]:
ha_reader: HaReader = request.app.state.ha_reader
recommender: Recommender = request.app.state.recommender
entities = ha_reader.read_entities()
recommender.run(entities)
return entities

20
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Request
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HaClientError)
async def handle_ha_client_error(request: Request, exc: HaClientError) -> Any: # pragma: no cover - einfacher Wrapper
if isinstance(exc, HaAuthError):
return {"detail": "Ungültige Authentifizierung gegenüber Home Assistant."}
if isinstance(exc, HaHttpError):
return {
"detail": "Home Assistant meldet einen Fehler.",
"upstream_status": exc.status_code,
}
return {"detail": str(exc)}

View File

@@ -5,18 +5,13 @@ from dataclasses import dataclass
import requests
from app.ha.exceptions import HaAuthError, HaHttpError, HaTimeoutError, HaUnexpectedPayloadError
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class HaClientSettings:
url: str
token: str
timeout_seconds: int = 10
class HaClient:
def __init__(self, settings: HaClientSettings) -> None:
def __init__(self, settings) -> None:
self._settings = settings
self._session = requests.Session()
self._session.headers.update({
@@ -24,10 +19,35 @@ class HaClient:
"Content-Type": "application/json",
})
def list_entities(self) -> list[dict[str, object]]:
def list_entities(self):
url = f"{self._settings.url}/api/states"
try:
response = self._session.get(
f"{self._settings.url}/api/states",
url,
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
except requests.RequestException as exc:
raise HaHttpError(
getattr(exc.response, "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc
if response.status_code in (401, 403):
raise HaAuthError(response.status_code, "Authentifizierung bei Home Assistant fehlgeschlagen.")
try:
response.raise_for_status()
return response.json()
except requests.HTTPError as exc:
raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
try:
payload = response.json()
except ValueError as exc:
raise HaUnexpectedPayloadError("Antwort von Home Assistant ist kein gültiges JSON.") from exc
if not isinstance(payload, list):
raise HaUnexpectedPayloadError("Antwort von Home Assistant hat unerwartetes Format.")
return payload

25
app/ha/exceptions.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
class HaClientError(Exception):
"""Basisklasse für HA-Client-Fehler."""
class HaTimeoutError(HaClientError):
"""Zeitüberschreitung bei Request an Home Assistant."""
class HaHttpError(HaClientError):
"""Nicht erfolgreicher HTTP-Statuscode."""
def __init__(self, status_code: int, message: str = "") -> None:
super().__init__(message)
self.status_code = status_code
class HaAuthError(HaHttpError):
"""Authentifizierung oder Berechtigung fehlgeschlagen."""
class HaUnexpectedPayloadError(HaClientError):
"""Antwort hat nicht das erwartete Format."""

View File

@@ -5,16 +5,21 @@ from fastapi import FastAPI, Depends
from app.api.v1.entities import router as entities_router
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
from app.rules.heating import HeatingRule
@asynccontextmanager
async def lifespan(app: FastAPI):
ha_url = getattr(app.state.settings, "ha_url", None)
ha_token = getattr(app.state.settings, "ha_token", None)
settings = HaClientSettings(
url=app.state.settings.ha_url,
token=app.state.settings.ha_token,
url=ha_url or "",
token=ha_token or "",
)
client = HaClient(settings=settings)
app.state.ha_reader = HaReader(client=client)
app.state.recommender = Recommender(rules=[HeatingRule()])
yield
@@ -27,17 +32,22 @@ app = FastAPI(
class Settings:
ha_url: str
ha_token: str
ha_url: str = "http://localhost:8123"
ha_token: str = ""
app.state.settings = Settings()
register_exception_handlers(app)
def get_ha_reader() -> HaReader:
return app.state.ha_reader
def get_recommender() -> Recommender:
return app.state.recommender
app.include_router(entities_router, dependencies=[Depends(get_ha_reader)])

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
import requests
from app.ha.client import HaClient
from app.ha.exceptions import HaAuthError, HaHttpError, HaTimeoutError, HaUnexpectedPayloadError
from tests.helpers import build_ha_client_settings
@pytest.fixture()
def client():
settings = build_ha_client_settings()
return HaClient(settings)
def test_list_entities_timeout(client):
with patch.object(client._session, "get", side_effect=requests.Timeout("t")):
with pytest.raises(HaTimeoutError):
client.list_entities()
def test_list_entities_auth_error(client):
response = MagicMock()
response.status_code = 401
response.raise_for_status = MagicMock()
with patch.object(client._session, "get", return_value=response):
with pytest.raises(HaAuthError):
client.list_entities()
def test_list_entities_http_error(client):
response = MagicMock()
response.status_code = 502
response.raise_for_status = MagicMock(side_effect=requests.HTTPError("bad"))
with patch.object(client._session, "get", return_value=response):
with pytest.raises(HaHttpError):
client.list_entities()
def test_list_entities_invalid_json(client):
response = MagicMock()
response.status_code = 200
response.raise_for_status = MagicMock()
response.json = MagicMock(side_effect=ValueError("invalid json"))
with patch.object(client._session, "get", return_value=response):
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()
def test_list_entities_wrong_payload_type(client):
response = MagicMock()
response.status_code = 200
response.raise_for_status = MagicMock()
response.json = MagicMock(return_value={"data": []})
with patch.object(client._session, "get", return_value=response):
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()