Compare commits

...

2 Commits

18 changed files with 192 additions and 41 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")

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

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

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