Compare commits

...

11 Commits

Author SHA1 Message Date
816a516106 HA-008 HA-009: add discovery and history pipeline
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
Closes #17

Closes #18
2026-06-13 20:06:05 +02:00
1fbed37126 Merge pull request 'Release v0.1.0' (#16) from release/v0.1.0 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 19:12:06 +02:00
dd496f9cc3 release: finalize v0.1.0 changelog
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-13 19:11:42 +02:00
74b75de0fa Merge pull request 'ML-007: Retraining Pipeline und Model Updates' (#15) from feature/ml-007-retraining-pipeline into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 19:10:54 +02:00
840c404c1c ML-007: add retraining pipeline and API
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
Closes #13
2026-06-13 19:10:17 +02:00
ecd32d4813 Merge pull request 'Production hardening: runtime, registry, packaging and CI' (#14) from otto/production-hardening-20260611 into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-11 21:15:27 +02:00
aaf319ff14 harden delivery pipeline and production runtime
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
2026-06-11 21:14:07 +02:00
471146761e harden model registry persistence and evaluation 2026-06-11 21:08:14 +02:00
3bed5e790a unify production app configuration and ML routes 2026-06-11 21:08:14 +02:00
4b3dc3b7af Merge branch 'feature/ml-006-training-workflow' 2026-06-11 20:31:12 +02:00
63d10a6c4f ML-006: Training- und Evaluations-Workflow vorbereiten 2026-06-11 17:08:16 +02:00
42 changed files with 1570 additions and 165 deletions

View File

@@ -12,6 +12,5 @@ node_modules
.vscode
.git
.gitignore
README.md
.dockerignore
docker-compose*.yml
docker-compose*.yml

View File

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

View 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

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
/.vscode
__pycache__/
*.pyc
*.egg-info/
.mypy_cache/
.pytest_cache/
.ruff_cache/

View File

@@ -1,5 +1,16 @@
# Changelog
## Unreleased
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern
- Validierter Zugriff auf die Home-Assistant-History-API
- Normalisierte, chronologisch sortierte numerische Zeitreihen über `/v1/history`
## 0.1.0 - 2026-06-13
- Projektinitiierung
- 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
- Scheduler-tauglicher Retraining-Service mit API und atomischem Registry-Update

View File

@@ -1,15 +1,27 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
SILLYHOME_MODEL_STORE=/app/data/models
WORKDIR /app
COPY pyproject.toml ./
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -e ".[dev]"
RUN addgroup --system sillyhome && adduser --system --ingroup sillyhome sillyhome
COPY . .
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
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "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"]

View File

@@ -1,6 +1,13 @@
# 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
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.
@@ -13,14 +20,12 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert
## APPENDIX
### Quickstart
## Quickstart
1. Python-Venv anlegen und Abhängigkeiten installieren:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .
pip install -e ".[dev]"
```
2. Konfiguration aus `.env.example` übernehmen und anpassen:
@@ -37,17 +42,35 @@ uvicorn app.main:app --reload
- `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` - ML-Serving Health (ab ML-005)
- `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
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 aus Home Assistant (nur lesen)
- `SILLYHOME_HA_TOKEN` Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
- `SILLYHOME_MODEL_STORE` Verzeichnis für persistierte Modell-Metadaten
Hinweis: Nutze ausschließlich Long-Lived Access Tokens mit Leserechten. Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht in Versionskontrollsysteme.
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
Versionskontrollsystem.
### Tests
```bash
pytest -q
pytest
ruff check .
mypy app tests
```
mypy
```

View File

@@ -1,39 +1,64 @@
from __future__ import annotations
from datetime import datetime
from typing import List
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.dependencies import get_ha_reader
from app.ha.discovery import DiscoveredEntity
from app.ha.history import EntityHistorySeries
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.rules.recommender import Recommender
router = APIRouter(prefix="/v1", tags=["entities"])
def _state_ha_reader(request: Request) -> HaReader:
try:
return request.app.state.ha_reader
except AttributeError as exc:
raise HTTPException(status_code=503, detail="HA-Reader nicht initialisiert.") from exc
def _state_recommender(request: Request) -> Recommender:
try:
return request.app.state.recommender
except AttributeError as exc:
raise HTTPException(status_code=503, detail="Recommender nicht initialisiert.") from exc
@router.get(
"/entities",
summary="Home-Assistant-Entities auflisten",
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
response_model=List[HaEntitySummary],
)
def list_entities(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(ha_reader: HaReader = Depends(get_ha_reader)) -> List[HaEntitySummary]:
return list(ha_reader.read_entities())
@router.get(
"/discovery",
summary="Home-Assistant-Entities klassifizieren",
description="Klassifiziert Entities nach Lernrelevanz, Kontextquelle und Aktor-Rolle.",
response_model=List[DiscoveredEntity],
)
def discovery(
domain: List[str] | None = Query(default=None),
learnable: bool | None = None,
ha_reader: HaReader = Depends(get_ha_reader),
) -> List[DiscoveredEntity]:
return list(
ha_reader.discover(
domains=set(domain) if domain else None,
learnable=learnable,
)
)
@router.get(
"/history",
summary="Numerische Home-Assistant-Historie lesen",
description="Lädt und normalisiert numerische Zustände ausgewählter Entities.",
response_model=List[EntityHistorySeries],
)
def history(
entity_id: List[str] = Query(),
start_time: datetime = Query(),
end_time: datetime = Query(),
ha_reader: HaReader = Depends(get_ha_reader),
) -> List[EntityHistorySeries]:
try:
return list(ha_reader.read_history(entity_id, start_time, end_time))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc

View File

@@ -8,6 +8,7 @@ from dataclasses import dataclass
class Settings:
ha_url: str | None = None
ha_token: str | None = None
model_store: str = ".model_store"
@property
def ha_configured(self) -> bool:
@@ -18,4 +19,5 @@ 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"),
)

View File

@@ -2,6 +2,9 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
import re
from urllib.parse import quote
import requests
@@ -14,6 +17,9 @@ from app.ha.exceptions import (
logger = logging.getLogger(__name__)
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
@dataclass(frozen=True)
class HaClientSettings:
@@ -31,10 +37,62 @@ class HaClient:
"Content-Type": "application/json",
})
def close(self) -> None:
self._session.close()
def list_entities(self) -> list[dict[str, object]]:
payload = self._get_json("/api/states")
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant hat unerwartetes Format."
)
return payload
def get_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[object]:
if not entity_ids:
raise ValueError("Mindestens eine entity_id ist erforderlich.")
if len(entity_ids) > 100:
raise ValueError("Es können höchstens 100 Entities abgefragt werden.")
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
raise ValueError("entity_id enthält ein ungültiges Format.")
if start_time.tzinfo is None or end_time.tzinfo is None:
raise ValueError("start_time und end_time müssen eine Zeitzone enthalten.")
if end_time <= start_time:
raise ValueError("end_time muss nach start_time liegen.")
if (end_time - start_time).total_seconds() > _MAX_HISTORY_SECONDS:
raise ValueError("History-Abfragen sind auf 31 Tage begrenzt.")
start = quote(start_time.isoformat(), safe=":+")
payload = self._get_json(
f"/api/history/period/{start}",
params={
"filter_entity_id": ",".join(entity_ids),
"end_time": end_time.isoformat(),
"minimal_response": "1",
"no_attributes": "1",
},
)
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"History-Antwort von Home Assistant hat unerwartetes Format."
)
return payload
def _get_json(
self,
path: str,
*,
params: dict[str, str] | None = None,
) -> object:
try:
response = self._session.get(
f"{self._settings.url}/api/states",
f"{self._settings.url.rstrip('/')}{path}",
params=params,
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
@@ -66,9 +124,4 @@ class HaClient:
"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
return payload

184
app/ha/discovery.py Normal file
View File

@@ -0,0 +1,184 @@
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel
from app.ha.models import HaEntitySummary
class EntityRole(StrEnum):
MEASUREMENT = "measurement"
BINARY_CONTEXT = "binary_context"
CONTEXT = "context"
ACTUATOR = "actuator"
UNSUPPORTED = "unsupported"
class DiscoveredEntity(BaseModel):
entity_id: str
domain: str
device_class: str | None = None
state_class: str | None = None
unit_of_measurement: str | None = None
role: EntityRole
learnable: bool
reason: str
_MEASUREMENT_CLASSES = frozenset({
"apparent_power",
"atmospheric_pressure",
"battery",
"carbon_dioxide",
"carbon_monoxide",
"current",
"distance",
"duration",
"energy",
"frequency",
"gas",
"humidity",
"illuminance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"pm1",
"pm10",
"pm25",
"power",
"precipitation",
"pressure",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"volatile_organic_compounds",
"voltage",
"volume",
"volume_flow_rate",
"water",
"weight",
"wind_speed",
})
_BINARY_CONTEXT_CLASSES = frozenset({
"door",
"garage_door",
"lock",
"motion",
"occupancy",
"opening",
"presence",
"problem",
"safety",
"smoke",
"sound",
"vibration",
"window",
})
_ACTUATOR_DOMAINS = frozenset({
"button",
"climate",
"cover",
"fan",
"humidifier",
"light",
"lock",
"scene",
"select",
"siren",
"switch",
"valve",
})
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})
_NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"})
def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
if entity.domain == "sensor" and (
entity.state_class in _NUMERIC_STATE_CLASSES
or entity.device_class in _MEASUREMENT_CLASSES
or entity.unit_of_measurement is not None
):
return _result(
entity,
EntityRole.MEASUREMENT,
learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.",
)
if entity.domain == "binary_sensor" and entity.device_class in _BINARY_CONTEXT_CLASSES:
return _result(
entity,
EntityRole.BINARY_CONTEXT,
learnable=True,
reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.",
)
if entity.domain in _CONTEXT_DOMAINS:
learnable = entity.domain in _LEARNABLE_CONTEXT_DOMAINS
return _result(
entity,
EntityRole.CONTEXT,
learnable=learnable,
reason=(
"Kontextquelle für Training und Erklärungen."
if learnable
else "Kontextquelle ohne direkte Trainingsfreigabe."
),
)
if entity.domain in _ACTUATOR_DOMAINS:
return _result(
entity,
EntityRole.ACTUATOR,
learnable=False,
reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.",
)
return _result(
entity,
EntityRole.UNSUPPORTED,
learnable=False,
reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.",
)
def discover_entities(
entities: list[HaEntitySummary],
domains: set[str] | None = None,
learnable: bool | None = None,
) -> list[DiscoveredEntity]:
normalized_domains = {domain.strip().lower() for domain in domains or set() if domain.strip()}
discovered = [classify_entity(entity) for entity in entities]
return [
entity
for entity in discovered
if (not normalized_domains or entity.domain in normalized_domains)
and (learnable is None or entity.learnable is learnable)
]
def _result(
entity: HaEntitySummary,
role: EntityRole,
*,
learnable: bool,
reason: str,
) -> DiscoveredEntity:
return DiscoveredEntity(
entity_id=entity.entity_id,
domain=entity.domain,
device_class=entity.device_class,
state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement,
role=role,
learnable=learnable,
reason=reason,
)

91
app/ha/history.py Normal file
View File

@@ -0,0 +1,91 @@
from __future__ import annotations
import math
from datetime import datetime
from pydantic import BaseModel
from app.ha.exceptions import HaUnexpectedPayloadError
class NumericHistoryPoint(BaseModel):
timestamp: datetime
value: float
class EntityHistorySeries(BaseModel):
entity_id: str
points: list[NumericHistoryPoint]
def normalize_history_payload(payload: object) -> list[EntityHistorySeries]:
if not isinstance(payload, list):
raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.")
normalized: list[EntityHistorySeries] = []
for raw_series in payload:
if not isinstance(raw_series, list):
raise HaUnexpectedPayloadError("History-Serie muss eine Liste sein.")
series = _normalize_series(raw_series)
if series is not None:
normalized.append(series)
return sorted(normalized, key=lambda item: item.entity_id)
def _normalize_series(raw_series: list[object]) -> EntityHistorySeries | None:
entity_id: str | None = None
points: list[NumericHistoryPoint] = []
for raw_entry in raw_series:
if not isinstance(raw_entry, dict):
raise HaUnexpectedPayloadError("History-Eintrag muss ein Objekt sein.")
raw_entity_id = raw_entry.get("entity_id")
if raw_entity_id is not None:
if not isinstance(raw_entity_id, str) or "." not in raw_entity_id:
raise HaUnexpectedPayloadError("History-Eintrag enthält ungültige entity_id.")
if entity_id is not None and entity_id != raw_entity_id:
raise HaUnexpectedPayloadError("History-Serie enthält mehrere Entities.")
entity_id = raw_entity_id
raw_state = raw_entry.get("state")
value = _finite_float(raw_state)
if value is None:
continue
if entity_id is None:
raise HaUnexpectedPayloadError("History-Serie enthält keine entity_id.")
raw_timestamp = raw_entry.get("last_changed") or raw_entry.get("last_updated")
timestamp = _parse_timestamp(raw_timestamp)
points.append(NumericHistoryPoint(timestamp=timestamp, value=value))
if entity_id is None or not points:
return None
points.sort(key=lambda point: point.timestamp)
return EntityHistorySeries(entity_id=entity_id, points=points)
def _finite_float(value: object) -> float | None:
if isinstance(value, bool) or value is None:
return None
if not isinstance(value, (str, int, float)):
return None
try:
converted = float(value)
except (TypeError, ValueError):
return None
return converted if math.isfinite(converted) else None
def _parse_timestamp(value: object) -> datetime:
if not isinstance(value, str):
raise HaUnexpectedPayloadError("Numerischer History-Eintrag enthält keinen Zeitstempel.")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HaUnexpectedPayloadError("History-Eintrag enthält ungültigen Zeitstempel.") from exc
if parsed.tzinfo is None:
raise HaUnexpectedPayloadError("History-Zeitstempel muss eine Zeitzone enthalten.")
return parsed

View File

@@ -1,9 +1,12 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from typing import Any
from app.ha.client import HaClient
from app.ha.discovery import DiscoveredEntity, discover_entities
from app.ha.history import EntityHistorySeries, normalize_history_payload
from app.ha.models import HaEntitySummary
@@ -15,9 +18,10 @@ class HaReader:
entities = self._client.list_entities()
summaries: list[HaEntitySummary] = []
for item in entities:
entity_id = item.get("entity_id", "")
if "." not in entity_id:
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 {}
@@ -32,6 +36,22 @@ class HaReader:
)
return summaries
def discover(
self,
domains: set[str] | None = None,
learnable: bool | None = None,
) -> Sequence[DiscoveredEntity]:
return discover_entities(list(self.read_entities()), domains=domains, learnable=learnable)
def read_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> Sequence[EntityHistorySeries]:
payload = self._client.get_history(entity_ids, start_time, end_time)
return normalize_history_payload(payload)
def _optional_str(value: object) -> str | None:
if value is None or value == "":

View File

@@ -1,34 +1,38 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from typing import cast
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
from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings
ha_url = getattr(settings, "ha_url", None)
ha_token = getattr(settings, "ha_token", None)
client = HaClient(
settings=HaClientSettings(
url=ha_url or "",
token=ha_token or "",
client: HaClient | 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)
app.state.recommender = Recommender(rules=[HeatingRule()])
yield
class Settings:
ha_url: str = "http://localhost:8123"
ha_token: str = ""
app.state.ha_reader = HaReader(client=client)
try:
yield
finally:
if client is not None:
client.close()
app = FastAPI(
@@ -37,9 +41,10 @@ app = FastAPI(
version="0.1.0",
lifespan=lifespan,
)
app.state.settings = Settings()
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")
@@ -49,4 +54,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"}

View File

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

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Sequence
from collections.abc import Sequence
from dataclasses import dataclass
from app.ml.feature_store import FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
from app.ml.training import TrainingPipeline
logger = logging.getLogger(__name__)
@@ -29,15 +28,16 @@ class Evaluator:
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.")
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
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)
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_references / sample_size if sample_size else 0.0
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)
@@ -54,4 +54,11 @@ class Evaluator:
coverage,
unknown_rate,
)
return report
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]

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Iterable
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
@@ -28,4 +28,4 @@ class FeatureStore:
return series[-1] if series else None
def all(self) -> list[FeatureVector]:
return [vector for vectors in self._vectors.values() for vector in vectors]
return [vector for vectors in self._vectors.values() for vector in vectors]

View File

@@ -1,37 +1,90 @@
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Iterable
import re
from threading import RLock
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)
self._root = Path(root).resolve()
self._root.mkdir(parents=True, exist_ok=True)
self._artifacts: dict[str, TrainedArtifact] = {}
self._lock = RLock()
self._load_existing()
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
self._artifacts[artifact.artifact_id] = artifact
self._persist(artifact)
return artifact
registered, _ = self.register_with_status(artifact)
return registered
def register_with_status(self, artifact: TrainedArtifact) -> tuple[TrainedArtifact, bool]:
self._validate_artifact_id(artifact.artifact_id)
with self._lock:
replaced = artifact.artifact_id in self._artifacts
self._persist(artifact)
self._artifacts[artifact.artifact_id] = artifact
return artifact, replaced
def load_artifact(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
return self._artifacts[artifact_id]
self._validate_artifact_id(artifact_id)
with self._lock:
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())
with self._lock:
return [self._artifacts[key] for key in sorted(self._artifacts)]
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"
target.write_text(
f"{artifact.artifact_id}\t{','.join(artifact.supported_sensors)}\n",
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",
)
logger.info("Modell gespeichert: %s", target)
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."
)

43
app/ml/retraining.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact, TrainingPipeline
@dataclass(frozen=True)
class RetrainingResult:
artifact: TrainedArtifact
replaced: bool
class RetrainingService:
"""Runs one retraining cycle without owning scheduling or background threads."""
def __init__(self, registry: ModelRegistry) -> None:
self._registry = registry
def retrain(
self,
artifact_id: str,
vectors: Iterable[FeatureVector],
) -> RetrainingResult:
store = FeatureStore()
store.add_batch(vectors)
pipeline = TrainingPipeline(store)
artifact = pipeline.run(artifact_id)
_, replaced = self._registry.register_with_status(artifact)
return RetrainingResult(artifact=artifact, replaced=replaced)
def retrain_model(
registry: ModelRegistry,
artifact_id: str,
vectors: Iterable[FeatureVector],
) -> RetrainingResult:
"""Scheduler-compatible entry point for exactly one retraining run."""
return RetrainingService(registry).retrain(artifact_id, vectors)

View File

@@ -2,9 +2,8 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Sequence
from app.ml.feature_store import FeatureVector, FeatureStore
from app.ml.feature_store import FeatureStore
logger = logging.getLogger(__name__)
@@ -25,7 +24,7 @@ class TrainingPipeline:
if not vectors:
raise ValueError("FeatureStore enthält keine Trainingsdaten.")
sensors = tuple({vector.sensor_id for vector in vectors})
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))
@@ -34,4 +33,4 @@ class TrainingPipeline:
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]
return self._artifacts[artifact_id]

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Secondary application entry points for SillyHome Next."""

View File

@@ -1,4 +1,8 @@
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
@@ -6,18 +10,20 @@ 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")
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
init_ml_routes(application)
_seed_default_model(application.state if hasattr(application, "state") else application)
return application
def _app_state(): # noqa: ANN001
return app.state
def _seed_default_model(state) -> None: # noqa: ANN001
def _seed_default_model(state: State) -> None:
registry = getattr(state, "registry", None)
if registry is None:
registry = ModelRegistry(".model_store")
@@ -34,4 +40,4 @@ def _seed_default_model(state) -> None: # noqa: ANN001
registry.register(artifact)
app = create_app()
app = create_app()

View File

@@ -0,0 +1 @@
"""API route modules."""

View File

@@ -2,15 +2,15 @@ from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import List, Sequence
from collections.abc import Sequence
from fastapi import APIRouter
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
from app.ml.training import TrainedArtifact
from app.ml.retraining import retrain_model
logger = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ class HealthResponse(BaseModel):
class PredictRequest(BaseModel):
model_id: str = Field(..., alias="modelId")
sensor_id: str
values: dict
values: dict[str, float]
class PredictResponse(BaseModel):
@@ -43,7 +43,24 @@ class BatchResponse(BaseModel):
class ModelsResponse(BaseModel):
models: List[str]
models: list[str]
class TrainingSample(BaseModel):
sensor_id: str = Field(min_length=1)
values: dict[str, float]
label: str | None = None
class RetrainRequest(BaseModel):
model_id: str = Field(..., alias="modelId", min_length=1, max_length=128)
samples: list[TrainingSample] = Field(min_length=1)
class RetrainResponse(BaseModel):
model_id: str
supported_sensors: list[str]
replaced: bool
@router.get("/health", response_model=HealthResponse, status_code=200)
@@ -52,53 +69,91 @@ def health() -> HealthResponse:
@router.get("/models", response_model=ModelsResponse, status_code=200)
def list_models() -> ModelsResponse:
registry = _require_registry()
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(request: PredictRequest) -> PredictResponse:
registry = _require_registry()
predictor = Predictor(registry=registry)
vector = FeatureVector(sensor_id=request.sensor_id, values=request.values)
@router.post("/retrain", response_model=RetrainResponse, status_code=200)
def retrain(payload: RetrainRequest, request: Request) -> RetrainResponse:
registry = _require_registry(request)
vectors = [
FeatureVector(
sensor_id=sample.sensor_id,
values=sample.values,
label=sample.label,
)
for sample in payload.samples
]
try:
prediction = predictor.predict(request.model_id, vector)
except KeyError as exc: # unknown artifact
raise _not_found_error(str(exc)) from exc
return PredictResponse(model_id=request.model_id, sensor_id=request.sensor_id, prediction=prediction)
result = retrain_model(registry, payload.model_id, vectors)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return RetrainResponse(
model_id=result.artifact.artifact_id,
supported_sensors=list(result.artifact.supported_sensors),
replaced=result.replaced,
)
@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(request: BatchRequest) -> BatchResponse:
registry = _require_registry()
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
registry = _require_registry(request)
predictor = Predictor(registry=registry)
responses: List[PredictResponse] = []
for item in request.requests:
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 _not_found_error(str(exc)) from 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() -> ModelRegistry:
from backend.app import _app_state
state_obj = _app_state()
registry = getattr(state_obj, "registry", None)
if registry is None:
raise RuntimeError("ML registry nicht initialisiert.")
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) -> None: # noqa: ANN001
registry = ModelRegistry(".model_store")
app.state.registry = 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")
logger.info("ML routes registered")

View File

@@ -2,7 +2,22 @@ services:
api:
build: .
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
env_file:
- .env
restart: unless-stopped
- 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:

42
docs/ha_data.md Normal file
View File

@@ -0,0 +1,42 @@
# Home-Assistant-Datenpipeline
SillyHome Next trennt aktuelle Entity-Metadaten, Discovery und historische
Messwerte. Dadurch gelangen nur klassifizierte, geeignete Daten in spätere
Trainings- und Erklärungsprozesse.
## Entity Discovery
`GET /v1/discovery` klassifiziert Home-Assistant-Entities in:
- `measurement`: numerische Messsensoren, für Training geeignet
- `binary_context`: binäre Kontextsensoren wie Bewegung oder Anwesenheit
- `context`: Personen-, Wetter- und Standortkontext
- `actuator`: mögliche Automationsziele, nicht als Trainingssensor verwendet
- `unsupported`: noch nicht klassifizierte Entity-Typen
Optionale Query-Parameter:
- `domain=sensor` kann mehrfach angegeben werden
- `learnable=true|false` filtert nach Trainingsrelevanz
## Historische Daten
Historische Zustände werden über Home Assistants
`/api/history/period/<start>`-Schnittstelle geladen. Abfragen verlangen:
- mindestens eine Entity-ID, maximal 100
- zeitzonenbehaftete Start- und Endzeit
- ein Enddatum nach dem Startdatum
- maximal 31 Tage pro Abfrage
Die Normalisierung übernimmt nur endliche numerische Zustände. `unknown`,
`unavailable`, nichtnumerische Werte, `NaN` und unendliche Werte werden nicht
als Trainingsdaten verwendet. Ergebnisse werden je Entity chronologisch
sortiert.
## Datenschutz und Betrieb
Die Daten bleiben lokal. Home-Assistant-Tokens gehören ausschließlich in die
Umgebungskonfiguration und dürfen nicht protokolliert oder versioniert werden.
Die API sollte nur lokal oder hinter einem authentifizierenden Reverse Proxy
erreichbar sein.

View File

@@ -1,16 +1,23 @@
# ML-Serving-API
Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHome Next.
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`
- Retraining: `/retrain`
- Einzelvorhersage: `/predict`
- Batchvorhersage: `/batch`
Der Standard-Start erfolgt über `uvicorn backend.app:app --reload`, danach steht die API unter `/ml` bereit.
Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und
ML-Routen in derselben Anwendung bereit.
## Endpoints
@@ -59,6 +66,35 @@ Einzelne Vorhersage für einen Sensor.
}
```
### `POST /ml/retrain`
Trainiert die Artefakt-Metadaten aus neuen Sensordaten. Existiert `modelId`
bereits, wird das Artefakt atomisch ersetzt und beim nächsten Prozessstart aus
dem Modellverzeichnis geladen.
**Request**
```json
{
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
"label": "occupied"
}
]
}
```
**Antwort**
```json
{
"model_id": "home-model",
"supported_sensors": ["sensor.kitchen"],
"replaced": false
}
```
### `POST /ml/batch`
Batch-Vorhersage für mehrere Sensorwerte.
@@ -101,16 +137,22 @@ Batch-Vorhersage für mehrere Sensorwerte.
## Fehlerfälle
- `400 Bad Request`: Fehlende oder ungültige Felder.
- `404 Not Found`: Modell oder Sensor nicht registriert.
- `500 Internal Server Error`: Registry nicht initialisiert oder unerwarteter Fehler.
- `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
Beim Start wird automatisch ein Default-Artefakt erstellt, falls noch kein Modell registriert ist. Neue Modelle müssen zusätzlich über `ModelRegistry.register(...)` eingetragen werden.
Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte
werden über `/ml/retrain`, `RetrainingService` oder direkt über
`ModelRegistry.register(...)` registriert. Die Registry speichert validiertes
JSON atomisch und lädt es beim Neustart. Die API sollte nur in einem
vertrauenswürdigen Netz oder hinter einem authentifizierenden Reverse Proxy
erreichbar sein.
## Verweise
- `app/ml/predictor.py`
- `app/ml/retraining.py`
- `app/ml/registry/model_registry.py`
- `backend/routes/ml.py`
- `backend/routes/ml.py`

