Compare commits

...

10 Commits

Author SHA1 Message Date
ce568056fc Merge pull request 'v0.5.0: behavior learning, shadow prediction and safe activation' (#32) from feature/actuator-sensor-lifecycle into main
Merge pull request v0.5.0 behavior learning and safe activation (#32)
2026-06-14 10:41:16 +02:00
da4603be17 BEHAVIOR-002: isolate per-actuator runtime failures
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 10:40:19 +02:00
b215f23dd9 Merge remote-tracking branch 'origin/main' into feature/actuator-sensor-lifecycle 2026-06-14 10:38:33 +02:00
fa250216be BEHAVIOR-001: learn and predict actuator actions 2026-06-14 10:37:59 +02:00
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
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
39 changed files with 3319 additions and 266 deletions

View File

@@ -2,3 +2,14 @@ SILLYHOME_HA_URL=http://homeassistant.local:8123
SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN
SILLYHOME_MODEL_STORE=.model_store SILLYHOME_MODEL_STORE=.model_store
SILLYHOME_AUTOMATION_STORE=.automation_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
SILLYHOME_MIN_BEHAVIOR_ACTIONS=3
SILLYHOME_PREDICTION_CONFIDENCE=0.82
SILLYHOME_PREDICTION_WINDOW_MINUTES=30
SILLYHOME_PREDICTION_INTERVAL_SECONDS=60
SILLYHOME_EXECUTION_COOLDOWN_SECONDS=900
SILLYHOME_TIMEZONE=Europe/Berlin

View File

@@ -1,13 +1,21 @@
# SillyHome Next — Architekturübersicht # SillyHome Next — Architekturübersicht
Ziel ist ein lokales, datensparsames, erklärbares Smart-Home-Intelligenzsystem für Home Assistant. Es analysiert Historie, erkennt Gewohnheiten, erstellt Vorhersagen, empfiehlt Automationen und kann auf Wunsch einfach in Automationen übersetzen. Vier Intelligenzebenen sind vorgesehen: regelbasiert, ML-gestützt, LLM-unterstützt und autonomer Hausagent. Ziel ist ein lokales, datensparsames und erklärbares Smart-Home-Intelligenzsystem
für Home Assistant. Nutzer wählen ausschließlich erlaubte Aktoren. Das System
ordnet Kontext automatisch zu, erkennt historische Nutzerhandlungen, trainiert
pro Aktor ein Verhaltensmodell und trifft zunächst nur Shadow-Vorhersagen.
Autonomes Schalten wird separat pro Aktor freigegeben.
## Leitentscheidungen ## Leitentscheidungen
- Lokal-first und datensparsam; keine Cloudpflicht. - Lokal-first und datensparsam; keine Cloudpflicht.
- Trennung von Datenintegration, Trainingspipeline, Vorhersageservice und Erklärungsschicht. - Trennung von Datenintegration, Kontextzuordnung, Verhaltenslernen,
- Standardintegration über MQTT und Home Assistant WebSocket plus REST. Vorhersage und Aktorausführung.
- Schnittstellen über FastAPI und OpenAI-kompatible Endpunkte. - Logbook-basierte Herkunftserkennung; bekannte Automationen und eigene
- Langzeitdaten in PostgreSQL und TimescaleDB; Vektoren für semantische Suche optional. Schaltungen werden nicht als Nutzerhandlungen trainiert.
- Deployment über Docker Compose; Kubernetes optional für erweiterte Betriebsgrößen. - Ausführung nur für freigegebene, reversible Domains und Zustände sowie mit
Konfidenzschwelle und Cooldown.
- Standardintegration über die lokale Home-Assistant-REST-API.
- Persistenz als atomische lokale Modell- und Aktorartefakte.
- Deployment als Home-Assistant-Add-on oder über Docker Compose.
- Tests, Docs und Changelog sind Pflichtbestandteil jeder Änderung. - Tests, Docs und Changelog sind Pflichtbestandteil jeder Änderung.

View File

@@ -1,8 +1,21 @@
# Changelog # Changelog
## Unreleased ## 0.5.0 - 2026-06-14
- Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage - Ingress auf reine Aktorauswahl, automatischen Lernstatus und Vorhersagen reduziert
- Persistenter Automation-Freigabeprozess mit sicherem YAML-Export - Automatische Kontextzuordnung ohne Sensor-Overrides oder Review-Blockade
- Historische Handlungserkennung aus HA-State-History und Logbook-Herkunft
- Persistentes Verhaltensmodell pro Aktor mit Zeit-, Wochentags- und Kontextmustern
- Shadow-Vorhersagen vor jeder Ausführungsfreigabe
- Explizite Aktivierung pro Aktor, Konfidenzschwelle, Cooldown und enge Service-Whitelist
- Schutz vor dem Lernen erkannter HA-Automationen und eigener Schaltvorgänge
- Automation-Proposal- und Override-Endpunkte aus dem aktiven Produkt entfernt
## 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 ## 0.2.0 - 2026-06-13
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern - Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern

View File

@@ -4,7 +4,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
SILLYHOME_MODEL_STORE=/app/data/models SILLYHOME_MODEL_STORE=/app/data/models
ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations 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 \
SILLYHOME_MIN_BEHAVIOR_ACTIONS=3 \
SILLYHOME_PREDICTION_CONFIDENCE=0.82 \
SILLYHOME_PREDICTION_WINDOW_MINUTES=30 \
SILLYHOME_PREDICTION_INTERVAL_SECONDS=60 \
SILLYHOME_EXECUTION_COOLDOWN_SECONDS=900 \
SILLYHOME_TIMEZONE=Europe/Berlin
WORKDIR /app WORKDIR /app
@@ -15,7 +26,7 @@ COPY app ./app
COPY backend ./backend COPY backend ./backend
RUN python -m pip install --upgrade pip && \ RUN python -m pip install --upgrade pip && \
python -m pip install . && \ python -m pip install . && \
mkdir -p /app/data/models /app/data/automations && \ mkdir -p /app/data/models /app/data/automations /app/data/actuators && \
chown -R sillyhome:sillyhome /app/data chown -R sillyhome:sillyhome /app/data
EXPOSE 8000 EXPOSE 8000

View File

@@ -4,11 +4,11 @@ Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant.
## Reifegrad ## Reifegrad
Die aktuelle Entwicklungslinie stellt eine gehärtete technische Basis bereit: Die aktuelle Entwicklungslinie ist vollständig aktor-zentriert: Nutzer wählen
Home-Assistant-Entities und Historie lesen, Sensoren klassifizieren, nur Home-Assistant-Aktuatoren aus. SillyHome Next findet Sensoren, Zustände und
regelbasierte Bausteine sowie ein lokal trainierbares statistisches Kontext automatisch, wertet die vorhandene Historie aus und hält passende
Baseline-Modell mit persistenter Registry, Confidence und echten lokale Modelle autonom aktuell. Es gibt keinen Regel-, Trigger-, Sensor- oder
Evaluationsmetriken. YAML-Konfigurationsschritt.
## Motivation ## Motivation
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit. TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
@@ -17,7 +17,7 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Home Assistant und Sensoren/Aktoren verstehen - Home Assistant und Sensoren/Aktoren verstehen
- Historie auswerten und Gewohnheiten erkennen - Historie auswerten und Gewohnheiten erkennen
- Vorhersagen erstellen und erklären - Vorhersagen erstellen und erklären
- Automationen vorschlagen und direkt generieren - Persönliches Verhalten pro Aktor lernen und zukünftige Handlungen vorhersagen
- Lokal-first ohne Cloudpflicht - Lokal-first ohne Cloudpflicht
- Erweiterbar, testbar, dokumentiert - Erweiterbar, testbar, dokumentiert
@@ -46,10 +46,14 @@ uvicorn app.main:app --reload
- `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities - `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/discovery` - klassifizierte, filterbare Entities
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen - `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` - Aktor freigeben; Kontextzuordnung und Modell-Lebenszyklus starten automatisch
- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/evaluate` - Shadow-Vorhersage aktualisieren
- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen
- `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 - `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/retrain` - Modell-Metadaten aktualisieren
- `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen - `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`. Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.
@@ -68,7 +72,17 @@ dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden.
- `SILLYHOME_HA_URL` Basis-URL deiner Home-Assistant-Instanz (z. B. `http://homeassistant.local:8123`) - `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_HA_TOKEN` Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
- `SILLYHOME_MODEL_STORE` Verzeichnis für persistierte Modell-Metadaten - `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 Aktor-Zuordnungen 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
- `SILLYHOME_MIN_BEHAVIOR_ACTIONS` Mindestzahl gelernter Handlungen vor einer Freigabe
- `SILLYHOME_PREDICTION_CONFIDENCE` Mindestkonfidenz für autonomes Schalten
- `SILLYHOME_PREDICTION_WINDOW_MINUTES` Zeitfenster um gelernte Handlungsmuster
- `SILLYHOME_PREDICTION_INTERVAL_SECONDS` Intervall für Shadow-/Aktiv-Vorhersagen
- `SILLYHOME_EXECUTION_COOLDOWN_SECONDS` Mindestabstand zwischen eigenen Schaltungen
- `SILLYHOME_TIMEZONE` lokale Zeitzone für Tages- und Wochenmuster
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
Versionskontrollsystem. Versionskontrollsystem.
@@ -81,8 +95,27 @@ unter **Einstellungen → Add-ons → Add-on-Shop → Repositories** diese URL e
`http://192.168.6.31:3000/pino/sillyhome-next` `http://192.168.6.31:3000/pino/sillyhome-next`
Danach **SillyHome Next** installieren und starten. Das Dashboard wird per Ingress 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 geöffnet. Dort werden ausschließlich erlaubte Aktoren ausgewählt; Kontext- und
lokal gespeichert und niemals automatisch ausgeführt. Lernentscheidungen erfolgen automatisch.
### Normaler Workflow
1. Im Dashboard einen Aktor auswählen, zum Beispiel `light.abstellkammer`.
2. SillyHome Next bewertet automatisch Messwerte, Anwesenheit, Bewegung,
Bereiche, Gerätebeziehungen und weitere HA-Kontexte.
3. Das System verwendet selbstständig die beste verfügbare Zuordnung.
Niedrige Sicherheit bleibt als Diagnose sichtbar, verlangt aber keine
manuelle Konfiguration.
4. Sobald genügend Historie vorhanden ist, trainiert und aktualisiert das
System das lokale Modell automatisch.
5. Vorhersagen laufen zunächst ausschließlich im Shadow-Modus.
6. Erst nach ausdrücklicher Freigabe pro Aktor werden hochkonfidente,
erlaubte Zustände geschaltet. Eigene Schaltungen und erkannte
HA-Automationen werden nicht als Nutzerhandlungen zurückgelernt.
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 ### Tests
```bash ```bash

View File

@@ -1,7 +1,7 @@
name: SillyHome Next name: SillyHome Next
version: "0.3.0" version: "0.5.0"
slug: sillyhome_next slug: sillyhome_next
description: Lokale HA-Analyse, Vorhersagen und sichere Automation-Entwürfe description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next
arch: arch:
- amd64 - amd64
@@ -10,12 +10,34 @@ boot: auto
init: false init: false
ingress: true ingress: true
ingress_port: 8000 ingress_port: 8000
panel_title: SillyHome Next
panel_icon: mdi:home-analytics panel_icon: mdi:home-analytics
panel_admin: true
homeassistant_api: true homeassistant_api: true
hassio_api: false hassio_api: false
auth_api: false auth_api: false
options: {} options:
schema: {} history_days: 14
min_training_points: 24
retrain_stale_hours: 24
reconcile_interval_seconds: 900
min_behavior_actions: 3
prediction_confidence: 0.82
prediction_window_minutes: 30
prediction_interval_seconds: 60
execution_cooldown_seconds: 900
timezone: Europe/Berlin
schema:
history_days: "int(1,31)"
min_training_points: "int(2,10000)"
retrain_stale_hours: "int(1,720)"
reconcile_interval_seconds: "int(60,86400)"
min_behavior_actions: "int(2,100)"
prediction_confidence: "float(0.5,0.99)"
prediction_window_minutes: "int(5,120)"
prediction_interval_seconds: "int(30,3600)"
execution_cooldown_seconds: "int(60,86400)"
timezone: "str"
map: map:
- type: addon_config - type: addon_config
read_only: false read_only: false

View File

@@ -5,7 +5,21 @@ export SILLYHOME_HA_URL="${SILLYHOME_HA_URL:-http://supervisor/core}"
export SILLYHOME_HA_TOKEN="${SILLYHOME_HA_TOKEN:-${SUPERVISOR_TOKEN:-}}" export SILLYHOME_HA_TOKEN="${SILLYHOME_HA_TOKEN:-${SUPERVISOR_TOKEN:-}}"
export SILLYHOME_MODEL_STORE=/data/models export SILLYHOME_MODEL_STORE=/data/models
export SILLYHOME_AUTOMATION_STORE=/data/automations export SILLYHOME_AUTOMATION_STORE=/data/automations
export SILLYHOME_ACTUATOR_STORE=/data/actuators
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" 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))')"
export SILLYHOME_MIN_BEHAVIOR_ACTIONS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_behavior_actions", 3))')"
export SILLYHOME_PREDICTION_CONFIDENCE="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_confidence", 0.82))')"
export SILLYHOME_PREDICTION_WINDOW_MINUTES="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_window_minutes", 30))')"
export SILLYHOME_PREDICTION_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_interval_seconds", 60))')"
export SILLYHOME_EXECUTION_COOLDOWN_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("execution_cooldown_seconds", 900))')"
export SILLYHOME_TIMEZONE="$(python -c 'import json; print(json.load(open("/data/options.json")).get("timezone", "Europe/Berlin"))')"
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 \ exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*' --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",
]

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

