Compare commits
28 Commits
feature/ap
...
v0.1.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
| ecd32d4813 | |||
| aaf319ff14 | |||
| 471146761e | |||
| 3bed5e790a | |||
| 4b3dc3b7af | |||
| 63d10a6c4f | |||
| 0bc928799a | |||
| d6c48b495a | |||
| 57275d5172 | |||
| fad517e56a | |||
| 79e883f77d | |||
| 3cf9af3515 | |||
| 24be7a4f11 | |||
| 627ee03230 | |||
| 2fb086b1a1 | |||
| e2bc0644ae | |||
| 57ffd1dda6 | |||
| 445e4bcdf4 | |||
| d550030a1a | |||
| 29ec53cc5e | |||
| 8841a68c8d | |||
| 6540d62ff7 | |||
| 6b1e2ad0dc | |||
| 6bba8f5947 | |||
| 009d4b68cb | |||
| 9016dbad18 | |||
| d81ebf399c | |||
| 43fb8fac2e |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.venv
|
||||||
|
.venv/*
|
||||||
|
__pycache__
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
docker-compose*.yml
|
||||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
SILLYHOME_HA_URL=http://homeassistant.local:8123
|
||||||
|
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
|
||||||
|
SILLYHOME_MODEL_STORE=.model_store
|
||||||
24
.gitea/workflows/quality.yml
Normal file
24
.gitea/workflows/quality.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "otto/**", "feature/**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.11", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
- run: python -m pip install --upgrade pip
|
||||||
|
- run: python -m pip install -e ".[dev]"
|
||||||
|
- run: python -m pytest
|
||||||
|
- run: ruff check .
|
||||||
|
- run: mypy
|
||||||
@@ -3,3 +3,8 @@
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
- Projektinitiierung
|
- Projektinitiierung
|
||||||
- Architektur, ADRs und Roadmap
|
- Architektur, ADRs und Roadmap
|
||||||
|
- Einheitliche produktive FastAPI-App für HA- und ML-Routen
|
||||||
|
- Funktionierende ENV-Konfiguration und sauberer HA-503-Zustand
|
||||||
|
- Persistente, validierte und gegen Path Traversal gehärtete Model Registry
|
||||||
|
- Reproduzierbares Packaging, CI-Gates und gehärteter non-root Container
|
||||||
|
- Definierte API-Fehler und korrigierte Evaluationsmetriken
|
||||||
|
|||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
SILLYHOME_MODEL_STORE=/app/data/models
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system sillyhome && adduser --system --ingroup sillyhome sillyhome
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY app ./app
|
||||||
|
COPY backend ./backend
|
||||||
|
RUN python -m pip install --upgrade pip && \
|
||||||
|
python -m pip install . && \
|
||||||
|
mkdir -p /app/data/models && \
|
||||||
|
chown -R sillyhome:sillyhome /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
USER sillyhome
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
61
README.md
61
README.md
@@ -1,6 +1,13 @@
|
|||||||
# SillyHome Next
|
# SillyHome Next
|
||||||
|
|
||||||
Modern, lokal-first und datenschutzfreundliches Smart-Home-Intelligenzsystem für Home Assistant.
|
Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant.
|
||||||
|
|
||||||
|
## Reifegrad
|
||||||
|
|
||||||
|
Version `0.1.0` stellt eine gehärtete technische Basis bereit: Home-Assistant-Entities
|
||||||
|
lesen, regelbasierte Bausteine und eine persistente Modell-Artefakt-Registry. Die
|
||||||
|
aktuelle Trainings- und Vorhersagelogik ist noch eine deterministische
|
||||||
|
Schnittstellen-Implementierung und **kein produktives Machine-Learning-Modell**.
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
|
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
|
||||||
@@ -12,3 +19,55 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
|
|||||||
- Automationen vorschlagen und direkt generieren
|
- Automationen vorschlagen und direkt generieren
|
||||||
- Lokal-first ohne Cloudpflicht
|
- Lokal-first ohne Cloudpflicht
|
||||||
- Erweiterbar, testbar, dokumentiert
|
- Erweiterbar, testbar, dokumentiert
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
1. Python-Venv anlegen und Abhängigkeiten installieren:
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
|
||||||
|
|
||||||
|
Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build -d
|
||||||
|
curl --fail http://127.0.0.1:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose veröffentlicht die API standardmäßig nur auf `127.0.0.1`. Für Zugriff aus
|
||||||
|
dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden.
|
||||||
|
|
||||||
|
### 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 eines dedizierten HA-Benutzers mit minimalen Rechten
|
||||||
|
- `SILLYHOME_MODEL_STORE` – Verzeichnis für persistierte Modell-Metadaten
|
||||||
|
|
||||||
|
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
|
||||||
|
Versionskontrollsystem.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
ruff check .
|
||||||
|
mypy
|
||||||
|
```
|
||||||
|
|||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""SillyHome Next application package."""
|
||||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API package."""
|
||||||
1
app/api/v1/__init__.py
Normal file
1
app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Version 1 API package."""
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Sequence
|
from typing import List
|
||||||
|
|
||||||
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"])
|
||||||
|
|
||||||
@@ -15,5 +17,5 @@ router = APIRouter(prefix="/v1", tags=["entities"])
|
|||||||
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(ha_reader: HaReader = Depends(get_ha_reader)) -> List[HaEntitySummary]:
|
||||||
raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.")
|
return list(ha_reader.read_entities())
|
||||||
|
|||||||
23
app/config.py
Normal file
23
app/config.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
|
model_store: str = ".model_store"
|
||||||
|
|
||||||
|
@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"),
|
||||||
|
model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"),
|
||||||
|
)
|
||||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core application helpers."""
|
||||||
23
app/core/exception_handlers.py
Normal file
23
app/core/exception_handlers.py
Normal 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
20
app/core/exceptions.py
Normal 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
15
app/dependencies.py
Normal 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
|
||||||
1
app/ha/__init__.py
Normal file
1
app/ha/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# sillyhome-next.ha
|
||||||
77
app/ha/client.py
Normal file
77
app/ha/client.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.ha.exceptions import (
|
||||||
|
HaAuthError,
|
||||||
|
HaHttpError,
|
||||||
|
HaTimeoutError,
|
||||||
|
HaUnexpectedPayloadError,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 close(self) -> None:
|
||||||
|
self._session.close()
|
||||||
|
|
||||||
|
def list_entities(self) -> list[dict[str, object]]:
|
||||||
|
try:
|
||||||
|
response = self._session.get(
|
||||||
|
f"{self._settings.url.rstrip('/')}/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
35
app/ha/exceptions.py
Normal 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."
|
||||||
19
app/ha/models.py
Normal file
19
app/ha/models.py
Normal 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
|
||||||
40
app/ha/reader.py
Normal file
40
app/ha/reader.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
raw_entity_id = item.get("entity_id")
|
||||||
|
if not isinstance(raw_entity_id, str) or "." not in raw_entity_id:
|
||||||
|
continue
|
||||||
|
entity_id = raw_entity_id
|
||||||
|
domain = entity_id.split(".", 1)[0]
|
||||||
|
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=_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)
|
||||||
40
app/main.py
40
app/main.py
@@ -1,10 +1,50 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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.ml.registry.model_registry import ModelRegistry
|
||||||
|
from backend.routes.ml import init_ml_routes
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
settings = app.state.settings
|
||||||
|
client: HaClient | None = None
|
||||||
|
app.state.registry = ModelRegistry(settings.model_store)
|
||||||
|
if hasattr(app.state, "ha_reader"):
|
||||||
|
del app.state.ha_reader
|
||||||
|
if settings.ha_configured:
|
||||||
|
client = HaClient(
|
||||||
|
settings=HaClientSettings(
|
||||||
|
url=cast(str, settings.ha_url),
|
||||||
|
token=cast(str, settings.ha_token),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
app.state.ha_reader = HaReader(client=client)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SillyHome Next API",
|
title="SillyHome Next API",
|
||||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
app.state.settings = load_settings()
|
||||||
|
register_exception_handlers(app)
|
||||||
|
app.include_router(entities_router)
|
||||||
|
init_ml_routes(app, model_store=app.state.settings.model_store)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
5
app/ml/__init__.py
Normal file
5
app/ml/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
"""Machine-Learning-Grundbausteine für SillyHome Next."""
|
||||||
|
__all__ = ["FeatureStore", "FeatureVector", "TrainedArtifact", "TrainingPipeline"]
|
||||||
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
|
from app.ml.training import TrainedArtifact, TrainingPipeline
|
||||||
64
app/ml/evaluation.py
Normal file
64
app/ml/evaluation.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.ml.training import TrainingPipeline
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
supported_sensors = set(self._pipeline.export(artifact_id).supported_sensors)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") from exc
|
||||||
|
|
||||||
|
parsed_sensors = [_prediction_sensor(prediction) for prediction in predictions]
|
||||||
|
supported_hits = sum(sensor in supported_sensors for sensor in parsed_sensors)
|
||||||
|
unknown_hits = sum(sensor not in supported_sensors for sensor in parsed_sensors)
|
||||||
|
sample_size = len(predictions)
|
||||||
|
coverage = supported_hits / 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
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_sensor(prediction: str) -> str | None:
|
||||||
|
parts = prediction.split(":", 2)
|
||||||
|
if len(parts) != 3 or not parts[0] or not parts[1]:
|
||||||
|
return None
|
||||||
|
return parts[1]
|
||||||
31
app/ml/feature_store.py
Normal file
31
app/ml/feature_store.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@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]
|
||||||
50
app/ml/predictor.py
Normal file
50
app/ml/predictor.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from app.ml.feature_store import FeatureVector
|
||||||
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
|
from app.ml.training import TrainedArtifact, TrainingPipeline
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Predictor:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
pipeline: TrainingPipeline | None = None,
|
||||||
|
registry: ModelRegistry | None = None,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(pipeline, ModelRegistry) and registry is None:
|
||||||
|
registry = pipeline
|
||||||
|
pipeline = None
|
||||||
|
if pipeline is None and registry is None:
|
||||||
|
raise ValueError("Predictor erfordert TrainingPipeline oder ModelRegistry.")
|
||||||
|
self._pipeline = pipeline
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def predict(self, artifact_id: str, entity: FeatureVector) -> str:
|
||||||
|
artifact = self._get_artifact(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])
|
||||||
|
|
||||||
|
def _get_artifact(self, artifact_id: str) -> TrainedArtifact:
|
||||||
|
if self._registry is not None:
|
||||||
|
return self._registry.load_artifact(artifact_id)
|
||||||
|
if self._pipeline is not None:
|
||||||
|
return self._pipeline.export(artifact_id)
|
||||||
|
raise RuntimeError("Predictor nicht initialisiert.")
|
||||||
3
app/ml/registry/__init__.py
Normal file
3
app/ml/registry/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .model_registry import ModelRegistry
|
||||||
|
|
||||||
|
__all__ = ["ModelRegistry"]
|
||||||
80
app/ml/registry/model_registry.py
Normal file
80
app/ml/registry/model_registry.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from app.ml.training import TrainedArtifact
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ARTIFACT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRegistry:
|
||||||
|
def __init__(self, root: str | Path) -> None:
|
||||||
|
self._root = Path(root).resolve()
|
||||||
|
self._root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._artifacts: dict[str, TrainedArtifact] = {}
|
||||||
|
self._load_existing()
|
||||||
|
|
||||||
|
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
|
||||||
|
self._validate_artifact_id(artifact.artifact_id)
|
||||||
|
self._persist(artifact)
|
||||||
|
self._artifacts[artifact.artifact_id] = artifact
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
def load_artifact(self, artifact_id: str) -> TrainedArtifact:
|
||||||
|
self._validate_artifact_id(artifact_id)
|
||||||
|
if artifact_id not in self._artifacts:
|
||||||
|
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
|
||||||
|
return self._artifacts[artifact_id]
|
||||||
|
|
||||||
|
def list_models(self) -> Iterable[TrainedArtifact]:
|
||||||
|
return list(self._artifacts.values())
|
||||||
|
|
||||||
|
def _load_existing(self) -> None:
|
||||||
|
for source in sorted(self._root.glob("*.json")):
|
||||||
|
try:
|
||||||
|
raw = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
artifact_id = raw["artifact_id"]
|
||||||
|
supported_sensors = raw["supported_sensors"]
|
||||||
|
if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list):
|
||||||
|
raise ValueError("invalid artifact structure")
|
||||||
|
self._validate_artifact_id(artifact_id)
|
||||||
|
if source.name != f"{artifact_id}.json":
|
||||||
|
raise ValueError("artifact id does not match filename")
|
||||||
|
if not all(isinstance(sensor, str) for sensor in supported_sensors):
|
||||||
|
raise ValueError("supported_sensors must contain strings")
|
||||||
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError(f"Ungültiges Modell-Artefakt: {source.name}") from exc
|
||||||
|
|
||||||
|
self._artifacts[artifact_id] = TrainedArtifact(
|
||||||
|
artifact_id=artifact_id,
|
||||||
|
supported_sensors=tuple(supported_sensors),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _persist(self, artifact: TrainedArtifact) -> None:
|
||||||
|
target = self._root / f"{artifact.artifact_id}.json"
|
||||||
|
temporary = target.with_suffix(".json.tmp")
|
||||||
|
payload = {
|
||||||
|
"artifact_id": artifact.artifact_id,
|
||||||
|
"supported_sensors": list(artifact.supported_sensors),
|
||||||
|
}
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, target)
|
||||||
|
logger.info("Modell gespeichert: %s", target)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_artifact_id(artifact_id: str) -> None:
|
||||||
|
if not _ARTIFACT_ID_PATTERN.fullmatch(artifact_id) or ".." in artifact_id:
|
||||||
|
raise ValueError(
|
||||||
|
"artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich "
|
||||||
|
"und Bindestrich enthalten."
|
||||||
|
)
|
||||||
36
app/ml/training.py
Normal file
36
app/ml/training.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.ml.feature_store import 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(sorted({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
1
app/rules/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# sillyhome-next.rules
|
||||||
34
app/rules/heating.py
Normal file
34
app/rules/heating.py
Normal 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
25
app/rules/recommender.py
Normal 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
|
||||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Secondary application entry points for SillyHome Next."""
|
||||||
43
backend/app.py
Normal file
43
backend/app.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.datastructures import State
|
||||||
|
|
||||||
|
from backend.routes.ml import init_ml_routes
|
||||||
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
|
from app.ml.training import TrainingPipeline
|
||||||
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||||
|
application.state.registry = ModelRegistry(application.state.model_store)
|
||||||
|
_seed_default_model(application.state)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
|
||||||
|
init_ml_routes(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_default_model(state: State) -> None:
|
||||||
|
registry = getattr(state, "registry", None)
|
||||||
|
if registry is None:
|
||||||
|
registry = ModelRegistry(".model_store")
|
||||||
|
state.registry = registry
|
||||||
|
|
||||||
|
if list(registry.list_models()):
|
||||||
|
return
|
||||||
|
|
||||||
|
store = FeatureStore()
|
||||||
|
store.add(FeatureVector(sensor_id="sensor.front_door", values={"contact": 1.0}))
|
||||||
|
store.add(FeatureVector(sensor_id="sensor.living_room", values={"temperature": 21.0}))
|
||||||
|
pipeline = TrainingPipeline(store)
|
||||||
|
artifact = pipeline.run("default")
|
||||||
|
registry.register(artifact)
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
1
backend/routes/__init__.py
Normal file
1
backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API route modules."""
|
||||||
116
backend/routes/ml.py
Normal file
116
backend/routes/ml.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.ml.feature_store import FeatureVector
|
||||||
|
from app.ml.predictor import Predictor
|
||||||
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ml", tags=["ml"])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class PredictRequest(BaseModel):
|
||||||
|
model_id: str = Field(..., alias="modelId")
|
||||||
|
sensor_id: str
|
||||||
|
values: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
class PredictResponse(BaseModel):
|
||||||
|
model_id: str
|
||||||
|
sensor_id: str
|
||||||
|
prediction: str
|
||||||
|
|
||||||
|
|
||||||
|
class BatchRequest(BaseModel):
|
||||||
|
requests: Sequence[PredictRequest]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchResponse(BaseModel):
|
||||||
|
predictions: Sequence[PredictResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsResponse(BaseModel):
|
||||||
|
models: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse, status_code=200)
|
||||||
|
def health() -> HealthResponse:
|
||||||
|
return HealthResponse(status="ok")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=ModelsResponse, status_code=200)
|
||||||
|
def list_models(request: Request) -> ModelsResponse:
|
||||||
|
registry = _require_registry(request)
|
||||||
|
models = [artifact.artifact_id for artifact in registry.list_models()]
|
||||||
|
return ModelsResponse(models=models)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/predict", response_model=PredictResponse, status_code=200)
|
||||||
|
def predict(payload: PredictRequest, request: Request) -> PredictResponse:
|
||||||
|
registry = _require_registry(request)
|
||||||
|
predictor = Predictor(registry=registry)
|
||||||
|
vector = FeatureVector(sensor_id=payload.sensor_id, values=payload.values)
|
||||||
|
try:
|
||||||
|
prediction = predictor.predict(payload.model_id, vector)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return PredictResponse(
|
||||||
|
model_id=payload.model_id,
|
||||||
|
sensor_id=payload.sensor_id,
|
||||||
|
prediction=prediction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=BatchResponse, status_code=200)
|
||||||
|
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
|
||||||
|
registry = _require_registry(request)
|
||||||
|
predictor = Predictor(registry=registry)
|
||||||
|
responses: list[PredictResponse] = []
|
||||||
|
for item in payload.requests:
|
||||||
|
vector = FeatureVector(sensor_id=item.sensor_id, values=item.values)
|
||||||
|
try:
|
||||||
|
prediction = predictor.predict(item.model_id, vector)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
responses.append(
|
||||||
|
PredictResponse(model_id=item.model_id, sensor_id=item.sensor_id, prediction=prediction)
|
||||||
|
)
|
||||||
|
return BatchResponse(predictions=responses)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_registry(request: Request) -> ModelRegistry:
|
||||||
|
registry = getattr(request.app.state, "registry", None)
|
||||||
|
if not isinstance(registry, ModelRegistry):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="ML registry nicht initialisiert.",
|
||||||
|
)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None:
|
||||||
|
app.state.model_store = model_store
|
||||||
|
app.include_router(router)
|
||||||
|
logger.info("ML routes registered")
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
SILLYHOME_MODEL_STORE: /app/data/models
|
||||||
|
volumes:
|
||||||
|
- model-data:/app/data/models
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
model-data:
|
||||||
124
docs/ml_api.md
Normal file
124
docs/ml_api.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# ML-Serving-API
|
||||||
|
|
||||||
|
Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen
|
||||||
|
Modell-Artefakt- und Vorhersage-Schnittstelle.
|
||||||
|
|
||||||
|
> Hinweis: Version 0.1.0 enthält noch kein statistisch trainiertes ML-Modell.
|
||||||
|
> Die Vorhersage ist eine deterministische Referenzimplementierung für den
|
||||||
|
> späteren Modellvertrag.
|
||||||
|
|
||||||
|
## Basis-URL
|
||||||
|
|
||||||
|
- Standard: `http://127.0.0.1:8000/ml`
|
||||||
|
- Health: `/health`
|
||||||
|
- Modelle: `/models`
|
||||||
|
- Einzelvorhersage: `/predict`
|
||||||
|
- Batchvorhersage: `/batch`
|
||||||
|
|
||||||
|
Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und
|
||||||
|
ML-Routen in derselben Anwendung bereit.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `GET /ml/health`
|
||||||
|
|
||||||
|
Health-Check der ML-Services.
|
||||||
|
|
||||||
|
**Beispielantwort**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"updated_at": "2026-06-11T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /ml/models`
|
||||||
|
|
||||||
|
Listet alle registrierten Modell-Artefakte auf.
|
||||||
|
|
||||||
|
**Beispielantwort**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"models": ["default"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /ml/predict`
|
||||||
|
|
||||||
|
Einzelne Vorhersage für einen Sensor.
|
||||||
|
|
||||||
|
**Request**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modelId": "default",
|
||||||
|
"sensor_id": "sensor.kitchen",
|
||||||
|
"values": {"temperature": 21.0}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_id": "default",
|
||||||
|
"sensor_id": "sensor.kitchen",
|
||||||
|
"prediction": "default:sensor.kitchen:{'temperature': 21.0}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /ml/batch`
|
||||||
|
|
||||||
|
Batch-Vorhersage für mehrere Sensorwerte.
|
||||||
|
|
||||||
|
**Request**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"modelId": "default",
|
||||||
|
"sensor_id": "sensor.kitchen",
|
||||||
|
"values": {"temperature": 21.0}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelId": "default",
|
||||||
|
"sensor_id": "sensor.bedroom",
|
||||||
|
"values": {"temperature": 18.5}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"predictions": [
|
||||||
|
{
|
||||||
|
"model_id": "default",
|
||||||
|
"sensor_id": "sensor.kitchen",
|
||||||
|
"prediction": "default:sensor.kitchen:{'temperature': 21.0}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "default",
|
||||||
|
"sensor_id": "sensor.bedroom",
|
||||||
|
"prediction": "default:sensor.bedroom:{'temperature': 18.5}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fehlerfälle
|
||||||
|
|
||||||
|
- `404 Not Found`: Modell nicht registriert.
|
||||||
|
- `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig.
|
||||||
|
- `503 Service Unavailable`: Registry ist nicht initialisiert.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte
|
||||||
|
werden derzeit intern über `ModelRegistry.register(...)` registriert. Die
|
||||||
|
Registry speichert validiertes JSON atomisch und lädt es beim Neustart.
|
||||||
|
|
||||||
|
## Verweise
|
||||||
|
|
||||||
|
- `app/ml/predictor.py`
|
||||||
|
- `app/ml/registry/model_registry.py`
|
||||||
|
- `backend/routes/ml.py`
|
||||||
44
docs/ml_training.md
Normal file
44
docs/ml_training.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# ML Training- und Evaluations-Workflow
|
||||||
|
|
||||||
|
Dieser Workflow beschreibt den aktuellen Platzhalter für Modell-Metadaten,
|
||||||
|
Evaluation und Serving. Er trainiert in Version 0.1.0 noch kein statistisches
|
||||||
|
Modell.
|
||||||
|
|
||||||
|
## 1. Daten sammeln
|
||||||
|
|
||||||
|
Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen.
|
||||||
|
|
||||||
|
## 2. Artefakt-Metadaten erzeugen
|
||||||
|
|
||||||
|
```python
|
||||||
|
store = FeatureStore()
|
||||||
|
store.add(FeatureVector(sensor_id="sensor.kitchen", values={"temperature": 21.0}))
|
||||||
|
pipeline = TrainingPipeline(store)
|
||||||
|
artifact = pipeline.run("my_artifact")
|
||||||
|
pipeline.export("my_artifact")
|
||||||
|
```
|
||||||
|
|
||||||
|
`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten
|
||||||
|
Sensor-IDs. Gewichte, Parameter oder ein echtes Modell werden noch nicht
|
||||||
|
berechnet.
|
||||||
|
|
||||||
|
## 3. Modell evaluieren
|
||||||
|
|
||||||
|
```python
|
||||||
|
evaluator = Evaluator(pipeline)
|
||||||
|
report = evaluator.evaluate(artifact.artifact_id, predictions)
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Report enthält:
|
||||||
|
- `artifact_id`
|
||||||
|
- `sample_size`
|
||||||
|
- Metriken wie `coverage` und `unknown_rate` mit Default-Schwellenwerten
|
||||||
|
|
||||||
|
## 4. Modell registrieren
|
||||||
|
|
||||||
|
Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifact)` bereitgestellt werden. Die ML-Serving-API stellt es unter `/ml/predict` und `/ml/batch` zur Verfügung.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
|
||||||
|
- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.
|
||||||
|
- `coverage` zählt nur exakte Sensor-Referenzen und bleibt im Bereich 0 bis 1.
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -7,10 +11,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",
|
||||||
@@ -22,6 +28,10 @@ addopts = "-q"
|
|||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
strict = true
|
strict = true
|
||||||
|
files = ["app", "backend", "tests"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*", "backend*"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -1,10 +1,61 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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
|
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:
|
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:
|
||||||
|
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."}
|
||||||
|
|||||||
52
tests/api/test_ml_routes.py
Normal file
52
tests/api/test_ml_routes.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_routes_are_exposed_by_production_app() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
health = client.get("/ml/health")
|
||||||
|
models = client.get("/ml/models")
|
||||||
|
|
||||||
|
assert health.status_code == 200
|
||||||
|
assert models.status_code == 200
|
||||||
|
assert isinstance(models.json()["models"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_model_returns_404() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/ml/predict",
|
||||||
|
json={
|
||||||
|
"modelId": "missing",
|
||||||
|
"sensor_id": "sensor.kitchen",
|
||||||
|
"values": {"temperature": 21.0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_sensor_returns_422(tmp_path: Path) -> None:
|
||||||
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
|
from app.ml.training import TrainedArtifact
|
||||||
|
|
||||||
|
registry = ModelRegistry(tmp_path)
|
||||||
|
registry.register(TrainedArtifact("default", ("sensor.kitchen",)))
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
app.state.registry = registry
|
||||||
|
response = client.post(
|
||||||
|
"/ml/predict",
|
||||||
|
json={
|
||||||
|
"modelId": "default",
|
||||||
|
"sensor_id": "sensor.unknown",
|
||||||
|
"values": {"temperature": 21.0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
71
tests/ha/test_ha_client.py
Normal file
71
tests/ha/test_ha_client.py
Normal 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()
|
||||||
37
tests/ha/test_ha_reader.py
Normal file
37
tests/ha/test_ha_reader.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.ha.client import HaClient, HaClientSettings
|
||||||
|
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"
|
||||||
55
tests/ml/test_evaluation.py
Normal file
55
tests/ml/test_evaluation.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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"}
|
||||||
|
assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_without_training_raises_value_error() -> None:
|
||||||
|
evaluator = Evaluator(TrainingPipeline(FeatureStore()))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
evaluator.evaluate("artifact_v1", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_is_bounded_and_requires_exact_sensor_match() -> None:
|
||||||
|
evaluator = evaluator_factory()
|
||||||
|
report = evaluator.evaluate(
|
||||||
|
"artifact_v1",
|
||||||
|
[
|
||||||
|
"artifact_v1:sensor.kitchen:{'note': 'sensor.bedroom'}",
|
||||||
|
"artifact_v1:sensor.kitchen_extra:{}",
|
||||||
|
"malformed",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = {metric.name: metric.value for metric in report.metrics}
|
||||||
|
assert metrics == {"coverage": pytest.approx(1 / 3), "unknown_rate": pytest.approx(2 / 3)}
|
||||||
46
tests/ml/test_feature_store.py
Normal file
46
tests/ml/test_feature_store.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
latest = store.latest("sensor.kitchen")
|
||||||
|
assert latest is not None
|
||||||
|
assert latest.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))
|
||||||
|
living_room = store.latest("sensor.living_room")
|
||||||
|
bedroom = store.latest("sensor.bedroom")
|
||||||
|
assert living_room is not None
|
||||||
|
assert bedroom is not None
|
||||||
|
assert living_room.values["temperature"] == 21.0
|
||||||
|
assert bedroom.values["temperature"] == 18.5
|
||||||
39
tests/ml/test_model_registry.py
Normal file
39
tests/ml/test_model_registry.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
|
from app.ml.training import TrainedArtifact
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_loads_persisted_artifacts_after_restart(tmp_path: Path) -> None:
|
||||||
|
registry = ModelRegistry(tmp_path)
|
||||||
|
artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom"))
|
||||||
|
registry.register(artifact)
|
||||||
|
|
||||||
|
restarted = ModelRegistry(tmp_path)
|
||||||
|
|
||||||
|
assert restarted.load_artifact("model-v1") == artifact
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact_id", ["../escape", "nested/model", "..", ""])
|
||||||
|
def test_registry_rejects_unsafe_artifact_ids(tmp_path: Path, artifact_id: str) -> None:
|
||||||
|
registry = ModelRegistry(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
registry.register(TrainedArtifact(artifact_id, ("sensor.kitchen",)))
|
||||||
|
|
||||||
|
assert list(tmp_path.parent.glob("escape.json")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_corrupt_persisted_artifact(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "broken.json").write_text(
|
||||||
|
json.dumps({"artifact_id": "../broken", "supported_sensors": []}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="broken.json"):
|
||||||
|
ModelRegistry(tmp_path)
|
||||||
48
tests/ml/test_predictor.py
Normal file
48
tests/ml/test_predictor.py
Normal 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
48
tests/ml/test_training.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
33
tests/ml/test_training_evaluation.py
Normal file
33
tests/ml/test_training_evaluation.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.ml.evaluation import Evaluator, EvalReport, Metric
|
||||||
|
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 test_end_to_end_training_then_evaluation() -> None:
|
||||||
|
store = FeatureStore()
|
||||||
|
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
|
||||||
|
pipeline = TrainingPipeline(store)
|
||||||
|
artifact = pipeline.run("artifact_v1")
|
||||||
|
|
||||||
|
evaluator = Evaluator(pipeline)
|
||||||
|
predictions = [
|
||||||
|
"artifact_v1:sensor.kitchen:{'temperature': 21.0}",
|
||||||
|
"artifact_v1:sensor.bedroom:{'temperature': 18.5}",
|
||||||
|
]
|
||||||
|
report = evaluator.evaluate(artifact.artifact_id, predictions)
|
||||||
|
assert isinstance(report, EvalReport)
|
||||||
|
assert report.sample_size == len(predictions)
|
||||||
|
assert any(metric.name == "coverage" for metric in report.metrics)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_helpers_are_serializable() -> None:
|
||||||
|
metric = Metric(name="coverage", value=0.85, threshold=0.8)
|
||||||
|
assert metric.name == "coverage"
|
||||||
|
assert metric.value == 0.85
|
||||||
|
assert metric.threshold == 0.8
|
||||||
64
tests/rules/test_heating.py
Normal file
64
tests/rules/test_heating.py
Normal 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
|
||||||
18
tests/test_config.py
Normal file
18
tests/test_config.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pytest import MonkeyPatch
|
||||||
|
|
||||||
|
from app.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("SILLYHOME_HA_URL", "http://ha.local:8123")
|
||||||
|
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
|
||||||
|
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
assert settings.ha_url == "http://ha.local:8123"
|
||||||
|
assert settings.ha_token == "secret"
|
||||||
|
assert settings.model_store == "/tmp/models"
|
||||||
|
assert settings.ha_configured
|
||||||
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user