59
docs/ml_training.md Normal file
View File

@@ -0,0 +1,59 @@
# 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.
## 5. Retraining ausführen
`RetrainingService.retrain(...)` führt genau einen Trainingslauf aus und ersetzt
ein vorhandenes Artefakt mit derselben ID atomisch in der Registry:
```python
service = RetrainingService(registry)
result = service.retrain("home-model", vectors)
```
Scheduler, Cronjobs oder Home-Assistant-Automationen können alternativ die
zustandslose Funktion `retrain_model(registry, artifact_id, vectors)` aufrufen.
Der Service startet bewusst keinen eigenen Hintergrundprozess. Über
`POST /ml/retrain` kann derselbe Ablauf per API angestoßen werden.
## 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.

View File

@@ -1,3 +1,7 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "0.1.0"
@@ -24,6 +28,10 @@ addopts = "-q"
[tool.mypy]
strict = true
files = ["app", "backend", "tests"]
[tool.setuptools.packages.find]
include = ["app*", "backend*"]
[tool.ruff]
line-length = 100

View File

@@ -1,8 +1,11 @@
from collections.abc import Sequence
from datetime import datetime
from fastapi.testclient import TestClient
from app.ha.exceptions import HaTimeoutError
from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import app
@@ -15,6 +18,38 @@ class FakeHaReader(HaReader):
def read_entities(self) -> Sequence[HaEntitySummary]:
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
def discover(
self,
domains: set[str] | None = None,
learnable: bool | None = None,
) -> Sequence[DiscoveredEntity]:
result = DiscoveredEntity(
entity_id="sensor.temperature",
domain="sensor",
device_class="temperature",
role=EntityRole.MEASUREMENT,
learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.",
)
if domains and result.domain not in domains:
return []
if learnable is not None and result.learnable is not learnable:
return []
return [result]
def read_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> Sequence[EntityHistorySeries]:
return [
EntityHistorySeries(
entity_id=entity_ids[0],
points=[NumericHistoryPoint(timestamp=start_time, value=21.5)],
)
]
class TimeoutHaReader(HaReader):
def __init__(self) -> None:
@@ -49,8 +84,6 @@ def test_entities_returns_reader_data() -> None:
def test_entities_returns_503_without_home_assistant_config() -> None:
with TestClient(app) as client:
if hasattr(app.state, "ha_reader"):
delattr(app.state, "ha_reader")
response = client.get("/v1/entities")
assert response.status_code == 503
@@ -61,3 +94,44 @@ def test_entities_maps_ha_errors_without_leaking_details() -> None:
response = client.get("/v1/entities")
assert response.status_code == 504
assert response.json() == {"detail": "Home Assistant request timed out."}
def test_discovery_filters_entities() -> None:
with TestClient(app) as client:
app.state.ha_reader = FakeHaReader()
response = client.get("/v1/discovery?domain=sensor&learnable=true")
assert response.status_code == 200
assert response.json() == [
{
"entity_id": "sensor.temperature",
"domain": "sensor",
"device_class": "temperature",
"state_class": None,
"unit_of_measurement": None,
"role": "measurement",
"learnable": True,
"reason": "Numerischer Messsensor für Zeitreihen und Training.",
}
]
def test_history_returns_normalized_series() -> None:
with TestClient(app) as client:
app.state.ha_reader = FakeHaReader()
response = client.get(
"/v1/history",
params=[
("entity_id", "sensor.temperature"),
("start_time", "2026-06-01T00:00:00Z"),
("end_time", "2026-06-02T00:00:00Z"),
],
)
assert response.status_code == 200
assert response.json() == [
{
"entity_id": "sensor.temperature",
"points": [{"timestamp": "2026-06-01T00:00:00Z", "value": 21.5}],
}
]