@@ -0,0 +1,570 @@
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,
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 = 5
_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 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 niedriger Zuordnungssicherheit."
),
)
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,
)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
assignment=assignment,
lifecycle=lifecycle,
now=now,
)
updated = record.model_copy(
update={
"assignment": assignment,
"manual_override": None,
"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],
) -> AssignmentSelection:
top_numeric = numeric_candidates[0] if numeric_candidates else None
top_contexts = [
candidate.entity_id
for candidate in context_candidates
][: _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"Für {display_name(actuator)} ist noch kein nutzbarer numerischer "
"Kontext verfügbar. Die Zuordnung wird automatisch erneut geprüft."
),
)
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=(
"Kontext automatisch und eindeutig zugeordnet."
if top_numeric.auto_accepted
else "Besten verfügbaren Kontext automatisch mit niedriger Sicherheit zugeordnet."
),
)
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,
)
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": "Historie wird automatisch weiter gesammelt.",
"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": "Neue Daten automatisch überwachen und nachtrainieren.",
}
),
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 automatischen Kontextzuordnung.",
"next_action": "Neue Historie automatisch auswerten.",
}
),
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": "Bei neuen Home-Assistant-Daten automatisch erneut zuordnen.",
}
),
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."

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

@@ -0,0 +1,154 @@
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 BehaviorMode(StrEnum):
SHADOW = "shadow"
ACTIVE = "active"
PAUSED = "paused"
class BehaviorStatus(StrEnum):
COLLECTING = "collecting"
TRAINED = "trained"
BLOCKED = "blocked"
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 = "Aktor auswählen; Kontext und Historie werden automatisch geprüft."
audit: list[LifecycleAuditEntry] = Field(default_factory=list)
class BehaviorPattern(BaseModel):
target_state: str = Field(min_length=1, max_length=100)
minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict)
source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime
class BehaviorPrediction(BaseModel):
target_state: str
confidence: float = Field(ge=0.0, le=1.0)
generated_at: datetime
reason: str
matching_patterns: int = Field(default=0, ge=0)
executed: bool = False
class ExecutionEvent(BaseModel):
target_state: str
executed_at: datetime
class BehaviorState(BaseModel):
mode: BehaviorMode = BehaviorMode.SHADOW
status: BehaviorStatus = BehaviorStatus.COLLECTING
approved_at: datetime | None = None
sample_count: int = Field(default=0, ge=0)
high_confidence_sample_count: int = Field(default=0, ge=0)
patterns: list[BehaviorPattern] = Field(default_factory=list)
prediction: BehaviorPrediction | None = None
last_trained_at: datetime | None = None
last_evaluated_at: datetime | None = None
last_executed_at: datetime | None = None
execution_events: list[ExecutionEvent] = Field(default_factory=list)
reason: str = "Historische Aktorhandlungen werden analysiert."
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
behavior: BehaviorState = Field(default_factory=BehaviorState)
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

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

