Compare commits
1 Commits
feature/do
...
otto/quali
| Author | SHA1 | Date | |
|---|---|---|---|
| 8841a68c8d |
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SillyHome Next application package."""
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
1
app/api/v1/__init__.py
Normal file
1
app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Version 1 API package."""
|
||||
@@ -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()
|
||||
|
||||
21
app/config.py
Normal file
21
app/config.py
Normal 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
15
app/dependencies.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
38
app/main.py
38
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"}
|
||||
return {"service": "sillyhome-next", "docs": "/docs"}
|
||||
|
||||
@@ -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"
|
||||
target-version = "py311"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import requests
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
p = Path('/root/.openclaw/secrets/gitea.env')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user