109
tests/api/test_ml_routes.py Normal file
View File

@@ -0,0 +1,109 @@
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
def test_retrain_creates_and_replaces_persisted_model(tmp_path: Path) -> None:
from app.ml.registry.model_registry import ModelRegistry
registry = ModelRegistry(tmp_path)
with TestClient(app) as client:
app.state.registry = registry
created = client.post(
"/ml/retrain",
json={
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
}
],
},
)
replaced = client.post(
"/ml/retrain",
json={
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.bedroom",
"values": {"temperature": 18.0},
}
],
},
)
assert created.status_code == 200
assert created.json() == {
"model_id": "home-model",
"supported_sensors": ["sensor.kitchen"],
"replaced": False,
}
assert replaced.status_code == 200
assert replaced.json() == {
"model_id": "home-model",
"supported_sensors": ["sensor.bedroom"],
"replaced": True,
}
restarted = ModelRegistry(tmp_path)
assert restarted.load_artifact("home-model").supported_sensors == ("sensor.bedroom",)
def test_retrain_rejects_empty_samples() -> None:
with TestClient(app) as client:
response = client.post(
"/ml/retrain",
json={"modelId": "home-model", "samples": []},
)
assert response.status_code == 422

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import pytest
from app.ha.discovery import EntityRole, classify_entity, discover_entities
from app.ha.models import HaEntitySummary
@pytest.mark.parametrize(
("entity", "role", "learnable"),
[
(
HaEntitySummary(
entity_id="sensor.temperature",
domain="sensor",
device_class="temperature",
state_class="measurement",
unit_of_measurement="°C",
),
EntityRole.MEASUREMENT,
True,
),
(
HaEntitySummary(
entity_id="binary_sensor.motion",
domain="binary_sensor",
device_class="motion",
),
EntityRole.BINARY_CONTEXT,
True,
),
(
HaEntitySummary(entity_id="person.simon", domain="person"),
EntityRole.CONTEXT,
True,
),
(
HaEntitySummary(entity_id="light.living_room", domain="light"),
EntityRole.ACTUATOR,
False,
),
(
HaEntitySummary(entity_id="camera.driveway", domain="camera"),
EntityRole.UNSUPPORTED,
False,
),
],
)
def test_classify_entity(
entity: HaEntitySummary,
role: EntityRole,
learnable: bool,
) -> None:
result = classify_entity(entity)
assert result.role is role
assert result.learnable is learnable
def test_discovery_filters_domain_and_learnable() -> None:
entities = [
HaEntitySummary(
entity_id="sensor.temperature",
domain="sensor",
device_class="temperature",
),
HaEntitySummary(entity_id="sensor.status", domain="sensor"),
HaEntitySummary(
entity_id="binary_sensor.motion",
domain="binary_sensor",
device_class="motion",
),
]
result = discover_entities(entities, domains={" SENSOR "}, learnable=True)
assert [item.entity_id for item in result] == ["sensor.temperature"]

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import Mock
import pytest
@@ -68,4 +69,60 @@ def test_list_entities_rejects_invalid_json() -> None:
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()
def test_get_history_calls_home_assistant_history_api() -> None:
response = _response(payload=[[{"entity_id": "sensor.temperature", "state": "21.0"}]])
client = _client_with_response(response)
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
payload = client.get_history(["sensor.temperature"], start, end)
assert payload == [[{"entity_id": "sensor.temperature", "state": "21.0"}]]
client._session.get.assert_called_once() # type: ignore[attr-defined]
call = client._session.get.call_args # type: ignore[attr-defined]
assert "/api/history/period/2026-06-01T00:00:00+00:00" in call.args[0]
assert call.kwargs["params"]["filter_entity_id"] == "sensor.temperature"
assert call.kwargs["params"]["end_time"] == "2026-06-02T00:00:00+00:00"
@pytest.mark.parametrize(
("entity_ids", "start", "end"),
[
(
[],
datetime(2026, 6, 1, tzinfo=timezone.utc),
datetime(2026, 6, 2, tzinfo=timezone.utc),
),
(
["sensor.temperature"],
datetime(2026, 6, 1),
datetime(2026, 6, 2, tzinfo=timezone.utc),
),
(
["sensor.temperature"],
datetime(2026, 6, 2, tzinfo=timezone.utc),
datetime(2026, 6, 1, tzinfo=timezone.utc),
),
(
["invalid entity"],
datetime(2026, 6, 1, tzinfo=timezone.utc),
datetime(2026, 6, 2, tzinfo=timezone.utc),
),
(
["sensor.temperature"],
datetime(2026, 5, 1, tzinfo=timezone.utc),
datetime(2026, 6, 2, tzinfo=timezone.utc),
),
],
)
def test_get_history_validates_request(
entity_ids: list[str],
start: datetime,
end: datetime,
) -> None:
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
with pytest.raises(ValueError):
client.get_history(entity_ids, start, end)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
@@ -26,6 +28,22 @@ class FakeHaClient(HaClient):
},
]
def get_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[object]:
return [
[
{
"entity_id": entity_ids[0],
"state": "21.5",
"last_changed": start_time.isoformat(),
}
]
]
def test_ha_reader_returns_summaries() -> None:
reader = HaReader(FakeHaClient())
@@ -35,3 +53,25 @@ def test_ha_reader_returns_summaries() -> None:
assert domains == {"sensor", "light"}
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
assert sensor.unit_of_measurement == "°C"
def test_ha_reader_discovers_learnable_sensors() -> None:
reader = HaReader(FakeHaClient())
discovered = reader.discover(learnable=True)
assert [entity.entity_id for entity in discovered] == ["sensor.temperature"]
def test_ha_reader_normalizes_history() -> None:
reader = HaReader(FakeHaClient())
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
history = reader.read_history(
["sensor.temperature"],
start,
datetime(2026, 6, 2, tzinfo=timezone.utc),
)
assert history[0].entity_id == "sensor.temperature"
assert history[0].points[0].value == 21.5