@@ -0,0 +1,145 @@
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, ReconciliationState
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
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 ActivationRequest(BaseModel):
active: bool
@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:
record = _service(request).configure_actuator(
payload.actuator_entity_id,
enabled=payload.enabled,
)
_behavior(request).train(record.actuator_entity_id)
return _behavior(request).evaluate(record.actuator_entity_id)
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}/reconcile", response_model=ActuatorRecord)
def reconcile_actuator(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
try:
_service(request).reconcile_actuator(actuator_entity_id, trigger="manual")
_behavior(request).train(actuator_entity_id)
return _behavior(request).evaluate(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/evaluate", response_model=ActuatorRecord)
def evaluate_actuator(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).evaluate(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation(
actuator_entity_id: str,
payload: ActivationRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).set_active(actuator_entity_id, active=payload.active)
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
@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:
state = _service(request).reconcile_all(trigger=trigger)
_behavior(request).train_all()
_behavior(request).evaluate_all()
return state
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
def _behavior(request: Request) -> BehaviorEngine:
engine = getattr(request.app.state, "behavior_engine", None)
if not isinstance(engine, BehaviorEngine):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Verhaltenslernen ist nicht initialisiert.",
)
return engine

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

@@ -0,0 +1 @@
"""Learning and prediction for actuator behavior."""

481
app/behavior/engine.py Normal file
View File

@@ -0,0 +1,481 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from app.actuators.models import (
ActuatorRecord,
BehaviorMode,
BehaviorPattern,
BehaviorPrediction,
BehaviorState,
BehaviorStatus,
ExecutionEvent,
)
from app.actuators.store import ActuatorStore
from app.config import Settings
from app.ha.exceptions import HaClientError
from app.ha.history import LogbookEntry, StateHistoryPoint, StateHistorySeries
from app.ha.reader import HaReader
_MAX_PATTERNS = 500
_MAX_EXECUTION_EVENTS = 100
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
logger = logging.getLogger(__name__)
class BehaviorEngine:
def __init__(
self,
*,
ha_reader: HaReader,
store: ActuatorStore,
settings: Settings,
) -> None:
self._ha_reader = ha_reader
self._store = store
self._settings = settings
def train_all(self) -> list[ActuatorRecord]:
results: list[ActuatorRecord] = []
for record in self._store.list():
try:
results.append(self.train(record.actuator_entity_id))
except Exception:
logger.exception("Behavior training failed for %s", record.actuator_entity_id)
results.append(record)
return results
def train(self, actuator_entity_id: str) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
raw_context_ids = list(
dict.fromkeys(
[
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
)
)
context_ids = [
entity_id for entity_id in raw_context_ids if isinstance(entity_id, str)
]
if not context_ids:
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"status": BehaviorStatus.COLLECTING,
"last_trained_at": now,
"reason": "Noch kein geeigneter Kontext für Verhaltenslernen vorhanden.",
}
),
)
start = now - timedelta(days=self._settings.history_days)
history_ids = [actuator_entity_id, *context_ids]
try:
history = {
series.entity_id: series
for series in self._ha_reader.read_state_history(history_ids, start, now)
}
except (HaClientError, ValueError) as exc:
logger.warning("Behavior history unavailable for %s: %s", actuator_entity_id, exc)
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"status": BehaviorStatus.BLOCKED,
"last_trained_at": now,
"reason": f"Home-Assistant-Historie konnte nicht gelesen werden: {exc}",
}
),
)
actuator_history = history.get(actuator_entity_id)
if actuator_history is None or len(actuator_history.points) < 2:
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"status": BehaviorStatus.COLLECTING,
"sample_count": 0,
"high_confidence_sample_count": 0,
"patterns": [],
"last_trained_at": now,
"reason": "Noch keine historischen Aktorhandlungen gefunden.",
}
),
)
try:
logbook = list(self._ha_reader.read_logbook(actuator_entity_id, start, now))
except (HaClientError, ValueError) as exc:
logger.warning("Logbook unavailable for %s: %s", actuator_entity_id, exc)
logbook = []
patterns = self._build_patterns(
actuator_history=actuator_history,
context_history=history,
context_ids=context_ids,
logbook=logbook,
own_executions=record.behavior.execution_events,
)
high_confidence = sum(1 for pattern in patterns if pattern.source == "user")
status = (
BehaviorStatus.TRAINED
if len(patterns) >= self._settings.min_behavior_actions
else BehaviorStatus.COLLECTING
)
reason = (
f"{len(patterns)} Handlungen mit automatisch erfasstem Kontext gelernt."
if status is BehaviorStatus.TRAINED
else (
f"{len(patterns)} von mindestens {self._settings.min_behavior_actions} "
"benötigten Handlungen gelernt."
)
)
behavior = record.behavior.model_copy(
update={
"status": status,
"sample_count": len(patterns),
"high_confidence_sample_count": high_confidence,
"patterns": patterns[-_MAX_PATTERNS:],
"last_trained_at": now,
"reason": reason,
}
)
return self._save_behavior(record, behavior)
def evaluate_all(self) -> list[ActuatorRecord]:
results: list[ActuatorRecord] = []
for record in self._store.list():
try:
results.append(self.evaluate(record.actuator_entity_id))
except Exception:
logger.exception("Behavior evaluation failed for %s", record.actuator_entity_id)
results.append(record)
return results
def evaluate(self, actuator_entity_id: str) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
try:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
except HaClientError as exc:
logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc)
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"last_evaluated_at": now,
"prediction": None,
"reason": f"Aktueller Home-Assistant-Zustand ist nicht verfügbar: {exc}",
}
),
)
actuator = entities.get(actuator_entity_id)
if actuator is None:
return self._save_behavior(
record,
record.behavior.model_copy(
update={
"last_evaluated_at": now,
"prediction": None,
"reason": "Aktor ist aktuell nicht in Home Assistant verfügbar.",
}
),
)
current_context = {
entity_id: entities[entity_id].state
for entity_id in (
[
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
)
if entity_id and entity_id in entities and entities[entity_id].state is not None
}
prediction = predict_behavior(
record.behavior.patterns,
current_context=current_context,
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
timezone_name=self._settings.timezone,
)
behavior = record.behavior.model_copy(
update={
"last_evaluated_at": now,
"prediction": prediction,
"reason": (
prediction.reason
if prediction is not None
else "Aktuell ist kein gelerntes Handlungsmuster fällig."
),
}
)
if (
prediction is not None
and behavior.mode is BehaviorMode.ACTIVE
and prediction.confidence >= self._settings.prediction_confidence
and actuator.state != prediction.target_state
and self._cooldown_elapsed(behavior, now)
):
domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state)
if service is not None:
try:
self._ha_reader.call_service(
domain,
service,
{"entity_id": actuator_entity_id},
)
except (HaClientError, ValueError) as exc:
logger.error(
"Predicted action failed for %s: %s",
actuator_entity_id,
exc,
)
behavior = behavior.model_copy(
update={
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
}
)
return self._save_behavior(record, behavior)
event = ExecutionEvent(
target_state=prediction.target_state,
executed_at=now,
)
behavior = behavior.model_copy(
update={
"prediction": prediction.model_copy(update={"executed": True}),
"last_executed_at": now,
"execution_events": [
*behavior.execution_events,
event,
][-_MAX_EXECUTION_EVENTS:],
"reason": (
f"Vorhersage mit {prediction.confidence:.0%} Sicherheit ausgeführt."
),
}
)
else:
behavior = behavior.model_copy(
update={
"reason": (
f"Der vorhergesagte Zustand {prediction.target_state!r} "
"ist für autonomes Schalten nicht freigegeben."
)
}
)
return self._save_behavior(record, behavior)
def set_active(self, actuator_entity_id: str, *, active: bool) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
if active:
domain = actuator_entity_id.split(".", 1)[0]
if domain not in _SAFE_ACTIVE_DOMAINS:
raise ValueError(
f"Automatisches Schalten ist für die Domain {domain} nicht freigegeben."
)
if record.behavior.status is not BehaviorStatus.TRAINED:
raise ValueError("Das Verhaltensmodell hat noch nicht genügend Handlungen gelernt.")
if (
record.behavior.high_confidence_sample_count
< self._settings.min_behavior_actions
):
raise ValueError(
"Für die Freigabe fehlen noch eindeutig dir zugeordnete Handlungen. "
"Bediene den Aktor einige Male über Home Assistant."
)
mode = BehaviorMode.ACTIVE
approved_at = now
reason = "Autonomes Lernen und Schalten wurde ausdrücklich freigegeben."
else:
mode = BehaviorMode.SHADOW
approved_at = None
reason = "Shadow-Modus aktiv; Vorhersagen werden nicht ausgeführt."
behavior = record.behavior.model_copy(
update={
"mode": mode,
"approved_at": approved_at,
"reason": reason,
}
)
return self._save_behavior(record, behavior)
def _build_patterns(
self,
*,
actuator_history: StateHistorySeries,
context_history: dict[str, StateHistorySeries],
context_ids: list[str],
logbook: list[LogbookEntry],
own_executions: list[ExecutionEvent],
) -> list[BehaviorPattern]:
patterns: list[BehaviorPattern] = []
previous_state = actuator_history.points[0].state
for point in actuator_history.points[1:]:
if point.state == previous_state:
continue
previous_state = point.state
if _matches_own_execution(point, own_executions):
continue
source, weight = _action_source(point, logbook)
if source == "automation":
continue
contexts = {
entity_id: state
for entity_id in context_ids
if (state := _state_at(context_history.get(entity_id), point.timestamp)) is not None
}
local = point.timestamp.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=point.state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states=contexts,
source=source,
weight=weight,
observed_at=point.timestamp,
)
)
return patterns
def _cooldown_elapsed(self, behavior: BehaviorState, now: datetime) -> bool:
return behavior.last_executed_at is None or (
now - behavior.last_executed_at
) >= timedelta(seconds=self._settings.execution_cooldown_seconds)
def _save_behavior(
self,
record: ActuatorRecord,
behavior: BehaviorState,
) -> ActuatorRecord:
updated = record.model_copy(
update={
"behavior": behavior,
"updated_at": datetime.now(timezone.utc),
}
)
return self._store.upsert(updated)
def predict_behavior(
patterns: list[BehaviorPattern],
*,
current_context: dict[str, str | None],
now: datetime,
min_support: int,
window_minutes: int,
timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None:
if not patterns:
return None
local = now.astimezone(ZoneInfo(timezone_name))
minute_of_day = local.hour * 60 + local.minute
by_state: dict[str, list[float]] = {}
for pattern in patterns:
distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day)
if distance > window_minutes:
continue
time_score = 1.0 - (distance / max(window_minutes, 1))
weekday_score = (
1.0
if local.weekday() == pattern.weekday
else 0.5
if (local.weekday() >= 5) == (pattern.weekday >= 5)
else 0.0
)
comparable = [
(entity_id, expected)
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
/ len(comparable)
if comparable
else 0.5
)
score = pattern.weight * (
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
)
by_state.setdefault(pattern.target_state, []).append(score)
if not by_state:
return None
target_state, scores = max(
by_state.items(),
key=lambda item: (sum(item[1]), len(item[1]), item[0]),
)
support = len(scores)
confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support))
if confidence <= 0:
return None
return BehaviorPrediction(
target_state=target_state,
confidence=round(confidence, 4),
generated_at=now,
matching_patterns=support,
reason=(
f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
),
)
def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
return None
def _state_at(series: StateHistorySeries | None, timestamp: datetime) -> str | None:
if series is None:
return None
state: str | None = None
for point in series.points:
if point.timestamp > timestamp:
break
state = point.state
return state
def _action_source(
point: StateHistoryPoint,
logbook: list[LogbookEntry],
) -> tuple[str, float]:
nearest = min(
logbook,
key=lambda item: abs(item.timestamp - point.timestamp),
default=None,
)
if nearest is None or abs(nearest.timestamp - point.timestamp) > _ACTION_LOGBOOK_TOLERANCE:
return "physical_or_unknown", 0.7
if nearest.context_user_id:
return "user", 1.0
if nearest.context_domain in _AUTOMATION_CONTEXT_DOMAINS:
return "automation", 0.1
return "physical_or_unknown", 0.7
def _matches_own_execution(
point: StateHistoryPoint,
own_executions: list[ExecutionEvent],
) -> bool:
return any(
event.target_state == point.state
and abs(event.executed_at - point.timestamp) <= _OWN_ACTION_TOLERANCE
for event in own_executions
)
def _circular_minute_distance(left: int, right: int) -> int:
direct = abs(left - right)
return min(direct, 1440 - direct)

View File

