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"}