Compare commits

...

21 Commits

Author SHA1 Message Date
d6631fe752 OPS-001: persist HA panel and rollback instructions
Some checks failed
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 21:18:24 +02:00
9f4fc2f4ce Merge pull request 'MVP: Dashboard and Home Assistant add-on' (#29) from feature/mvp-testable into main
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-13 21:12:31 +02:00
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
9ddb86cc1a Merge pull request 'AUTO-001: Safe Automation Approval Workflow' (#28) from feature/automation-approval 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:21:22 +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
59 changed files with 2805 additions and 195 deletions

View File

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

View File

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

View File

@@ -1,5 +1,22 @@
# Changelog
## 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
- Architektur, ADRs und Roadmap
- Einheitliche produktive FastAPI-App für HA- und ML-Routen
- Funktionierende ENV-Konfiguration und sauberer HA-503-Zustand
- Persistente, validierte und gegen Path Traversal gehärtete Model Registry
- Reproduzierbares Packaging, CI-Gates und gehärteter non-root Container
- Definierte API-Fehler und korrigierte Evaluationsmetriken
- Scheduler-tauglicher Retraining-Service mit API und atomischem Registry-Update

View File

@@ -1,15 +1,28 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
SILLYHOME_MODEL_STORE=/app/data/models
ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations
WORKDIR /app
COPY pyproject.toml ./
RUN python -m pip install --upgrade pip && \
pip install --no-cache-dir -e ".[dev]"
RUN addgroup --system sillyhome && adduser --system --ingroup sillyhome sillyhome
COPY . .
COPY pyproject.toml README.md ./
COPY app ./app
COPY backend ./backend
RUN python -m pip install --upgrade pip && \
python -m pip install . && \
mkdir -p /app/data/models /app/data/automations && \
chown -R sillyhome:sillyhome /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
USER sillyhome
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,6 +1,14 @@
# 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
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
@@ -13,14 +21,12 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert
## APPENDIX
### Quickstart
## Quickstart
1. Python-Venv anlegen und Abhängigkeiten installieren:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .
pip install -e ".[dev]"
```
2. Konfiguration aus `.env.example` übernehmen und anpassen:
@@ -34,20 +40,58 @@ 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/ml/health` - ML-Serving Health (ab ML-005)
- `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
- `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 aus Home Assistant (nur lesen)
- `SILLYHOME_HA_TOKEN` Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
- `SILLYHOME_MODEL_STORE` Verzeichnis für persistierte Modell-Metadaten
- `SILLYHOME_AUTOMATION_STORE` Verzeichnis für Automation-Entwürfe
Hinweis: Nutze ausschließlich Long-Lived Access Tokens mit Leserechten. Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht in Versionskontrollsysteme.
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
Versionskontrollsystem.
### 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.
Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups**
eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte
Backup öffnen, **SillyHome Next** auswählen und wiederherstellen. Der erste produktive
Teststand `v0.3.0` wurde als HA-Backup `7df0fca0` gesichert.
### Tests
```bash
pytest -q
pytest
ruff check .
mypy app tests
```
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"]

23
addon/config.yaml Normal file
View File

@@ -0,0 +1,23 @@
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_title: SillyHome Next
panel_icon: mdi:home-analytics
panel_admin: true
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='*'

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

View File

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

View File

@@ -8,6 +8,8 @@ from dataclasses import dataclass
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:
@@ -18,4 +20,6 @@ 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"),
)

View File

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

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

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

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

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

View File

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

View File

@@ -1,45 +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.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.rules.recommender import Recommender
from app.rules.heating import HeatingRule
from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings
ha_url = getattr(settings, "ha_url", None)
ha_token = getattr(settings, "ha_token", None)
client = HaClient(
settings=HaClientSettings(
url=ha_url or "",
token=ha_token or "",
client: HaClient | None = None
app.state.registry = ModelRegistry(settings.model_store)
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)
app.state.recommender = Recommender(rules=[HeatingRule()])
yield
class Settings:
ha_url: str = "http://localhost:8123"
ha_token: str = ""
app.state.ha_reader = HaReader(client=client)
try:
yield
finally:
if client is not None:
client.close()
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.1.0",
version="0.3.0",
lifespan=lifespan,
)
app.state.settings = Settings()
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")
@@ -48,5 +63,5 @@ def health() -> dict[str, str]:
@app.get("/")
def root() -> dict[str, str]:
return {"service": "sillyhome-next", "docs": "/docs"}
def root() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")

View File

@@ -1,5 +1,20 @@
"""Machine-Learning-Grundbausteine für SillyHome Next."""
__all__ = ["FeatureStore", "FeatureVector"]
__all__ = [
"FeatureStore",
"FeatureVector",
"FeatureModel",
"FeatureExplanation",
"PredictionResult",
"Predictor",
"RetrainingResult",
"RetrainingService",
"TrainedArtifact",
"TrainingPipeline",
"retrain_model",
]
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainedArtifact, TrainingPipeline
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

View File

@@ -1,11 +1,14 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Sequence
import math
from collections.abc import Sequence
from dataclasses import dataclass
from app.ml.feature_store import FeatureVector
from app.ml.training import TrainingPipeline, TrainedArtifact
from app.ml.predictor import Predictor
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
logger = logging.getLogger(__name__)
@@ -25,33 +28,62 @@ class EvalReport:
class Evaluator:
def __init__(self, pipeline: TrainingPipeline) -> None:
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, predictions: Sequence[str]) -> EvalReport:
artifacts = list(self._pipeline._artifacts)
if not artifacts:
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.")
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
supported_sensors = self._pipeline.export(artifact_id).supported_sensors
unknown_hits = sum(1 for prediction in predictions if ":" not in prediction)
supported_references = sum(1 for sensor in supported_sensors for prediction in predictions if sensor in prediction)
sample_size = len(predictions)
coverage = supported_references / sample_size if sample_size else 0.0
unknown_rate = unknown_hits / sample_size if sample_size else 0.0
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)
coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8)
unknown_metric = Metric(name="unknown_rate", value=unknown_rate, threshold=0.1)
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=[coverage_metric, unknown_metric],
metrics=[
Metric(name="mae", value=mae),
Metric(name="rmse", value=rmse),
Metric(name="coverage", value=coverage, threshold=0.8),
],
)
logger.info(
"Evaluation %s -> coverage=%.2f, unknown_rate=%.2f",
"Evaluation %s -> mae=%.4f, rmse=%.4f, coverage=%.2f",
artifact_id,
mae,
rmse,
coverage,
unknown_rate,
)
return report
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"