@@ -10,6 +10,17 @@ class Settings:
ha_token: str | None = None ha_token: str | None = None
model_store: str = ".model_store" model_store: str = ".model_store"
automation_store: str = ".automation_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
min_behavior_actions: int = 3
prediction_confidence: float = 0.82
prediction_window_minutes: int = 30
prediction_interval_seconds: int = 60
execution_cooldown_seconds: int = 900
timezone: str = "Europe/Berlin"
@property @property
def ha_configured(self) -> bool: def ha_configured(self) -> bool:
@@ -22,4 +33,26 @@ def load_settings() -> Settings:
ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"), ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"),
model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"), model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"),
automation_store=os.getenv("SILLYHOME_AUTOMATION_STORE", ".automation_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"))
),
min_behavior_actions=max(2, int(os.getenv("SILLYHOME_MIN_BEHAVIOR_ACTIONS", "3"))),
prediction_confidence=max(
0.5,
min(0.99, float(os.getenv("SILLYHOME_PREDICTION_CONFIDENCE", "0.82"))),
),
prediction_window_minutes=max(
5, min(120, int(os.getenv("SILLYHOME_PREDICTION_WINDOW_MINUTES", "30")))
),
prediction_interval_seconds=max(
30, int(os.getenv("SILLYHOME_PREDICTION_INTERVAL_SECONDS", "60"))
),
execution_cooldown_seconds=max(
60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
),
timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
) )

View File

@@ -3,7 +3,9 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import json
import re import re
from typing import Any
from urllib.parse import quote from urllib.parse import quote
import requests import requests
@@ -18,6 +20,7 @@ from app.ha.exceptions import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$") _ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_SERVICE_PART_PATTERN = re.compile(r"^[a-z0-9_]+$")
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60 _MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
@@ -83,6 +86,70 @@ class HaClient:
) )
return payload return payload
def get_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[object]:
self._validate_period([entity_id], start_time, end_time)
start = quote(start_time.isoformat(), safe=":+")
payload = self._get_json(
f"/api/logbook/{start}",
params={
"entity": entity_id,
"end_time": end_time.isoformat(),
},
)
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"Logbook-Antwort von Home Assistant hat unerwartetes Format."
)
return payload
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> list[object]:
if not _SERVICE_PART_PATTERN.fullmatch(domain):
raise ValueError("Ungültige Service-Domain.")
if not _SERVICE_PART_PATTERN.fullmatch(service):
raise ValueError("Ungültiger Service-Name.")
payload = self._post_json(f"/api/services/{domain}/{service}", service_data)
if not isinstance(payload, list):
raise HaUnexpectedPayloadError(
"Service-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( def _get_json(
self, self,
path: str, path: str,
@@ -125,3 +192,106 @@ class HaClient:
) from exc ) from exc
return payload return payload
def _post_json(self, path: str, payload: Any) -> object:
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
try:
return response.json()
except ValueError as exc:
raise HaUnexpectedPayloadError(
"Antwort von Home Assistant ist kein gültiges JSON."
) from exc
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
@staticmethod
def _validate_period(
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> None:
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.")
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)

View File

@@ -18,6 +18,25 @@ class EntityHistorySeries(BaseModel):
points: list[NumericHistoryPoint] points: list[NumericHistoryPoint]
class StateHistoryPoint(BaseModel):
timestamp: datetime
state: str
class StateHistorySeries(BaseModel):
entity_id: str
points: list[StateHistoryPoint]
class LogbookEntry(BaseModel):
entity_id: str
timestamp: datetime
message: str = ""
context_user_id: str | None = None
context_domain: str | None = None
context_service: str | None = None
def normalize_history_payload(payload: object) -> list[EntityHistorySeries]: def normalize_history_payload(payload: object) -> list[EntityHistorySeries]:
if not isinstance(payload, list): if not isinstance(payload, list):
raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.") raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.")
@@ -33,6 +52,68 @@ def normalize_history_payload(payload: object) -> list[EntityHistorySeries]:
return sorted(normalized, key=lambda item: item.entity_id) return sorted(normalized, key=lambda item: item.entity_id)
def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]:
if not isinstance(payload, list):
raise HaUnexpectedPayloadError("History-Payload muss eine Liste sein.")
normalized: list[StateHistorySeries] = []
for raw_series in payload:
if not isinstance(raw_series, list):
raise HaUnexpectedPayloadError("History-Serie muss eine Liste sein.")
entity_id: str | None = None
points: list[StateHistoryPoint] = []
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")
if not isinstance(raw_state, str) or raw_state in {"unknown", "unavailable"}:
continue
if entity_id is None:
raise HaUnexpectedPayloadError("History-Serie enthält keine entity_id.")
timestamp = _parse_timestamp(
raw_entry.get("last_changed") or raw_entry.get("last_updated")
)
if not points or points[-1].state != raw_state:
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state))
if entity_id is not None and points:
points.sort(key=lambda point: point.timestamp)
normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
return sorted(normalized, key=lambda item: item.entity_id)
def normalize_logbook_payload(payload: object, entity_id: str) -> list[LogbookEntry]:
if not isinstance(payload, list):
raise HaUnexpectedPayloadError("Logbook-Payload muss eine Liste sein.")
entries: list[LogbookEntry] = []
for raw_entry in payload:
if not isinstance(raw_entry, dict):
raise HaUnexpectedPayloadError("Logbook-Eintrag muss ein Objekt sein.")
raw_entity_id = raw_entry.get("entity_id")
if raw_entity_id != entity_id:
continue
entries.append(
LogbookEntry(
entity_id=entity_id,
timestamp=_parse_timestamp(raw_entry.get("when")),
message=str(raw_entry.get("message") or ""),
context_user_id=_optional_string(raw_entry.get("context_user_id")),
context_domain=_optional_string(
raw_entry.get("context_domain") or raw_entry.get("domain")
),
context_service=_optional_string(raw_entry.get("context_service")),
)
)
return sorted(entries, key=lambda item: item.timestamp)
def _normalize_series(raw_series: list[object]) -> EntityHistorySeries | None: def _normalize_series(raw_series: list[object]) -> EntityHistorySeries | None:
entity_id: str | None = None entity_id: str | None = None
points: list[NumericHistoryPoint] = [] points: list[NumericHistoryPoint] = []
@@ -89,3 +170,9 @@ def _parse_timestamp(value: object) -> datetime:
if parsed.tzinfo is None: if parsed.tzinfo is None:
raise HaUnexpectedPayloadError("History-Zeitstempel muss eine Zeitzone enthalten.") raise HaUnexpectedPayloadError("History-Zeitstempel muss eine Zeitzone enthalten.")
return parsed return parsed
def _optional_string(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)

View File

@@ -14,6 +14,12 @@ class HaState(BaseModel):
class HaEntitySummary(BaseModel): class HaEntitySummary(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
state: str | None = None
state_class: str | None = None state_class: str | None = None
device_class: str | None = None device_class: str | None = None
unit_of_measurement: 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

@@ -3,12 +3,24 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
import logging
from app.ha.exceptions import HaClientError
from app.ha.client import HaClient from app.ha.client import HaClient
from app.ha.discovery import DiscoveredEntity, discover_entities from app.ha.discovery import DiscoveredEntity, discover_entities
from app.ha.history import EntityHistorySeries, normalize_history_payload from app.ha.history import (
EntityHistorySeries,
LogbookEntry,
StateHistorySeries,
normalize_history_payload,
normalize_logbook_payload,
normalize_state_history_payload,
)
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
logger = logging.getLogger(__name__)
class HaReader: class HaReader:
def __init__(self, client: HaClient) -> None: def __init__(self, client: HaClient) -> None:
@@ -16,6 +28,16 @@ class HaReader:
def read_entities(self) -> Sequence[HaEntitySummary]: def read_entities(self) -> Sequence[HaEntitySummary]:
entities = self._client.list_entities() 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] = [] summaries: list[HaEntitySummary] = []
for item in entities: for item in entities:
raw_entity_id = item.get("entity_id") raw_entity_id = item.get("entity_id")
@@ -25,13 +47,24 @@ class HaReader:
domain = entity_id.split(".", 1)[0] domain = entity_id.split(".", 1)[0]
raw_attributes = item.get("attributes") or {} raw_attributes = item.get("attributes") or {}
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {} attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
metadata = metadata_by_entity.get(entity_id, {})
summaries.append( summaries.append(
HaEntitySummary( HaEntitySummary(
entity_id=entity_id, entity_id=entity_id,
domain=domain, domain=domain,
state=_optional_str(item.get("state")),
state_class=_optional_str(attributes.get("state_class")), state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")), device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")), 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 return summaries
@@ -52,6 +85,32 @@ class HaReader:
payload = self._client.get_history(entity_ids, start_time, end_time) payload = self._client.get_history(entity_ids, start_time, end_time)
return normalize_history_payload(payload) return normalize_history_payload(payload)
def read_state_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> Sequence[StateHistorySeries]:
payload = self._client.get_history(entity_ids, start_time, end_time)
return normalize_state_history_payload(payload)
def read_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> Sequence[LogbookEntry]:
payload = self._client.get_logbook(entity_id, start_time, end_time)
return normalize_logbook_payload(payload, entity_id)
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> Sequence[object]:
return self._client.call_service(domain, service, service_data)
def _optional_str(value: object) -> str | None: def _optional_str(value: object) -> str | None:
if value is None or value == "": if value is None or value == "":

View File

@@ -1,4 +1,5 @@
from contextlib import asynccontextmanager import asyncio
from contextlib import asynccontextmanager, suppress
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@@ -7,9 +8,11 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles 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.entities import router as entities_router
from app.api.v1.automations import router as automations_router from app.behavior.engine import BehaviorEngine
from app.automations.store import AutomationStore
from app.config import load_settings from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
@@ -22,10 +25,16 @@ from backend.routes.ml import init_ml_routes
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings settings = app.state.settings
client: HaClient | None = None client: HaClient | None = None
reconcile_task: asyncio.Task[None] | None = None
prediction_task: asyncio.Task[None] | None = None
app.state.registry = ModelRegistry(settings.model_store) 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"): if hasattr(app.state, "ha_reader"):
del app.state.ha_reader del app.state.ha_reader
if hasattr(app.state, "actuator_service"):
del app.state.actuator_service
if hasattr(app.state, "behavior_engine"):
del app.state.behavior_engine
if settings.ha_configured: if settings.ha_configured:
client = HaClient( client = HaClient(
settings=HaClientSettings( settings=HaClientSettings(
@@ -34,9 +43,33 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
) )
) )
app.state.ha_reader = HaReader(client=client) app.state.ha_reader = HaReader(client=client)
app.state.actuator_service = ActuatorReconciliationService(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
registry=app.state.registry,
settings=settings,
)
app.state.behavior_engine = BehaviorEngine(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
settings=settings,
)
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
await asyncio.to_thread(app.state.behavior_engine.train_all)
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
prediction_task = asyncio.create_task(_periodic_prediction(app))
try: try:
yield yield
finally: finally:
if reconcile_task is not None:
reconcile_task.cancel()
with suppress(asyncio.CancelledError):
await reconcile_task
if prediction_task is not None:
prediction_task.cancel()
with suppress(asyncio.CancelledError):
await prediction_task
if client is not None: if client is not None:
client.close() client.close()
@@ -44,13 +77,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.3.0", version="0.5.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(entities_router) 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) init_ml_routes(app, model_store=app.state.settings.model_store)
STATIC_DIR = Path(__file__).with_name("static") STATIC_DIR = Path(__file__).with_name("static")
@@ -65,3 +98,24 @@ def health() -> dict[str, str]:
@app.get("/") @app.get("/")
def root() -> FileResponse: def root() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html") 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")
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all)
async def _periodic_prediction(app: FastAPI) -> None:
while True:
await asyncio.sleep(app.state.settings.prediction_interval_seconds)
engine = getattr(app.state, "behavior_engine", None)
if not isinstance(engine, BehaviorEngine):
continue
await asyncio.to_thread(engine.evaluate_all)

