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
12 changed files with 108 additions and 181 deletions

View File

@@ -1,3 +0,0 @@
# Copy to .env for local development. Do not commit real tokens.
SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=replace-with-a-long-lived-access-token

View File

@@ -1,31 +0,0 @@
name: Quality
on:
push:
branches:
- "**"
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install project
run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
- name: Run tests
run: pytest -q
- name: Run Ruff
run: ruff check .
- name: Run Mypy
run: mypy app tests

1
.gitignore vendored
View File

@@ -10,4 +10,3 @@ __pycache__/
.env
.env.local
.env.*
!.env.example

View File

@@ -12,53 +12,3 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Automationen vorschlagen und direkt generieren
- Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert
## Lokaler Quickstart
Voraussetzung ist Python 3.11 oder neuer.
```bash
python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
cp .env.example .env
```
In `.env` müssen für echte Home-Assistant-Daten diese Werte gesetzt werden:
```bash
SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=<long-lived-access-token>
```
Alternativ werden aus Kompatibilitätsgründen auch `HA_URL` und `HA_TOKEN` gelesen.
Tokens bleiben lokal und dürfen nicht committed, geloggt oder in Issues kopiert werden.
API starten:
```bash
uvicorn app.main:app --reload
```
Nützliche Checks:
```bash
curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/v1/entities
```
Die interaktive API-Dokumentation liegt unter `http://127.0.0.1:8000/docs`.
## Qualität
Vor jedem Pull Request lokal laufen lassen:
```bash
pytest -q
ruff check .
mypy app tests
```
Der Gitea-Actions-Workflow in `.gitea/workflows/quality.yml` führt dieselben Checks für
Pushes und Pull Requests aus.

View File

@@ -1,21 +1,39 @@
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.reader import HaReader
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",
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]:
return reader.read_entities()
def list_entities(request: Request) -> List[HaEntitySummary]:
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
from dataclasses import dataclass
from typing import Any
import requests
@@ -32,36 +31,44 @@ class HaClient:
"Content-Type": "application/json",
})
def list_entities(self) -> list[dict[str, Any]]:
def list_entities(self) -> list[dict[str, object]]:
try:
response = self._session.get(
f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds,
)
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:
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(
status_code=response.status_code,
message="Home Assistant authentication failed.",
response.status_code,
"Authentifizierung bei Home Assistant fehlgeschlagen.",
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise HaHttpError(
status_code=response.status_code,
message="Home Assistant returned an HTTP error.",
response.status_code,
"Home Assistant meldet einen Fehler.",
) from exc
try:
payload = response.json()
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):
raise HaUnexpectedPayloadError("Home Assistant states response must be a list.")
return payload
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant hat unerwartetes Format."
)
return payload

View File

@@ -2,26 +2,34 @@ from __future__ import annotations
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):
"""Zeitüberschreitung bei Request an Home Assistant."""
public_detail = "Home Assistant request timed out."
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:
super().__init__(message or self.public_detail)
public_detail = "Home Assistant request failed."
def __init__(self, status_code: int, message: str = "") -> None:
super().__init__(message)
self.status_code = status_code
class HaAuthError(HaHttpError):
"""Authentifizierung oder Berechtigung fehlgeschlagen."""
public_detail = "Home Assistant authentication failed."
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 fastapi import FastAPI
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.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) -> AsyncIterator[None]:
settings = load_settings()
app.state.settings = settings
if settings.ha_configured:
client = HaClient(
settings=HaClientSettings(
url=settings.ha_url or "",
token=settings.ha_token or "",
)
async def lifespan(app: FastAPI):
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 "",
)
app.state.ha_reader = HaReader(client=client)
)
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,
)
app.state.settings = Settings()
register_exception_handlers(app)
app.include_router(entities_router)
@@ -44,4 +49,4 @@ def health() -> dict[str, str]:
@app.get("/")
def root() -> dict[str, str]:
return {"service": "sillyhome-next", "docs": "/docs"}
return {"service": "sillyhome-next", "docs": "/docs"}

View File

@@ -6,26 +6,10 @@ from app.ha.models import HaEntitySummary
from app.rules.recommender import Rule
HEATING_SENSOR_DEVICE_CLASSES = frozenset({"temperature", "humidity"})
HEATING_BINARY_SENSOR_DEVICE_CLASSES = frozenset({"occupancy", "presence"})
class HeatingRule(Rule):
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
for entity in entities:
if entity.domain == "climate":
return True
if (
entity.domain == "sensor"
and entity.device_class in HEATING_SENSOR_DEVICE_CLASSES
):
return True
if (
entity.domain == "binary_sensor"
and entity.device_class in HEATING_BINARY_SENSOR_DEVICE_CLASSES
):
return True
return False
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."
return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."

View File

@@ -15,7 +15,7 @@ from app.ha.exceptions import (
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]
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:
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="secret-token"))
client._session.get = Mock(side_effect=requests.Timeout("secret-token")) # type: ignore[method-assign]
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):
client.list_entities()
@@ -47,19 +45,15 @@ def test_list_entities_maps_timeout() -> None:
@pytest.mark.parametrize("status_code", [401, 403])
def test_list_entities_maps_auth_errors(status_code: int) -> None:
client = _client_with_response(_response(status_code=status_code))
with pytest.raises(HaAuthError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == status_code
def test_list_entities_maps_http_errors() -> None:
client = _client_with_response(_response(status_code=500))
with pytest.raises(HaHttpError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == 500
@@ -67,13 +61,11 @@ def test_list_entities_rejects_invalid_json() -> None:
response = _response()
response.json.side_effect = ValueError("not json")
client = _client_with_response(response)
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()
def test_list_entities_rejects_non_list_payload() -> None:
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()
client.list_entities()

View File

@@ -5,16 +5,8 @@ from app.rules.heating import HeatingRule
from app.rules.recommender import Recommender
def _sensor(entity_id: str, device_class: str | None = None) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain="sensor", device_class=device_class)
def _binary_sensor(entity_id: str, device_class: str | None = None) -> HaEntitySummary:
return HaEntitySummary(
entity_id=entity_id,
domain="binary_sensor",
device_class=device_class,
)
def _sensor(entity_id: str) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain="sensor")
def _climate(entity_id: str) -> HaEntitySummary:
@@ -24,25 +16,11 @@ def _climate(entity_id: str) -> HaEntitySummary:
def test_heating_rule_triggers() -> None:
rule = HeatingRule()
assert rule.matches([_climate("climate.living_room")])
assert rule.matches([_sensor("sensor.temperature_living", device_class="temperature")])
assert rule.matches([_sensor("sensor.humidity_bath", device_class="humidity")])
assert rule.matches([_binary_sensor("binary_sensor.occupancy_living", "occupancy")])
assert rule.matches([_binary_sensor("binary_sensor.presence_entry", "presence")])
def test_heating_rule_ignores_non_relevant_sensors() -> None:
rule = HeatingRule()
assert not rule.matches([_sensor("sensor.temperature_living")])
assert not rule.matches([_sensor("sensor.power", device_class="power")])
assert not rule.matches([_sensor("sensor.voltage", device_class="voltage")])
assert not rule.matches([_sensor("sensor.door", device_class="door")])
assert not rule.matches([_sensor("sensor.window", device_class="window")])
assert not rule.matches([_sensor("sensor.light", device_class="illuminance")])
assert not rule.matches([_binary_sensor("binary_sensor.window", device_class="window")])
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."
]
]