From 6540d62ff7359fd1fa5f17ae6de36780d7e76280 Mon Sep 17 00:00:00 2001 From: Pino Date: Wed, 10 Jun 2026 20:46:54 +0200 Subject: [PATCH 1/3] ml/rules-recommendations: regelbasierte Heizungsempfehlung und Recommender --- app/rules/__init__.py | 1 + app/rules/heating.py | 15 +++++++++++++++ app/rules/recommender.py | 25 +++++++++++++++++++++++++ pyproject.toml | 2 +- tests/rules/test_heating.py | 26 ++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 app/rules/__init__.py create mode 100644 app/rules/heating.py create mode 100644 app/rules/recommender.py create mode 100644 tests/rules/test_heating.py 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..c1d26ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,4 +25,4 @@ strict = true [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py311" \ No newline at end of file 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 From 8841a68c8d1eae3c370e30d9b4fbb07ac8ae7e74 Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 10 Jun 2026 21:15:41 +0200 Subject: [PATCH 2/3] fix api integration quality baseline --- app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/v1/__init__.py | 1 + app/api/v1/entities.py | 12 +++++++----- app/config.py | 21 ++++++++++++++++++++ app/dependencies.py | 15 ++++++++++++++ app/ha/client.py | 9 +++++++-- app/ha/reader.py | 18 ++++++++++++----- app/main.py | 38 +++++++++++++++--------------------- pyproject.toml | 4 +++- scripts/gitea_setup.py | 1 - tests/api/test_entities.py | 40 ++++++++++++++++++++++++++++++++++++-- tests/ha/test_ha_reader.py | 1 - tests/test_health.py | 6 +++--- 14 files changed, 126 insertions(+), 42 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/config.py create mode 100644 app/dependencies.py 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 8b6f293..34c15c3 100644 --- a/app/api/v1/entities.py +++ b/app/api/v1/entities.py @@ -1,10 +1,12 @@ from __future__ import annotations -from typing import List, Sequence +from collections.abc import Sequence -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from app.dependencies import get_ha_reader from app.ha.models import HaEntitySummary +from app.ha.reader import HaReader router = APIRouter(prefix="/v1", tags=["entities"]) @@ -13,7 +15,7 @@ router = APIRouter(prefix="/v1", tags=["entities"]) "/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() -> Sequence[HaEntitySummary]: - raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.") +def list_entities(reader: HaReader = Depends(get_ha_reader)) -> Sequence[HaEntitySummary]: + return reader.read_entities() 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/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 5f64ef9..e952a4f 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging from dataclasses import dataclass +from typing import Any import requests @@ -24,10 +25,14 @@ class HaClient: "Content-Type": "application/json", }) - def list_entities(self) -> list[dict[str, object]]: + 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() - return response.json() + payload = response.json() + if not isinstance(payload, list): + msg = "Home Assistant states response must be a list." + raise TypeError(msg) + return payload 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 6826c1c..72d6839 100644 --- a/app/main.py +++ b/app/main.py @@ -1,20 +1,26 @@ +from collections.abc import AsyncIterator 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.config import load_settings from app.ha.client import HaClient, HaClientSettings from app.ha.reader import HaReader @asynccontextmanager -async def lifespan(app: FastAPI): - settings = HaClientSettings( - url=app.state.settings.ha_url, - token=app.state.settings.ha_token, - ) - client = HaClient(settings=settings) - app.state.ha_reader = HaReader(client=client) +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 "", + ) + ) + app.state.ha_reader = HaReader(client=client) yield @@ -26,19 +32,7 @@ app = FastAPI( ) -class Settings: - ha_url: str - ha_token: str - - -app.state.settings = Settings() - - -def get_ha_reader() -> HaReader: - return app.state.ha_reader - - -app.include_router(entities_router, dependencies=[Depends(get_ha_reader)]) +app.include_router(entities_router) @app.get("/health") @@ -48,4 +42,4 @@ def health() -> dict[str, str]: @app.get("/") def root() -> dict[str, str]: - return {"service": "sillyhome-next", "docs": "/docs"} \ No newline at end of file + return {"service": "sillyhome-next", "docs": "/docs"} diff --git a/pyproject.toml b/pyproject.toml index c1d26ab..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", @@ -25,4 +27,4 @@ strict = true [tool.ruff] line-length = 100 -target-version = "py311" \ No newline at end of file +target-version = "py311" 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..5db1e29 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -1,10 +1,46 @@ +from collections.abc import Sequence + from fastapi.testclient import TestClient + +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")] 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 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/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"} From 29ec53cc5ea4ea343687f0789280e6407ccb5719 Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 10 Jun 2026 21:24:34 +0200 Subject: [PATCH 3/3] add safe home assistant error handling --- app/core/__init__.py | 1 + app/core/exception_handlers.py | 23 ++++++++++ app/ha/client.py | 45 +++++++++++++++---- app/ha/exceptions.py | 27 ++++++++++++ app/main.py | 2 + tests/api/test_entities.py | 17 ++++++++ tests/ha/test_ha_client.py | 79 ++++++++++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 app/core/__init__.py create mode 100644 app/core/exception_handlers.py create mode 100644 app/ha/exceptions.py create mode 100644 tests/ha/test_ha_client.py 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/ha/client.py b/app/ha/client.py index e952a4f..44558bd 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -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 diff --git a/app/ha/exceptions.py b/app/ha/exceptions.py new file mode 100644 index 0000000..64ae389 --- /dev/null +++ b/app/ha/exceptions.py @@ -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." diff --git a/app/main.py b/app/main.py index 72d6839..55b06a5 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py index 5db1e29..6df2771 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -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."} diff --git a/tests/ha/test_ha_client.py b/tests/ha/test_ha_client.py new file mode 100644 index 0000000..131d606 --- /dev/null +++ b/tests/ha/test_ha_client.py @@ -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()