Merge otto/ha-client-errors into main

This commit is contained in:
2026-06-10 23:29:30 +02:00
22 changed files with 331 additions and 93 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""SillyHome Next application package."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

1
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Version 1 API package."""

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import List
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
@@ -11,6 +11,20 @@ from app.rules.recommender import Recommender
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(
"/entities",
summary="Home-Assistant-Entities auflisten",
@@ -18,8 +32,8 @@ router = APIRouter(prefix="/v1", tags=["entities"])
response_model=List[HaEntitySummary],
)
def list_entities(request: Request) -> List[HaEntitySummary]:
ha_reader: HaReader = request.app.state.ha_reader
recommender: Recommender = request.app.state.recommender
ha_reader = _state_ha_reader(request)
recommender = _state_recommender(request)
entities = ha_reader.read_entities()
recommender.run(entities)
return entities

21
app/config.py Normal file
View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
ha_url: str | None = None
ha_token: str | None = None
@property
def ha_configured(self) -> bool:
return bool(self.ha_url and self.ha_token)
def load_settings() -> Settings:
return Settings(
ha_url=os.getenv("SILLYHOME_HA_URL") or os.getenv("HA_URL"),
ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"),
)

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core application helpers."""

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError, HaTimeoutError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HaClientError)
async def handle_ha_client_error(_: Request, exc: HaClientError) -> JSONResponse:
return JSONResponse(
status_code=_status_code_for_ha_error(exc),
content={"detail": exc.public_detail},
)
def _status_code_for_ha_error(exc: HaClientError) -> int:
if isinstance(exc, HaTimeoutError):
return status.HTTP_504_GATEWAY_TIMEOUT
if isinstance(exc, (HaAuthError, HaHttpError)):
return status.HTTP_502_BAD_GATEWAY
return status.HTTP_502_BAD_GATEWAY

15
app/dependencies.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import HTTPException, Request, status
from app.ha.reader import HaReader
def get_ha_reader(request: Request) -> HaReader:
reader = getattr(request.app.state, "ha_reader", None)
if not isinstance(reader, HaReader):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Home Assistant is not configured.",
)
return reader

View File

@@ -5,13 +5,25 @@ from dataclasses import dataclass
import requests
from app.ha.exceptions import HaAuthError, HaHttpError, HaTimeoutError, HaUnexpectedPayloadError
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) -> None:
def __init__(self, settings: HaClientSettings) -> None:
self._settings = settings
self._session = requests.Session()
self._session.headers.update({
@@ -19,35 +31,44 @@ class HaClient:
"Content-Type": "application/json",
})
def list_entities(self):
url = f"{self._settings.url}/api/states"
def list_entities(self) -> list[dict[str, object]]:
try:
response = self._session.get(
url,
f"{self._settings.url}/api/states",
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),
getattr(getattr(exc, "response", None), "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.")
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
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
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.")
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant hat unerwartetes Format."
)
return payload

View File

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

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from app.ha.client import HaClient
from app.ha.models import HaEntitySummary, HaState
from app.ha.models import HaEntitySummary
class HaReader:
@@ -18,14 +19,21 @@ class HaReader:
if "." not in entity_id:
continue
domain = entity_id.split(".", 1)[0]
attributes = item.get("attributes") or {}
raw_attributes = item.get("attributes") or {}
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
summaries.append(
HaEntitySummary(
entity_id=entity_id,
domain=domain,
state_class=str(attributes.get("state_class") or ""),
device_class=str(attributes.get("device_class") or ""),
unit_of_measurement=str(attributes.get("unit_of_measurement") or ""),
state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
)
)
return summaries
def _optional_str(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)

View File

@@ -1,8 +1,9 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi import FastAPI
from app.api.v1.entities import router as entities_router
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
@@ -11,44 +12,34 @@ 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=ha_url or "",
token=ha_token or "",
settings = app.state.settings
ha_url = getattr(settings, "ha_url", None)
ha_token = getattr(settings, "ha_token", None)
client = HaClient(
settings=HaClientSettings(
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
class Settings:
ha_url: str = "http://localhost:8123"
ha_token: str = ""
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.1.0",
lifespan=lifespan,
)
class Settings:
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)])
app.include_router(entities_router)
@app.get("/health")

1
app/rules/__init__.py Normal file
View File

@@ -0,0 +1 @@
# sillyhome-next.rules

15
app/rules/heating.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from collections.abc import Sequence
from app.ha.models import HaEntitySummary
from app.rules.recommender import Rule
class HeatingRule(Rule):
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
domains = {item.domain for item in entities}
return "climate" in domains or "sensor" in domains
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."

25
app/rules/recommender.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from collections.abc import Sequence
from app.ha.models import HaEntitySummary
class Rule:
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
raise NotImplementedError
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
raise NotImplementedError
class Recommender:
def __init__(self, rules: Sequence[Rule]) -> None:
self._rules = rules
def run(self, entities: Sequence[HaEntitySummary]) -> list[str]:
results: list[str] = []
for rule in self._rules:
if rule.matches(entities):
results.append(rule.recommendation(entities))
return results