diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..6451a34 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""SillyHome Next application package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..7939870 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +"""Version 1 API package.""" diff --git a/app/api/v1/entities.py b/app/api/v1/entities.py index ef89ae7..e05ad73 100644 --- a/app/api/v1/entities.py +++ b/app/api/v1/entities.py @@ -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 \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..fd9d619 --- /dev/null +++ b/app/config.py @@ -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"), + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..3220b9a --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core application helpers.""" diff --git a/app/core/exception_handlers.py b/app/core/exception_handlers.py new file mode 100644 index 0000000..6e89857 --- /dev/null +++ b/app/core/exception_handlers.py @@ -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 diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..de4207c --- /dev/null +++ b/app/dependencies.py @@ -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 diff --git a/app/ha/client.py b/app/ha/client.py index 7fcbf3c..69dd1f2 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -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 \ No newline at end of file diff --git a/app/ha/exceptions.py b/app/ha/exceptions.py index b2f50cf..f2e01f2 100644 --- a/app/ha/exceptions.py +++ b/app/ha/exceptions.py @@ -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.""" \ No newline at end of file + """Antwort hat nicht das erwartete Format.""" + + public_detail = "Home Assistant returned an unexpected payload." \ No newline at end of file diff --git a/app/ha/reader.py b/app/ha/reader.py index ccb72c0..828d28b 100644 --- a/app/ha/reader.py +++ b/app/ha/reader.py @@ -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) diff --git a/app/main.py b/app/main.py index 563dcdf..4d8c33c 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/rules/__init__.py b/app/rules/__init__.py new file mode 100644 index 0000000..f390491 --- /dev/null +++ b/app/rules/__init__.py @@ -0,0 +1 @@ +# sillyhome-next.rules \ No newline at end of file diff --git a/app/rules/heating.py b/app/rules/heating.py new file mode 100644 index 0000000..994711a --- /dev/null +++ b/app/rules/heating.py @@ -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." \ No newline at end of file diff --git a/app/rules/recommender.py b/app/rules/recommender.py new file mode 100644 index 0000000..ca44f48 --- /dev/null +++ b/app/rules/recommender.py @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c28b433..cc8d904 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,12 @@ dependencies = [ "fastapi>=0.110.0", "uvicorn[standard]>=0.29.0", "pydantic>=2.6.0", + "requests>=2.31.0", ] [project.optional-dependencies] dev = [ + "httpx2>=2.3.0", "pytest>=8.0.0", "ruff>=0.4.0", "mypy>=1.9.0", diff --git a/scripts/gitea_setup.py b/scripts/gitea_setup.py index f9f46a2..3e37476 100644 --- a/scripts/gitea_setup.py +++ b/scripts/gitea_setup.py @@ -1,5 +1,4 @@ import requests -import json from pathlib import Path p = Path('/root/.openclaw/secrets/gitea.env') diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py index c98d5c3..6df2771 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -1,10 +1,63 @@ +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 -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: - response = client.get("/docs") + with TestClient(app) as client: + response = client.get("/docs") assert response.status_code == 200 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."} diff --git a/tests/ha/test_ha_client.py b/tests/ha/test_ha_client.py index 28e54c7..22dd41e 100644 --- a/tests/ha/test_ha_client.py +++ b/tests/ha/test_ha_client.py @@ -1,60 +1,71 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import Mock import pytest import requests -from app.ha.client import HaClient -from app.ha.exceptions import HaAuthError, HaHttpError, HaTimeoutError, HaUnexpectedPayloadError -from tests.helpers import build_ha_client_settings +from app.ha.client import HaClient, HaClientSettings +from app.ha.exceptions import ( + HaAuthError, + HaHttpError, + HaTimeoutError, + HaUnexpectedPayloadError, +) -@pytest.fixture() -def client(): - settings = build_ha_client_settings() - return HaClient(settings) +def _client_with_response(response: Mock) -> HaClient: + client = HaClient(HaClientSettings(url="http://ha.local", token="test-token")) + client._session.get = Mock(return_value=response) # type: ignore[method-assign] + return client -def test_list_entities_timeout(client): - with patch.object(client._session, "get", side_effect=requests.Timeout("t")): - with pytest.raises(HaTimeoutError): - client.list_entities() +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_auth_error(client): - response = MagicMock() - response.status_code = 401 - response.raise_for_status = MagicMock() - with patch.object(client._session, "get", return_value=response): - with pytest.raises(HaAuthError): - client.list_entities() +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_http_error(client): - response = MagicMock() - response.status_code = 502 - 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() +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): + client.list_entities() -def test_list_entities_invalid_json(client): - response = MagicMock() - response.status_code = 200 - response.raise_for_status = MagicMock() - response.json = MagicMock(side_effect=ValueError("invalid json")) - with patch.object(client._session, "get", return_value=response): - with pytest.raises(HaUnexpectedPayloadError): - 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_wrong_payload_type(client): - response = MagicMock() - 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): - client.list_entities() \ No newline at end of file +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() \ No newline at end of file diff --git a/tests/ha/test_ha_reader.py b/tests/ha/test_ha_reader.py index 02bbc40..8740759 100644 --- a/tests/ha/test_ha_reader.py +++ b/tests/ha/test_ha_reader.py @@ -1,7 +1,6 @@ from __future__ import annotations from app.ha.client import HaClient, HaClientSettings -from app.ha.models import HaEntitySummary, HaState from app.ha.reader import HaReader diff --git a/tests/rules/test_heating.py b/tests/rules/test_heating.py new file mode 100644 index 0000000..da4d753 --- /dev/null +++ b/tests/rules/test_heating.py @@ -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." + ] \ No newline at end of file diff --git a/tests/test_health.py b/tests/test_health.py index 83a4062..0fba9d7 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,10 +1,10 @@ from fastapi.testclient import TestClient -from app.main import app -client = TestClient(app) +from app.main import app def test_health_returns_ok() -> None: - response = client.get("/health") + with TestClient(app) as client: + response = client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"}