Compare commits

...

2 Commits

Author SHA1 Message Date
445e4bcdf4 Merge otto/ha-client-errors into main 2026-06-10 23:29:30 +02:00
d550030a1a main: HA-Integration mit Exception-Handling und Testabdeckung 2026-06-10 22:47:08 +02:00
6 changed files with 101 additions and 51 deletions

View File

@@ -1,21 +1,39 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from typing import List
from fastapi import APIRouter, Depends from fastapi import APIRouter, HTTPException, Request
from app.dependencies import get_ha_reader
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.rules.recommender import Recommender
router = APIRouter(prefix="/v1", tags=["entities"]) router = APIRouter(prefix="/v1", tags=["entities"])
def _state_ha_reader(request: Request) -> HaReader:
try:
return request.app.state.ha_reader
except AttributeError as exc:
raise HTTPException(status_code=503, detail="HA-Reader nicht initialisiert.") from exc
def _state_recommender(request: Request) -> Recommender:
try:
return request.app.state.recommender
except AttributeError as exc:
raise HTTPException(status_code=503, detail="Recommender nicht initialisiert.") from exc
@router.get( @router.get(
"/entities", "/entities",
summary="Home-Assistant-Entities auflisten", summary="Home-Assistant-Entities auflisten",
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.", description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
response_model=list[HaEntitySummary], response_model=List[HaEntitySummary],
) )
def list_entities(reader: HaReader = Depends(get_ha_reader)) -> Sequence[HaEntitySummary]: def list_entities(request: Request) -> List[HaEntitySummary]:
return reader.read_entities() ha_reader = _state_ha_reader(request)
recommender = _state_recommender(request)
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