View File

@@ -20,6 +20,8 @@ class ModelRegistry:
def __init__(self, root: str | Path) -> None: def __init__(self, root: str | Path) -> None:
self._root = Path(root).resolve() self._root = Path(root).resolve()
self._root.mkdir(parents=True, exist_ok=True) 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._artifacts: dict[str, TrainedArtifact] = {}
self._lock = RLock() self._lock = RLock()
self._load_existing() self._load_existing()
@@ -43,10 +45,27 @@ class ModelRegistry:
raise KeyError(f"Artifact '{artifact_id}' nicht registriert.") raise KeyError(f"Artifact '{artifact_id}' nicht registriert.")
return self._artifacts[artifact_id] 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]: def list_models(self) -> Iterable[TrainedArtifact]:
with self._lock: with self._lock:
return [self._artifacts[key] for key in sorted(self._artifacts)] 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: def _load_existing(self) -> None:
for source in sorted(self._root.glob("*.json")): for source in sorted(self._root.glob("*.json")):
try: try:

View File

@@ -7,143 +7,284 @@
<style> <style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; } :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
body { margin: 0; } body { margin: 0; }
header { padding: 20px; background: linear-gradient(135deg,#142b3a,#193f36); } header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
h1,h2 { margin: 0 0 12px; } h1,h2,h3 { margin: 0 0 12px; }
header p { margin: 4px 0; color: #b9c9d6; } header p { margin: 5px 0; color: #c3d1dc; }
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(310px,1fr)); gap: 14px; padding: 14px; } 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; } section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
.wide { grid-column: 1 / -1; } .wide { grid-column: 1 / -1; }
.ok { color: #66dfa9; } .bad { color: #ff8f8f; } .ok { color: #66dfa9; }
.warn { color: #f3c969; }
.bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; } 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; } select,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; }
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; } button.secondary { background: #37495c; }
pre { white-space: pre-wrap; max-height: 310px; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; } button.danger { background: #7b3434; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; } table { width: 100%; border-collapse: collapse; font-size: .92rem; }
td,th { padding: 7px; border-bottom: 1px solid #2d3a47; text-align: left; } td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
.notice { border-left: 4px solid #e8b34b; padding-left: 10px; } ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid #66dfa9; padding-left: 10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.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; }
.muted { color:#9fb0be; }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>SillyHome Next</h1> <h1>SillyHome Next</h1>
<p>Lokale Home-Assistant-Analyse, Vorhersagen und kontrollierte Automation-Entwürfe.</p> <p>Du wählst nur die Aktoren. SillyHome findet Kontext, lernt Gewohnheiten und trifft Vorhersagen im Shadow-Modus.</p>
<p class="notice">Sicherheitsmodus: Entwürfe werden niemals automatisch in Home Assistant ausgeführt.</p> <p class="notice">Geschaltet wird erst nach deiner ausdrücklichen Freigabe pro Aktor.</p>
</header> </header>
<main> <main>
<section> <section>
<h2>Systemstatus</h2> <h2>Systemstatus</h2>
<div id="status">Prüfung läuft ...</div> <div id="status">Prüfung läuft ...</div>
<button class="secondary" onclick="loadStatus()">Neu laden</button> <div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
</section> </section>
<section> <section>
<h2>Entity Discovery</h2> <h2>Aktor freigeben</h2>
<label for="domain">Domain (optional)</label> <p class="muted">Nach der Auswahl analysiert SillyHome automatisch passende Sensoren, Zustände und Historie.</p>
<input id="domain" placeholder="sensor"> <label for="actuator-select">Home-Assistant-Aktor</label>
<button onclick="discover()">HA-Entities analysieren</button> <select id="actuator-select"></select>
<pre id="discovery">Noch nicht geladen.</pre> <button onclick="configureActuator()">Auswählen und Lernen starten</button>
</section> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
<section>
<h2>Modell trainieren</h2>
<label for="train-model">Modell-ID</label><input id="train-model" value="home-model">
<label for="train-sensor">Sensor</label><input id="train-sensor" placeholder="sensor.temperatur">
<label for="train-feature">Merkmal</label><input id="train-feature" value="value">
<label for="train-values">Messwerte, komma-getrennt</label><input id="train-values" placeholder="19,20,21">
<button onclick="train()">Trainieren</button>
<pre id="training">Bereit.</pre>
</section>
<section>
<h2>Vorhersage</h2>
<label for="predict-model">Modell-ID</label><input id="predict-model" value="home-model">
<label for="predict-sensor">Sensor</label><input id="predict-sensor" placeholder="sensor.temperatur">
<label for="predict-feature">Merkmal</label><input id="predict-feature" value="value">
<label for="predict-value">Aktueller Wert</label><input id="predict-value" type="number" step="any">
<button onclick="predict()">Vorhersagen und erklären</button>
<pre id="prediction">Bereit.</pre>
</section> </section>
<section class="wide"> <section class="wide">
<h2>Automation-Entwurf</h2> <h2>Ausgewählte Aktoren</h2>
<p>Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.</p> <div id="configured-actuators">Noch nicht geladen.</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px"> </section>
<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> <section class="wide">
<div><label for="below">Unter Grenzwert</label><input id="below" type="number" value="10"></div> <h2>Automatisch erkannter Lernkontext</h2>
<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 id="actuator-detail" class="muted">Wähle einen Aktor aus der Liste.</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> </section>
</main> </main>
<script> <script>
const pretty = value => JSON.stringify(value, null, 2); const escapeHtml = value => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
let currentActuatorId = null;
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = await response.json().catch(() => ({})); const body = response.status === 204 ? null : await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.detail || `${response.status} ${response.statusText}`); if (!response.ok) throw new Error(body?.detail || `${response.status} ${response.statusText}`);
return body; return body;
} }
async function loadStatus() {
const box=document.getElementById("status"); function lifecycleLabel(record) {
const labels = {
trained: "lernt",
pending_history: "sammelt Historie",
pending_assignment: "sucht Kontext",
review_required: "geringe Zuordnungssicherheit",
archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden",
};
return labels[record.lifecycle.status] || record.lifecycle.status;
}
function statusClass(record) {
if (record.lifecycle.status === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
return "bad";
}
function behaviorLabel(record) {
if (record.behavior.mode === "active") return "aktiv freigegeben";
if (record.behavior.status === "trained") return "Shadow-Vorhersage";
if (record.behavior.status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen";
}
async function loadOverview() {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
try { try {
const [health, ml, models]=await Promise.all([api("health"),api("ml/health"),api("ml/models")]); const [health, ml, reconciliation, actuators] = await Promise.all([
box.innerHTML=`<p class="ok">API und ML bereit</p><p>Modelle: ${models.models.length}</p>`; api("health"),
} catch(e) { box.innerHTML=`<p class="bad">${e.message}</p>`; } api("ml/health"),
api("v1/actuators/reconciliation/state"),
api("v1/actuators"),
]);
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
`<span class="chip">Aktoren: ${actuators.length}</span>`,
`<span class="chip">Aktive Modelle: ${reconciliation.trained_models}</span>`,
].join("");
} catch (error) {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = "";
} }
async function discover() { await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
const out=document.getElementById("discovery"), domain=document.getElementById("domain").value.trim(); }
out.textContent="Lade ...";
async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select");
try { try {
const rows=await api(`v1/discovery?learnable=true${domain?`&domain=${encodeURIComponent(domain)}`:""}`); const [available, configured] = await Promise.all([
out.textContent=pretty({learnable_entities:rows.length, entities:rows.slice(0,100)}); api("v1/actuators/discovery"),
} catch(e) { out.textContent=e.message; } api("v1/actuators"),
]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
const choices = available.filter(entity => !configuredIds.has(entity.entity_id));
select.innerHTML = choices.length
? choices.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`).join("")
: "<option value=''>Alle erkannten Aktoren sind ausgewählt</option>";
} catch (error) {
select.innerHTML = `<option value="">${escapeHtml(error.message)}</option>`;
} }
async function train() { }
const out=document.getElementById("training");
async function configureActuator() {
const actuatorId = document.getElementById("actuator-select").value;
const result = document.getElementById("actuator-config-result");
if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ...";
try { try {
const values=document.getElementById("train-values").value.split(",").map(Number).filter(Number.isFinite); const record = await api("v1/actuators", {
if (!values.length) throw new Error("Mindestens einen Messwert eingeben."); method: "POST",
const sensor=document.getElementById("train-sensor").value.trim(), feature=document.getElementById("train-feature").value.trim(); body: JSON.stringify({actuator_entity_id: actuatorId}),
const samples=values.map(value=>({sensor_id:sensor,values:{[feature]:value}})); });
out.textContent=pretty(await api("ml/retrain",{method:"POST",body:JSON.stringify({modelId:document.getElementById("train-model").value,samples})})); currentActuatorId = record.actuator_entity_id;
await loadStatus(); result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
} catch(e) { out.textContent=e.message; } await loadOverview();
await showActuator(record.actuator_entity_id);
} catch (error) {
result.textContent = error.message;
} }
async function predict() { }
const out=document.getElementById("prediction");
async function loadConfiguredActuators() {
const box = document.getElementById("configured-actuators");
try { try {
const feature=document.getElementById("predict-feature").value.trim(); const rows = await api("v1/actuators");
out.textContent=pretty(await api("ml/predict",{method:"POST",body:JSON.stringify({ box.innerHTML = rows.length ? `
modelId:document.getElementById("predict-model").value, <table>
sensor_id:document.getElementById("predict-sensor").value.trim(), <tr><th>Aktor</th><th>Verhaltensmodell</th><th>Handlungen</th><th>Vorhersage</th><th></th></tr>
values:{[feature]:Number(document.getElementById("predict-value").value)} ${rows.map(record => `
})})); <tr>
} catch(e) { out.textContent=e.message; } <td>${escapeHtml(record.actuator_entity_id)}</td>
<td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td>
<td>${record.behavior.sample_count}</td>
<td>${record.behavior.prediction
? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
: "-"}</td>
<td>
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details</button>
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
</td>
</tr>
`).join("")}
</table>` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) {
box.textContent = error.message;
} }
async function createProposal() { }
async function showActuator(actuatorId) {
currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail");
try { try {
await api("v1/automations/proposals",{method:"POST",body:JSON.stringify({ const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
alias:document.getElementById("alias").value, const contexts = [
description:"Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.", record.assignment.selected_numeric_entity_id,
trigger:{entity_id:document.getElementById("trigger").value,below:Number(document.getElementById("below").value)}, ...record.assignment.selected_context_entity_ids,
action:{service:document.getElementById("service").value,entity_id:document.getElementById("target").value,data:{}} ].filter(Boolean);
})}); const evidence = [...record.numeric_candidates, ...record.context_candidates]
await loadProposals(); .filter(candidate => contexts.includes(candidate.entity_id))
} catch(e) { alert(e.message); } .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join("");
const prediction = record.behavior.prediction;
const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
: record.behavior.status === "trained"
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
box.innerHTML = `
<div class="grid-two">
<div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
<p><strong>Zuordnung:</strong> automatisch</p>
<p><strong>Sicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
<p><strong>Bewertung:</strong> ${escapeHtml(record.assignment.reason)}</p>
</div>
<div>
<h3>Verhaltensmodell</h3>
<p><strong>Modus:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
<p><strong>Status:</strong> ${escapeHtml(record.behavior.reason)}</p>
${activationButton}
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Vorhersage jetzt prüfen</button>
</div>
</div>
<h3>Aktuelle Vorhersage</h3>
${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${prediction.executed ? "<span class='ok'>Ausgeführt.</span>" : "<span class='muted'>Nicht ausgeführt.</span>"}</p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<h3>Automatisch verwendeter Kontext</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
`;
} catch (error) {
box.textContent = 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(e) { alert(e.message); }
} }
async function loadProposals() {
const box=document.getElementById("proposals"); async function evaluateActuator(actuatorId) {
try { try {
const rows=await api("v1/automations/proposals"); await api(`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`, {method: "POST"});
box.innerHTML=rows.length?`<table><tr><th>Name</th><th>Status</th><th>Aktion</th></tr>${rows.map(x=>`<tr><td>${x.alias}</td><td>${x.status}</td><td>${x.status==="draft"?`<button onclick="decide('${x.proposal_id}',${x.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${x.proposal_id}',${x.revision},'reject')">Ablehnen</button>`:`${x.status==="approved"?`<a href="v1/automations/proposals/${x.proposal_id}/yaml">YAML laden</a>`:"-"}`}</td></tr>`).join("")}</table>`:"<p>Keine Entwürfe.</p>"; await loadConfiguredActuators();
} catch(e) { box.textContent=e.message; } await showActuator(actuatorId);
} catch (error) {
alert(error.message);
} }
loadStatus(); loadProposals(); }
async function setActivation(actuatorId, active) {
const question = active
? `${actuatorId} wirklich für autonomes Lernen und Schalten freigeben?`
: `${actuatorId} wieder in den Shadow-Modus setzen?`;
if (!confirm(question)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, {
method: "POST",
body: JSON.stringify({active}),
});
await loadConfiguredActuators();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function removeActuator(actuatorId) {
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
if (currentActuatorId === actuatorId) {
currentActuatorId = null;
document.getElementById("actuator-detail").textContent = "Wähle einen Aktor aus der Liste.";
}
await loadOverview();
} catch (error) {
alert(error.message);
}
}
loadOverview();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -9,9 +9,21 @@ services:
environment: environment:
SILLYHOME_MODEL_STORE: /app/data/models SILLYHOME_MODEL_STORE: /app/data/models
SILLYHOME_AUTOMATION_STORE: /app/data/automations 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
SILLYHOME_MIN_BEHAVIOR_ACTIONS: 3
SILLYHOME_PREDICTION_CONFIDENCE: 0.82
SILLYHOME_PREDICTION_WINDOW_MINUTES: 30
SILLYHOME_PREDICTION_INTERVAL_SECONDS: 60
SILLYHOME_EXECUTION_COOLDOWN_SECONDS: 900
SILLYHOME_TIMEZONE: Europe/Berlin
volumes: volumes:
- model-data:/app/data/models - model-data:/app/data/models
- automation-data:/app/data/automations - automation-data:/app/data/automations
- actuator-data:/app/data/actuators
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp
@@ -24,3 +36,4 @@ services:
volumes: volumes:
model-data: model-data:
automation-data: automation-data:
actuator-data:

View File

@@ -1,14 +1,6 @@
# Automation-Vorschläge # Keine manuell erzeugten Automationen
SillyHome Next führt Automationen niemals automatisch aus. Der Workflow ist: Seit `v0.5.0` erstellt SillyHome Next keine YAML-Automationen und bietet keinen
Regel- oder Trigger-Editor mehr an. Der produktive Ablauf besteht aus
1. Vorschlag als `draft` erstellen. Aktorauswahl, automatischem Verhaltenslernen, Shadow-Vorhersage und einer
2. Inhalt und Ziel-Entity prüfen. separaten Ausführungsfreigabe pro Aktor.
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.

View File

@@ -14,6 +14,15 @@ Trainings- und Erklärungsprozesse.
- `actuator`: mögliche Automationsziele, nicht als Trainingssensor verwendet - `actuator`: mögliche Automationsziele, nicht als Trainingssensor verwendet
- `unsupported`: noch nicht klassifizierte Entity-Typen - `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: Optionale Query-Parameter:
- `domain=sensor` kann mehrfach angegeben werden - `domain=sensor` kann mehrfach angegeben werden
@@ -32,7 +41,8 @@ Historische Zustände werden über Home Assistants
Die Normalisierung übernimmt nur endliche numerische Zustände. `unknown`, Die Normalisierung übernimmt nur endliche numerische Zustände. `unknown`,
`unavailable`, nichtnumerische Werte, `NaN` und unendliche Werte werden nicht `unavailable`, nichtnumerische Werte, `NaN` und unendliche Werte werden nicht
als Trainingsdaten verwendet. Ergebnisse werden je Entity chronologisch als Trainingsdaten verwendet. Ergebnisse werden je Entity chronologisch
sortiert. sortiert. Binäre Kontext-Entities werden bewusst nicht in numerische
Trainingsreihen konvertiert.
## Datenschutz und Betrieb ## Datenschutz und Betrieb

View File

@@ -1,7 +1,7 @@
# ML-Serving-API # ML-Serving-API
Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen
Modell-Artefakt- und Vorhersage-Schnittstelle. Modell-Artefakt-, Vorhersage- und aktor-zentrierten Lifecycle-Schnittstelle.
Das Serving verwendet ein lokal trainiertes statistisches Baseline-Modell. Das Serving verwendet ein lokal trainiertes statistisches Baseline-Modell.
@@ -15,6 +15,8 @@ Das Serving verwendet ein lokal trainiertes statistisches Baseline-Modell.
- Einzelvorhersage: `/predict` - Einzelvorhersage: `/predict`
- Batchvorhersage: `/batch` - Batchvorhersage: `/batch`
Die aktor-zentrierte API liegt unter `/v1/actuators`.
Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und
ML-Routen in derselben Anwendung bereit. ML-Routen in derselben Anwendung bereit.
@@ -168,14 +170,56 @@ Batch-Vorhersage für mehrere Sensorwerte.
- `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig. - `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig.
- `503 Service Unavailable`: Registry ist nicht initialisiert. - `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 Aktor. Das System ermittelt passende Messwerte und
Kontext-Entities vollständig automatisch, trainiert bei ausreichender Historie
ein Modell und liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zur
Diagnose zurück.
**Request**
```json
{
"actuator_entity_id": "light.abstellkammer",
"enabled": true
}
```
### `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.
### `POST /v1/actuators/{actuator_entity_id}/evaluate`
Erstellt aus aktuellem Kontext eine neue Shadow- oder Aktiv-Vorhersage. Im
Shadow-Modus wird niemals geschaltet.
### `POST /v1/actuators/{actuator_entity_id}/activation`
```json
{"active": true}
```
Aktiviert autonomes Schalten erst nach ausreichendem Training und nur für
erlaubte Aktor-Domains. Mit `false` wird der Aktor sofort wieder in den
Shadow-Modus versetzt.
## Betrieb ## Betrieb
Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktor- und
werden über `/ml/retrain`, `RetrainingService` oder direkt über Reconciliation-Zustände liegen atomisch in
`ModelRegistry.register(...)` registriert. Die Registry speichert validiertes `SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`,
JSON atomisch und lädt es beim Neustart. Die API sollte nur in einem `RetrainingService` oder den aktor-zentrierten Lifecycle registriert. Die API
vertrauenswürdigen Netz oder hinter einem authentifizierenden Reverse Proxy sollte nur in einem vertrauenswürdigen Netz oder hinter einem
erreichbar sein. authentifizierenden Reverse Proxy erreichbar sein.
## Verweise ## Verweise

View File

@@ -1,68 +1,51 @@
# ML Training- und Evaluations-Workflow # Verhaltenslernen und Vorhersage
SillyHome Next trainiert ein lokales statistisches Baseline-Modell pro Sensor Seit `v0.5.0` ist der produktive Lernpfad aktor-zentriert. Nutzer wählen nur
und Merkmal. Es benötigt keine Cloud und keine externe ML-Laufzeit. einen Aktor; Sensoren, Kontext und Modelle werden automatisch verwaltet.
## 1. Daten sammeln ## Datengrundlage
Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen. Für jeden Aktor lädt SillyHome Next:
## 2. Statistisches Artefakt erzeugen - dessen Zustandswechsel aus der Home-Assistant-Historie
- Logbook-Einträge zur Herkunft der Handlung
- automatisch zugeordnete Mess- und Kontext-Entities
- deren Zustand zum Zeitpunkt der Handlung
```python Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen erhalten das
store = FeatureStore() höchste Gewicht. Erkannte Automations- und Script-Aktionen werden verworfen.
store.add(FeatureVector(sensor_id="sensor.kitchen", values={"temperature": 21.0})) Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das
pipeline = TrainingPipeline(store) Shadow-Modell ergänzen, reichen allein aber nicht zur Aktivierung.
artifact = pipeline.run("my_artifact")
pipeline.export("my_artifact")
```
`TrainingPipeline.run(...)` berechnet für jedes numerische Merkmal: ## Modell
- Stichprobenzahl Das lokale Modell speichert pro beobachteter Handlung:
- Mittelwert und Standardabweichung
- Minimum und Maximum
- linearen Trend mit Steigung und Achsenabschnitt
Die nächste Vorhersage kombiniert den letzten beobachteten Wert mit der - Zielzustand
trainierten Trendsteigung. Die Confidence berücksichtigt Datenmenge und - lokale Tageszeit
Stabilität. - Wochentag
- Kontextzustände
- Herkunft und Gewicht
## 3. Modell evaluieren Eine Vorhersage bewertet zeitliche Nähe, Wochentagsmuster und aktuellen
Kontext. Mehrere passende historische Handlungen erhöhen die Confidence.
```python ## Betriebsstufen
evaluator = Evaluator(pipeline)
report = evaluator.evaluate(artifact.artifact_id, validation_samples)
```
Der Report enthält echte numerische Vergleichsmetriken: 1. `collecting`: Noch nicht genügend Handlungen vorhanden.
- `artifact_id` 2. `shadow`: Modell ist trainiert; Vorhersagen werden angezeigt, aber nicht ausgeführt.
- `sample_size` 3. `active`: Nutzer hat den Aktor ausdrücklich freigegeben.
- `mae` (Mean Absolute Error)
- `rmse` (Root Mean Squared Error)
- `coverage` für den Anteil auswertbarer Merkmale
## 4. Modell registrieren Die Aktivierung verlangt genügend eindeutig einem Benutzer zugeordnete
Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains:
`light`, `switch`, `fan`, `humidifier` und `cover`.
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. ## Schutzmechanismen
## 5. Retraining ausführen - explizite Freigabe pro Aktor
- konfigurierbare Mindestkonfidenz
`RetrainingService.retrain(...)` führt genau einen Trainingslauf aus und ersetzt - Cooldown zwischen Schaltungen
ein vorhandenes Artefakt mit derselben ID atomisch in der Registry: - keine Ausführung bei bereits erreichtem Zielzustand
- keine Ausführung unbekannter Zustände oder riskanter Domains
```python - eigene Schaltungen werden beim nächsten Training herausgefiltert
service = RetrainingService(registry) - bekannte Automation-/Script-Aktionen werden nicht als Nutzerverhalten gelernt
result = service.retrain("home-model", vectors)
```
Scheduler, Cronjobs oder Home-Assistant-Automationen können alternativ die
zustandslose Funktion `retrain_model(registry, artifact_id, vectors)` aufrufen.
Der Service startet bewusst keinen eigenen Hintergrundprozess. Über
`POST /ml/retrain` kann derselbe Ablauf per API angestoßen werden.
## Hinweise
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.
- Nur endliche numerische Werte werden trainiert.
- `coverage` bleibt im Bereich 0 bis 1.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.3.0" version = "0.5.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

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 (
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_uses_best_automatic_mapping_when_ambiguous(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.assignment.selected_numeric_entity_id == "sensor.garage_energy"
assert record.lifecycle.status is LifecycleStatus.TRAINED
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(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)
configured = service.configure_actuator("light.abstellkammer")
legacy = configured.model_copy(
update={
"manual_override": ManualOverride(
numeric_entity_id="sensor.abstellkammer_power",
context_entity_ids=[],
note="Alte manuelle Zuordnung",
)
}
)
service._store.upsert(legacy)
restarted = _service(tmp_path, entities, history)
record = restarted.reconcile_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance"
assert record.assignment.source.value == "automatic"
assert record.manual_override is None

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

@@ -0,0 +1,186 @@
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.behavior.engine import BehaviorEngine
from app.config import Settings
from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities
from app.ha.history import (
EntityHistorySeries,
LogbookEntry,
NumericHistoryPoint,
StateHistorySeries,
)
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 read_state_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[StateHistorySeries]:
return []
def read_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[LogbookEntry]:
return []
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> list[object]:
return []
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,
)
app.state.behavior_engine = BehaviorEngine(
ha_reader=app.state.ha_reader,
store=app.state.actuator_store,
settings=settings,
)
def test_actuator_api_configures_reconciles_and_removes(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"
assert listed.json()[0]["behavior"]["mode"] == "shadow"
evaluation = client.post("/v1/actuators/light.abstellkammer/evaluate")
assert evaluation.status_code == 200
premature_activation = client.post(
"/v1/actuators/light.abstellkammer/activation",
json={"active": True},
)
assert premature_activation.status_code == 409
reconciliation = client.post("/v1/actuators/reconciliation/run")
assert reconciliation.status_code == 200
assert reconciliation.json()["trained_models"] == 1
removed = client.delete("/v1/actuators/light.abstellkammer")
assert removed.status_code == 204
assert client.get("/v1/actuators").json() == []
def test_manual_override_endpoint_is_not_exposed(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post(
"/v1/actuators/light.abstellkammer/override",
json={"numeric_entity_id": "sensor.abstellkammer_illuminance"},
)
assert response.status_code == 404

View File

@@ -1,59 +1,17 @@
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.automations.store import AutomationStore
from app.main import app from app.main import app
def _payload() -> dict[str, object]: def test_automation_api_is_not_exposed() -> None:
return { with TestClient(app) as client:
"alias": "Licht bei Dunkelheit", response = client.post(
"description": "Schaltet das Flurlicht unter dem Helligkeitsgrenzwert ein.", "/v1/automations/proposals",
json={
"alias": "Nicht mehr verfügbar",
"trigger": {"entity_id": "sensor.hall_illuminance", "below": 10}, "trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
"action": { "action": {"service": "light.turn_on", "entity_id": "light.hall"},
"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
assert response.status_code == 404
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

@@ -75,9 +75,15 @@ def test_entities_returns_reader_data() -> None:
{ {
"entity_id": "sensor.temperature", "entity_id": "sensor.temperature",
"domain": "sensor", "domain": "sensor",
"state": None,
"state_class": None, "state_class": None,
"device_class": None, "device_class": None,
"unit_of_measurement": None, "unit_of_measurement": None,
"friendly_name": None,
"area_id": None,
"area_name": None,
"device_id": None,
"device_name": None,
} }
] ]

View File

@@ -0,0 +1,261 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from app.actuators.models import BehaviorMode, BehaviorStatus
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
from app.config import Settings
from app.ha.history import (
LogbookEntry,
StateHistoryPoint,
StateHistorySeries,
)
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
class FakeBehaviorReader(HaReader):
def __init__(
self,
*,
entities: list[HaEntitySummary],
history: list[StateHistorySeries],
logbook: list[LogbookEntry],
) -> None:
self.entities = entities
self.history = history
self.logbook = logbook
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
def read_entities(self) -> list[HaEntitySummary]:
return list(self.entities)
def read_state_history(
self,
entity_ids: list[str],
start_time: datetime,
end_time: datetime,
) -> list[StateHistorySeries]:
return [series for series in self.history if series.entity_id in entity_ids]
def read_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[LogbookEntry]:
return [entry for entry in self.logbook if entry.entity_id == entity_id]
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> list[object]:
self.service_calls.append((domain, service, service_data))
return []
def _settings(tmp_path: Path) -> Settings:
return Settings(
actuator_store=str(tmp_path / "actuators"),
model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"),
history_days=14,
min_behavior_actions=3,
prediction_confidence=0.8,
prediction_window_minutes=30,
execution_cooldown_seconds=900,
timezone="Europe/Berlin",
)
def _reader(now: datetime) -> FakeBehaviorReader:
actuator_points: list[StateHistoryPoint] = []
logbook: list[LogbookEntry] = []
for days_ago in (3, 2, 1):
action_at = now - timedelta(days=days_ago)
actuator_points.extend(
[
StateHistoryPoint(timestamp=action_at - timedelta(minutes=1), state="off"),
StateHistoryPoint(timestamp=action_at, state="on"),
StateHistoryPoint(timestamp=action_at + timedelta(hours=6), state="off"),
]
)
logbook.extend(
[
LogbookEntry(
entity_id="light.office",
timestamp=action_at,
message="turned on",
context_user_id="user-1",
),
LogbookEntry(
entity_id="light.office",
timestamp=action_at + timedelta(hours=6),
message="turned off",
context_domain="automation",
context_service="trigger",
),
]
)
actuator_points.sort(key=lambda point: point.timestamp)
context_points = [
StateHistoryPoint(timestamp=now - timedelta(days=7), state="on"),
]
return FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[
StateHistorySeries(entity_id="light.office", points=actuator_points),
StateHistorySeries(
entity_id="binary_sensor.office_presence",
points=context_points,
),
],
logbook=logbook,
)
def _engine(tmp_path: Path, now: datetime) -> tuple[BehaviorEngine, FakeBehaviorReader]:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
store.upsert(
record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
)
}
)
)
reader = _reader(now)
return (
BehaviorEngine(ha_reader=reader, store=store, settings=settings),
reader,
)
def test_engine_trains_predicts_in_shadow_and_executes_only_after_approval(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
engine, reader = _engine(tmp_path, now)
trained = engine.train("light.office")
shadow = engine.evaluate("light.office")
assert trained.behavior.status is BehaviorStatus.TRAINED
assert trained.behavior.sample_count == 3
assert trained.behavior.high_confidence_sample_count == 3
assert shadow.behavior.mode is BehaviorMode.SHADOW
assert shadow.behavior.prediction is not None
assert shadow.behavior.prediction.target_state == "on"
assert reader.service_calls == []
engine.set_active("light.office", active=True)
active = engine.evaluate("light.office")
assert active.behavior.mode is BehaviorMode.ACTIVE
assert active.behavior.prediction is not None
assert active.behavior.prediction.executed is True
assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.office"})
]
def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
engine, _ = _engine(tmp_path, now)
trained = engine.train("light.office")
assert {pattern.target_state for pattern in trained.behavior.patterns} == {"on"}
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("lock.front_door")
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={"status": BehaviorStatus.TRAINED}
)
}
)
)
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
with pytest.raises(ValueError, match="nicht freigegeben"):
engine.set_active("lock.front_door", active=True)
def test_active_mode_requires_user_attributed_actions(tmp_path: Path) -> None:
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"status": BehaviorStatus.TRAINED,
"sample_count": 3,
"high_confidence_sample_count": 0,
}
)
}
)
)
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
with pytest.raises(ValueError, match="eindeutig dir zugeordnete"):
engine.set_active("light.office", active=True)
@pytest.mark.parametrize(
("domain", "state", "service"),
[
("light", "on", "turn_on"),
("switch", "off", "turn_off"),
("cover", "open", "open_cover"),
("cover", "closed", "close_cover"),
("lock", "unlocked", None),
],
)
def test_service_for_state_is_strictly_allowlisted(
domain: str,
state: str,
service: str | None,
) -> None:
assert service_for_state(domain, state) == service
def test_prediction_requires_temporal_support() -> None:
assert predict_behavior(
[],
current_context={},
now=datetime.now(timezone.utc),
min_support=3,
window_minutes=30,
) is None

