Compare commits

..

8 Commits

Author SHA1 Message Date
b8012de934 BUG-003: HeatingRule auf heizungsrelevante Sensoren begrenzen
- matches(): nur climate oder sensor mit device_class in {temperature, humidity, occupancy, presence, heating}
- Tests: ergänzt für relevante und nicht-relevante Sensoren
- Verhindert falsche Empfehlungen bei Strom-/Tür-/Wetter-Sensoren
2026-06-10 21:30:25 +02:00
6540d62ff7 ml/rules-recommendations: regelbasierte Heizungsempfehlung und Recommender 2026-06-10 20:46:54 +02:00
6b1e2ad0dc feature/ha-api-integration: lifespan und Settings für HA-Reader vorbereiten 2026-06-10 20:42:26 +02:00
6bba8f5947 Merge branch 'feature/api-core' into feature/ha-api-integration 2026-06-10 20:41:27 +02:00
009d4b68cb feature/ha-api-integration: API-Code aus feature/api-core übernehmen 2026-06-10 20:41:12 +02:00
9016dbad18 Merge branch 'feature/ha-adapter' into feature/ha-api-integration 2026-06-10 20:39:10 +02:00
d81ebf399c feature/ha-api-integration: lokale HA-Adapterdateien aus vorheriger Sitzung aufnehmen 2026-06-10 20:38:48 +02:00
bdf33458f0 feature/api-core: API-V1-Gerüst und Entitäten-Endpoint 2026-06-10 15:22:48 +02:00
9 changed files with 163 additions and 3 deletions

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

@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import List, Sequence
from fastapi import APIRouter
from app.ha.models import HaEntitySummary
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() -> Sequence[HaEntitySummary]:
raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.")

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

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

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

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

@@ -0,0 +1,29 @@
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}
if "climate" in domains:
return True
if "sensor" in domains:
# Nur Sensoren mit Heizungs-/Klimarelevanz
relevant_device_classes = {
"temperature",
"humidity",
"occupancy",
"presence",
"heating",
}
for entity in entities:
if entity.domain == "sensor" and entity.device_class in relevant_device_classes:
return True
return False
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,10 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_openapi_docs_are_available() -> None:
response = client.get("/docs")
assert response.status_code == 200
assert "SillyHome Next API" in response.text

View File

@@ -0,0 +1,41 @@
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, device_class: str | None = None) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain="sensor", device_class=device_class)
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")])
# Relevante Sensoren
assert rule.matches([_sensor("sensor.temperature_living", device_class="temperature")])
assert rule.matches([_sensor("sensor.humidity_bath", device_class="humidity")])
assert rule.matches([_sensor("sensor.occupancy_living", device_class="occupancy")])
assert rule.matches([_sensor("sensor.presence_entry", device_class="presence")])
assert rule.matches([_sensor("sensor.heating_status", device_class="heating")])
def test_heating_rule_ignores_non_relevant_sensors() -> None:
rule = HeatingRule()
# Nicht-relevante Sensoren
assert not rule.matches([_sensor("sensor.power", device_class="power")])
assert not rule.matches([_sensor("sensor.voltage", device_class="voltage")])
assert not rule.matches([_sensor("sensor.door", device_class="door")])
assert not rule.matches([_sensor("sensor.window", device_class="window")])
assert not rule.matches([_sensor("sensor.light", device_class="illuminance")])
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."
]