Compare commits

..

40 Commits

Author SHA1 Message Date
5764b27bac MVP: add dashboard and Home Assistant add-on
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-13 21:12:02 +02:00
2f7f49b8a0 AUTO-001: add automation approval workflow
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 #21
2026-06-13 20:21:08 +02:00
6f9b5ea48f Merge pull request 'ML-009: Explainable Predictions' (#27) from feature/ml-explanations into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 20:16:40 +02:00
0de537572d ML-009: add explainable predictions
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 #20
2026-06-13 20:16:26 +02:00
9d9e08cc0b Merge pull request 'ML-008: Statistical Baseline Model' (#26) from feature/ml-baseline-model into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 20:13:29 +02:00
df2ddacfbf ML-008: add statistical baseline model
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 #19
2026-06-13 20:13:06 +02:00
ea5a206a86 Merge pull request 'HA data pipeline: Discovery und History' (#25) from feature/ha-discovery-history into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 20:06:29 +02:00
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
0bc928799a Merge branch 'feature/ml-serving' 2026-06-11 13:30:15 +02:00
d6c48b495a ML-005: FastAPI-App-Start und Batch-Sensor-Support finalisieren 2026-06-11 13:28:40 +02:00
57275d5172 ML-005: Doku zu ML-Serving-API ergänzen 2026-06-11 13:27:01 +02:00
fad517e56a ML-005 vorbereiten: Registry, API-Routen und kompatibler Predictor 2026-06-11 12:04:52 +02:00
79e883f77d ML-004: Training-Feedback und Evaluation-Metriken 2026-06-11 00:39:17 +02:00
3cf9af3515 ML-003: Predictor mit Sensor-Validierung und Batch-Interface 2026-06-11 00:38:45 +02:00
24be7a4f11 ML-002: Trainingspipeline mit Tainted-Data-Check 2026-06-11 00:21:21 +02:00
627ee03230 ML-001: Feature Store und erste ML-Tests hinzufügen 2026-06-11 00:21:02 +02:00
2fb086b1a1 INFRA-001: Docker-Compose-Basis für SillyHome Next anlegen 2026-06-11 00:13:21 +02:00
e2bc0644ae main: HeatingRule auf heizungsrelevante Sensoren begrenzen 2026-06-11 00:12:19 +02:00
57ffd1dda6 DOC-QUALITY-001: Quickstart, ENV-Doku und Tests beschreiben 2026-06-11 00:12:12 +02:00
445e4bcdf4 Merge otto/ha-client-errors into main 2026-06-10 23:29:30 +02:00
d550030a1a main: HA-Integration mit Exception-Handling und Testabdeckung 2026-06-10 22:47:08 +02:00
29ec53cc5e add safe home assistant error handling 2026-06-10 21:24:34 +02:00
8841a68c8d fix api integration quality baseline 2026-06-10 21:15:41 +02:00
6540d62ff7 ml/rules-recommendations: regelbasierte Heizungsempfehlung und Recommender 2026-06-10 20:46:54 +02:00
6b1e2ad0dc feature/ha-api-integration: lifespan und Settings für HA-Reader vorbereiten 2026-06-10 20:42:26 +02:00
6bba8f5947 Merge branch 'feature/api-core' into feature/ha-api-integration 2026-06-10 20:41:27 +02:00
009d4b68cb feature/ha-api-integration: API-Code aus feature/api-core übernehmen 2026-06-10 20:41:12 +02:00
9016dbad18 Merge branch 'feature/ha-adapter' into feature/ha-api-integration 2026-06-10 20:39:10 +02:00
d81ebf399c feature/ha-api-integration: lokale HA-Adapterdateien aus vorheriger Sitzung aufnehmen 2026-06-10 20:38:48 +02:00
43fb8fac2e feature/ha-adapter: Grundmodell, Client und Reader für Home Assistant 2026-06-10 09:17:27 +02:00
76 changed files with 3939 additions and 15 deletions

16
.dockerignore Normal file
View 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

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
SILLYHOME_MODEL_STORE=.model_store
SILLYHOME_AUTOMATION_STORE=.automation_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 /.vscode
__pycache__/ __pycache__/
*.pyc *.pyc
*.egg-info/
.mypy_cache/ .mypy_cache/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/

View File

@@ -1,5 +1,22 @@
# Changelog # Changelog
## Unreleased ## Unreleased
- Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage
- Persistenter Automation-Freigabeprozess mit sicherem YAML-Export
## 0.2.0 - 2026-06-13
- 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`
- Trainierbares statistisches Baseline-Modell mit persistierten Parametern
- Numerische Vorhersagen mit Confidence sowie MAE-/RMSE-Evaluation
## 0.1.0 - 2026-06-13
- 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
- Scheduler-tauglicher Retraining-Service mit API und atomischem Registry-Update

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
SILLYHOME_MODEL_STORE=/app/data/models
ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations
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 /app/data/automations && \
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"]

View File

@@ -1,6 +1,14 @@
# 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
Die aktuelle Entwicklungslinie stellt eine gehärtete technische Basis bereit:
Home-Assistant-Entities und Historie lesen, Sensoren klassifizieren,
regelbasierte Bausteine sowie ein lokal trainierbares statistisches
Baseline-Modell mit persistenter Registry, Confidence und echten
Evaluationsmetriken.
## 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 +20,73 @@ 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/` - lokales Dashboard
- `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/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
- `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen
- `POST http://127.0.0.1:8000/v1/automations/proposals` - sicheren Entwurf anlegen
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
- `SILLYHOME_AUTOMATION_STORE` Verzeichnis für Automation-Entwürfe
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
Versionskontrollsystem.
### Home-Assistant-Add-on
Das Repository ist zugleich ein Home-Assistant-Add-on-Repository. In Home Assistant
unter **Einstellungen → Add-ons → Add-on-Shop → Repositories** diese URL eintragen:
`http://192.168.6.31:3000/pino/sillyhome-next`
Danach **SillyHome Next** installieren und starten. Das Dashboard wird per Ingress
geöffnet. Das Add-on nutzt die Supervisor-API nur lesend; Automation-Entwürfe werden
lokal gespeichert und niemals automatisch ausgeführt.
### Tests
```bash
pytest
ruff check .
mypy
```

19
addon/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& git clone --depth 1 --branch main \
http://192.168.6.31:3000/pino/sillyhome-next.git /app \
&& python -m pip install --upgrade pip \
&& python -m pip install /app \
&& rm -rf /var/lib/apt/lists/* /app/.git
COPY run.sh /run.sh
RUN chmod 0755 /run.sh
EXPOSE 8000
CMD ["/run.sh"]

21
addon/config.yaml Normal file
View File

@@ -0,0 +1,21 @@
name: SillyHome Next
version: "0.3.0"
slug: sillyhome_next
description: Lokale HA-Analyse, Vorhersagen und sichere Automation-Entwürfe
url: http://192.168.6.31:3000/pino/sillyhome-next
arch:
- amd64
startup: application
boot: auto
init: false
ingress: true
ingress_port: 8000
panel_icon: mdi:home-analytics
homeassistant_api: true
hassio_api: false
auth_api: false
options: {}
schema: {}
map:
- type: addon_config
read_only: false

11
addon/run.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
export SILLYHOME_HA_URL="${SILLYHOME_HA_URL:-http://supervisor/core}"
export SILLYHOME_HA_TOKEN="${SILLYHOME_HA_TOKEN:-${SUPERVISOR_TOKEN:-}}"
export SILLYHOME_MODEL_STORE=/data/models
export SILLYHOME_AUTOMATION_STORE=/data/automations
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE"
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""SillyHome Next application package."""

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

@@ -0,0 +1 @@
"""API package."""

1
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Version 1 API package."""

77
app/api/v1/automations.py Normal file
View File

@@ -0,0 +1,77 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.automations.models import (
AutomationProposal,
ProposalDecision,
ProposalStatus,
)
from app.automations.store import AutomationStore
router = APIRouter(prefix="/v1/automations", tags=["automations"])
@router.post("/proposals", response_model=AutomationProposal, status_code=201)
def create_proposal(payload: AutomationProposal, request: Request) -> AutomationProposal:
if payload.trigger.above is None and payload.trigger.below is None:
raise HTTPException(status_code=422, detail="Trigger benötigt above oder below.")
return _store(request).create(payload.model_copy(update={"status": ProposalStatus.DRAFT}))
@router.get("/proposals", response_model=list[AutomationProposal])
def list_proposals(request: Request) -> list[AutomationProposal]:
return _store(request).list()
@router.post("/proposals/{proposal_id}/approve", response_model=AutomationProposal)
def approve(
proposal_id: str,
payload: ProposalDecision,
request: Request,
) -> AutomationProposal:
return _decide(request, proposal_id, ProposalStatus.APPROVED, payload.expected_revision)
@router.post("/proposals/{proposal_id}/reject", response_model=AutomationProposal)
def reject(
proposal_id: str,
payload: ProposalDecision,
request: Request,
) -> AutomationProposal:
return _decide(request, proposal_id, ProposalStatus.REJECTED, payload.expected_revision)
@router.get("/proposals/{proposal_id}/yaml")
def export_yaml(proposal_id: str, request: Request) -> Response:
try:
content = _store(request).export_yaml(proposal_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return Response(content=content, media_type="application/yaml")
def _decide(
request: Request,
proposal_id: str,
decision: ProposalStatus,
expected_revision: int,
) -> AutomationProposal:
try:
return _store(request).decide(proposal_id, decision, expected_revision)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
def _store(request: Request) -> AutomationStore:
store = getattr(request.app.state, "automation_store", None)
if not isinstance(store, AutomationStore):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Automation Store nicht initialisiert.",
)
return store

View File

@@ -1,10 +1,15 @@
from __future__ import annotations from __future__ import annotations
from typing import List, Sequence from datetime import datetime
from typing import List
from fastapi import APIRouter 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.models import HaEntitySummary
from app.ha.reader import HaReader
router = APIRouter(prefix="/v1", tags=["entities"]) router = APIRouter(prefix="/v1", tags=["entities"])
@@ -15,5 +20,45 @@ 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())
@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

@@ -0,0 +1,3 @@
from app.automations.store import AutomationStore
__all__ = ["AutomationStore"]

41
app/automations/models.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import StrEnum
from uuid import uuid4
from pydantic import BaseModel, Field
class ProposalStatus(StrEnum):
DRAFT = "draft"
APPROVED = "approved"
REJECTED = "rejected"
class NumericStateTrigger(BaseModel):
entity_id: str = Field(pattern=r"^sensor\.[a-z0-9_]+$")
above: float | None = None
below: float | None = None
class ServiceAction(BaseModel):
service: str = Field(pattern=r"^(light|switch|climate|fan|cover)\.[a-z0-9_]+$")
entity_id: str = Field(pattern=r"^(light|switch|climate|fan|cover)\.[a-z0-9_]+$")
data: dict[str, str | int | float | bool] = Field(default_factory=dict)
class AutomationProposal(BaseModel):
proposal_id: str = Field(default_factory=lambda: uuid4().hex)
alias: str = Field(min_length=1, max_length=120)
description: str = Field(min_length=1, max_length=500)
trigger: NumericStateTrigger
action: ServiceAction
status: ProposalStatus = ProposalStatus.DRAFT
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
revision: int = 1
class ProposalDecision(BaseModel):
expected_revision: int = Field(ge=1)

124
app/automations/store.py Normal file
View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from threading import RLock
from app.automations.models import AutomationProposal, ProposalStatus
class AutomationStore:
def __init__(self, root: str | Path) -> None:
self._root = Path(root).resolve()
self._root.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
def create(self, proposal: AutomationProposal) -> AutomationProposal:
with self._lock:
target = self._target(proposal.proposal_id)
if target.exists():
raise ValueError("Automation-Vorschlag existiert bereits.")
self._persist(proposal)
return proposal
def list(self) -> list[AutomationProposal]:
with self._lock:
return [self._load(path) for path in sorted(self._root.glob("*.json"))]
def get(self, proposal_id: str) -> AutomationProposal:
with self._lock:
target = self._target(proposal_id)
if not target.exists():
raise KeyError("Automation-Vorschlag nicht gefunden.")
return self._load(target)
def decide(
self,
proposal_id: str,
status: ProposalStatus,
expected_revision: int,
) -> AutomationProposal:
if status is ProposalStatus.DRAFT:
raise ValueError("Entscheidung darf nicht auf draft gesetzt werden.")
with self._lock:
proposal = self.get(proposal_id)
if proposal.revision != expected_revision:
raise ValueError("Revision stimmt nicht mit dem aktuellen Vorschlag überein.")
if proposal.status is not ProposalStatus.DRAFT:
raise ValueError("Über den Vorschlag wurde bereits entschieden.")
updated = proposal.model_copy(
update={
"status": status,
"updated_at": datetime.now(timezone.utc),
"revision": proposal.revision + 1,
}
)
self._persist(updated)
return updated
def export_yaml(self, proposal_id: str) -> str:
proposal = self.get(proposal_id)
if proposal.status is not ProposalStatus.APPROVED:
raise ValueError("Nur freigegebene Vorschläge dürfen exportiert werden.")
trigger_lines = [
"trigger:",
" - platform: numeric_state",
f" entity_id: {proposal.trigger.entity_id}",
]
if proposal.trigger.above is not None:
trigger_lines.append(f" above: {proposal.trigger.above}")
if proposal.trigger.below is not None:
trigger_lines.append(f" below: {proposal.trigger.below}")
action_lines = [
"action:",
f" - service: {proposal.action.service}",
" target:",
f" entity_id: {proposal.action.entity_id}",
]
if proposal.action.data:
action_lines.append(" data:")
action_lines.extend(
f" {key}: {_yaml_scalar(value)}"
for key, value in sorted(proposal.action.data.items())
)
return "\n".join(
[
f"alias: {_yaml_scalar(proposal.alias)}",
f"description: {_yaml_scalar(proposal.description)}",
*trigger_lines,
*action_lines,
"mode: single",
"",
]
)
def _target(self, proposal_id: str) -> Path:
if len(proposal_id) != 32 or not proposal_id.isalnum():
raise ValueError("Ungültige proposal_id.")
return self._root / f"{proposal_id}.json"
def _persist(self, proposal: AutomationProposal) -> None:
target = self._target(proposal.proposal_id)
temporary = target.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(proposal.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, target)
@staticmethod
def _load(path: Path) -> AutomationProposal:
try:
return AutomationProposal.model_validate_json(path.read_text(encoding="utf-8"))
except ValueError as exc:
raise ValueError(f"Ungültiger Automation-Vorschlag: {path.name}") from exc
def _yaml_scalar(value: str | int | float | bool) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
return json.dumps(value, ensure_ascii=True)

25
app/config.py Normal file
View File

@@ -0,0 +1,25 @@
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"
automation_store: str = ".automation_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"),
automation_store=os.getenv("SILLYHOME_AUTOMATION_STORE", ".automation_store"),
)

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

@@ -0,0 +1 @@
"""Core application helpers."""

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError, HaTimeoutError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HaClientError)
async def handle_ha_client_error(_: Request, exc: HaClientError) -> JSONResponse:
return JSONResponse(
status_code=_status_code_for_ha_error(exc),
content={"detail": exc.public_detail},
)
def _status_code_for_ha_error(exc: HaClientError) -> int:
if isinstance(exc, HaTimeoutError):
return status.HTTP_504_GATEWAY_TIMEOUT
if isinstance(exc, (HaAuthError, HaHttpError)):
return status.HTTP_502_BAD_GATEWAY
return status.HTTP_502_BAD_GATEWAY

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

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

15
app/dependencies.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import HTTPException, Request, status
from app.ha.reader import HaReader
def get_ha_reader(request: Request) -> HaReader:
reader = getattr(request.app.state, "ha_reader", None)
if not isinstance(reader, HaReader):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Home Assistant is not configured.",
)
return reader

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

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

127
app/ha/client.py Normal file
View File

@@ -0,0 +1,127 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
import re
from urllib.parse import quote
import requests
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
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:
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]]:
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.rstrip('/')}{path}",
params=params,
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
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,
)

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

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

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

19
app/ha/models.py Normal file
View 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

59
app/ha/reader.py Normal file
View File

@@ -0,0 +1,59 @@
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
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 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 == "":
return None
return str(value)

View File

@@ -1,10 +1,60 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from pathlib import Path
from typing import cast
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.v1.entities import router as entities_router
from app.api.v1.automations import router as automations_router
from app.automations.store import AutomationStore
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)
app.state.automation_store = AutomationStore(settings.automation_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.3.0",
lifespan=lifespan,
) )
app.state.settings = load_settings()
register_exception_handlers(app)
app.include_router(entities_router)
app.include_router(automations_router)
init_ml_routes(app, model_store=app.state.settings.model_store)
STATIC_DIR = Path(__file__).with_name("static")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/health") @app.get("/health")
@@ -13,5 +63,5 @@ def health() -> dict[str, str]:
@app.get("/") @app.get("/")
def root() -> dict[str, str]: def root() -> FileResponse:
return {"service": "sillyhome-next", "docs": "/docs"} return FileResponse(STATIC_DIR / "index.html")

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

@@ -0,0 +1,20 @@
"""Machine-Learning-Grundbausteine für SillyHome Next."""
__all__ = [
"FeatureStore",
"FeatureVector",
"FeatureModel",
"FeatureExplanation",
"PredictionResult",
"Predictor",
"RetrainingResult",
"RetrainingService",
"TrainedArtifact",
"TrainingPipeline",
"retrain_model",
]
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.explanation import FeatureExplanation
from app.ml.predictor import PredictionResult, Predictor
from app.ml.retraining import RetrainingResult, RetrainingService, retrain_model
from app.ml.training import FeatureModel, TrainedArtifact, TrainingPipeline

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

@@ -0,0 +1,89 @@
from __future__ import annotations
import logging
import math
from collections.abc import Sequence
from dataclasses import dataclass
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 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 = 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("Evaluator erfordert TrainingPipeline oder ModelRegistry.")
self._pipeline = pipeline
self._registry = registry
self._predictor = Predictor(pipeline=pipeline, registry=registry)
def evaluate(self, artifact_id: str, samples: Sequence[FeatureVector]) -> EvalReport:
try:
if self._registry is not None:
self._registry.load_artifact(artifact_id)
elif self._pipeline is not None:
self._pipeline.export(artifact_id)
except KeyError as exc:
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") from exc
absolute_errors: list[float] = []
squared_errors: list[float] = []
for sample in samples:
try:
prediction = self._predictor.predict(artifact_id, sample)
except ValueError:
continue
for feature_name, predicted in prediction.predictions.items():
actual = float(sample.values[feature_name])
error = predicted - actual
absolute_errors.append(abs(error))
squared_errors.append(error**2)
sample_size = len(absolute_errors)
mae = sum(absolute_errors) / sample_size if sample_size else 0.0
rmse = math.sqrt(sum(squared_errors) / sample_size) if sample_size else 0.0
expected_values = sum(len(sample.values) for sample in samples)
coverage = sample_size / expected_values if expected_values else 0.0
report = EvalReport(
artifact_id=artifact_id,
sample_size=sample_size,
metrics=[
Metric(name="mae", value=mae),
Metric(name="rmse", value=rmse),
Metric(name="coverage", value=coverage, threshold=0.8),
],
)
logger.info(
"Evaluation %s -> mae=%.4f, rmse=%.4f, coverage=%.2f",
artifact_id,
mae,
rmse,
coverage,
)
return report

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

@@ -0,0 +1,57 @@
from __future__ import annotations
from dataclasses import dataclass
from app.ml.training import FeatureModel
@dataclass(frozen=True)
class FeatureExplanation:
feature: str
current_value: float
predicted_value: float
change: float
direction: str
sample_count: int
historical_mean: float
historical_range: tuple[float, float]
standard_deviation: float
trend_per_step: float
confidence: float
summary: str
def explain_feature(
feature_name: str,
current_value: float,
predicted_value: float,
model: FeatureModel,
) -> FeatureExplanation:
change = predicted_value - current_value
direction = _direction(change)
summary = (
f"{feature_name}: {direction}; Prognose {predicted_value:.3f} "
f"aus aktuellem Wert {current_value:.3f} und Trend {model.slope:+.3f}. "
f"Basis: {model.sample_count} Messwerte, Mittelwert {model.mean:.3f}, "
f"Confidence {model.confidence:.0%}."
)
return FeatureExplanation(
feature=feature_name,
current_value=current_value,
predicted_value=predicted_value,
change=change,
direction=direction,
sample_count=model.sample_count,
historical_mean=model.mean,
historical_range=(model.minimum, model.maximum),
standard_deviation=model.standard_deviation,
trend_per_step=model.slope,
confidence=model.confidence,
summary=summary,
)
def _direction(change: float) -> str:
if abs(change) < 1e-12:
return "stabil"
return "steigend" if change > 0 else "fallend"

31
app/ml/feature_store.py Normal file
View 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]

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

@@ -0,0 +1,102 @@
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Sequence
from app.ml.explanation import FeatureExplanation, explain_feature
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__)
@dataclass(frozen=True)
class PredictionResult:
artifact_id: str
sensor_id: str
predictions: dict[str, float]
confidence: float
model_type: str
explanations: dict[str, FeatureExplanation]
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) -> PredictionResult:
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."
)
sensor_models = artifact.feature_models.get(entity.sensor_id, {})
if not sensor_models:
raise ValueError(f"Modell '{artifact_id}' enthält keine statistischen Parameter.")
feature_names = sorted(set(sensor_models).intersection(entity.values))
if not feature_names:
raise ValueError(
f"Keine Eingabemerkmale werden vom Modell '{artifact_id}' unterstützt."
)
predictions: dict[str, float] = {}
explanations: dict[str, FeatureExplanation] = {}
confidences: list[float] = []
for feature_name in feature_names:
model = sensor_models[feature_name]
current_value = float(entity.values[feature_name])
if not math.isfinite(current_value):
raise ValueError("Vorhersagewerte müssen endlich sein.")
predicted_value = model.forecast(current_value)
predictions[feature_name] = predicted_value
explanations[feature_name] = explain_feature(
feature_name,
current_value,
predicted_value,
model,
)
confidences.append(model.confidence)
return PredictionResult(
artifact_id=artifact_id,
sensor_id=entity.sensor_id,
predictions=predictions,
confidence=sum(confidences) / len(confidences),
model_type=artifact.model_type,
explanations=explanations,
)
def predict_batch(
self,
artifact_id: str,
entities: Sequence[FeatureVector],
) -> list[PredictionResult]:
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.")

View File

@@ -0,0 +1,3 @@
from .model_registry import ModelRegistry
__all__ = ["ModelRegistry"]

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import json
import logging
import math
import os
from pathlib import Path
import re
from threading import RLock
from collections.abc import Iterable
from app.ml.training import FeatureModel, 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._lock = RLock()
self._load_existing()
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
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:
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]:
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"]
model_type = raw.get("model_type", "metadata")
raw_feature_models = raw.get("feature_models", {})
if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list):
raise ValueError("invalid artifact structure")
if not isinstance(model_type, str):
raise ValueError("model_type must be a string")
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")
feature_models = _deserialize_feature_models(raw_feature_models)
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),
feature_models=feature_models,
model_type=model_type,
)
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),
"model_type": artifact.model_type,
"feature_models": {
sensor_id: {
feature_name: {
"sample_count": model.sample_count,
"mean": model.mean,
"standard_deviation": model.standard_deviation,
"minimum": model.minimum,
"maximum": model.maximum,
"slope": model.slope,
"intercept": model.intercept,
}
for feature_name, model in sorted(models.items())
}
for sensor_id, models in sorted(artifact.feature_models.items())
},
}
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."
)
def _deserialize_feature_models(raw: object) -> dict[str, dict[str, FeatureModel]]:
if not isinstance(raw, dict):
raise ValueError("feature_models must be an object")
result: dict[str, dict[str, FeatureModel]] = {}
for sensor_id, raw_features in raw.items():
if not isinstance(sensor_id, str) or not isinstance(raw_features, dict):
raise ValueError("invalid sensor feature models")
features: dict[str, FeatureModel] = {}
for feature_name, raw_model in raw_features.items():
if not isinstance(feature_name, str) or not isinstance(raw_model, dict):
raise ValueError("invalid feature model")
sample_count = raw_model.get("sample_count")
if not isinstance(sample_count, int) or isinstance(sample_count, bool) or sample_count < 1:
raise ValueError("sample_count must be a positive integer")
values = {
key: _finite_number(raw_model.get(key))
for key in (
"mean",
"standard_deviation",
"minimum",
"maximum",
"slope",
"intercept",
)
}
features[feature_name] = FeatureModel(
sample_count=sample_count,
mean=values["mean"],
standard_deviation=values["standard_deviation"],
minimum=values["minimum"],
maximum=values["maximum"],
slope=values["slope"],
intercept=values["intercept"],
)
result[sensor_id] = features
return result
def _finite_number(value: object) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise ValueError("feature model values must be finite numbers")
converted = float(value)
if not math.isfinite(converted):
raise ValueError("feature model values must be finite numbers")
return converted

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)

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

@@ -0,0 +1,117 @@
from __future__ import annotations
import logging
import math
from collections import defaultdict
from dataclasses import dataclass, field
from app.ml.feature_store import FeatureStore
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class FeatureModel:
sample_count: int
mean: float
standard_deviation: float
minimum: float
maximum: float
slope: float
intercept: float
def forecast(self, current_value: float | None = None) -> float:
if current_value is not None:
return current_value + self.slope
return self.intercept + self.slope * self.sample_count
@property
def confidence(self) -> float:
sample_score = self.sample_count / (self.sample_count + 2)
scale = abs(self.mean) if abs(self.mean) > 1e-9 else 1.0
stability_score = 1.0 / (1.0 + self.standard_deviation / scale)
return min(0.99, max(0.05, sample_score * stability_score))
@dataclass(frozen=True)
class TrainedArtifact:
artifact_id: str
supported_sensors: tuple[str, ...]
feature_models: dict[str, dict[str, FeatureModel]] = field(default_factory=dict)
model_type: str = "statistical_baseline"
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.")
samples: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
for vector in vectors:
for feature_name, raw_value in vector.values.items():
value = float(raw_value)
if math.isfinite(value):
samples[vector.sensor_id][feature_name].append(value)
feature_models = {
sensor_id: {
feature_name: _fit_feature(values)
for feature_name, values in sorted(features.items())
if values
}
for sensor_id, features in sorted(samples.items())
}
feature_models = {
sensor_id: models for sensor_id, models in feature_models.items() if models
}
if not feature_models:
raise ValueError("Trainingsdaten enthalten keine endlichen numerischen Werte.")
sensors = tuple(feature_models)
artifact = TrainedArtifact(
artifact_id=artifact_id,
supported_sensors=sensors,
feature_models=feature_models,
)
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]
def _fit_feature(values: list[float]) -> FeatureModel:
sample_count = len(values)
mean = sum(values) / sample_count
variance = sum((value - mean) ** 2 for value in values) / sample_count
standard_deviation = math.sqrt(variance)
if sample_count == 1:
slope = 0.0
intercept = mean
else:
x_mean = (sample_count - 1) / 2
denominator = sum((index - x_mean) ** 2 for index in range(sample_count))
numerator = sum(
(index - x_mean) * (value - mean) for index, value in enumerate(values)
)
slope = numerator / denominator
intercept = mean - slope * x_mean
return FeatureModel(
sample_count=sample_count,
mean=mean,
standard_deviation=standard_deviation,
minimum=min(values),
maximum=max(values),
slope=slope,
intercept=intercept,
)

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

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

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

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

25
app/rules/recommender.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from collections.abc import Sequence
from app.ha.models import HaEntitySummary
class Rule:
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
raise NotImplementedError
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
raise NotImplementedError
class Recommender:
def __init__(self, rules: Sequence[Rule]) -> None:
self._rules = rules
def run(self, entities: Sequence[HaEntitySummary]) -> list[str]:
results: list[str] = []
for rule in self._rules:
if rule.matches(entities):
results.append(rule.recommendation(entities))
return results

149
app/static/index.html Normal file
View File

@@ -0,0 +1,149 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
body { margin: 0; }
header { padding: 20px; background: linear-gradient(135deg,#142b3a,#193f36); }
h1,h2 { margin: 0 0 12px; }
header p { margin: 4px 0; color: #b9c9d6; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(310px,1fr)); gap: 14px; padding: 14px; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
.wide { grid-column: 1 / -1; }
.ok { color: #66dfa9; } .bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
input,select,textarea,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 9px; background: #101820; color: #fff; }
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; }
pre { white-space: pre-wrap; max-height: 310px; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
td,th { padding: 7px; border-bottom: 1px solid #2d3a47; text-align: left; }
.notice { border-left: 4px solid #e8b34b; padding-left: 10px; }
</style>
</head>
<body>
<header>
<h1>SillyHome Next</h1>
<p>Lokale Home-Assistant-Analyse, Vorhersagen und kontrollierte Automation-Entwürfe.</p>
<p class="notice">Sicherheitsmodus: Entwürfe werden niemals automatisch in Home Assistant ausgeführt.</p>
</header>
<main>
<section>
<h2>Systemstatus</h2>
<div id="status">Prüfung läuft ...</div>
<button class="secondary" onclick="loadStatus()">Neu laden</button>
</section>
<section>
<h2>Entity Discovery</h2>
<label for="domain">Domain (optional)</label>
<input id="domain" placeholder="sensor">
<button onclick="discover()">HA-Entities analysieren</button>
<pre id="discovery">Noch nicht geladen.</pre>
</section>
<section>
<h2>Modell trainieren</h2>
<label for="train-model">Modell-ID</label><input id="train-model" value="home-model">
<label for="train-sensor">Sensor</label><input id="train-sensor" placeholder="sensor.temperatur">
<label for="train-feature">Merkmal</label><input id="train-feature" value="value">
<label for="train-values">Messwerte, komma-getrennt</label><input id="train-values" placeholder="19,20,21">
<button onclick="train()">Trainieren</button>
<pre id="training">Bereit.</pre>
</section>
<section>
<h2>Vorhersage</h2>
<label for="predict-model">Modell-ID</label><input id="predict-model" value="home-model">
<label for="predict-sensor">Sensor</label><input id="predict-sensor" placeholder="sensor.temperatur">
<label for="predict-feature">Merkmal</label><input id="predict-feature" value="value">
<label for="predict-value">Aktueller Wert</label><input id="predict-value" type="number" step="any">
<button onclick="predict()">Vorhersagen und erklären</button>
<pre id="prediction">Bereit.</pre>
</section>
<section class="wide">
<h2>Automation-Entwurf</h2>
<p>Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
<div><label for="alias">Name</label><input id="alias" value="Licht bei Dunkelheit"></div>
<div><label for="trigger">Trigger-Entity</label><input id="trigger" placeholder="sensor.flur_illuminance"></div>
<div><label for="below">Unter Grenzwert</label><input id="below" type="number" value="10"></div>
<div><label for="service">Dienst</label><select id="service"><option>light.turn_on</option><option>light.turn_off</option><option>switch.turn_on</option><option>switch.turn_off</option></select></div>
<div><label for="target">Ziel-Entity</label><input id="target" placeholder="light.flur"></div>
</div>
<button onclick="createProposal()">Entwurf speichern</button>
<button class="secondary" onclick="loadProposals()">Entwürfe aktualisieren</button>
<div id="proposals"></div>
</section>
</main>
<script>
const pretty = value => JSON.stringify(value, null, 2);
async function api(path, options={}) {
const response = await fetch(path, {headers: {"Content-Type":"application/json"}, ...options});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.detail || `${response.status} ${response.statusText}`);
return body;
}
async function loadStatus() {
const box=document.getElementById("status");
try {
const [health, ml, models]=await Promise.all([api("health"),api("ml/health"),api("ml/models")]);
box.innerHTML=`<p class="ok">API und ML bereit</p><p>Modelle: ${models.models.length}</p>`;
} catch(e) { box.innerHTML=`<p class="bad">${e.message}</p>`; }
}
async function discover() {
const out=document.getElementById("discovery"), domain=document.getElementById("domain").value.trim();
out.textContent="Lade ...";
try {
const rows=await api(`v1/discovery?learnable=true${domain?`&domain=${encodeURIComponent(domain)}`:""}`);
out.textContent=pretty({learnable_entities:rows.length, entities:rows.slice(0,100)});
} catch(e) { out.textContent=e.message; }
}
async function train() {
const out=document.getElementById("training");
try {
const values=document.getElementById("train-values").value.split(",").map(Number).filter(Number.isFinite);
if (!values.length) throw new Error("Mindestens einen Messwert eingeben.");
const sensor=document.getElementById("train-sensor").value.trim(), feature=document.getElementById("train-feature").value.trim();
const samples=values.map(value=>({sensor_id:sensor,values:{[feature]:value}}));
out.textContent=pretty(await api("ml/retrain",{method:"POST",body:JSON.stringify({modelId:document.getElementById("train-model").value,samples})}));
await loadStatus();
} catch(e) { out.textContent=e.message; }
}
async function predict() {
const out=document.getElementById("prediction");
try {
const feature=document.getElementById("predict-feature").value.trim();
out.textContent=pretty(await api("ml/predict",{method:"POST",body:JSON.stringify({
modelId:document.getElementById("predict-model").value,
sensor_id:document.getElementById("predict-sensor").value.trim(),
values:{[feature]:Number(document.getElementById("predict-value").value)}
})}));
} catch(e) { out.textContent=e.message; }
}
async function createProposal() {
try {
await api("v1/automations/proposals",{method:"POST",body:JSON.stringify({
alias:document.getElementById("alias").value,
description:"Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.",
trigger:{entity_id:document.getElementById("trigger").value,below:Number(document.getElementById("below").value)},
action:{service:document.getElementById("service").value,entity_id:document.getElementById("target").value,data:{}}
})});
await loadProposals();
} catch(e) { alert(e.message); }
}
async function decide(id, revision, action) {
try { await api(`v1/automations/proposals/${id}/${action}`,{method:"POST",body:JSON.stringify({expected_revision:revision})}); await loadProposals(); }
catch(e) { alert(e.message); }
}
async function loadProposals() {
const box=document.getElementById("proposals");
try {
const rows=await api("v1/automations/proposals");
box.innerHTML=rows.length?`<table><tr><th>Name</th><th>Status</th><th>Aktion</th></tr>${rows.map(x=>`<tr><td>${x.alias}</td><td>${x.status}</td><td>${x.status==="draft"?`<button onclick="decide('${x.proposal_id}',${x.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${x.proposal_id}',${x.revision},'reject')">Ablehnen</button>`:`${x.status==="approved"?`<a href="v1/automations/proposals/${x.proposal_id}/yaml">YAML laden</a>`:"-"}`}</td></tr>`).join("")}</table>`:"<p>Keine Entwürfe.</p>";
} catch(e) { box.textContent=e.message; }
}
loadStatus(); loadProposals();
</script>
</body>
</html>

1
backend/__init__.py Normal file
View File

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

43
backend/app.py Normal file
View 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()

View File

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

253
backend/routes/ml.py Normal file
View File

@@ -0,0 +1,253 @@
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.evaluation import Evaluator
from app.ml.feature_store import FeatureVector
from app.ml.predictor import Predictor
from app.ml.registry.model_registry import ModelRegistry
from app.ml.retraining import retrain_model
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
predictions: dict[str, float]
confidence: float
model_type: str
explanations: dict[str, "FeatureExplanationResponse"]
class FeatureExplanationResponse(BaseModel):
feature: str
current_value: float
predicted_value: float
change: float
direction: str
sample_count: int
historical_mean: float
historical_range: tuple[float, float]
standard_deviation: float
trend_per_step: float
confidence: float
summary: str
class BatchRequest(BaseModel):
requests: Sequence[PredictRequest]
class BatchResponse(BaseModel):
predictions: Sequence[PredictResponse]
class ModelsResponse(BaseModel):
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]
trained_features: int
model_type: str
replaced: bool
class EvaluateRequest(BaseModel):
model_id: str = Field(..., alias="modelId", min_length=1, max_length=128)
samples: list[TrainingSample] = Field(min_length=1)
class MetricResponse(BaseModel):
name: str
value: float
threshold: float | None = None
class EvaluateResponse(BaseModel):
model_id: str
sample_size: int
metrics: list[MetricResponse]
@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("/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:
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),
trained_features=sum(
len(feature_models)
for feature_models in result.artifact.feature_models.values()
),
model_type=result.artifact.model_type,
replaced=result.replaced,
)
@router.post("/evaluate", response_model=EvaluateResponse, status_code=200)
def evaluate(payload: EvaluateRequest, request: Request) -> EvaluateResponse:
registry = _require_registry(request)
vectors = [
FeatureVector(
sensor_id=sample.sensor_id,
values=sample.values,
label=sample.label,
)
for sample in payload.samples
]
try:
report = Evaluator(registry=registry).evaluate(payload.model_id, vectors)
except ValueError as exc:
try:
registry.load_artifact(payload.model_id)
except KeyError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return EvaluateResponse(
model_id=report.artifact_id,
sample_size=report.sample_size,
metrics=[
MetricResponse(name=metric.name, value=metric.value, threshold=metric.threshold)
for metric in report.metrics
],
)
@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,
predictions=prediction.predictions,
confidence=prediction.confidence,
model_type=prediction.model_type,
explanations={
name: FeatureExplanationResponse(**explanation.__dict__)
for name, explanation in prediction.explanations.items()
},
)
@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,
predictions=prediction.predictions,
confidence=prediction.confidence,
model_type=prediction.model_type,
explanations={
name: FeatureExplanationResponse(**explanation.__dict__)
for name, explanation in prediction.explanations.items()
},
)
)
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")

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
api:
build: .
ports:
- "127.0.0.1:8000:8000"
env_file:
- path: .env
required: false
environment:
SILLYHOME_MODEL_STORE: /app/data/models
SILLYHOME_AUTOMATION_STORE: /app/data/automations
volumes:
- model-data:/app/data/models
- automation-data:/app/data/automations
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
volumes:
model-data:
automation-data:

14
docs/automations.md Normal file
View File

@@ -0,0 +1,14 @@
# Automation-Vorschläge
SillyHome Next führt Automationen niemals automatisch aus. Der Workflow ist:
1. Vorschlag als `draft` erstellen.
2. Inhalt und Ziel-Entity prüfen.
3. Mit aktueller Revision explizit freigeben oder ablehnen.
4. Nur freigegebene Vorschläge als Home-Assistant-YAML exportieren.
5. Das YAML außerhalb von SillyHome Next in Home Assistant importieren.
Erlaubt sind numerische Sensor-Trigger und Aktionsdienste aus den Domains
`light`, `switch`, `climate`, `fan` und `cover`. Shell-Kommandos, Skripte und
beliebige Service-Domains werden abgewiesen. Eine einmal getroffene Entscheidung
kann nicht überschrieben werden; Änderungen benötigen einen neuen Vorschlag.

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.

185
docs/ml_api.md Normal file
View File

@@ -0,0 +1,185 @@
# ML-Serving-API
Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen
Modell-Artefakt- und Vorhersage-Schnittstelle.
Das Serving verwendet ein lokal trainiertes statistisches Baseline-Modell.
## Basis-URL
- Standard: `http://127.0.0.1:8000/ml`
- Health: `/health`
- Modelle: `/models`
- Retraining: `/retrain`
- Evaluation: `/evaluate`
- 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",
"predictions": {"temperature": 21.4},
"confidence": 0.78,
"model_type": "statistical_baseline",
"explanations": {
"temperature": {
"direction": "steigend",
"change": 0.4,
"sample_count": 24,
"historical_mean": 20.7,
"trend_per_step": 0.4,
"summary": "temperature: steigend; Prognose ..."
}
}
}
```
Die Erklärung nennt pro Merkmal den aktuellen und prognostizierten Wert,
Richtung, Veränderung, Datenbasis, historischen Bereich, Streuung, Trend und
Confidence. Sie wird deterministisch aus den gespeicherten Modellparametern
erzeugt.
### `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"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": false
}
```
### `POST /ml/evaluate`
Vergleicht Modellvorhersagen mit Validierungsdaten und liefert MAE, RMSE und
Coverage. Der Request verwendet dasselbe Sample-Format wie `/ml/retrain`.
### `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",
"predictions": {"temperature": 21.4},
"confidence": 0.78,
"model_type": "statistical_baseline"
},
{
"model_id": "default",
"sensor_id": "sensor.bedroom",
"predictions": {"temperature": 18.3},
"confidence": 0.74,
"model_type": "statistical_baseline"
}
]
}
```
## 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 ü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`

68
docs/ml_training.md Normal file
View File

@@ -0,0 +1,68 @@
# ML Training- und Evaluations-Workflow
SillyHome Next trainiert ein lokales statistisches Baseline-Modell pro Sensor
und Merkmal. Es benötigt keine Cloud und keine externe ML-Laufzeit.
## 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. Statistisches Artefakt 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(...)` berechnet für jedes numerische Merkmal:
- Stichprobenzahl
- Mittelwert und Standardabweichung
- Minimum und Maximum
- linearen Trend mit Steigung und Achsenabschnitt
Die nächste Vorhersage kombiniert den letzten beobachteten Wert mit der
trainierten Trendsteigung. Die Confidence berücksichtigt Datenmenge und
Stabilität.
## 3. Modell evaluieren
```python
evaluator = Evaluator(pipeline)
report = evaluator.evaluate(artifact.artifact_id, validation_samples)
```
Der Report enthält echte numerische Vergleichsmetriken:
- `artifact_id`
- `sample_size`
- `mae` (Mean Absolute Error)
- `rmse` (Root Mean Squared Error)
- `coverage` für den Anteil auswertbarer Merkmale
## 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.
- Nur endliche numerische Werte werden trainiert.
- `coverage` bleibt im Bereich 0 bis 1.

View File

@@ -1,16 +1,22 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.1.0" version = "0.3.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ 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

3
repository.yaml Normal file
View File

@@ -0,0 +1,3 @@
name: SillyHome Next Add-ons
url: http://192.168.6.31:3000/pino/sillyhome-next
maintainer: Pino

View File

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

View File

@@ -0,0 +1,59 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app.automations.store import AutomationStore
from app.main import app
def _payload() -> dict[str, object]:
return {
"alias": "Licht bei Dunkelheit",
"description": "Schaltet das Flurlicht unter dem Helligkeitsgrenzwert ein.",
"trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
"action": {
"service": "light.turn_on",
"entity_id": "light.hall",
"data": {"brightness_pct": 40},
},
}
def test_proposal_requires_explicit_approval_before_yaml(tmp_path: Path) -> None:
with TestClient(app) as client:
app.state.automation_store = AutomationStore(tmp_path)
created = client.post("/v1/automations/proposals", json=_payload())
proposal_id = created.json()["proposal_id"]
blocked = client.get(f"/v1/automations/proposals/{proposal_id}/yaml")
approved = client.post(
f"/v1/automations/proposals/{proposal_id}/approve",
json={"expected_revision": 1},
)
exported = client.get(f"/v1/automations/proposals/{proposal_id}/yaml")
assert created.status_code == 201
assert created.json()["status"] == "draft"
assert blocked.status_code == 409
assert approved.json()["status"] == "approved"
assert "service: light.turn_on" in exported.text
def test_proposal_rejects_unsafe_service_domain(tmp_path: Path) -> None:
payload = _payload()
payload["action"] = {
"service": "shell_command.run",
"entity_id": "light.hall",
"data": {},
}
with TestClient(app) as client:
app.state.automation_store = AutomationStore(tmp_path)
response = client.post("/v1/automations/proposals", json=payload)
assert response.status_code == 422
def test_proposal_requires_numeric_threshold(tmp_path: Path) -> None:
payload = _payload()
payload["trigger"] = {"entity_id": "sensor.hall_illuminance"}
with TestClient(app) as client:
app.state.automation_store = AutomationStore(tmp_path)
response = client.post("/v1/automations/proposals", json=payload)
assert response.status_code == 422

View File

@@ -1,10 +1,137 @@
from collections.abc import Sequence
from datetime import datetime
from fastapi.testclient import TestClient 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 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")]
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:
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:
with TestClient(app) as client:
response = client.get("/docs") 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."}
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}],
}
]

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

@@ -0,0 +1,184 @@
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"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": False,
}
assert replaced.status_code == 200
assert replaced.json() == {
"model_id": "home-model",
"supported_sensors": ["sensor.bedroom"],
"trained_features": 1,
"model_type": "statistical_baseline",
"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
def test_predict_returns_numeric_forecast_and_confidence(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
registry = ModelRegistry(tmp_path)
registry.register(TrainingPipeline(store).run("home-model"))
with TestClient(app) as client:
app.state.registry = registry
response = client.post(
"/ml/predict",
json={
"modelId": "home-model",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
},
)
assert response.status_code == 200
assert response.json()["predictions"] == {"temperature": 22.0}
assert 0.0 < response.json()["confidence"] <= 1.0
assert response.json()["model_type"] == "statistical_baseline"
explanation = response.json()["explanations"]["temperature"]
assert explanation["direction"] == "steigend"
assert explanation["change"] == 1.0
assert explanation["sample_count"] == 2
def test_evaluate_returns_real_error_metrics(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
registry = ModelRegistry(tmp_path)
registry.register(TrainingPipeline(store).run("home-model"))
with TestClient(app) as client:
app.state.registry = registry
response = client.post(
"/ml/evaluate",
json={
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
}
],
},
)
assert response.status_code == 200
metrics = {metric["name"]: metric["value"] for metric in response.json()["metrics"]}
assert metrics == {"mae": 1.0, "rmse": 1.0, "coverage": 1.0}

View File

@@ -0,0 +1,53 @@
from pathlib import Path
import pytest
from app.automations.models import (
AutomationProposal,
NumericStateTrigger,
ProposalStatus,
ServiceAction,
)
from app.automations.store import AutomationStore
def proposal() -> AutomationProposal:
return AutomationProposal(
alias="Wohnzimmer bei Kälte heizen",
description="Aktiviert den Heizmodus unter 18 Grad.",
trigger=NumericStateTrigger(entity_id="sensor.living_room_temperature", below=18.0),
action=ServiceAction(
service="climate.set_temperature",
entity_id="climate.living_room",
data={"temperature": 21.0},
),
)
def test_store_persists_approval_and_exports_yaml(tmp_path: Path) -> None:
store = AutomationStore(tmp_path)
created = store.create(proposal())
approved = store.decide(created.proposal_id, ProposalStatus.APPROVED, 1)
yaml = AutomationStore(tmp_path).export_yaml(created.proposal_id)
assert approved.status is ProposalStatus.APPROVED
assert approved.revision == 2
assert "platform: numeric_state" in yaml
assert "service: climate.set_temperature" in yaml
assert "temperature: 21.0" in yaml
def test_store_requires_approval_and_current_revision(tmp_path: Path) -> None:
store = AutomationStore(tmp_path)
created = store.create(proposal())
with pytest.raises(ValueError, match="freigegebene"):
store.export_yaml(created.proposal_id)
with pytest.raises(ValueError, match="Revision"):
store.decide(created.proposal_id, ProposalStatus.APPROVED, 2)
def test_store_allows_only_one_decision(tmp_path: Path) -> None:
store = AutomationStore(tmp_path)
created = store.create(proposal())
store.decide(created.proposal_id, ProposalStatus.REJECTED, 1)
with pytest.raises(ValueError, match="bereits entschieden"):
store.decide(created.proposal_id, ProposalStatus.APPROVED, 2)

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

128
tests/ha/test_ha_client.py Normal file
View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime, timezone
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()
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

@@ -0,0 +1,77 @@
from __future__ import annotations
from datetime import datetime, timezone
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 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())
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"
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

@@ -0,0 +1,56 @@
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",
[
_vector("sensor.kitchen", 21.0),
_vector("sensor.bedroom", 18.5),
],
)
assert report.artifact_id == "artifact_v1"
assert report.sample_size == 2
assert {metric.name for metric in report.metrics} == {"mae", "rmse", "coverage"}
assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0
assert next(metric.value for metric in report.metrics if metric.name == "mae") == 0.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_counts_only_supported_sensor_features() -> None:
evaluator = evaluator_factory()
report = evaluator.evaluate(
"artifact_v1",
[
_vector("sensor.kitchen", 21.0),
FeatureVector(sensor_id="sensor.kitchen", values={"humidity": 50.0}),
_vector("sensor.kitchen_extra", 20.0),
],
)
metrics = {metric.name: metric.value for metric in report.metrics}
assert metrics["coverage"] == pytest.approx(1 / 3)

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from app.ml.explanation import explain_feature
from app.ml.training import FeatureModel
def _model(slope: float) -> FeatureModel:
return FeatureModel(
sample_count=4,
mean=20.0,
standard_deviation=1.0,
minimum=18.0,
maximum=22.0,
slope=slope,
intercept=18.5,
)
def test_explain_feature_describes_rising_forecast() -> None:
explanation = explain_feature("temperature", 21.0, 21.5, _model(0.5))
assert explanation.direction == "steigend"
assert explanation.change == 0.5
assert explanation.historical_range == (18.0, 22.0)
assert "4 Messwerte" in explanation.summary
assert "Trend +0.500" in explanation.summary
def test_explain_feature_describes_stable_and_falling_forecasts() -> None:
stable = explain_feature("humidity", 50.0, 50.0, _model(0.0))
falling = explain_feature("temperature", 21.0, 20.5, _model(-0.5))
assert stable.direction == "stabil"
assert falling.direction == "fallend"

View 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

View File

@@ -0,0 +1,68 @@
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_persists_statistical_parameters(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
artifact = TrainingPipeline(store).run("model-v1")
ModelRegistry(tmp_path).register(artifact)
assert ModelRegistry(tmp_path).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,63 @@
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.kitchen", 20.0),
_vector("sensor.bedroom", 18.5),
]
)
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Predictor(pipeline)
def test_predict_returns_statistical_forecast() -> None:
p = predictor()
result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0))
assert result.artifact_id == "artifact_v1"
assert result.sensor_id == "sensor.kitchen"
assert result.predictions == {"temperature": 22.0}
assert 0.0 < result.confidence <= 1.0
assert result.model_type == "statistical_baseline"
explanation = result.explanations["temperature"]
assert explanation.direction == "steigend"
assert explanation.current_value == 21.0
assert explanation.predicted_value == 22.0
assert explanation.sample_count == 2
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"

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", [])

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

@@ -0,0 +1,53 @@
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")
kitchen = artifact.feature_models["sensor.kitchen"]["temperature"]
assert kitchen.sample_count == 2
assert kitchen.mean == 19.5
assert kitchen.slope == 1.0
assert kitchen.forecast() == 21.0
def test_run_without_data_raises_value_error() -> None:
pipeline = TrainingPipeline(FeatureStore())
with pytest.raises(ValueError):
pipeline.run("artifact_v1")
def test_export_returns_registered_artifact() -> None:
pipeline = store_with_data()
pipeline.run("artifact_v1")
exported = pipeline.export("artifact_v1")
assert exported == pipeline.export("artifact_v1")
def test_export_missing_artifact_raises_key_error() -> None:
pipeline = store_with_data()
with pytest.raises(KeyError):
pipeline.export("artifact_v1")

View File

@@ -0,0 +1,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)
samples = [
_vector("sensor.kitchen", 21.0),
_vector("sensor.bedroom", 18.5),
]
report = evaluator.evaluate(artifact.artifact_id, samples)
assert isinstance(report, EvalReport)
assert report.sample_size == len(samples)
assert any(metric.name == "coverage" for metric in report.metrics)
def test_metric_helpers_are_serializable() -> None:
metric = Metric(name="mae", value=0.85, threshold=1.0)
assert metric.name == "mae"
assert metric.value == 0.85
assert metric.threshold == 1.0

View File

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

20
tests/test_config.py Normal file
View File

@@ -0,0 +1,20 @@
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")
monkeypatch.setenv("SILLYHOME_AUTOMATION_STORE", "/tmp/automations")
settings = load_settings()
assert settings.ha_url == "http://ha.local:8123"
assert settings.ha_token == "secret"
assert settings.model_store == "/tmp/models"
assert settings.automation_store == "/tmp/automations"
assert settings.ha_configured

12
tests/test_dashboard.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi.testclient import TestClient
from app.main import app
def test_dashboard_is_served_at_root() -> None:
with TestClient(app) as client:
response = client.get("/")
assert response.status_code == 200
assert "SillyHome Next" in response.text
assert "Automation-Entwurf" in response.text

View File

@@ -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:
with TestClient(app) as client:
response = client.get("/health") response = client.get("/health")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}