Compare commits
1 Commits
79e883f77d
...
otto/integ
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cb2630cec |
@@ -1,17 +0,0 @@
|
||||
.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
|
||||
@@ -1,2 +1,3 @@
|
||||
# Copy to .env for local development. Do not commit real tokens.
|
||||
SILLYHOME_HA_URL=http://homeassistant.local:8123
|
||||
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
|
||||
SILLYHOME_HA_TOKEN=replace-with-a-long-lived-access-token
|
||||
|
||||
31
.gitea/workflows/quality.yml
Normal file
31
.gitea/workflows/quality.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install project
|
||||
run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -q
|
||||
|
||||
- name: Run Ruff
|
||||
run: ruff check .
|
||||
|
||||
- name: Run Mypy
|
||||
run: mypy app tests
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ __pycache__/
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,15 +0,0 @@
|
||||
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"]
|
||||
52
README.md
52
README.md
@@ -13,40 +13,52 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
|
||||
- Lokal-first ohne Cloudpflicht
|
||||
- Erweiterbar, testbar, dokumentiert
|
||||
|
||||
## APPENDIX
|
||||
## Lokaler Quickstart
|
||||
|
||||
Voraussetzung ist Python 3.11 oder neuer.
|
||||
|
||||
### 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
|
||||
. .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. API starten:
|
||||
In `.env` müssen für echte Home-Assistant-Daten diese Werte gesetzt werden:
|
||||
|
||||
```bash
|
||||
SILLYHOME_HA_URL=http://homeassistant.local:8123
|
||||
SILLYHOME_HA_TOKEN=<long-lived-access-token>
|
||||
```
|
||||
|
||||
Alternativ werden aus Kompatibilitätsgründen auch `HA_URL` und `HA_TOKEN` gelesen.
|
||||
Tokens bleiben lokal und dürfen nicht committed, geloggt oder in Issues kopiert werden.
|
||||
|
||||
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
|
||||
Nützliche Checks:
|
||||
|
||||
### 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)
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/health
|
||||
curl http://127.0.0.1:8000/v1/entities
|
||||
```
|
||||
|
||||
Hinweis: Nutze ausschließlich Long-Lived Access Tokens mit Leserechten. Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht in Versionskontrollsysteme.
|
||||
Die interaktive API-Dokumentation liegt unter `http://127.0.0.1:8000/docs`.
|
||||
|
||||
## Qualität
|
||||
|
||||
Vor jedem Pull Request lokal laufen lassen:
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
pytest -q
|
||||
ruff check .
|
||||
mypy app tests
|
||||
```
|
||||
```
|
||||
|
||||
Der Gitea-Actions-Workflow in `.gitea/workflows/quality.yml` führt dieselben Checks für
|
||||
Pushes und Pull Requests aus.
|
||||
|
||||
@@ -1,39 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
from collections.abc import Sequence
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.dependencies import get_ha_reader
|
||||
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],
|
||||
response_model=list[HaEntitySummary],
|
||||
)
|
||||
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
|
||||
def list_entities(reader: HaReader = Depends(get_ha_reader)) -> Sequence[HaEntitySummary]:
|
||||
return reader.read_entities()
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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)}
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
@@ -31,44 +32,36 @@ class HaClient:
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
def list_entities(self) -> list[dict[str, object]]:
|
||||
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("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
|
||||
raise HaTimeoutError("Home Assistant request timed out.") 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
|
||||
raise HaHttpError(status_code=502, message="Home Assistant request failed.") from exc
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
if response.status_code in {401, 403}:
|
||||
raise HaAuthError(
|
||||
response.status_code,
|
||||
"Authentifizierung bei Home Assistant fehlgeschlagen.",
|
||||
status_code=response.status_code,
|
||||
message="Home Assistant authentication failed.",
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise HaHttpError(
|
||||
response.status_code,
|
||||
"Home Assistant meldet einen Fehler.",
|
||||
status_code=response.status_code,
|
||||
message="Home Assistant returned an HTTP error.",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise HaUnexpectedPayloadError(
|
||||
"Antwort von Home Assistant ist kein gültiges JSON."
|
||||
) from exc
|
||||
raise HaUnexpectedPayloadError("Home Assistant returned invalid JSON.") from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise HaUnexpectedPayloadError(
|
||||
"Antwort von Home Assistant hat unerwartetes Format."
|
||||
)
|
||||
|
||||
return payload
|
||||
raise HaUnexpectedPayloadError("Home Assistant states response must be a list.")
|
||||
return payload
|
||||
|
||||
@@ -2,34 +2,26 @@ from __future__ import annotations
|
||||
|
||||
|
||||
class HaClientError(Exception):
|
||||
"""Basisklasse für HA-Client-Fehler."""
|
||||
"""Base class for Home Assistant integration failures."""
|
||||
|
||||
public_detail: str | None = None
|
||||
public_detail = "Home Assistant is currently unavailable."
|
||||
|
||||
|
||||
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 returned an error."
|
||||
|
||||
public_detail = "Home Assistant request failed."
|
||||
|
||||
def __init__(self, status_code: int, message: str = "") -> None:
|
||||
super().__init__(message)
|
||||
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):
|
||||
"""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."
|
||||
public_detail = "Home Assistant returned an unexpected response."
|
||||
|
||||
35
app/main.py
35
app/main.py
@@ -1,44 +1,39 @@
|
||||
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
|
||||
from app.rules.recommender import Recommender
|
||||
from app.rules.heating import HeatingRule
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
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 "",
|
||||
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)
|
||||
app.state.recommender = Recommender(rules=[HeatingRule()])
|
||||
app.state.ha_reader = HaReader(client=client)
|
||||
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,
|
||||
)
|
||||
app.state.settings = Settings()
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
app.include_router(entities_router)
|
||||
|
||||
|
||||
@@ -49,4 +44,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,5 +0,0 @@
|
||||
|
||||
"""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
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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]
|
||||
@@ -1,33 +0,0 @@
|
||||
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])
|
||||
@@ -1,37 +0,0 @@
|
||||
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]
|
||||
@@ -6,29 +6,26 @@ from app.ha.models import HaEntitySummary
|
||||
from app.rules.recommender import Rule
|
||||
|
||||
|
||||
HEATING_SENSOR_DEVICE_CLASSES = frozenset({"temperature", "humidity"})
|
||||
HEATING_BINARY_SENSOR_DEVICE_CLASSES = frozenset({"occupancy", "presence"})
|
||||
|
||||
|
||||
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":
|
||||
for entity in entities:
|
||||
if entity.domain == "climate":
|
||||
return True
|
||||
if item.domain == "sensor" and item.device_class in self.HEATING_SENSOR_CLASSES:
|
||||
if (
|
||||
entity.domain == "sensor"
|
||||
and entity.device_class in HEATING_SENSOR_DEVICE_CLASSES
|
||||
):
|
||||
return True
|
||||
if item.domain == "binary_sensor" and item.device_class in self.HEATING_PRESENCE_CLASSES:
|
||||
if (
|
||||
entity.domain == "binary_sensor"
|
||||
and entity.device_class in HEATING_BINARY_SENSOR_DEVICE_CLASSES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
|
||||
return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
|
||||
return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
@@ -15,7 +15,7 @@ from app.ha.exceptions import (
|
||||
|
||||
|
||||
def _client_with_response(response: Mock) -> HaClient:
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token"))
|
||||
client._session.get = Mock(return_value=response) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
@@ -32,12 +32,14 @@ def _response(status_code: int = 200, payload: object | None = None) -> Mock:
|
||||
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]
|
||||
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()
|
||||
|
||||
@@ -45,15 +47,19 @@ def test_list_entities_maps_timeout() -> None:
|
||||
@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
|
||||
|
||||
|
||||
@@ -61,11 +67,13 @@ 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()
|
||||
client.list_entities()
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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", [])
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
@@ -1,48 +0,0 @@
|
||||
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"
|
||||
@@ -1,48 +0,0 @@
|
||||
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")
|
||||
@@ -1,64 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.rules.heating import HeatingRule
|
||||
from app.rules.recommender import Recommender
|
||||
|
||||
|
||||
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)
|
||||
def _sensor(entity_id: str, device_class: str | None = None) -> HaEntitySummary:
|
||||
return HaEntitySummary(entity_id=entity_id, domain="sensor", 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:
|
||||
def _binary_sensor(entity_id: str, device_class: str | None = None) -> HaEntitySummary:
|
||||
return HaEntitySummary(
|
||||
entity_id=entity_id,
|
||||
domain="binary_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([entity]) is True
|
||||
assert rule.matches([_climate("climate.living_room")])
|
||||
assert rule.matches([_sensor("sensor.temperature_living", device_class="temperature")])
|
||||
assert rule.matches([_sensor("sensor.humidity_bath", device_class="humidity")])
|
||||
assert rule.matches([_binary_sensor("binary_sensor.occupancy_living", "occupancy")])
|
||||
assert rule.matches([_binary_sensor("binary_sensor.presence_entry", "presence")])
|
||||
|
||||
|
||||
# --- 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:
|
||||
def test_heating_rule_ignores_non_relevant_sensors() -> None:
|
||||
rule = HeatingRule()
|
||||
assert rule.matches([entity]) is False
|
||||
assert not rule.matches([_sensor("sensor.temperature_living")])
|
||||
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")])
|
||||
assert not rule.matches([_binary_sensor("binary_sensor.window", device_class="window")])
|
||||
|
||||
|
||||
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"),
|
||||
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."
|
||||
]
|
||||
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
|
||||
Reference in New Issue
Block a user