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 typing import List
from fastapi import APIRouter, Request from fastapi import APIRouter, HTTPException, Request
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -11,6 +11,20 @@ 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",
@@ -18,8 +32,8 @@ router = APIRouter(prefix="/v1", tags=["entities"])
response_model=List[HaEntitySummary], response_model=List[HaEntitySummary],
) )
def list_entities(request: Request) -> List[HaEntitySummary]: def list_entities(request: Request) -> List[HaEntitySummary]:
ha_reader: HaReader = request.app.state.ha_reader ha_reader = _state_ha_reader(request)
recommender: Recommender = request.app.state.recommender recommender = _state_recommender(request)
entities = ha_reader.read_entities() entities = ha_reader.read_entities()
recommender.run(entities) recommender.run(entities)
return 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 import requests
from app.ha.exceptions import HaAuthError, HaHttpError, HaTimeoutError, HaUnexpectedPayloadError from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class HaClientSettings:
url: str
token: str
timeout_seconds: int = 10
class HaClient: class HaClient:
def __init__(self, settings) -> None: def __init__(self, settings: HaClientSettings) -> None:
self._settings = settings self._settings = settings
self._session = requests.Session() self._session = requests.Session()
self._session.headers.update({ self._session.headers.update({
@@ -19,35 +31,44 @@ class HaClient:
"Content-Type": "application/json", "Content-Type": "application/json",
}) })
def list_entities(self): def list_entities(self) -> list[dict[str, object]]:
url = f"{self._settings.url}/api/states"
try: try:
response = self._session.get( response = self._session.get(
url, 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("Zeitüberschreitung beim Zugriff auf Home Assistant.") 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( raise HaHttpError(
getattr(exc.response, "status_code", 502), getattr(getattr(exc, "response", None), "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.", "Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc ) from exc
if response.status_code in (401, 403): 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: try:
response.raise_for_status() response.raise_for_status()
except requests.HTTPError as exc: 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: try:
payload = response.json() payload = response.json()
except ValueError as exc: 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): 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 return payload

View File

@@ -4,14 +4,20 @@ from __future__ import annotations
class HaClientError(Exception): class HaClientError(Exception):
"""Basisklasse für HA-Client-Fehler.""" """Basisklasse für HA-Client-Fehler."""
public_detail: str | None = None
class HaTimeoutError(HaClientError): class HaTimeoutError(HaClientError):
"""Zeitüberschreitung bei Request an Home Assistant.""" """Zeitüberschreitung bei Request an Home Assistant."""
public_detail = "Home Assistant request timed out."
class HaHttpError(HaClientError): class HaHttpError(HaClientError):
"""Nicht erfolgreicher HTTP-Statuscode.""" """Nicht erfolgreicher HTTP-Statuscode."""
public_detail = "Home Assistant request failed."
def __init__(self, status_code: int, message: str = "") -> None: def __init__(self, status_code: int, message: str = "") -> None:
super().__init__(message) super().__init__(message)
self.status_code = status_code self.status_code = status_code
@@ -20,6 +26,10 @@ class HaHttpError(HaClientError):
class HaAuthError(HaHttpError): class HaAuthError(HaHttpError):
"""Authentifizierung oder Berechtigung fehlgeschlagen.""" """Authentifizierung oder Berechtigung fehlgeschlagen."""
public_detail = "Home Assistant authentication failed."
class HaUnexpectedPayloadError(HaClientError): 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 __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any
from app.ha.client import HaClient from app.ha.client import HaClient
from app.ha.models import HaEntitySummary, HaState from app.ha.models import HaEntitySummary
class HaReader: class HaReader:
@@ -18,14 +19,21 @@ class HaReader:
if "." not in entity_id: if "." not in entity_id:
continue continue
domain = entity_id.split(".", 1)[0] 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( summaries.append(
HaEntitySummary( HaEntitySummary(
entity_id=entity_id, entity_id=entity_id,
domain=domain, domain=domain,
state_class=str(attributes.get("state_class") or ""), state_class=_optional_str(attributes.get("state_class")),
device_class=str(attributes.get("device_class") or ""), device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=str(attributes.get("unit_of_measurement") or ""), unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
) )
) )
return summaries 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 contextlib import asynccontextmanager
from fastapi import FastAPI, Depends 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.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.recommender import Recommender
@@ -11,44 +12,34 @@ from app.rules.heating import HeatingRule
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
ha_url = getattr(app.state.settings, "ha_url", None) settings = app.state.settings
ha_token = getattr(app.state.settings, "ha_token", None) ha_url = getattr(settings, "ha_url", None)
settings = HaClientSettings( ha_token = getattr(settings, "ha_token", None)
client = HaClient(
settings=HaClientSettings(
url=ha_url or "", url=ha_url or "",
token=ha_token or "", token=ha_token or "",
) )
client = HaClient(settings=settings) )
app.state.ha_reader = HaReader(client=client) app.state.ha_reader = HaReader(client=client)
app.state.recommender = Recommender(rules=[HeatingRule()]) 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,
) )
class Settings:
ha_url: str = "http://localhost:8123"
ha_token: str = ""
app.state.settings = Settings() app.state.settings = Settings()
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(entities_router)
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.get("/health") @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

View File

@@ -7,10 +7,12 @@ dependencies = [
"fastapi>=0.110.0", "fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0", "uvicorn[standard]>=0.29.0",
"pydantic>=2.6.0", "pydantic>=2.6.0",
"requests>=2.31.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"httpx2>=2.3.0",
"pytest>=8.0.0", "pytest>=8.0.0",
"ruff>=0.4.0", "ruff>=0.4.0",
"mypy>=1.9.0", "mypy>=1.9.0",

View File

@@ -1,5 +1,4 @@
import requests import requests
import json
from pathlib import Path from pathlib import Path
p = Path('/root/.openclaw/secrets/gitea.env') p = Path('/root/.openclaw/secrets/gitea.env')

View File

@@ -1,10 +1,63 @@
from collections.abc import Sequence
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.ha.exceptions import HaTimeoutError
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import app from app.main import app
client = TestClient(app)
class FakeHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> Sequence[HaEntitySummary]:
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
class TimeoutHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> Sequence[HaEntitySummary]:
raise HaTimeoutError("contains internal details that must not leak")
def test_openapi_docs_are_available() -> None: def test_openapi_docs_are_available() -> None:
with TestClient(app) as client:
response = client.get("/docs") response = client.get("/docs")
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next API" in response.text assert "SillyHome Next API" in response.text
def test_entities_returns_reader_data() -> None:
with TestClient(app) as client:
app.state.ha_reader = FakeHaReader()
response = client.get("/v1/entities")
assert response.status_code == 200
assert response.json() == [
{
"entity_id": "sensor.temperature",
"domain": "sensor",
"state_class": None,
"device_class": None,
"unit_of_measurement": None,
}
]
def test_entities_returns_503_without_home_assistant_config() -> None:
with TestClient(app) as client:
if hasattr(app.state, "ha_reader"):
delattr(app.state, "ha_reader")
response = client.get("/v1/entities")
assert response.status_code == 503
def test_entities_maps_ha_errors_without_leaking_details() -> None:
with TestClient(app) as client:
app.state.ha_reader = TimeoutHaReader()
response = client.get("/v1/entities")
assert response.status_code == 504
assert response.json() == {"detail": "Home Assistant request timed out."}

View File

@@ -1,60 +1,71 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock, patch from unittest.mock import Mock
import pytest import pytest
import requests import requests
from app.ha.client import HaClient from app.ha.client import HaClient, HaClientSettings
from app.ha.exceptions import HaAuthError, HaHttpError, HaTimeoutError, HaUnexpectedPayloadError from app.ha.exceptions import (
from tests.helpers import build_ha_client_settings HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
@pytest.fixture() def _client_with_response(response: Mock) -> HaClient:
def client(): client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
settings = build_ha_client_settings() client._session.get = Mock(return_value=response) # type: ignore[method-assign]
return HaClient(settings) return client
def test_list_entities_timeout(client): def _response(status_code: int = 200, payload: object | None = None) -> Mock:
with patch.object(client._session, "get", side_effect=requests.Timeout("t")): response = Mock()
response.status_code = status_code
response.json.return_value = [] if payload is None else payload
if status_code >= 400:
response.raise_for_status.side_effect = requests.HTTPError("upstream failed")
return response
def test_list_entities_returns_home_assistant_payload() -> None:
payload = [{"entity_id": "sensor.temperature", "state": "21"}]
client = _client_with_response(_response(payload=payload))
assert client.list_entities() == payload
def test_list_entities_maps_timeout() -> None:
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
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()
def test_list_entities_auth_error(client): @pytest.mark.parametrize("status_code", [401, 403])
response = MagicMock() def test_list_entities_maps_auth_errors(status_code: int) -> None:
response.status_code = 401 client = _client_with_response(_response(status_code=status_code))
response.raise_for_status = MagicMock() with pytest.raises(HaAuthError) as exc_info:
with patch.object(client._session, "get", return_value=response):
with pytest.raises(HaAuthError):
client.list_entities() client.list_entities()
assert exc_info.value.status_code == status_code
def test_list_entities_http_error(client): def test_list_entities_maps_http_errors() -> None:
response = MagicMock() client = _client_with_response(_response(status_code=500))
response.status_code = 502 with pytest.raises(HaHttpError) as exc_info:
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() client.list_entities()
assert exc_info.value.status_code == 500
def test_list_entities_invalid_json(client): def test_list_entities_rejects_invalid_json() -> None:
response = MagicMock() response = _response()
response.status_code = 200 response.json.side_effect = ValueError("not json")
response.raise_for_status = MagicMock() client = _client_with_response(response)
response.json = MagicMock(side_effect=ValueError("invalid json"))
with patch.object(client._session, "get", return_value=response):
with pytest.raises(HaUnexpectedPayloadError): with pytest.raises(HaUnexpectedPayloadError):
client.list_entities() client.list_entities()
def test_list_entities_wrong_payload_type(client): def test_list_entities_rejects_non_list_payload() -> None:
response = MagicMock() client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
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): with pytest.raises(HaUnexpectedPayloadError):
client.list_entities() client.list_entities()

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.models import HaEntitySummary, HaState
from app.ha.reader import HaReader from app.ha.reader import HaReader

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from app.ha.models import HaEntitySummary
from app.rules.heating import HeatingRule
from app.rules.recommender import Recommender
def _sensor(entity_id: str) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain="sensor")
def _climate(entity_id: str) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain="climate")
def test_heating_rule_triggers() -> None:
rule = HeatingRule()
assert rule.matches([_climate("climate.living_room")])
assert rule.matches([_sensor("sensor.temperature_living")])
def test_recommender_uses_rule() -> None:
recommender = Recommender(rules=[HeatingRule()])
assert recommender.run([_climate("climate.living_room")]) == [
"Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
]

View File

@@ -1,10 +1,10 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app) from app.main import app
def test_health_returns_ok() -> None: def test_health_returns_ok() -> None:
with TestClient(app) as client:
response = client.get("/health") response = client.get("/health")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}