Compare commits

...

12 Commits

37 changed files with 891 additions and 41 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
.env
.env.*
!.env.example
.venv
.venv/*
__pycache__
.mypy_cache
.pytest_cache
.ruff_cache
node_modules
.idea
.vscode
.git
.gitignore
README.md
.dockerignore
docker-compose*.yml

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml ./
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -e ".[dev]"
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -12,3 +12,41 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Automationen vorschlagen und direkt generieren
- Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert
## APPENDIX
### Quickstart
1. Python-Venv anlegen und Abhängigkeiten installieren:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .
```
2. Konfiguration aus `.env.example` übernehmen und anpassen:
```bash
cp .env.example .env
```
3. API starten:
```bash
uvicorn app.main:app --reload
```
4. Erreichbar unter:
- `http://127.0.0.1:8000/health` - Health-Check
- `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation
- `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities
### ENV-Konfiguration (`.env.example`)
- `SILLYHOME_HA_URL` Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`)
- `SILLYHOME_HA_TOKEN` Long-Lived Access Token aus Home Assistant (nur lesen)
Hinweis: Nutze ausschließlich Long-Lived Access Tokens mit Leserechten. Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht in Versionskontrollsysteme.
### Tests
```bash
pytest -q
ruff check .
mypy app tests
```

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,19 +1,39 @@
from __future__ import annotations
from typing import List, Sequence
from typing import List
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Request
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
router = APIRouter(prefix="/v1", tags=["entities"])
def _state_ha_reader(request: Request) -> HaReader:
try:
return request.app.state.ha_reader
except AttributeError as exc:
raise HTTPException(status_code=503, detail="HA-Reader nicht initialisiert.") from exc
def _state_recommender(request: Request) -> Recommender:
try:
return request.app.state.recommender
except AttributeError as exc:
raise HTTPException(status_code=503, detail="Recommender nicht initialisiert.") from exc
@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.")
def list_entities(request: Request) -> List[HaEntitySummary]:
ha_reader = _state_ha_reader(request)
recommender = _state_recommender(request)
entities = ha_reader.read_entities()
recommender.run(entities)
return 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

20
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Request
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HaClientError)
async def handle_ha_client_error(request: Request, exc: HaClientError) -> Any: # pragma: no cover - einfacher Wrapper
if isinstance(exc, HaAuthError):
return {"detail": "Ungültige Authentifizierung gegenüber Home Assistant."}
if isinstance(exc, HaHttpError):
return {
"detail": "Home Assistant meldet einen Fehler.",
"upstream_status": exc.status_code,
}
return {"detail": str(exc)}

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

@@ -5,6 +5,13 @@ from dataclasses import dataclass
import requests
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
logger = logging.getLogger(__name__)
@@ -25,9 +32,43 @@ class HaClient:
})
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()
try:
response = self._session.get(
f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
except requests.RequestException as exc:
raise HaHttpError(
getattr(getattr(exc, "response", None), "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc
if response.status_code in (401, 403):
raise HaAuthError(
response.status_code,
"Authentifizierung bei Home Assistant fehlgeschlagen.",
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise HaHttpError(
response.status_code,
"Home Assistant meldet einen Fehler.",
) from exc
try:
payload = response.json()
except ValueError as exc:
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant ist kein gültiges JSON."
) from exc
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant hat unerwartetes Format."
)
return payload

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

@@ -0,0 +1,35 @@
from __future__ import annotations
class HaClientError(Exception):
"""Basisklasse für HA-Client-Fehler."""
public_detail: str | None = None
class HaTimeoutError(HaClientError):
"""Zeitüberschreitung bei Request an Home Assistant."""
public_detail = "Home Assistant request timed out."
class HaHttpError(HaClientError):
"""Nicht erfolgreicher HTTP-Statuscode."""
public_detail = "Home Assistant request failed."
def __init__(self, status_code: int, message: str = "") -> None:
super().__init__(message)
self.status_code = status_code
class HaAuthError(HaHttpError):
"""Authentifizierung oder Berechtigung fehlgeschlagen."""
public_detail = "Home Assistant authentication failed."
class HaUnexpectedPayloadError(HaClientError):
"""Antwort hat nicht das erwartete Format."""
public_detail = "Home Assistant returned an unexpected payload."

View File

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

View File

@@ -1,44 +1,45 @@
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.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
from app.rules.heating import HeatingRule
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = HaClientSettings(
url=app.state.settings.ha_url,
token=app.state.settings.ha_token,
settings = app.state.settings
ha_url = getattr(settings, "ha_url", None)
ha_token = getattr(settings, "ha_token", None)
client = HaClient(
settings=HaClientSettings(
url=ha_url or "",
token=ha_token or "",
)
)
client = HaClient(settings=settings)
app.state.ha_reader = HaReader(client=client)
app.state.recommender = Recommender(rules=[HeatingRule()])
yield
class Settings:
ha_url: str = "http://localhost:8123"
ha_token: str = ""
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)])
register_exception_handlers(app)
app.include_router(entities_router)
@app.get("/health")

5
app/ml/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Machine-Learning-Grundbausteine für SillyHome Next."""
__all__ = ["FeatureStore", "FeatureVector"]
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainedArtifact, TrainingPipeline

57
app/ml/evaluation.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Sequence
from app.ml.feature_store import FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
logger = logging.getLogger(__name__)
@dataclass
class Metric:
name: str
value: float
threshold: float | None = None
@dataclass
class EvalReport:
artifact_id: str
sample_size: int
metrics: list[Metric]
class Evaluator:
def __init__(self, pipeline: TrainingPipeline) -> None:
self._pipeline = pipeline
def evaluate(self, artifact_id: str, predictions: Sequence[str]) -> EvalReport:
artifacts = list(self._pipeline._artifacts)
if not artifacts:
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.")
supported_sensors = self._pipeline.export(artifact_id).supported_sensors
unknown_hits = sum(1 for prediction in predictions if ":" not in prediction)
supported_references = sum(1 for sensor in supported_sensors for prediction in predictions if sensor in prediction)
sample_size = len(predictions)
coverage = supported_references / sample_size if sample_size else 0.0
unknown_rate = unknown_hits / sample_size if sample_size else 0.0
coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8)
unknown_metric = Metric(name="unknown_rate", value=unknown_rate, threshold=0.1)
report = EvalReport(
artifact_id=artifact_id,
sample_size=sample_size,
metrics=[coverage_metric, unknown_metric],
)
logger.info(
"Evaluation %s -> coverage=%.2f, unknown_rate=%.2f",
artifact_id,
coverage,
unknown_rate,
)
return report

31
app/ml/feature_store.py Normal file
View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Iterable
@dataclass(frozen=True)
class FeatureVector:
sensor_id: str
values: dict[str, float]
label: str | None = None
class FeatureStore:
def __init__(self) -> None:
self._vectors: dict[str, list[FeatureVector]] = defaultdict(list)
def add(self, vector: FeatureVector) -> None:
self._vectors[vector.sensor_id].append(vector)
def add_batch(self, vectors: Iterable[FeatureVector]) -> None:
for vector in vectors:
self.add(vector)
def latest(self, sensor_id: str) -> FeatureVector | None:
series = self._vectors.get(sensor_id)
return series[-1] if series else None
def all(self) -> list[FeatureVector]:
return [vector for vectors in self._vectors.values() for vector in vectors]

33
app/ml/predictor.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import logging
from functools import lru_cache
from typing import Sequence
from app.ml.feature_store import FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
logger = logging.getLogger(__name__)
class Predictor:
def __init__(self, pipeline: TrainingPipeline) -> None:
self._pipeline = pipeline
def predict(self, artifact_id: str, entity: FeatureVector) -> str:
artifact = self._pipeline.export(artifact_id)
if entity.sensor_id not in artifact.supported_sensors:
raise ValueError(
f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt."
)
return f"{artifact_id}:{entity.sensor_id}:{entity.values}"
def predict_batch(self, artifact_id: str, entities: Sequence[FeatureVector]) -> list[str]:
return [self.predict(artifact_id, entity) for entity in entities]
@staticmethod
def default_artifact(pipeline: TrainingPipeline) -> TrainedArtifact:
artifacts = list(pipeline._artifacts)
if not artifacts:
raise ValueError("Kein trainiertes Modell gefunden.")
return pipeline.export(artifacts[-1])

37
app/ml/training.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Sequence
from app.ml.feature_store import FeatureVector, FeatureStore
logger = logging.getLogger(__name__)
@dataclass
class TrainedArtifact:
artifact_id: str
supported_sensors: tuple[str, ...]
class TrainingPipeline:
def __init__(self, store: FeatureStore) -> None:
self._store = store
self._artifacts: dict[str, TrainedArtifact] = {}
def run(self, artifact_id: str) -> TrainedArtifact:
vectors = self._store.all()
if not vectors:
raise ValueError("FeatureStore enthält keine Trainingsdaten.")
sensors = tuple({vector.sensor_id for vector in vectors})
artifact = TrainedArtifact(artifact_id=artifact_id, supported_sensors=sensors)
self._artifacts[artifact_id] = artifact
logger.info("Training abgeschlossen für %s mit %d Sensoren", artifact_id, len(sensors))
return artifact
def export(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
return self._artifacts[artifact_id]

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

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

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

@@ -0,0 +1,34 @@
from __future__ import annotations
from collections.abc import Sequence
from app.ha.models import HaEntitySummary
from app.rules.recommender import Rule
class HeatingRule(Rule):
"""Heizungsregel: Nur auf heizungsrelevante Entitäten reagieren.
Triggert bei:
- `climate`-Entitäten direkt
- `sensor` mit `device_class` in {temperature, humidity}
- `binary_sensor` mit `device_class` in {occupancy, presence}
Alle anderen Domains/Device-Klassen bleiben ohne Effekt.
"""
HEATING_SENSOR_CLASSES: frozenset[str] = frozenset({"temperature", "humidity"})
HEATING_PRESENCE_CLASSES: frozenset[str] = frozenset({"occupancy", "presence"})
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
for item in entities:
if item.domain == "climate":
return True
if item.domain == "sensor" and item.device_class in self.HEATING_SENSOR_CLASSES:
return True
if item.domain == "binary_sensor" and item.device_class in self.HEATING_PRESENCE_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

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
api:
build: .
ports:
- "8000:8000"
env_file:
- .env
restart: unless-stopped

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

@@ -1,10 +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
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")]
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:
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
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,71 @@
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="test-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="test-token"))
client._session.get = Mock(side_effect=requests.Timeout("timed out")) # 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

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

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from app.ml.evaluation import Evaluator
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def evaluator_factory() -> Evaluator:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Evaluator(pipeline)
def test_evaluate_returns_report_with_metrics() -> None:
evaluator = evaluator_factory()
report = evaluator.evaluate("artifact_v1", ["artifact_v1:sensor.kitchen:{'temperature': 21.0}", "artifact_v1:sensor.bedroom:{'temperature': 18.5}"])
assert report.artifact_id == "artifact_v1"
assert report.sample_size == 2
assert {metric.name for metric in report.metrics} == {"coverage", "unknown_rate"}
def test_evaluate_without_training_raises_value_error() -> None:
evaluator = Evaluator(TrainingPipeline(FeatureStore()))
with pytest.raises(ValueError):
evaluator.evaluate("artifact_v1", [])

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def test_append_and_latest_returns_last_vector() -> None:
store = FeatureStore()
vectors = [_vector("sensor.living_room", 20.0), _vector("sensor.living_room", 21.5)]
for item in vectors:
store.add(item)
assert store.latest("sensor.living_room") == vectors[-1]
def test_latest_returns_none_when_empty() -> None:
store = FeatureStore()
assert store.latest("sensor.living_room") is None
def test_add_batch_appends_all_vectors() -> None:
store = FeatureStore()
vectors = [
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bathroom", 23.5),
]
store.add_batch(vectors)
assert len(store.all()) == 3
assert store.latest("sensor.kitchen").values["temperature"] == 20.0
def test_different_sensors_are_stored_independently() -> None:
store = FeatureStore()
store.add(_vector("sensor.living_room", 21.0))
store.add(_vector("sensor.bedroom", 18.5))
assert store.latest("sensor.living_room").values["temperature"] == 21.0
assert store.latest("sensor.bedroom").values["temperature"] == 18.5

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.predictor import Predictor
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def predictor() -> Predictor:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Predictor(pipeline)
def test_predict_returns_expected_format() -> None:
p = predictor()
result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0))
assert result == "artifact_v1:sensor.kitchen:{'temperature': 21.0}"
def test_predict_rejects_unknown_sensor() -> None:
p = predictor()
with pytest.raises(ValueError):
p.predict("artifact_v1", _vector("sensor.unknown", 10.0))
def test_predict_batch_matches_single_calls() -> None:
p = predictor()
entities = [_vector("sensor.kitchen", 21.0), _vector("sensor.bedroom", 19.0)]
assert p.predict_batch("artifact_v1", entities) == [
p.predict("artifact_v1", item) for item in entities
]
def test_default_artifact_returns_last_registered() -> None:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("first")
pipeline.run("second")
assert Predictor.default_artifact(pipeline).artifact_id == "second"

48
tests/ml/test_training.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def store_with_data() -> TrainingPipeline:
store = FeatureStore()
store.add_batch(
[
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bedroom", 18.5),
]
)
return TrainingPipeline(store)
def test_run_returns_trained_artifact() -> None:
pipeline = store_with_data()
artifact = pipeline.run("artifact_v1")
assert artifact.artifact_id == "artifact_v1"
assert artifact.supported_sensors == ("sensor.bedroom", "sensor.kitchen")
def test_run_without_data_raises_value_error() -> None:
pipeline = TrainingPipeline(FeatureStore())
with pytest.raises(ValueError):
pipeline.run("artifact_v1")
def test_export_returns_registered_artifact() -> None:
pipeline = store_with_data()
pipeline.run("artifact_v1")
exported = pipeline.export("artifact_v1")
assert exported == pipeline.export("artifact_v1")
def test_export_missing_artifact_raises_key_error() -> None:
pipeline = store_with_data()
with pytest.raises(KeyError):
pipeline.export("artifact_v1")

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import pytest
from app.ha.models import HaEntitySummary
from app.rules.heating import HeatingRule
def _entity(entity_id: str, domain: str, device_class: str | None = None) -> HaEntitySummary:
return HaEntitySummary(entity_id=entity_id, domain=domain, device_class=device_class)
# --- positive cases --------------------------------------------------------
@pytest.mark.parametrize(
"entity",
[
_entity("climate.living_room", "climate"),
_entity("sensor.temperature_living", "sensor", "temperature"),
_entity("sensor.humidity_bathroom", "sensor", "humidity"),
_entity("binary_sensor.living_room_occupancy", "binary_sensor", "occupancy"),
_entity("binary_sensor.entrance_presence", "binary_sensor", "presence"),
],
ids=lambda e: e.entity_id,
)
def test_heating_rule_triggers_for_relevant_entities(entity: HaEntitySummary) -> None:
rule = HeatingRule()
assert rule.matches([entity]) is True
# --- negative cases -------------------------------------------------------
@pytest.mark.parametrize(
"entity",
[
_entity("sensor.power_consumption", "sensor", "power"),
_entity("sensor.door", "sensor", "door"),
_entity("sensor.energy", "sensor", "energy"),
_entity("binary_sensor.door_window", "binary_sensor", "door"),
_entity("binary_sensor.motion", "binary_sensor", "motion"),
_entity("light.living_room", "light"),
_entity("switch.plug", "switch"),
_entity("sensor.some_random", "sensor"),
_entity("binary_sensor.some_binary", "binary_sensor"),
],
ids=lambda e: e.entity_id,
)
def test_heating_rule_ignores_non_heating_entities(entity: HaEntitySummary) -> None:
rule = HeatingRule()
assert rule.matches([entity]) is False
def test_heating_rule_mixed_list_returns_true() -> None:
rule = HeatingRule()
entities = [
_entity("sensor.power", "sensor", "power"),
_entity("climate.living_room", "climate"),
_entity("light.ceiling", "light"),
]
assert rule.matches(entities) is True
def test_heating_rule_recommendation_is_stable() -> None:
rule = HeatingRule()
expected = "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
assert rule.recommendation([_entity("climate.living_room", "climate")]) == expected

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