Compare commits
2 Commits
dfc97c24ee
...
otto/main-
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb2630cec | |||
| 29ec53cc5e |
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# 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
|
||||
31
.gitea/workflows/quality.yml
Normal file
31
.gitea/workflows/quality.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
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
1
.gitignore
vendored
@@ -10,3 +10,4 @@ __pycache__/
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
50
README.md
50
README.md
@@ -12,3 +12,53 @@ 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.
|
||||
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core application helpers."""
|
||||
23
app/core/exception_handlers.py
Normal file
23
app/core/exception_handlers.py
Normal 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
|
||||
@@ -6,6 +6,13 @@ from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from app.ha.exceptions import (
|
||||
HaAuthError,
|
||||
HaHttpError,
|
||||
HaTimeoutError,
|
||||
HaUnexpectedPayloadError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,13 +33,35 @@ class HaClient:
|
||||
})
|
||||
|
||||
def list_entities(self) -> list[dict[str, Any]]:
|
||||
response = self._session.get(
|
||||
f"{self._settings.url}/api/states",
|
||||
timeout=self._settings.timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
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
|
||||
except requests.RequestException as exc:
|
||||
raise HaHttpError(status_code=502, message="Home Assistant request failed.") from exc
|
||||
|
||||
if response.status_code in {401, 403}:
|
||||
raise HaAuthError(
|
||||
status_code=response.status_code,
|
||||
message="Home Assistant authentication failed.",
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise HaHttpError(
|
||||
status_code=response.status_code,
|
||||
message="Home Assistant returned an HTTP error.",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise HaUnexpectedPayloadError("Home Assistant returned invalid JSON.") from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
msg = "Home Assistant states response must be a list."
|
||||
raise TypeError(msg)
|
||||
raise HaUnexpectedPayloadError("Home Assistant states response must be a list.")
|
||||
return payload
|
||||
|
||||
27
app/ha/exceptions.py
Normal file
27
app/ha/exceptions.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HaClientError(Exception):
|
||||
"""Base class for Home Assistant integration failures."""
|
||||
|
||||
public_detail = "Home Assistant is currently unavailable."
|
||||
|
||||
|
||||
class HaTimeoutError(HaClientError):
|
||||
public_detail = "Home Assistant request timed out."
|
||||
|
||||
|
||||
class HaHttpError(HaClientError):
|
||||
public_detail = "Home Assistant returned an error."
|
||||
|
||||
def __init__(self, status_code: int, message: str | None = None) -> None:
|
||||
super().__init__(message or self.public_detail)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class HaAuthError(HaHttpError):
|
||||
public_detail = "Home Assistant authentication failed."
|
||||
|
||||
|
||||
class HaUnexpectedPayloadError(HaClientError):
|
||||
public_detail = "Home Assistant returned an unexpected response."
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
|
||||
@@ -31,6 +32,7 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
app.include_router(entities_router)
|
||||
|
||||
|
||||
@@ -6,10 +6,26 @@ 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:
|
||||
domains = {item.domain for item in entities}
|
||||
return "climate" in domains or "sensor" in domains
|
||||
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
|
||||
|
||||
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."
|
||||
|
||||
@@ -2,6 +2,7 @@ from collections.abc import Sequence
|
||||
|
||||
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
|
||||
@@ -15,6 +16,14 @@ class FakeHaReader(HaReader):
|
||||
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:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/docs")
|
||||
@@ -44,3 +53,11 @@ def test_entities_returns_503_without_home_assistant_config() -> None:
|
||||
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."}
|
||||
|
||||
79
tests/ha/test_ha_client.py
Normal file
79
tests/ha/test_ha_client.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from app.ha.client import HaClient, HaClientSettings
|
||||
from app.ha.exceptions import (
|
||||
HaAuthError,
|
||||
HaHttpError,
|
||||
HaTimeoutError,
|
||||
HaUnexpectedPayloadError,
|
||||
)
|
||||
|
||||
|
||||
def _client_with_response(response: Mock) -> HaClient:
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token"))
|
||||
client._session.get = Mock(return_value=response) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _response(status_code: int = 200, payload: object | None = None) -> Mock:
|
||||
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="secret-token"))
|
||||
client._session.get = Mock(side_effect=requests.Timeout("secret-token")) # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(HaTimeoutError):
|
||||
client.list_entities()
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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()
|
||||
@@ -5,8 +5,16 @@ 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 _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 _climate(entity_id: str) -> HaEntitySummary:
|
||||
@@ -16,11 +24,25 @@ 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")])
|
||||
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")])
|
||||
|
||||
|
||||
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."
|
||||
]
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user