View File

@@ -88,6 +88,55 @@ def test_get_history_calls_home_assistant_history_api() -> None:
assert call.kwargs["params"]["end_time"] == "2026-06-02T00:00:00+00:00" 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",
}
}
def test_get_logbook_filters_entity_and_period() -> None:
response = _response(payload=[{"entity_id": "light.office"}])
client = _client_with_response(response)
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
payload = client.get_logbook("light.office", start, end)
assert payload == [{"entity_id": "light.office"}]
call = client._session.get.call_args # type: ignore[attr-defined]
assert "/api/logbook/2026-06-01T00:00:00+00:00" in call.args[0]
assert call.kwargs["params"]["entity"] == "light.office"
def test_call_service_posts_to_home_assistant() -> None:
response = _response(payload=[])
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
client._session.post = Mock(return_value=response) # type: ignore[method-assign]
result = client.call_service("light", "turn_on", {"entity_id": "light.office"})
assert result == []
client._session.post.assert_called_once_with(
"http://ha.local/api/services/light/turn_on",
json={"entity_id": "light.office"},
timeout=10,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("entity_ids", "start", "end"), ("entity_ids", "start", "end"),
[ [

View File

@@ -44,6 +44,39 @@ class FakeHaClient(HaClient):
] ]
] ]
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 get_logbook(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> list[object]:
return [
{
"entity_id": entity_id,
"when": start_time.isoformat(),
"message": "turned on",
"context_user_id": "user-1",
}
]
def call_service(
self,
domain: str,
service: str,
service_data: dict[str, object],
) -> list[object]:
return []
def test_ha_reader_returns_summaries() -> None: def test_ha_reader_returns_summaries() -> None:
reader = HaReader(FakeHaClient()) reader = HaReader(FakeHaClient())
@@ -53,6 +86,9 @@ def test_ha_reader_returns_summaries() -> None:
assert domains == {"sensor", "light"} assert domains == {"sensor", "light"}
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature") sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
assert sensor.unit_of_measurement == "°C" assert sensor.unit_of_measurement == "°C"
assert sensor.state == "21.5"
assert sensor.area_name == "Kueche"
assert sensor.device_name == "Thermometer"
def test_ha_reader_discovers_learnable_sensors() -> None: def test_ha_reader_discovers_learnable_sensors() -> None:
@@ -75,3 +111,15 @@ def test_ha_reader_normalizes_history() -> None:
assert history[0].entity_id == "sensor.temperature" assert history[0].entity_id == "sensor.temperature"
assert history[0].points[0].value == 21.5 assert history[0].points[0].value == 21.5
def test_ha_reader_normalizes_state_history_and_logbook() -> None:
reader = HaReader(FakeHaClient())
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
history = reader.read_state_history(["light.living_room"], start, end)
logbook = reader.read_logbook("light.living_room", start, end)
assert history[0].points[0].state == "21.5"
assert logbook[0].context_user_id == "user-1"

View File

@@ -5,7 +5,11 @@ from datetime import datetime, timezone
import pytest import pytest
from app.ha.exceptions import HaUnexpectedPayloadError from app.ha.exceptions import HaUnexpectedPayloadError
from app.ha.history import normalize_history_payload from app.ha.history import (
normalize_history_payload,
normalize_logbook_payload,
normalize_state_history_payload,
)
def test_normalize_history_payload_groups_and_sorts_numeric_states() -> None: def test_normalize_history_payload_groups_and_sorts_numeric_states() -> None:
@@ -90,3 +94,46 @@ def test_normalize_history_payload_rejects_malformed_structure(payload: object)
def test_normalize_history_payload_accepts_empty_series() -> None: def test_normalize_history_payload_accepts_empty_series() -> None:
assert normalize_history_payload([[]]) == [] assert normalize_history_payload([[]]) == []
def test_normalize_state_history_keeps_categorical_changes() -> None:
result = normalize_state_history_payload(
[
[
{
"entity_id": "light.office",
"state": "off",
"last_changed": "2026-06-01T08:00:00+00:00",
},
{
"state": "on",
"last_changed": "2026-06-01T08:05:00+00:00",
},
{
"state": "on",
"last_changed": "2026-06-01T08:06:00+00:00",
},
]
]
)
assert [point.state for point in result[0].points] == ["off", "on"]
def test_normalize_logbook_preserves_action_origin() -> None:
result = normalize_logbook_payload(
[
{
"entity_id": "light.office",
"when": "2026-06-01T08:05:00+00:00",
"message": "turned on",
"context_user_id": "user-1",
"context_domain": "light",
"context_service": "turn_on",
}
],
"light.office",
)
assert result[0].context_user_id == "user-1"
assert result[0].context_service == "turn_on"

View File

@@ -10,6 +10,17 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret") monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models") monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")
monkeypatch.setenv("SILLYHOME_AUTOMATION_STORE", "/tmp/automations") 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")
monkeypatch.setenv("SILLYHOME_MIN_BEHAVIOR_ACTIONS", "4")
monkeypatch.setenv("SILLYHOME_PREDICTION_CONFIDENCE", "0.9")
monkeypatch.setenv("SILLYHOME_PREDICTION_WINDOW_MINUTES", "20")
monkeypatch.setenv("SILLYHOME_PREDICTION_INTERVAL_SECONDS", "45")
monkeypatch.setenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "1200")
monkeypatch.setenv("SILLYHOME_TIMEZONE", "Europe/Berlin")
settings = load_settings() settings = load_settings()
@@ -17,4 +28,15 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
assert settings.ha_token == "secret" assert settings.ha_token == "secret"
assert settings.model_store == "/tmp/models" assert settings.model_store == "/tmp/models"
assert settings.automation_store == "/tmp/automations" 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.min_behavior_actions == 4
assert settings.prediction_confidence == 0.9
assert settings.prediction_window_minutes == 20
assert settings.prediction_interval_seconds == 45
assert settings.execution_cooldown_seconds == 1200
assert settings.timezone == "Europe/Berlin"
assert settings.ha_configured assert settings.ha_configured

View File

@@ -9,4 +9,7 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next" in response.text assert "SillyHome Next" in response.text
assert "Automation-Entwurf" in response.text assert "Aktor freigeben" in response.text
assert "ausdrücklichen Freigabe pro Aktor" in response.text
assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text