Compare commits

...

7 Commits

11 changed files with 226 additions and 3 deletions

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

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

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

@@ -0,0 +1,33 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
import requests
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, object]]:
response = self._session.get(
f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds,
)
response.raise_for_status()
return response.json()

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

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

@@ -0,0 +1,31 @@
from __future__ import annotations
from collections.abc import Sequence
from app.ha.client import HaClient
from app.ha.models import HaEntitySummary, HaState
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]
attributes = item.get("attributes") or {}
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 ""),
)
)
return summaries

View File

@@ -1,12 +1,46 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from app.api.v1.entities import router as entities_router
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)
yield
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.1.0",
lifespan=lifespan,
)
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.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@@ -14,4 +48,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"}

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

@@ -25,4 +25,4 @@ strict = true
[tool.ruff]
line-length = 100
target-version = "py311"
target-version = "py311"

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from app.ha.client import HaClient, HaClientSettings
from app.ha.models import HaEntitySummary, HaState
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."
]