92
tests/ha/test_history.py Normal file
View File

@@ -0,0 +1,92 @@
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from app.ha.exceptions import HaUnexpectedPayloadError
from app.ha.history import normalize_history_payload
def test_normalize_history_payload_groups_and_sorts_numeric_states() -> None:
payload = [
[
{
"entity_id": "sensor.temperature",
"state": "22.5",
"last_changed": "2026-06-01T12:15:00+00:00",
},
{
"state": "21.0",
"last_changed": "2026-06-01T12:00:00Z",
},
],
[
{
"entity_id": "sensor.humidity",
"state": 45,
"last_updated": "2026-06-01T12:00:00+00:00",
}
],
]
result = normalize_history_payload(payload)
assert [series.entity_id for series in result] == [
"sensor.humidity",
"sensor.temperature",
]
temperature = result[1]
assert [point.value for point in temperature.points] == [21.0, 22.5]
assert temperature.points[0].timestamp == datetime(
2026, 6, 1, 12, 0, tzinfo=timezone.utc
)
def test_normalize_history_payload_skips_non_numeric_and_non_finite_states() -> None:
payload = [
[
{
"entity_id": "sensor.temperature",
"state": state,
"last_changed": "2026-06-01T12:00:00+00:00",
}
for state in ("unknown", "unavailable", "nan", "inf", "-inf", True, None)
]
]
assert normalize_history_payload(payload) == []
@pytest.mark.parametrize(
"payload",
[
{},
[{}],
[["invalid"]],
[[{"entity_id": "invalid", "state": "21", "last_changed": "2026-06-01"}]],
[[{"entity_id": "sensor.a", "state": "21", "last_changed": "invalid"}]],
[[{"state": "21", "last_changed": "2026-06-01T12:00:00+00:00"}]],
[
[
{
"entity_id": "sensor.a",
"state": "21",
"last_changed": "2026-06-01T12:00:00+00:00",
},
{
"entity_id": "sensor.b",
"state": "22",
"last_changed": "2026-06-01T12:01:00+00:00",
},
]
],
],
)
def test_normalize_history_payload_rejects_malformed_structure(payload: object) -> None:
with pytest.raises(HaUnexpectedPayloadError):
normalize_history_payload(payload)
def test_normalize_history_payload_accepts_empty_series() -> None:
assert normalize_history_payload([[]]) == []