@@ -2,7 +2,6 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import requests import requests
@@ -32,36 +31,44 @@ class HaClient:
"Content-Type": "application/json", "Content-Type": "application/json",
}) })
def list_entities(self) -> list[dict[str, Any]]: def list_entities(self) -> list[dict[str, object]]:
try: try:
response = self._session.get( response = self._session.get(
f"{self._settings.url}/api/states", f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds, timeout=self._settings.timeout_seconds,
) )
except requests.Timeout as exc: except requests.Timeout as exc:
raise HaTimeoutError("Home Assistant request timed out.") from exc raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
except requests.RequestException as exc: except requests.RequestException as exc:
raise HaHttpError(status_code=502, message="Home Assistant request failed.") from exc raise HaHttpError(
getattr(getattr(exc, "response", None), "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc
if response.status_code in {401, 403}: if response.status_code in (401, 403):
raise HaAuthError( raise HaAuthError(
status_code=response.status_code, response.status_code,
message="Home Assistant authentication failed.", "Authentifizierung bei Home Assistant fehlgeschlagen.",
) )
try: try:
response.raise_for_status() response.raise_for_status()
except requests.HTTPError as exc: except requests.HTTPError as exc:
raise HaHttpError( raise HaHttpError(
status_code=response.status_code, response.status_code,
message="Home Assistant returned an HTTP error.", "Home Assistant meldet einen Fehler.",
) from exc ) from exc
try: try:
payload = response.json() payload = response.json()
except ValueError as exc: except ValueError as exc:
raise HaUnexpectedPayloadError("Home Assistant returned invalid JSON.") from exc raise HaUnexpectedPayloadError(
"Antwort von Home Assistant ist kein gültiges JSON."
) from exc
if not isinstance(payload, list): if not isinstance(payload, list):
raise HaUnexpectedPayloadError("Home Assistant states response must be a list.") raise HaUnexpectedPayloadError(
return payload "Antwort von Home Assistant hat unerwartetes Format."
)
return payload

View File

@@ -2,26 +2,34 @@ from __future__ import annotations
class HaClientError(Exception): class HaClientError(Exception):
"""Base class for Home Assistant integration failures.""" """Basisklasse für HA-Client-Fehler."""
public_detail = "Home Assistant is currently unavailable." public_detail: str | None = None
class HaTimeoutError(HaClientError): class HaTimeoutError(HaClientError):
"""Zeitüberschreitung bei Request an Home Assistant."""
public_detail = "Home Assistant request timed out." public_detail = "Home Assistant request timed out."
class HaHttpError(HaClientError): class HaHttpError(HaClientError):
public_detail = "Home Assistant returned an error." """Nicht erfolgreicher HTTP-Statuscode."""
def __init__(self, status_code: int, message: str | None = None) -> None: public_detail = "Home Assistant request failed."
super().__init__(message or self.public_detail)
def __init__(self, status_code: int, message: str = "") -> None:
super().__init__(message)
self.status_code = status_code self.status_code = status_code
class HaAuthError(HaHttpError): class HaAuthError(HaHttpError):
"""Authentifizierung oder Berechtigung fehlgeschlagen."""
public_detail = "Home Assistant authentication failed." public_detail = "Home Assistant authentication failed."
class HaUnexpectedPayloadError(HaClientError): class HaUnexpectedPayloadError(HaClientError):
public_detail = "Home Assistant returned an unexpected response." """Antwort hat nicht das erwartete Format."""
public_detail = "Home Assistant returned an unexpected payload."

View File

@@ -1,39 +1,44 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.api.v1.entities import router as entities_router from app.api.v1.entities import router as entities_router
from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.rules.recommender import Recommender
from app.rules.heating import HeatingRule
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI):
settings = load_settings() settings = app.state.settings
app.state.settings = settings ha_url = getattr(settings, "ha_url", None)
if settings.ha_configured: ha_token = getattr(settings, "ha_token", None)
client = HaClient( client = HaClient(
settings=HaClientSettings( settings=HaClientSettings(
url=settings.ha_url or "", url=ha_url or "",
token=settings.ha_token or "", token=ha_token or "",
)
) )
app.state.ha_reader = HaReader(client=client) )
app.state.ha_reader = HaReader(client=client)
app.state.recommender = Recommender(rules=[HeatingRule()])
yield yield
class Settings:
ha_url: str = "http://localhost:8123"
ha_token: str = ""
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.1.0", version="0.1.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = Settings()
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(entities_router) app.include_router(entities_router)
@@ -44,4 +49,4 @@ def health() -> dict[str, str]:
@app.get("/") @app.get("/")
def root() -> dict[str, str]: def root() -> dict[str, str]:
return {"service": "sillyhome-next", "docs": "/docs"} return {"service": "sillyhome-next", "docs": "/docs"}

View File

@@ -15,7 +15,7 @@ from app.ha.exceptions import (
def _client_with_response(response: Mock) -> HaClient: def _client_with_response(response: Mock) -> HaClient:
client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token")) client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.get = Mock(return_value=response) # type: ignore[method-assign] client._session.get = Mock(return_value=response) # type: ignore[method-assign]
return client return client
@@ -32,14 +32,12 @@ def _response(status_code: int = 200, payload: object | None = None) -> Mock:
def test_list_entities_returns_home_assistant_payload() -> None: def test_list_entities_returns_home_assistant_payload() -> None:
payload = [{"entity_id": "sensor.temperature", "state": "21"}] payload = [{"entity_id": "sensor.temperature", "state": "21"}]
client = _client_with_response(_response(payload=payload)) client = _client_with_response(_response(payload=payload))
assert client.list_entities() == payload assert client.list_entities() == payload
def test_list_entities_maps_timeout() -> None: def test_list_entities_maps_timeout() -> None:
client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token")) client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.get = Mock(side_effect=requests.Timeout("secret-token")) # type: ignore[method-assign] client._session.get = Mock(side_effect=requests.Timeout("timed out")) # type: ignore[method-assign]
with pytest.raises(HaTimeoutError): with pytest.raises(HaTimeoutError):
client.list_entities() client.list_entities()
@@ -47,19 +45,15 @@ def test_list_entities_maps_timeout() -> None:
@pytest.mark.parametrize("status_code", [401, 403]) @pytest.mark.parametrize("status_code", [401, 403])
def test_list_entities_maps_auth_errors(status_code: int) -> None: def test_list_entities_maps_auth_errors(status_code: int) -> None:
client = _client_with_response(_response(status_code=status_code)) client = _client_with_response(_response(status_code=status_code))
with pytest.raises(HaAuthError) as exc_info: with pytest.raises(HaAuthError) as exc_info:
client.list_entities() client.list_entities()
assert exc_info.value.status_code == status_code assert exc_info.value.status_code == status_code
def test_list_entities_maps_http_errors() -> None: def test_list_entities_maps_http_errors() -> None:
client = _client_with_response(_response(status_code=500)) client = _client_with_response(_response(status_code=500))
with pytest.raises(HaHttpError) as exc_info: with pytest.raises(HaHttpError) as exc_info:
client.list_entities() client.list_entities()
assert exc_info.value.status_code == 500 assert exc_info.value.status_code == 500
@@ -67,13 +61,11 @@ def test_list_entities_rejects_invalid_json() -> None:
response = _response() response = _response()
response.json.side_effect = ValueError("not json") response.json.side_effect = ValueError("not json")
client = _client_with_response(response) client = _client_with_response(response)
with pytest.raises(HaUnexpectedPayloadError): with pytest.raises(HaUnexpectedPayloadError):
client.list_entities() client.list_entities()
def test_list_entities_rejects_non_list_payload() -> None: def test_list_entities_rejects_non_list_payload() -> None:
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"})) client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
with pytest.raises(HaUnexpectedPayloadError): with pytest.raises(HaUnexpectedPayloadError):
client.list_entities() client.list_entities()