View File

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

View File

@@ -1,8 +1,11 @@
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
@@ -10,6 +13,16 @@ 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,
@@ -24,15 +37,54 @@ class Predictor:
self._pipeline = pipeline
self._registry = registry
def predict(self, artifact_id: str, entity: FeatureVector) -> str:
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."
)
return f"{artifact_id}:{entity.sensor_id}:{entity.values}"
sensor_models = artifact.feature_models.get(entity.sensor_id, {})
if not sensor_models:
raise ValueError(f"Modell '{artifact_id}' enthält keine statistischen Parameter.")
def predict_batch(self, artifact_id: str, entities: Sequence[FeatureVector]) -> list[str]:
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
@@ -47,4 +99,4 @@ class Predictor:
return self._registry.load_artifact(artifact_id)
if self._pipeline is not None:
return self._pipeline.export(artifact_id)
raise RuntimeError("Predictor nicht initialisiert.")
raise RuntimeError("Predictor nicht initialisiert.")

View File

@@ -1,37 +1,162 @@
from __future__ import annotations
import json
import logging
import math
import os
from pathlib import Path
from typing import Iterable
import re
from threading import RLock
from collections.abc import Iterable
from app.ml.training import TrainedArtifact
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)
self._root = Path(root).resolve()
self._root.mkdir(parents=True, exist_ok=True)
self._artifacts: dict[str, TrainedArtifact] = {}
self._lock = RLock()
self._load_existing()
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
self._artifacts[artifact.artifact_id] = artifact
self._persist(artifact)
return artifact
registered, _ = self.register_with_status(artifact)
return registered
def register_with_status(self, artifact: TrainedArtifact) -> tuple[TrainedArtifact, bool]:
self._validate_artifact_id(artifact.artifact_id)
with self._lock:
replaced = artifact.artifact_id in self._artifacts
self._persist(artifact)
self._artifacts[artifact.artifact_id] = artifact
return artifact, replaced
def load_artifact(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
return self._artifacts[artifact_id]
self._validate_artifact_id(artifact_id)
with self._lock:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
return self._artifacts[artifact_id]
def list_models(self) -> Iterable[TrainedArtifact]:
return list(self._artifacts.values())
with self._lock:
return [self._artifacts[key] for key in sorted(self._artifacts)]
def _load_existing(self) -> None:
for source in sorted(self._root.glob("*.json")):
try:
raw = json.loads(source.read_text(encoding="utf-8"))
artifact_id = raw["artifact_id"]
supported_sensors = raw["supported_sensors"]
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"
target.write_text(
f"{artifact.artifact_id}\t{','.join(artifact.supported_sensors)}\n",
temporary = target.with_suffix(".json.tmp")
payload = {
"artifact_id": artifact.artifact_id,
"supported_sensors": list(artifact.supported_sensors),
"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",
)
logger.info("Modell gespeichert: %s", target)
os.replace(temporary, target)
logger.info("Modell gespeichert: %s", target)
@staticmethod
def _validate_artifact_id(artifact_id: str) -> None:
if not _ARTIFACT_ID_PATTERN.fullmatch(artifact_id) or ".." in artifact_id:
raise ValueError(
"artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich "
"und Bindestrich enthalten."
)
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)

View File

@@ -1,18 +1,44 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Sequence
import math
from collections import defaultdict
from dataclasses import dataclass, field
from app.ml.feature_store import FeatureVector, FeatureStore
from app.ml.feature_store import FeatureStore
logger = logging.getLogger(__name__)
@dataclass
@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:
@@ -25,8 +51,33 @@ class TrainingPipeline:
if not vectors:
raise ValueError("FeatureStore enthält keine Trainingsdaten.")
sensors = tuple({vector.sensor_id for vector in vectors})
artifact = TrainedArtifact(artifact_id=artifact_id, supported_sensors=sensors)
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
@@ -34,4 +85,33 @@ class TrainingPipeline:
def export(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
return self._artifacts[artifact_id]
return self._artifacts[artifact_id]
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,
)

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

View File

@@ -1,4 +1,8 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.datastructures import State
from backend.routes.ml import init_ml_routes
from app.ml.registry.model_registry import ModelRegistry
@@ -6,18 +10,20 @@ from app.ml.training import TrainingPipeline
from app.ml.feature_store import FeatureStore, FeatureVector
@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
application.state.registry = ModelRegistry(application.state.model_store)
_seed_default_model(application.state)
yield
def create_app() -> FastAPI:
application = FastAPI(title="SillyHome Next ML")
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
init_ml_routes(application)
_seed_default_model(application.state if hasattr(application, "state") else application)
return application
def _app_state(): # noqa: ANN001
return app.state
def _seed_default_model(state) -> None: # noqa: ANN001
def _seed_default_model(state: State) -> None:
registry = getattr(state, "registry", None)
if registry is None:
registry = ModelRegistry(".model_store")
@@ -34,4 +40,4 @@ def _seed_default_model(state) -> None: # noqa: ANN001
registry.register(artifact)
app = create_app()
app = create_app()

View File

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

View File

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

View File

@@ -2,7 +2,25 @@ services:
api:
build: .
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
env_file:
- .env
restart: unless-stopped
- path: .env
required: false
environment:
SILLYHOME_MODEL_STORE: /app/data/models
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.

View File

@@ -1,16 +1,22 @@
# ML-Serving-API
Diese Dokumentation beschreibt die REST-Endpoints für ML-Vorhersagen in SillyHome Next.
Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen
Modell-Artefakt- und Vorhersage-Schnittstelle.
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 backend.app:app --reload`, danach steht die API unter `/ml` bereit.
Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und
ML-Routen in derselben Anwendung bereit.
## Endpoints
@@ -55,10 +61,63 @@ Einzelne Vorhersage für einen Sensor.
{
"model_id": "default",
"sensor_id": "sensor.kitchen",
"prediction": "default:sensor.kitchen:{'temperature': 21.0}"
"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.
@@ -88,12 +147,16 @@ Batch-Vorhersage für mehrere Sensorwerte.
{
"model_id": "default",
"sensor_id": "sensor.kitchen",
"prediction": "default:sensor.kitchen:{'temperature': 21.0}"
"predictions": {"temperature": 21.4},
"confidence": 0.78,
"model_type": "statistical_baseline"
},
{
"model_id": "default",
"sensor_id": "sensor.bedroom",
"prediction": "default:sensor.bedroom:{'temperature': 18.5}"
"predictions": {"temperature": 18.3},
"confidence": 0.74,
"model_type": "statistical_baseline"
}
]
}
@@ -101,16 +164,22 @@ Batch-Vorhersage für mehrere Sensorwerte.
## Fehlerfälle
- `400 Bad Request`: Fehlende oder ungültige Felder.
- `404 Not Found`: Modell oder Sensor nicht registriert.
- `500 Internal Server Error`: Registry nicht initialisiert oder unerwarteter Fehler.
- `404 Not Found`: Modell nicht registriert.
- `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig.
- `503 Service Unavailable`: Registry ist nicht initialisiert.
## Betrieb
Beim Start wird automatisch ein Default-Artefakt erstellt, falls noch kein Modell registriert ist. Neue Modelle müssen zusätzlich über `ModelRegistry.register(...)` eingetragen werden.
Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte
werden über `/ml/retrain`, `RetrainingService` oder direkt über
`ModelRegistry.register(...)` registriert. Die Registry speichert validiertes
JSON atomisch und lädt es beim Neustart. Die API sollte nur in einem
vertrauenswürdigen Netz oder hinter einem authentifizierenden Reverse Proxy
erreichbar sein.
## Verweise
- `app/ml/predictor.py`
- `app/ml/retraining.py`
- `app/ml/registry/model_registry.py`
- `backend/routes/ml.py`
- `backend/routes/ml.py`

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,6 +1,10 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "0.1.0"
version = "0.3.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11"
dependencies = [
@@ -24,6 +28,10 @@ addopts = "-q"
[tool.mypy]
strict = true
files = ["app", "backend", "tests"]
[tool.setuptools.packages.find]
include = ["app*", "backend*"]
[tool.ruff]
line-length = 100

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

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

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

View File

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

View File

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

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

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

View File

@@ -21,13 +21,36 @@ def evaluator_factory() -> Evaluator:
def test_evaluate_returns_report_with_metrics() -> None:
evaluator = evaluator_factory()
report = evaluator.evaluate("artifact_v1", ["artifact_v1:sensor.kitchen:{'temperature': 21.0}", "artifact_v1:sensor.bedroom:{'temperature': 18.5}"])
report = evaluator.evaluate(
"artifact_v1",
[
_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} == {"coverage", "unknown_rate"}
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", [])
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

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

View File

@@ -0,0 +1,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

@@ -13,16 +13,31 @@ def _vector(sensor_id: str, temperature: float, label: str | None = None) -> Fea
def predictor() -> Predictor:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
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_expected_format() -> None:
def test_predict_returns_statistical_forecast() -> None:
p = predictor()
result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0))
assert result == "artifact_v1:sensor.kitchen:{'temperature': 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:
@@ -45,4 +60,4 @@ def test_default_artifact_returns_last_registered() -> None:
pipeline = TrainingPipeline(store)
pipeline.run("first")
pipeline.run("second")
assert Predictor.default_artifact(pipeline).artifact_id == "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", [])

View File

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

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from app.ml.evaluation import Evaluator, EvalReport, Metric
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def test_end_to_end_training_then_evaluation() -> None:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
artifact = pipeline.run("artifact_v1")
evaluator = Evaluator(pipeline)
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

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