Compare commits

..

38 Commits

Author SHA1 Message Date
685feb57b3 Merge pull request 'ACT-001: actuator-first sensor assignment and lifecycle' (#31) from feature/actuator-sensor-lifecycle into main 2026-06-13 22:47:14 +02:00
6305f52cd2 ACT-001: actuator-first sensor lifecycle 2026-06-13 22:45:07 +02:00
7ed667f954 Merge pull request 'OPS-001: Persist HA panel and rollback instructions' (#30) from feature/ha-ops into main 2026-06-13 21:18:25 +02:00
d6631fe752 OPS-001: persist HA panel and rollback instructions
Some checks failed
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 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
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 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
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 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
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 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
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 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
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 2026-06-13 19:12:06 +02:00
dd496f9cc3 release: finalize v0.1.0 changelog 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 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
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 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
2026-06-11 21:14:07 +02:00
471146761e harden model registry persistence and evaluation 2026-06-11 21:08:14 +02:00
3bed5e790a unify production app configuration and ML routes 2026-06-11 21:08:14 +02:00
4b3dc3b7af Merge branch 'feature/ml-006-training-workflow' 2026-06-11 20:31:12 +02:00
63d10a6c4f ML-006: Training- und Evaluations-Workflow vorbereiten 2026-06-11 17:08:16 +02:00
0bc928799a Merge branch 'feature/ml-serving' 2026-06-11 13:30:15 +02:00
d6c48b495a ML-005: FastAPI-App-Start und Batch-Sensor-Support finalisieren 2026-06-11 13:28:40 +02:00
57275d5172 ML-005: Doku zu ML-Serving-API ergänzen 2026-06-11 13:27:01 +02:00
fad517e56a ML-005 vorbereiten: Registry, API-Routen und kompatibler Predictor 2026-06-11 12:04:52 +02:00
79e883f77d ML-004: Training-Feedback und Evaluation-Metriken 2026-06-11 00:39:17 +02:00
3cf9af3515 ML-003: Predictor mit Sensor-Validierung und Batch-Interface 2026-06-11 00:38:45 +02:00
24be7a4f11 ML-002: Trainingspipeline mit Tainted-Data-Check 2026-06-11 00:21:21 +02:00
627ee03230 ML-001: Feature Store und erste ML-Tests hinzufügen 2026-06-11 00:21:02 +02:00
2fb086b1a1 INFRA-001: Docker-Compose-Basis für SillyHome Next anlegen 2026-06-11 00:13:21 +02:00
e2bc0644ae main: HeatingRule auf heizungsrelevante Sensoren begrenzen 2026-06-11 00:12:19 +02:00
57ffd1dda6 DOC-QUALITY-001: Quickstart, ENV-Doku und Tests beschreiben 2026-06-11 00:12:12 +02:00
445e4bcdf4 Merge otto/ha-client-errors into main 2026-06-10 23:29:30 +02:00
d550030a1a main: HA-Integration mit Exception-Handling und Testabdeckung 2026-06-10 22:47:08 +02:00
29ec53cc5e add safe home assistant error handling 2026-06-10 21:24:34 +02:00
75 changed files with 5581 additions and 50 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.env
.env.*
!.env.example
.venv
.venv/*
__pycache__
.mypy_cache
.pytest_cache
.ruff_cache
node_modules
.idea
.vscode
.git
.gitignore
.dockerignore
docker-compose*.yml

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
SILLYHOME_MODEL_STORE=.model_store
SILLYHOME_AUTOMATION_STORE=.automation_store
SILLYHOME_ACTUATOR_STORE=.actuator_store
SILLYHOME_HISTORY_DAYS=14
SILLYHOME_MIN_TRAINING_POINTS=24
SILLYHOME_RETRAIN_STALE_HOURS=24
SILLYHOME_RECONCILE_INTERVAL_SECONDS=900

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,25 @@
# Changelog
## Unreleased
## 0.4.0 - 2026-06-13
- Aktuator-zentrierte Einrichtung: Nutzer wählen nur noch Aktuatoren, Sensoren werden deterministisch gefunden und bewertet
- Persistente automatische und manuelle Sensorzuordnungen mit Evidenz, Confidence, Review-Gating und Neustart-Sicherheit
- Autonomer Modell-Lebenszyklus auf echter HA-Historie: Training, Retraining bei Staleness oder Datenänderung, Archivierung von Waisen
- Neues Dashboard und API für Aktuatorauswahl, Reconciliation, Overrides, Modellstatus und Audit-Trail
- Neue Container-/Add-on-Defaults für Aktuator-Store und periodische Reconciliation ohne zusätzliche Gerätesteuerung
## 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

33
Dockerfile Normal file
View File

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

View File

@@ -1,6 +1,13 @@
# 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 ist aktor-zentriert: Nutzer konfigurieren nur
noch Home-Assistant-Aktuatoren. SillyHome Next findet dazu passende numerische
Sensoren und Kontext-Entities, zeigt Evidenz und Review-Bedarf an und hält
passende Modelle lokal und autonom aktuell.
## 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.
@@ -12,3 +19,93 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Automationen vorschlagen und direkt generieren
- Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert
## Quickstart
1. Python-Venv anlegen und Abhängigkeiten installieren:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
2. Konfiguration aus `.env.example` übernehmen und anpassen:
```bash
cp .env.example .env
```
3. API starten:
```bash
uvicorn app.main:app --reload
```
4. Erreichbar unter:
- `http://127.0.0.1:8000/` - lokales Dashboard
- `http://127.0.0.1:8000/health` - Health-Check
- `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation
- `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities
- `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow
- `POST http://127.0.0.1:8000/v1/actuators` - Aktuator registrieren, Sensorzuordnung prüfen und Modell-Lebenszyklus starten
- `POST http://127.0.0.1:8000/v1/actuators/reconciliation/run` - globale Reconciliation manuell anstoßen
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
- `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen
- `POST http://127.0.0.1:8000/v1/automations/proposals` - sicheren Entwurf anlegen
Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.
### Docker Compose
```bash
cp .env.example .env
docker compose up --build -d
curl --fail http://127.0.0.1:8000/health
```
Compose veröffentlicht die API standardmäßig nur auf `127.0.0.1`. Für Zugriff aus
dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden.
### ENV-Konfiguration (`.env.example`)
- `SILLYHOME_HA_URL` Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`)
- `SILLYHOME_HA_TOKEN` Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
- `SILLYHOME_MODEL_STORE` Verzeichnis für persistierte Modell-Metadaten
- `SILLYHOME_AUTOMATION_STORE` Verzeichnis für Automation-Entwürfe
- `SILLYHOME_ACTUATOR_STORE` Verzeichnis für persistente Aktuator-Zuordnungen, Overrides und Reconciliation-Status
- `SILLYHOME_HISTORY_DAYS` Trainingsfenster für HA-History (1 bis 31 Tage)
- `SILLYHOME_MIN_TRAINING_POINTS` Mindestanzahl nutzbarer numerischer Messpunkte vor einem Modelltraining
- `SILLYHOME_RETRAIN_STALE_HOURS` Staleness-Grenze für automatisches Retraining
- `SILLYHOME_RECONCILE_INTERVAL_SECONDS` Intervall für sichere periodische Reconciliation
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.
### Normaler Workflow
1. Im Dashboard oder per API einen Aktuator auswählen, zum Beispiel `light.abstellkammer`.
2. SillyHome Next bewertet passende numerische Sensoren und binäre Kontext-Entities anhand von Bereich, Gerät, Namen, Domain und `device_class`.
3. Starke und eindeutige Zuordnungen werden automatisch genutzt; schwache oder knappe Kandidaten bleiben mit Review-Hinweis sichtbar.
4. Manuelle Overrides haben Vorrang, bleiben persistent und überstehen Neustarts.
5. Sobald genügend numerische HA-Historie vorhanden ist, trainiert das System automatisch ein lokales Modell pro Aktuator-Zuordnung und retrainiert es bei relevanten Datenänderungen oder Staleness.
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
ruff check .
mypy
```

19
addon/Dockerfile Normal file
View File

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

31
addon/config.yaml Normal file
View File

@@ -0,0 +1,31 @@
name: SillyHome Next
version: "0.4.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:
history_days: 14
min_training_points: 24
retrain_stale_hours: 24
reconcile_interval_seconds: 900
schema:
history_days: "int(1,31)"
min_training_points: "int(2,10000)"
retrain_stale_hours: "int(1,720)"
reconcile_interval_seconds: "int(60,86400)"
map:
- type: addon_config
read_only: false

19
addon/run.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/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
export SILLYHOME_ACTUATOR_STORE=/data/actuators
if [ -f /data/options.json ]; then
export SILLYHOME_HISTORY_DAYS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("history_days", 14))')"
export SILLYHOME_MIN_TRAINING_POINTS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_training_points", 24))')"
export SILLYHOME_RETRAIN_STALE_HOURS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("retrain_stale_hours", 24))')"
export SILLYHOME_RECONCILE_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("reconcile_interval_seconds", 900))')"
fi
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'

27
app/actuators/__init__.py Normal file
View File

@@ -0,0 +1,27 @@
from app.actuators.lifecycle import (
ActuatorReconciliationService,
)
from app.actuators.models import (
ActuatorRecord,
AssignmentCandidate,
AssignmentSelection,
LifecycleAuditEntry,
LifecycleStatus,
ManualOverride,
ReconciliationState,
model_id_for_actuator,
)
from app.actuators.store import ActuatorStore
__all__ = [
"ActuatorReconciliationService",
"ActuatorRecord",
"ActuatorStore",
"AssignmentCandidate",
"AssignmentSelection",
"LifecycleAuditEntry",
"LifecycleStatus",
"ManualOverride",
"ReconciliationState",
"model_id_for_actuator",
]

607
app/actuators/lifecycle.py Normal file
View File

@@ -0,0 +1,607 @@
from __future__ import annotations
import hashlib
import logging
import re
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from app.actuators.models import (
ActuatorRecord,
AssignmentCandidate,
AssignmentSelection,
AssignmentSource,
LifecycleAuditEntry,
LifecycleStatus,
ManualOverride,
ModelLifecycleState,
ReconciliationState,
model_id_for_actuator,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
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.ml.feature_store import FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.retraining import retrain_model
from app.ml.training import TrainedArtifact
logger = logging.getLogger(__name__)
_TOKEN_PATTERN = re.compile(r"[a-z0-9]+", re.IGNORECASE)
_STOPWORDS = frozenset(
{
"actuator",
"battery",
"bin",
"binary",
"brightness",
"current",
"door",
"energy",
"entity",
"humidity",
"illuminance",
"light",
"power",
"sensor",
"state",
"switch",
"temperature",
"value",
}
)
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_MAX_CONTEXT_SELECTIONS = 3
_AUDIT_LIMIT = 20
class ActuatorReconciliationService:
def __init__(
self,
*,
ha_reader: HaReader,
store: ActuatorStore,
registry: ModelRegistry,
settings: Settings,
) -> None:
self._ha_reader = ha_reader
self._store = store
self._registry = registry
self._settings = settings
def list_configured(self) -> list[ActuatorRecord]:
return self._store.list()
def configure_actuator(self, actuator_entity_id: str, *, enabled: bool = True) -> ActuatorRecord:
self._store.configure(actuator_entity_id, enabled=enabled)
return self.reconcile_actuator(actuator_entity_id, trigger="configuration")
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id)
def set_override(
self,
actuator_entity_id: str,
override: ManualOverride | None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
updated = record.model_copy(
update={
"manual_override": override,
"updated_at": datetime.now(timezone.utc),
}
)
self._store.upsert(updated)
return self.reconcile_actuator(actuator_entity_id, trigger="override")
def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id)
self._store.delete(actuator_entity_id)
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy(
update={
"running": True,
"last_started_at": datetime.now(timezone.utc),
"last_trigger": trigger,
}
)
self._store.save_reconciliation_state(state)
records = self._store.list()
for record in records:
self.reconcile_actuator(record.actuator_entity_id, trigger=trigger)
self._archive_orphan_models({model_id_for_actuator(record.actuator_entity_id) for record in records})
refreshed = self._store.list()
summary = ReconciliationState(
last_started_at=state.last_started_at,
last_completed_at=datetime.now(timezone.utc),
last_trigger=trigger,
running=False,
configured_actuators=len(refreshed),
review_required=sum(1 for record in refreshed if record.assignment.review_required),
trained_models=sum(
1 for record in refreshed if record.lifecycle.status is LifecycleStatus.TRAINED
),
last_summary=(
f"{len(refreshed)} Aktuatoren geprüft, "
f"{sum(1 for record in refreshed if record.assignment.review_required)} "
"mit Prüfbedarf."
),
)
self._store.save_reconciliation_state(summary)
return summary
def reconcile_actuator(self, actuator_entity_id: str, trigger: str = "manual") -> ActuatorRecord:
now = datetime.now(timezone.utc)
record = self._store.get(actuator_entity_id)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
actuator = entities.get(actuator_entity_id)
descriptor = discovered.get(actuator_entity_id)
lifecycle = record.lifecycle.model_copy(update={"last_reconciled_at": now})
if not record.enabled:
lifecycle = self._archive_state(
lifecycle,
"Aktuator ist deaktiviert; Modell bleibt archiviert.",
now=now,
)
updated = record.model_copy(
update={
"assignment": AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=[],
source=AssignmentSource.NONE,
confidence=0.0,
review_required=False,
reason="Aktuator ist deaktiviert.",
),
"numeric_candidates": [],
"context_candidates": [],
"lifecycle": lifecycle,
"updated_at": now,
}
)
return self._store.upsert(updated)
if actuator is None or descriptor is None or descriptor.role is not EntityRole.ACTUATOR:
lifecycle = self._archive_state(
lifecycle,
"Aktuator ist in Home Assistant nicht mehr als Aktor vorhanden.",
now=now,
status=LifecycleStatus.ORPHANED,
)
updated = record.model_copy(
update={
"assignment": AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=[],
source=AssignmentSource.NONE,
confidence=0.0,
review_required=True,
reason="Aktuator fehlt oder ist kein unterstützter Aktor mehr.",
),
"numeric_candidates": [],
"context_candidates": [],
"lifecycle": lifecycle,
"updated_at": now,
}
)
return self._store.upsert(updated)
numeric_candidates = self._rank_candidates(
actuator=actuator,
candidates=_filter_candidates(entities, discovered, {EntityRole.MEASUREMENT}),
context=False,
)
context_candidates = self._rank_candidates(
actuator=actuator,
candidates=_filter_candidates(
entities,
discovered,
{EntityRole.BINARY_CONTEXT, EntityRole.CONTEXT},
),
context=True,
)
assignment = self._select_assignment(
actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
override=record.manual_override,
)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
assignment=assignment,
lifecycle=lifecycle,
now=now,
)
updated = record.model_copy(
update={
"assignment": assignment,
"numeric_candidates": numeric_candidates,
"context_candidates": context_candidates,
"lifecycle": lifecycle,
"updated_at": now,
}
)
self._store.upsert(updated)
logger.info(
"Actuator %s reconciled via %s -> %s",
actuator_entity_id,
trigger,
lifecycle.status,
)
return updated
def _select_assignment(
self,
*,
actuator: HaEntitySummary,
numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate],
override: ManualOverride | None,
) -> AssignmentSelection:
if override is not None:
selected_numeric = override.numeric_entity_id
selected_contexts = list(dict.fromkeys(override.context_entity_ids))
return AssignmentSelection(
selected_numeric_entity_id=selected_numeric,
selected_context_entity_ids=selected_contexts,
source=AssignmentSource.MANUAL,
confidence=1.0 if selected_numeric else 0.6,
review_required=False,
reason=(
"Manuelle Zuordnung überschreibt die automatische Heuristik dauerhaft."
),
)
top_numeric = numeric_candidates[0] if numeric_candidates else None
top_contexts = [
candidate.entity_id
for candidate in context_candidates
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS]
if top_numeric is None:
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.NONE,
confidence=0.0,
review_required=True,
reason=f"Kein numerischer Sensor konnte für {display_name(actuator)} bestimmt werden.",
)
return AssignmentSelection(
selected_numeric_entity_id=top_numeric.entity_id,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=top_numeric.confidence,
review_required=not top_numeric.auto_accepted,
reason=(
"Automatisch akzeptiert."
if top_numeric.auto_accepted
else "Top-Kandidat gefunden, aber Zuordnung ist noch nicht eindeutig genug."
),
)
def _reconcile_lifecycle(
self,
*,
actuator: HaEntitySummary,
assignment: AssignmentSelection,
lifecycle: ModelLifecycleState,
now: datetime,
) -> ModelLifecycleState:
model_id = lifecycle.model_id
if assignment.selected_numeric_entity_id is None:
return self._archive_state(
lifecycle,
"Ohne numerische Sensorzuordnung wird kein Modell aktiv gehalten.",
now=now,
)
if assignment.review_required and assignment.source is not AssignmentSource.MANUAL:
return self._archive_state(
lifecycle,
"Zuordnung ist nicht eindeutig; Modell wartet auf Review.",
now=now,
status=LifecycleStatus.REVIEW_REQUIRED,
)
sensor_id = assignment.selected_numeric_entity_id
series = self._read_history(sensor_id, now)
points = series.points if series is not None else []
if len(points) < self._settings.min_training_points:
return self._with_audit(
lifecycle.model_copy(
update={
"status": LifecycleStatus.PENDING_HISTORY,
"last_reconciled_at": now,
"reason": (
f"{len(points)} von mindestens {self._settings.min_training_points} "
f"Messpunkten für {sensor_id} vorhanden."
),
"next_action": "Mehr Historie sammeln und Reconciliation erneut ausführen.",
"last_history_point_count": len(points),
}
),
action="history_wait",
reason=(
f"Training für {display_name(actuator)} verschoben: zu wenig numerische Historie."
),
now=now,
)
signature = _history_signature(sensor_id, points)
artifact = self._registry.get_optional(model_id)
needs_retrain = artifact is None
retrain_reason = "Noch kein Modell vorhanden."
if artifact is not None:
valid, reason = _artifact_valid_for_sensor(artifact, sensor_id)
if not valid:
self._registry.archive(model_id)
needs_retrain = True
retrain_reason = reason
elif lifecycle.last_history_signature != signature:
needs_retrain = True
retrain_reason = "Historie hat sich seit dem letzten Training materiell geändert."
elif lifecycle.last_trained_at is None or (
now - lifecycle.last_trained_at
) >= timedelta(hours=self._settings.retrain_stale_hours):
needs_retrain = True
retrain_reason = "Modell gilt als veraltet und wird präventiv neu trainiert."
if needs_retrain:
vectors = [FeatureVector(sensor_id=sensor_id, values={"value": point.value}) for point in points]
result = retrain_model(self._registry, model_id, vectors)
return self._with_audit(
lifecycle.model_copy(
update={
"status": LifecycleStatus.TRAINED,
"last_reconciled_at": now,
"last_trained_at": now,
"last_history_signature": signature,
"last_history_point_count": len(points),
"reason": retrain_reason,
"next_action": "Automatisch überwachen und bei neuen Daten neu trainieren.",
}
),
action="retrained" if result.replaced else "trained",
reason=f"{retrain_reason} Modell {model_id} aktualisiert.",
now=now,
)
return self._with_audit(
lifecycle.model_copy(
update={
"status": LifecycleStatus.TRAINED,
"last_reconciled_at": now,
"last_history_signature": signature,
"last_history_point_count": len(points),
"reason": "Modell ist aktuell und passt zur bestätigten Sensorzuordnung.",
"next_action": "Auf neue Historie oder Staleness warten.",
}
),
action="kept",
reason=f"Modell {model_id} blieb unverändert.",
now=now,
)
def _read_history(self, sensor_id: str, now: datetime) -> EntityHistorySeries | None:
start = now - timedelta(days=self._settings.history_days)
history = list(self._ha_reader.read_history([sensor_id], start, now))
for series in history:
if series.entity_id == sensor_id:
return series
return None
def _archive_orphan_models(self, configured_model_ids: set[str]) -> None:
for artifact in self._registry.list_models():
if not artifact.artifact_id.startswith("actuator."):
continue
if artifact.artifact_id not in configured_model_ids:
self._registry.archive(artifact.artifact_id)
def _archive_state(
self,
lifecycle: ModelLifecycleState,
reason: str,
*,
now: datetime,
status: LifecycleStatus = LifecycleStatus.ARCHIVED,
) -> ModelLifecycleState:
self._registry.archive(lifecycle.model_id)
return self._with_audit(
lifecycle.model_copy(
update={
"status": status,
"last_reconciled_at": now,
"reason": reason,
"next_action": "Review oder neue Zuordnung erforderlich.",
}
),
action="archived",
reason=reason,
now=now,
)
def _rank_candidates(
self,
*,
actuator: HaEntitySummary,
candidates: Iterable[tuple[HaEntitySummary, DiscoveredEntity]],
context: bool,
) -> list[AssignmentCandidate]:
scored: list[AssignmentCandidate] = []
all_scores: list[float] = []
for entity, discovered in candidates:
score, evidence = _score_candidate(actuator, entity, discovered.role, context=context)
if score <= 0:
continue
all_scores.append(score)
scored.append(
AssignmentCandidate(
entity_id=entity.entity_id,
domain=entity.domain,
role=discovered.role,
device_class=entity.device_class,
state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
score=score,
confidence=0.0,
evidence=evidence,
)
)
if not scored:
return []
highest = max(all_scores)
sorted_candidates = sorted(scored, key=lambda item: (-item.score, item.entity_id))
second_score = sorted_candidates[1].score if len(sorted_candidates) > 1 else 0.0
for index, candidate in enumerate(sorted_candidates):
confidence = candidate.score / highest if highest else 0.0
margin = candidate.score - second_score if index == 0 else 0.0
auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE
auto_accepted = confidence >= auto_score and (
context or margin >= _NUMERIC_MIN_MARGIN
)
sorted_candidates[index] = candidate.model_copy(
update={
"confidence": round(confidence, 4),
"auto_accepted": auto_accepted,
}
)
return sorted_candidates
@staticmethod
def _with_audit(
lifecycle: ModelLifecycleState,
*,
action: str,
reason: str,
now: datetime,
) -> ModelLifecycleState:
audit = list(lifecycle.audit)
entry = LifecycleAuditEntry(at=now, action=action, reason=reason)
if not audit or audit[-1].action != action or audit[-1].reason != reason:
audit.append(entry)
if len(audit) > _AUDIT_LIMIT:
audit = audit[-_AUDIT_LIMIT:]
return lifecycle.model_copy(update={"audit": audit})
def display_name(entity: HaEntitySummary) -> str:
return entity.friendly_name or entity.device_name or entity.entity_id
def _filter_candidates(
entities: dict[str, HaEntitySummary],
discovered: dict[str, DiscoveredEntity],
roles: set[EntityRole],
) -> list[tuple[HaEntitySummary, DiscoveredEntity]]:
result: list[tuple[HaEntitySummary, DiscoveredEntity]] = []
for entity_id, summary in entities.items():
candidate = discovered.get(entity_id)
if candidate is None or candidate.role not in roles:
continue
result.append((summary, candidate))
return result
def _score_candidate(
actuator: HaEntitySummary,
entity: HaEntitySummary,
role: EntityRole,
*,
context: bool,
) -> tuple[float, list[str]]:
evidence: list[str] = []
score = 0.0
actuator_tokens = _metadata_tokens(actuator)
entity_tokens = _metadata_tokens(entity)
overlap = sorted(actuator_tokens.intersection(entity_tokens))
if overlap:
score += min(0.4, 0.1 * len(overlap))
evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}")
if actuator.area_name and entity.area_name and actuator.area_name == entity.area_name:
score += 0.35
evidence.append(f"Gleicher Bereich: {actuator.area_name}")
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
score += 0.2
evidence.append("Gleiche Home-Assistant-Geräte-ID")
if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name:
score += 0.15
evidence.append(f"Gleicher Gerätename: {actuator.device_name}")
if actuator.friendly_name and entity.friendly_name and actuator.friendly_name == entity.friendly_name:
score += 0.1
evidence.append("Gleicher Friendly Name")
preferred_device_classes = _preferred_device_classes(actuator.domain, context=context)
if entity.device_class in preferred_device_classes:
score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}")
if not context and entity.unit_of_measurement is not None:
score += 0.05
evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}")
if context and role is EntityRole.BINARY_CONTEXT:
score += 0.05
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
return round(min(score, 1.0), 4), evidence
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})
mapping = {
"climate": {"temperature", "humidity", "power"},
"cover": {"illuminance", "temperature", "wind_speed"},
"fan": {"temperature", "humidity", "power"},
"humidifier": {"humidity", "temperature", "power"},
"light": {"illuminance", "power", "energy"},
"switch": {"power", "energy", "current"},
"valve": {"temperature", "pressure", "humidity"},
}
return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
raw_values = [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
tokens: set[str] = set()
for value in raw_values:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
if len(token) < 3 or token in _STOPWORDS:
continue
tokens.add(token)
return tokens
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:
digest = hashlib.sha256()
digest.update(sensor_id.encode("utf-8"))
for point in points:
digest.update(point.timestamp.isoformat().encode("utf-8"))
digest.update(f"{point.value:.6f}".encode("utf-8"))
return digest.hexdigest()
def _artifact_valid_for_sensor(artifact: TrainedArtifact, sensor_id: str) -> tuple[bool, str]:
if sensor_id not in artifact.supported_sensors:
return False, "Vorhandenes Modell passt nicht mehr zur aktuellen Sensorzuordnung."
feature_models = artifact.feature_models.get(sensor_id, {})
if "value" not in feature_models:
return False, "Vorhandenes Modell enthält kein numerisches Trainingsmerkmal 'value'."
return True, "Modell ist kompatibel."

102
app/actuators/models.py Normal file
View File

@@ -0,0 +1,102 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import StrEnum
from pydantic import BaseModel, Field
from app.ha.discovery import EntityRole
class AssignmentSource(StrEnum):
NONE = "none"
AUTOMATIC = "automatic"
MANUAL = "manual"
class LifecycleStatus(StrEnum):
PENDING_ASSIGNMENT = "pending_assignment"
REVIEW_REQUIRED = "review_required"
PENDING_HISTORY = "pending_history"
TRAINED = "trained"
STALE = "stale"
INVALID = "invalid"
ORPHANED = "orphaned"
ARCHIVED = "archived"
class AssignmentCandidate(BaseModel):
entity_id: str
domain: str
role: EntityRole
device_class: str | None = None
state_class: str | None = None
unit_of_measurement: str | None = None
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
score: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.0)
auto_accepted: bool = False
evidence: list[str] = Field(default_factory=list)
class AssignmentSelection(BaseModel):
selected_numeric_entity_id: str | None = None
selected_context_entity_ids: list[str] = Field(default_factory=list)
source: AssignmentSource = AssignmentSource.NONE
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
review_required: bool = True
reason: str = "Noch keine Zuordnung vorhanden."
class ManualOverride(BaseModel):
numeric_entity_id: str | None = None
context_entity_ids: list[str] = Field(default_factory=list)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
note: str | None = None
class LifecycleAuditEntry(BaseModel):
at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
action: str = Field(min_length=1, max_length=120)
reason: str = Field(min_length=1, max_length=500)
class ModelLifecycleState(BaseModel):
model_id: str
status: LifecycleStatus = LifecycleStatus.PENDING_ASSIGNMENT
last_reconciled_at: datetime | None = None
last_trained_at: datetime | None = None
last_history_signature: str | None = None
last_history_point_count: int = Field(default=0, ge=0)
reason: str = "Noch keine Trainingsdaten ausgewertet."
next_action: str = "Aktuator auswählen und Zuordnung prüfen."
audit: list[LifecycleAuditEntry] = Field(default_factory=list)
class ActuatorRecord(BaseModel):
actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
enabled: bool = True
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
assignment: AssignmentSelection = Field(default_factory=AssignmentSelection)
manual_override: ManualOverride | None = None
numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list)
context_candidates: list[AssignmentCandidate] = Field(default_factory=list)
lifecycle: ModelLifecycleState
class ReconciliationState(BaseModel):
last_started_at: datetime | None = None
last_completed_at: datetime | None = None
last_trigger: str | None = None
running: bool = False
configured_actuators: int = Field(default=0, ge=0)
review_required: int = Field(default=0, ge=0)
trained_models: int = Field(default=0, ge=0)
last_summary: str = "Noch keine Reconciliation ausgeführt."
def model_id_for_actuator(actuator_entity_id: str) -> str:
return f"actuator.{actuator_entity_id}"

116
app/actuators/store.py Normal file
View File

@@ -0,0 +1,116 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from threading import RLock
from app.actuators.models import (
ActuatorRecord,
LifecycleStatus,
ModelLifecycleState,
ReconciliationState,
model_id_for_actuator,
)
class ActuatorStore:
def __init__(self, root: str | Path) -> None:
self._root = Path(root).resolve()
self._actuators_root = self._root / "actuators"
self._actuators_root.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self._reconciliation_state_path = self._root / "reconciliation_state.json"
def list(self) -> list[ActuatorRecord]:
with self._lock:
return [self._load(path) for path in sorted(self._actuators_root.glob("*.json"))]
def get(self, actuator_entity_id: str) -> ActuatorRecord:
with self._lock:
target = self._target(actuator_entity_id)
if not target.exists():
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
return self._load(target)
def upsert(self, record: ActuatorRecord) -> ActuatorRecord:
with self._lock:
self._persist(record)
return record
def configure(self, actuator_entity_id: str, *, enabled: bool = True) -> ActuatorRecord:
with self._lock:
target = self._target(actuator_entity_id)
if target.exists():
record = self._load(target)
updated = record.model_copy(
update={
"enabled": enabled,
"updated_at": datetime.now(timezone.utc),
}
)
self._persist(updated)
return updated
record = ActuatorRecord(
actuator_entity_id=actuator_entity_id,
enabled=enabled,
lifecycle=ModelLifecycleState(
model_id=model_id_for_actuator(actuator_entity_id),
status=LifecycleStatus.PENDING_ASSIGNMENT,
),
)
self._persist(record)
return record
def delete(self, actuator_entity_id: str) -> None:
with self._lock:
target = self._target(actuator_entity_id)
if target.exists():
target.unlink()
def load_reconciliation_state(self) -> ReconciliationState:
with self._lock:
if not self._reconciliation_state_path.exists():
return ReconciliationState()
try:
return ReconciliationState.model_validate_json(
self._reconciliation_state_path.read_text(encoding="utf-8")
)
except ValueError as exc:
raise ValueError("Ungültiger Reconciliation-Status.") from exc
def save_reconciliation_state(self, state: ReconciliationState) -> ReconciliationState:
with self._lock:
self._persist_reconciliation_state(state)
return state
def _target(self, actuator_entity_id: str) -> Path:
if "." not in actuator_entity_id:
raise ValueError("Ungültige actuator_entity_id.")
safe_name = actuator_entity_id.replace(".", "__")
return self._actuators_root / f"{safe_name}.json"
def _persist(self, record: ActuatorRecord) -> None:
target = self._target(record.actuator_entity_id)
temporary = target.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(record.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, target)
def _persist_reconciliation_state(self, state: ReconciliationState) -> None:
temporary = self._reconciliation_state_path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, self._reconciliation_state_path)
@staticmethod
def _load(path: Path) -> ActuatorRecord:
try:
return ActuatorRecord.model_validate_json(path.read_text(encoding="utf-8"))
except ValueError as exc:
raise ValueError(f"Ungültige Aktuator-Konfiguration: {path.name}") from exc

122
app/api/v1/actuators.py Normal file
View File

@@ -0,0 +1,122 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ManualOverride, ReconciliationState
from app.actuators.store import ActuatorStore
from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
router = APIRouter(prefix="/v1/actuators", tags=["actuators"])
class ConfigureActuatorRequest(BaseModel):
actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
enabled: bool = True
class OverrideRequest(BaseModel):
numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
context_entity_ids: list[str] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=300)
clear: bool = False
@router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = ha_reader.discover()
actuator_ids = sorted(
entity.entity_id for entity in discovered if entity.role is EntityRole.ACTUATOR
)
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
@router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured()
@router.post("", response_model=ActuatorRecord, status_code=201)
def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord:
try:
return _service(request).configure_actuator(
payload.actuator_entity_id,
enabled=payload.enabled,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{actuator_entity_id}", response_model=ActuatorRecord)
def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord:
try:
return _service(request).get_actuator(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{actuator_entity_id}", status_code=204)
def delete_actuator(actuator_entity_id: str, request: Request) -> None:
_service(request).delete_actuator(actuator_entity_id)
@router.post("/{actuator_entity_id}/override", response_model=ActuatorRecord)
def set_override(
actuator_entity_id: str,
payload: OverrideRequest,
request: Request,
) -> ActuatorRecord:
override = None if payload.clear else ManualOverride(
numeric_entity_id=payload.numeric_entity_id,
context_entity_ids=payload.context_entity_ids,
note=payload.note,
)
try:
return _service(request).set_override(actuator_entity_id, override)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord)
def reconcile_actuator(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
try:
return _service(request).reconcile_actuator(actuator_entity_id, trigger="manual")
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/reconciliation/state", response_model=ReconciliationState)
def get_reconciliation_state(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator Store nicht initialisiert.",
)
return store.load_reconciliation_state()
@router.post("/reconciliation/run", response_model=ReconciliationState)
def run_reconciliation(
request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState:
return _service(request).reconcile_all(trigger=trigger)
def _service(request: Request) -> ActuatorReconciliationService:
service = getattr(request.app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator-Reconciliation nicht initialisiert.",
)
return service

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

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

View File

@@ -1,10 +1,13 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends
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
@@ -15,7 +18,47 @@ router = APIRouter(prefix="/v1", tags=["entities"])
"/entities",
summary="Home-Assistant-Entities auflisten",
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
response_model=list[HaEntitySummary],
response_model=List[HaEntitySummary],
)
def list_entities(reader: HaReader = Depends(get_ha_reader)) -> Sequence[HaEntitySummary]:
return reader.read_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,13 @@ 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"
actuator_store: str = ".actuator_store"
history_days: int = 14
min_training_points: int = 24
retrain_stale_hours: int = 24
reconcile_interval_seconds: int = 900
@property
def ha_configured(self) -> bool:
@@ -18,4 +25,13 @@ 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"),
actuator_store=os.getenv("SILLYHOME_ACTUATOR_STORE", ".actuator_store"),
history_days=max(1, min(31, int(os.getenv("SILLYHOME_HISTORY_DAYS", "14")))),
min_training_points=max(2, int(os.getenv("SILLYHOME_MIN_TRAINING_POINTS", "24"))),
retrain_stale_hours=max(1, int(os.getenv("SILLYHOME_RETRAIN_STALE_HOURS", "24"))),
reconcile_interval_seconds=max(
60, int(os.getenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "900"))
),
)

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

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

View File

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

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

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

View File

@@ -2,12 +2,25 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from datetime import datetime
import json
import re
from urllib.parse import quote
import requests
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
logger = logging.getLogger(__name__)
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
@dataclass(frozen=True)
class HaClientSettings:
@@ -25,14 +38,171 @@ class HaClient:
"Content-Type": "application/json",
})
def list_entities(self) -> list[dict[str, Any]]:
response = self._session.get(
f"{self._settings.url}/api/states",
timeout=self._settings.timeout_seconds,
)
response.raise_for_status()
payload = response.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):
msg = "Home Assistant states response must be a list."
raise TypeError(msg)
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 list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]:
if not entity_ids:
return {}
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.")
template = _metadata_template(entity_ids)
rendered = self._post_text("/api/template", {"template": template})
try:
payload = json.loads(rendered)
except json.JSONDecodeError as exc:
raise HaUnexpectedPayloadError("Entity-Metadaten konnten nicht gelesen werden.") from exc
if not isinstance(payload, list):
raise HaUnexpectedPayloadError("Entity-Metadaten haben ein unerwartetes Format.")
result: dict[str, dict[str, str | None]] = {}
for item in payload:
if not isinstance(item, dict):
raise HaUnexpectedPayloadError("Entity-Metadaten haben ein unerwartetes Format.")
entity_id = item.get("entity_id")
if not isinstance(entity_id, str) or "." not in entity_id:
raise HaUnexpectedPayloadError("Entity-Metadaten enthalten ungültige entity_id.")
result[entity_id] = {
key: _optional_string(item.get(key))
for key in ("area_id", "area_name", "device_id", "device_name")
}
return result
def _get_json(
self,
path: str,
*,
params: dict[str, str] | None = None,
) -> object:
try:
response = self._session.get(
f"{self._settings.url.rstrip('/')}{path}",
params=params,
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
except requests.RequestException as exc:
raise HaHttpError(
getattr(getattr(exc, "response", None), "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc
if response.status_code in (401, 403):
raise HaAuthError(
response.status_code,
"Authentifizierung bei Home Assistant fehlgeschlagen.",
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise HaHttpError(
response.status_code,
"Home Assistant meldet einen Fehler.",
) from exc
try:
payload = response.json()
except ValueError as exc:
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant ist kein gültiges JSON."
) from exc
return payload
def _post_text(self, path: str, payload: dict[str, str]) -> str:
try:
response = self._session.post(
f"{self._settings.url.rstrip('/')}{path}",
json=payload,
timeout=self._settings.timeout_seconds,
)
except requests.Timeout as exc:
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
except requests.RequestException as exc:
raise HaHttpError(
getattr(getattr(exc, "response", None), "status_code", 502),
"Netzwerkfehler beim Zugriff auf Home Assistant.",
) from exc
if response.status_code in (401, 403):
raise HaAuthError(
response.status_code,
"Authentifizierung bei Home Assistant fehlgeschlagen.",
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
return response.text
def _metadata_template(entity_ids: list[str]) -> str:
ids = json.dumps(entity_ids, ensure_ascii=True)
return (
"{% set ids = "
f"{ids}"
" %}["
"{% for entity_id in ids %}"
"{% set device = device_id(entity_id) %}"
"{{ "
"{"
"\"entity_id\": entity_id,"
"\"area_id\": area_id(entity_id),"
"\"area_name\": area_name(entity_id),"
"\"device_id\": device,"
"\"device_name\": device_attr(device, 'name') if device else none"
"}"
" | tojson }}"
"{% if not loop.last %},{% endif %}"
"{% endfor %}]"
)
def _optional_string(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)

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

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

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

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

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

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

View File

@@ -17,3 +17,8 @@ class HaEntitySummary(BaseModel):
state_class: str | None = None
device_class: str | None = None
unit_of_measurement: str | None = None
friendly_name: str | None = None
area_id: str | None = None
area_name: str | None = None
device_id: str | None = None
device_name: str | None = None

View File

@@ -1,11 +1,19 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from typing import Any
import logging
from app.ha.exceptions import HaClientError
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
logger = logging.getLogger(__name__)
class HaReader:
def __init__(self, client: HaClient) -> None:
@@ -13,14 +21,26 @@ class HaReader:
def read_entities(self) -> Sequence[HaEntitySummary]:
entities = self._client.list_entities()
entity_ids = [
raw_entity_id
for item in entities
if isinstance((raw_entity_id := item.get("entity_id")), str) and "." in raw_entity_id
]
try:
metadata_by_entity = self._client.list_entity_metadata(entity_ids)
except (HaClientError, ValueError) as exc:
logger.warning("HA metadata enrichment skipped: %s", exc)
metadata_by_entity = {}
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 {}
metadata = metadata_by_entity.get(entity_id, {})
summaries.append(
HaEntitySummary(
entity_id=entity_id,
@@ -28,10 +48,35 @@ class HaReader:
state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
friendly_name=_optional_str(attributes.get("friendly_name")),
area_id=_optional_str(metadata.get("area_id") or attributes.get("area_id")),
area_name=_optional_str(metadata.get("area_name") or attributes.get("area_name")),
device_id=_optional_str(metadata.get("device_id") or attributes.get("device_id")),
device_name=_optional_str(
metadata.get("device_name")
or attributes.get("device_name")
or attributes.get("device")
),
)
)
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,38 +1,81 @@
import asyncio
from contextlib import asynccontextmanager, suppress
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import cast
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router
from app.api.v1.entities import router as entities_router
from app.api.v1.automations import router as automations_router
from app.automations.store import AutomationStore
from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = load_settings()
app.state.settings = settings
settings = app.state.settings
client: HaClient | None = None
reconcile_task: asyncio.Task[None] | None = None
app.state.registry = ModelRegistry(settings.model_store)
app.state.automation_store = AutomationStore(settings.automation_store)
app.state.actuator_store = ActuatorStore(settings.actuator_store)
if hasattr(app.state, "ha_reader"):
del app.state.ha_reader
if hasattr(app.state, "actuator_service"):
del app.state.actuator_service
if settings.ha_configured:
client = HaClient(
settings=HaClientSettings(
url=settings.ha_url or "",
token=settings.ha_token or "",
url=cast(str, settings.ha_url),
token=cast(str, settings.ha_token),
)
)
app.state.ha_reader = HaReader(client=client)
yield
app.state.actuator_service = ActuatorReconciliationService(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
registry=app.state.registry,
settings=settings,
)
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
try:
yield
finally:
if reconcile_task is not None:
reconcile_task.cancel()
with suppress(asyncio.CancelledError):
await reconcile_task
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.4.0",
lifespan=lifespan,
)
app.state.settings = load_settings()
register_exception_handlers(app)
app.include_router(entities_router)
app.include_router(automations_router)
app.include_router(actuators_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")
@@ -41,5 +84,14 @@ 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")
async def _periodic_reconciliation(app: FastAPI) -> None:
while True:
await asyncio.sleep(app.state.settings.reconcile_interval_seconds)
service = getattr(app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
continue
await asyncio.to_thread(service.reconcile_all, "scheduled")

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

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

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

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

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

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

31
app/ml/feature_store.py Normal file
View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
class FeatureVector:
sensor_id: str
values: dict[str, float]
label: str | None = None
class FeatureStore:
def __init__(self) -> None:
self._vectors: dict[str, list[FeatureVector]] = defaultdict(list)
def add(self, vector: FeatureVector) -> None:
self._vectors[vector.sensor_id].append(vector)
def add_batch(self, vectors: Iterable[FeatureVector]) -> None:
for vector in vectors:
self.add(vector)
def latest(self, sensor_id: str) -> FeatureVector | None:
series = self._vectors.get(sensor_id)
return series[-1] if series else None
def all(self) -> list[FeatureVector]:
return [vector for vectors in self._vectors.values() for vector in vectors]

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

@@ -0,0 +1,102 @@
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Sequence
from app.ml.explanation import FeatureExplanation, explain_feature
from app.ml.feature_store import FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact, TrainingPipeline
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class PredictionResult:
artifact_id: str
sensor_id: str
predictions: dict[str, float]
confidence: float
model_type: str
explanations: dict[str, FeatureExplanation]
class Predictor:
def __init__(
self,
pipeline: TrainingPipeline | None = None,
registry: ModelRegistry | None = None,
) -> None:
if isinstance(pipeline, ModelRegistry) and registry is None:
registry = pipeline
pipeline = None
if pipeline is None and registry is None:
raise ValueError("Predictor erfordert TrainingPipeline oder ModelRegistry.")
self._pipeline = pipeline
self._registry = registry
def predict(self, artifact_id: str, entity: FeatureVector) -> PredictionResult:
artifact = self._get_artifact(artifact_id)
if entity.sensor_id not in artifact.supported_sensors:
raise ValueError(
f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt."
)
sensor_models = artifact.feature_models.get(entity.sensor_id, {})
if not sensor_models:
raise ValueError(f"Modell '{artifact_id}' enthält keine statistischen Parameter.")
feature_names = sorted(set(sensor_models).intersection(entity.values))
if not feature_names:
raise ValueError(
f"Keine Eingabemerkmale werden vom Modell '{artifact_id}' unterstützt."
)
predictions: dict[str, float] = {}
explanations: dict[str, FeatureExplanation] = {}
confidences: list[float] = []
for feature_name in feature_names:
model = sensor_models[feature_name]
current_value = float(entity.values[feature_name])
if not math.isfinite(current_value):
raise ValueError("Vorhersagewerte müssen endlich sein.")
predicted_value = model.forecast(current_value)
predictions[feature_name] = predicted_value
explanations[feature_name] = explain_feature(
feature_name,
current_value,
predicted_value,
model,
)
confidences.append(model.confidence)
return PredictionResult(
artifact_id=artifact_id,
sensor_id=entity.sensor_id,
predictions=predictions,
confidence=sum(confidences) / len(confidences),
model_type=artifact.model_type,
explanations=explanations,
)
def predict_batch(
self,
artifact_id: str,
entities: Sequence[FeatureVector],
) -> list[PredictionResult]:
return [self.predict(artifact_id, entity) for entity in entities]
@staticmethod
def default_artifact(pipeline: TrainingPipeline) -> TrainedArtifact:
artifacts = list(pipeline._artifacts)
if not artifacts:
raise ValueError("Kein trainiertes Modell gefunden.")
return pipeline.export(artifacts[-1])
def _get_artifact(self, artifact_id: str) -> TrainedArtifact:
if self._registry is not None:
return self._registry.load_artifact(artifact_id)
if self._pipeline is not None:
return self._pipeline.export(artifact_id)
raise RuntimeError("Predictor nicht initialisiert.")

View File

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

View File

@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import logging
import math
import os
from pathlib import Path
import re
from threading import RLock
from collections.abc import Iterable
from app.ml.training import FeatureModel, TrainedArtifact
logger = logging.getLogger(__name__)
_ARTIFACT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
class ModelRegistry:
def __init__(self, root: str | Path) -> None:
self._root = Path(root).resolve()
self._root.mkdir(parents=True, exist_ok=True)
self._archive_root = self._root / "archive"
self._archive_root.mkdir(parents=True, exist_ok=True)
self._artifacts: dict[str, TrainedArtifact] = {}
self._lock = RLock()
self._load_existing()
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
registered, _ = self.register_with_status(artifact)
return registered
def register_with_status(self, artifact: TrainedArtifact) -> tuple[TrainedArtifact, bool]:
self._validate_artifact_id(artifact.artifact_id)
with self._lock:
replaced = artifact.artifact_id in self._artifacts
self._persist(artifact)
self._artifacts[artifact.artifact_id] = artifact
return artifact, replaced
def load_artifact(self, artifact_id: str) -> TrainedArtifact:
self._validate_artifact_id(artifact_id)
with self._lock:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
return self._artifacts[artifact_id]
def get_optional(self, artifact_id: str) -> TrainedArtifact | None:
self._validate_artifact_id(artifact_id)
with self._lock:
return self._artifacts.get(artifact_id)
def list_models(self) -> Iterable[TrainedArtifact]:
with self._lock:
return [self._artifacts[key] for key in sorted(self._artifacts)]
def archive(self, artifact_id: str) -> bool:
self._validate_artifact_id(artifact_id)
with self._lock:
artifact = self._artifacts.pop(artifact_id, None)
source = self._root / f"{artifact_id}.json"
if not source.exists():
return artifact is not None
target = self._archive_root / f"{artifact_id}.json"
os.replace(source, target)
logger.info("Modell archiviert: %s", target)
return True
def _load_existing(self) -> None:
for source in sorted(self._root.glob("*.json")):
try:
raw = json.loads(source.read_text(encoding="utf-8"))
artifact_id = raw["artifact_id"]
supported_sensors = raw["supported_sensors"]
model_type = raw.get("model_type", "metadata")
raw_feature_models = raw.get("feature_models", {})
if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list):
raise ValueError("invalid artifact structure")
if not isinstance(model_type, str):
raise ValueError("model_type must be a string")
self._validate_artifact_id(artifact_id)
if source.name != f"{artifact_id}.json":
raise ValueError("artifact id does not match filename")
if not all(isinstance(sensor, str) for sensor in supported_sensors):
raise ValueError("supported_sensors must contain strings")
feature_models = _deserialize_feature_models(raw_feature_models)
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError(f"Ungültiges Modell-Artefakt: {source.name}") from exc
self._artifacts[artifact_id] = TrainedArtifact(
artifact_id=artifact_id,
supported_sensors=tuple(supported_sensors),
feature_models=feature_models,
model_type=model_type,
)
def _persist(self, artifact: TrainedArtifact) -> None:
target = self._root / f"{artifact.artifact_id}.json"
temporary = target.with_suffix(".json.tmp")
payload = {
"artifact_id": artifact.artifact_id,
"supported_sensors": list(artifact.supported_sensors),
"model_type": artifact.model_type,
"feature_models": {
sensor_id: {
feature_name: {
"sample_count": model.sample_count,
"mean": model.mean,
"standard_deviation": model.standard_deviation,
"minimum": model.minimum,
"maximum": model.maximum,
"slope": model.slope,
"intercept": model.intercept,
}
for feature_name, model in sorted(models.items())
}
for sensor_id, models in sorted(artifact.feature_models.items())
},
}
temporary.write_text(
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, target)
logger.info("Modell gespeichert: %s", target)
@staticmethod
def _validate_artifact_id(artifact_id: str) -> None:
if not _ARTIFACT_ID_PATTERN.fullmatch(artifact_id) or ".." in artifact_id:
raise ValueError(
"artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich "
"und Bindestrich enthalten."
)
def _deserialize_feature_models(raw: object) -> dict[str, dict[str, FeatureModel]]:
if not isinstance(raw, dict):
raise ValueError("feature_models must be an object")
result: dict[str, dict[str, FeatureModel]] = {}
for sensor_id, raw_features in raw.items():
if not isinstance(sensor_id, str) or not isinstance(raw_features, dict):
raise ValueError("invalid sensor feature models")
features: dict[str, FeatureModel] = {}
for feature_name, raw_model in raw_features.items():
if not isinstance(feature_name, str) or not isinstance(raw_model, dict):
raise ValueError("invalid feature model")
sample_count = raw_model.get("sample_count")
if not isinstance(sample_count, int) or isinstance(sample_count, bool) or sample_count < 1:
raise ValueError("sample_count must be a positive integer")
values = {
key: _finite_number(raw_model.get(key))
for key in (
"mean",
"standard_deviation",
"minimum",
"maximum",
"slope",
"intercept",
)
}
features[feature_name] = FeatureModel(
sample_count=sample_count,
mean=values["mean"],
standard_deviation=values["standard_deviation"],
minimum=values["minimum"],
maximum=values["maximum"],
slope=values["slope"],
intercept=values["intercept"],
)
result[sensor_id] = features
return result
def _finite_number(value: object) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise ValueError("feature model values must be finite numbers")
converted = float(value)
if not math.isfinite(converted):
raise ValueError("feature model values must be finite numbers")
return converted

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

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

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

@@ -0,0 +1,117 @@
from __future__ import annotations
import logging
import math
from collections import defaultdict
from dataclasses import dataclass, field
from app.ml.feature_store import FeatureStore
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class FeatureModel:
sample_count: int
mean: float
standard_deviation: float
minimum: float
maximum: float
slope: float
intercept: float
def forecast(self, current_value: float | None = None) -> float:
if current_value is not None:
return current_value + self.slope
return self.intercept + self.slope * self.sample_count
@property
def confidence(self) -> float:
sample_score = self.sample_count / (self.sample_count + 2)
scale = abs(self.mean) if abs(self.mean) > 1e-9 else 1.0
stability_score = 1.0 / (1.0 + self.standard_deviation / scale)
return min(0.99, max(0.05, sample_score * stability_score))
@dataclass(frozen=True)
class TrainedArtifact:
artifact_id: str
supported_sensors: tuple[str, ...]
feature_models: dict[str, dict[str, FeatureModel]] = field(default_factory=dict)
model_type: str = "statistical_baseline"
class TrainingPipeline:
def __init__(self, store: FeatureStore) -> None:
self._store = store
self._artifacts: dict[str, TrainedArtifact] = {}
def run(self, artifact_id: str) -> TrainedArtifact:
vectors = self._store.all()
if not vectors:
raise ValueError("FeatureStore enthält keine Trainingsdaten.")
samples: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
for vector in vectors:
for feature_name, raw_value in vector.values.items():
value = float(raw_value)
if math.isfinite(value):
samples[vector.sensor_id][feature_name].append(value)
feature_models = {
sensor_id: {
feature_name: _fit_feature(values)
for feature_name, values in sorted(features.items())
if values
}
for sensor_id, features in sorted(samples.items())
}
feature_models = {
sensor_id: models for sensor_id, models in feature_models.items() if models
}
if not feature_models:
raise ValueError("Trainingsdaten enthalten keine endlichen numerischen Werte.")
sensors = tuple(feature_models)
artifact = TrainedArtifact(
artifact_id=artifact_id,
supported_sensors=sensors,
feature_models=feature_models,
)
self._artifacts[artifact_id] = artifact
logger.info("Training abgeschlossen für %s mit %d Sensoren", artifact_id, len(sensors))
return artifact
def export(self, artifact_id: str) -> TrainedArtifact:
if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
return self._artifacts[artifact_id]
def _fit_feature(values: list[float]) -> FeatureModel:
sample_count = len(values)
mean = sum(values) / sample_count
variance = sum((value - mean) ** 2 for value in values) / sample_count
standard_deviation = math.sqrt(variance)
if sample_count == 1:
slope = 0.0
intercept = mean
else:
x_mean = (sample_count - 1) / 2
denominator = sum((index - x_mean) ** 2 for index in range(sample_count))
numerator = sum(
(index - x_mean) * (value - mean) for index, value in enumerate(values)
)
slope = numerator / denominator
intercept = mean - slope * x_mean
return FeatureModel(
sample_count=sample_count,
mean=mean,
standard_deviation=standard_deviation,
minimum=min(values),
maximum=max(values),
slope=slope,
intercept=intercept,
)

View File

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

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

@@ -0,0 +1,355 @@
<!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,h3 { margin: 0 0 12px; }
header p { margin: 4px 0; color: #b9c9d6; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,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; }
.warn { color: #f3c969; }
.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; 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; vertical-align: top; }
ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid #e8b34b; padding-left: 10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:8px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
</style>
</head>
<body>
<header>
<h1>SillyHome Next</h1>
<p>Aktuator-zentrierte Home-Assistant-Analyse mit nachvollziehbarer Sensorzuordnung und kontrolliertem Modell-Lebenszyklus.</p>
<p class="notice">Sicherheitsmodus: SillyHome führt niemals selbst Aktor-Services aus. Automationen bleiben manuell freizugebende YAML-Entwürfe.</p>
</header>
<main>
<section>
<h2>Systemstatus</h2>
<div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Neu laden</button>
<button onclick="runReconciliation()">Reconciliation ausführen</button>
</section>
<section>
<h2>Aktuator wählen</h2>
<label for="actuator-select">Home-Assistant-Aktor</label>
<select id="actuator-select"></select>
<button onclick="configureActuator()">Aktuator übernehmen</button>
<pre id="actuator-config-result">Noch kein Aktuator konfiguriert.</pre>
</section>
<section class="wide">
<h2>Konfigurierte Aktuatoren</h2>
<div id="configured-actuators">Noch nicht geladen.</div>
</section>
<section class="wide">
<h2>Zuordnung und Modellstatus</h2>
<div id="actuator-detail">Einen konfigurierten Aktuator auswählen.</div>
</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 class="grid-two">
<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);
let currentActuatorId = null;
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;
}
function statusClass(record) {
if (record.assignment.review_required) return "warn";
if (record.lifecycle.status === "trained") return "ok";
if (record.lifecycle.status === "review_required" || record.lifecycle.status === "invalid") return "warn";
return "bad";
}
function renderEvidence(evidence) {
return evidence.length ? `<ul>${evidence.map(item => `<li>${item}</li>`).join("")}</ul>` : "<span class='bad'>Keine Evidenz</span>";
}
async function loadOverview() {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
try {
const [health, ml, reconciliation, actuators] = await Promise.all([
api("health"),
api("ml/health"),
api("v1/actuators/reconciliation/state"),
api("v1/actuators"),
]);
status.innerHTML = `<p class="ok">API und ML bereit</p><p>Letzte Reconciliation: ${reconciliation.last_completed_at || "noch nie"}</p><p>${reconciliation.last_summary}</p>`;
chips.innerHTML = [
`<span class="chip">Health: ${health.status}</span>`,
`<span class="chip">ML: ${ml.status}</span>`,
`<span class="chip">Aktuatoren: ${actuators.length}</span>`,
`<span class="chip">Trainierte Modelle: ${reconciliation.trained_models}</span>`,
].join("");
} catch (error) {
status.innerHTML = `<p class="bad">${error.message}</p>`;
chips.innerHTML = "";
}
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators(), loadProposals()]);
}
async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select");
try {
const actuators = await api("v1/actuators/discovery");
select.innerHTML = actuators.length
? actuators.map(entity => `<option value="${entity.entity_id}">${entity.friendly_name || entity.entity_id}${entity.area_name ? ` (${entity.area_name})` : ""}</option>`).join("")
: "<option value=''>Keine Aktuatoren gefunden</option>";
} catch (error) {
select.innerHTML = `<option value="">${error.message}</option>`;
}
}
async function configureActuator() {
const actuatorId = document.getElementById("actuator-select").value;
const box = document.getElementById("actuator-config-result");
if (!actuatorId) return;
try {
const record = await api("v1/actuators", {
method: "POST",
body: JSON.stringify({actuator_entity_id: actuatorId}),
});
currentActuatorId = record.actuator_entity_id;
box.textContent = pretty(record);
await loadOverview();
await showActuator(record.actuator_entity_id);
} catch (error) {
box.textContent = error.message;
}
}
async function runReconciliation() {
try {
await api("v1/actuators/reconciliation/run", {method: "POST"});
await loadOverview();
if (currentActuatorId) await showActuator(currentActuatorId);
} catch (error) {
alert(error.message);
}
}
async function loadConfiguredActuators() {
const box = document.getElementById("configured-actuators");
try {
const rows = await api("v1/actuators");
box.innerHTML = rows.length ? `
<table>
<tr><th>Aktuator</th><th>Numerischer Sensor</th><th>Review</th><th>Modellstatus</th><th>Letztes Training</th><th>Aktion</th></tr>
${rows.map(record => `
<tr>
<td>${record.actuator_entity_id}</td>
<td>${record.assignment.selected_numeric_entity_id || "-"}</td>
<td class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nein"}</td>
<td class="${statusClass(record)}">${record.lifecycle.status}</td>
<td>${record.lifecycle.last_trained_at || "-"}</td>
<td><button onclick="showActuator('${record.actuator_entity_id}')">Details</button></td>
</tr>
`).join("")}
</table>` : "<p>Keine konfigurierten Aktuatoren.</p>";
} catch (error) {
box.textContent = error.message;
}
}
async function showActuator(actuatorId) {
currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail");
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericRows = record.numeric_candidates.map(candidate => `
<tr>
<td>${candidate.entity_id}</td>
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td>
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>review</span>"}</td>
<td>${renderEvidence(candidate.evidence)}</td>
</tr>
`).join("");
const contextRows = record.context_candidates.map(candidate => `
<tr>
<td>${candidate.entity_id}</td>
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td>
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>optional</span>"}</td>
<td>${renderEvidence(candidate.evidence)}</td>
</tr>
`).join("");
box.innerHTML = `
<div class="grid-two">
<div>
<h3>Auswahl</h3>
<p><strong>Aktuator:</strong> ${record.actuator_entity_id}</p>
<p><strong>Numerischer Sensor:</strong> ${record.assignment.selected_numeric_entity_id || "-"}</p>
<p><strong>Kontext:</strong> ${record.assignment.selected_context_entity_ids.join(", ") || "-"}</p>
<p><strong>Quelle:</strong> ${record.assignment.source}</p>
<p><strong>Review:</strong> <span class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nicht erforderlich"}</span></p>
<p><strong>Begruendung:</strong> ${record.assignment.reason}</p>
</div>
<div>
<h3>Modell-Lebenszyklus</h3>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${record.lifecycle.status}</span></p>
<p><strong>Letztes Training:</strong> ${record.lifecycle.last_trained_at || "-"}</p>
<p><strong>Messpunkte:</strong> ${record.lifecycle.last_history_point_count}</p>
<p><strong>Grund:</strong> ${record.lifecycle.reason}</p>
<p><strong>Nächste Aktion:</strong> ${record.lifecycle.next_action}</p>
<button onclick="reconcileActuator('${record.actuator_entity_id}')">Diesen Aktuator erneut prüfen</button>
</div>
</div>
<div class="grid-two">
<div>
<h3>Manuelle Overrides</h3>
<label for="override-numeric">Numerischer Sensor</label>
<input id="override-numeric" value="${record.manual_override?.numeric_entity_id || record.assignment.selected_numeric_entity_id || ""}">
<label for="override-context">Kontext-Entities (kommagetrennt)</label>
<textarea id="override-context">${(record.manual_override?.context_entity_ids || record.assignment.selected_context_entity_ids || []).join(", ")}</textarea>
<label for="override-note">Notiz</label>
<input id="override-note" value="${record.manual_override?.note || ""}">
<button onclick="saveOverride('${record.actuator_entity_id}')">Override speichern</button>
<button class="secondary" onclick="clearOverride('${record.actuator_entity_id}')">Override löschen</button>
</div>
<div>
<h3>Audit</h3>
<pre>${pretty(record.lifecycle.audit)}</pre>
</div>
</div>
<h3>Numerische Kandidaten</h3>
${numericRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${numericRows}</table>` : "<p>Keine Kandidaten.</p>"}
<h3>Kontext-Kandidaten</h3>
${contextRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${contextRows}</table>` : "<p>Keine Kandidaten.</p>"}
`;
} catch (error) {
box.textContent = error.message;
}
}
async function reconcileActuator(actuatorId) {
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/reconcile`, {method: "POST"});
await loadOverview();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function saveOverride(actuatorId) {
const numeric = document.getElementById("override-numeric").value.trim() || null;
const contexts = document.getElementById("override-context").value
.split(",")
.map(item => item.trim())
.filter(Boolean);
const note = document.getElementById("override-note").value.trim() || null;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numeric,
context_entity_ids: contexts,
note,
}),
});
await loadOverview();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function clearOverride(actuatorId) {
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, {
method: "POST",
body: JSON.stringify({clear: true}),
});
await loadOverview();
await showActuator(actuatorId);
} catch (error) {
alert(error.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 (error) {
alert(error.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 (error) {
alert(error.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(item => `
<tr>
<td>${item.alias}</td>
<td>${item.status}</td>
<td>${item.status === "draft"
? `<button onclick="decide('${item.proposal_id}',${item.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${item.proposal_id}',${item.revision},'reject')">Ablehnen</button>`
: item.status === "approved"
? `<a href="v1/automations/proposals/${item.proposal_id}/yaml">YAML laden</a>`
: "-"}</td>
</tr>
`).join("")}
</table>` : "<p>Keine Entwürfe.</p>";
} catch (error) {
box.textContent = error.message;
}
}
loadOverview();
</script>
</body>
</html>

1
backend/__init__.py Normal file
View File

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

43
backend/app.py Normal file
View File

@@ -0,0 +1,43 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.datastructures import State
from backend.routes.ml import init_ml_routes
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
from app.ml.feature_store import FeatureStore, FeatureVector
@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
application.state.registry = ModelRegistry(application.state.model_store)
_seed_default_model(application.state)
yield
def create_app() -> FastAPI:
application = FastAPI(title="SillyHome Next ML", lifespan=lifespan)
init_ml_routes(application)
return application
def _seed_default_model(state: State) -> None:
registry = getattr(state, "registry", None)
if registry is None:
registry = ModelRegistry(".model_store")
state.registry = registry
if list(registry.list_models()):
return
store = FeatureStore()
store.add(FeatureVector(sensor_id="sensor.front_door", values={"contact": 1.0}))
store.add(FeatureVector(sensor_id="sensor.living_room", values={"temperature": 21.0}))
pipeline = TrainingPipeline(store)
artifact = pipeline.run("default")
registry.register(artifact)
app = create_app()

View File

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

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

@@ -0,0 +1,253 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from collections.abc import Sequence
from fastapi import APIRouter, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field
from app.ml.evaluation import Evaluator
from app.ml.feature_store import FeatureVector
from app.ml.predictor import Predictor
from app.ml.registry.model_registry import ModelRegistry
from app.ml.retraining import retrain_model
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ml", tags=["ml"])
class HealthResponse(BaseModel):
status: str
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class PredictRequest(BaseModel):
model_id: str = Field(..., alias="modelId")
sensor_id: str
values: dict[str, float]
class PredictResponse(BaseModel):
model_id: str
sensor_id: str
predictions: dict[str, float]
confidence: float
model_type: str
explanations: dict[str, "FeatureExplanationResponse"]
class FeatureExplanationResponse(BaseModel):
feature: str
current_value: float
predicted_value: float
change: float
direction: str
sample_count: int
historical_mean: float
historical_range: tuple[float, float]
standard_deviation: float
trend_per_step: float
confidence: float
summary: str
class BatchRequest(BaseModel):
requests: Sequence[PredictRequest]
class BatchResponse(BaseModel):
predictions: Sequence[PredictResponse]
class ModelsResponse(BaseModel):
models: list[str]
class TrainingSample(BaseModel):
sensor_id: str = Field(min_length=1)
values: dict[str, float]
label: str | None = None
class RetrainRequest(BaseModel):
model_id: str = Field(..., alias="modelId", min_length=1, max_length=128)
samples: list[TrainingSample] = Field(min_length=1)
class RetrainResponse(BaseModel):
model_id: str
supported_sensors: list[str]
trained_features: int
model_type: str
replaced: bool
class EvaluateRequest(BaseModel):
model_id: str = Field(..., alias="modelId", min_length=1, max_length=128)
samples: list[TrainingSample] = Field(min_length=1)
class MetricResponse(BaseModel):
name: str
value: float
threshold: float | None = None
class EvaluateResponse(BaseModel):
model_id: str
sample_size: int
metrics: list[MetricResponse]
@router.get("/health", response_model=HealthResponse, status_code=200)
def health() -> HealthResponse:
return HealthResponse(status="ok")
@router.get("/models", response_model=ModelsResponse, status_code=200)
def list_models(request: Request) -> ModelsResponse:
registry = _require_registry(request)
models = [artifact.artifact_id for artifact in registry.list_models()]
return ModelsResponse(models=models)
@router.post("/retrain", response_model=RetrainResponse, status_code=200)
def retrain(payload: RetrainRequest, request: Request) -> RetrainResponse:
registry = _require_registry(request)
vectors = [
FeatureVector(
sensor_id=sample.sensor_id,
values=sample.values,
label=sample.label,
)
for sample in payload.samples
]
try:
result = retrain_model(registry, payload.model_id, vectors)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return RetrainResponse(
model_id=result.artifact.artifact_id,
supported_sensors=list(result.artifact.supported_sensors),
trained_features=sum(
len(feature_models)
for feature_models in result.artifact.feature_models.values()
),
model_type=result.artifact.model_type,
replaced=result.replaced,
)
@router.post("/evaluate", response_model=EvaluateResponse, status_code=200)
def evaluate(payload: EvaluateRequest, request: Request) -> EvaluateResponse:
registry = _require_registry(request)
vectors = [
FeatureVector(
sensor_id=sample.sensor_id,
values=sample.values,
label=sample.label,
)
for sample in payload.samples
]
try:
report = Evaluator(registry=registry).evaluate(payload.model_id, vectors)
except ValueError as exc:
try:
registry.load_artifact(payload.model_id)
except KeyError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return EvaluateResponse(
model_id=report.artifact_id,
sample_size=report.sample_size,
metrics=[
MetricResponse(name=metric.name, value=metric.value, threshold=metric.threshold)
for metric in report.metrics
],
)
@router.post("/predict", response_model=PredictResponse, status_code=200)
def predict(payload: PredictRequest, request: Request) -> PredictResponse:
registry = _require_registry(request)
predictor = Predictor(registry=registry)
vector = FeatureVector(sensor_id=payload.sensor_id, values=payload.values)
try:
prediction = predictor.predict(payload.model_id, vector)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return PredictResponse(
model_id=payload.model_id,
sensor_id=payload.sensor_id,
predictions=prediction.predictions,
confidence=prediction.confidence,
model_type=prediction.model_type,
explanations={
name: FeatureExplanationResponse(**explanation.__dict__)
for name, explanation in prediction.explanations.items()
},
)
@router.post("/batch", response_model=BatchResponse, status_code=200)
def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
registry = _require_registry(request)
predictor = Predictor(registry=registry)
responses: list[PredictResponse] = []
for item in payload.requests:
vector = FeatureVector(sensor_id=item.sensor_id, values=item.values)
try:
prediction = predictor.predict(item.model_id, vector)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
responses.append(
PredictResponse(
model_id=item.model_id,
sensor_id=item.sensor_id,
predictions=prediction.predictions,
confidence=prediction.confidence,
model_type=prediction.model_type,
explanations={
name: FeatureExplanationResponse(**explanation.__dict__)
for name, explanation in prediction.explanations.items()
},
)
)
return BatchResponse(predictions=responses)
def _require_registry(request: Request) -> ModelRegistry:
registry = getattr(request.app.state, "registry", None)
if not isinstance(registry, ModelRegistry):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ML registry nicht initialisiert.",
)
return registry
def init_ml_routes(app: FastAPI, model_store: str = ".model_store") -> None:
app.state.model_store = model_store
app.include_router(router)
logger.info("ML routes registered")

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
api:
build: .
ports:
- "127.0.0.1:8000:8000"
env_file:
- path: .env
required: false
environment:
SILLYHOME_MODEL_STORE: /app/data/models
SILLYHOME_AUTOMATION_STORE: /app/data/automations
SILLYHOME_ACTUATOR_STORE: /app/data/actuators
SILLYHOME_HISTORY_DAYS: 14
SILLYHOME_MIN_TRAINING_POINTS: 24
SILLYHOME_RETRAIN_STALE_HOURS: 24
SILLYHOME_RECONCILE_INTERVAL_SECONDS: 900
volumes:
- model-data:/app/data/models
- automation-data:/app/data/automations
- actuator-data:/app/data/actuators
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
volumes:
model-data:
automation-data:
actuator-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.

52
docs/ha_data.md Normal file
View File

@@ -0,0 +1,52 @@
# 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
Zusätzlich reichert `HaReader` verfügbare Metadaten wie `friendly_name`,
Bereich und Gerät aus Home Assistant an. Für die aktor-zentrierte Zuordnung
nutzt SillyHome Next bevorzugt:
- `area_id` und `area_name`
- `device_id` und `device_name`
- Friendly Names und Entity-ID-Tokens
- Domain und `device_class`
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. Binäre Kontext-Entities werden bewusst nicht in numerische
Trainingsreihen konvertiert.
## 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.

218
docs/ml_api.md Normal file
View File

@@ -0,0 +1,218 @@
# ML-Serving-API
Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen
Modell-Artefakt-, Vorhersage- und aktor-zentrierten Lifecycle-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`
Die aktor-zentrierte API liegt unter `/v1/actuators`.
Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und
ML-Routen in derselben Anwendung bereit.
## Endpoints
### `GET /ml/health`
Health-Check der ML-Services.
**Beispielantwort**
```json
{
"status": "ok",
"updated_at": "2026-06-11T12:00:00Z"
}
```
### `GET /ml/models`
Listet alle registrierten Modell-Artefakte auf.
**Beispielantwort**
```json
{
"models": ["default"]
}
```
### `POST /ml/predict`
Einzelne Vorhersage für einen Sensor.
**Request**
```json
{
"modelId": "default",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0}
}
```
**Antwort**
```json
{
"model_id": "default",
"sensor_id": "sensor.kitchen",
"predictions": {"temperature": 21.4},
"confidence": 0.78,
"model_type": "statistical_baseline",
"explanations": {
"temperature": {
"direction": "steigend",
"change": 0.4,
"sample_count": 24,
"historical_mean": 20.7,
"trend_per_step": 0.4,
"summary": "temperature: steigend; Prognose ..."
}
}
}
```
Die Erklärung nennt pro Merkmal den aktuellen und prognostizierten Wert,
Richtung, Veränderung, Datenbasis, historischen Bereich, Streuung, Trend und
Confidence. Sie wird deterministisch aus den gespeicherten Modellparametern
erzeugt.
### `POST /ml/retrain`
Trainiert die Artefakt-Metadaten aus neuen Sensordaten. Existiert `modelId`
bereits, wird das Artefakt atomisch ersetzt und beim nächsten Prozessstart aus
dem Modellverzeichnis geladen.
**Request**
```json
{
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
"label": "occupied"
}
]
}
```
**Antwort**
```json
{
"model_id": "home-model",
"supported_sensors": ["sensor.kitchen"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": false
}
```
### `POST /ml/evaluate`
Vergleicht Modellvorhersagen mit Validierungsdaten und liefert MAE, RMSE und
Coverage. Der Request verwendet dasselbe Sample-Format wie `/ml/retrain`.
### `POST /ml/batch`
Batch-Vorhersage für mehrere Sensorwerte.
**Request**
```json
{
"requests": [
{
"modelId": "default",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0}
},
{
"modelId": "default",
"sensor_id": "sensor.bedroom",
"values": {"temperature": 18.5}
}
]
}
```
**Antwort**
```json
{
"predictions": [
{
"model_id": "default",
"sensor_id": "sensor.kitchen",
"predictions": {"temperature": 21.4},
"confidence": 0.78,
"model_type": "statistical_baseline"
},
{
"model_id": "default",
"sensor_id": "sensor.bedroom",
"predictions": {"temperature": 18.3},
"confidence": 0.74,
"model_type": "statistical_baseline"
}
]
}
```
## Fehlerfälle
- `404 Not Found`: Modell nicht registriert.
- `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig.
- `503 Service Unavailable`: Registry ist nicht initialisiert.
## Aktuator-zentrierte API
### `GET /v1/actuators/discovery`
Listet unterstützte Aktuatoren mit angereicherter HA-Metadatenbasis.
### `POST /v1/actuators`
Registriert einen Aktuator, ermittelt passende numerische Sensoren und
Kontext-Entities, trainiert bei ausreichender History automatisch ein Modell und
liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zurück.
**Request**
```json
{
"actuator_entity_id": "light.abstellkammer",
"enabled": true
}
```
### `POST /v1/actuators/{actuator_entity_id}/override`
Persistiert manuelle Overrides. Diese haben Vorrang vor der automatischen
Heuristik und überstehen Neustarts.
### `POST /v1/actuators/reconciliation/run`
Führt eine sichere globale Reconciliation aus. Die periodische Add-on-Schleife
ruft denselben idempotenten Ablauf auf, startet aber keine Services in Home
Assistant.
## Betrieb
Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktuator-,
Override- und Reconciliation-Zustände liegen atomisch in
`SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`,
`RetrainingService` oder den aktor-zentrierten Lifecycle registriert. 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`

92
docs/ml_training.md Normal file
View File

@@ -0,0 +1,92 @@
# 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.
Seit `v0.4.0` ist der bevorzugte Weg aktor-zentriert: ein bestätigter Aktuator
wird mit einem numerischen Primärsensor verknüpft, die Historie dieses Sensors
wird automatisch geladen und in ein deterministisches Artefakt überführt.
## 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.
Im Normalbetrieb erzeugt die Reconciliation diese Vektoren selbst aus realer
Home-Assistant-History. Das Trainingsmerkmal heißt dabei immer `value`.
Binäre Kontextsensoren bleiben Kontext und werden nicht als numerische Samples
missverstanden.
## 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.
## 6. Autonomer Lebenszyklus
Der `ActuatorReconciliationService` verwaltet pro konfiguriertem Aktuator:
- die automatische Sensor- und Kontextzuordnung mit Score, Confidence und Evidenz
- persistente manuelle Overrides
- den Modellstatus (`trained`, `pending_history`, `review_required`, `archived`, ...)
- ein Audit-Protokoll mit Gründen für Training, Retraining oder Archivierung
Retraining erfolgt nur, wenn:
- genügend nutzbare numerische Historie vorliegt
- die aktuelle Zuordnung eindeutig oder manuell bestätigt ist
- die Historie sich materiell verändert hat oder das Modell als stale gilt
## 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.4.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,18 @@
from __future__ import annotations
from pathlib import Path
from app.actuators.models import ReconciliationState
from app.actuators.store import ActuatorStore
def test_actuator_store_persists_record_and_reconciliation_state(tmp_path: Path) -> None:
store = ActuatorStore(tmp_path)
store.configure("light.abstellkammer")
state = ReconciliationState(last_summary="ok", configured_actuators=1)
store.save_reconciliation_state(state)
restarted = ActuatorStore(tmp_path)
assert restarted.get("light.abstellkammer").actuator_entity_id == "light.abstellkammer"
assert restarted.load_reconciliation_state().last_summary == "ok"

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import (
AssignmentSource,
LifecycleStatus,
ManualOverride,
model_id_for_actuator,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry
class FakeActuatorReader(HaReader):
def __init__(
self,
entities: list[HaEntitySummary],
history_by_entity: dict[str, list[NumericHistoryPoint]],
) -> None:
self._entities = entities
self._history_by_entity = history_by_entity
def read_entities(self) -> list[HaEntitySummary]:
return list(self._entities)
def discover(
self,
domains: set[str] | None = None,
learnable: bool | None = None,
) -> list[DiscoveredEntity]:
return discover_entities(self._entities, domains=domains, learnable=learnable)
def read_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[EntityHistorySeries]:
series: list[EntityHistorySeries] = []
for entity_id in entity_ids:
points = [
point
for point in self._history_by_entity.get(entity_id, [])
if start_time <= point.timestamp <= end_time
]
if points:
series.append(EntityHistorySeries(entity_id=entity_id, points=points))
return series
def _points(count: int, start: datetime, value: float) -> list[NumericHistoryPoint]:
return [
NumericHistoryPoint(timestamp=start + timedelta(hours=index), value=value + index)
for index in range(count)
]
def _service(
tmp_path: Path,
entities: list[HaEntitySummary],
history_by_entity: dict[str, list[NumericHistoryPoint]],
) -> ActuatorReconciliationService:
return ActuatorReconciliationService(
ha_reader=FakeActuatorReader(entities, history_by_entity),
store=ActuatorStore(tmp_path / "actuators"),
registry=ModelRegistry(tmp_path / "models"),
settings=Settings(
ha_url="http://ha.local",
ha_token="token",
model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"),
actuator_store=str(tmp_path / "actuators"),
history_days=14,
min_training_points=5,
retrain_stale_hours=24,
reconcile_interval_seconds=900,
),
)
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_illuminance",
domain="sensor",
device_class="illuminance",
state_class="measurement",
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
domain="binary_sensor",
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.kitchen_temperature",
domain="sensor",
device_class="temperature",
state_class="measurement",
unit_of_measurement="°C",
friendly_name="Kueche Temperatur",
area_name="Kueche",
),
]
service = _service(
tmp_path,
entities,
{
"sensor.abstellkammer_illuminance": _points(8, start, 10.0),
"sensor.kitchen_temperature": _points(8, start, 18.0),
},
)
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance"
assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_motion"]
assert record.assignment.review_required is False
assert record.lifecycle.status is LifecycleStatus.TRAINED
artifact = service._registry.load_artifact(model_id_for_actuator("light.abstellkammer"))
assert artifact.supported_sensors == ("sensor.abstellkammer_illuminance",)
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="switch.garage_pump",
domain="switch",
friendly_name="Garage Pumpe",
area_name="Garage",
),
HaEntitySummary(
entity_id="sensor.garage_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Garage Leistung",
area_name="Garage",
),
HaEntitySummary(
entity_id="sensor.garage_energy",
domain="sensor",
device_class="energy",
state_class="measurement",
unit_of_measurement="kWh",
friendly_name="Garage Energie",
area_name="Garage",
),
]
service = _service(
tmp_path,
entities,
{
"sensor.garage_power": _points(8, start, 10.0),
"sensor.garage_energy": _points(8, start, 11.0),
},
)
record = service.configure_actuator("switch.garage_pump")
assert record.assignment.review_required is True
assert record.lifecycle.status is LifecycleStatus.REVIEW_REQUIRED
def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_illuminance",
domain="sensor",
device_class="illuminance",
state_class="measurement",
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Abstellkammer Leistung",
area_name="Abstellkammer",
),
]
history = {
"sensor.abstellkammer_illuminance": _points(8, start, 10.0),
"sensor.abstellkammer_power": _points(8, start, 30.0),
}
service = _service(tmp_path, entities, history)
service.configure_actuator("light.abstellkammer")
updated = service.set_override(
"light.abstellkammer",
ManualOverride(
numeric_entity_id="sensor.abstellkammer_power",
context_entity_ids=[],
note="Manuelle Leistungs-Zuordnung",
),
)
restarted = _service(tmp_path, entities, history)
record = restarted.reconcile_actuator("light.abstellkammer")
assert updated.assignment.source is AssignmentSource.MANUAL
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power"
assert record.manual_override is not None
assert record.manual_override.numeric_entity_id == "sensor.abstellkammer_power"

135
tests/api/test_actuators.py Normal file
View File

@@ -0,0 +1,135 @@
from __future__ import annotations
from datetime import datetime, timedelta
from pathlib import Path
from fastapi.testclient import TestClient
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore
from app.config import Settings
from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import app
from app.ml.registry.model_registry import ModelRegistry
class FakeHaReader(HaReader):
def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None:
self._entities = entities
self._history = history
def read_entities(self) -> list[HaEntitySummary]:
return list(self._entities)
def discover(
self,
domains: set[str] | None = None,
learnable: bool | None = None,
) -> list[DiscoveredEntity]:
return discover_entities(self._entities, domains=domains, learnable=learnable)
def read_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[EntityHistorySeries]:
base = start_time
return [
EntityHistorySeries(
entity_id=entity_id,
points=[
NumericHistoryPoint(
timestamp=base + timedelta(hours=index),
value=value,
)
for index, value in enumerate(self._history.get(entity_id, []))
],
)
for entity_id in entity_ids
if entity_id in self._history
]
def _install_service(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_illuminance",
domain="sensor",
device_class="illuminance",
state_class="measurement",
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
domain="binary_sensor",
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
),
]
settings = Settings(
ha_url="http://ha.local",
ha_token="token",
model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"),
actuator_store=str(tmp_path / "actuators"),
history_days=14,
min_training_points=5,
retrain_stale_hours=24,
reconcile_interval_seconds=900,
)
app.state.registry = ModelRegistry(tmp_path / "models")
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
app.state.ha_reader = FakeHaReader(
entities,
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
)
app.state.actuator_service = ActuatorReconciliationService(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
registry=app.state.registry,
settings=settings,
)
def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
created = client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
assert created.status_code == 201
assert created.json()["assignment"]["selected_numeric_entity_id"] == (
"sensor.abstellkammer_illuminance"
)
listed = client.get("/v1/actuators")
assert listed.status_code == 200
assert listed.json()[0]["lifecycle"]["status"] == "trained"
override = client.post(
"/v1/actuators/light.abstellkammer/override",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
"note": "Explizit bestaetigt",
},
)
assert override.status_code == 200
assert override.json()["assignment"]["source"] == "manual"
reconciliation = client.post("/v1/actuators/reconciliation/run")
assert reconciliation.status_code == 200
assert reconciliation.json()["trained_models"] == 1

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,7 +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
@@ -14,6 +18,46 @@ 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:
pass
def read_entities(self) -> Sequence[HaEntitySummary]:
raise HaTimeoutError("contains internal details that must not leak")
def test_openapi_docs_are_available() -> None:
with TestClient(app) as client:
@@ -34,13 +78,65 @@ def test_entities_returns_reader_data() -> None:
"state_class": None,
"device_class": None,
"unit_of_measurement": None,
"friendly_name": None,
"area_id": None,
"area_name": None,
"device_id": None,
"device_name": 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
def test_entities_maps_ha_errors_without_leaking_details() -> None:
with TestClient(app) as client:
app.state.ha_reader = TimeoutHaReader()
response = client.get("/v1/entities")
assert response.status_code == 504
assert response.json() == {"detail": "Home Assistant request timed out."}
def test_discovery_filters_entities() -> None:
with TestClient(app) as client:
app.state.ha_reader = FakeHaReader()
response = client.get("/v1/discovery?domain=sensor&learnable=true")
assert response.status_code == 200
assert response.json() == [
{
"entity_id": "sensor.temperature",
"domain": "sensor",
"device_class": "temperature",
"state_class": None,
"unit_of_measurement": None,
"role": "measurement",
"learnable": True,
"reason": "Numerischer Messsensor für Zeitreihen und Training.",
}
]
def test_history_returns_normalized_series() -> None:
with TestClient(app) as client:
app.state.ha_reader = FakeHaReader()
response = client.get(
"/v1/history",
params=[
("entity_id", "sensor.temperature"),
("start_time", "2026-06-01T00:00:00Z"),
("end_time", "2026-06-02T00:00:00Z"),
],
)
assert response.status_code == 200
assert response.json() == [
{
"entity_id": "sensor.temperature",
"points": [{"timestamp": "2026-06-01T00:00:00Z", "value": 21.5}],
}
]

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

@@ -0,0 +1,184 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
def test_ml_routes_are_exposed_by_production_app() -> None:
with TestClient(app) as client:
health = client.get("/ml/health")
models = client.get("/ml/models")
assert health.status_code == 200
assert models.status_code == 200
assert isinstance(models.json()["models"], list)
def test_unknown_model_returns_404() -> None:
with TestClient(app) as client:
response = client.post(
"/ml/predict",
json={
"modelId": "missing",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
},
)
assert response.status_code == 404
def test_unsupported_sensor_returns_422(tmp_path: Path) -> None:
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact
registry = ModelRegistry(tmp_path)
registry.register(TrainedArtifact("default", ("sensor.kitchen",)))
with TestClient(app) as client:
app.state.registry = registry
response = client.post(
"/ml/predict",
json={
"modelId": "default",
"sensor_id": "sensor.unknown",
"values": {"temperature": 21.0},
},
)
assert response.status_code == 422
def test_retrain_creates_and_replaces_persisted_model(tmp_path: Path) -> None:
from app.ml.registry.model_registry import ModelRegistry
registry = ModelRegistry(tmp_path)
with TestClient(app) as client:
app.state.registry = registry
created = client.post(
"/ml/retrain",
json={
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
}
],
},
)
replaced = client.post(
"/ml/retrain",
json={
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.bedroom",
"values": {"temperature": 18.0},
}
],
},
)
assert created.status_code == 200
assert created.json() == {
"model_id": "home-model",
"supported_sensors": ["sensor.kitchen"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": False,
}
assert replaced.status_code == 200
assert replaced.json() == {
"model_id": "home-model",
"supported_sensors": ["sensor.bedroom"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": True,
}
restarted = ModelRegistry(tmp_path)
assert restarted.load_artifact("home-model").supported_sensors == ("sensor.bedroom",)
def test_retrain_rejects_empty_samples() -> None:
with TestClient(app) as client:
response = client.post(
"/ml/retrain",
json={"modelId": "home-model", "samples": []},
)
assert response.status_code == 422
def test_predict_returns_numeric_forecast_and_confidence(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
registry = ModelRegistry(tmp_path)
registry.register(TrainingPipeline(store).run("home-model"))
with TestClient(app) as client:
app.state.registry = registry
response = client.post(
"/ml/predict",
json={
"modelId": "home-model",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
},
)
assert response.status_code == 200
assert response.json()["predictions"] == {"temperature": 22.0}
assert 0.0 < response.json()["confidence"] <= 1.0
assert response.json()["model_type"] == "statistical_baseline"
explanation = response.json()["explanations"]["temperature"]
assert explanation["direction"] == "steigend"
assert explanation["change"] == 1.0
assert explanation["sample_count"] == 2
def test_evaluate_returns_real_error_metrics(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
registry = ModelRegistry(tmp_path)
registry.register(TrainingPipeline(store).run("home-model"))
with TestClient(app) as client:
app.state.registry = registry
response = client.post(
"/ml/evaluate",
json={
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
}
],
},
)
assert response.status_code == 200
metrics = {metric["name"]: metric["value"] for metric in response.json()["metrics"]}
assert metrics == {"mae": 1.0, "rmse": 1.0, "coverage": 1.0}

View File

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

View File

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

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

@@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import Mock
import pytest
import requests
from app.ha.client import HaClient, HaClientSettings
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
def _client_with_response(response: Mock) -> HaClient:
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.get = Mock(return_value=response) # type: ignore[method-assign]
return client
def _response(status_code: int = 200, payload: object | None = None) -> Mock:
response = Mock()
response.status_code = status_code
response.json.return_value = [] if payload is None else payload
if status_code >= 400:
response.raise_for_status.side_effect = requests.HTTPError("upstream failed")
return response
def test_list_entities_returns_home_assistant_payload() -> None:
payload = [{"entity_id": "sensor.temperature", "state": "21"}]
client = _client_with_response(_response(payload=payload))
assert client.list_entities() == payload
def test_list_entities_maps_timeout() -> None:
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.get = Mock(side_effect=requests.Timeout("timed out")) # type: ignore[method-assign]
with pytest.raises(HaTimeoutError):
client.list_entities()
@pytest.mark.parametrize("status_code", [401, 403])
def test_list_entities_maps_auth_errors(status_code: int) -> None:
client = _client_with_response(_response(status_code=status_code))
with pytest.raises(HaAuthError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == status_code
def test_list_entities_maps_http_errors() -> None:
client = _client_with_response(_response(status_code=500))
with pytest.raises(HaHttpError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == 500
def test_list_entities_rejects_invalid_json() -> None:
response = _response()
response.json.side_effect = ValueError("not json")
client = _client_with_response(response)
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()
def test_list_entities_rejects_non_list_payload() -> None:
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()
def test_get_history_calls_home_assistant_history_api() -> None:
response = _response(payload=[[{"entity_id": "sensor.temperature", "state": "21.0"}]])
client = _client_with_response(response)
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
payload = client.get_history(["sensor.temperature"], start, end)
assert payload == [[{"entity_id": "sensor.temperature", "state": "21.0"}]]
client._session.get.assert_called_once() # type: ignore[attr-defined]
call = client._session.get.call_args # type: ignore[attr-defined]
assert "/api/history/period/2026-06-01T00:00:00+00:00" in call.args[0]
assert call.kwargs["params"]["filter_entity_id"] == "sensor.temperature"
assert call.kwargs["params"]["end_time"] == "2026-06-02T00:00:00+00:00"
def test_list_entity_metadata_calls_template_api() -> None:
response = _response()
response.text = (
'[{"entity_id":"sensor.temperature","area_name":"Kueche","device_name":"Thermometer"}]'
)
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.post = Mock(return_value=response) # type: ignore[method-assign]
metadata = client.list_entity_metadata(["sensor.temperature"])
assert metadata == {
"sensor.temperature": {
"area_id": None,
"area_name": "Kueche",
"device_id": None,
"device_name": "Thermometer",
}
}
@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,32 @@ 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 list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]:
return {
"sensor.temperature": {
"area_id": "kitchen",
"area_name": "Kueche",
"device_id": "device-1",
"device_name": "Thermometer",
}
}
def test_ha_reader_returns_summaries() -> None:
reader = HaReader(FakeHaClient())
@@ -35,3 +63,27 @@ 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"
assert sensor.area_name == "Kueche"
assert sensor.device_name == "Thermometer"
def test_ha_reader_discovers_learnable_sensors() -> None:
reader = HaReader(FakeHaClient())
discovered = reader.discover(learnable=True)
assert [entity.entity_id for entity in discovered] == ["sensor.temperature"]
def test_ha_reader_normalizes_history() -> None:
reader = HaReader(FakeHaClient())
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
history = reader.read_history(
["sensor.temperature"],
start,
datetime(2026, 6, 2, tzinfo=timezone.utc),
)
assert history[0].entity_id == "sensor.temperature"
assert history[0].points[0].value == 21.5

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

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

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import pytest
from app.ml.evaluation import Evaluator
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def evaluator_factory() -> Evaluator:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Evaluator(pipeline)
def test_evaluate_returns_report_with_metrics() -> None:
evaluator = evaluator_factory()
report = evaluator.evaluate(
"artifact_v1",
[
_vector("sensor.kitchen", 21.0),
_vector("sensor.bedroom", 18.5),
],
)
assert report.artifact_id == "artifact_v1"
assert report.sample_size == 2
assert {metric.name for metric in report.metrics} == {"mae", "rmse", "coverage"}
assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0
assert next(metric.value for metric in report.metrics if metric.name == "mae") == 0.0
def test_evaluate_without_training_raises_value_error() -> None:
evaluator = Evaluator(TrainingPipeline(FeatureStore()))
with pytest.raises(ValueError):
evaluator.evaluate("artifact_v1", [])
def test_coverage_counts_only_supported_sensor_features() -> None:
evaluator = evaluator_factory()
report = evaluator.evaluate(
"artifact_v1",
[
_vector("sensor.kitchen", 21.0),
FeatureVector(sensor_id="sensor.kitchen", values={"humidity": 50.0}),
_vector("sensor.kitchen_extra", 20.0),
],
)
metrics = {metric.name: metric.value for metric in report.metrics}
assert metrics["coverage"] == pytest.approx(1 / 3)

View File

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

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from app.ml.feature_store import FeatureStore, FeatureVector
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def test_append_and_latest_returns_last_vector() -> None:
store = FeatureStore()
vectors = [_vector("sensor.living_room", 20.0), _vector("sensor.living_room", 21.5)]
for item in vectors:
store.add(item)
assert store.latest("sensor.living_room") == vectors[-1]
def test_latest_returns_none_when_empty() -> None:
store = FeatureStore()
assert store.latest("sensor.living_room") is None
def test_add_batch_appends_all_vectors() -> None:
store = FeatureStore()
vectors = [
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bathroom", 23.5),
]
store.add_batch(vectors)
assert len(store.all()) == 3
latest = store.latest("sensor.kitchen")
assert latest is not None
assert latest.values["temperature"] == 20.0
def test_different_sensors_are_stored_independently() -> None:
store = FeatureStore()
store.add(_vector("sensor.living_room", 21.0))
store.add(_vector("sensor.bedroom", 18.5))
living_room = store.latest("sensor.living_room")
bedroom = store.latest("sensor.bedroom")
assert living_room is not None
assert bedroom is not None
assert living_room.values["temperature"] == 21.0
assert bedroom.values["temperature"] == 18.5

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainedArtifact
def test_registry_loads_persisted_artifacts_after_restart(tmp_path: Path) -> None:
registry = ModelRegistry(tmp_path)
artifact = TrainedArtifact("model-v1", ("sensor.kitchen", "sensor.bedroom"))
registry.register(artifact)
restarted = ModelRegistry(tmp_path)
assert restarted.load_artifact("model-v1") == artifact
def test_registry_persists_statistical_parameters(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
artifact = TrainingPipeline(store).run("model-v1")
ModelRegistry(tmp_path).register(artifact)
assert ModelRegistry(tmp_path).load_artifact("model-v1") == artifact
def test_registry_replaces_persisted_artifact_after_restart(tmp_path: Path) -> None:
registry = ModelRegistry(tmp_path)
registry.register(TrainedArtifact("model-v1", ("sensor.kitchen",)))
replacement = TrainedArtifact("model-v1", ("sensor.bedroom",))
registry.register(replacement)
assert registry.load_artifact("model-v1") == replacement
assert ModelRegistry(tmp_path).load_artifact("model-v1") == replacement
@pytest.mark.parametrize("artifact_id", ["../escape", "nested/model", "..", ""])
def test_registry_rejects_unsafe_artifact_ids(tmp_path: Path, artifact_id: str) -> None:
registry = ModelRegistry(tmp_path)
with pytest.raises(ValueError):
registry.register(TrainedArtifact(artifact_id, ("sensor.kitchen",)))
assert list(tmp_path.parent.glob("escape.json")) == []
def test_registry_rejects_corrupt_persisted_artifact(tmp_path: Path) -> None:
(tmp_path / "broken.json").write_text(
json.dumps({"artifact_id": "../broken", "supported_sensors": []}),
encoding="utf-8",
)
with pytest.raises(ValueError, match="broken.json"):
ModelRegistry(tmp_path)

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.predictor import Predictor
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def predictor() -> Predictor:
store = FeatureStore()
store.add_batch(
[
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bedroom", 18.5),
]
)
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Predictor(pipeline)
def test_predict_returns_statistical_forecast() -> None:
p = predictor()
result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0))
assert result.artifact_id == "artifact_v1"
assert result.sensor_id == "sensor.kitchen"
assert result.predictions == {"temperature": 22.0}
assert 0.0 < result.confidence <= 1.0
assert result.model_type == "statistical_baseline"
explanation = result.explanations["temperature"]
assert explanation.direction == "steigend"
assert explanation.current_value == 21.0
assert explanation.predicted_value == 22.0
assert explanation.sample_count == 2
def test_predict_rejects_unknown_sensor() -> None:
p = predictor()
with pytest.raises(ValueError):
p.predict("artifact_v1", _vector("sensor.unknown", 10.0))
def test_predict_batch_matches_single_calls() -> None:
p = predictor()
entities = [_vector("sensor.kitchen", 21.0), _vector("sensor.bedroom", 19.0)]
assert p.predict_batch("artifact_v1", entities) == [
p.predict("artifact_v1", item) for item in entities
]
def test_default_artifact_returns_last_registered() -> None:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
pipeline.run("first")
pipeline.run("second")
assert Predictor.default_artifact(pipeline).artifact_id == "second"

View File

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

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

@@ -0,0 +1,53 @@
from __future__ import annotations
import pytest
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def store_with_data() -> TrainingPipeline:
store = FeatureStore()
store.add_batch(
[
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bedroom", 18.5),
]
)
return TrainingPipeline(store)
def test_run_returns_trained_artifact() -> None:
pipeline = store_with_data()
artifact = pipeline.run("artifact_v1")
assert artifact.artifact_id == "artifact_v1"
assert artifact.supported_sensors == ("sensor.bedroom", "sensor.kitchen")
kitchen = artifact.feature_models["sensor.kitchen"]["temperature"]
assert kitchen.sample_count == 2
assert kitchen.mean == 19.5
assert kitchen.slope == 1.0
assert kitchen.forecast() == 21.0
def test_run_without_data_raises_value_error() -> None:
pipeline = TrainingPipeline(FeatureStore())
with pytest.raises(ValueError):
pipeline.run("artifact_v1")
def test_export_returns_registered_artifact() -> None:
pipeline = store_with_data()
pipeline.run("artifact_v1")
exported = pipeline.export("artifact_v1")
assert exported == pipeline.export("artifact_v1")
def test_export_missing_artifact_raises_key_error() -> None:
pipeline = store_with_data()
with pytest.raises(KeyError):
pipeline.export("artifact_v1")

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from app.ml.evaluation import Evaluator, EvalReport, Metric
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
def _vector(sensor_id: str, temperature: float, label: str | None = None) -> FeatureVector:
return FeatureVector(sensor_id=sensor_id, values={"temperature": temperature}, label=label)
def test_end_to_end_training_then_evaluation() -> None:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
pipeline = TrainingPipeline(store)
artifact = pipeline.run("artifact_v1")
evaluator = Evaluator(pipeline)
samples = [
_vector("sensor.kitchen", 21.0),
_vector("sensor.bedroom", 18.5),
]
report = evaluator.evaluate(artifact.artifact_id, samples)
assert isinstance(report, EvalReport)
assert report.sample_size == len(samples)
assert any(metric.name == "coverage" for metric in report.metrics)
def test_metric_helpers_are_serializable() -> None:
metric = Metric(name="mae", value=0.85, threshold=1.0)
assert metric.name == "mae"
assert metric.value == 0.85
assert metric.threshold == 1.0

View File

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

30
tests/test_config.py Normal file
View File

@@ -0,0 +1,30 @@
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")
monkeypatch.setenv("SILLYHOME_ACTUATOR_STORE", "/tmp/actuators")
monkeypatch.setenv("SILLYHOME_HISTORY_DAYS", "7")
monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12")
monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48")
monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600")
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.actuator_store == "/tmp/actuators"
assert settings.history_days == 7
assert settings.min_training_points == 12
assert settings.retrain_stale_hours == 48
assert settings.reconcile_interval_seconds == 600
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