From d550030a1a7da37351d12722796da4d4409a5426 Mon Sep 17 00:00:00 2001 From: Pino Date: Wed, 10 Jun 2026 22:47:08 +0200 Subject: [PATCH] main: HA-Integration mit Exception-Handling und Testabdeckung --- app/api/v1/entities.py | 14 ++++++--- app/core/exceptions.py | 20 +++++++++++++ app/ha/client.py | 50 +++++++++++++++++++++---------- app/ha/exceptions.py | 25 ++++++++++++++++ app/main.py | 18 +++++++++--- tests/ha/test_ha_client.py | 60 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 app/core/exceptions.py create mode 100644 app/ha/exceptions.py create mode 100644 tests/ha/test_ha_client.py diff --git a/app/api/v1/entities.py b/app/api/v1/entities.py index 8b6f293..ef89ae7 100644 --- a/app/api/v1/entities.py +++ b/app/api/v1/entities.py @@ -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 \ No newline at end of file diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..41101e6 --- /dev/null +++ b/app/core/exceptions.py @@ -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)} \ No newline at end of file diff --git a/app/ha/client.py b/app/ha/client.py index 5f64ef9..7fcbf3c 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -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]]: - response = self._session.get( - f"{self._settings.url}/api/states", - timeout=self._settings.timeout_seconds, - ) - response.raise_for_status() - return response.json() + def list_entities(self): + url = f"{self._settings.url}/api/states" + try: + response = self._session.get( + 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() + 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 \ No newline at end of file diff --git a/app/ha/exceptions.py b/app/ha/exceptions.py new file mode 100644 index 0000000..b2f50cf --- /dev/null +++ b/app/ha/exceptions.py @@ -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.""" \ No newline at end of file diff --git a/app/main.py b/app/main.py index 6826c1c..563dcdf 100644 --- a/app/main.py +++ b/app/main.py @@ -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)]) diff --git a/tests/ha/test_ha_client.py b/tests/ha/test_ha_client.py new file mode 100644 index 0000000..28e54c7 --- /dev/null +++ b/tests/ha/test_ha_client.py @@ -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() \ No newline at end of file