Compare commits

...

1 Commits

Author SHA1 Message Date
8841a68c8d fix api integration quality baseline 2026-06-10 21:15:41 +02:00
14 changed files with 126 additions and 42 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""SillyHome Next application package."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

1
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Version 1 API package."""

View File

@@ -1,10 +1,12 @@
from __future__ import annotations 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.models import HaEntitySummary
from app.ha.reader import HaReader
router = APIRouter(prefix="/v1", tags=["entities"]) router = APIRouter(prefix="/v1", tags=["entities"])
@@ -13,7 +15,7 @@ router = APIRouter(prefix="/v1", tags=["entities"])
"/entities", "/entities",
summary="Home-Assistant-Entities auflisten", summary="Home-Assistant-Entities auflisten",
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.", 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]: def list_entities(reader: HaReader = Depends(get_ha_reader)) -> Sequence[HaEntitySummary]:
raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.") return reader.read_entities()

21
app/config.py Normal file
View File

@@ -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"),
)

15
app/dependencies.py Normal file
View File

@@ -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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import requests import requests
@@ -24,10 +25,14 @@ class HaClient:
"Content-Type": "application/json", "Content-Type": "application/json",
}) })
def list_entities(self) -> list[dict[str, object]]: def list_entities(self) -> list[dict[str, Any]]:
response = self._session.get( response = self._session.get(
f"{self._settings.url}/api/states", f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds, timeout=self._settings.timeout_seconds,
) )
response.raise_for_status() 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

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any
from app.ha.client import HaClient from app.ha.client import HaClient
from app.ha.models import HaEntitySummary, HaState from app.ha.models import HaEntitySummary
class HaReader: class HaReader:
@@ -18,14 +19,21 @@ class HaReader:
if "." not in entity_id: if "." not in entity_id:
continue continue
domain = entity_id.split(".", 1)[0] 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( summaries.append(
HaEntitySummary( HaEntitySummary(
entity_id=entity_id, entity_id=entity_id,
domain=domain, domain=domain,
state_class=str(attributes.get("state_class") or ""), state_class=_optional_str(attributes.get("state_class")),
device_class=str(attributes.get("device_class") or ""), device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=str(attributes.get("unit_of_measurement") or ""), unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
) )
) )
return summaries return summaries
def _optional_str(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)

View File

@@ -1,20 +1,26 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager 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.api.v1.entities import router as entities_router
from app.config import load_settings
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader from app.ha.reader import HaReader
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = HaClientSettings( settings = load_settings()
url=app.state.settings.ha_url, app.state.settings = settings
token=app.state.settings.ha_token, if settings.ha_configured:
) client = HaClient(
client = HaClient(settings=settings) settings=HaClientSettings(
app.state.ha_reader = HaReader(client=client) url=settings.ha_url or "",
token=settings.ha_token or "",
)
)
app.state.ha_reader = HaReader(client=client)
yield yield
@@ -26,19 +32,7 @@ app = FastAPI(
) )
class Settings: app.include_router(entities_router)
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.get("/health") @app.get("/health")
@@ -48,4 +42,4 @@ def health() -> dict[str, str]:
@app.get("/") @app.get("/")
def root() -> dict[str, str]: def root() -> dict[str, str]:
return {"service": "sillyhome-next", "docs": "/docs"} return {"service": "sillyhome-next", "docs": "/docs"}

View File

@@ -7,10 +7,12 @@ dependencies = [
"fastapi>=0.110.0", "fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0", "uvicorn[standard]>=0.29.0",
"pydantic>=2.6.0", "pydantic>=2.6.0",
"requests>=2.31.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"httpx2>=2.3.0",
"pytest>=8.0.0", "pytest>=8.0.0",
"ruff>=0.4.0", "ruff>=0.4.0",
"mypy>=1.9.0", "mypy>=1.9.0",
@@ -25,4 +27,4 @@ strict = true
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py311" target-version = "py311"

View File

@@ -1,5 +1,4 @@
import requests import requests
import json
from pathlib import Path from pathlib import Path
p = Path('/root/.openclaw/secrets/gitea.env') p = Path('/root/.openclaw/secrets/gitea.env')

View File

@@ -1,10 +1,46 @@
from collections.abc import Sequence
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import app 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: 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 response.status_code == 200
assert "SillyHome Next API" in response.text 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

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.models import HaEntitySummary, HaState
from app.ha.reader import HaReader from app.ha.reader import HaReader

View File

@@ -1,10 +1,10 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app) from app.main import app
def test_health_returns_ok() -> None: 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.status_code == 200
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}