Compare commits

...

10 Commits

24 changed files with 518 additions and 4 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."""

21
app/api/v1/entities.py Normal file
View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from collections.abc import Sequence
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"])
@router.get(
"/entities",
summary="Home-Assistant-Entities auflisten",
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
response_model=list[HaEntitySummary],
)
def list_entities(reader: HaReader = Depends(get_ha_reader)) -> Sequence[HaEntitySummary]:
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"),
)

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

@@ -0,0 +1 @@
"""Core application helpers."""

View File

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

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

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

@@ -0,0 +1 @@
# sillyhome-next.ha

67
app/ha/client.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import requests
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: HaClientSettings) -> None:
self._settings = settings
self._session = requests.Session()
self._session.headers.update({
"Authorization": f"Bearer {settings.token}",
"Content-Type": "application/json",
})
def list_entities(self) -> list[dict[str, Any]]:
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):
raise HaUnexpectedPayloadError("Home Assistant states response must be a list.")
return payload

27
app/ha/exceptions.py Normal file
View File

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

19
app/ha/models.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from pydantic import BaseModel
class HaState(BaseModel):
entity_id: str
state: str
attributes: dict[str, object] | None = None
last_changed: str | None = None
last_updated: str | None = None
class HaEntitySummary(BaseModel):
entity_id: str
domain: str
state_class: str | None = None
device_class: str | None = None
unit_of_measurement: str | None = None

39
app/ha/reader.py Normal file
View File

@@ -0,0 +1,39 @@
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
class HaReader:
def __init__(self, client: HaClient) -> None:
self._client = client
def read_entities(self) -> Sequence[HaEntitySummary]:
entities = self._client.list_entities()
summaries: list[HaEntitySummary] = []
for item in entities:
entity_id = item.get("entity_id", "")
if "." not in entity_id:
continue
domain = entity_id.split(".", 1)[0]
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=_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)

View File

@@ -1,11 +1,41 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
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
@asynccontextmanager
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
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.1.0",
lifespan=lifespan,
)
register_exception_handlers(app)
app.include_router(entities_router)
@app.get("/health")
def health() -> dict[str, str]:

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

@@ -0,0 +1 @@
# sillyhome-next.rules

15
app/rules/heating.py Normal file
View File

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

25
app/rules/recommender.py Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +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
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:
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."}

View File

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

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
class FakeHaClient(HaClient):
def __init__(self) -> None:
super().__init__(HaClientSettings(url="http://test", token="token"))
def list_entities(self) -> list[dict[str, object]]:
return [
{
"entity_id": "sensor.temperature",
"state": "21.5",
"attributes": {
"state_class": "measurement",
"device_class": "temperature",
"unit_of_measurement": "°C",
},
},
{
"entity_id": "light.living_room",
"state": "on",
"attributes": {},
},
]
def test_ha_reader_returns_summaries() -> None:
reader = HaReader(FakeHaClient())
summaries = reader.read_entities()
assert len(summaries) == 2
domains = {summary.domain for summary in summaries}
assert domains == {"sensor", "light"}
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
assert sensor.unit_of_measurement == "°C"

View File

@@ -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."
]

View File

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