View File

@@ -21,13 +21,35 @@ def evaluator_factory() -> Evaluator:
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}"])
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", [])
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)}

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
@@ -31,12 +29,18 @@ def test_add_batch_appends_all_vectors() -> None:
]
store.add_batch(vectors)
assert len(store.all()) == 3
assert store.latest("sensor.kitchen").values["temperature"] == 20.0
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))
assert store.latest("sensor.living_room").values["temperature"] == 21.0
assert store.latest("sensor.bedroom").values["temperature"] == 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

View File

@@ -0,0 +1,50 @@
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
def test_registry_replaces_persisted_artifact_after_restart(tmp_path: Path) -> None:
registry = ModelRegistry(tmp_path)
registry.register(TrainedArtifact("model-v1", ("sensor.kitchen",)))
replacement = TrainedArtifact("model-v1", ("sensor.bedroom",))
registry.register(replacement)
assert registry.load_artifact("model-v1") == replacement
assert ModelRegistry(tmp_path).load_artifact("model-v1") == replacement
@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)

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
import pytest
from app.ml.feature_store import FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.retraining import RetrainingService, retrain_model
def _vector(sensor_id: str) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": 21.0})
def test_retraining_registers_new_artifact(tmp_path: Path) -> None:
registry = ModelRegistry(tmp_path)
result = retrain_model(registry, "home-model", [_vector("sensor.kitchen")])
assert result.replaced is False
assert registry.load_artifact("home-model") == result.artifact
def test_retraining_replaces_existing_artifact(tmp_path: Path) -> None:
registry = ModelRegistry(tmp_path)
service = RetrainingService(registry)
service.retrain("home-model", [_vector("sensor.kitchen")])
result = service.retrain("home-model", [_vector("sensor.bedroom")])
assert result.replaced is True
assert result.artifact.supported_sensors == ("sensor.bedroom",)
assert ModelRegistry(tmp_path).load_artifact("home-model") == result.artifact
def test_retraining_rejects_empty_training_data(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="keine Trainingsdaten"):
retrain_model(ModelRegistry(tmp_path), "home-model", [])

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
@@ -45,4 +45,4 @@ def test_export_returns_registered_artifact() -> None:
def test_export_missing_artifact_raises_key_error() -> None:
pipeline = store_with_data()
with pytest.raises(KeyError):
pipeline.export("artifact_v1")
pipeline.export("artifact_v1")

View 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

18
tests/test_config.py Normal file
View 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