main: HA-Integration mit Exception-Handling und Testabdeckung
This commit is contained in:
@@ -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
20
app/core/exceptions.py
Normal 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)}
|
||||
@@ -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
|
||||
25
app/ha/exceptions.py
Normal file
25
app/ha/exceptions.py
Normal 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."""
|
||||
18
app/main.py
18
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)])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user